From a1eaf5747157bb5a1d92508fdb7075d273118ba6 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:05:23 +0800 Subject: [PATCH 01/23] Prototype locked Windows Node MXC sandbox Relates to security framework issue #202. Adds fail-closed policy generation, Windows Companion and MXC readiness attestation, contained smoke diagnostics, Security UI, and focused tests. Runtime activation remains blocked pending upstream cwd enforcement and atomic Gateway ingress quarantine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/env.d.ts | 88 ++- desktop/renderer/src/browser-openclaw.ts | 39 ++ desktop/renderer/src/i18n/en-US.ts | 24 + desktop/renderer/src/i18n/zh-CN.ts | 24 + desktop/renderer/src/views/SettingsView.vue | 263 +++++++- desktop/src/main.ts | 387 ++++++++++- desktop/src/preload.ts | 7 + desktop/src/windows-node-mxc-service.ts | 492 ++++++++++++++ desktop/src/windows-node-mxc.test.ts | 296 +++++++++ desktop/src/windows-node-mxc.ts | 682 ++++++++++++++++++++ docs/experimental-windows-node-mxc.md | 108 ++++ 11 files changed, 2383 insertions(+), 27 deletions(-) create mode 100644 desktop/src/windows-node-mxc-service.ts create mode 100644 desktop/src/windows-node-mxc.test.ts create mode 100644 desktop/src/windows-node-mxc.ts create mode 100644 docs/experimental-windows-node-mxc.md diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 2bebb1f..f39b060 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -202,9 +202,15 @@ interface OpenClawAPI { refresh(): Promise<{ builtin: SkillEntry[]; custom: SkillEntry[]; managed: SkillEntry[] }>; updateAllowlist(allowBundled: string[]): Promise; updateManagedEntries(entries: Record): Promise; - setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; + setAgentSkills( + agentId: string, + skillIds: string[], + ): Promise<{ agentId: string; skills: string[] }>; getStatus(agentId: string): Promise; - setGlobalEnabled(skillKey: string, enabled: boolean): Promise<{ skillKey: string; enabled: boolean }>; + setGlobalEnabled( + skillKey: string, + enabled: boolean, + ): Promise<{ skillKey: string; enabled: boolean }>; applyAgentConfig( agentId: string, skillIds: string[], @@ -222,11 +228,7 @@ interface OpenClawAPI { }; chat: { isConnected(): Promise; - sendMessage( - sessionKey: string, - message: string, - attachments?: ChatAttachment[], - ): Promise; + sendMessage(sessionKey: string, message: string, attachments?: ChatAttachment[]): Promise; loadHistory(sessionKey: string): Promise<{ messages?: unknown[]; thinkingLevel?: string }>; listSessionTitles(keys: string[]): Promise<{ titles: Record }>; generateSessionTitle(sessionKey: string): Promise; @@ -310,9 +312,7 @@ interface OpenClawAPI { disconnectGitHubCopilot(): Promise<{ disconnected: true; removedProfiles: number }>; getGitHubCopilotStatus(): Promise<{ authenticated: boolean }>; listGitHubCopilotModels(): Promise>; - onGitHubCopilotLoginEvent( - callback: (event: GitHubCopilotLoginEvent) => void, - ): () => void; + onGitHubCopilotLoginEvent(callback: (event: GitHubCopilotLoginEvent) => void): () => void; }; window: { minimize(): Promise; @@ -339,6 +339,74 @@ interface OpenClawAPI { logs: { exportGateway(lines: string[]): Promise<{ canceled: boolean; filePath?: string }>; }; + windowsNodeMxc: { + getStatus(): Promise<{ + desiredEnabled: boolean; + effectiveEnabled: boolean; + selectedNodeId: string; + settingsPath: string; + companionPath: string; + companionInstalled: boolean; + settingsLoaded: boolean; + settingsFingerprint: string | null; + strictFallbackEffective: boolean; + allowWindowsUiEffective: boolean; + folders: Array<{ path: string; access: "ro" | "rw" }>; + nodes: Array<{ + id: string; + displayName: string; + platform: string; + connected: boolean; + paired: boolean; + remoteIp: string | null; + commands: string[]; + }>; + selectedNode: { + id: string; + displayName: string; + platform: string; + connected: boolean; + paired: boolean; + remoteIp: string | null; + commands: string[]; + } | null; + gatewayPolicyState: "active" | "locked" | "drift"; + gatewayPolicyReady: boolean; + effectiveToolsReady: boolean; + durableApprovalsPresent: boolean | null; + probe: { + outcome: "supported" | "unsupported" | "error"; + tier: string | null; + needsDaclAugmentation: boolean; + degraded: boolean; + warnings: string[]; + reason: string | null; + }; + smoke: { + nodeId: string; + settingsFingerprint: string; + probeTier: string; + checkedAt: string; + hostname: { outcome: string; reason: string }; + powershell: { outcome: string; reason: string }; + } | null; + blockers: string[]; + warnings: string[]; + remediation: string[]; + }>; + setEnabled(params: { + enabled: boolean; + nodeId?: string; + }): Promise>>; + runSmoke(): Promise<{ + nodeId: string; + settingsFingerprint: string; + probeTier: string; + checkedAt: string; + hostname: { outcome: string; reason: string }; + powershell: { outcome: string; reason: string }; + }>; + }; sandbox: { getStatus(): Promise<{ available: boolean; diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 99cfe7f..e148825 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -68,6 +68,45 @@ export function createBrowserOpenClawMock(): OpenClawAPI { generateSnapshot: noopAsync, onIntegrityAlert: noopSub, }, + windowsNodeMxc: { + getStatus: async () => ({ + desiredEnabled: false, + effectiveEnabled: false, + selectedNodeId: "", + settingsPath: "", + companionPath: "", + companionInstalled: false, + settingsLoaded: false, + settingsFingerprint: null, + strictFallbackEffective: false, + allowWindowsUiEffective: false, + folders: [], + nodes: [], + selectedNode: null, + gatewayPolicyState: "drift" as const, + gatewayPolicyReady: false, + effectiveToolsReady: false, + durableApprovalsPresent: null, + probe: { + outcome: "error" as const, + tier: null, + needsDaclAugmentation: false, + degraded: false, + warnings: [], + reason: "Unavailable in browser development", + }, + smoke: null, + blockers: ["Unavailable in browser development"], + warnings: [], + remediation: [], + }), + setEnabled: async () => { + throw new Error("Unavailable in browser development"); + }, + runSmoke: async () => { + throw new Error("Unavailable in browser development"); + }, + }, sandbox: { getStatus: async () => ({ available: true, diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index eb31bb8..76ba389 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -287,6 +287,30 @@ export default { "Ask for confirmation before the agent reads files that match these patterns.", "settings.sensitiveFilePatterns": ".env, *_key*, *.pem, *.p12, *.pfx, id_rsa, credentials", "settings.security": "Security sandbox", + "settings.windowsNodeMxc": "Experimental Windows Node + MXC", + "settings.windowsNodeMxcExperimental": "Security framework #202 proof of concept", + "settings.windowsNodeMxcSelectedNode": "Selected local Windows node", + "settings.windowsNodeMxcSelectNode": "Select one paired Windows node", + "settings.windowsNodeMxcEffective": "Effective state", + "settings.windowsNodeMxcGatewayPolicy": "Gateway agent policy", + "settings.windowsNodeMxcReady": "Ready", + "settings.windowsNodeMxcBlocked": "Blocked (fail closed)", + "settings.windowsNodeMxcTier": "MXC isolation tier", + "settings.windowsNodeMxcStrictFallback": "Strict host-fallback blocking", + "settings.windowsNodeMxcEnabled": "Enabled", + "settings.windowsNodeMxcDisabled": "Disabled", + "settings.windowsNodeMxcCommands": "Declared node commands", + "settings.windowsNodeMxcConnection": "Connection / pairing", + "settings.windowsNodeMxcFolders": "Effective folder grants", + "settings.windowsNodeMxcSmoke": "Contained child-process smoke", + "settings.windowsNodeMxcNotRun": "Not run", + "settings.windowsNodeMxcRefresh": "Refresh readiness", + "settings.windowsNodeMxcRunSmoke": "Run contained smoke", + "settings.windowsNodeMxcSmokePassed": "Contained hostname.exe and PowerShell checks passed", + "settings.windowsNodeMxcCompatibility": + "Allow Windows UI APIs is required for PowerShell under MXC 0.7. It is a compatibility relaxation, not activation of UI capabilities. AppContainer and this mode are mutually exclusive.", + "settings.windowsNodeMxcDegraded": + "Warning: this machine uses degraded DACL-based MXC containment. Execution remains blocked unless every other proof passes.", "settings.sandboxEnabled": "Tool sandbox (AppContainer)", "settings.externalApps": "Apps allowed outside the sandbox", "settings.externalAppsHint": diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index d2dbea4..32b78d1 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -270,6 +270,30 @@ export default { "settings.sensitiveFilesDesc": "智能体读取匹配以下模式的文件前,需要用户确认。", "settings.sensitiveFilePatterns": ".env, *_key*, *.pem, *.p12, *.pfx, id_rsa, credentials", "settings.security": "安全沙箱", + "settings.windowsNodeMxc": "实验性 Windows Node + MXC", + "settings.windowsNodeMxcExperimental": "安全框架议题 #202 概念验证", + "settings.windowsNodeMxcSelectedNode": "已选择的本地 Windows 节点", + "settings.windowsNodeMxcSelectNode": "请选择一个已配对的 Windows 节点", + "settings.windowsNodeMxcEffective": "实际状态", + "settings.windowsNodeMxcGatewayPolicy": "网关智能体策略", + "settings.windowsNodeMxcReady": "就绪", + "settings.windowsNodeMxcBlocked": "已阻止(故障关闭)", + "settings.windowsNodeMxcTier": "MXC 隔离层级", + "settings.windowsNodeMxcStrictFallback": "严格禁止主机回退", + "settings.windowsNodeMxcEnabled": "已启用", + "settings.windowsNodeMxcDisabled": "未启用", + "settings.windowsNodeMxcCommands": "节点声明的命令", + "settings.windowsNodeMxcConnection": "连接 / 配对", + "settings.windowsNodeMxcFolders": "实际文件夹授权", + "settings.windowsNodeMxcSmoke": "受控子进程冒烟测试", + "settings.windowsNodeMxcNotRun": "尚未运行", + "settings.windowsNodeMxcRefresh": "刷新就绪状态", + "settings.windowsNodeMxcRunSmoke": "运行受控冒烟测试", + "settings.windowsNodeMxcSmokePassed": "受控 hostname.exe 和 PowerShell 检查已通过", + "settings.windowsNodeMxcCompatibility": + "MXC 0.7 下 PowerShell 需要“允许 Windows UI API”。这是兼容性放宽,并不启用屏幕、输入或其他 UI 能力。AppContainer 与此模式互斥。", + "settings.windowsNodeMxcDegraded": + "警告:此计算机使用基于 DACL 的降级 MXC 隔离。除非其余所有证明均通过,否则执行仍会被阻止。", "settings.sandboxEnabled": "工具沙箱(AppContainer)", "settings.externalApps": "允许在沙箱外运行的应用", "settings.externalAppsHint": diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 4de3800..5b53491 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -365,7 +365,158 @@
-
+
+
+
+ {{ t("settings.windowsNodeMxc") }} +
{{ t("settings.windowsNodeMxcExperimental") }}
+
+ +
+
+ {{ t("settings.windowsNodeMxcSelectedNode") }} + + + +
+ +
+ + {{ t("settings.windowsNodeMxcRefresh") }} + + + {{ t("settings.windowsNodeMxcRunSmoke") }} + +
+
+ + +
+ {{ t("settings.windowsNodeMxcDegraded") }} +
+
+ {{ blocker }} +
+
+ {{ warning }} +
+
+ {{ step }} +
+ +
{{ t("settings.sandboxEnabled") }}
@@ -381,7 +532,10 @@
-
+
@@ -939,6 +1093,64 @@ const sandboxSystemDirs = reactive<{ rw: string[]; ro: string[] }>({ rw: [], ro: const sandboxCapabilities = ref([]); const capsRestarting = ref(false); const sandboxRestarting = ref(false); +type WindowsNodeMxcStatus = Awaited>; +const windowsNodeMxcStatus = ref(null); +const windowsNodeMxcSelectedNodeId = ref(""); +const windowsNodeMxcApplying = ref(false); +const windowsNodeMxcRefreshing = ref(false); +const windowsNodeMxcSmokeRunning = ref(false); + +async function loadWindowsNodeMxcStatus() { + windowsNodeMxcRefreshing.value = true; + try { + const status = await window.openclaw.windowsNodeMxc.getStatus(); + windowsNodeMxcStatus.value = status; + if (status.selectedNodeId || !windowsNodeMxcSelectedNodeId.value) { + windowsNodeMxcSelectedNodeId.value = status.selectedNodeId; + } + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : String(error)); + } finally { + windowsNodeMxcRefreshing.value = false; + } +} + +async function toggleWindowsNodeMxc(enabled: boolean) { + if (enabled && !windowsNodeMxcSelectedNodeId.value) { + ElMessage.error(t("settings.windowsNodeMxcSelectNode")); + return; + } + windowsNodeMxcApplying.value = true; + try { + windowsNodeMxcStatus.value = await window.openclaw.windowsNodeMxc.setEnabled({ + enabled, + nodeId: windowsNodeMxcSelectedNodeId.value, + }); + await loadSandboxStatus(); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : String(error)); + await loadWindowsNodeMxcStatus(); + } finally { + windowsNodeMxcApplying.value = false; + } +} + +async function runWindowsNodeMxcSmoke() { + windowsNodeMxcSmokeRunning.value = true; + try { + const smoke = await window.openclaw.windowsNodeMxc.runSmoke(); + if (smoke.hostname.outcome === "passed" && smoke.powershell.outcome === "passed") { + ElMessage.success(t("settings.windowsNodeMxcSmokePassed")); + } else { + ElMessage.error(`${smoke.hostname.reason}; ${smoke.powershell.reason}`); + } + await loadWindowsNodeMxcStatus(); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : String(error)); + } finally { + windowsNodeMxcSmokeRunning.value = false; + } +} async function loadSandboxStatus() { try { @@ -1442,7 +1654,7 @@ watch(activeSection, (v) => { void refreshModelsConfig(); } if (v === "security") { - loadSandboxStatus(); + void Promise.all([loadSandboxStatus(), loadWindowsNodeMxcStatus()]); } }); @@ -1543,6 +1755,8 @@ async function refreshModelsConfig(): Promise { function handleSettingsWindowFocus(): void { if (activeSection.value === "models" && !showProviderSetup.value) { void refreshModelsConfig(); + } else if (activeSection.value === "security") { + void loadWindowsNodeMxcStatus(); } } @@ -1578,6 +1792,9 @@ onMounted(async () => { // Load web search provider configuration loadSearchConfig(config); + if (activeSection.value === "security") { + await Promise.all([loadSandboxStatus(), loadWindowsNodeMxcStatus()]); + } }); onUnmounted(() => { @@ -2505,6 +2722,46 @@ async function clearChatHistory() { } /* Sandbox external apps */ +.mxc-card { + border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border)); +} +.mxc-subtitle { + margin-top: 3px; + color: var(--text-muted); + font-size: 12px; +} +.mxc-state { + font-size: 12px; + font-weight: 700; +} +.mxc-state-ok { + color: #4caf50; +} +.mxc-state-blocked { + color: var(--settings-action-danger); +} +.mxc-actions { + justify-content: flex-end; + gap: 8px; +} +.mxc-alert { + margin-top: 8px; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.mxc-alert-warning { + border-color: rgba(230, 162, 60, 0.45); + background: rgba(230, 162, 60, 0.08); +} +.mxc-alert-error { + border-color: color-mix(in srgb, var(--settings-action-danger) 45%, transparent); + background: color-mix(in srgb, var(--settings-action-danger) 8%, transparent); +} .external-apps-list { display: flex; flex-wrap: wrap; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 447b24c..2fc812a 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -114,6 +114,19 @@ import { applyGlobalSkillChange, type GlobalSkillChange, } from "./skill-config"; +import { + WINDOWS_NODE_MXC_MODE, + applyWindowsNodeMxcGatewayPolicy, + getWindowsNodeMxcGatewayPolicyState, + restoreWindowsNodeMxcGatewayPolicy, + validateWindowsNodeMxcGatewayPolicy, +} from "./windows-node-mxc"; +import { + inspectWindowsNodeMxc, + runWindowsNodeMxcSmoke, + type StoredWindowsNodeMxcSmoke, + type WindowsNodeMxcRuntimeStatus, +} from "./windows-node-mxc-service"; /** * Normalize a directory path for comparison/storage. @@ -190,6 +203,16 @@ const settingsStore = new Store<{ sandboxUserDirsRO: string[]; /** All directories we've ever granted AC ACL to. Used to detect stale ACLs on startup. */ sandboxGrantHistory: string[]; + /** Mutually exclusive active sandbox route. */ + securityMode: "appcontainer" | "windows-node-mxc"; + /** Stable paired node ID selected for the experimental Windows Node route. */ + windowsNodeMxcNodeId: string; + /** Original per-agent tool policies restored when the experimental mode is disabled. */ + windowsNodeMxcToolBackups: Record; + /** AppContainer preference captured before entering the experimental mode. */ + windowsNodeMxcPreviousSandboxEnabled: boolean; + /** Last contained hostname + PowerShell readiness proof. */ + windowsNodeMxcSmoke?: StoredWindowsNodeMxcSmoke; /** Privacy protection level. */ privacyLevel: "basic" | "strict"; /** Per-control privacy preferences. Missing fields use mode-specific defaults. */ @@ -222,9 +245,22 @@ const settingsStore = new Store<{ sandboxUserDirsRW: [], sandboxUserDirsRO: [], sandboxGrantHistory: [], + securityMode: "appcontainer", + windowsNodeMxcNodeId: "", + windowsNodeMxcToolBackups: {}, + windowsNodeMxcPreviousSandboxEnabled: true, privacyLevel: "basic", }, }); +const RENDERER_WRITABLE_SETTING_KEYS = new Set([ + "accentColor", + "autoStart", + "language", + "minimizeToTray", + "privacyControls", + "privacyLevel", + "themeMode", +]); let mainWindow: BrowserWindow | null = null; let gatewayProcess: ChildProcess | null = null; @@ -699,6 +735,31 @@ function readConfig(): any { } } +function isWindowsNodeMxcDesired(): boolean { + return settingsStore.get("securityMode") === WINDOWS_NODE_MXC_MODE; +} + +async function getWindowsNodeMxcStatus(): Promise { + return inspectWindowsNodeMxc({ + desiredEnabled: isWindowsNodeMxcDesired(), + selectedNodeId: settingsStore.get("windowsNodeMxcNodeId"), + config: readConfig(), + gateway: gwClient, + managedGateway: gatewaySpawnedByUs && isManagedGatewayProcessAlive(), + storedSmoke: settingsStore.get("windowsNodeMxcSmoke") ?? null, + }); +} + +async function requireEffectiveWindowsNodeMxc(): Promise { + if (!isWindowsNodeMxcDesired()) return; + const status = await getWindowsNodeMxcStatus(); + if (!status.effectiveEnabled) { + throw new Error( + `Windows Node + MXC execution is blocked: ${status.blockers.join("; ") || "readiness proof failed"}`, + ); + } +} + function resolveGitHubCopilotAuthRuntime(): GitHubCopilotAuthRuntime { const entryPath = resolveOpenClawEntry(); const stateDir = getOpenClawStateDir(); @@ -1131,13 +1192,7 @@ function needsSetup(): boolean { type AutoConfigApiFormat = "openai-chat" | "openai-responses" | "anthropic"; type AutoConfigReasoningEffort = - | "off" - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "adaptive"; + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; function normalizeEnvApiFormat(value: string | undefined): AutoConfigApiFormat { const normalized = (value || "").trim().toLowerCase(); @@ -1793,6 +1848,7 @@ async function waitForGatewayReady( function stopGatewayProcess(): void { const knownPid = gatewayProcess?.pid; gatewayProcess = null; + gatewaySpawnedByUs = false; const pids = new Set(); let listenerScanSucceeded = gatewayPort === 0; let allGatewayProcessesStopped = process.platform === "win32"; @@ -1856,6 +1912,94 @@ function stopGatewayProcess(): void { } } +function getGatewayListenerPids(port: number): Set | null { + if (process.platform !== "win32") return null; + try { + const output = execFileSync("netstat", ["-ano"], { + windowsHide: true, + encoding: "utf-8", + timeout: 5_000, + }); + const pids = new Set(); + for (const line of output.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/); + if ( + columns.length >= 5 && + columns[0].toUpperCase() === "TCP" && + columns[1].endsWith(`:${port}`) && + columns[3].toUpperCase() === "LISTENING" + ) { + const pid = Number.parseInt(columns[4], 10); + if (Number.isInteger(pid) && pid > 0) pids.add(pid); + } + } + return pids; + } catch { + return null; + } +} + +function terminateGatewayProcessTree(pid: number): void { + try { + if (process.platform === "win32") { + execFileSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 10_000, + stdio: "ignore", + }); + } else { + process.kill(pid, "SIGTERM"); + } + } catch { + // Callers decide whether process-exit confirmation is required. + } +} + +async function stopGatewayForSecurityTransition(port: number): Promise { + const managedPid = + gatewaySpawnedByUs && isManagedGatewayProcessAlive() ? gatewayProcess?.pid : undefined; + const listenerPids = getGatewayListenerPids(port); + if (listenerPids === null) { + if (await isGatewayPortOccupied(port)) { + throw new Error( + `Cannot prove ownership of the Gateway listener on port ${port}; security mode was not changed`, + ); + } + } else if ([...listenerPids].some((pid) => pid !== managedPid)) { + throw new Error( + `Port ${port} is owned by an external process; stop it before changing security mode`, + ); + } + if (!managedPid) { + gwClient?.stop(); + setGatewayStatus("stopped"); + return; + } + + gwClient?.stop(); + gatewayProcess = null; + gatewaySpawnedByUs = false; + terminateGatewayProcessTree(managedPid); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + let processAlive = true; + try { + process.kill(managedPid, 0); + } catch { + processAlive = false; + } + if (!processAlive && !(await isGatewayPortOccupied(port))) { + setGatewayStatus("stopped"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + setGatewayStatus("failed"); + throw new Error( + `Could not confirm that the previous Gateway stopped on port ${port}; MXC mode was not changed`, + ); +} + async function restartManagedGateway(reason: string): Promise { if (gatewayRestartPromise) return gatewayRestartPromise; const restart = (async () => { @@ -1919,6 +2063,29 @@ function startHealthMonitor(): void { unresponsiveSince = null; return; } + if (isWindowsNodeMxcDesired()) { + const inspectedProcess = gatewayProcess; + const status = await getWindowsNodeMxcStatus(); + if ( + !isWindowsNodeMxcDesired() || + !inspectedProcess || + gatewayProcess !== inspectedProcess || + !isManagedGatewayProcessAlive() + ) { + return; + } + if (status.gatewayPolicyState !== "locked" || !status.effectiveToolsReady) { + const message = `Windows Node + MXC readiness drifted; stopping managed Gateway: ${status.blockers.join("; ")}`; + console.error(`[windows-node-mxc] ${message}`); + mainWindow?.webContents.send("gateway:log", `[error] ${message}`); + gwClient?.stop(); + gatewayProcess = null; + gatewaySpawnedByUs = false; + if (inspectedProcess.pid) terminateGatewayProcessTree(inspectedProcess.pid); + setGatewayStatus("failed"); + return; + } + } const alive = await checkExistingGateway(gatewayPort); if (alive) { @@ -2204,7 +2371,33 @@ async function startGateway(): Promise { async function startGatewayInner(): Promise { logStartupTiming("gateway-preflight-start"); // Read config to get token and configured port - const config = readConfig(); + let config = readConfig(); + if (isWindowsNodeMxcDesired()) { + const nodeId = settingsStore.get("windowsNodeMxcNodeId"); + const policyState = getWindowsNodeMxcGatewayPolicyState(config, nodeId); + if (policyState === "active") { + const locked = applyWindowsNodeMxcGatewayPolicy( + config, + nodeId, + settingsStore.get("windowsNodeMxcToolBackups"), + "locked", + ); + config = locked.config; + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + settingsStore.delete("windowsNodeMxcSmoke"); + console.warn( + "[windows-node-mxc] Downgraded unsupported active policy to diagnostic-only locked policy", + ); + } + const policy = validateWindowsNodeMxcGatewayPolicy(config, nodeId, "locked"); + if (!policy.ready) { + const message = `Windows Node + MXC Gateway policy drift: ${policy.blockers.join("; ")}`; + console.error(`[windows-node-mxc] ${message}`); + mainWindow?.webContents.send("gateway:log", `[error] ${message}`); + setGatewayStatus("failed"); + return; + } + } gatewayToken = config?.gateway?.auth?.token || ""; const configuredPort = config?.gateway?.port || DEFAULT_PORT; gatewayPort = configuredPort; @@ -2225,12 +2418,33 @@ async function startGatewayInner(): Promise { const preparedPersonas = prepareAgentPersonas(stateDir); const agentRosterChanged = preparedPersonas?.changed ?? false; + if (isWindowsNodeMxcDesired() && preparedPersonas) { + const policy = validateWindowsNodeMxcGatewayPolicy( + preparedPersonas.config, + settingsStore.get("windowsNodeMxcNodeId"), + "locked", + ); + if (!policy.ready) { + const message = `Windows Node + MXC blocked an unprotected agent roster change: ${policy.blockers.join("; ")}`; + console.error(`[windows-node-mxc] ${message}`); + mainWindow?.webContents.send("gateway:log", `[error] ${message}`); + setGatewayStatus("failed"); + return; + } + } // If gateway is already healthy, just connect WS and return — no new process. // Callers that need replacement use restartManagedGateway(), which stops the // old process and waits for the port before invoking this function. const alreadyRunning = await checkExistingGateway(configuredPort); logStartupTiming("gateway-existing-check-complete"); + if ( + isWindowsNodeMxcDesired() && + alreadyRunning && + (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive()) + ) { + failForExternalGateway(configuredPort); + } if ( requiresExternalGatewayStop( alreadyRunning, @@ -2338,7 +2552,7 @@ async function startGatewayInner(): Promise { toolSandbox = new ToolSandbox(launcherPath, nodePath); // Restore sandbox enabled state from settings - const sandboxEnabled = settingsStore.get("sandboxEnabled"); + const sandboxEnabled = settingsStore.get("sandboxEnabled") && !isWindowsNodeMxcDesired(); if (!sandboxEnabled) { toolSandbox.setEnabled(false); } @@ -2483,14 +2697,20 @@ async function startGatewayInner(): Promise { console.error("Gateway spawn error:", err); safeSendLog("gateway:log", `[error] Gateway spawn failed: ${err.message}`); safeSendLog("gateway:log", `[info] node=${nodePath} entry=${entryPath}`); - gatewayProcess = null; - setGatewayStatus("failed"); + if (gatewayProcess === child) { + gatewayProcess = null; + gatewaySpawnedByUs = false; + setGatewayStatus("failed"); + } }); child.on("exit", (code, signal) => { console.log(`[gateway] exited: code=${code} signal=${signal}`); safeSendLog("gateway:log", `Gateway exited: code=${code} signal=${signal}`); - gatewayProcess = null; + if (gatewayProcess === child) { + gatewayProcess = null; + gatewaySpawnedByUs = false; + } }); // Log ALL IPC messages from gateway for debugging remote permission routing @@ -3217,6 +3437,18 @@ function registerIpcHandlers(): void { const stateDir = getOpenClawStateDir(); await fs.promises.mkdir(stateDir, { recursive: true }); assertConfigWriteAllowed(config, readConfig()); + if (isWindowsNodeMxcDesired()) { + const policy = validateWindowsNodeMxcGatewayPolicy( + config, + settingsStore.get("windowsNodeMxcNodeId"), + "locked", + ); + if (!policy.ready) { + throw new Error( + `config:write would weaken Windows Node + MXC policy: ${policy.blockers.join("; ")}`, + ); + } + } fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8"); }); @@ -3798,6 +4030,7 @@ function registerIpcHandlers(): void { "chat:send-message", async (_event, params: { sessionKey: string; message: string; attachments?: unknown }) => { if (!gwClient?.connected) throw new Error("Gateway not connected"); + await requireEffectiveWindowsNodeMxc(); // Mark that the latest input is from the local desktop UI. lastInputFromRemote = false; await gwClient.sendChat( @@ -3868,6 +4101,9 @@ function registerIpcHandlers(): void { if (typeof agentId !== "string" || !agentId.trim()) { throw new Error("Agent id is required"); } + if (isWindowsNodeMxcDesired()) { + throw new Error("Agent roster changes are blocked while Windows Node + MXC mode is enabled"); + } if (!gwClient?.connected) throw new Error("Gateway not connected"); if (agentRosterChangeInProgress) { throw new Error("Another agent roster change is already in progress"); @@ -3883,6 +4119,9 @@ function registerIpcHandlers(): void { if (typeof agentId !== "string" || !agentId.trim()) { throw new Error("Agent id is required"); } + if (isWindowsNodeMxcDesired()) { + throw new Error("Agent roster changes are blocked while Windows Node + MXC mode is enabled"); + } if (!gwClient?.connected) throw new Error("Gateway not connected"); if (agentRosterChangeInProgress) { throw new Error("Another agent roster change is already in progress"); @@ -4540,6 +4779,9 @@ function registerIpcHandlers(): void { // --- Settings --- ipcMain.handle("settings:get", () => settingsStore.store); ipcMain.handle("settings:set", (_event, key: string, value: any) => { + if (!RENDERER_WRITABLE_SETTING_KEYS.has(key)) { + throw new Error(`Setting "${key}" cannot be changed through the generic settings API`); + } settingsStore.set(key as any, value); if (key === "autoStart") { app.setLoginItemSettings({ openAtLogin: !!value }); @@ -4548,6 +4790,119 @@ function registerIpcHandlers(): void { } }); + // --- Experimental Windows Node + MXC sandbox (security framework #202) --- + ipcMain.handle("windows-node-mxc:get-status", () => getWindowsNodeMxcStatus()); + + ipcMain.handle( + "windows-node-mxc:set-enabled", + async (_event, params: { enabled: boolean; nodeId?: string }) => { + const enabled = params?.enabled === true; + if (gwClient?.connected && (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive())) { + throw new Error( + "Windows Node + MXC mode requires MicroClaw's managed Gateway; stop the external Gateway first", + ); + } + 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; + + if (enabled) { + const nodeId = typeof params.nodeId === "string" ? params.nodeId.trim() : ""; + const applied = applyWindowsNodeMxcGatewayPolicy( + config, + nodeId, + settingsStore.get("windowsNodeMxcToolBackups"), + "locked", + ); + await stopGatewayForSecurityTransition(configuredPort); + if (!isWindowsNodeMxcDesired()) { + settingsStore.set( + "windowsNodeMxcPreviousSandboxEnabled", + settingsStore.get("sandboxEnabled"), + ); + } + settingsStore.set("windowsNodeMxcToolBackups", applied.backups); + settingsStore.set("windowsNodeMxcNodeId", nodeId); + settingsStore.set("securityMode", WINDOWS_NODE_MXC_MODE); + settingsStore.set("sandboxEnabled", false); + settingsStore.delete("windowsNodeMxcSmoke"); + toolSandbox?.setEnabled(false); + writeConfigTextAtomically(JSON.stringify(applied.config, null, 2)); + } else { + const restored = restoreWindowsNodeMxcGatewayPolicy( + config, + settingsStore.get("windowsNodeMxcToolBackups"), + ); + await stopGatewayForSecurityTransition(configuredPort); + settingsStore.set( + "sandboxEnabled", + settingsStore.get("windowsNodeMxcPreviousSandboxEnabled"), + ); + writeConfigTextAtomically(JSON.stringify(restored, null, 2)); + settingsStore.set("windowsNodeMxcToolBackups", {}); + settingsStore.delete("windowsNodeMxcSmoke"); + settingsStore.set("securityMode", "appcontainer"); + } + + const transitionReason = `Applying ${ + enabled ? "Windows Node + MXC" : "AppContainer" + } security mode`; + mainWindow?.webContents.send("gateway:log", `[start] ${transitionReason}`); + await startGateway(); + if (gatewayStatus !== "running") { + throw new Error( + `Gateway did not become ready after security-mode transition (status: ${gatewayStatus})`, + ); + } + return getWindowsNodeMxcStatus(); + }, + ); + + ipcMain.handle("windows-node-mxc:run-smoke", async () => { + const status = await getWindowsNodeMxcStatus(); + if (!gwClient?.connected) throw new Error("MicroClaw managed Gateway is not connected"); + if (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive()) { + throw new Error("Windows Node + MXC smoke requires MicroClaw's managed Gateway"); + } + if (!status.selectedNode?.connected) + throw new Error("The selected Windows node is disconnected"); + if ( + !status.selectedNode.commands.includes("system.run") || + !status.selectedNode.commands.includes("system.run.prepare") + ) { + throw new Error("The selected node does not declare system.run and system.run.prepare"); + } + if (!status.strictFallbackEffective) { + throw new Error("Strict MXC host-fallback blocking is not effective"); + } + if (!status.allowWindowsUiEffective) { + throw new Error("Allow Windows UI APIs is required for the PowerShell smoke"); + } + if (status.probe.outcome !== "supported" || !status.probe.tier) { + throw new Error(status.probe.reason || "MXC probe did not report a supported tier"); + } + if (!status.settingsFingerprint) { + throw new Error("Windows Companion settings are unavailable"); + } + if (!status.gatewayPolicyReady) { + throw new Error("Gateway exec-only node policy is not effective"); + } + if (status.durableApprovalsPresent) { + throw new Error("Remove durable approvals before running the cwd-sensitive MXC mode proof"); + } + const smoke = await runWindowsNodeMxcSmoke( + gwClient, + status.selectedNodeId, + status.settingsFingerprint, + status.probe.tier, + ); + settingsStore.set("windowsNodeMxcSmoke", smoke); + return smoke; + }); + // --- Updates --- ipcMain.handle("updates:check", () => { return checkForUpdates({ @@ -4579,8 +4934,7 @@ function registerIpcHandlers(): void { if (!mainWindow) return; mainWindow.setResizable(true); const savedBounds = store.get("windowBounds") as - | { width?: number; height?: number; x?: number; y?: number } - | undefined; + { width?: number; height?: number; x?: number; y?: number } | undefined; const width = savedBounds?.width || DEFAULT_WINDOW_WIDTH; const height = savedBounds?.height || DEFAULT_WINDOW_HEIGHT; mainWindow.setSize(width, height); @@ -4626,6 +4980,11 @@ function registerIpcHandlers(): void { }); ipcMain.handle("sandbox:set-enabled", async (_event, enabled: boolean) => { + if (isWindowsNodeMxcDesired()) { + throw new Error( + "AppContainer and Windows Node + MXC modes are mutually exclusive; disable the experimental mode first", + ); + } toolSandbox?.setEnabled(enabled); settingsStore.set("sandboxEnabled", enabled); // Sandbox enabled/disabled requires hard gateway restart — COMSPEC and diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 0bfd7d3..e1bce84 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -305,6 +305,13 @@ contextBridge.exposeInMainWorld("openclaw", { }>, }, + windowsNodeMxc: { + getStatus: () => ipcRenderer.invoke("windows-node-mxc:get-status"), + setEnabled: (params: { enabled: boolean; nodeId?: string }) => + ipcRenderer.invoke("windows-node-mxc:set-enabled", params), + runSmoke: () => ipcRenderer.invoke("windows-node-mxc:run-smoke"), + }, + // --- Tool Sandbox --- sandbox: { getStatus: () => ipcRenderer.invoke("sandbox:get-status"), diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts new file mode 100644 index 0000000..1b20a62 --- /dev/null +++ b/desktop/src/windows-node-mxc-service.ts @@ -0,0 +1,492 @@ +import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + WINDOWS_NODE_MXC_REQUIRED_COMMANDS, + WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, + type MxcProbeResult, + type MxcSmokeResult, + type WindowsNodeMxcFolder, + type WindowsNodeMxcSettings, + type WindowsNodeRecord, + classifyMxcProbe, + classifyMxcSmoke, + extractEffectiveToolNames, + getWindowsNodeMxcGatewayPolicyState, + getMxcTierWarning, + listAgentSessionKeys, + listConfiguredSandboxFolders, + normalizeWindowsNodeRecord, + validateEffectiveToolNames, + validateSelectedWindowsNode, + validateWindowsNodeMxcSettings, + type WindowsNodeMxcGatewayPolicyState, +} from "./windows-node-mxc"; + +const WINDOWS_NODE_SETTINGS_FILENAME = "settings.json"; +const HOSTNAME_MARKER = "MICROCLAW_MXC_HOSTNAME_OK"; +const POWERSHELL_MARKER = "MICROCLAW_MXC_POWERSHELL_OK"; + +export interface WindowsNodeMxcGateway { + connected: boolean; + request(method: string, params?: unknown): Promise; +} + +export interface StoredWindowsNodeMxcSmoke { + nodeId: string; + settingsFingerprint: string; + probeTier: string; + checkedAt: string; + hostname: MxcSmokeResult; + powershell: MxcSmokeResult; +} + +export interface WindowsNodeMxcRuntimeStatus { + desiredEnabled: boolean; + effectiveEnabled: boolean; + selectedNodeId: string; + settingsPath: string; + companionPath: string; + companionInstalled: boolean; + settingsLoaded: boolean; + settingsFingerprint: string | null; + strictFallbackEffective: boolean; + allowWindowsUiEffective: boolean; + folders: WindowsNodeMxcFolder[]; + nodes: WindowsNodeRecord[]; + selectedNode: WindowsNodeRecord | null; + gatewayPolicyState: WindowsNodeMxcGatewayPolicyState; + gatewayPolicyReady: boolean; + effectiveToolsReady: boolean; + durableApprovalsPresent: boolean | null; + probe: MxcProbeResult; + smoke: StoredWindowsNodeMxcSmoke | null; + blockers: string[]; + warnings: string[]; + remediation: string[]; +} + +export interface InspectWindowsNodeMxcOptions { + desiredEnabled: boolean; + selectedNodeId: string; + config: unknown; + gateway: WindowsNodeMxcGateway | null; + managedGateway: boolean; + storedSmoke?: StoredWindowsNodeMxcSmoke | null; + appData?: string; + localAppData?: string; + userProfile?: string; + environment?: NodeJS.ProcessEnv; +} + +export function resolveWindowsNodeSettingsPath( + environment: NodeJS.ProcessEnv = process.env, + appData = environment.APPDATA ?? "", +): string { + const override = environment.OPENCLAW_TRAY_DATA_DIR?.trim(); + return path.join(override || path.join(appData, "OpenClawTray"), WINDOWS_NODE_SETTINGS_FILENAME); +} + +export function resolveWindowsCompanionPath(localAppData = process.env.LOCALAPPDATA ?? ""): string { + return path.join(localAppData, "OpenClawTray", "OpenClaw.Tray.WinUI.exe"); +} + +export function resolveWxcExecPath( + environment: NodeJS.ProcessEnv = process.env, + localAppData = environment.LOCALAPPDATA ?? "", +): string { + const override = environment.OPENCLAW_WXC_EXEC?.trim(); + if (override) return override; + const architecture = process.arch === "arm64" ? "arm64" : "x64"; + return path.join(localAppData, "OpenClawTray", "tools", "mxc", architecture, "wxc-exec.exe"); +} + +export async function inspectWindowsNodeMxc( + options: InspectWindowsNodeMxcOptions, +): Promise { + const environment = options.environment ?? process.env; + const appData = options.appData ?? environment.APPDATA ?? ""; + const localAppData = options.localAppData ?? environment.LOCALAPPDATA ?? ""; + const userProfile = options.userProfile ?? environment.USERPROFILE ?? os.homedir(); + const selectedNodeId = options.selectedNodeId.trim(); + const settingsPath = resolveWindowsNodeSettingsPath(environment, appData); + const companionPath = resolveWindowsCompanionPath(localAppData); + const wxcExecPath = resolveWxcExecPath(environment, localAppData); + const blockers: string[] = []; + const warnings: string[] = []; + const remediation: string[] = []; + + const settings = await readWindowsNodeSettings(settingsPath); + const settingsCheck = validateWindowsNodeMxcSettings(settings); + blockers.push(...settingsCheck.blockers); + warnings.push(...settingsCheck.warnings); + if (!settings) { + remediation.push( + `Install and launch OpenClaw Windows Companion, then configure its Sandbox page. Expected settings: ${settingsPath}`, + ); + } else if (!settingsCheck.ready) { + remediation.push( + "In Windows Companion, enable node mode and system tools; enable MXC and strict host-fallback blocking; enable Allow Windows UI APIs; disable network, clipboard, MCP, and all non-system node capabilities.", + ); + } + + const settingsFingerprint = settings ? fingerprintSecuritySettings(settings) : null; + const folders = settings ? listConfiguredSandboxFolders(settings, userProfile) : []; + const companionInstalled = fs.existsSync(companionPath); + if (!companionInstalled) { + blockers.push("OpenClaw Windows Companion is not installed at the supported per-user path"); + remediation.push(`Install the pinned Windows Companion build at ${companionPath}`); + } + + const probe = await runMxcProbe(wxcExecPath); + if (probe.outcome !== "supported") { + blockers.push(probe.reason ?? "MXC probe did not report a usable tier"); + remediation.push( + `Repair MXC or set OPENCLAW_WXC_EXEC to the pinned wxc-exec.exe; checked ${wxcExecPath}`, + ); + } + const tierWarning = getMxcTierWarning(probe); + if (tierWarning) warnings.push(tierWarning); + warnings.push(...probe.warnings); + + const gatewayPolicyState = getWindowsNodeMxcGatewayPolicyState(options.config, selectedNodeId); + const gatewayPolicyReady = gatewayPolicyState === "locked"; + if (gatewayPolicyState === "drift") { + blockers.push("Gateway agent tool policy drifted from the diagnostic-only locked MXC policy"); + } else if (gatewayPolicyState === "active") { + blockers.push("Active Gateway execution is unsupported without atomic ingress quarantine"); + } else { + blockers.push("Gateway agent execution remains diagnostic-only and locked"); + } + + let nodes: WindowsNodeRecord[] = []; + let selectedNode: WindowsNodeRecord | null = null; + let effectiveToolsReady = false; + let durableApprovalsPresent: boolean | null = null; + if (!selectedNodeId) { + blockers.push("Select one paired local Windows node by stable node ID"); + } + if (options.desiredEnabled && !options.managedGateway) { + blockers.push("Windows Node + MXC mode requires a freshly started MicroClaw managed Gateway"); + } + if (!options.gateway?.connected) { + blockers.push("MicroClaw managed Gateway is not connected"); + } else { + try { + const payload = await options.gateway.request("node.list", {}); + nodes = extractNodeRecords(payload); + selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; + blockers.push(...validateSelectedWindowsNode(selectedNode).blockers); + } catch (error) { + blockers.push(`Could not list Gateway nodes: ${messageOf(error)}`); + } + + if (selectedNodeId) { + const agentIds = listAgentIds(options.config); + try { + const expectedToolsState = gatewayPolicyState === "active" ? "active" : "locked"; + const sessionKeys = await listAgentSessionKeys(options.gateway, agentIds); + const effectiveChecks = await Promise.all( + agentIds.map(async (agentId) => { + const sessionKey = sessionKeys.get(agentId); + if (!sessionKey) { + return { + ready: false, + blockers: [`Agent "${agentId}" has no persisted session for tools.effective`], + }; + } + const result = await options.gateway!.request("tools.effective", { + agentId, + sessionKey, + }); + const check = validateEffectiveToolNames( + extractEffectiveToolNames(result), + expectedToolsState, + ); + return { + ready: check.ready, + blockers: check.blockers.map((blocker) => `Agent "${agentId}": ${blocker}`), + }; + }), + ); + effectiveToolsReady = effectiveChecks.every((check) => check.ready); + blockers.push(...effectiveChecks.flatMap((check) => check.blockers)); + } catch (error) { + blockers.push(`Could not verify effective Gateway tools: ${messageOf(error)}`); + } + + try { + const approvals = await options.gateway.request("exec.approvals.node.get", { + nodeId: selectedNodeId, + }); + durableApprovalsPresent = hasDurableApprovals(approvals); + if (durableApprovalsPresent) { + blockers.push( + "Selected node has durable exec approvals, which the pinned Windows Node does not bind to cwd", + ); + } + } catch (error) { + blockers.push(`Could not verify selected-node durable approvals: ${messageOf(error)}`); + } + } + } + + if ( + selectedNode && + selectedNode.commands.includes(WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND) === false + ) { + remediation.push( + "Upstream Windows Node must canonicalize cwd through reparse points, restrict it to configured folder grants, bind canonical cwd into durable approval identity, and revalidate immediately before launch.", + ); + } + + const smoke = + options.storedSmoke && + options.storedSmoke.nodeId === selectedNodeId && + options.storedSmoke.settingsFingerprint === settingsFingerprint && + options.storedSmoke.probeTier === probe.tier + ? options.storedSmoke + : null; + if (!smoke || smoke.hostname.outcome !== "passed" || smoke.powershell.outcome !== "passed") { + blockers.push( + "A current contained hostname.exe and PowerShell child-process smoke proof is required", + ); + remediation.push( + "Run the contained child-process check from MicroClaw Security settings and approve each command once in Windows Companion.", + ); + } + + return { + desiredEnabled: options.desiredEnabled, + effectiveEnabled: options.desiredEnabled && blockers.length === 0, + selectedNodeId, + settingsPath, + companionPath, + companionInstalled, + settingsLoaded: settings !== null, + settingsFingerprint, + strictFallbackEffective: + settings?.SystemRunSandboxEnabled === true && + settings.SystemRunBlockHostFallbackWhenMxcUnavailable === true, + allowWindowsUiEffective: settings?.SystemRunAllowWindowsUi === true, + folders, + nodes, + selectedNode, + gatewayPolicyState, + gatewayPolicyReady, + effectiveToolsReady, + durableApprovalsPresent, + probe, + smoke, + blockers: [...new Set(blockers)], + warnings: [...new Set(warnings)], + remediation: [...new Set(remediation)], + }; +} + +export async function runWindowsNodeMxcSmoke( + gateway: WindowsNodeMxcGateway, + nodeId: string, + settingsFingerprint: string, + probeTier: string, +): 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"); + + const hostname = await invokeSmoke( + gateway, + nodeId, + [ + "C:\\Windows\\System32\\cmd.exe", + "/d", + "/s", + "/c", + `"C:\\Windows\\System32\\hostname.exe" && echo ${HOSTNAME_MARKER}`, + ], + HOSTNAME_MARKER, + ); + let powershell: MxcSmokeResult = { + outcome: "failed", + reason: "PowerShell smoke was not run because hostname.exe did not pass", + }; + if (hostname.outcome === "passed") { + powershell = await invokeSmoke( + gateway, + nodeId, + [ + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + `[Console]::Out.Write('${POWERSHELL_MARKER}')`, + ], + POWERSHELL_MARKER, + ); + } + return { + nodeId: nodeId.trim(), + settingsFingerprint, + probeTier, + checkedAt: new Date().toISOString(), + hostname, + powershell, + }; +} + +async function invokeSmoke( + gateway: WindowsNodeMxcGateway, + nodeId: string, + command: string[], + marker: string, +): Promise { + try { + const result = await gateway.request("node.invoke", { + nodeId, + command: "system.run", + params: { + command, + timeoutMs: 15_000, + agentId: "main", + sessionKey: "agent:main:main", + }, + timeoutMs: 60_000, + idempotencyKey: randomUUID(), + }); + return classifyMxcSmoke(result, marker); + } catch (error) { + return classifyMxcSmoke({ error: messageOf(error) }, marker); + } +} + +async function readWindowsNodeSettings( + settingsPath: string, +): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const contents = await fs.promises.readFile(settingsPath, "utf-8"); + const parsed = JSON.parse(contents); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as WindowsNodeMxcSettings; + } + } catch { + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + return null; +} + +function fingerprintSecuritySettings(settings: WindowsNodeMxcSettings): string { + const relevant = { + EnableNodeMode: settings.EnableNodeMode, + NodeSystemRunEnabled: settings.NodeSystemRunEnabled, + NodeCanvasEnabled: settings.NodeCanvasEnabled, + NodeScreenEnabled: settings.NodeScreenEnabled, + NodeCameraEnabled: settings.NodeCameraEnabled, + NodeLocationEnabled: settings.NodeLocationEnabled, + NodeBrowserProxyEnabled: settings.NodeBrowserProxyEnabled, + NodeSttEnabled: settings.NodeSttEnabled, + NodeTtsEnabled: settings.NodeTtsEnabled, + EnableMcpServer: settings.EnableMcpServer, + SystemRunSandboxEnabled: settings.SystemRunSandboxEnabled, + SystemRunBlockHostFallbackWhenMxcUnavailable: + settings.SystemRunBlockHostFallbackWhenMxcUnavailable, + SystemRunAllowOutbound: settings.SystemRunAllowOutbound, + SystemRunAllowWindowsUi: settings.SystemRunAllowWindowsUi, + SandboxClipboard: settings.SandboxClipboard, + SandboxDocumentsAccess: settings.SandboxDocumentsAccess, + SandboxDownloadsAccess: settings.SandboxDownloadsAccess, + SandboxDesktopAccess: settings.SandboxDesktopAccess, + SandboxCustomFolders: settings.SandboxCustomFolders, + }; + return createHash("sha256").update(JSON.stringify(relevant)).digest("hex"); +} + +function extractNodeRecords(payload: unknown): WindowsNodeRecord[] { + const record = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; + const values = Array.isArray(payload) + ? payload + : Array.isArray(record.nodes) + ? record.nodes + : Array.isArray(record.paired) + ? record.paired + : []; + return values + .map(normalizeWindowsNodeRecord) + .filter((entry): entry is WindowsNodeRecord => entry !== null); +} + +function listAgentIds(config: unknown): string[] { + if (!config || typeof config !== "object" || Array.isArray(config)) return []; + const agents = (config as Record).agents; + if (!agents || typeof agents !== "object" || Array.isArray(agents)) return []; + const list = (agents as Record).list; + if (!Array.isArray(list)) return []; + return list.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const id = (entry as Record).id; + return typeof id === "string" && id.trim() ? [id.trim()] : []; + }); +} + +function hasDurableApprovals(payload: unknown): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const root = payload as Record; + const file = + root.file && typeof root.file === "object" && !Array.isArray(root.file) + ? (root.file as Record) + : root; + const agents = + file.agents && typeof file.agents === "object" && !Array.isArray(file.agents) + ? (file.agents as Record) + : {}; + return Object.values(agents).some((agent) => { + if (!agent || typeof agent !== "object" || Array.isArray(agent)) return false; + const allowlist = (agent as Record).allowlist; + return Array.isArray(allowlist) && allowlist.length > 0; + }); +} + +async function runMxcProbe(wxcExecPath: string): Promise { + if (!fs.existsSync(wxcExecPath)) { + return classifyMxcProbe(null, "", "", `wxc-exec.exe not found at ${wxcExecPath}`); + } + return new Promise((resolve) => { + execFile( + wxcExecPath, + ["--probe"], + { + windowsHide: true, + timeout: 15_000, + maxBuffer: 1024 * 1024, + encoding: "utf-8", + }, + (error, stdout, stderr) => { + if (error && "killed" in error && error.killed) { + resolve(classifyMxcProbe(null, "", "wxc-exec --probe timed out", "probe timed out")); + return; + } + const exitCode = error && typeof error.code === "number" ? error.code : error ? 1 : 0; + resolve( + classifyMxcProbe(exitCode, stdout, stderr, error && !stdout ? error.message : undefined), + ); + }, + ); + }); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const WINDOWS_NODE_MXC_DIAGNOSTIC_COMMANDS = [ + ...WINDOWS_NODE_MXC_REQUIRED_COMMANDS, + WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, +] as const; diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts new file mode 100644 index 0000000..29f63f3 --- /dev/null +++ b/desktop/src/windows-node-mxc.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import { + applyWindowsNodeMxcGatewayPolicy, + buildWindowsNodeMxcLockedToolPolicy, + buildWindowsNodeMxcToolPolicy, + canonicalizeApprovedCwd, + classifyMxcProbe, + classifyMxcSmoke, + extractEffectiveToolNames, + getMxcTierWarning, + getWindowsNodeMxcGatewayPolicyState, + listAgentSessionKeys, + normalizeWindowsNodeRecord, + restoreWindowsNodeMxcGatewayPolicy, + validateEffectiveToolNames, + validateSelectedWindowsNode, + validateWindowsNodeMxcGatewayPolicy, + validateWindowsNodeMxcSettings, +} from "./windows-node-mxc"; + +const strictSettings = { + EnableNodeMode: true, + NodeSystemRunEnabled: true, + NodeCanvasEnabled: false, + NodeScreenEnabled: false, + NodeCameraEnabled: false, + NodeLocationEnabled: false, + NodeBrowserProxyEnabled: false, + NodeSttEnabled: false, + NodeTtsEnabled: false, + EnableMcpServer: false, + SystemRunSandboxEnabled: true, + SystemRunBlockHostFallbackWhenMxcUnavailable: true, + SystemRunAllowOutbound: false, + SystemRunAllowWindowsUi: true, + SandboxClipboard: 0, +}; + +describe("Windows Node MXC Gateway policy", () => { + it("exposes only exec and pins it to one stable node", () => { + expect(buildWindowsNodeMxcToolPolicy("node-123")).toEqual({ + profile: "full", + allow: ["exec"], + deny: ["process", "group:fs", "group:plugins"], + codeMode: false, + exec: { + host: "node", + node: "node-123", + security: "allowlist", + ask: "on-miss", + timeoutSec: 1800, + strictInlineEval: true, + }, + }); + }); + + it("applies the policy to every configured agent and restores prior tools", () => { + const source = { + agents: { + list: [ + { id: "main", tools: { allow: ["read"] } }, + { id: "coder", name: "Coder" }, + ], + }, + }; + const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123"); + + expect(applied.config).not.toBe(source); + expect( + (applied.config.agents as { list: Array<{ tools: unknown }> }).list.map( + (entry) => entry.tools, + ), + ).toEqual([ + buildWindowsNodeMxcToolPolicy("node-123"), + buildWindowsNodeMxcToolPolicy("node-123"), + ]); + expect(validateWindowsNodeMxcGatewayPolicy(applied.config, "node-123").ready).toBe(true); + expect(restoreWindowsNodeMxcGatewayPolicy(applied.config, applied.backups)).toEqual(source); + }); + + it("uses an empty locked tool inventory until all readiness proofs pass", () => { + const source = { agents: { list: [{ id: "main" }] } }; + const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "locked"); + const tools = (applied.config.agents as { list: Array<{ tools: unknown }> }).list[0].tools; + + expect(tools).toEqual(buildWindowsNodeMxcLockedToolPolicy("node-123")); + expect(tools).toMatchObject({ + allow: ["__microclaw_windows_node_mxc_locked__"], + }); + expect(getWindowsNodeMxcGatewayPolicyState(applied.config, "node-123")).toBe("locked"); + expect(validateEffectiveToolNames([], "locked").ready).toBe(true); + expect(validateEffectiveToolNames(["exec"], "locked").ready).toBe(false); + }); + + it("detects file-tool, plugin, or host-exec policy drift", () => { + const config = { + agents: { + list: [ + { + id: "main", + tools: { + ...buildWindowsNodeMxcToolPolicy("node-123"), + allow: ["exec", "read"], + exec: { host: "gateway" }, + }, + }, + ], + }, + }; + const result = validateWindowsNodeMxcGatewayPolicy(config, "node-123"); + expect(result.ready).toBe(false); + expect(result.blockers[0]).toContain("tool policy drifted"); + }); + + it("requires the effective runtime inventory to contain only exec", () => { + expect(validateEffectiveToolNames(["exec"]).ready).toBe(true); + expect(validateEffectiveToolNames(["exec", "read"]).ready).toBe(false); + }); + + it("parses pinned OpenClaw grouped tool IDs and rejects unknown payloads", () => { + expect( + extractEffectiveToolNames({ + groups: [ + { + id: "core", + tools: [ + { id: "exec", label: "Execute", description: "Run", source: "core" }, + { id: "read", label: "Read", description: "Read", source: "core" }, + ], + }, + ], + }), + ).toEqual(["exec", "read"]); + expect(extractEffectiveToolNames({ groups: [] })).toEqual([]); + expect(() => extractEffectiveToolNames({ tools: [] })).toThrow("groups inventory"); + }); + + it("uses actual persisted session keys for each configured agent", async () => { + const request = async (method: string) => { + expect(method).toBe("sessions.list"); + return { + sessions: [{ key: "global", agentId: "main" }, { key: "agent:coder:work" }], + }; + }; + const keys = await listAgentSessionKeys({ request }, ["main", "coder", "unused"]); + + expect([...keys]).toEqual([ + ["main", "global"], + ["coder", "agent:coder:work"], + ]); + }); +}); + +describe("Windows Node strict settings", () => { + it("accepts the minimum system-only strict MXC settings", () => { + expect(validateWindowsNodeMxcSettings(strictSettings).ready).toBe(true); + }); + + it("blocks host fallback, network, clipboard, MCP, and peripheral drift", () => { + const result = validateWindowsNodeMxcSettings({ + ...strictSettings, + SystemRunBlockHostFallbackWhenMxcUnavailable: false, + SystemRunAllowOutbound: true, + SandboxClipboard: 1, + EnableMcpServer: true, + NodeScreenEnabled: true, + }); + expect(result.ready).toBe(false); + expect(result.blockers).toEqual( + expect.arrayContaining([ + "Strict MXC host-fallback blocking is disabled", + "Outbound network access must remain blocked", + "Sandbox clipboard policy must be None", + "Local MCP must remain disabled for this mode", + "Screen capability must be disabled", + ]), + ); + }); +}); + +describe("Windows cwd policy", () => { + const realpaths = new Map([ + ["c:\\approved", "C:\\Approved"], + ["c:\\approved\\work", "C:\\Approved\\Work"], + ["c:\\junction", "C:\\Users\\Alice\\.ssh"], + ["c:\\users\\alice\\.ssh", "C:\\Users\\Alice\\.ssh"], + ["d:\\outside", "D:\\Outside"], + ]); + const realpath = (candidate: string) => { + const resolved = realpaths.get(candidate.toLowerCase()); + if (!resolved) throw new Error("missing"); + return resolved; + }; + + it("canonicalizes case-insensitively into the most specific approved folder", () => { + expect( + canonicalizeApprovedCwd( + "C:\\APPROVED\\work", + [ + { path: "C:\\Approved", access: "ro" }, + { path: "C:\\Approved\\Work", access: "rw" }, + ], + realpath, + ), + ).toEqual({ + allowed: true, + canonicalPath: "c:\\approved\\work", + access: "rw", + reason: null, + }); + }); + + it("rejects relative and unapproved cwd values", () => { + expect(canonicalizeApprovedCwd("relative", [], realpath).reason).toContain("absolute"); + expect( + canonicalizeApprovedCwd("D:\\Outside", [{ path: "C:\\Approved", access: "rw" }], realpath) + .reason, + ).toContain("outside"); + }); + + it("rejects a junction that resolves into a sensitive root", () => { + const result = canonicalizeApprovedCwd( + "C:\\Junction", + [{ path: "C:\\Junction", access: "rw" }], + realpath, + ["C:\\Users\\Alice\\.ssh"], + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("sensitive"); + }); +}); + +describe("MXC probe and smoke classification", () => { + it("accepts DACL-only machines with a prominent degraded warning", () => { + const probe = classifyMxcProbe( + 0, + JSON.stringify({ tier: "appcontainer-dacl", needsDaclAugmentation: true }), + "", + ); + expect(probe.outcome).toBe("supported"); + expect(probe.degraded).toBe(true); + expect(getMxcTierWarning(probe)).toContain("requires host DACL augmentation"); + }); + + it("treats base-container and BFS as full-strength", () => { + for (const tier of ["base-container", "appcontainer-bfs"]) { + expect(classifyMxcProbe(0, JSON.stringify({ tier }), "").degraded).toBe(false); + } + }); + + it("fails closed on an unknown isolation tier", () => { + const probe = classifyMxcProbe(0, JSON.stringify({ tier: "host" }), ""); + expect(probe.outcome).toBe("error"); + expect(probe.reason).toContain("unrecognized isolation tier"); + }); + + it("requires a contained external-child marker", () => { + expect( + classifyMxcSmoke({ exitCode: 0, stdout: "MICROCLAW_MXC_HOSTNAME_OK" }, "HOSTNAME_OK").outcome, + ).toBe("passed"); + expect( + classifyMxcSmoke( + { exitCode: -1, stderr: "0xC0000142 DLL initialization failed" }, + "HOSTNAME_OK", + ).outcome, + ).toBe("child-launch-failed"); + expect( + classifyMxcSmoke( + { exitCode: -1, stderr: "MXC unavailable and host fallback blocked" }, + "HOSTNAME_OK", + ).outcome, + ).toBe("sandbox-unavailable"); + }); +}); + +describe("selected node readiness", () => { + it("requires a paired local connected Windows node with strict cwd support", () => { + const node = normalizeWindowsNodeRecord({ + id: "node-123", + displayName: "Local Windows", + platform: "win32", + connected: true, + paired: true, + remoteIp: "127.0.0.1", + commands: ["system.run", "system.run.prepare"], + }); + const blocked = validateSelectedWindowsNode(node); + expect(blocked.ready).toBe(false); + expect(blocked.blockers).toContain( + "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", + ); + + node!.commands.push("system.run.cwd-policy"); + expect(validateSelectedWindowsNode(node).ready).toBe(true); + }); +}); diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts new file mode 100644 index 0000000..4e550db --- /dev/null +++ b/desktop/src/windows-node-mxc.ts @@ -0,0 +1,682 @@ +import * as path from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +export const WINDOWS_NODE_MXC_MODE = "windows-node-mxc"; +export const WINDOWS_NODE_MXC_REQUIRED_COMMANDS = ["system.run", "system.run.prepare"] as const; +export const WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND = "system.run.cwd-policy"; +export const WINDOWS_NODE_MXC_TOOL_ALLOWLIST = ["exec"] as const; +export const WINDOWS_NODE_MXC_LOCKED_TOOL_ALLOWLIST = [ + "__microclaw_windows_node_mxc_locked__", +] as const; +export const WINDOWS_NODE_MXC_TOOL_DENYLIST = ["process", "group:fs", "group:plugins"] as const; +export const WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST = [ + "group:runtime", + "group:fs", + "group:plugins", + "browser", + "gateway", + "nodes", +] as const; + +export type WindowsNodeMxcGatewayPolicyState = "active" | "locked" | "drift"; + +export type SandboxFolderAccess = "ro" | "rw"; + +export interface WindowsNodeMxcFolder { + path: string; + access: SandboxFolderAccess; +} + +export interface WindowsNodeMxcSettings { + EnableNodeMode?: boolean; + NodeSystemRunEnabled?: boolean; + NodeCanvasEnabled?: boolean; + NodeScreenEnabled?: boolean; + NodeCameraEnabled?: boolean; + NodeLocationEnabled?: boolean; + NodeBrowserProxyEnabled?: boolean; + NodeSttEnabled?: boolean; + NodeTtsEnabled?: boolean; + EnableMcpServer?: boolean; + SystemRunSandboxEnabled?: boolean; + SystemRunBlockHostFallbackWhenMxcUnavailable?: boolean; + SystemRunAllowOutbound?: boolean; + SystemRunAllowWindowsUi?: boolean; + SandboxClipboard?: number; + SandboxDocumentsAccess?: number | null; + SandboxDownloadsAccess?: number | null; + SandboxDesktopAccess?: number | null; + SandboxCustomFolders?: Array<{ Path?: string; Access?: number }>; +} + +export interface WindowsNodeRecord { + id: string; + displayName: string; + platform: string; + connected: boolean; + paired: boolean; + remoteIp: string | null; + commands: string[]; +} + +export interface WindowsNodeReadiness { + ready: boolean; + blockers: string[]; + warnings: string[]; +} + +export interface MxcProbeResult { + outcome: "supported" | "unsupported" | "error"; + tier: string | null; + needsDaclAugmentation: boolean; + degraded: boolean; + warnings: string[]; + reason: string | null; +} + +export interface MxcSmokeResult { + outcome: + | "passed" + | "sandbox-unavailable" + | "child-launch-failed" + | "approval-required" + | "timed-out" + | "failed"; + reason: string; +} + +export interface CanonicalCwdResult { + allowed: boolean; + canonicalPath: string | null; + access: SandboxFolderAccess | null; + reason: string | null; +} + +type AgentEntry = { + id?: unknown; + tools?: unknown; + [key: string]: unknown; +}; + +type AgentToolsBackup = Record; + +export interface WindowsNodeMxcPolicyApplication { + config: Record; + backups: AgentToolsBackup; + agentIds: string[]; +} + +export function buildWindowsNodeMxcToolPolicy(nodeId: string): Record { + const normalizedNodeId = nodeId.trim(); + if (!normalizedNodeId) throw new Error("A stable Windows node ID is required"); + + return { + profile: "full", + allow: [...WINDOWS_NODE_MXC_TOOL_ALLOWLIST], + deny: [...WINDOWS_NODE_MXC_TOOL_DENYLIST], + codeMode: false, + exec: { + host: "node", + node: normalizedNodeId, + security: "allowlist", + ask: "on-miss", + timeoutSec: 1800, + strictInlineEval: true, + }, + }; +} + +export function buildWindowsNodeMxcLockedToolPolicy(nodeId: string): Record { + return { + ...buildWindowsNodeMxcToolPolicy(nodeId), + // OpenClaw treats an empty allow list as unrestricted. This non-tool sentinel + // makes the allow policy restrictive while matching no runtime tool. + allow: [...WINDOWS_NODE_MXC_LOCKED_TOOL_ALLOWLIST], + deny: [...WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST], + }; +} + +export function applyWindowsNodeMxcGatewayPolicy( + source: Record, + nodeId: string, + existingBackups: AgentToolsBackup = {}, + state: Exclude = "active", +): WindowsNodeMxcPolicyApplication { + const config = structuredClone(source); + const agents = + config.agents && typeof config.agents === "object" && !Array.isArray(config.agents) + ? (config.agents as Record) + : {}; + const list = Array.isArray(agents.list) ? (agents.list as AgentEntry[]) : []; + if (list.length === 0) { + throw new Error("Windows Node + MXC mode requires at least one configured agent"); + } + + const policy = + state === "active" + ? buildWindowsNodeMxcToolPolicy(nodeId) + : buildWindowsNodeMxcLockedToolPolicy(nodeId); + const backups = structuredClone(existingBackups); + const agentIds: string[] = []; + for (const entry of list) { + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (!id) throw new Error("Every configured agent must have a stable ID"); + if (!Object.hasOwn(backups, id)) { + backups[id] = Object.hasOwn(entry, "tools") ? structuredClone(entry.tools) : null; + } + entry.tools = structuredClone(policy); + agentIds.push(id); + } + + agents.list = list; + config.agents = agents; + return { config, backups, agentIds }; +} + +export function restoreWindowsNodeMxcGatewayPolicy( + source: Record, + backups: AgentToolsBackup, +): Record { + const config = structuredClone(source); + const agents = + config.agents && typeof config.agents === "object" && !Array.isArray(config.agents) + ? (config.agents as Record) + : null; + const list = agents && Array.isArray(agents.list) ? (agents.list as AgentEntry[]) : []; + for (const entry of list) { + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (!id || !Object.hasOwn(backups, id)) continue; + const previous = backups[id]; + if (previous === null) delete entry.tools; + else entry.tools = structuredClone(previous); + } + return config; +} + +export function validateWindowsNodeMxcGatewayPolicy( + config: unknown, + nodeId: string, + expectedState: Exclude | "either" = "active", +): WindowsNodeReadiness { + const blockers: string[] = []; + const warnings: string[] = []; + if (!nodeId.trim()) { + return { ready: false, blockers: ["A stable Windows node ID is required"], warnings }; + } + if (!config || typeof config !== "object" || Array.isArray(config)) { + return { ready: false, blockers: ["OpenClaw configuration is unavailable"], warnings }; + } + + const root = config as Record; + const agents = + root.agents && typeof root.agents === "object" && !Array.isArray(root.agents) + ? (root.agents as Record) + : null; + const list = agents && Array.isArray(agents.list) ? (agents.list as AgentEntry[]) : []; + if (list.length === 0) blockers.push("No configured agents are protected by the MXC policy"); + + const active = buildWindowsNodeMxcToolPolicy(nodeId); + const locked = buildWindowsNodeMxcLockedToolPolicy(nodeId); + for (const entry of list) { + const id = typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : ""; + const matchesActive = isDeepStrictEqual(entry.tools, active); + const matchesLocked = isDeepStrictEqual(entry.tools, locked); + const matchesExpected = + expectedState === "either" + ? matchesActive || matchesLocked + : expectedState === "active" + ? matchesActive + : matchesLocked; + if (!matchesExpected) { + blockers.push(`Agent "${id}" tool policy drifted from exec-only Windows-node routing`); + } + } + + const globalTools = + root.tools && typeof root.tools === "object" && !Array.isArray(root.tools) + ? (root.tools as Record) + : null; + const globalDeny = globalTools && Array.isArray(globalTools.deny) ? globalTools.deny : []; + if (globalDeny.some((value) => value === "exec" || value === "group:runtime")) { + blockers.push("Global Gateway tool policy blocks node exec"); + } + + return { ready: blockers.length === 0, blockers, warnings }; +} + +export function getWindowsNodeMxcGatewayPolicyState( + config: unknown, + nodeId: string, +): WindowsNodeMxcGatewayPolicyState { + if (validateWindowsNodeMxcGatewayPolicy(config, nodeId, "active").ready) return "active"; + if (validateWindowsNodeMxcGatewayPolicy(config, nodeId, "locked").ready) return "locked"; + return "drift"; +} + +export function validateWindowsNodeMxcSettings( + settings: WindowsNodeMxcSettings | null, +): WindowsNodeReadiness { + const blockers: string[] = []; + const warnings: string[] = []; + if (!settings) { + return { + ready: false, + blockers: ["Windows Companion settings.json is missing or unreadable"], + warnings, + }; + } + + const requiredTrue: Array<[keyof WindowsNodeMxcSettings, string]> = [ + ["EnableNodeMode", "Windows Companion node mode is disabled"], + ["NodeSystemRunEnabled", "Windows node system.run is disabled"], + ["SystemRunSandboxEnabled", "Windows node MXC sandbox is disabled"], + [ + "SystemRunBlockHostFallbackWhenMxcUnavailable", + "Strict MXC host-fallback blocking is disabled", + ], + [ + "SystemRunAllowWindowsUi", + "Windows UI APIs must be allowed for PowerShell compatibility with MXC 0.7", + ], + ]; + for (const [property, message] of requiredTrue) { + if (settings[property] !== true) blockers.push(message); + } + + const requiredFalse: Array<[keyof WindowsNodeMxcSettings, string]> = [ + ["SystemRunAllowOutbound", "Outbound network access must remain blocked"], + ["EnableMcpServer", "Local MCP must remain disabled for this mode"], + ["NodeCanvasEnabled", "Canvas capability must be disabled"], + ["NodeScreenEnabled", "Screen capability must be disabled"], + ["NodeCameraEnabled", "Camera capability must be disabled"], + ["NodeLocationEnabled", "Location capability must be disabled"], + ["NodeBrowserProxyEnabled", "Browser proxy capability must be disabled"], + ["NodeSttEnabled", "Speech-to-text capability must be disabled"], + ["NodeTtsEnabled", "Text-to-speech capability must be disabled"], + ]; + for (const [property, message] of requiredFalse) { + if (settings[property] !== false) blockers.push(message); + } + + if (settings.SandboxClipboard !== 0) blockers.push("Sandbox clipboard policy must be None"); + + warnings.push( + "allowWindowsUi is a PowerShell compatibility relaxation; it does not enable screen, input, canvas, camera, microphone, browser, location, or speech capabilities.", + ); + return { ready: blockers.length === 0, blockers, warnings }; +} + +export function normalizeWindowsNodeRecord(value: unknown): WindowsNodeRecord | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const idValue = record.id ?? record.nodeId ?? record.deviceId; + if (typeof idValue !== "string" || !idValue.trim()) return null; + const commands = Array.isArray(record.commands) + ? record.commands.filter((entry): entry is string => typeof entry === "string") + : []; + const connected = + record.connected === true || + record.online === true || + record.status === "connected" || + record.status === "online"; + return { + id: idValue.trim(), + displayName: + typeof record.displayName === "string" + ? record.displayName + : typeof record.name === "string" + ? record.name + : idValue.trim(), + platform: typeof record.platform === "string" ? record.platform.toLowerCase() : "", + connected, + paired: record.paired !== false, + remoteIp: + typeof record.remoteIp === "string" + ? record.remoteIp + : typeof record.ip === "string" + ? record.ip + : null, + commands, + }; +} + +export function validateSelectedWindowsNode(node: WindowsNodeRecord | null): WindowsNodeReadiness { + const blockers: string[] = []; + const warnings: string[] = []; + if (!node) { + return { ready: false, blockers: ["The selected Windows node is not paired"], warnings }; + } + if (!node.connected) blockers.push("The selected Windows node is disconnected"); + if (!node.paired) blockers.push("The selected Windows node requires pairing or reapproval"); + if (node.platform !== "win32" && node.platform !== "windows") { + blockers.push("The selected node is not a Windows node"); + } + if (!node.remoteIp || !isLoopbackAddress(node.remoteIp)) { + blockers.push("The selected node is not proven to be local to this MicroClaw Gateway"); + } + for (const command of WINDOWS_NODE_MXC_REQUIRED_COMMANDS) { + if (!node.commands.includes(command)) + blockers.push(`Selected node does not declare ${command}`); + } + if (!node.commands.includes(WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND)) { + blockers.push( + "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", + ); + } + return { ready: blockers.length === 0, blockers, warnings }; +} + +export function validateEffectiveToolNames( + toolNames: string[], + expectedState: Exclude = "active", +): WindowsNodeReadiness { + const unique = [...new Set(toolNames)].sort(); + const expected = expectedState === "active" ? ["exec"] : []; + const blockers = isDeepStrictEqual(unique, expected) + ? [] + : [ + `Effective Gateway tools must be ${ + expectedState === "active" ? 'exactly "exec"' : "empty while readiness is locked" + }; observed: ${unique.join(", ") || "none"}`, + ]; + return { ready: blockers.length === 0, blockers, warnings: [] }; +} + +export function extractEffectiveToolNames(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("tools.effective returned a non-object payload"); + } + const groups = (payload as Record).groups; + if (!Array.isArray(groups)) { + throw new Error("tools.effective did not return a groups inventory"); + } + + const names = new Set(); + for (const group of groups) { + if (!group || typeof group !== "object" || Array.isArray(group)) { + throw new Error("tools.effective returned a malformed group"); + } + const tools = (group as Record).tools; + if (!Array.isArray(tools)) { + throw new Error("tools.effective returned a group without a tools inventory"); + } + for (const tool of tools) { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) { + throw new Error("tools.effective returned a malformed tool entry"); + } + const id = (tool as Record).id; + if (typeof id !== "string" || !id.trim()) { + throw new Error("tools.effective returned a tool without a stable id"); + } + names.add(id.trim()); + } + } + return [...names]; +} + +export async function listAgentSessionKeys( + gateway: { request(method: string, params?: unknown): Promise }, + agentIds: string[], +): Promise> { + const payload = await gateway.request("sessions.list", { limit: 500 }); + const sessions = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record).sessions + : null; + if (!Array.isArray(sessions)) { + throw new Error("sessions.list did not return a sessions inventory"); + } + + const wanted = new Set(agentIds); + const result = new Map(); + for (const value of sessions) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const session = value as Record; + const key = + typeof session.key === "string" + ? session.key.trim() + : typeof session.sessionKey === "string" + ? session.sessionKey.trim() + : ""; + if (!key) continue; + const keyAgentId = /^agent:([^:]+):/i.exec(key)?.[1]; + const agentId = + typeof session.agentId === "string" && session.agentId.trim() + ? session.agentId.trim() + : keyAgentId || (key === "global" ? "main" : ""); + if (wanted.has(agentId) && !result.has(agentId)) result.set(agentId, key); + } + return result; +} + +export function classifyMxcProbe( + exitCode: number | null, + stdout: string, + stderr: string, + launchError?: string, +): MxcProbeResult { + if (launchError) return probeError(`wxc-exec --probe could not be launched: ${launchError}`); + if (!stdout.trim()) { + return probeError( + `wxc-exec --probe ${exitCode === 0 ? "returned no output" : `exited ${exitCode}`}${ + stderr.trim() ? `: ${stderr.trim()}` : "" + }`, + ); + } + + let parsed: Record; + try { + parsed = JSON.parse(stdout) as Record; + } catch { + return probeError("wxc-exec --probe returned unparseable output"); + } + const warnings = Array.isArray(parsed.warnings) + ? parsed.warnings.filter((entry): entry is string => typeof entry === "string") + : []; + if (typeof parsed.error === "string" || parsed.supported === false) { + return { + outcome: "unsupported", + tier: null, + needsDaclAugmentation: false, + degraded: false, + warnings, + reason: + typeof parsed.error === "string" + ? parsed.error + : "No usable MXC isolation tier is available", + }; + } + if (exitCode !== 0) return probeError(`wxc-exec --probe exited ${exitCode}`); + 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)) { + return probeError(`wxc-exec --probe reported an unrecognized isolation tier: ${tier}`); + } + const needsDaclAugmentation = parsed.needsDaclAugmentation === true; + return { + outcome: "supported", + tier, + needsDaclAugmentation, + degraded: + needsDaclAugmentation || !["base-container", "appcontainer-bfs"].includes(normalizedTier), + 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"}${ + probe.needsDaclAugmentation ? " requires host DACL augmentation" : "" + }. This mode remains usable but is weaker than base-container or appcontainer-bfs.`; +} + +export function classifyMxcSmoke(value: unknown, expectedMarker: string): MxcSmokeResult { + const record = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const payload = + record.payload && typeof record.payload === "object" && !Array.isArray(record.payload) + ? (record.payload as Record) + : record; + const stdout = typeof payload.stdout === "string" ? payload.stdout : ""; + const stderr = typeof payload.stderr === "string" ? payload.stderr : ""; + const error = + typeof record.error === "string" + ? record.error + : typeof payload.error === "string" + ? payload.error + : ""; + const combined = `${stderr}\n${error}`.trim(); + const exitCode = typeof payload.exitCode === "number" ? payload.exitCode : null; + + if (payload.timedOut === true) + return { outcome: "timed-out", reason: "Contained smoke timed out" }; + if (/approval|allowlist|user denied|exec-approvals/i.test(combined)) { + return { outcome: "approval-required", reason: combined || "Local approval is required" }; + } + if (/sandbox.*unavailable|host fallback.*blocked|mxc.*unavailable/i.test(combined)) { + return { outcome: "sandbox-unavailable", reason: combined }; + } + if (/0x?c0000142|dll initialization failed|access is denied|access denied/i.test(combined)) { + return { outcome: "child-launch-failed", reason: combined }; + } + if (exitCode === 0 && stdout.includes(expectedMarker)) { + return { outcome: "passed", reason: "Contained external child process completed" }; + } + return { + outcome: "failed", + reason: combined || `Contained smoke exited ${exitCode ?? "without a result"}`, + }; +} + +export function canonicalizeApprovedCwd( + cwd: string, + approvedFolders: WindowsNodeMxcFolder[], + realpath: (candidate: string) => string, + sensitiveRoots: string[] = [], +): CanonicalCwdResult { + if (!path.win32.isAbsolute(cwd)) { + return { + allowed: false, + canonicalPath: null, + access: null, + reason: "cwd must be an absolute Windows path", + }; + } + + let canonicalCwd: string; + try { + canonicalCwd = normalizeWindowsPath(realpath(path.win32.normalize(cwd))); + } catch (error) { + return { + allowed: false, + canonicalPath: null, + access: null, + reason: `cwd cannot be resolved without following reparse points: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + for (const sensitiveRoot of sensitiveRoots) { + let canonicalSensitive: string; + try { + canonicalSensitive = normalizeWindowsPath(realpath(path.win32.normalize(sensitiveRoot))); + } catch { + canonicalSensitive = normalizeWindowsPath(sensitiveRoot); + } + if (isWithinWindowsRoot(canonicalCwd, canonicalSensitive)) { + return { + allowed: false, + canonicalPath: canonicalCwd, + access: null, + reason: "cwd resolves into a sensitive denied root", + }; + } + } + + const matches: Array<{ root: string; access: SandboxFolderAccess }> = []; + for (const folder of approvedFolders) { + try { + const root = normalizeWindowsPath(realpath(path.win32.normalize(folder.path))); + if (isWithinWindowsRoot(canonicalCwd, root)) matches.push({ root, access: folder.access }); + } catch { + continue; + } + } + matches.sort((left, right) => right.root.length - left.root.length); + const match = matches[0]; + if (!match) { + return { + allowed: false, + canonicalPath: canonicalCwd, + access: null, + reason: "cwd is outside every globally approved folder", + }; + } + return { + allowed: true, + canonicalPath: canonicalCwd, + access: match.access, + reason: null, + }; +} + +export function listConfiguredSandboxFolders( + settings: WindowsNodeMxcSettings, + userProfile: string, +): WindowsNodeMxcFolder[] { + const folders: WindowsNodeMxcFolder[] = []; + const add = (folderPath: string, value: number | null | undefined) => { + if (value !== 0 && value !== 1) return; + folders.push({ path: folderPath, access: value === 1 ? "rw" : "ro" }); + }; + add(path.win32.join(userProfile, "Documents"), settings.SandboxDocumentsAccess); + add(path.win32.join(userProfile, "Downloads"), settings.SandboxDownloadsAccess); + add(path.win32.join(userProfile, "Desktop"), settings.SandboxDesktopAccess); + for (const custom of settings.SandboxCustomFolders ?? []) { + if (typeof custom.Path !== "string" || !custom.Path.trim()) continue; + add(custom.Path.trim(), custom.Access); + } + return folders; +} + +function probeError(reason: string): MxcProbeResult { + return { + outcome: "error", + tier: null, + needsDaclAugmentation: false, + degraded: false, + warnings: [], + reason, + }; +} + +function normalizeWindowsPath(value: string): string { + const parsed = path.win32.parse(value); + const normalized = path.win32.normalize(value); + const withoutTrailing = + normalized.length > parsed.root.length ? normalized.replace(/[\\]+$/, "") : normalized; + return withoutTrailing.toLowerCase(); +} + +function isWithinWindowsRoot(candidate: string, root: string): boolean { + return candidate === root || candidate.startsWith(`${root}\\`); +} + +function isLoopbackAddress(value: string): boolean { + const normalized = value + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return ( + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "::ffff:127.0.0.1" || + normalized === "localhost" || + normalized.startsWith("127.") + ); +} diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md new file mode 100644 index 0000000..5d357b1 --- /dev/null +++ b/docs/experimental-windows-node-mxc.md @@ -0,0 +1,108 @@ +# Experimental Windows Node + MXC sandbox + +This branch contains a proof of concept for the security framework tracked by +[issue #202](https://github.com/microsofthackathons/MicroClaw/issues/202). It is independent of +the Docker sandbox experiment and does not replace MicroClaw's existing AppContainer mode. + +## Boundary + +MicroClaw and its managed OpenClaw Gateway remain on the host. Agent-controlled commands use +the normal Gateway WebSocket route to one explicitly selected local Windows node: + +```text +agent exec -> managed Gateway -> node.invoke system.run -> Windows Node V2 approval -> MXC +``` + +Local MCP is not used. The Gateway agent policy is an explicit `allow: ["exec"]` allowlist, +with code mode disabled and `exec.host` pinned to the selected stable node ID. This removes +Gateway-host `read`, `write`, `edit`, `apply_patch`, `process`, browser, plugin, MCP, and other +tool schemas from the agent path. MicroClaw verifies the session-scoped `tools.effective` +inventory before considering the mode effective. + +The mode is mutually exclusive with MicroClaw AppContainer. Enabling it first installs a locked +policy with a non-tool sentinel allowlist (an empty OpenClaw allowlist is unrestricted). Direct +operator diagnostics can still inspect the node and run the explicit smoke, but channel and chat +agents have no execution surface. The branch generates and schema-validates the intended exec-only +policy, but does not activate it: pinned Gateway APIs cannot atomically quarantine channel and +scheduled ingress while a newly started active Gateway is attested. Agent roster changes are +blocked while the mode is desired. + +When the mode is desired but any proof fails, chat execution is denied rather than falling back +to the host. An already-running external Gateway is never reused for this mode. + +## Required Windows Companion state + +The integration targets `openclaw/openclaw-windows-node` commit +`fc9add75eda78daf548d80a55ffb64e63b159961`. That build has no remote sandbox-settings API, so +the operator must configure its Sandbox page and MicroClaw reads the effective local settings +from `%APPDATA%\OpenClawTray\settings.json`. + +Required settings: + +- node mode and system tools enabled; +- MXC enabled and host fallback blocked when MXC is unavailable; +- outbound network disabled and clipboard set to `None`; +- local MCP, canvas, screen, camera, location, browser proxy, STT, and TTS disabled; +- `Allow Windows UI APIs` enabled for PowerShell/pwsh compatibility with MXC 0.7. + +`Allow Windows UI APIs` is a compatibility relaxation. It does not enable screen capture, +input injection, canvas, camera, microphone, browser, location, or speech capabilities. +Windows Node continues to force input injection off and to deny its settings directory, +SSH roots, browser profiles, PowerShell history, and other sensitive roots. + +For an unpackaged release, MicroClaw detects: + +```text +%LOCALAPPDATA%\OpenClawTray\OpenClaw.Tray.WinUI.exe +%LOCALAPPDATA%\OpenClawTray\tools\mxc\\wxc-exec.exe +``` + +Custom/dev/MSIX layouts must launch MicroClaw with `OPENCLAW_TRAY_DATA_DIR` and +`OPENCLAW_WXC_EXEC` pointing at the matching pinned installation. MicroClaw does not install, +elevate, or make host-wide changes. + +## Readiness + +The Security page reports desired/effective state, connection and pairing, declared commands, +strict fallback state, effective folder grants, MXC tier, DACL augmentation, durable approval +state, and smoke results. + +`wxc-exec --probe` is necessary but not sufficient. The explicit smoke invokes contained +`hostname.exe` through `cmd.exe`, then contained Windows PowerShell, through the selected node. +`0xC0000142`, DLL initialization failures, access-denied results, timeouts, approval failures, +and sandbox-unavailable results are classified as unusable. `appcontainer-dacl` is accepted +with a prominent degraded-containment warning. + +No environment is supplied to smoke commands, and Windows Node rejects custom command +environments while MXC is active. The smoke must be repeated when the selected node, relevant +Windows Companion settings, or MXC tier changes. + +## Upstream blocker + +The pinned Windows Node validates `cwd` only as a nonblank string. It does not canonicalize it +through reparse points, restrict it to configured folder grants, or include it in durable +approval identity. MXC also grants an otherwise-unlisted explicit cwd read-only access. A +durable executable+argv approval can therefore be reused with another cwd. + +MicroClaw cannot safely repair this at the Gateway config layer because it cannot intercept or +rewrite the node's approved `system.run` payload. This proof of concept consequently remains +fail-closed unless the selected node declares `system.run.cwd-policy`, representing an upstream +implementation that: + +1. resolves cwd to a canonical local path and rejects network/reparse escapes; +2. requires it to be inside one configured RO/RW folder and preserves that access level; +3. binds canonical cwd into durable approval identity; +4. revalidates cwd and the folder grant immediately before process launch; and +5. rejects durable approval for cwd/relative-path-sensitive commands until those guarantees + are available. + +Even after that declaration exists, activation requires an upstream Gateway +quarantine/attestation mechanism that keeps channel and scheduled ingress disabled until the +active `tools.effective` inventory and all mutable Windows Node/MXC state have been verified. +Without an atomic mechanism, an active Gateway could accept work between startup and the +verification RPC. This branch therefore never transitions the managed Gateway from locked to +active. + +The smaller acceptable upstream patch is to reject `AllowAlways` whenever caller-supplied cwd +is nonempty, but approved-root canonicalization is still required before this MicroClaw mode +can become effective. From 2a14e0343c0bef23f3d533843c16ad6dd681f588 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:28:26 +0800 Subject: [PATCH 02/23] Keep locked Gateway alive during diagnostics Defer effective tool inventory drift enforcement until the managed Gateway WebSocket is connected. The sentinel locked policy remains mandatory while startup diagnostics complete. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/src/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 2fc812a..0da6122 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -2074,7 +2074,8 @@ function startHealthMonitor(): void { ) { return; } - if (status.gatewayPolicyState !== "locked" || !status.effectiveToolsReady) { + const effectiveToolsDrifted = gwClient?.connected && !status.effectiveToolsReady; + if (status.gatewayPolicyState !== "locked" || effectiveToolsDrifted) { const message = `Windows Node + MXC readiness drifted; stopping managed Gateway: ${status.blockers.join("; ")}`; console.error(`[windows-node-mxc] ${message}`); mainWindow?.webContents.send("gateway:log", `[error] ${message}`); From 2c766f0a77b6d5c4733ebd9a37de086170ddf1c2 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:35:51 +0800 Subject: [PATCH 03/23] Keep unused agents locked during diagnostics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/src/windows-node-mxc-service.ts | 8 ++++---- desktop/src/windows-node-mxc.test.ts | 13 +++++++++++++ desktop/src/windows-node-mxc.ts | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index 1b20a62..c8b6ec1 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -13,6 +13,7 @@ import { type WindowsNodeRecord, classifyMxcProbe, classifyMxcSmoke, + classifyMissingEffectiveToolSession, extractEffectiveToolNames, getWindowsNodeMxcGatewayPolicyState, getMxcTierWarning, @@ -192,10 +193,7 @@ export async function inspectWindowsNodeMxc( agentIds.map(async (agentId) => { const sessionKey = sessionKeys.get(agentId); if (!sessionKey) { - return { - ready: false, - blockers: [`Agent "${agentId}" has no persisted session for tools.effective`], - }; + return classifyMissingEffectiveToolSession(agentId, expectedToolsState); } const result = await options.gateway!.request("tools.effective", { agentId, @@ -208,11 +206,13 @@ export async function inspectWindowsNodeMxc( return { ready: check.ready, blockers: check.blockers.map((blocker) => `Agent "${agentId}": ${blocker}`), + warnings: [], }; }), ); effectiveToolsReady = effectiveChecks.every((check) => check.ready); blockers.push(...effectiveChecks.flatMap((check) => check.blockers)); + warnings.push(...effectiveChecks.flatMap((check) => check.warnings)); } catch (error) { blockers.push(`Could not verify effective Gateway tools: ${messageOf(error)}`); } diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index 29f63f3..7e847ba 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -6,6 +6,7 @@ import { canonicalizeApprovedCwd, classifyMxcProbe, classifyMxcSmoke, + classifyMissingEffectiveToolSession, extractEffectiveToolNames, getMxcTierWarning, getWindowsNodeMxcGatewayPolicyState, @@ -149,6 +150,18 @@ describe("Windows Node MXC Gateway policy", () => { ["coder", "agent:coder:work"], ]); }); + + it("allows missing sessions only while the Gateway remains locked", () => { + expect(classifyMissingEffectiveToolSession("unused", "locked")).toMatchObject({ + ready: true, + blockers: [], + warnings: [expect.stringContaining("locked config applies")], + }); + expect(classifyMissingEffectiveToolSession("unused", "active")).toMatchObject({ + ready: false, + blockers: [expect.stringContaining("no persisted session")], + }); + }); }); describe("Windows Node strict settings", () => { diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index 4e550db..8a53291 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -382,6 +382,26 @@ export function validateEffectiveToolNames( return { ready: blockers.length === 0, blockers, warnings: [] }; } +export function classifyMissingEffectiveToolSession( + agentId: string, + expectedState: Exclude, +): WindowsNodeReadiness { + if (expectedState === "locked") { + return { + ready: true, + blockers: [], + warnings: [ + `Agent "${agentId}" has no persisted session; the locked config applies before its first session is created`, + ], + }; + } + return { + ready: false, + blockers: [`Agent "${agentId}" has no persisted session for tools.effective`], + warnings: [], + }; +} + export function extractEffectiveToolNames(payload: unknown): string[] { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new Error("tools.effective returned a non-object payload"); From 4458835f94247b4f563991f16efc8c17422be231 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:16:03 +0800 Subject: [PATCH 04/23] Persist Windows Node MXC selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../utils/windows-node-mxc-selection.test.ts | 61 ++++++++ .../src/utils/windows-node-mxc-selection.ts | 39 +++++ .../renderer/src/views/SettingsView.test.ts | 147 +++++++++++++++++- desktop/renderer/src/views/SettingsView.vue | 56 ++++++- 4 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 desktop/renderer/src/utils/windows-node-mxc-selection.test.ts create mode 100644 desktop/renderer/src/utils/windows-node-mxc-selection.ts diff --git a/desktop/renderer/src/utils/windows-node-mxc-selection.test.ts b/desktop/renderer/src/utils/windows-node-mxc-selection.test.ts new file mode 100644 index 0000000..daa4787 --- /dev/null +++ b/desktop/renderer/src/utils/windows-node-mxc-selection.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + getAutomaticWindowsNodeMxcSelection, + WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID, + type WindowsNodeMxcSelectableNode, +} from "./windows-node-mxc-selection"; + +const localNode: WindowsNodeMxcSelectableNode = { + id: "node-local", + platform: "windows", + connected: true, + paired: true, + remoteIp: null, +}; + +describe("Windows Node MXC selection", () => { + it("replaces only the exact diagnostic sentinel with one eligible local node", () => { + expect( + getAutomaticWindowsNodeMxcSelection({ + desiredEnabled: true, + selectedNodeId: WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID, + nodes: [localNode], + }), + ).toBe("node-local"); + + expect( + getAutomaticWindowsNodeMxcSelection({ + desiredEnabled: true, + selectedNodeId: "node-explicit", + nodes: [localNode], + }), + ).toBeNull(); + }); + + it("does not choose among multiple eligible nodes", () => { + expect( + getAutomaticWindowsNodeMxcSelection({ + desiredEnabled: true, + selectedNodeId: WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID, + nodes: [localNode, { ...localNode, id: "node-other" }], + }), + ).toBeNull(); + }); + + it("rejects disconnected, unpaired, non-Windows, and remote nodes", () => { + for (const node of [ + { ...localNode, connected: false }, + { ...localNode, paired: false }, + { ...localNode, platform: "linux" }, + { ...localNode, remoteIp: "192.0.2.10" }, + ]) { + expect( + getAutomaticWindowsNodeMxcSelection({ + desiredEnabled: true, + selectedNodeId: WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID, + nodes: [node], + }), + ).toBeNull(); + } + }); +}); diff --git a/desktop/renderer/src/utils/windows-node-mxc-selection.ts b/desktop/renderer/src/utils/windows-node-mxc-selection.ts new file mode 100644 index 0000000..f6e3f3a --- /dev/null +++ b/desktop/renderer/src/utils/windows-node-mxc-selection.ts @@ -0,0 +1,39 @@ +export const WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID = "diagnostic-unpaired-local-node"; + +export interface WindowsNodeMxcSelectableNode { + id: string; + platform: string; + connected: boolean; + paired: boolean; + remoteIp?: string | null; +} + +export interface WindowsNodeMxcSelectionStatus { + desiredEnabled: boolean; + selectedNodeId: string; + nodes: WindowsNodeMxcSelectableNode[]; +} + +function isLocalNode(node: WindowsNodeMxcSelectableNode): boolean { + const remoteIp = node.remoteIp?.trim().toLowerCase(); + return ( + !remoteIp || remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1" + ); +} + +export function getAutomaticWindowsNodeMxcSelection( + status: WindowsNodeMxcSelectionStatus, +): string | null { + if (!status.desiredEnabled || status.selectedNodeId !== WINDOWS_NODE_MXC_DIAGNOSTIC_NODE_ID) { + return null; + } + + const eligible = status.nodes.filter( + (node) => + node.connected && + node.paired && + node.platform.trim().toLowerCase() === "windows" && + isLocalNode(node), + ); + return eligible.length === 1 ? eligible[0].id : null; +} diff --git a/desktop/renderer/src/views/SettingsView.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index ad8ea9d..22495f9 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -1,19 +1,74 @@ import { createPinia } from "pinia"; import { flushPromises, shallowMount } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent } from "vue"; import { setLocale } from "@/i18n"; import { useGatewayStore } from "@/stores/gateway"; import SettingsView from "./SettingsView.vue"; +const routeState = vi.hoisted(() => ({ section: "skills" })); vi.mock("vue-router", () => ({ - useRoute: () => ({ params: { section: "skills" } }), + useRoute: () => ({ params: { section: routeState.section } }), })); describe("SettingsView", () => { + type WindowsNodeMxcStatus = Awaited>; const exportGatewayLogs = vi.fn(); + const getWindowsNodeMxcStatus = vi.fn(); + const setWindowsNodeMxcEnabled = vi.fn(); + + const localNode = { + id: "node-local", + displayName: "Local Windows node", + platform: "windows", + connected: true, + paired: true, + remoteIp: null, + commands: ["system.run", "system.run.prepare"], + }; + + const status = ( + selectedNodeId: string, + nodes = [localNode, { ...localNode, id: "node-other" }], + ): WindowsNodeMxcStatus => ({ + desiredEnabled: true, + effectiveEnabled: false, + selectedNodeId, + settingsPath: "", + companionPath: "", + companionInstalled: true, + settingsLoaded: true, + settingsFingerprint: "settings", + nodes, + selectedNode: nodes.find((node) => node.id === selectedNodeId) ?? null, + gatewayPolicyState: "locked", + gatewayPolicyReady: true, + effectiveToolsReady: true, + strictFallbackEffective: false, + allowWindowsUiEffective: false, + folders: [], + durableApprovalsPresent: false, + probe: { + outcome: "supported", + tier: "appcontainer-dacl", + needsDaclAugmentation: true, + degraded: true, + warnings: [], + reason: null, + }, + smoke: null, + blockers: [], + warnings: [], + remediation: [], + }); beforeEach(() => { + routeState.section = "skills"; setLocale("en-US"); + getWindowsNodeMxcStatus.mockReset().mockResolvedValue(status("diagnostic-unpaired-local-node")); + setWindowsNodeMxcEnabled + .mockReset() + .mockImplementation(async ({ nodeId }: { nodeId: string }) => status(nodeId)); window.openclaw = { config: { read: vi.fn().mockResolvedValue(null), @@ -31,6 +86,22 @@ describe("SettingsView", () => { logs: { exportGateway: exportGatewayLogs, }, + windowsNodeMxc: { + getStatus: getWindowsNodeMxcStatus, + setEnabled: setWindowsNodeMxcEnabled, + runSmoke: vi.fn(), + }, + sandbox: { + getStatus: vi.fn().mockResolvedValue({ + available: true, + enabled: false, + sandboxDirsRW: [], + sandboxDirsRO: [], + }), + getExternalApps: vi.fn().mockResolvedValue([]), + getCapabilities: vi.fn().mockResolvedValue([]), + getUserDirs: vi.fn().mockResolvedValue({ rw: [], ro: [] }), + }, } as unknown as typeof window.openclaw; exportGatewayLogs.mockReset().mockResolvedValue({ canceled: false, @@ -81,4 +152,78 @@ describe("SettingsView", () => { expect(exportGatewayLogs).toHaveBeenCalledWith(["[info] Gateway started"]); }); + + it("persists a changed node immediately while MXC mode is enabled", async () => { + routeState.section = "security"; + const NodeSelectStub = defineComponent({ + emits: ["change"], + template: `
{{ t("settings.windowsNodeMxcSelectedNode") }} - - - + + {{ + windowsNodeMxcStatus?.selectedNode + ? `${windowsNodeMxcStatus.selectedNode.displayName} (${windowsNodeMxcStatus.selectedNode.connected ? "online" : "offline"})` + : "app-owned node unavailable" + }} +
+
+ Bundled helper + {{ + windowsNodeMxcStatus.helperRevision + ? windowsNodeMxcStatus.helperRevision.slice(0, 12) + : "unavailable" + }} +
+
+ MXC runtime / CWD policy + {{ + `${windowsNodeMxcStatus.mxcRuntimeVersion ?? "unavailable"} / ${windowsNodeMxcStatus.cwdPolicyContract ?? "unavailable"}` + }} +
{{ t("settings.windowsNodeMxcStrictFallback") }} {{ @@ -465,7 +471,7 @@ {{ windowsNodeMxcStatus.smoke - ? `${windowsNodeMxcStatus.smoke.hostname.outcome} / ${windowsNodeMxcStatus.smoke.powershell.outcome}` + ? `${windowsNodeMxcStatus.smoke.deniedOutsideRoot.outcome} / ${windowsNodeMxcStatus.smoke.hostname.outcome} / ${windowsNodeMxcStatus.smoke.powershell.outcome}` : t("settings.windowsNodeMxcNotRun") }} @@ -1000,8 +1006,6 @@ import { type PrivacyControls, type PrivacyLevel, } from "@/utils/privacy-settings"; -import { getAutomaticWindowsNodeMxcSelection } from "@/utils/windows-node-mxc-selection"; - const route = useRoute(); const gateway = useGatewayStore(); const chatStore = useChatStore(); @@ -1097,36 +1101,21 @@ const capsRestarting = ref(false); const sandboxRestarting = ref(false); type WindowsNodeMxcStatus = Awaited>; const windowsNodeMxcStatus = ref(null); -const windowsNodeMxcSelectedNodeId = ref(""); const windowsNodeMxcApplying = ref(false); const windowsNodeMxcRefreshing = ref(false); const windowsNodeMxcSmokeRunning = ref(false); -let windowsNodeMxcPendingNodeId: string | null = null; let windowsNodeMxcApprovalUnsubscribe: (() => void) | null = null; let activeWindowsNodeMxcApprovalId: string | null = null; function updateWindowsNodeMxcStatus(status: WindowsNodeMxcStatus) { windowsNodeMxcStatus.value = status; - const pendingNodeId = windowsNodeMxcPendingNodeId; - if (pendingNodeId && status.nodes.some((node) => node.id === pendingNodeId)) { - windowsNodeMxcSelectedNodeId.value = pendingNodeId; - } else { - windowsNodeMxcSelectedNodeId.value = status.selectedNodeId; - } } -async function loadWindowsNodeMxcStatus(allowAutomaticSelection = true) { +async function loadWindowsNodeMxcStatus() { windowsNodeMxcRefreshing.value = true; try { const status = await window.openclaw.windowsNodeMxc.getStatus(); updateWindowsNodeMxcStatus(status); - if (allowAutomaticSelection && !windowsNodeMxcApplying.value) { - const automaticNodeId = getAutomaticWindowsNodeMxcSelection(status); - if (automaticNodeId) { - windowsNodeMxcSelectedNodeId.value = automaticNodeId; - await applyWindowsNodeMxcSelection(automaticNodeId); - } - } } catch (error) { ElMessage.error(error instanceof Error ? error.message : String(error)); } finally { @@ -1134,41 +1123,10 @@ async function loadWindowsNodeMxcStatus(allowAutomaticSelection = true) { } } -async function applyWindowsNodeMxcSelection(nodeId: string) { - const status = windowsNodeMxcStatus.value; - if (!status?.desiredEnabled || status.selectedNodeId === nodeId) return; - - windowsNodeMxcPendingNodeId = nodeId; - windowsNodeMxcApplying.value = true; - try { - const updated = await window.openclaw.windowsNodeMxc.setEnabled({ - enabled: true, - nodeId, - }); - updateWindowsNodeMxcStatus(updated); - await loadSandboxStatus(); - } catch (error) { - windowsNodeMxcPendingNodeId = null; - ElMessage.error(error instanceof Error ? error.message : String(error)); - await loadWindowsNodeMxcStatus(false); - } finally { - windowsNodeMxcPendingNodeId = null; - windowsNodeMxcApplying.value = false; - } -} - -async function selectWindowsNodeMxc(nodeId: string) { - windowsNodeMxcSelectedNodeId.value = nodeId; - await applyWindowsNodeMxcSelection(nodeId); -} - async function toggleWindowsNodeMxc(enabled: boolean) { windowsNodeMxcApplying.value = true; try { - const status = await window.openclaw.windowsNodeMxc.setEnabled({ - enabled, - nodeId: windowsNodeMxcSelectedNodeId.value, - }); + const status = await window.openclaw.windowsNodeMxc.setEnabled({ enabled }); updateWindowsNodeMxcStatus(status); await loadSandboxStatus(); } catch (error) { @@ -1183,10 +1141,16 @@ async function runWindowsNodeMxcSmoke() { windowsNodeMxcSmokeRunning.value = true; try { const smoke = await window.openclaw.windowsNodeMxc.runSmoke(); - if (smoke.hostname.outcome === "passed" && smoke.powershell.outcome === "passed") { + if ( + smoke.deniedOutsideRoot.outcome === "passed" && + smoke.hostname.outcome === "passed" && + smoke.powershell.outcome === "passed" + ) { ElMessage.success(t("settings.windowsNodeMxcSmokePassed")); } else { - ElMessage.error(`${smoke.hostname.reason}; ${smoke.powershell.reason}`); + ElMessage.error( + `${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, + ); } await loadWindowsNodeMxcStatus(); } catch (error) { diff --git a/desktop/src/bundled-windows-node-host.test.ts b/desktop/src/bundled-windows-node-host.test.ts index 00e4a7a..c8c85e3 100644 --- a/desktop/src/bundled-windows-node-host.test.ts +++ b/desktop/src/bundled-windows-node-host.test.ts @@ -7,6 +7,7 @@ import { assertLoopbackGateway, createBundledWindowsNodeEnvironment, findBundledDevicePairRequest, + findBundledNodePairRequest, } from "./bundled-windows-node-host"; describe("bundled Windows node host", () => { @@ -95,6 +96,41 @@ describe("bundled Windows node host", () => { ).toBeNull(); }); + it("approves only the exact app-owned system-only node surface", () => { + const nodeId = "d".repeat(64); + const commands = ["system.run", "system.run.prepare", "system.which", "system.run.cwd-policy"]; + expect( + findBundledNodePairRequest( + { + pending: [ + { + requestId: "remote", + nodeId, + platform: "windows", + remoteIp: "192.168.1.10", + commands, + }, + { + requestId: "extra-command", + nodeId, + platform: "windows", + remoteIp: "127.0.0.1", + commands: [...commands, "device.info"], + }, + { + requestId: "owned", + nodeId: nodeId.toUpperCase(), + platform: "windows", + remoteIp: "::1", + commands, + }, + ], + }, + nodeId, + ), + ).toBe("owned"); + }); + it("binds approval responses to the exact pending request", () => { expect(() => assertApprovalResponseMatches("request-1", "request-1")).not.toThrow(); expect(() => assertApprovalResponseMatches("request-1", "request-2")).toThrow(/does not match/); diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index e825b2b..d344875 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -5,10 +5,11 @@ import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { createHash, randomBytes } from "node:crypto"; +import { WINDOWS_NODE_MXC_NODE_COMMANDS } from "./windows-node-mxc"; export const BUNDLED_WINDOWS_NODE_CWD_CONTRACT = "microclaw.windows-cwd.v1"; export const BUNDLED_WINDOWS_NODE_DISPLAY_NAME = "MicroClaw Bundled Windows Node"; -const BUNDLED_WINDOWS_NODE_REVISION = "fc9add75eda78daf548d80a55ffb64e63b159961"; +export const BUNDLED_WINDOWS_NODE_REVISION = "fc9add75eda78daf548d80a55ffb64e63b159961"; const MXC_WXC_EXEC_SHA256 = { x64: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", arm64: "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab", @@ -26,6 +27,8 @@ export interface BundledWindowsNodeHostStatus { wxcExecPath: string; runtimeVersion: "0.7.0"; cwdPolicyContract: typeof BUNDLED_WINDOWS_NODE_CWD_CONTRACT; + displayName: typeof BUNDLED_WINDOWS_NODE_DISPLAY_NAME; + helperRevision: typeof BUNDLED_WINDOWS_NODE_REVISION; pendingApproval: BundledApprovalRequest | null; lastError: string | null; nodeId: string | null; @@ -173,10 +176,9 @@ export class BundledWindowsNodeHost { } const nodeId = this.ensureIdentityNodeId(); let approved = false; - let connectedObservations = 0; - const deadline = Date.now() + 90_000; + const deadline = Date.now() + 5 * 60_000; - for (let attempt = 0; attempt < 30 && Date.now() < deadline; attempt++) { + while (Date.now() < deadline) { if (this.process !== expectedProcess || expectedProcess.exitCode !== null) { throw new Error("Bundled Windows node generation changed during pairing"); } @@ -189,23 +191,24 @@ export class BundledWindowsNodeHost { approved = true; } } + const nodePairing = await gateway.request("node.pair.list", {}); + const nodePairRequestId = findBundledNodePairRequest(nodePairing, nodeId); + if (nodePairRequestId) { + await gateway.request("node.pair.approve", { requestId: nodePairRequestId }); + approved = true; + } const nodes = await gateway.request("node.list", {}); const connected = findBundledNode(nodes, nodeId); if (connected?.connected) { - connectedObservations++; - if (connectedObservations >= 2) { - if (connected.displayName !== BUNDLED_WINDOWS_NODE_DISPLAY_NAME) { - await gateway.request("node.rename", { - nodeId, - displayName: BUNDLED_WINDOWS_NODE_DISPLAY_NAME, - }); - } - this.lastError = null; - return; + if (connected.displayName !== BUNDLED_WINDOWS_NODE_DISPLAY_NAME) { + await gateway.request("node.rename", { + nodeId, + displayName: BUNDLED_WINDOWS_NODE_DISPLAY_NAME, + }); } - } else { - connectedObservations = 0; + this.lastError = null; + return; } } catch (error) { if (!isGatewayRequestTimeout(error)) throw error; @@ -214,8 +217,8 @@ export class BundledWindowsNodeHost { } const message = approved - ? "App-owned Windows node did not reconnect after automatic pairing" - : "App-owned Windows node did not present an exact loopback pairing request"; + ? "App-owned Windows node did not reconnect after automatic pairing or reapproval" + : "App-owned Windows node did not remain connected with its existing pairing"; this.lastError = message; if (this.process === expectedProcess) this.stop(); throw new Error(message); @@ -267,6 +270,8 @@ export class BundledWindowsNodeHost { wxcExecPath: this.wxcExecPath, runtimeVersion: "0.7.0", cwdPolicyContract: BUNDLED_WINDOWS_NODE_CWD_CONTRACT, + displayName: BUNDLED_WINDOWS_NODE_DISPLAY_NAME, + helperRevision: BUNDLED_WINDOWS_NODE_REVISION, pendingApproval: this.pendingApproval?.request ?? null, lastError: this.lastError, nodeId: this.tryReadNodeId(), @@ -307,6 +312,31 @@ export function findBundledDevicePairRequest(payload: unknown, nodeId: string): return requestId || null; } +export function findBundledNodePairRequest(payload: unknown, nodeId: string): string | null { + const pending = getRecordArray(payload, "pending"); + const matches = pending.filter((entry) => { + const candidateId = stringField(entry, "nodeId") || stringField(entry, "deviceId"); + const platform = stringField(entry, "platform").toLowerCase(); + const remoteIp = stringField(entry, "remoteIp"); + return ( + candidateId.toLowerCase() === nodeId.toLowerCase() && + (!platform || platform === "windows" || platform === "win32") && + (!remoteIp || isLoopbackAddress(remoteIp)) && + hasExactBundledCommands(stringArrayField(entry, "commands")) + ); + }); + if (matches.length > 1) { + throw new Error( + "Multiple node reapproval requests claimed the app-owned Windows node identity", + ); + } + const requestId = matches.length === 1 ? stringField(matches[0], "requestId") : ""; + if (matches.length === 1 && !requestId) { + throw new Error("App-owned Windows node reapproval request is missing its request ID"); + } + return requestId || null; +} + export function assertApprovalResponseMatches(pendingRequestId: string, responseRequestId: string) { if (!pendingRequestId || pendingRequestId !== responseRequestId) { throw new Error("Bundled Windows node approval response does not match the pending request"); @@ -316,7 +346,7 @@ export function assertApprovalResponseMatches(pendingRequestId: string, response function findBundledNode( payload: unknown, nodeId: string, -): { connected: boolean; displayName: string } | null { +): { connected: boolean; displayName: string; commands: string[] } | null { const nodes = Array.isArray(payload) ? records(payload) : getRecordArray(payload, "nodes", "paired"); @@ -331,6 +361,7 @@ function findBundledNode( match.isConnected === true || stringField(match, "status").toLowerCase() === "connected", displayName: stringField(match, "displayName") || stringField(match, "name"), + commands: stringArrayField(match, "commands"), }; } @@ -355,6 +386,24 @@ function stringField(record: Record, key: string): string { return typeof value === "string" ? value.trim() : ""; } +function stringArrayField(record: Record, key: string): string[] { + const value = record[key]; + return Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + : []; +} + +function hasExactBundledCommands(commands: string[]): boolean { + return ( + commands.length === WINDOWS_NODE_MXC_NODE_COMMANDS.length && + [...commands] + .sort() + .every((command, index) => command === [...WINDOWS_NODE_MXC_NODE_COMMANDS].sort()[index]) + ); +} + function isLoopbackAddress(value: string): boolean { const normalized = value.toLowerCase().replace(/^\[|\]$/g, ""); return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 7e0312a..fdbaf14 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -124,6 +124,7 @@ import { import { inspectWindowsNodeMxc, runWindowsNodeMxcSmoke, + shouldStopManagedGatewayForWindowsNodeMxc, type StoredWindowsNodeMxcSmoke, type WindowsNodeMxcRuntimeStatus, } from "./windows-node-mxc-service"; @@ -1222,7 +1223,13 @@ function needsSetup(): boolean { type AutoConfigApiFormat = "openai-chat" | "openai-responses" | "anthropic"; type AutoConfigReasoningEffort = - "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "adaptive"; function normalizeEnvApiFormat(value: string | undefined): AutoConfigApiFormat { const normalized = (value || "").trim().toLowerCase(); @@ -2096,6 +2103,9 @@ function startHealthMonitor(): void { return; } if (isWindowsNodeMxcDesired()) { + // Pairing and warm-up intentionally run while the Gateway remains locked. + // Avoid competing control-plane requests until that startup transaction settles. + if (bundledWindowsNodeStartup) return; const inspectedProcess = gatewayProcess; const status = await getWindowsNodeMxcStatus(); if ( @@ -2106,8 +2116,7 @@ function startHealthMonitor(): void { ) { return; } - const effectiveToolsDrifted = gwClient?.connected && !status.effectiveToolsReady; - if (status.gatewayPolicyState !== "locked" || effectiveToolsDrifted) { + if (shouldStopManagedGatewayForWindowsNodeMxc(status)) { const message = `Windows Node + MXC readiness drifted; stopping managed Gateway: ${status.blockers.join("; ")}`; console.error(`[windows-node-mxc] ${message}`); mainWindow?.webContents.send("gateway:log", `[error] ${message}`); @@ -2408,33 +2417,23 @@ async function startGatewayInner(): Promise { let config = readConfig(); if (isWindowsNodeMxcDesired()) { const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); - if (settingsStore.get("windowsNodeMxcNodeId") !== nodeId) { - const pinned = applyWindowsNodeMxcGatewayPolicy( - config, - nodeId, - settingsStore.get("windowsNodeMxcToolBackups"), - "locked", - ); - config = pinned.config; - settingsStore.set("windowsNodeMxcToolBackups", pinned.backups); - settingsStore.set("windowsNodeMxcNodeId", nodeId); - writeConfigTextAtomically(JSON.stringify(config, null, 2)); - } const policyState = getWindowsNodeMxcGatewayPolicyState(config, nodeId); if (policyState === "active") { - const locked = applyWindowsNodeMxcGatewayPolicy( - config, - nodeId, - settingsStore.get("windowsNodeMxcToolBackups"), - "locked", - ); - config = locked.config; - writeConfigTextAtomically(JSON.stringify(config, null, 2)); settingsStore.delete("windowsNodeMxcSmoke"); - console.warn( - "[windows-node-mxc] Downgraded unsupported active policy to diagnostic-only locked policy", - ); + console.warn("[windows-node-mxc] Relocked active policy for fresh startup attestation"); } + const pinned = applyWindowsNodeMxcGatewayPolicy( + config, + nodeId, + settingsStore.get("windowsNodeMxcToolBackups"), + "locked", + ); + if (JSON.stringify(config) !== JSON.stringify(pinned.config)) { + writeConfigTextAtomically(JSON.stringify(pinned.config, null, 2)); + } + config = pinned.config; + settingsStore.set("windowsNodeMxcToolBackups", pinned.backups); + settingsStore.set("windowsNodeMxcNodeId", nodeId); const policy = validateWindowsNodeMxcGatewayPolicy(config, nodeId, "locked"); if (!policy.ready) { const message = `Windows Node + MXC Gateway policy drift: ${policy.blockers.join("; ")}`; @@ -3146,8 +3145,7 @@ function connectGatewayWs(): void { throw new Error("Managed Gateway generation changed before Windows node startup"); } }; - const startup = gateway - .warmUpAgent() + const startup = Promise.resolve() .then(() => { assertCurrentGatewayGeneration(); return bundledWindowsNodeHost.start(startOptions); @@ -3156,6 +3154,10 @@ function connectGatewayWs(): void { assertCurrentGatewayGeneration(); return bundledWindowsNodeHost.ensurePaired(gateway); }) + .then(() => { + assertCurrentGatewayGeneration(); + return gateway.warmUpAgent().then(() => undefined); + }) .catch((error) => { if (bundledWindowsNodeGeneration === hostGeneration) { bundledWindowsNodeHost.stop(); @@ -4997,6 +4999,9 @@ function registerIpcHandlers(): void { if (!status.gatewayPolicyReady) { throw new Error("Gateway exec-only node policy is not effective"); } + if (status.gatewayPolicyState !== "locked" || status.effectiveToolsState !== "verified") { + throw new Error("Locked Gateway effective tools must be verified before the MXC smoke"); + } if (status.durableApprovalsPresent) { throw new Error("Remove durable approvals before running the cwd-sensitive MXC mode proof"); } @@ -5007,6 +5012,13 @@ function registerIpcHandlers(): void { status.probe.tier, ); settingsStore.set("windowsNodeMxcSmoke", smoke); + if ( + smoke.deniedOutsideRoot.outcome !== "passed" || + smoke.hostname.outcome !== "passed" || + smoke.powershell.outcome !== "passed" + ) { + return smoke; + } return smoke; }); @@ -5030,10 +5042,7 @@ function registerIpcHandlers(): void { ) { throw new Error("Invalid Windows node approval decision"); } - bundledWindowsNodeHost.respond( - requestId, - decision as "deny" | "allow-once" | "allow-always", - ); + bundledWindowsNodeHost.respond(requestId, decision as "deny" | "allow-once" | "allow-always"); }, ); @@ -5068,7 +5077,8 @@ function registerIpcHandlers(): void { if (!mainWindow) return; mainWindow.setResizable(true); const savedBounds = store.get("windowBounds") as - { width?: number; height?: number; x?: number; y?: number } | undefined; + | { width?: number; height?: number; x?: number; y?: number } + | undefined; const width = savedBounds?.width || DEFAULT_WINDOW_WIDTH; const height = savedBounds?.height || DEFAULT_WINDOW_HEIGHT; mainWindow.setSize(width, height); diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index 6b72a4e..c3211e2 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -43,6 +43,7 @@ export interface StoredWindowsNodeMxcSmoke { checkedAt: string; hostname: MxcSmokeResult; powershell: MxcSmokeResult; + deniedOutsideRoot: MxcSmokeResult; } export interface WindowsNodeMxcRuntimeStatus { @@ -59,9 +60,13 @@ export interface WindowsNodeMxcRuntimeStatus { folders: WindowsNodeMxcFolder[]; nodes: WindowsNodeRecord[]; selectedNode: WindowsNodeRecord | null; + helperRevision?: string; + mxcRuntimeVersion?: string; + cwdPolicyContract?: string; gatewayPolicyState: WindowsNodeMxcGatewayPolicyState; gatewayPolicyReady: boolean; effectiveToolsReady: boolean; + effectiveToolsState: "unverified" | "verified" | "drift"; durableApprovalsPresent: boolean | null; probe: MxcProbeResult; smoke: StoredWindowsNodeMxcSmoke | null; @@ -192,21 +197,28 @@ export async function inspectWindowsNodeMxc( warnings.push(...probe.warnings); const gatewayPolicyState = getWindowsNodeMxcGatewayPolicyState(options.config, selectedNodeId); - const gatewayPolicyReady = gatewayPolicyState === "locked"; + const gatewayPolicyReady = gatewayPolicyState !== "drift"; if (gatewayPolicyState === "drift") { blockers.push("Gateway agent tool policy drifted from the diagnostic-only locked MXC policy"); - } else if (gatewayPolicyState === "active") { - blockers.push("Active Gateway execution is unsupported without atomic ingress quarantine"); - } else { + } else if (gatewayPolicyState === "locked") { blockers.push("Gateway agent execution remains diagnostic-only and locked"); + } else { + blockers.push( + "Active Gateway policy is unsupported until ingress can be quarantined atomically", + ); } let nodes: WindowsNodeRecord[] = []; let selectedNode: WindowsNodeRecord | null = null; let effectiveToolsReady = false; + let effectiveToolsState: WindowsNodeMxcRuntimeStatus["effectiveToolsState"] = "unverified"; let durableApprovalsPresent: boolean | null = null; if (!selectedNodeId) { - blockers.push("Select one paired local Windows node by stable node ID"); + blockers.push( + bundled + ? "The app-owned Windows node identity is unavailable" + : "Select one paired local Windows node by stable node ID", + ); } if (options.desiredEnabled && !options.managedGateway) { blockers.push("Windows Node + MXC mode requires a freshly started MicroClaw managed Gateway"); @@ -217,8 +229,13 @@ export async function inspectWindowsNodeMxc( try { const payload = await options.gateway.request("node.list", {}); nodes = extractNodeRecords(payload); + if (bundled?.nodeId) { + nodes = nodes.map((node) => + node.id === bundled.nodeId ? { ...node, displayName: bundled.displayName } : node, + ); + } selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; - blockers.push(...validateSelectedWindowsNode(selectedNode).blockers); + blockers.push(...validateSelectedWindowsNode(selectedNode, bundled?.nodeId ?? "").blockers); if (bundled && selectedNode?.connected) { try { const attestation = await options.gateway.request("node.invoke", { @@ -265,6 +282,7 @@ export async function inspectWindowsNodeMxc( }), ); effectiveToolsReady = effectiveChecks.every((check) => check.ready); + effectiveToolsState = effectiveToolsReady ? "verified" : "drift"; blockers.push(...effectiveChecks.flatMap((check) => check.blockers)); warnings.push(...effectiveChecks.flatMap((check) => check.warnings)); } catch (error) { @@ -302,12 +320,18 @@ export async function inspectWindowsNodeMxc( const smoke = options.storedSmoke && + isCurrentWindowsNodeMxcSmoke(options.storedSmoke) && options.storedSmoke.nodeId === selectedNodeId && options.storedSmoke.settingsFingerprint === settingsFingerprint && options.storedSmoke.probeTier === probe.tier ? options.storedSmoke : null; - if (!smoke || smoke.hostname.outcome !== "passed" || smoke.powershell.outcome !== "passed") { + if ( + !smoke || + smoke.hostname.outcome !== "passed" || + smoke.powershell.outcome !== "passed" || + smoke.deniedOutsideRoot.outcome !== "passed" + ) { blockers.push( "A current contained hostname.exe and PowerShell child-process smoke proof is required", ); @@ -316,11 +340,18 @@ export async function inspectWindowsNodeMxc( ? "Run the contained child-process check from MicroClaw Security settings and approve each command in MicroClaw." : "Run the contained child-process check from MicroClaw Security settings and approve each command once in Windows Companion.", ); + } else if (bundled && gatewayPolicyState === "locked") { + blockers.push( + "The pinned Gateway cannot quarantine channel and scheduled ingress during an active-policy restart", + ); + remediation.push( + "Keep diagnostic lock enabled until the Gateway provides atomic ingress quarantine or the bundled helper gains an independently attested activation gate.", + ); } return { desiredEnabled: options.desiredEnabled, - effectiveEnabled: options.desiredEnabled && blockers.length === 0, + effectiveEnabled: false, selectedNodeId, settingsPath, companionPath, @@ -334,9 +365,13 @@ export async function inspectWindowsNodeMxc( folders, nodes, selectedNode, + helperRevision: bundled?.helperRevision, + mxcRuntimeVersion: bundled?.runtimeVersion, + cwdPolicyContract: bundled?.cwdPolicyContract, gatewayPolicyState, gatewayPolicyReady, effectiveToolsReady, + effectiveToolsState, durableApprovalsPresent, probe, smoke, @@ -346,6 +381,28 @@ export async function inspectWindowsNodeMxc( }; } +export function shouldStopManagedGatewayForWindowsNodeMxc( + status: Pick< + WindowsNodeMxcRuntimeStatus, + "effectiveEnabled" | "effectiveToolsState" | "gatewayPolicyState" + >, +): boolean { + return status.gatewayPolicyState !== "locked" || status.effectiveToolsState === "drift"; +} + +export function isCurrentWindowsNodeMxcSmoke(value: unknown): value is StoredWindowsNodeMxcSmoke { + if (!isRecord(value)) return false; + return ( + typeof value.nodeId === "string" && + typeof value.settingsFingerprint === "string" && + typeof value.probeTier === "string" && + typeof value.checkedAt === "string" && + isMxcSmokeResult(value.hostname) && + isMxcSmokeResult(value.powershell) && + isMxcSmokeResult(value.deniedOutsideRoot) + ); +} + export function validateBundledCwdAttestation(value: unknown): { ready: boolean; blockers: string[]; @@ -389,6 +446,7 @@ export async function runWindowsNodeMxcSmoke( if (!settingsFingerprint) throw new Error("Strict Windows Companion settings are not loaded"); if (!probeTier) throw new Error("A supported MXC tier is required"); + const deniedOutsideRoot = await invokeDeniedCwdSmoke(gateway, nodeId); const hostname = await invokeSmoke( gateway, nodeId, @@ -397,7 +455,7 @@ export async function runWindowsNodeMxcSmoke( "/d", "/s", "/c", - `"C:\\Windows\\System32\\hostname.exe" && echo ${HOSTNAME_MARKER}`, + `C:\\Windows\\System32\\hostname.exe && echo ${HOSTNAME_MARKER}`, ], HOSTNAME_MARKER, ); @@ -427,6 +485,49 @@ export async function runWindowsNodeMxcSmoke( checkedAt: new Date().toISOString(), hostname, powershell, + deniedOutsideRoot, + }; +} + +async function invokeDeniedCwdSmoke( + gateway: WindowsNodeMxcGateway, + nodeId: string, +): Promise { + try { + await gateway.request("node.invoke", { + nodeId, + command: "system.run", + params: { + command: ["C:\\Windows\\System32\\hostname.exe"], + cwd: "C:\\Windows", + timeoutMs: 15_000, + }, + timeoutMs: 20_000, + idempotencyKey: randomUUID(), + }); + return { + outcome: "failed", + reason: "The bundled node unexpectedly accepted a protected CWD outside approved roots", + }; + } catch (error) { + return classifyDeniedCwdSmoke(messageOf(error)); + } +} + +export function classifyDeniedCwdSmoke(message: string): MxcSmokeResult { + const normalized = message.toLowerCase(); + if ( + normalized.includes("cwd-sensitive-root") || + normalized.includes("cwd-outside-approved-root") + ) { + return { + outcome: "passed", + reason: "The bundled node denied a protected CWD outside approved roots", + }; + } + return { + outcome: "failed", + reason: `Unapproved-CWD proof failed with an unexpected result: ${message}`, }; } @@ -578,6 +679,14 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function isMxcSmokeResult(value: unknown): value is MxcSmokeResult { + return isRecord(value) && typeof value.outcome === "string" && typeof value.reason === "string"; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + export const WINDOWS_NODE_MXC_DIAGNOSTIC_COMMANDS = [ ...WINDOWS_NODE_MXC_REQUIRED_COMMANDS, WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index ad984b2..85b254d 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -18,7 +18,12 @@ import { validateWindowsNodeMxcGatewayPolicy, validateWindowsNodeMxcSettings, } from "./windows-node-mxc"; -import { validateBundledCwdAttestation } from "./windows-node-mxc-service"; +import { + classifyDeniedCwdSmoke, + isCurrentWindowsNodeMxcSmoke, + shouldStopManagedGatewayForWindowsNodeMxc, + validateBundledCwdAttestation, +} from "./windows-node-mxc-service"; const strictSettings = { EnableNodeMode: true, @@ -56,6 +61,45 @@ describe("bundled Windows Node CWD attestation", () => { ).toEqual({ ready: true, blockers: [] }); }); + describe("Windows Node MXC diagnostic lifecycle", () => { + it("keeps an unverified locked diagnostic Gateway alive but stops actual drift", () => { + expect( + shouldStopManagedGatewayForWindowsNodeMxc({ + effectiveEnabled: false, + effectiveToolsState: "unverified", + gatewayPolicyState: "locked", + }), + ).toBe(false); + expect( + shouldStopManagedGatewayForWindowsNodeMxc({ + effectiveEnabled: false, + effectiveToolsState: "drift", + gatewayPolicyState: "locked", + }), + ).toBe(true); + expect( + shouldStopManagedGatewayForWindowsNodeMxc({ + effectiveEnabled: true, + effectiveToolsState: "verified", + gatewayPolicyState: "active", + }), + ).toBe(true); + }); + + it("rejects legacy smoke records without the denied-root proof", () => { + expect( + isCurrentWindowsNodeMxcSmoke({ + nodeId: "node-1", + settingsFingerprint: "settings", + probeTier: "appcontainer-dacl", + checkedAt: "2026-08-18T00:00:00.000Z", + hostname: { outcome: "passed", reason: "ok" }, + powershell: { outcome: "passed", reason: "ok" }, + }), + ).toBe(false); + }); + }); + it("rejects a command-name-only or incomplete attestation", () => { const result = validateBundledCwdAttestation({ contract: "microclaw.windows-cwd.v1", @@ -315,6 +359,16 @@ describe("MXC probe and smoke classification", () => { ).outcome, ).toBe("sandbox-unavailable"); }); + + it("requires an explicit typed denial for a protected or unapproved CWD", () => { + expect(classifyDeniedCwdSmoke("cwd-sensitive-root: protected")).toMatchObject({ + outcome: "passed", + }); + expect(classifyDeniedCwdSmoke("cwd-outside-approved-roots: denied")).toMatchObject({ + outcome: "passed", + }); + expect(classifyDeniedCwdSmoke("approval timed out")).toMatchObject({ outcome: "failed" }); + }); }); describe("selected node readiness", () => { @@ -334,7 +388,7 @@ describe("selected node readiness", () => { "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", ); - node!.commands.push("system.run.cwd-policy"); + node!.commands.push("system.which", "system.run.cwd-policy"); expect(validateSelectedWindowsNode(node).ready).toBe(true); }); }); diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index 8a53291..d46ccbb 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -2,8 +2,16 @@ import * as path from "node:path"; import { isDeepStrictEqual } from "node:util"; export const WINDOWS_NODE_MXC_MODE = "windows-node-mxc"; -export const WINDOWS_NODE_MXC_REQUIRED_COMMANDS = ["system.run", "system.run.prepare"] as const; +export const WINDOWS_NODE_MXC_REQUIRED_COMMANDS = [ + "system.run", + "system.run.prepare", + "system.which", +] as const; export const WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND = "system.run.cwd-policy"; +export const WINDOWS_NODE_MXC_NODE_COMMANDS = [ + ...WINDOWS_NODE_MXC_REQUIRED_COMMANDS, + WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, +] as const; export const WINDOWS_NODE_MXC_TOOL_ALLOWLIST = ["exec"] as const; export const WINDOWS_NODE_MXC_LOCKED_TOOL_ALLOWLIST = [ "__microclaw_windows_node_mxc_locked__", @@ -99,6 +107,7 @@ type AgentEntry = { }; type AgentToolsBackup = Record; +const GATEWAY_NODES_BACKUP_KEY = "$microclaw.gateway.nodes"; export interface WindowsNodeMxcPolicyApplication { config: Record; @@ -170,6 +179,23 @@ export function applyWindowsNodeMxcGatewayPolicy( agents.list = list; config.agents = agents; + const gateway = + config.gateway && typeof config.gateway === "object" && !Array.isArray(config.gateway) + ? (config.gateway as Record) + : {}; + if (!Object.hasOwn(backups, GATEWAY_NODES_BACKUP_KEY)) { + backups[GATEWAY_NODES_BACKUP_KEY] = Object.hasOwn(gateway, "nodes") + ? structuredClone(gateway.nodes) + : null; + } + const nodes = + gateway.nodes && typeof gateway.nodes === "object" && !Array.isArray(gateway.nodes) + ? (gateway.nodes as Record) + : {}; + nodes.allowCommands = [...WINDOWS_NODE_MXC_NODE_COMMANDS]; + nodes.denyCommands = []; + gateway.nodes = nodes; + config.gateway = gateway; return { config, backups, agentIds }; } @@ -190,6 +216,21 @@ export function restoreWindowsNodeMxcGatewayPolicy( if (previous === null) delete entry.tools; else entry.tools = structuredClone(previous); } + if (Object.hasOwn(backups, GATEWAY_NODES_BACKUP_KEY)) { + const gateway = + config.gateway && typeof config.gateway === "object" && !Array.isArray(config.gateway) + ? (config.gateway as Record) + : {}; + const previous = backups[GATEWAY_NODES_BACKUP_KEY]; + if (previous === null) { + delete gateway.nodes; + if (Object.keys(gateway).length === 0) delete config.gateway; + else config.gateway = gateway; + } else { + gateway.nodes = structuredClone(previous); + config.gateway = gateway; + } + } return config; } @@ -240,6 +281,22 @@ export function validateWindowsNodeMxcGatewayPolicy( if (globalDeny.some((value) => value === "exec" || value === "group:runtime")) { blockers.push("Global Gateway tool policy blocks node exec"); } + const gateway = + root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway) + ? (root.gateway as Record) + : null; + const nodes = + gateway?.nodes && typeof gateway.nodes === "object" && !Array.isArray(gateway.nodes) + ? (gateway.nodes as Record) + : null; + const allowCommands = Array.isArray(nodes?.allowCommands) ? nodes.allowCommands : []; + const denyCommands = Array.isArray(nodes?.denyCommands) ? nodes.denyCommands : []; + if (!isDeepStrictEqual(allowCommands, [...WINDOWS_NODE_MXC_NODE_COMMANDS])) { + blockers.push("Gateway node command allowlist drifted from the bundled system-only surface"); + } + if (!isDeepStrictEqual(denyCommands, [])) { + blockers.push("Gateway node command denylist conflicts with the bundled system-only surface"); + } return { ready: blockers.length === 0, blockers, warnings }; } @@ -340,7 +397,10 @@ export function normalizeWindowsNodeRecord(value: unknown): WindowsNodeRecord | }; } -export function validateSelectedWindowsNode(node: WindowsNodeRecord | null): WindowsNodeReadiness { +export function validateSelectedWindowsNode( + node: WindowsNodeRecord | null, + trustedBundledNodeId = "", +): WindowsNodeReadiness { const blockers: string[] = []; const warnings: string[] = []; if (!node) { @@ -351,7 +411,9 @@ export function validateSelectedWindowsNode(node: WindowsNodeRecord | null): Win if (node.platform !== "win32" && node.platform !== "windows") { blockers.push("The selected node is not a Windows node"); } - if (!node.remoteIp || !isLoopbackAddress(node.remoteIp)) { + const appOwnedLocalIdentity = + trustedBundledNodeId.length > 0 && node.id.toLowerCase() === trustedBundledNodeId.toLowerCase(); + if ((!node.remoteIp || !isLoopbackAddress(node.remoteIp)) && !appOwnedLocalIdentity) { blockers.push("The selected node is not proven to be local to this MicroClaw Gateway"); } for (const command of WINDOWS_NODE_MXC_REQUIRED_COMMANDS) { @@ -363,6 +425,11 @@ export function validateSelectedWindowsNode(node: WindowsNodeRecord | null): Win "Pinned Windows Node does not expose canonical approved-root cwd enforcement or cwd-bound durable approvals", ); } + const allowedCommands = new Set(WINDOWS_NODE_MXC_NODE_COMMANDS); + const unexpectedCommands = node.commands.filter((command) => !allowedCommands.has(command)); + if (unexpectedCommands.length > 0) { + blockers.push(`Selected node declares unexpected commands: ${unexpectedCommands.join(", ")}`); + } return { ready: blockers.length === 0, blockers, warnings }; } diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index 902577e..f91f357 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -31,7 +31,10 @@ MXC 0.7; it does not activate any UI capture or input capability. 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 -unrestricted, so the pre-attestation state uses a non-tool lock sentinel. +unrestricted, so the pre-attestation state uses a non-tool lock sentinel. The Gateway node-command +policy allowlists exactly the four bundled commands above. MicroClaw pairs/reapproves only the exact +app-owned device identity and does not expose manual node selection, even if another Windows +Companion is connected. ## CWD and durable approval contract @@ -74,26 +77,32 @@ and a runtime manifest is checked again by the app. Development resources, porta resources, and MSIX preparation all use the same staging script. `wxc-host-prep.exe` is packaged but never invoked automatically. -## Readiness and current activation boundary +## Readiness and activation transaction Readiness requires the exact CWD attestation payload, selected app-owned node identity, connected and paired node state, strict locked/effective Gateway tools, MXC tier, contained `hostname.exe`, contained PowerShell, and denied-access proof. `appcontainer-dacl` is accepted with a degraded containment warning. -Local validation proved that MicroClaw automatically approved only the pending pairing request -matching the app-owned device identity, `clientId=node-host`, and `role=node`. With the pinned -OpenClaw 2026.7.1-1 Gateway, the subsequent node authentication repeatedly remained at -`auth_validated` until the Gateway handshake deadline elapsed, even after helper startup was -sequenced behind Gateway agent warm-up. Readiness therefore stopped the Gateway and retained the -diagnostic lock; no command was exposed or executed through an unauthenticated node. - -The build remains fail-closed in diagnostic lock until those proofs pass. The pinned OpenClaw -Gateway does not provide an atomic way to quarantine channel/scheduled ingress while an active -exec-only configuration starts and is attested. Consequently, this branch still refuses the final -locked-to-active transition rather than expose a startup race. This is the remaining cross-project -activation blocker. The node handshake timeout is an additional pinned-Gateway integration blocker; -neither is bypassed by prompt instructions or optimistic post-start checks. +The pinned OpenClaw 2026.7.1-1 Gateway can block its event loop for more than a minute during +startup. Pairing therefore remains generation-bound and locked for up to five minutes while the +helper reconnects; timeout or a Gateway-generation change stops the helper. Transient inability to +query effective tools is recorded as unverified and does not kill an otherwise statically locked +Gateway; confirmed drift still stops it. + +Local non-elevated proof reached the exact app-owned node, validated +`microclaw.windows-cwd.v1`, verified an empty locked effective-tool surface, and proved that +`C:\Windows` is rejected as a protected/unapproved CWD. The attended one-time approval reached +official MXC for `cmd.exe`, but the contained child failed with `Access is denied` on this +`appcontainer-dacl` machine. PowerShell therefore did not run. The mode remains diagnostic-only and +host fallback remains impossible. + +The pinned Gateway also has no atomic way to quarantine channel and scheduled ingress while an +active exec-only policy starts and is attested. Starting active and checking afterward would expose +a pre-attestation execution window, so MicroClaw deliberately does not perform that transition. +Unlocking requires either an upstream atomic ingress-quarantine primitive or an independently +attested activation gate in the bundled helper. Until then, even a fully passing smoke remains +diagnostic-only. No elevation or host-wide `prepare-system-drive` / `prepare-null-device` action is performed. If a live DACL-tier smoke requires those changes, MicroClaw reports the requirement for explicit user diff --git a/windows-node-host.Tests/CwdPolicyTests.cs b/windows-node-host.Tests/CwdPolicyTests.cs index 8f2de42..ce9f6b2 100644 --- a/windows-node-host.Tests/CwdPolicyTests.cs +++ b/windows-node-host.Tests/CwdPolicyTests.cs @@ -181,6 +181,24 @@ public void AttestationRequiresEverySecurityProperty() Assert.True(attestation.HostFallbackAbsent); } + [Fact] + public void AttestationUsesTheVersionedCamelCaseWireContract() + { + using var document = System.Text.Json.JsonDocument.Parse( + System.Text.Json.JsonSerializer.Serialize(CwdPolicyAttestation.Current)); + var root = document.RootElement; + + Assert.Equal("microclaw.windows-cwd.v1", root.GetProperty("contract").GetString()); + Assert.True(root.GetProperty("approvedRootOnly").GetBoolean()); + Assert.True(root.GetProperty("canonicalFinalPath").GetBoolean()); + Assert.True(root.GetProperty("rejectsReparseComponents").GetBoolean()); + Assert.True(root.GetProperty("durableApprovalBindsCwd").GetBoolean()); + Assert.True(root.GetProperty("launchTimeRevalidation").GetBoolean()); + Assert.True(root.GetProperty("omittedCwdUsesIsolatedScratch").GetBoolean()); + Assert.True(root.GetProperty("hostFallbackAbsent").GetBoolean()); + Assert.False(root.TryGetProperty("Contract", out _)); + } + [Fact] public async Task LoadsElectronPolicyWithNamedFolderAccess() { diff --git a/windows-node-host/BundledSystemCapability.cs b/windows-node-host/BundledSystemCapability.cs index 7279999..6960f14 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.Serialization; using OpenClaw.Shared; using OpenClaw.Shared.Mxc; @@ -305,7 +306,12 @@ public void Dispose() } internal enum ApprovalDecision { Deny, AllowOnce, AllowAlways } -internal sealed record ApprovalRequest(string Id, string Executable, IReadOnlyList Arguments, string? Agent, string CanonicalCwd); +internal sealed record ApprovalRequest( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("executable")] string Executable, + [property: JsonPropertyName("arguments")] IReadOnlyList Arguments, + [property: JsonPropertyName("agent")] string? Agent, + [property: JsonPropertyName("canonicalCwd")] string CanonicalCwd); internal static class ApprovalPipeClient { @@ -335,6 +341,7 @@ public static async Task RequestAsync( private sealed class ApprovalResponse { + [JsonPropertyName("decision")] public string Decision { get; init; } = "deny"; } } diff --git a/windows-node-host/CwdPolicy.cs b/windows-node-host/CwdPolicy.cs index 6d078f5..06ddadd 100644 --- a/windows-node-host/CwdPolicy.cs +++ b/windows-node-host/CwdPolicy.cs @@ -12,14 +12,14 @@ public static class CwdPolicyContract } public sealed record CwdPolicyAttestation( - string Contract, - bool ApprovedRootOnly, - bool CanonicalFinalPath, - bool RejectsReparseComponents, - bool DurableApprovalBindsCwd, - bool LaunchTimeRevalidation, - bool OmittedCwdUsesIsolatedScratch, - bool HostFallbackAbsent) + [property: JsonPropertyName("contract")] string Contract, + [property: JsonPropertyName("approvedRootOnly")] bool ApprovedRootOnly, + [property: JsonPropertyName("canonicalFinalPath")] bool CanonicalFinalPath, + [property: JsonPropertyName("rejectsReparseComponents")] bool RejectsReparseComponents, + [property: JsonPropertyName("durableApprovalBindsCwd")] bool DurableApprovalBindsCwd, + [property: JsonPropertyName("launchTimeRevalidation")] bool LaunchTimeRevalidation, + [property: JsonPropertyName("omittedCwdUsesIsolatedScratch")] bool OmittedCwdUsesIsolatedScratch, + [property: JsonPropertyName("hostFallbackAbsent")] bool HostFallbackAbsent) { public static readonly CwdPolicyAttestation Current = new( CwdPolicyContract.Version, From 5e3aa9dcd7cd0f9f3ded332de95246a87e51a913 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:22:46 +0800 Subject: [PATCH 08/23] Vendor target-only MXC host preparation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/codeql.yml | 1 + .github/workflows/pr-security-check.yml | 13 +- NOTICE | 11 ++ desktop/electron-builder.yml | 4 + .../prepare-windows-node-resources.mjs | 99 ++++++++++-- .../update-signed-windows-node-manifest.mjs | 25 +++ desktop/src/bundled-windows-node-host.test.ts | 5 + desktop/src/bundled-windows-node-host.ts | 40 ++++- docs/experimental-windows-node-mxc.md | 31 +++- .../MicroClaw.MxcHostPrep.Tests.csproj | 16 ++ .../SystemDrivePreparationTests.cs | 140 +++++++++++++++++ third_party/mxc-host-prep-patch/LICENSE.md | 21 +++ .../mxc-host-prep-patch/PROVENANCE.json | 29 ++++ third_party/mxc-host-prep-patch/README.md | 20 +++ .../source/MicroClaw.MxcHostPrep.csproj | 27 ++++ .../mxc-host-prep-patch/source/Program.cs | 86 ++++++++++ .../source/SystemDrivePreparation.cs | 130 +++++++++++++++ .../source/TargetOnlyDacl.cs | 148 ++++++++++++++++++ .../mxc-host-prep-patch/source/app.manifest | 16 ++ 19 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 desktop/scripts/update-signed-windows-node-manifest.mjs create mode 100644 mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj create mode 100644 mxc-host-prep.Tests/SystemDrivePreparationTests.cs create mode 100644 third_party/mxc-host-prep-patch/LICENSE.md create mode 100644 third_party/mxc-host-prep-patch/PROVENANCE.json create mode 100644 third_party/mxc-host-prep-patch/README.md create mode 100644 third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj create mode 100644 third_party/mxc-host-prep-patch/source/Program.cs create mode 100644 third_party/mxc-host-prep-patch/source/SystemDrivePreparation.cs create mode 100644 third_party/mxc-host-prep-patch/source/TargetOnlyDacl.cs create mode 100644 third_party/mxc-host-prep-patch/source/app.manifest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 39b0c32..ebadcfe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -87,6 +87,7 @@ jobs: run: | dotnet build appcontainer/AppContainerLauncher.csproj dotnet build windows-node-host/MicroClaw.WindowsNodeHost.csproj -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false + dotnet build third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/pr-security-check.yml b/.github/workflows/pr-security-check.yml index e3611c6..a8aad95 100644 --- a/.github/workflows/pr-security-check.yml +++ b/.github/workflows/pr-security-check.yml @@ -55,7 +55,8 @@ jobs: - 'appcontainer/**' - 'windows-node-host/**' - 'windows-node-host.Tests/**' - - 'MicroClaw.sln' + - 'mxc-host-prep.Tests/**' + - 'third_party/mxc-host-prep-patch/**' python: - 'deployer/**' - 'requirements.txt' @@ -140,6 +141,7 @@ jobs: run: | dotnet restore appcontainer/AppContainerLauncher.csproj dotnet restore windows-node-host.Tests/MicroClaw.WindowsNodeHost.Tests.csproj -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false + dotnet restore mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false # Security: check for known vulnerable NuGet packages - name: Check for vulnerable NuGet packages @@ -157,6 +159,12 @@ jobs: Write-Host "::error::Vulnerable NuGet packages detected!" exit 1 } + $output = dotnet list mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj package --vulnerable --include-transitive --no-restore 2>&1 + Write-Host $output + if ($output -match "has the following vulnerable packages") { + Write-Host "::error::Vulnerable NuGet packages detected!" + exit 1 + } Write-Host "No vulnerable NuGet packages found." # Build @@ -166,6 +174,9 @@ jobs: - name: Test bundled Windows Node host run: dotnet test windows-node-host.Tests/MicroClaw.WindowsNodeHost.Tests.csproj -c Release --no-restore -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false + - name: Test MicroClaw MXC host-prep patch + run: dotnet test mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj -c Release --no-restore -p:ImportDirectoryBuildProps=false -p:ImportDirectoryBuildTargets=false + # ── Python ────────────────────────────────────────────────────── security-check-python: name: Python - Audit Dependencies diff --git a/NOTICE b/NOTICE index d185a55..a64ac4c 100644 --- a/NOTICE +++ b/NOTICE @@ -23,6 +23,17 @@ included below or can be found in the respective project repositories. - License: MIT License - The official architecture-specific wxc-exec.exe and sibling runtime files are packaged unchanged. +## MicroClaw MXC system-drive host-preparation patch +- Source: https://github.com/microsoft/mxc +- Issue: https://github.com/microsoft/mxc/issues/648 +- Pull request: https://github.com/microsoft/mxc/pull/649 +- Revision: 695c2b89c6142090a098ec4484f49aff8157f0b3 +- Copyright: Copyright (c) Microsoft Corporation +- License: MIT License +- Use: minimal C# port of target-only system-drive prepare/unprepare behavior +- Artifact: microclaw-mxc-host-prep.exe is built and optionally signed by MicroClaw; it is not an + official or Microsoft-signed MXC binary. Official MXC runtime files remain unchanged. + ## Electron - Source: https://github.com/electron/electron - License: MIT License diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index f2ab2a7..5ad9e74 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -5,6 +5,8 @@ copyright: Copyright © 2026 MicroClaw directories: output: release +afterSign: scripts/update-signed-windows-node-manifest.mjs + win: target: - target: nsis @@ -12,6 +14,8 @@ win: - x64 icon: assets/microclaw.ico signExts: + # These are unmodified third-party MXC binaries. MicroClaw-owned helpers, + # including microclaw-mxc-host-prep.exe, remain eligible for product signing. - "!mxc-diagnostic-console.exe" - "!winhttp-proxy-shim.exe" - "!wxc-exec.exe" diff --git a/desktop/scripts/prepare-windows-node-resources.mjs b/desktop/scripts/prepare-windows-node-resources.mjs index b4c2f14..bd1228a 100644 --- a/desktop/scripts/prepare-windows-node-resources.mjs +++ b/desktop/scripts/prepare-windows-node-resources.mjs @@ -31,7 +31,16 @@ const expectedWxcExecSha256 = { x64: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", arm64: "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab", }; +const expectedOfficialWxcHostPrepSha256 = { + x64: "531fb3cdb4b0c964908fd71b71d40961417afb399cbab72f92a25e95309a6416", + arm64: "3ef702332286a39153fc259310b5021e3de3c191751d7522684f6475f73af5ef", +}; +const expectedPatchedHostPrepSha256 = { + x64: "452332016eaf13e09fa28e542b03e3c0c992648d693ffc9781e1e1aa15a431c6", + arm64: "ee1d647f60a724fad500190ff93ca189fa481fbcecc49d9c352de4cf2654dd23", +}; const expectedWindowsNodeRevision = "fc9add75eda78daf548d80a55ffb64e63b159961"; +const expectedMxcHostPrepPatchRevision = "695c2b89c6142090a098ec4484f49aff8157f0b3"; const rid = ridByArch[targetArch]; if (process.platform !== "win32" || !rid) { @@ -52,10 +61,19 @@ if (packageJson.version !== "0.7.0") { const sourceMxcDir = path.join(packageDir, "bin", targetArch); const sourceWxcExec = path.join(sourceMxcDir, "wxc-exec.exe"); +const sourceOfficialWxcHostPrep = path.join(sourceMxcDir, "wxc-host-prep.exe"); const actualWxcExecHash = createHash("sha256").update(readFileSync(sourceWxcExec)).digest("hex"); if (actualWxcExecHash !== expectedWxcExecSha256[targetArch]) { throw new Error(`MXC 0.7.0 ${targetArch} wxc-exec.exe hash mismatch: ${actualWxcExecHash}`); } +const actualOfficialWxcHostPrepHash = createHash("sha256") + .update(readFileSync(sourceOfficialWxcHostPrep)) + .digest("hex"); +if (actualOfficialWxcHostPrepHash !== expectedOfficialWxcHostPrepSha256[targetArch]) { + throw new Error( + `Official MXC 0.7.0 ${targetArch} wxc-host-prep.exe hash mismatch: ${actualOfficialWxcHostPrepHash}`, + ); +} const upstreamDir = path.join(repositoryDir, "third_party", "openclaw-windows-node", "source"); const sharedProject = path.join(upstreamDir, "src", "OpenClaw.Shared", "OpenClaw.Shared.csproj"); @@ -79,6 +97,7 @@ if (upstreamRevision.stdout.trim() !== expectedWindowsNodeRevision) { const resourceRoot = path.join(desktopDir, "resources", "windows-node"); const hostOutput = path.join(resourceRoot, "host"); +const patchedHostPrepOutput = path.join(resourceRoot, "host-prep"); const mxcOutput = path.join(resourceRoot, "mxc"); rmSync(resourceRoot, { recursive: true, force: true }); mkdirSync(resourceRoot, { recursive: true }); @@ -106,8 +125,45 @@ const publish = spawnSync( if (publish.error) throw publish.error; if (publish.status !== 0) process.exit(publish.status ?? 1); +const hostPrepPublish = spawnSync( + "dotnet", + [ + "publish", + path.join( + repositoryDir, + "third_party", + "mxc-host-prep-patch", + "source", + "MicroClaw.MxcHostPrep.csproj", + ), + "--configuration", + "Release", + "--runtime", + rid, + "--self-contained", + "true", + "--output", + patchedHostPrepOutput, + "-p:PublishSingleFile=true", + "-p:DebugType=None", + "-p:ImportDirectoryBuildProps=false", + "-p:ImportDirectoryBuildTargets=false", + ], + { stdio: "inherit" }, +); +if (hostPrepPublish.error) throw hostPrepPublish.error; +if (hostPrepPublish.status !== 0) process.exit(hostPrepPublish.status ?? 1); + cpSync(sourceMxcDir, mxcOutput, { recursive: true }); copyFileSync(path.join(packageDir, "LICENSE.md"), path.join(resourceRoot, "MXC-LICENSE.md")); +copyFileSync( + path.join(repositoryDir, "third_party", "mxc-host-prep-patch", "LICENSE.md"), + path.join(resourceRoot, "MXC-HOST-PREP-PATCH-LICENSE.md"), +); +copyFileSync( + path.join(repositoryDir, "third_party", "mxc-host-prep-patch", "PROVENANCE.json"), + path.join(resourceRoot, "MXC-HOST-PREP-PATCH-PROVENANCE.json"), +); copyFileSync( path.join(repositoryDir, "third_party", "openclaw-windows-node", "LICENSE"), path.join(resourceRoot, "WINDOWS-NODE-LICENSE"), @@ -116,6 +172,32 @@ copyFileSync( path.join(repositoryDir, "third_party", "openclaw-windows-node", "PROVENANCE.json"), path.join(resourceRoot, "PROVENANCE.json"), ); + +const hostExe = path.join(hostOutput, "microclaw-windows-node-host.exe"); +if (!existsSync(hostExe)) throw new Error(`Host publish did not produce ${hostExe}`); +for (const entry of readdirSync(hostOutput)) { + if (entry !== path.basename(hostExe)) { + rmSync(path.join(hostOutput, entry), { recursive: true, force: true }); + } +} +const patchedHostPrepExe = path.join(patchedHostPrepOutput, "microclaw-mxc-host-prep.exe"); +if (!existsSync(patchedHostPrepExe)) { + throw new Error(`Patched host-prep publish did not produce ${patchedHostPrepExe}`); +} +for (const entry of readdirSync(patchedHostPrepOutput)) { + if (entry !== path.basename(patchedHostPrepExe)) { + rmSync(path.join(patchedHostPrepOutput, entry), { recursive: true, force: true }); + } +} +const actualPatchedHostPrepHash = createHash("sha256") + .update(readFileSync(patchedHostPrepExe)) + .digest("hex"); +if (actualPatchedHostPrepHash !== expectedPatchedHostPrepSha256[targetArch]) { + throw new Error( + `MicroClaw host-prep ${targetArch} build hash mismatch: ${actualPatchedHostPrepHash}`, + ); +} + writeFileSync( path.join(resourceRoot, "RUNTIME.json"), JSON.stringify( @@ -124,20 +206,19 @@ writeFileSync( runtimeIdentifier: rid, mxcVersion: packageJson.version, wxcExecSha256: actualWxcExecHash, + officialWxcHostPrepSha256: actualOfficialWxcHostPrepHash, windowsNodeRevision: expectedWindowsNodeRevision, + mxcHostPrepPatchRevision: expectedMxcHostPrepPatchRevision, + microclawHostPrepSha256: actualPatchedHostPrepHash, + microclawHostPrepOperations: ["prepare-system-drive", "unprepare-system-drive"], + microclawHostPrepOrigin: "microclaw-built", }, null, 2, ), ); - -const hostExe = path.join(hostOutput, "microclaw-windows-node-host.exe"); -if (!existsSync(hostExe)) throw new Error(`Host publish did not produce ${hostExe}`); -for (const entry of readdirSync(hostOutput)) { - if (entry !== path.basename(hostExe)) { - rmSync(path.join(hostOutput, entry), { recursive: true, force: true }); - } -} console.log( - `[windows-node] staged ${rid}, MXC ${packageJson.version}, wxc-exec sha256=${actualWxcExecHash}`, + `[windows-node] staged ${rid}, MXC ${packageJson.version}, ` + + `wxc-exec sha256=${actualWxcExecHash}, ` + + `microclaw-mxc-host-prep sha256=${actualPatchedHostPrepHash}`, ); diff --git a/desktop/scripts/update-signed-windows-node-manifest.mjs b/desktop/scripts/update-signed-windows-node-manifest.mjs new file mode 100644 index 0000000..305809c --- /dev/null +++ b/desktop/scripts/update-signed-windows-node-manifest.mjs @@ -0,0 +1,25 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +export function updateSignedWindowsNodeManifest(resourceRoot) { + const manifestPath = path.join(resourceRoot, "RUNTIME.json"); + const patchedHostPrepPath = path.join(resourceRoot, "host-prep", "microclaw-mxc-host-prep.exe"); + if (!existsSync(manifestPath) || !existsSync(patchedHostPrepPath)) { + throw new Error(`Packaged Windows Node resources are incomplete under ${resourceRoot}`); + } + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.microclawHostPrepSha256 = createHash("sha256") + .update(readFileSync(patchedHostPrepPath)) + .digest("hex"); + manifest.microclawHostPrepPackagedHashStage = "electron-builder-after-sign"; + + const temporaryPath = `${manifestPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`); + renameSync(temporaryPath, manifestPath); +} + +export default async function afterSign(context) { + updateSignedWindowsNodeManifest(path.join(context.appOutDir, "resources", "windows-node")); +} diff --git a/desktop/src/bundled-windows-node-host.test.ts b/desktop/src/bundled-windows-node-host.test.ts index c8c85e3..1f39afc 100644 --- a/desktop/src/bundled-windows-node-host.test.ts +++ b/desktop/src/bundled-windows-node-host.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { BUNDLED_WINDOWS_NODE_CWD_CONTRACT, + MXC_HOST_PREP_PATCH_REVISION, assertApprovalResponseMatches, assertLoopbackGateway, createBundledWindowsNodeEnvironment, @@ -15,6 +16,10 @@ describe("bundled Windows node host", () => { expect(BUNDLED_WINDOWS_NODE_CWD_CONTRACT).toBe("microclaw.windows-cwd.v1"); }); + it("pins the reviewed MXC target-only host-prep patch", () => { + expect(MXC_HOST_PREP_PATCH_REVISION).toBe("695c2b89c6142090a098ec4484f49aff8157f0b3"); + }); + it.each(["ws://127.0.0.1:18789", "ws://localhost:18789", "wss://[::1]:18789"])( "accepts loopback gateway %s", (url) => expect(() => assertLoopbackGateway(url)).not.toThrow(), diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index d344875..d0a7f1d 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -10,10 +10,15 @@ import { WINDOWS_NODE_MXC_NODE_COMMANDS } from "./windows-node-mxc"; export const BUNDLED_WINDOWS_NODE_CWD_CONTRACT = "microclaw.windows-cwd.v1"; export const BUNDLED_WINDOWS_NODE_DISPLAY_NAME = "MicroClaw Bundled Windows Node"; export const BUNDLED_WINDOWS_NODE_REVISION = "fc9add75eda78daf548d80a55ffb64e63b159961"; +export const MXC_HOST_PREP_PATCH_REVISION = "695c2b89c6142090a098ec4484f49aff8157f0b3"; const MXC_WXC_EXEC_SHA256 = { x64: "db0a3422be9e1b396cc1b2547c70ff16b27412438a31c10a45abf370cac86ae2", arm64: "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab", } as const; +const MXC_OFFICIAL_HOST_PREP_SHA256 = { + x64: "531fb3cdb4b0c964908fd71b71d40961417afb399cbab72f92a25e95309a6416", + arm64: "3ef702332286a39153fc259310b5021e3de3c191751d7522684f6475f73af5ef", +} as const; export interface BundledWindowsNodeFolder { path: string; @@ -440,12 +445,25 @@ function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { if (process.arch !== "x64" && process.arch !== "arm64") { throw new Error(`Bundled Windows node does not support architecture ${process.arch}`); } - const manifestPath = path.join(path.dirname(path.dirname(hostPath)), "RUNTIME.json"); + const resourceRoot = path.dirname(path.dirname(hostPath)); + const patchedHostPrepPath = path.join(resourceRoot, "host-prep", "microclaw-mxc-host-prep.exe"); + const officialHostPrepPath = path.join(path.dirname(wxcExecPath), "wxc-host-prep.exe"); + if (!fs.existsSync(patchedHostPrepPath)) { + throw new Error(`MicroClaw MXC host-prep patch is missing: ${patchedHostPrepPath}`); + } + if (!fs.existsSync(officialHostPrepPath)) { + throw new Error(`Official MXC host-prep runtime is missing: ${officialHostPrepPath}`); + } + const manifestPath = path.join(resourceRoot, "RUNTIME.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record; if ( manifest.architecture !== process.arch || manifest.mxcVersion !== "0.7.0" || - manifest.windowsNodeRevision !== BUNDLED_WINDOWS_NODE_REVISION + manifest.windowsNodeRevision !== BUNDLED_WINDOWS_NODE_REVISION || + manifest.mxcHostPrepPatchRevision !== MXC_HOST_PREP_PATCH_REVISION || + manifest.microclawHostPrepOrigin !== "microclaw-built" || + JSON.stringify(manifest.microclawHostPrepOperations) !== + JSON.stringify(["prepare-system-drive", "unprepare-system-drive"]) ) { throw new Error("Bundled Windows node runtime manifest does not match this application"); } @@ -453,6 +471,24 @@ function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { if (actualHash !== MXC_WXC_EXEC_SHA256[process.arch]) { throw new Error(`Pinned MXC runtime hash mismatch: ${actualHash}`); } + const officialHostPrepHash = createHash("sha256") + .update(fs.readFileSync(officialHostPrepPath)) + .digest("hex"); + if ( + officialHostPrepHash !== MXC_OFFICIAL_HOST_PREP_SHA256[process.arch] || + manifest.officialWxcHostPrepSha256 !== officialHostPrepHash + ) { + throw new Error(`Official MXC host-prep hash mismatch: ${officialHostPrepHash}`); + } + const patchedHostPrepHash = createHash("sha256") + .update(fs.readFileSync(patchedHostPrepPath)) + .digest("hex"); + if ( + typeof manifest.microclawHostPrepSha256 !== "string" || + manifest.microclawHostPrepSha256 !== patchedHostPrepHash + ) { + throw new Error(`MicroClaw MXC host-prep hash mismatch: ${patchedHostPrepHash}`); + } } async function writeJsonAtomically(filePath: string, value: unknown): Promise { diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index f91f357..46f4572 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -74,8 +74,27 @@ in `windows-node-host/`. architecture-specific `wxc-exec.exe` SHA-256 before copying the complete matching x64/ARM64 runtime layout unchanged. Package architecture is mandatory rather than inferred from the build machine, and a runtime manifest is checked again by the app. Development resources, portable/NSIS extra -resources, and MSIX preparation all use the same staging script. `wxc-host-prep.exe` is packaged but -never invoked automatically. +resources, and MSIX preparation all use the same staging script. + +MXC 0.7.0's official `wxc-host-prep.exe` is affected by +[`microsoft/mxc#648`](https://github.com/microsoft/mxc/issues/648): its +`prepare-system-drive` path writes the merged root DACL with `SetNamedSecurityInfoW`, which can +normalize existing descendant ACLs across the volume. MicroClaw therefore must not use that binary +for system-drive preparation. The official MXC directory remains byte-for-byte unchanged, including +that helper because its separate null-device operation is unaffected. + +For system-drive preparation only, MicroClaw builds `microclaw-mxc-host-prep.exe` from the minimal +managed-code port in `third_party/mxc-host-prep-patch/`. It is pinned to draft upstream PR +[`microsoft/mxc#649`](https://github.com/microsoft/mxc/pull/649), commit +`695c2b89c6142090a098ec4484f49aff8157f0b3`. Prepare and precise unprepare write only the named root +with `SetFileSecurityW`; runtime MXC grants still use the official implementation and behavior. +The derived helper supports no null-device or other MXC operation. It is a MicroClaw-built artifact, +not an official or Microsoft-signed MXC binary, and remains eligible for MicroClaw product signing. +Staging pins the .NET runtime, checks architecture-specific hashes for both official binaries and +the unsigned MicroClaw-built helper, and records origin, operations, revision, and hashes in +`RUNTIME.json`. Electron's `afterSign` hook replaces only the helper's manifest hash with the final +signed package hash; this avoids both excluding the MicroClaw-owned executable from product signing +and rejecting the package because Authenticode changed its bytes. ## Readiness and activation transaction @@ -104,6 +123,8 @@ Unlocking requires either an upstream atomic ingress-quarantine primitive or an attested activation gate in the bundled helper. Until then, even a fully passing smoke remains diagnostic-only. -No elevation or host-wide `prepare-system-drive` / `prepare-null-device` action is performed. If a -live DACL-tier smoke requires those changes, MicroClaw reports the requirement for explicit user -consent. +No elevation or host-wide `prepare-system-drive` / `prepare-null-device` action is performed by +build, staging, or validation. The next live step requires fresh explicit consent for two narrow +operations: use the MicroClaw-built helper for `prepare-system-drive --target C:\`, and use the +unchanged official helper only for `prepare-null-device --json`. MicroClaw, Electron, Gateway, the +node host, and any shell remain non-elevated. diff --git a/mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj b/mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj new file mode 100644 index 0000000..ceb204d --- /dev/null +++ b/mxc-host-prep.Tests/MicroClaw.MxcHostPrep.Tests.csproj @@ -0,0 +1,16 @@ + + + net10.0-windows + enable + enable + false + + + + + + + + + + diff --git a/mxc-host-prep.Tests/SystemDrivePreparationTests.cs b/mxc-host-prep.Tests/SystemDrivePreparationTests.cs new file mode 100644 index 0000000..396d2b5 --- /dev/null +++ b/mxc-host-prep.Tests/SystemDrivePreparationTests.cs @@ -0,0 +1,140 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using MicroClaw.MxcHostPrep; +using Xunit; + +namespace MicroClaw.MxcHostPrep.Tests; + +public sealed class SystemDrivePreparationTests : IDisposable +{ + private const int MetadataReadMask = 0x0012_0088; + private const int ConflictingReadMask = 0x0012_0089; + + private static readonly SecurityIdentifier AllApplicationPackages = + new("S-1-15-2-1"); + + private readonly string root = Path.Combine( + Path.GetTempPath(), + $"microclaw-mxc-host-prep-{Guid.NewGuid():N}"); + + public SystemDrivePreparationTests() + { + Directory.CreateDirectory(root); + } + + [Fact] + public void PrepareAndRevokeLeaveExistingChildDaclByteForByteUnchanged() + { + var child = Directory.CreateDirectory(Path.Combine(root, "existing-child")).FullName; + var childBefore = TargetOnlyDacl.ReadDaclBytes(child); + + TargetOnlyDacl.AddExplicitBasicAce( + root, + new SecurityIdentifier(WellKnownSidType.WorldSid, null), + ConflictingReadMask, + AceQualifier.AccessAllowed, + AceFlags.ContainerInherit | AceFlags.ObjectInherit); + Assert.Equal(childBefore, TargetOnlyDacl.ReadDaclBytes(child)); + + SystemDrivePreparation.ApplyAll(root); + Assert.Equal(childBefore, TargetOnlyDacl.ReadDaclBytes(child)); + + SystemDrivePreparation.RevokeAll(root); + Assert.Equal(childBefore, TargetOnlyDacl.ReadDaclBytes(child)); + } + + [Fact] + public void PrepareAndRevokeAreIdempotent() + { + SystemDrivePreparation.ApplyAll(root); + var afterFirstPrepare = TargetOnlyDacl.ReadDaclBytes(root); + + SystemDrivePreparation.ApplyAll(root); + Assert.Equal(afterFirstPrepare, TargetOnlyDacl.ReadDaclBytes(root)); + Assert.Single(TargetOnlyDacl.ScanExplicitBasicAces(root, AllApplicationPackages)); + + SystemDrivePreparation.RevokeAll(root); + var afterFirstRevoke = TargetOnlyDacl.ReadDaclBytes(root); + + SystemDrivePreparation.RevokeAll(root); + Assert.Equal(afterFirstRevoke, TargetOnlyDacl.ReadDaclBytes(root)); + Assert.Empty(TargetOnlyDacl.ScanExplicitBasicAces(root, AllApplicationPackages)); + } + + [Fact] + public void PrepareRejectsAndPreservesAConflictingAce() + { + TargetOnlyDacl.AddExplicitBasicAce( + root, + AllApplicationPackages, + ConflictingReadMask, + AceQualifier.AccessAllowed, + AceFlags.None); + var before = TargetOnlyDacl.ReadDaclBytes(root); + + var exception = Assert.Throws( + () => SystemDrivePreparation.ApplyAll(root)); + + Assert.Contains("0x00120089", exception.Message, StringComparison.Ordinal); + Assert.Equal(before, TargetOnlyDacl.ReadDaclBytes(root)); + } + + [Fact] + public void PreciseRevokePreservesNonMatchingAceForSameSid() + { + TargetOnlyDacl.AddExplicitBasicAce( + root, + AllApplicationPackages, + ConflictingReadMask, + AceQualifier.AccessAllowed, + AceFlags.None); + TargetOnlyDacl.AddExplicitBasicAce( + root, + AllApplicationPackages, + MetadataReadMask, + AceQualifier.AccessAllowed, + AceFlags.None); + + SystemDrivePreparation.RevokeAll(root); + + var remaining = Assert.Single( + TargetOnlyDacl.ScanExplicitBasicAces(root, AllApplicationPackages)); + Assert.Equal(ConflictingReadMask, remaining.AccessMask); + Assert.Equal(AceQualifier.AccessAllowed, remaining.Qualifier); + Assert.Equal(AceFlags.None, remaining.Flags); + } + + [Fact] + public void PrepareAndRevokePreserveCallbackAceForSameSid() + { + TargetOnlyDacl.AddExplicitBasicAce( + root, + AllApplicationPackages, + MetadataReadMask, + AceQualifier.AccessAllowed, + AceFlags.None, + isCallback: true); + var callbackOnly = TargetOnlyDacl.ReadDaclBytes(root); + + SystemDrivePreparation.ApplyAll(root); + Assert.Single(TargetOnlyDacl.ScanExplicitBasicAces(root, AllApplicationPackages)); + + SystemDrivePreparation.RevokeAll(root); + Assert.Equal(callbackOnly, TargetOnlyDacl.ReadDaclBytes(root)); + } + + [Fact] + public void ExplicitTargetMustAlreadyBeALiteralDriveRoot() + { + Assert.Throws( + () => SystemDrivePreparation.ResolveAndValidateTarget("C:")); + Assert.Equal( + Path.GetFullPath(@"C:\"), + SystemDrivePreparation.ResolveAndValidateTarget(@"C:\")); + } + + public void Dispose() + { + Directory.Delete(root, recursive: true); + } +} diff --git a/third_party/mxc-host-prep-patch/LICENSE.md b/third_party/mxc-host-prep-patch/LICENSE.md new file mode 100644 index 0000000..22aed37 --- /dev/null +++ b/third_party/mxc-host-prep-patch/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/mxc-host-prep-patch/PROVENANCE.json b/third_party/mxc-host-prep-patch/PROVENANCE.json new file mode 100644 index 0000000..c2dd3d9 --- /dev/null +++ b/third_party/mxc-host-prep-patch/PROVENANCE.json @@ -0,0 +1,29 @@ +{ + "name": "MicroClaw MXC system-drive host-preparation patch", + "upstreamRepository": "https://github.com/microsoft/mxc", + "upstreamIssue": "https://github.com/microsoft/mxc/issues/648", + "upstreamPullRequest": "https://github.com/microsoft/mxc/pull/649", + "upstreamRevision": "695c2b89c6142090a098ec4484f49aff8157f0b3", + "upstreamVersionBaseline": "0.7.0", + "license": "MIT", + "sourceForm": "Minimal C# port of the target-only system-drive prepare/unprepare behavior", + "upstreamSourceFiles": [ + "src/core/wxc_common/src/filesystem_dacl.rs", + "src/host/wxc_host_prep/src/system_drive/mod.rs" + ], + "includedOperations": [ + "prepare-system-drive", + "unprepare-system-drive" + ], + "excludedOperations": [ + "prepare-null-device", + "verify-null-device", + "dump-null-device" + ], + "buildRuntime": ".NET 10.0.10 self-contained single-file", + "unsignedArtifactSha256": { + "win-x64": "452332016eaf13e09fa28e542b03e3c0c992648d693ffc9781e1e1aa15a431c6", + "win-arm64": "ee1d647f60a724fad500190ff93ca189fa481fbcecc49d9c352de4cf2654dd23" + }, + "artifactOwnership": "Built and packaged by MicroClaw; not an official or Microsoft-signed MXC binary" +} diff --git a/third_party/mxc-host-prep-patch/README.md b/third_party/mxc-host-prep-patch/README.md new file mode 100644 index 0000000..91888cd --- /dev/null +++ b/third_party/mxc-host-prep-patch/README.md @@ -0,0 +1,20 @@ +# MicroClaw MXC host-preparation patch + +This directory is a minimal managed-code port of the target-only system-drive DACL fix from +[`microsoft/mxc#649`](https://github.com/microsoft/mxc/pull/649), pinned to commit +`695c2b89c6142090a098ec4484f49aff8157f0b3`. The patch addresses +[`microsoft/mxc#648`](https://github.com/microsoft/mxc/issues/648). + +Only `prepare-system-drive` and `unprepare-system-drive` are included. Both write the drive-root +DACL with `SetFileSecurityW`, which changes only the named root and does not normalize descendant +ACLs. Exact-ACE conflict detection, idempotence, and precise revoke semantics match the pinned +upstream change. + +The output is named `microclaw-mxc-host-prep.exe` and is a MicroClaw-built derivative. It is not +the official `wxc-host-prep.exe` and must not be represented as Microsoft-signed. The official +`@microsoft/mxc-sdk@0.7.0` runtime is still staged unchanged; its helper remains available only for +the separate null-device operation until an official release includes the target-only fix. + +Resource staging verifies the reproducible unsigned x64/ARM64 hashes recorded in `PROVENANCE.json`. +Electron packaging may then apply the MicroClaw product signature; its `afterSign` hook records the +final packaged hash in `RUNTIME.json` so runtime integrity checks use the signed artifact. diff --git a/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj b/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj new file mode 100644 index 0000000..332cdaf --- /dev/null +++ b/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj @@ -0,0 +1,27 @@ + + + Exe + net10.0-windows + win-x64;win-arm64 + 10.0.10 + enable + enable + true + true + true + microclaw-mxc-host-prep + MicroClaw.MxcHostPrep + app.manifest + 0.7.0 + 0.7.0-microclaw-pr649.695c2b89 + MicroClaw + MicroClaw MXC host-preparation patch + MicroClaw-built target-only system-drive preparation helper derived from microsoft/mxc PR 649 + + + false + + + + + diff --git a/third_party/mxc-host-prep-patch/source/Program.cs b/third_party/mxc-host-prep-patch/source/Program.cs new file mode 100644 index 0000000..931b361 --- /dev/null +++ b/third_party/mxc-host-prep-patch/source/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// Adapted for MicroClaw from microsoft/mxc PR #649 at 695c2b89. + +namespace MicroClaw.MxcHostPrep; + +internal static class Program +{ + private const string PrepareCommand = "prepare-system-drive"; + private const string UnprepareCommand = "unprepare-system-drive"; + + public static int Main(string[] args) + { + try + { + var (command, target) = ParseArguments(args); + var root = SystemDrivePreparation.ResolveAndValidateTarget(target); + Console.WriteLine( + $"{(command == PrepareCommand ? "Adding" : "Removing")} metadata-read ACEs on {root}"); + Console.WriteLine( + $" mask : 0x{SystemDrivePreparation.MetadataReadMask:X8} " + + "(FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE)"); + + if (command == PrepareCommand) + { + SystemDrivePreparation.ApplyAll(root); + } + else + { + SystemDrivePreparation.RevokeAll(root); + } + + Console.WriteLine("Done."); + return 0; + } + catch (ArgumentException exception) + { + Console.Error.WriteLine($"error: {exception.Message}"); + PrintUsage(); + return 1; + } + catch (HostPreparationException exception) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return exception.ExitCode; + } + catch (Exception exception) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 6; + } + } + + private static (string Command, string? Target) ParseArguments(string[] args) + { + if (args.Length is < 1 or > 3) + { + throw new ArgumentException("Expected one system-drive operation and an optional target."); + } + + var command = args[0]; + if (command is not PrepareCommand and not UnprepareCommand) + { + throw new ArgumentException($"Unsupported operation: {command}"); + } + + if (args.Length == 1) + { + return (command, null); + } + + if (args.Length != 3 || !string.Equals(args[1], "--target", StringComparison.Ordinal)) + { + throw new ArgumentException("The only supported option is --target ."); + } + + return (command, args[2]); + } + + private static void PrintUsage() + { + Console.Error.WriteLine( + "usage: microclaw-mxc-host-prep.exe " + + " [--target C:\\]"); + } +} diff --git a/third_party/mxc-host-prep-patch/source/SystemDrivePreparation.cs b/third_party/mxc-host-prep-patch/source/SystemDrivePreparation.cs new file mode 100644 index 0000000..a55926d --- /dev/null +++ b/third_party/mxc-host-prep-patch/source/SystemDrivePreparation.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// Adapted for MicroClaw from microsoft/mxc PR #649 at 695c2b89. + +using System.Security.AccessControl; +using System.Security.Principal; + +namespace MicroClaw.MxcHostPrep; + +internal class HostPreparationException(string message, int exitCode = 6) + : Exception(message) +{ + public int ExitCode { get; } = exitCode; +} + +internal sealed class ConflictingAceException( + string path, + SecurityIdentifier sid, + ExplicitAce existing) + : HostPreparationException( + $"{path} already has an explicit {existing.Qualifier} ACE for {sid.Value} with mask " + + $"0x{existing.AccessMask:X8} and flags 0x{(byte)existing.Flags:X2}; expected an " + + $"AccessAllowed ACE with mask 0x{SystemDrivePreparation.MetadataReadMask:X8} " + + "and flags 0x00. Refusing to merge or overwrite the existing ACE.", + 1); + +internal static class SystemDrivePreparation +{ + internal const int MetadataReadMask = 0x0012_0088; + + private static readonly (string Name, SecurityIdentifier Sid)[] Trustees = + [ + ( + "ALL APPLICATION PACKAGES", + new SecurityIdentifier("S-1-15-2-1") + ), + ( + "ALL RESTRICTED APPLICATION PACKAGES", + new SecurityIdentifier("S-1-15-2-2") + ), + ]; + + internal static string ResolveAndValidateTarget(string? explicitTarget) + { + var raw = explicitTarget ?? Environment.GetEnvironmentVariable("SystemDrive"); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new HostPreparationException( + "Could not resolve %SystemDrive%; specify --target .", + 1); + } + + var normalized = + explicitTarget is null && raw.Length == 2 && raw[1] == ':' ? $"{raw}\\" : raw; + if ( + normalized.Length != 3 + || !char.IsAsciiLetter(normalized[0]) + || normalized[1] != ':' + || normalized[2] != '\\' + ) + { + throw new HostPreparationException( + $"Target must be a literal local drive root such as C:\\; got {raw}.", + 1); + } + + var fullPath = Path.GetFullPath(normalized); + if (!Directory.Exists(fullPath)) + { + throw new HostPreparationException($"Target drive root does not exist: {fullPath}", 1); + } + + return fullPath; + } + + internal static void ApplyAll(string path) + { + var existingByTrustee = new List<(string Name, SecurityIdentifier Sid, int MatchCount)>(); + foreach (var (name, sid) in Trustees) + { + var prior = TargetOnlyDacl.ScanExplicitBasicAces(path, sid); + foreach (var existing in prior) + { + if ( + existing.AccessMask != MetadataReadMask + || existing.Qualifier != AceQualifier.AccessAllowed + || existing.Flags != AceFlags.None + ) + { + throw new ConflictingAceException(path, sid, existing); + } + } + + existingByTrustee.Add((name, sid, prior.Count)); + } + + // Preflight every trustee before the first write so a conflict on the + // second SID cannot leave the drive root partially prepared. + foreach (var (name, sid, matchCount) in existingByTrustee) + { + Console.WriteLine($" + {name,-45} ({sid.Value})"); + if (matchCount == 0) + { + TargetOnlyDacl.AddExplicitBasicAce( + path, + sid, + MetadataReadMask, + AceQualifier.AccessAllowed, + AceFlags.None); + } + } + } + + internal static void RevokeAll(string path) + { + foreach (var (name, sid) in Trustees) + { + var removed = TargetOnlyDacl.RemoveExactExplicitBasicAces( + path, + sid, + MetadataReadMask, + AceQualifier.AccessAllowed, + AceFlags.None); + Console.WriteLine( + removed > 0 + ? $" - {name,-45} ({sid.Value}) [{removed} ACE(s) removed]" + : $" . {name,-45} ({sid.Value}) [no matching ACE; nothing to do]"); + } + } +} diff --git a/third_party/mxc-host-prep-patch/source/TargetOnlyDacl.cs b/third_party/mxc-host-prep-patch/source/TargetOnlyDacl.cs new file mode 100644 index 0000000..f8d9127 --- /dev/null +++ b/third_party/mxc-host-prep-patch/source/TargetOnlyDacl.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// Adapted for MicroClaw from microsoft/mxc PR #649 at 695c2b89. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace MicroClaw.MxcHostPrep; + +internal readonly record struct ExplicitAce( + AceQualifier Qualifier, + int AccessMask, + AceFlags Flags); + +internal static class TargetOnlyDacl +{ + private const uint DaclSecurityInformation = 0x0000_0004; + + [DllImport("advapi32.dll", EntryPoint = "SetFileSecurityW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileSecurity( + [MarshalAs(UnmanagedType.LPWStr)] string path, + uint securityInformation, + byte[] securityDescriptor); + + internal static IReadOnlyList ScanExplicitBasicAces( + string path, + SecurityIdentifier sid) + { + var dacl = ReadDacl(path); + var matches = new List(); + for (var index = 0; index < dacl.Count; index++) + { + if ( + dacl[index] is CommonAce ace + && !ace.IsInherited + && !ace.IsCallback + && ace.SecurityIdentifier == sid + && ace.AceQualifier is AceQualifier.AccessAllowed or AceQualifier.AccessDenied + ) + { + matches.Add(new ExplicitAce(ace.AceQualifier, ace.AccessMask, ace.AceFlags)); + } + } + + return matches; + } + + internal static void AddExplicitBasicAce( + string path, + SecurityIdentifier sid, + int accessMask, + AceQualifier qualifier, + AceFlags flags, + bool isCallback = false) + { + if (qualifier is not AceQualifier.AccessAllowed and not AceQualifier.AccessDenied) + { + throw new ArgumentOutOfRangeException(nameof(qualifier)); + } + + var dacl = ReadDacl(path); + var insertionIndex = 0; + if (qualifier == AceQualifier.AccessAllowed) + { + while (insertionIndex < dacl.Count && !dacl[insertionIndex].IsInherited) + { + insertionIndex++; + } + } + + dacl.InsertAce( + insertionIndex, + new CommonAce(flags, qualifier, accessMask, sid, isCallback, opaque: null)); + WriteDacl(path, dacl); + } + + internal static int RemoveExactExplicitBasicAces( + string path, + SecurityIdentifier sid, + int accessMask, + AceQualifier qualifier, + AceFlags flags) + { + var dacl = ReadDacl(path); + var removed = 0; + for (var index = dacl.Count - 1; index >= 0; index--) + { + if ( + dacl[index] is CommonAce ace + && !ace.IsInherited + && !ace.IsCallback + && ace.SecurityIdentifier == sid + && ace.AceQualifier == qualifier + && ace.AccessMask == accessMask + && ace.AceFlags == flags + ) + { + dacl.RemoveAce(index); + removed++; + } + } + + if (removed > 0) + { + WriteDacl(path, dacl); + } + + return removed; + } + + internal static byte[] ReadDaclBytes(string path) + { + var dacl = ReadDacl(path); + var bytes = new byte[dacl.BinaryLength]; + dacl.GetBinaryForm(bytes, 0); + return bytes; + } + + private static RawAcl ReadDacl(string path) + { + var security = new DirectoryInfo(path).GetAccessControl(AccessControlSections.Access); + var descriptorBytes = security.GetSecurityDescriptorBinaryForm(); + var descriptor = new RawSecurityDescriptor(descriptorBytes, 0); + return descriptor.DiscretionaryAcl + ?? throw new HostPreparationException( + $"Refusing to modify {path} because it has a null DACL."); + } + + private static void WriteDacl(string path, RawAcl dacl) + { + var descriptor = new RawSecurityDescriptor( + ControlFlags.DiscretionaryAclPresent | ControlFlags.SelfRelative, + owner: null, + group: null, + systemAcl: null, + discretionaryAcl: dacl); + var bytes = new byte[descriptor.BinaryLength]; + descriptor.GetBinaryForm(bytes, 0); + if (!SetFileSecurity(path, DaclSecurityInformation, bytes)) + { + throw new HostPreparationException( + $"SetFileSecurityW failed for {path}: {new Win32Exception(Marshal.GetLastWin32Error()).Message}"); + } + } +} diff --git a/third_party/mxc-host-prep-patch/source/app.manifest b/third_party/mxc-host-prep-patch/source/app.manifest new file mode 100644 index 0000000..4083332 --- /dev/null +++ b/third_party/mxc-host-prep-patch/source/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + From bc0ea55a64b8484e3a86cedac448372598bf9cc3 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:55:07 +0800 Subject: [PATCH 09/23] Activate bundled Windows Node MXC sandbox Add generation-bound ingress release, signed helper activation leases, strict Gateway policy attestation, contained child-process readiness, and fail-closed drift handling for the experimental bundled Windows Node route. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/env.d.ts | 8 + desktop/renderer/src/browser-openclaw.ts | 8 + desktop/renderer/src/i18n/en-US.ts | 5 + desktop/renderer/src/i18n/zh-CN.ts | 4 + .../renderer/src/views/SettingsView.test.ts | 51 ++ desktop/renderer/src/views/SettingsView.vue | 52 +- .../prepare-windows-node-resources.mjs | 4 +- desktop/src/bundled-windows-node-host.ts | 148 ++++- desktop/src/config-write-policy.ts | 1 + desktop/src/gateway-client.test.ts | 35 +- desktop/src/gateway-client.ts | 10 +- desktop/src/main.ts | 517 ++++++++++++++---- desktop/src/preload.ts | 1 + desktop/src/windows-node-mxc-service.ts | 145 +++-- desktop/src/windows-node-mxc.test.ts | 145 ++++- desktop/src/windows-node-mxc.ts | 101 +++- docs/experimental-windows-node-mxc.md | 82 ++- .../mxc-host-prep-patch/PROVENANCE.json | 15 +- .../source/MicroClaw.MxcHostPrep.csproj | 1 + windows-node-host.Tests/CwdPolicyTests.cs | 174 ++++++ windows-node-host/ActivationLease.cs | 215 ++++++++ windows-node-host/BundledSystemCapability.cs | 11 +- windows-node-host/CwdPolicy.cs | 33 +- windows-node-host/Program.cs | 15 +- 24 files changed, 1579 insertions(+), 202 deletions(-) create mode 100644 windows-node-host/ActivationLease.cs diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 4c3b093..86ac369 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -373,6 +373,11 @@ interface OpenClawAPI { helperRevision?: string; mxcRuntimeVersion?: string; cwdPolicyContract?: string; + cwdAttestationReady: boolean; + activationLeaseContract?: string; + gatewayGeneration: string; + activationLeaseMode: "diagnostic" | "active" | null; + activationLeaseExpiresAt: string | null; gatewayPolicyState: "active" | "locked" | "drift"; gatewayPolicyReady: boolean; effectiveToolsReady: boolean; @@ -387,6 +392,7 @@ interface OpenClawAPI { reason: string | null; }; smoke: { + gatewayGeneration: string; nodeId: string; settingsFingerprint: string; probeTier: string; @@ -404,6 +410,7 @@ interface OpenClawAPI { nodeId?: string; }): Promise>>; runSmoke(): Promise<{ + gatewayGeneration: string; nodeId: string; settingsFingerprint: string; probeTier: string; @@ -412,6 +419,7 @@ interface OpenClawAPI { powershell: { outcome: string; reason: string }; deniedOutsideRoot: { outcome: string; reason: string }; }>; + activate(): Promise>>; respondApproval(params: { requestId: string; decision: "deny" | "allow-once" | "allow-always"; diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 6ea3e98..5759cd1 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -86,6 +86,11 @@ export function createBrowserOpenClawMock(): OpenClawAPI { helperRevision: undefined, mxcRuntimeVersion: undefined, cwdPolicyContract: undefined, + cwdAttestationReady: false, + activationLeaseContract: undefined, + gatewayGeneration: "", + activationLeaseMode: null, + activationLeaseExpiresAt: null, gatewayPolicyState: "drift" as const, gatewayPolicyReady: false, effectiveToolsReady: false, @@ -110,6 +115,9 @@ export function createBrowserOpenClawMock(): OpenClawAPI { runSmoke: async () => { throw new Error("Unavailable in browser development"); }, + activate: async () => { + throw new Error("Unavailable in browser development"); + }, respondApproval: 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 1c67db6..1833745 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -308,6 +308,11 @@ export default { "settings.windowsNodeMxcRefresh": "Refresh readiness", "settings.windowsNodeMxcRunSmoke": "Run contained smoke", "settings.windowsNodeMxcSmokePassed": "Contained hostname.exe and PowerShell checks passed", + "settings.windowsNodeMxcActivationLease": "Activation lease", + "settings.windowsNodeMxcGatewayGeneration": "Gateway generation", + "settings.windowsNodeMxcActivate": "Activate verified route", + "settings.windowsNodeMxcActivated": + "Windows Node + MXC route activated for this Gateway generation", "settings.windowsNodeMxcCompatibility": "Allow Windows UI APIs is required for PowerShell under MXC 0.7. It is a compatibility relaxation, not activation of UI capabilities. AppContainer and this mode are mutually exclusive.", "settings.windowsNodeMxcDegraded": diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 92e9ab8..8fc740a 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -291,6 +291,10 @@ export default { "settings.windowsNodeMxcRefresh": "刷新就绪状态", "settings.windowsNodeMxcRunSmoke": "运行受控冒烟测试", "settings.windowsNodeMxcSmokePassed": "受控 hostname.exe 和 PowerShell 检查已通过", + "settings.windowsNodeMxcActivationLease": "激活租约", + "settings.windowsNodeMxcGatewayGeneration": "网关代次", + "settings.windowsNodeMxcActivate": "激活已验证路由", + "settings.windowsNodeMxcActivated": "Windows Node + MXC 路由已为当前网关代次激活", "settings.windowsNodeMxcCompatibility": "MXC 0.7 下 PowerShell 需要“允许 Windows UI API”。这是兼容性放宽,并不启用屏幕、输入或其他 UI 能力。AppContainer 与此模式互斥。", "settings.windowsNodeMxcDegraded": diff --git a/desktop/renderer/src/views/SettingsView.test.ts b/desktop/renderer/src/views/SettingsView.test.ts index e2b7e90..32534b7 100644 --- a/desktop/renderer/src/views/SettingsView.test.ts +++ b/desktop/renderer/src/views/SettingsView.test.ts @@ -15,6 +15,7 @@ describe("SettingsView", () => { const exportGatewayLogs = vi.fn(); const getWindowsNodeMxcStatus = vi.fn(); const setWindowsNodeMxcEnabled = vi.fn(); + const activateWindowsNodeMxc = vi.fn(); const localNode = { id: "node-local", @@ -40,6 +41,11 @@ describe("SettingsView", () => { settingsFingerprint: "settings", nodes, selectedNode: nodes.find((node) => node.id === selectedNodeId) ?? null, + cwdAttestationReady: true, + activationLeaseContract: "microclaw.windows-activation.v1", + gatewayGeneration: "generation-1", + activationLeaseMode: null, + activationLeaseExpiresAt: null, gatewayPolicyState: "locked", gatewayPolicyReady: true, effectiveToolsReady: true, @@ -69,6 +75,7 @@ describe("SettingsView", () => { setWindowsNodeMxcEnabled .mockReset() .mockImplementation(async ({ nodeId }: { nodeId: string }) => status(nodeId)); + activateWindowsNodeMxc.mockReset(); window.openclaw = { config: { read: vi.fn().mockResolvedValue(null), @@ -90,6 +97,7 @@ describe("SettingsView", () => { getStatus: getWindowsNodeMxcStatus, setEnabled: setWindowsNodeMxcEnabled, runSmoke: vi.fn(), + activate: activateWindowsNodeMxc, }, sandbox: { getStatus: vi.fn().mockResolvedValue({ @@ -168,4 +176,47 @@ describe("SettingsView", () => { expect(wrapper.find(".mxc-card").find("el-select").exists()).toBe(false); expect(setWindowsNodeMxcEnabled).not.toHaveBeenCalled(); }); + + it("activates only after the locked-generation smoke proof is ready", async () => { + routeState.section = "security"; + const passed = { outcome: "passed" as const, reason: "ok" }; + const lockedReady = { + ...status("node-local"), + strictFallbackEffective: true, + allowWindowsUiEffective: true, + smoke: { + gatewayGeneration: "generation-1", + nodeId: "node-local", + settingsFingerprint: "settings", + probeTier: "appcontainer-dacl", + checkedAt: new Date().toISOString(), + hostname: passed, + powershell: passed, + deniedOutsideRoot: passed, + }, + }; + getWindowsNodeMxcStatus.mockResolvedValueOnce(lockedReady); + activateWindowsNodeMxc.mockResolvedValueOnce({ + ...lockedReady, + effectiveEnabled: true, + gatewayPolicyState: "active", + activationLeaseMode: "active", + }); + const wrapper = shallowMount(SettingsView, { + global: { + plugins: [createPinia()], + }, + }); + + await flushPromises(); + const activateButton = wrapper + .findAll("el-button") + .find((button) => button.text() === "Activate verified route"); + expect(activateButton?.attributes("disabled")).toBe("false"); + + await activateButton!.trigger("click"); + await flushPromises(); + + expect(activateWindowsNodeMxc).toHaveBeenCalledOnce(); + }); }); diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index 98adee0..a6e19e8 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -430,6 +430,24 @@ `${windowsNodeMxcStatus.mxcRuntimeVersion ?? "unavailable"} / ${windowsNodeMxcStatus.cwdPolicyContract ?? "unavailable"}` }}
+
+ {{ t("settings.windowsNodeMxcActivationLease") }} + + {{ + `${windowsNodeMxcStatus.activationLeaseMode ?? "none"} / ${ + windowsNodeMxcStatus.activationLeaseContract ?? "unavailable" + }` + }} + +
+
+ {{ t("settings.windowsNodeMxcGatewayGeneration") }} + {{ + windowsNodeMxcStatus.gatewayGeneration + ? windowsNodeMxcStatus.gatewayGeneration.slice(0, 12) + : "unavailable" + }} +
{{ t("settings.windowsNodeMxcStrictFallback") }} {{ @@ -484,12 +502,28 @@ {{ t("settings.windowsNodeMxcRunSmoke") }} + + {{ t("settings.windowsNodeMxcActivate") }} +
@@ -1104,6 +1138,7 @@ const windowsNodeMxcStatus = ref(null); const windowsNodeMxcApplying = ref(false); const windowsNodeMxcRefreshing = ref(false); const windowsNodeMxcSmokeRunning = ref(false); +const windowsNodeMxcActivating = ref(false); let windowsNodeMxcApprovalUnsubscribe: (() => void) | null = null; let activeWindowsNodeMxcApprovalId: string | null = null; @@ -1152,6 +1187,7 @@ async function runWindowsNodeMxcSmoke() { `${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, ); } + await loadWindowsNodeMxcStatus(); } catch (error) { ElMessage.error(error instanceof Error ? error.message : String(error)); @@ -1160,6 +1196,20 @@ async function runWindowsNodeMxcSmoke() { } } +async function activateWindowsNodeMxc() { + windowsNodeMxcActivating.value = true; + try { + const status = await window.openclaw.windowsNodeMxc.activate(); + updateWindowsNodeMxcStatus(status); + ElMessage.success(t("settings.windowsNodeMxcActivated")); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : String(error)); + await loadWindowsNodeMxcStatus(); + } finally { + windowsNodeMxcActivating.value = false; + } +} + async function loadSandboxStatus() { try { const status = await window.openclaw.sandbox.getStatus(); diff --git a/desktop/scripts/prepare-windows-node-resources.mjs b/desktop/scripts/prepare-windows-node-resources.mjs index bd1228a..d6a9d0d 100644 --- a/desktop/scripts/prepare-windows-node-resources.mjs +++ b/desktop/scripts/prepare-windows-node-resources.mjs @@ -36,8 +36,8 @@ const expectedOfficialWxcHostPrepSha256 = { arm64: "3ef702332286a39153fc259310b5021e3de3c191751d7522684f6475f73af5ef", }; const expectedPatchedHostPrepSha256 = { - x64: "452332016eaf13e09fa28e542b03e3c0c992648d693ffc9781e1e1aa15a431c6", - arm64: "ee1d647f60a724fad500190ff93ca189fa481fbcecc49d9c352de4cf2654dd23", + x64: "661cada5d4d32db4255e0c39d982ff7329d3a2bbc93acaf091eb5fed3c9ea205", + arm64: "f6d9c09311906c60746c9a9f8e463c92deaf06c86ca564b7cfa8a64fe6f6e5bd", }; const expectedWindowsNodeRevision = "fc9add75eda78daf548d80a55ffb64e63b159961"; const expectedMxcHostPrepPatchRevision = "695c2b89c6142090a098ec4484f49aff8157f0b3"; diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index d0a7f1d..f0b9c3d 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -4,10 +4,11 @@ import * as fs from "node:fs"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; -import { createHash, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes } from "node:crypto"; import { WINDOWS_NODE_MXC_NODE_COMMANDS } from "./windows-node-mxc"; export const BUNDLED_WINDOWS_NODE_CWD_CONTRACT = "microclaw.windows-cwd.v1"; +export const BUNDLED_WINDOWS_NODE_ACTIVATION_CONTRACT = "microclaw.windows-activation.v1"; export const BUNDLED_WINDOWS_NODE_DISPLAY_NAME = "MicroClaw Bundled Windows Node"; export const BUNDLED_WINDOWS_NODE_REVISION = "fc9add75eda78daf548d80a55ffb64e63b159961"; export const MXC_HOST_PREP_PATCH_REVISION = "695c2b89c6142090a098ec4484f49aff8157f0b3"; @@ -37,6 +38,17 @@ export interface BundledWindowsNodeHostStatus { pendingApproval: BundledApprovalRequest | null; lastError: string | null; nodeId: string | null; + gatewayGeneration: string | null; + policyFingerprint: string | null; + activationLease: BundledWindowsNodeActivationLease | null; +} + +export interface BundledWindowsNodeActivationLease { + contract: typeof BUNDLED_WINDOWS_NODE_ACTIVATION_CONTRACT; + mode: "diagnostic" | "active"; + gatewayGeneration: string; + policyFingerprint: string; + expiresAtUnixMs: number; } export interface BundledApprovalRequest { @@ -51,6 +63,7 @@ interface StartOptions { gatewayUrl: string; gatewayToken: string; gatewayProcessId: number; + gatewayGeneration: string; folders: BundledWindowsNodeFolder[]; onApproval: (approval: BundledApprovalRequest | null) => void; } @@ -69,6 +82,13 @@ export class BundledWindowsNodeHost { } | null = null; private lastError: string | null = null; private startOptions: StartOptions | null = null; + private activationLeaseSecret: Buffer | null = null; + private activationLeasePath: string | null = null; + private gatewayGeneration: string | null = null; + private policyFingerprint: string | null = null; + private activationLease: BundledWindowsNodeActivationLease | null = null; + private activationLeaseEpoch = 0; + private activationLeaseWriteQueue: Promise = Promise.resolve(); private readonly resourceRoot = resolveBundledWindowsNodeResourceRoot(); private readonly hostPath = path.join( this.resourceRoot, @@ -89,10 +109,11 @@ export class BundledWindowsNodeHost { const identityDirectory = path.join(stateRoot, "identity"); const approvalsPath = path.join(stateRoot, "approvals-v2.json"); const policyPath = path.join(stateRoot, "policy.json"); + const activationLeasePath = path.join(stateRoot, "activation-lease.json"); const scratchRoot = path.join(stateRoot, "scratch"); await fs.promises.mkdir(stateRoot, { recursive: true }); await fs.promises.mkdir(scratchRoot, { recursive: true }); - await writeJsonAtomically(policyPath, { + const policy = { approvedRoots: options.folders.map((folder) => ({ path: folder.path, access: folder.access === "rw" ? "ReadWrite" : "ReadOnly", @@ -104,7 +125,17 @@ export class BundledWindowsNodeHost { clipboard: "none", inputInjection: false, strictNoHostFallback: true, - }); + }; + const policyJson = JSON.stringify(policy, null, 2); + await writeTextAtomically(policyPath, policyJson); + await fs.promises.rm(activationLeasePath, { force: true }); + const activationLeaseSecret = randomBytes(32); + const policyFingerprint = createHash("sha256").update(policyJson, "utf8").digest("hex"); + this.activationLeaseSecret = activationLeaseSecret; + this.activationLeasePath = activationLeasePath; + this.gatewayGeneration = options.gatewayGeneration; + this.policyFingerprint = policyFingerprint; + this.activationLease = null; const pipeName = `microclaw-node-approval-${process.pid}-${randomBytes(12).toString("hex")}`; this.approvalServer = net.createServer((socket) => { @@ -169,10 +200,90 @@ export class BundledWindowsNodeHost { identityDirectory, approvalPipeName: pipeName, approvalsPath, + activationLeasePath, + activationLeaseSecret: activationLeaseSecret.toString("base64"), + gatewayGeneration: options.gatewayGeneration, + policyFingerprint, })}\n`, ); } + async setActivationLease( + mode: "diagnostic" | "active", + ttlMs: number, + ): Promise { + const expectedProcess = this.process; + if (!expectedProcess || expectedProcess.exitCode !== null) { + throw new Error("Bundled Windows node process is not running"); + } + const secret = this.activationLeaseSecret; + const leasePath = this.activationLeasePath; + const gatewayGeneration = this.gatewayGeneration; + const policyFingerprint = this.policyFingerprint; + if (!secret || !leasePath || !gatewayGeneration || !policyFingerprint) { + throw new Error("Bundled Windows node activation state is unavailable"); + } + if (!Number.isFinite(ttlMs) || ttlMs < 5_000 || ttlMs > 5 * 60_000) { + throw new Error("Activation lease lifetime must be between 5 seconds and 5 minutes"); + } + const lease: BundledWindowsNodeActivationLease = { + contract: BUNDLED_WINDOWS_NODE_ACTIVATION_CONTRACT, + mode, + gatewayGeneration, + policyFingerprint, + expiresAtUnixMs: Date.now() + Math.floor(ttlMs), + }; + const signature = createHmac("sha256", secret) + .update( + [ + lease.contract, + lease.mode, + lease.gatewayGeneration, + lease.policyFingerprint, + String(lease.expiresAtUnixMs), + ].join("\n"), + "utf8", + ) + .digest("hex"); + const epoch = this.activationLeaseEpoch; + const update = this.activationLeaseWriteQueue.then(async () => { + if ( + epoch !== this.activationLeaseEpoch || + this.process !== expectedProcess || + expectedProcess.exitCode !== null || + this.activationLeasePath !== leasePath + ) { + throw new Error("Bundled Windows node activation generation changed before lease update"); + } + await writeJsonAtomically(leasePath, { ...lease, signature }); + if ( + epoch !== this.activationLeaseEpoch || + this.process !== expectedProcess || + expectedProcess.exitCode !== null || + this.activationLeasePath !== leasePath + ) { + await fs.promises.rm(leasePath, { force: true }); + throw new Error("Bundled Windows node activation generation changed during lease update"); + } + this.activationLease = lease; + }); + this.activationLeaseWriteQueue = update.catch(() => undefined); + await update; + return lease; + } + + revokeActivationLease(): void { + this.activationLeaseEpoch += 1; + if (this.activationLeasePath) { + try { + fs.rmSync(this.activationLeasePath, { force: true }); + } catch { + // An expired or generation-mismatched lease still fails closed in the host. + } + } + this.activationLease = null; + } + async ensurePaired(gateway: BundledWindowsNodeGateway): Promise { if (!this.startOptions) throw new Error("Bundled Windows node has not been started"); const expectedProcess = this.process; @@ -254,6 +365,7 @@ export class BundledWindowsNodeHost { } stop(): void { + this.revokeActivationLease(); if (this.pendingApproval) { clearTimeout(this.pendingApproval.timer); this.pendingApproval.socket.end('{"decision":"deny"}\n'); @@ -265,6 +377,10 @@ export class BundledWindowsNodeHost { if (this.process?.pid && this.process.exitCode === null) this.process.kill(); this.process = null; this.startOptions = null; + this.activationLeaseSecret = null; + this.activationLeasePath = null; + this.gatewayGeneration = null; + this.policyFingerprint = null; } status(): BundledWindowsNodeHostStatus { @@ -280,6 +396,12 @@ export class BundledWindowsNodeHost { pendingApproval: this.pendingApproval?.request ?? null, lastError: this.lastError, nodeId: this.tryReadNodeId(), + gatewayGeneration: this.gatewayGeneration, + policyFingerprint: this.policyFingerprint, + activationLease: + this.activationLease && this.activationLease.expiresAtUnixMs > Date.now() + ? { ...this.activationLease } + : null, }; } @@ -492,12 +614,20 @@ function assertBundledArtifacts(hostPath: string, wxcExecPath: string): void { } async function writeJsonAtomically(filePath: string, value: unknown): Promise { - const temporary = `${filePath}.${process.pid}.tmp`; - await fs.promises.writeFile(temporary, JSON.stringify(value, null, 2), { - encoding: "utf8", - mode: 0o600, - }); - await fs.promises.rename(temporary, filePath); + await writeTextAtomically(filePath, JSON.stringify(value, null, 2)); +} + +async function writeTextAtomically(filePath: string, contents: string): Promise { + const temporary = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`; + try { + await fs.promises.writeFile(temporary, contents, { + encoding: "utf8", + mode: 0o600, + }); + await fs.promises.rename(temporary, filePath); + } finally { + await fs.promises.rm(temporary, { force: true }); + } } export function createBundledWindowsNodeEnvironment( diff --git a/desktop/src/config-write-policy.ts b/desktop/src/config-write-policy.ts index 0713bba..a8fe8dd 100644 --- a/desktop/src/config-write-policy.ts +++ b/desktop/src/config-write-policy.ts @@ -13,6 +13,7 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([ "commands", "permissions", "hooks", + "cron", "mcp", "channels", "telemetry", diff --git a/desktop/src/gateway-client.test.ts b/desktop/src/gateway-client.test.ts index f95291d..40eb208 100644 --- a/desktop/src/gateway-client.test.ts +++ b/desktop/src/gateway-client.test.ts @@ -113,6 +113,7 @@ describe("isAgentWarmupEvent", () => { describe("GatewayClient.sendChat", () => { function createClient() { const client = Object.create(GatewayClient.prototype) as GatewayClient; + Object.assign(client, { opts: { port: 18789, token: "" } }); const request = vi.spyOn(client, "request").mockResolvedValue(undefined); return { client, request }; } @@ -156,6 +157,21 @@ describe("GatewayClient.sendChat", () => { }), ); }); + + it("does not issue or queue chat.send when the ingress gate rejects", async () => { + const { client, request } = createClient(); + const beforeChatSend = vi.fn().mockRejectedValue(new Error("MXC ingress is locked")); + Object.assign(client, { opts: { port: 18789, token: "", beforeChatSend } }); + + await expect(client.sendChat("session-123", "blocked")).rejects.toThrow( + "MXC ingress is locked", + ); + expect(request).not.toHaveBeenCalled(); + + beforeChatSend.mockResolvedValue(undefined); + await Promise.resolve(); + expect(request).not.toHaveBeenCalled(); + }); }); describe("GatewayClient.deleteSession", () => { @@ -211,11 +227,11 @@ describe("GatewayClient.deleteSession", () => { handleMessage(raw: string): void; }; - function createClient() { + function createClient(beforeChatSend?: () => Promise) { const onEvent = vi.fn(); const client = Object.create(GatewayClient.prototype) as GatewayClient; Object.assign(client, { - opts: { port: 18789, token: "", onEvent }, + opts: { port: 18789, token: "", onEvent, beforeChatSend }, _mainSessionKey: "agent:main:main", agentWarmupPromise: null, agentWarmupWaiter: null, @@ -301,6 +317,21 @@ describe("GatewayClient.deleteSession", () => { }); }); + it("does not issue or replay a warm-up turn while ingress is locked", async () => { + const beforeChatSend = vi.fn().mockRejectedValue(new Error("MXC ingress is locked")); + const { client, request } = createClient(beforeChatSend); + + await expect(client.warmUpAgent(30_000)).resolves.toEqual({ + outcome: "error", + transcriptDeleted: false, + }); + expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything()); + + beforeChatSend.mockResolvedValue(undefined); + await Promise.resolve(); + expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything()); + }); + it("fails open at the timeout and starts cleanup", async () => { vi.useFakeTimers(); const { client, request } = createClient(); diff --git a/desktop/src/gateway-client.ts b/desktop/src/gateway-client.ts index a9cbc93..ccb48ac 100644 --- a/desktop/src/gateway-client.ts +++ b/desktop/src/gateway-client.ts @@ -181,6 +181,7 @@ type Pending = { export type GatewayClientOptions = { port: number; token: string; + beforeChatSend?: () => Promise; onEvent?: (evt: GatewayEventFrame) => void; onConnected?: (hello: Record) => void; onDisconnected?: (reason: string) => void; @@ -279,8 +280,13 @@ export class GatewayClient { } /** Send a chat message (server maintains history). */ - sendChat(sessionKey: string, message: string, attachments?: ChatAttachment[]): Promise { - return this.request("chat.send", { + async sendChat( + sessionKey: string, + message: string, + attachments?: ChatAttachment[], + ): Promise { + await this.opts?.beforeChatSend?.(); + return await this.request("chat.send", { sessionKey, message, deliver: false, diff --git a/desktop/src/main.ts b/desktop/src/main.ts index fdbaf14..99df210 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import * as http from "http"; import * as net from "net"; import * as os from "os"; +import { randomUUID } from "crypto"; import { ChildProcess, execFileSync, spawn } from "child_process"; import { GatewayClient, type ChatEventPayload } from "./gateway-client"; import { @@ -116,8 +117,10 @@ import { } from "./skill-config"; import { WINDOWS_NODE_MXC_MODE, + WINDOWS_NODE_MXC_NODE_COMMANDS, applyWindowsNodeMxcGatewayPolicy, getWindowsNodeMxcGatewayPolicyState, + isWindowsNodeMxcIngressReleased, restoreWindowsNodeMxcGatewayPolicy, validateWindowsNodeMxcGatewayPolicy, } from "./windows-node-mxc"; @@ -277,6 +280,12 @@ let gatewayToken = ""; const bundledWindowsNodeHost = new BundledWindowsNodeHost(); let bundledWindowsNodeStartup: Promise | null = null; let bundledWindowsNodeGeneration = 0; +let gatewayGenerationId = ""; +let windowsNodeMxcIngressGeneration: string | null = null; +let windowsNodeMxcActivationInProgress = false; +let windowsNodeMxcFailClosedPromise: Promise | null = null; +// This gate covers MicroClaw-owned ingress. Independently configured upstream Gateway ingress is +// outside this experimental mode's accepted boundary and must not share the app-owned Gateway. let gatewayStatus: GatewayStatus = "stopped"; const appStartupStartedAt = Date.now(); @@ -762,6 +771,7 @@ async function getWindowsNodeMxcStatus(): Promise { config: readConfig(), gateway: gwClient, managedGateway: gatewaySpawnedByUs && isManagedGatewayProcessAlive(), + gatewayGeneration: gatewayGenerationId, storedSmoke: settingsStore.get("windowsNodeMxcSmoke") ?? null, bundledHost: bundledWindowsNodeHost.status(), bundledFolders, @@ -783,14 +793,250 @@ function getBundledWindowsNodeFolders(): BundledWindowsNodeFolder[] { async function requireEffectiveWindowsNodeMxc(): Promise { if (!isWindowsNodeMxcDesired()) return; - const status = await getWindowsNodeMxcStatus(); + if ( + !isWindowsNodeMxcIngressReleased( + true, + gatewayGenerationId, + windowsNodeMxcIngressGeneration, + windowsNodeMxcActivationInProgress, + ) + ) { + throw new Error("Windows Node + MXC ingress is locked pending current-generation attestation"); + } + let status: WindowsNodeMxcRuntimeStatus; + try { + status = await getWindowsNodeMxcStatus(); + } catch (error) { + await failClosedWindowsNodeMxc( + `Runtime attestation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } if (!status.effectiveEnabled) { + const detail = status.blockers.join("; ") || "readiness proof failed"; + await failClosedWindowsNodeMxc(`Runtime attestation drifted: ${detail}`); + throw new Error(`Windows Node + MXC execution is blocked: ${detail}`); + } + try { + await bundledWindowsNodeHost.setActivationLease("active", 120_000); + } catch (error) { + await failClosedWindowsNodeMxc( + `Activation lease renewal failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +} + +function assertWindowsNodeMxcConfigurationMutable(): void { + if (isWindowsNodeMxcDesired()) { throw new Error( - `Windows Node + MXC execution is blocked: ${status.blockers.join("; ") || "readiness proof failed"}`, + "This Gateway configuration is locked while Windows Node + MXC mode is enabled", ); } } +function assertWindowsNodeMxcBaseReady( + status: WindowsNodeMxcRuntimeStatus, + expectedPolicy: "locked" | "active", + requireSmoke: boolean, +): void { + const failures: string[] = []; + if (!status.desiredEnabled) failures.push("Windows Node + MXC mode is not enabled"); + if (!gatewayGenerationId || status.gatewayGeneration !== gatewayGenerationId) { + failures.push("Gateway generation changed during attestation"); + } + if (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive() || !gwClient?.connected) { + failures.push("MicroClaw's managed Gateway is not connected"); + } + if (!status.selectedNode?.connected || !status.selectedNode.paired) { + failures.push("The app-owned Windows node is not paired and connected"); + } + if ( + status.selectedNode?.id !== status.selectedNodeId || + [...(status.selectedNode?.commands ?? [])].sort().join("\n") !== + [...WINDOWS_NODE_MXC_NODE_COMMANDS].sort().join("\n") + ) { + failures.push("The exact bundled node identity or command declaration changed"); + } + if (!status.cwdAttestationReady) failures.push("The exact CWD/activation attestation failed"); + if (!status.strictFallbackEffective) + 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) { + 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"); + if (!status.gatewayPolicyReady || status.gatewayPolicyState !== expectedPolicy) { + failures.push(`Gateway policy is not exactly ${expectedPolicy}`); + } + if (!status.effectiveToolsReady || status.effectiveToolsState !== "verified") { + failures.push("The effective Gateway tool inventory was not verified"); + } + if (status.durableApprovalsPresent === null) { + failures.push("Bundled-node durable approval state could not be attested"); + } + if ( + requireSmoke && + (!status.smoke || + status.smoke.gatewayGeneration !== gatewayGenerationId || + status.smoke.deniedOutsideRoot.outcome !== "passed" || + status.smoke.hostname.outcome !== "passed" || + status.smoke.powershell.outcome !== "passed") + ) { + failures.push("Current-generation denied-CWD, hostname, and PowerShell smokes must pass"); + } + if (failures.length > 0) { + throw new Error(`Windows Node + MXC activation blocked: ${failures.join("; ")}`); + } +} + +async function waitForWindowsNodeMxcBaseReady( + expectedGeneration: string, + expectedPolicy: "locked" | "active", + requireSmoke: boolean, + timeoutMs = 120_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown = new Error("Windows Node + MXC readiness has not completed"); + while (Date.now() < deadline) { + if (gatewayGenerationId !== expectedGeneration) { + throw new Error("Managed Gateway generation changed during activation readiness"); + } + if (!gwClient?.connected || !isManagedGatewayProcessAlive()) { + lastError = new Error("MicroClaw's managed Gateway is not connected"); + } else { + try { + const status = await getWindowsNodeMxcStatus(); + assertWindowsNodeMxcBaseReady(status, expectedPolicy, requireSmoke); + return status; + } catch (error) { + lastError = error; + } + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + throw new Error( + `Timed out waiting for current-generation MXC readiness: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +async function waitForBundledWindowsNodeGeneration( + expectedGeneration: string, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (gatewayGenerationId !== expectedGeneration) { + throw new Error("Managed Gateway generation changed during bundled-node startup"); + } + if (!gwClient?.connected || !isManagedGatewayProcessAlive()) { + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + const startup = bundledWindowsNodeStartup; + if (startup) await startup; + if (bundledWindowsNodeHost.status().processRunning) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("Timed out waiting for the bundled Windows node to pair with this Gateway"); +} + +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"); + } + const expectedGeneration = gatewayGenerationId; + await bundledWindowsNodeHost.setActivationLease("diagnostic", 120_000); + try { + const smoke = await runWindowsNodeMxcSmoke( + gwClient, + expectedGeneration, + status.selectedNodeId, + status.settingsFingerprint, + status.probe.tier, + ); + if (gatewayGenerationId !== expectedGeneration || !gwClient.connected) { + throw new Error("Managed Gateway generation changed during contained smokes"); + } + settingsStore.set("windowsNodeMxcSmoke", smoke); + return smoke; + } finally { + bundledWindowsNodeHost.revokeActivationLease(); + } +} + +async function activateWindowsNodeMxc(): Promise { + if (windowsNodeMxcActivationInProgress) { + throw new Error("Windows Node + MXC activation is already in progress"); + } + const lockedStatus = await getWindowsNodeMxcStatus(); + assertWindowsNodeMxcBaseReady(lockedStatus, "locked", true); + 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; + const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); + const activePolicy = applyWindowsNodeMxcGatewayPolicy( + config, + nodeId, + settingsStore.get("windowsNodeMxcToolBackups"), + "active", + ); + + windowsNodeMxcActivationInProgress = true; + windowsNodeMxcIngressGeneration = null; + mainWindow?.webContents.send("gateway:ws-disconnected", "MXC activation attestation"); + try { + await stopGatewayForSecurityTransition(configuredPort); + settingsStore.delete("windowsNodeMxcSmoke"); + settingsStore.set("windowsNodeMxcToolBackups", activePolicy.backups); + writeConfigTextAtomically(JSON.stringify(activePolicy.config, null, 2)); + await startGateway(); + const activeGeneration = gatewayGenerationId; + await waitForBundledWindowsNodeGeneration(activeGeneration); + + const activeStatus = await waitForWindowsNodeMxcBaseReady(activeGeneration, "active", false); + const smoke = await runCurrentWindowsNodeMxcSmoke(activeStatus); + if ( + smoke.deniedOutsideRoot.outcome !== "passed" || + smoke.hostname.outcome !== "passed" || + smoke.powershell.outcome !== "passed" + ) { + throw new Error( + `Active-generation contained smokes failed: ${smoke.deniedOutsideRoot.reason}; ${smoke.hostname.reason}; ${smoke.powershell.reason}`, + ); + } + + await bundledWindowsNodeHost.setActivationLease("active", 120_000); + const finalStatus = await getWindowsNodeMxcStatus(); + if (!finalStatus.effectiveEnabled) { + throw new Error( + `Final activation attestation failed: ${finalStatus.blockers.join("; ") || "unknown failure"}`, + ); + } + if (gatewayGenerationId !== activeGeneration) { + throw new Error("Managed Gateway generation changed before ingress release"); + } + windowsNodeMxcIngressGeneration = activeGeneration; + windowsNodeMxcActivationInProgress = false; + mainWindow?.webContents.send("gateway:ws-connected", gwClient?.mainSessionKey || null); + return finalStatus; + } catch (error) { + windowsNodeMxcActivationInProgress = false; + await failClosedWindowsNodeMxc( + `Activation transaction failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } +} + function resolveGitHubCopilotAuthRuntime(): GitHubCopilotAuthRuntime { const entryPath = resolveOpenClawEntry(); const stateDir = getOpenClawStateDir(); @@ -1223,13 +1469,7 @@ function needsSetup(): boolean { type AutoConfigApiFormat = "openai-chat" | "openai-responses" | "anthropic"; type AutoConfigReasoningEffort = - | "off" - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "adaptive"; + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive"; function normalizeEnvApiFormat(value: string | undefined): AutoConfigApiFormat { const normalized = (value || "").trim().toLowerCase(); @@ -1360,6 +1600,7 @@ function autoConfigureModelFromEnv(config: any, env: Record): vo */ function ensurePluginsAllow(): void { try { + if (isWindowsNodeMxcDesired()) return; const config = readConfig(); if (!config) return; let changed = ensureSelectedModelProviderPlugins(config); @@ -2009,6 +2250,7 @@ async function stopGatewayForSecurityTransition(port: number): Promise { `Port ${port} is owned by an external process; stop it before changing security mode`, ); } + if (!managedPid) { gwClient?.stop(); setGatewayStatus("stopped"); @@ -2039,6 +2281,49 @@ async function stopGatewayForSecurityTransition(port: number): Promise { ); } +async function failClosedWindowsNodeMxc(reason: string): Promise { + if (!isWindowsNodeMxcDesired()) return; + if (windowsNodeMxcFailClosedPromise) return windowsNodeMxcFailClosedPromise; + const failClosed = (async () => { + windowsNodeMxcIngressGeneration = null; + windowsNodeMxcActivationInProgress = false; + bundledWindowsNodeHost.revokeActivationLease(); + settingsStore.delete("windowsNodeMxcSmoke"); + const message = `Windows Node + MXC relocked: ${reason}`; + console.error(`[windows-node-mxc] ${message}`); + mainWindow?.webContents.send("gateway:log", `[error] ${message}`); + mainWindow?.webContents.send("gateway:ws-disconnected", message); + try { + const config = readConfig(); + if (config && typeof config === "object" && !Array.isArray(config)) { + const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); + const locked = applyWindowsNodeMxcGatewayPolicy( + config, + nodeId, + settingsStore.get("windowsNodeMxcToolBackups"), + "locked", + ); + settingsStore.set("windowsNodeMxcToolBackups", locked.backups); + writeConfigTextAtomically(JSON.stringify(locked.config, null, 2)); + } + } finally { + try { + await stopGatewayForSecurityTransition(gatewayPort || DEFAULT_PORT); + } finally { + setGatewayStatus("failed"); + } + } + })(); + windowsNodeMxcFailClosedPromise = failClosed; + try { + await failClosed; + } finally { + if (windowsNodeMxcFailClosedPromise === failClosed) { + windowsNodeMxcFailClosedPromise = null; + } + } +} + async function restartManagedGateway(reason: string): Promise { if (gatewayRestartPromise) return gatewayRestartPromise; const restart = (async () => { @@ -2103,29 +2388,40 @@ function startHealthMonitor(): void { return; } if (isWindowsNodeMxcDesired()) { - // Pairing and warm-up intentionally run while the Gateway remains locked. - // Avoid competing control-plane requests until that startup transaction settles. - if (bundledWindowsNodeStartup) return; + // Pairing and attended smokes are part of one activation transaction. + // Avoid a competing health attestation until that transaction settles. + if (windowsNodeMxcActivationInProgress || bundledWindowsNodeStartup) return; const inspectedProcess = gatewayProcess; - const status = await getWindowsNodeMxcStatus(); - if ( - !isWindowsNodeMxcDesired() || - !inspectedProcess || - gatewayProcess !== inspectedProcess || - !isManagedGatewayProcessAlive() - ) { - return; - } - if (shouldStopManagedGatewayForWindowsNodeMxc(status)) { - const message = `Windows Node + MXC readiness drifted; stopping managed Gateway: ${status.blockers.join("; ")}`; - console.error(`[windows-node-mxc] ${message}`); - mainWindow?.webContents.send("gateway:log", `[error] ${message}`); - stopBundledWindowsNodeHost(); - gwClient?.stop(); - gatewayProcess = null; - gatewaySpawnedByUs = false; - if (inspectedProcess.pid) terminateGatewayProcessTree(inspectedProcess.pid); - setGatewayStatus("failed"); + try { + const status = await getWindowsNodeMxcStatus(); + if ( + !isWindowsNodeMxcDesired() || + !inspectedProcess || + gatewayProcess !== inspectedProcess || + !isManagedGatewayProcessAlive() + ) { + return; + } + if (shouldStopManagedGatewayForWindowsNodeMxc(status)) { + await failClosedWindowsNodeMxc( + `Readiness drifted: ${status.blockers.join("; ") || "unknown readiness failure"}`, + ); + return; + } + if (status.effectiveEnabled) { + try { + await bundledWindowsNodeHost.setActivationLease("active", 120_000); + } catch (error) { + await failClosedWindowsNodeMxc( + `Activation lease renewal failed: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + } catch (error) { + await failClosedWindowsNodeMxc( + `Health attestation failed: ${error instanceof Error ? error.message : String(error)}`, + ); return; } } @@ -2418,7 +2714,8 @@ async function startGatewayInner(): Promise { if (isWindowsNodeMxcDesired()) { const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); const policyState = getWindowsNodeMxcGatewayPolicyState(config, nodeId); - if (policyState === "active") { + const requestedState = windowsNodeMxcActivationInProgress ? "active" : "locked"; + if (policyState === "active" && requestedState === "locked") { settingsStore.delete("windowsNodeMxcSmoke"); console.warn("[windows-node-mxc] Relocked active policy for fresh startup attestation"); } @@ -2426,7 +2723,7 @@ async function startGatewayInner(): Promise { config, nodeId, settingsStore.get("windowsNodeMxcToolBackups"), - "locked", + requestedState, ); if (JSON.stringify(config) !== JSON.stringify(pinned.config)) { writeConfigTextAtomically(JSON.stringify(pinned.config, null, 2)); @@ -2434,7 +2731,7 @@ async function startGatewayInner(): Promise { config = pinned.config; settingsStore.set("windowsNodeMxcToolBackups", pinned.backups); settingsStore.set("windowsNodeMxcNodeId", nodeId); - const policy = validateWindowsNodeMxcGatewayPolicy(config, nodeId, "locked"); + const policy = validateWindowsNodeMxcGatewayPolicy(config, nodeId, requestedState); if (!policy.ready) { const message = `Windows Node + MXC Gateway policy drift: ${policy.blockers.join("; ")}`; console.error(`[windows-node-mxc] ${message}`); @@ -2467,7 +2764,7 @@ async function startGatewayInner(): Promise { const policy = validateWindowsNodeMxcGatewayPolicy( preparedPersonas.config, settingsStore.get("windowsNodeMxcNodeId"), - "locked", + windowsNodeMxcActivationInProgress ? "active" : "locked", ); if (!policy.ready) { const message = `Windows Node + MXC blocked an unprotected agent roster change: ${policy.blockers.join("; ")}`; @@ -2707,6 +3004,8 @@ async function startGatewayInner(): Promise { }); gatewayProcess = child; + gatewayGenerationId = randomUUID(); + windowsNodeMxcIngressGeneration = null; gatewaySpawnedByUs = true; // Only allow post-spawn restart on the very first gateway launch. // Do NOT reset on subsequent restarts — it causes an infinite restart loop. @@ -2743,6 +3042,8 @@ async function startGatewayInner(): Promise { safeSendLog("gateway:log", `[error] Gateway spawn failed: ${err.message}`); safeSendLog("gateway:log", `[info] node=${nodePath} entry=${entryPath}`); if (gatewayProcess === child) { + windowsNodeMxcIngressGeneration = null; + bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); gatewayProcess = null; gatewaySpawnedByUs = false; @@ -2754,6 +3055,8 @@ async function startGatewayInner(): Promise { console.log(`[gateway] exited: code=${code} signal=${signal}`); safeSendLog("gateway:log", `Gateway exited: code=${code} signal=${signal}`); if (gatewayProcess === child) { + windowsNodeMxcIngressGeneration = null; + bundledWindowsNodeHost.revokeActivationLease(); stopBundledWindowsNodeHost(); gatewayProcess = null; gatewaySpawnedByUs = false; @@ -3111,6 +3414,7 @@ function connectGatewayWs(): void { gwClient = new GatewayClient({ port: gatewayPort, token: gatewayToken, + beforeChatSend: requireEffectiveWindowsNodeMxc, onConnected: () => { console.log("[gateway-ws] connected"); wsAuthRestartInProgress = false; @@ -3126,6 +3430,7 @@ function connectGatewayWs(): void { gatewayUrl: `ws://127.0.0.1:${gatewayPort}`, gatewayToken, gatewayProcessId: gatewayGeneration.pid, + gatewayGeneration: gatewayGenerationId, folders: getBundledWindowsNodeFolders(), onApproval: (approval: BundledApprovalRequest | null) => mainWindow?.webContents.send("windows-node-mxc:approval-request", approval), @@ -3154,10 +3459,6 @@ function connectGatewayWs(): void { assertCurrentGatewayGeneration(); return bundledWindowsNodeHost.ensurePaired(gateway); }) - .then(() => { - assertCurrentGatewayGeneration(); - return gateway.warmUpAgent().then(() => undefined); - }) .catch((error) => { if (bundledWindowsNodeGeneration === hostGeneration) { bundledWindowsNodeHost.stop(); @@ -3203,13 +3504,32 @@ function connectGatewayWs(): void { return; // skip ws-connected notification — will fire on reconnect } - mainWindow?.webContents.send("gateway:ws-connected", mainSessionKey || null); + if ( + isWindowsNodeMxcIngressReleased( + isWindowsNodeMxcDesired(), + gatewayGenerationId, + windowsNodeMxcIngressGeneration, + windowsNodeMxcActivationInProgress, + ) + ) { + mainWindow?.webContents.send("gateway:ws-connected", mainSessionKey || null); + } signalPostInstallReady(); }, onDisconnected: (reason) => { console.log(`[gateway-ws] disconnected: ${reason}`); + const activeGeneration = + isWindowsNodeMxcDesired() && + windowsNodeMxcIngressGeneration !== null && + windowsNodeMxcIngressGeneration === gatewayGenerationId; + windowsNodeMxcIngressGeneration = null; stopBundledWindowsNodeHost(); mainWindow?.webContents.send("gateway:ws-disconnected", reason); + if (activeGeneration && !windowsNodeMxcActivationInProgress && !gatewayRestarting) { + void failClosedWindowsNodeMxc( + `Managed Gateway disconnected: ${reason || "unknown reason"}`, + ); + } }, onAuthError: (message) => { // A stale gateway (from a previous install / scheduled task) is running @@ -3524,6 +3844,7 @@ function registerIpcHandlers(): void { // flows through main-process IPC handlers (chat:send-message, etc.). ipcMain.handle("gateway:get-status", () => gatewayStatus); ipcMain.handle("gateway:restart", async (_event, _options?: { hard?: boolean }) => { + assertWindowsNodeMxcConfigurationMutable(); try { await restartManagedGateway("Restart requested by user"); mainWindow?.webContents.send("gateway:log", "[restart] 网关重启完成"); @@ -3546,16 +3867,7 @@ function registerIpcHandlers(): void { await fs.promises.mkdir(stateDir, { recursive: true }); assertConfigWriteAllowed(config, readConfig()); if (isWindowsNodeMxcDesired()) { - const policy = validateWindowsNodeMxcGatewayPolicy( - config, - settingsStore.get("windowsNodeMxcNodeId"), - "locked", - ); - if (!policy.ready) { - throw new Error( - `config:write would weaken Windows Node + MXC policy: ${policy.blockers.join("; ")}`, - ); - } + throw new Error("config:write is blocked while Windows Node + MXC mode is enabled"); } fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8"); }); @@ -3777,6 +4089,7 @@ function registerIpcHandlers(): void { agentId: string, skillIds: string[], ): Promise<{ agentId: string; skills: string[] }> => { + assertWindowsNodeMxcConfigurationMutable(); if (typeof agentId !== "string" || agentId.length === 0) { throw new Error("A non-empty agentId is required"); } @@ -3940,6 +4253,7 @@ function registerIpcHandlers(): void { _event, params: { skillKey: string; enabled: boolean }, ): Promise<{ skillKey: string; enabled: boolean }> => { + assertWindowsNodeMxcConfigurationMutable(); const skillKey = params?.skillKey; const enabled = params?.enabled; if (typeof skillKey !== "string" || skillKey.length === 0) { @@ -4013,6 +4327,7 @@ function registerIpcHandlers(): void { _event, params: { agentId: string; skillIds: string[]; globalChanges: GlobalSkillChange[] }, ): Promise<{ agentId: string; skills: string[]; globalChanges: GlobalSkillChange[] }> => { + assertWindowsNodeMxcConfigurationMutable(); const agentId = params?.agentId; if (typeof agentId !== "string" || agentId.length === 0) { throw new Error("A non-empty agentId is required"); @@ -4138,7 +4453,6 @@ function registerIpcHandlers(): void { "chat:send-message", async (_event, params: { sessionKey: string; message: string; attachments?: unknown }) => { if (!gwClient?.connected) throw new Error("Gateway not connected"); - await requireEffectiveWindowsNodeMxc(); // Mark that the latest input is from the local desktop UI. lastInputFromRemote = false; await gwClient.sendChat( @@ -4181,6 +4495,18 @@ function registerIpcHandlers(): void { ipcMain.handle("gateway:warm-up-agent", async () => { if (!gwClient?.connected) throw new Error("Gateway not connected"); + if ( + isWindowsNodeMxcDesired() && + !isWindowsNodeMxcIngressReleased( + true, + gatewayGenerationId, + windowsNodeMxcIngressGeneration, + windowsNodeMxcActivationInProgress, + ) + ) { + console.log("[gateway-ws] agent warm-up skipped: Windows Node + MXC ingress is locked"); + return { outcome: "skipped", transcriptDeleted: true }; + } if (needsSetup()) { console.log("[gateway-ws] agent warm-up skipped: no model is configured"); return { outcome: "skipped", transcriptDeleted: true }; @@ -4192,10 +4518,22 @@ function registerIpcHandlers(): void { // Without this, the renderer's isConnected() poll on mount bypasses the // ws-connected gate and lets the user send messages before the gateway's // post-spawn process replacement completes (sandbox runtime not yet initialized). - ipcMain.handle("chat:is-connected", () => (gwClient?.connected ?? false) && postSpawnRestartDone); + ipcMain.handle( + "chat:is-connected", + () => + (gwClient?.connected ?? false) && + postSpawnRestartDone && + isWindowsNodeMxcIngressReleased( + isWindowsNodeMxcDesired(), + gatewayGenerationId, + windowsNodeMxcIngressGeneration, + windowsNodeMxcActivationInProgress, + ), + ); // --- Cron / Scheduled Tasks --- ipcMain.handle("cron:list", async () => { + if (isWindowsNodeMxcDesired()) return { jobs: [] }; if (!gwClient?.connected) throw new Error("Gateway not connected"); return await gwClient.listCronJobs(); }); @@ -4244,6 +4582,7 @@ function registerIpcHandlers(): void { // --- Channels --- ipcMain.handle("channels:list", async () => { + if (isWindowsNodeMxcDesired()) return { channels: [] }; if (!gwClient?.connected) return { channels: [] }; try { return await gwClient.listChannels(); @@ -4280,6 +4619,7 @@ function registerIpcHandlers(): void { }); ipcMain.handle("plugin:weixin:set-enabled", async (_event, enabled: boolean) => { + assertWindowsNodeMxcConfigurationMutable(); const config = readConfig() || {}; if (!config.plugins) config.plugins = {}; if (!config.plugins.entries) config.plugins.entries = {}; @@ -4297,6 +4637,7 @@ function registerIpcHandlers(): void { }); ipcMain.handle("plugin:weixin:login", async () => { + assertWindowsNodeMxcConfigurationMutable(); if (weixinLoginProcess) { weixinLoginProcess.kill(); weixinLoginProcess = null; @@ -4391,6 +4732,7 @@ function registerIpcHandlers(): void { force?: boolean; }, ) => { + assertWindowsNodeMxcConfigurationMutable(); if (!gwClient?.connected) { return { ok: false, error: "Gateway not connected" }; } @@ -4414,6 +4756,7 @@ function registerIpcHandlers(): void { timeoutMs?: number; }, ) => { + assertWindowsNodeMxcConfigurationMutable(); if (!gwClient?.connected) { return { connected: false, message: "Gateway not connected" }; } @@ -4428,6 +4771,7 @@ function registerIpcHandlers(): void { ); ipcMain.handle("plugin:weixin:disconnect", async (_event, params?: { accountId?: string }) => { + assertWindowsNodeMxcConfigurationMutable(); // Remove all account files and restart gateway try { const stateDir = getOpenClawStateDir(); @@ -4600,6 +4944,7 @@ function registerIpcHandlers(): void { ); ipcMain.handle("model:github-copilot:prepare", async () => { + assertWindowsNodeMxcConfigurationMutable(); const config = readConfig(); if (!config || typeof config !== "object" || Array.isArray(config)) { throw new Error("OpenClaw configuration is unavailable"); @@ -4916,6 +5261,9 @@ function registerIpcHandlers(): void { } const configuredPort = config?.gateway?.port || gatewayPort || DEFAULT_PORT; + windowsNodeMxcIngressGeneration = null; + windowsNodeMxcActivationInProgress = false; + bundledWindowsNodeHost.revokeActivationLease(); if (enabled) { const nodeId = bundledWindowsNodeHost.ensureIdentityNodeId(); @@ -4972,56 +5320,12 @@ function registerIpcHandlers(): void { ipcMain.handle("windows-node-mxc:run-smoke", async () => { const status = await getWindowsNodeMxcStatus(); - if (!gwClient?.connected) throw new Error("MicroClaw managed Gateway is not connected"); - if (!gatewaySpawnedByUs || !isManagedGatewayProcessAlive()) { - throw new Error("Windows Node + MXC smoke requires MicroClaw's managed Gateway"); - } - if (!status.selectedNode?.connected) - throw new Error("The selected Windows node is disconnected"); - if ( - !status.selectedNode.commands.includes("system.run") || - !status.selectedNode.commands.includes("system.run.prepare") - ) { - throw new Error("The selected node does not declare system.run and system.run.prepare"); - } - if (!status.strictFallbackEffective) { - throw new Error("Strict MXC host-fallback blocking is not effective"); - } - if (!status.allowWindowsUiEffective) { - throw new Error("Allow Windows UI APIs is required for the PowerShell smoke"); - } - if (status.probe.outcome !== "supported" || !status.probe.tier) { - throw new Error(status.probe.reason || "MXC probe did not report a supported tier"); - } - if (!status.settingsFingerprint) { - throw new Error("Windows Companion settings are unavailable"); - } - if (!status.gatewayPolicyReady) { - throw new Error("Gateway exec-only node policy is not effective"); - } - if (status.gatewayPolicyState !== "locked" || status.effectiveToolsState !== "verified") { - throw new Error("Locked Gateway effective tools must be verified before the MXC smoke"); - } - if (status.durableApprovalsPresent) { - throw new Error("Remove durable approvals before running the cwd-sensitive MXC mode proof"); - } - const smoke = await runWindowsNodeMxcSmoke( - gwClient, - status.selectedNodeId, - status.settingsFingerprint, - status.probe.tier, - ); - settingsStore.set("windowsNodeMxcSmoke", smoke); - if ( - smoke.deniedOutsideRoot.outcome !== "passed" || - smoke.hostname.outcome !== "passed" || - smoke.powershell.outcome !== "passed" - ) { - return smoke; - } - return smoke; + assertWindowsNodeMxcBaseReady(status, "locked", false); + return runCurrentWindowsNodeMxcSmoke(status); }); + ipcMain.handle("windows-node-mxc:activate", () => activateWindowsNodeMxc()); + ipcMain.handle( "windows-node-mxc:approval-respond", ( @@ -5077,8 +5381,7 @@ function registerIpcHandlers(): void { if (!mainWindow) return; mainWindow.setResizable(true); const savedBounds = store.get("windowBounds") as - | { width?: number; height?: number; x?: number; y?: number } - | undefined; + { width?: number; height?: number; x?: number; y?: number } | undefined; const width = savedBounds?.width || DEFAULT_WINDOW_WIDTH; const height = savedBounds?.height || DEFAULT_WINDOW_HEIGHT; mainWindow.setSize(width, height); @@ -5240,6 +5543,11 @@ function registerIpcHandlers(): void { }); // --- Sandbox directory permissions --- + const assertSandboxFolderPolicyMutable = () => { + if (isWindowsNodeMxcDesired()) { + throw new Error("Disable Windows Node + MXC mode before changing its approved folder policy"); + } + }; // --- Sandbox capabilities --- ipcMain.handle("sandbox:get-capabilities", () => { @@ -5278,6 +5586,7 @@ function registerIpcHandlers(): void { }); ipcMain.handle("sandbox:add-user-dir", async (_event, params: { access: "rw" | "ro" }) => { + assertSandboxFolderPolicyMutable(); if (!mainWindow) return { ok: false, dirs: { rw: [], ro: [] } }; const result = await dialog.showOpenDialog(mainWindow, { properties: ["openDirectory"], @@ -5471,6 +5780,7 @@ function registerIpcHandlers(): void { ipcMain.handle( "sandbox:remove-user-dir", async (_event, params: { dir: string; access: "rw" | "ro" }) => { + assertSandboxFolderPolicyMutable(); const key = params.access === "rw" ? "sandboxUserDirsRW" : "sandboxUserDirsRO"; const normalTarget = normalizeDirPath(params.dir).toLowerCase(); @@ -5657,6 +5967,7 @@ function registerIpcHandlers(): void { ipcMain.handle( "sandbox:repair-acl", async (_event, params: { dir: string; access: "rw" | "ro" }) => { + assertSandboxFolderPolicyMutable(); if (!toolSandbox) return { ok: false }; const access = params.access === "rw" ? "rw" : ("r" as const); const result = await grantAndVerifyAcl(params.dir, access); @@ -5669,6 +5980,7 @@ function registerIpcHandlers(): void { // Revoke a stale ACL entry found by scan-acl. // Also removes from settings lists + grant history to prevent re-grant. ipcMain.handle("sandbox:revoke-stale-acl", async (_event, dir: string) => { + assertSandboxFolderPolicyMutable(); if (!toolSandbox) return { ok: false }; const ok = await revokeWithUnshield(dir); if (ok) { @@ -5697,6 +6009,7 @@ function registerIpcHandlers(): void { ipcMain.handle( "sandbox:permission-respond", async (_event, requestId: string, decision: string) => { + assertSandboxFolderPolicyMutable(); const pending = pendingPermissionRequests.get(requestId); if (!pending) return; pendingPermissionRequests.delete(requestId); diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index b1c338b..b6bc289 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -310,6 +310,7 @@ contextBridge.exposeInMainWorld("openclaw", { setEnabled: (params: { enabled: boolean; nodeId?: string }) => ipcRenderer.invoke("windows-node-mxc:set-enabled", params), runSmoke: () => ipcRenderer.invoke("windows-node-mxc:run-smoke"), + activate: () => ipcRenderer.invoke("windows-node-mxc:activate"), respondApproval: (params: { requestId: string; decision: "deny" | "allow-once" | "allow-always"; diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index c3211e2..b45f211 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -25,7 +25,10 @@ import { validateWindowsNodeMxcSettings, type WindowsNodeMxcGatewayPolicyState, } from "./windows-node-mxc"; -import type { BundledWindowsNodeHostStatus } from "./bundled-windows-node-host"; +import type { + BundledWindowsNodeActivationLease, + BundledWindowsNodeHostStatus, +} from "./bundled-windows-node-host"; const WINDOWS_NODE_SETTINGS_FILENAME = "settings.json"; const HOSTNAME_MARKER = "MICROCLAW_MXC_HOSTNAME_OK"; @@ -37,6 +40,7 @@ export interface WindowsNodeMxcGateway { } export interface StoredWindowsNodeMxcSmoke { + gatewayGeneration: string; nodeId: string; settingsFingerprint: string; probeTier: string; @@ -63,6 +67,11 @@ export interface WindowsNodeMxcRuntimeStatus { helperRevision?: string; mxcRuntimeVersion?: string; cwdPolicyContract?: string; + cwdAttestationReady: boolean; + activationLeaseContract?: string; + gatewayGeneration: string; + activationLeaseMode: "diagnostic" | "active" | null; + activationLeaseExpiresAt: string | null; gatewayPolicyState: WindowsNodeMxcGatewayPolicyState; gatewayPolicyReady: boolean; effectiveToolsReady: boolean; @@ -81,6 +90,7 @@ export interface InspectWindowsNodeMxcOptions { config: unknown; gateway: WindowsNodeMxcGateway | null; managedGateway: boolean; + gatewayGeneration: string; storedSmoke?: StoredWindowsNodeMxcSmoke | null; appData?: string; localAppData?: string; @@ -202,10 +212,6 @@ export async function inspectWindowsNodeMxc( blockers.push("Gateway agent tool policy drifted from the diagnostic-only locked MXC policy"); } else if (gatewayPolicyState === "locked") { blockers.push("Gateway agent execution remains diagnostic-only and locked"); - } else { - blockers.push( - "Active Gateway policy is unsupported until ingress can be quarantined atomically", - ); } let nodes: WindowsNodeRecord[] = []; @@ -213,6 +219,7 @@ export async function inspectWindowsNodeMxc( let effectiveToolsReady = false; let effectiveToolsState: WindowsNodeMxcRuntimeStatus["effectiveToolsState"] = "unverified"; let durableApprovalsPresent: boolean | null = null; + let cwdAttestationReady = false; if (!selectedNodeId) { blockers.push( bundled @@ -242,10 +249,12 @@ export async function inspectWindowsNodeMxc( nodeId: selectedNodeId, command: WINDOWS_NODE_MXC_REQUIRED_CWD_COMMAND, params: {}, - timeoutMs: 15_000, + timeoutMs: 30_000, idempotencyKey: randomUUID(), }); const attestationCheck = validateBundledCwdAttestation(attestation); + cwdAttestationReady = attestationCheck.ready; + durableApprovalsPresent = attestationCheck.durableApprovalsPresent; if (!attestationCheck.ready) blockers.push(...attestationCheck.blockers); } catch (error) { blockers.push(`Bundled node CWD attestation failed: ${messageOf(error)}`); @@ -289,22 +298,20 @@ export async function inspectWindowsNodeMxc( blockers.push(`Could not verify effective Gateway tools: ${messageOf(error)}`); } - try { - const approvals = await options.gateway.request("exec.approvals.node.get", { - nodeId: selectedNodeId, - }); - durableApprovalsPresent = bundled ? false : hasDurableApprovals(approvals); + if (!bundled) { + try { + const approvals = await options.gateway.request("exec.approvals.node.get", { + nodeId: selectedNodeId, + }); + durableApprovalsPresent = hasDurableApprovals(approvals); + } catch (error) { + blockers.push(`Could not verify selected-node durable approvals: ${messageOf(error)}`); + } if (durableApprovalsPresent) { blockers.push( "Selected node has durable exec approvals, which the pinned Windows Node does not bind to cwd", ); } - } catch (error) { - if (bundled) { - durableApprovalsPresent = false; - } else { - blockers.push(`Could not verify selected-node durable approvals: ${messageOf(error)}`); - } } } } @@ -318,14 +325,13 @@ export async function inspectWindowsNodeMxc( ); } - const smoke = - options.storedSmoke && - isCurrentWindowsNodeMxcSmoke(options.storedSmoke) && - options.storedSmoke.nodeId === selectedNodeId && - options.storedSmoke.settingsFingerprint === settingsFingerprint && - options.storedSmoke.probeTier === probe.tier - ? options.storedSmoke - : null; + const smoke = selectCurrentWindowsNodeMxcSmoke( + options.storedSmoke, + options.gatewayGeneration, + selectedNodeId, + settingsFingerprint, + probe.tier, + ); if ( !smoke || smoke.hostname.outcome !== "passed" || @@ -341,17 +347,36 @@ export async function inspectWindowsNodeMxc( : "Run the contained child-process check from MicroClaw Security settings and approve each command once in Windows Companion.", ); } else if (bundled && gatewayPolicyState === "locked") { - blockers.push( - "The pinned Gateway cannot quarantine channel and scheduled ingress during an active-policy restart", - ); - remediation.push( - "Keep diagnostic lock enabled until the Gateway provides atomic ingress quarantine or the bundled helper gains an independently attested activation gate.", - ); + remediation.push("Activate the verified MXC route from MicroClaw Security settings."); } + const activationLease = bundled?.activationLease ?? null; + if (gatewayPolicyState === "active") { + if ( + !isCurrentBundledActivationLease( + activationLease, + "active", + options.gatewayGeneration, + bundled?.policyFingerprint ?? null, + ) + ) { + blockers.push( + "The bundled node does not hold an active generation- and policy-bound activation lease", + ); + } + } else if (activationLease?.mode === "active") { + blockers.push("The bundled node retained an active lease while the Gateway policy is locked"); + } + + const effectiveEnabled = + options.desiredEnabled && + gatewayPolicyState === "active" && + effectiveToolsReady && + blockers.length === 0; + return { desiredEnabled: options.desiredEnabled, - effectiveEnabled: false, + effectiveEnabled, selectedNodeId, settingsPath, companionPath, @@ -368,6 +393,13 @@ export async function inspectWindowsNodeMxc( helperRevision: bundled?.helperRevision, mxcRuntimeVersion: bundled?.runtimeVersion, cwdPolicyContract: bundled?.cwdPolicyContract, + cwdAttestationReady, + activationLeaseContract: bundled ? "microclaw.windows-activation.v1" : undefined, + gatewayGeneration: options.gatewayGeneration, + activationLeaseMode: activationLease?.mode ?? null, + activationLeaseExpiresAt: activationLease + ? new Date(activationLease.expiresAtUnixMs).toISOString() + : null, gatewayPolicyState, gatewayPolicyReady, effectiveToolsReady, @@ -387,12 +419,14 @@ export function shouldStopManagedGatewayForWindowsNodeMxc( "effectiveEnabled" | "effectiveToolsState" | "gatewayPolicyState" >, ): boolean { - return status.gatewayPolicyState !== "locked" || status.effectiveToolsState === "drift"; + if (status.gatewayPolicyState === "drift" || status.effectiveToolsState === "drift") return true; + return status.gatewayPolicyState === "active" && !status.effectiveEnabled; } export function isCurrentWindowsNodeMxcSmoke(value: unknown): value is StoredWindowsNodeMxcSmoke { if (!isRecord(value)) return false; return ( + typeof value.gatewayGeneration === "string" && typeof value.nodeId === "string" && typeof value.settingsFingerprint === "string" && typeof value.probeTier === "string" && @@ -403,9 +437,41 @@ export function isCurrentWindowsNodeMxcSmoke(value: unknown): value is StoredWin ); } +export function selectCurrentWindowsNodeMxcSmoke( + value: unknown, + gatewayGeneration: string, + nodeId: string, + settingsFingerprint: string | null, + probeTier: string | null | undefined, +): StoredWindowsNodeMxcSmoke | null { + return isCurrentWindowsNodeMxcSmoke(value) && + value.gatewayGeneration === gatewayGeneration && + value.nodeId === nodeId && + value.settingsFingerprint === settingsFingerprint && + value.probeTier === probeTier + ? value + : null; +} + +export function isCurrentBundledActivationLease( + lease: BundledWindowsNodeActivationLease | null, + mode: BundledWindowsNodeActivationLease["mode"], + gatewayGeneration: string, + policyFingerprint: string | null, +): boolean { + return ( + lease !== null && + lease.mode === mode && + lease.gatewayGeneration === gatewayGeneration && + lease.policyFingerprint === policyFingerprint && + lease.expiresAtUnixMs > Date.now() + ); +} + export function validateBundledCwdAttestation(value: unknown): { ready: boolean; blockers: string[]; + durableApprovalsPresent: boolean | null; } { const record = findAttestationRecord(value); const expected = { @@ -417,11 +483,20 @@ export function validateBundledCwdAttestation(value: unknown): { launchTimeRevalidation: true, omittedCwdUsesIsolatedScratch: true, hostFallbackAbsent: true, + activationLeaseContract: "microclaw.windows-activation.v1", + generationBoundActivation: true, + policyBoundActivation: true, + launchTimeLeaseRevalidation: true, }; const blockers = Object.entries(expected) .filter(([key, expectedValue]) => record?.[key] !== expectedValue) .map(([key]) => `Bundled node CWD attestation is missing or invalid: ${key}`); - return { ready: blockers.length === 0, blockers }; + const durableApprovalsPresent = + typeof record?.durableApprovalsPresent === "boolean" ? record.durableApprovalsPresent : null; + if (durableApprovalsPresent === null) { + blockers.push("Bundled node CWD attestation is missing or invalid: durableApprovalsPresent"); + } + return { ready: blockers.length === 0, blockers, durableApprovalsPresent }; } function findAttestationRecord(value: unknown): Record | null { @@ -437,6 +512,7 @@ function findAttestationRecord(value: unknown): Record | null { export async function runWindowsNodeMxcSmoke( gateway: WindowsNodeMxcGateway, + gatewayGeneration: string, nodeId: string, settingsFingerprint: string, probeTier: string, @@ -479,6 +555,7 @@ export async function runWindowsNodeMxcSmoke( ); } return { + gatewayGeneration, nodeId: nodeId.trim(), settingsFingerprint, probeTier, diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index 85b254d..cf2c4e6 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -10,6 +10,7 @@ import { extractEffectiveToolNames, getMxcTierWarning, getWindowsNodeMxcGatewayPolicyState, + isWindowsNodeMxcIngressReleased, listAgentSessionKeys, normalizeWindowsNodeRecord, restoreWindowsNodeMxcGatewayPolicy, @@ -20,7 +21,9 @@ import { } from "./windows-node-mxc"; import { classifyDeniedCwdSmoke, + isCurrentBundledActivationLease, isCurrentWindowsNodeMxcSmoke, + selectCurrentWindowsNodeMxcSmoke, shouldStopManagedGatewayForWindowsNodeMxc, validateBundledCwdAttestation, } from "./windows-node-mxc-service"; @@ -56,9 +59,14 @@ describe("bundled Windows Node CWD attestation", () => { launchTimeRevalidation: true, omittedCwdUsesIsolatedScratch: true, hostFallbackAbsent: true, + activationLeaseContract: "microclaw.windows-activation.v1", + generationBoundActivation: true, + policyBoundActivation: true, + launchTimeLeaseRevalidation: true, + durableApprovalsPresent: true, }, }), - ).toEqual({ ready: true, blockers: [] }); + ).toEqual({ ready: true, blockers: [], durableApprovalsPresent: true }); }); describe("Windows Node MXC diagnostic lifecycle", () => { @@ -83,6 +91,13 @@ describe("bundled Windows Node CWD attestation", () => { effectiveToolsState: "verified", gatewayPolicyState: "active", }), + ).toBe(false); + expect( + shouldStopManagedGatewayForWindowsNodeMxc({ + effectiveEnabled: false, + effectiveToolsState: "verified", + gatewayPolicyState: "active", + }), ).toBe(true); }); @@ -98,6 +113,76 @@ describe("bundled Windows Node CWD attestation", () => { }), ).toBe(false); }); + + it("binds smoke proof and ingress release to the exact Gateway generation", () => { + const smoke = { + gatewayGeneration: "generation-1", + nodeId: "node-1", + settingsFingerprint: "settings-1", + probeTier: "appcontainer-dacl", + checkedAt: new Date().toISOString(), + hostname: { outcome: "passed" as const, reason: "ok" }, + powershell: { outcome: "passed" as const, reason: "ok" }, + deniedOutsideRoot: { outcome: "passed" as const, reason: "denied" }, + }; + + expect( + selectCurrentWindowsNodeMxcSmoke( + smoke, + "generation-1", + "node-1", + "settings-1", + "appcontainer-dacl", + ), + ).toBe(smoke); + expect( + selectCurrentWindowsNodeMxcSmoke( + smoke, + "generation-2", + "node-1", + "settings-1", + "appcontainer-dacl", + ), + ).toBeNull(); + expect(isWindowsNodeMxcIngressReleased(true, "generation-1", "generation-1", false)).toBe( + true, + ); + expect(isWindowsNodeMxcIngressReleased(true, "generation-2", "generation-1", false)).toBe( + false, + ); + expect(isWindowsNodeMxcIngressReleased(true, "generation-1", "generation-1", true)).toBe( + false, + ); + expect(isWindowsNodeMxcIngressReleased(false, "", null, false)).toBe(true); + }); + + it("requires an unexpired activation lease for the exact generation and policy", () => { + const lease = { + contract: "microclaw.windows-activation.v1" as const, + mode: "active" as const, + gatewayGeneration: "generation-1", + policyFingerprint: "policy-1", + expiresAtUnixMs: Date.now() + 60_000, + }; + + expect(isCurrentBundledActivationLease(lease, "active", "generation-1", "policy-1")).toBe( + true, + ); + expect(isCurrentBundledActivationLease(lease, "active", "generation-2", "policy-1")).toBe( + false, + ); + expect(isCurrentBundledActivationLease(lease, "active", "generation-1", "policy-2")).toBe( + false, + ); + expect( + isCurrentBundledActivationLease( + { ...lease, expiresAtUnixMs: Date.now() - 1 }, + "active", + "generation-1", + "policy-1", + ), + ).toBe(false); + }); }); it("rejects a command-name-only or incomplete attestation", () => { @@ -154,6 +239,54 @@ describe("Windows Node MXC Gateway policy", () => { expect(restoreWindowsNodeMxcGatewayPolicy(applied.config, applied.backups)).toEqual(source); }); + it("disables and restores every MicroClaw-managed external ingress surface", () => { + const source = { + agents: { list: [{ id: "main" }] }, + channels: { discord: { enabled: true, token: "preserved" } }, + hooks: { enabled: true, token: "preserved", internal: { enabled: true } }, + cron: { enabled: true, maxConcurrentRuns: 2 }, + plugins: { + enabled: true, + allow: ["openclaw-weixin"], + entries: { "openclaw-weixin": { enabled: true } }, + }, + }; + + const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "locked"); + + expect(applied.config).toMatchObject({ + channels: { discord: { enabled: false, token: "preserved" } }, + hooks: { enabled: false, token: "preserved", internal: { enabled: false } }, + cron: { enabled: false, maxConcurrentRuns: 2 }, + plugins: { + enabled: false, + allow: ["openclaw-weixin"], + entries: { "openclaw-weixin": { enabled: false } }, + }, + }); + expect(validateWindowsNodeMxcGatewayPolicy(applied.config, "node-123", "locked").ready).toBe( + true, + ); + expect(restoreWindowsNodeMxcGatewayPolicy(applied.config, applied.backups)).toEqual(source); + }); + + it("fails closed when channels, hooks, cron, or plugins are re-enabled", () => { + const source = { agents: { list: [{ id: "main" }] } }; + const baseline = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "active").config; + const drifts = [ + { ...baseline, cron: { enabled: true } }, + { ...baseline, hooks: { enabled: true, internal: { enabled: false } } }, + { ...baseline, hooks: { enabled: false, internal: { enabled: true } } }, + { ...baseline, channels: { discord: { enabled: true } } }, + { ...baseline, plugins: { enabled: true, entries: {} } }, + { ...baseline, plugins: { enabled: false, entries: { channel: { enabled: true } } } }, + ]; + + for (const drift of drifts) { + expect(validateWindowsNodeMxcGatewayPolicy(drift, "node-123", "active").ready).toBe(false); + } + }); + it("uses an empty locked tool inventory until all readiness proofs pass", () => { const source = { agents: { list: [{ id: "main" }] } }; const applied = applyWindowsNodeMxcGatewayPolicy(source, "node-123", {}, "locked"); @@ -211,7 +344,7 @@ describe("Windows Node MXC Gateway policy", () => { expect(() => extractEffectiveToolNames({ tools: [] })).toThrow("groups inventory"); }); - it("uses actual persisted session keys for each configured agent", async () => { + it("uses persisted agent sessions and the trusted global session for unsurfaced agents", async () => { const request = async (method: string) => { expect(method).toBe("sessions.list"); return { @@ -223,18 +356,20 @@ describe("Windows Node MXC Gateway policy", () => { expect([...keys]).toEqual([ ["main", "global"], ["coder", "agent:coder:work"], + ["unused", "global"], ]); }); - it("allows missing sessions only while the Gateway remains locked", () => { + it("uses exact static policy for agents without a persisted runtime session", () => { expect(classifyMissingEffectiveToolSession("unused", "locked")).toMatchObject({ ready: true, blockers: [], warnings: [expect.stringContaining("locked config applies")], }); expect(classifyMissingEffectiveToolSession("unused", "active")).toMatchObject({ - ready: false, - blockers: [expect.stringContaining("no persisted session")], + ready: true, + blockers: [], + warnings: [expect.stringContaining("exact active config applies")], }); }); }); diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index d46ccbb..96a3277 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -28,6 +28,20 @@ export const WINDOWS_NODE_MXC_LOCKED_TOOL_DENYLIST = [ export type WindowsNodeMxcGatewayPolicyState = "active" | "locked" | "drift"; +export function isWindowsNodeMxcIngressReleased( + desired: boolean, + gatewayGeneration: string, + releasedGeneration: string | null, + activationInProgress: boolean, +): boolean { + return ( + !desired || + (!activationInProgress && + gatewayGeneration.length > 0 && + releasedGeneration === gatewayGeneration) + ); +} + export type SandboxFolderAccess = "ro" | "rw"; export interface WindowsNodeMxcFolder { @@ -108,6 +122,12 @@ type AgentEntry = { type AgentToolsBackup = Record; const GATEWAY_NODES_BACKUP_KEY = "$microclaw.gateway.nodes"; +const INGRESS_BACKUP_KEYS = { + channels: "$microclaw.ingress.channels", + cron: "$microclaw.ingress.cron", + hooks: "$microclaw.ingress.hooks", + plugins: "$microclaw.ingress.plugins", +} as const; export interface WindowsNodeMxcPolicyApplication { config: Record; @@ -196,6 +216,37 @@ export function applyWindowsNodeMxcGatewayPolicy( nodes.denyCommands = []; gateway.nodes = nodes; config.gateway = gateway; + for (const [field, backupKey] of Object.entries(INGRESS_BACKUP_KEYS)) { + if (!Object.hasOwn(backups, backupKey)) { + backups[backupKey] = Object.hasOwn(config, field) ? structuredClone(config[field]) : null; + } + } + const channels = asRecord(config.channels); + config.channels = Object.fromEntries( + Object.entries(channels).map(([channelId, value]) => [ + channelId, + { ...asRecord(value), enabled: false }, + ]), + ); + const hooks = asRecord(config.hooks); + config.hooks = { + ...hooks, + enabled: false, + internal: { ...asRecord(hooks.internal), enabled: false }, + }; + config.cron = { ...asRecord(config.cron), enabled: false }; + const plugins = asRecord(config.plugins); + const pluginEntries = asRecord(plugins.entries); + config.plugins = { + ...plugins, + enabled: false, + entries: Object.fromEntries( + Object.entries(pluginEntries).map(([pluginId, value]) => [ + pluginId, + { ...asRecord(value), enabled: false }, + ]), + ), + }; return { config, backups, agentIds }; } @@ -231,6 +282,12 @@ export function restoreWindowsNodeMxcGatewayPolicy( config.gateway = gateway; } } + for (const [field, backupKey] of Object.entries(INGRESS_BACKUP_KEYS)) { + if (!Object.hasOwn(backups, backupKey)) continue; + const previous = backups[backupKey]; + if (previous === null) delete config[field]; + else config[field] = structuredClone(previous); + } return config; } @@ -297,10 +354,36 @@ export function validateWindowsNodeMxcGatewayPolicy( if (!isDeepStrictEqual(denyCommands, [])) { blockers.push("Gateway node command denylist conflicts with the bundled system-only surface"); } + const cron = asRecord(root.cron); + if (cron.enabled !== false) blockers.push("Gateway cron ingress must remain disabled"); + const hooks = asRecord(root.hooks); + if (hooks.enabled !== false) blockers.push("Gateway webhook ingress must remain disabled"); + if (asRecord(hooks.internal).enabled !== false) { + blockers.push("Gateway internal-hook ingress must remain disabled"); + } + const channels = asRecord(root.channels); + for (const [channelId, value] of Object.entries(channels)) { + if (asRecord(value).enabled !== false) { + blockers.push(`Gateway channel "${channelId}" must remain disabled`); + } + } + const plugins = asRecord(root.plugins); + if (plugins.enabled !== false) blockers.push("Gateway plugins must remain disabled"); + for (const [pluginId, value] of Object.entries(asRecord(plugins.entries))) { + if (asRecord(value).enabled !== false) { + blockers.push(`Gateway plugin "${pluginId}" must remain disabled`); + } + } return { ready: blockers.length === 0, blockers, warnings }; } +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + export function getWindowsNodeMxcGatewayPolicyState( config: unknown, nodeId: string, @@ -463,9 +546,11 @@ export function classifyMissingEffectiveToolSession( }; } return { - ready: false, - blockers: [`Agent "${agentId}" has no persisted session for tools.effective`], - warnings: [], + ready: true, + blockers: [], + warnings: [ + `Agent "${agentId}" has no persisted session; the exact active config applies before its first session is created`, + ], }; } @@ -516,6 +601,7 @@ export async function listAgentSessionKeys( const wanted = new Set(agentIds); const result = new Map(); + let globalSessionKey = ""; for (const value of sessions) { if (!value || typeof value !== "object" || Array.isArray(value)) continue; const session = value as Record; @@ -526,6 +612,7 @@ export async function listAgentSessionKeys( ? session.sessionKey.trim() : ""; if (!key) continue; + if (key === "global") globalSessionKey = key; const keyAgentId = /^agent:([^:]+):/i.exec(key)?.[1]; const agentId = typeof session.agentId === "string" && session.agentId.trim() @@ -533,6 +620,14 @@ export async function listAgentSessionKeys( : keyAgentId || (key === "global" ? "main" : ""); if (wanted.has(agentId) && !result.has(agentId)) result.set(agentId, key); } + // Pinned OpenClaw explicitly permits an operator tools.effective request to override + // the agent on the trusted global session, so one persisted global session can attest + // configured agents that have not yet created their own transcript. + if (globalSessionKey) { + for (const agentId of agentIds) { + if (!result.has(agentId)) result.set(agentId, globalSessionKey); + } + } return result; } diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index 46f4572..ffa6faa 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -53,6 +53,8 @@ the scratch semantic). Legacy or CWD-unbound entries never match. Policy, execut reparse state, root membership, and the exact CWD binding are revalidated immediately before launch. Delete-denying directory handles retain every approved-root/CWD path component through MXC completion, preventing a validated directory from being replaced by a junction during launch. +Approved-folder changes are rejected while the mode is enabled because the helper binds the +canonical folder policy at generation startup. Disable and reactivate the mode to change that policy. Approval presentation is owned by MicroClaw Security settings. The helper connects to a random per-launch Windows named pipe, displays executable, exact argv, agent, and canonical CWD, and @@ -94,14 +96,45 @@ Staging pins the .NET runtime, checks architecture-specific hashes for both offi the unsigned MicroClaw-built helper, and records origin, operations, revision, and hashes in `RUNTIME.json`. Electron's `afterSign` hook replaces only the helper's manifest hash with the final signed package hash; this avoids both excluding the MicroClaw-owned executable from product signing -and rejecting the package because Authenticode changed its bytes. +and rejecting the package because Authenticode changed its bytes. The helper build disables +repository-HEAD suffixes in its informational version so its pinned unsigned hash remains +reproducible across MicroClaw commits. ## Readiness and activation transaction -Readiness requires the exact CWD attestation payload, selected app-owned node identity, connected -and paired node state, strict locked/effective Gateway tools, MXC tier, contained `hostname.exe`, -contained PowerShell, and denied-access proof. `appcontainer-dacl` is accepted with a degraded -containment warning. +The helper also enforces `microclaw.windows-activation.v1`: an HMAC-SHA256 lease bound to the exact +Gateway generation, helper policy fingerprint, mode, and expiry. Its per-generation key is delivered +only through inherited stdin. A diagnostic lease admits only the fixed denied-CWD, `hostname.exe`, +and PowerShell smoke declarations. An active lease admits the normal attended `system.run` path. +The helper checks the lease before approval and again immediately before MXC launch. + +MicroClaw starts every Gateway generation locked, with no effective agent tools. Readiness requires +the exact CWD/activation attestation payload, selected app-owned node identity, paired and connected +state, strict no-fallback settings, current-generation effective tools, MXC tier, contained +`hostname.exe`, contained PowerShell, and denied-access proof. `appcontainer-dacl` is accepted with a +prominent degraded-containment warning. + +Every MicroClaw-owned `chat.send` path, including startup warm-up and generated session titles, +passes through the same current-generation active-ingress gate. Locked or attesting startup skips +warm-up without queuing or replaying it; warm-up can run only after final activation release. + +Activation is a fail-closed transaction: + +1. Attest the locked generation and its smoke record. +2. Stop the Gateway and reject, rather than queue, MicroClaw chat sends. +3. Write an active policy that exposes only node-pinned `exec` and disables configured channels, + webhooks, internal hooks, cron, plugin loading, and plugin entries. +4. Start a new managed loopback Gateway generation and pair the exact bundled node. +5. Verify the node declaration, CWD contract, strict helper policy, and effective `exec`-only tool + inventory. +6. Repeat all contained smokes for the active generation under a diagnostic lease. +7. Issue an active lease, perform a final attestation, then release MicroClaw chat ingress. + +Any restart, disconnect, timeout, policy/tool drift, missing or expired lease, helper failure, or +attestation error revokes the lease, clears the smoke proof, rewrites the locked policy, and stops +the managed Gateway. Configuration and restart operations that could invalidate an active +transaction are rejected while this mode is enabled. External channel, hook, cron, and plugin +inventory APIs return no active ingress. The pinned OpenClaw 2026.7.1-1 Gateway can block its event loop for more than a minute during startup. Pairing therefore remains generation-bound and locked for up to five minutes while the @@ -109,22 +142,23 @@ helper reconnects; timeout or a Gateway-generation change stops the helper. Tran query effective tools is recorded as unverified and does not kill an otherwise statically locked Gateway; confirmed drift still stops it. -Local non-elevated proof reached the exact app-owned node, validated -`microclaw.windows-cwd.v1`, verified an empty locked effective-tool surface, and proved that -`C:\Windows` is rejected as a protected/unapproved CWD. The attended one-time approval reached -official MXC for `cmd.exe`, but the contained child failed with `Access is denied` on this -`appcontainer-dacl` machine. PowerShell therefore did not run. The mode remains diagnostic-only and -host fallback remains impossible. - -The pinned Gateway also has no atomic way to quarantine channel and scheduled ingress while an -active exec-only policy starts and is attested. Starting active and checking afterward would expose -a pre-attestation execution window, so MicroClaw deliberately does not perform that transition. -Unlocking requires either an upstream atomic ingress-quarantine primitive or an independently -attested activation gate in the bundled helper. Until then, even a fully passing smoke remains -diagnostic-only. - -No elevation or host-wide `prepare-system-drive` / `prepare-null-device` action is performed by -build, staging, or validation. The next live step requires fresh explicit consent for two narrow -operations: use the MicroClaw-built helper for `prepare-system-drive --target C:\`, and use the -unchanged official helper only for `prepare-null-device --json`. MicroClaw, Electron, Gateway, the -node host, and any shell remain non-elevated. +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 passed with attended allow-once approvals and no durable approval or host fallback. +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. +The active config had channels, webhooks, internal hooks, cron, plugins, and every plugin entry +disabled; channel and cron inventories were empty, and a direct Gateway restart request was rejected. + +The accepted product boundary is MicroClaw-controlled ingress. The activation lease prevents +MicroClaw's helper from executing before MicroClaw releases the verified generation, and the +managed configuration disables MicroClaw-known external ingress. The pinned upstream Gateway does +not provide an atomic quarantine for ingress independently configured outside MicroClaw. Such +upstream ingress is explicitly out of scope and must not share this app-owned Gateway. + +MicroClaw never elevates Electron, the Gateway, the node host, a shell, or arbitrary commands. Host +preparation is not automatic; any future preparation or rollback requires a separate, explicit +consent for the narrowly scoped helper operation. diff --git a/third_party/mxc-host-prep-patch/PROVENANCE.json b/third_party/mxc-host-prep-patch/PROVENANCE.json index c2dd3d9..71b8600 100644 --- a/third_party/mxc-host-prep-patch/PROVENANCE.json +++ b/third_party/mxc-host-prep-patch/PROVENANCE.json @@ -11,19 +11,12 @@ "src/core/wxc_common/src/filesystem_dacl.rs", "src/host/wxc_host_prep/src/system_drive/mod.rs" ], - "includedOperations": [ - "prepare-system-drive", - "unprepare-system-drive" - ], - "excludedOperations": [ - "prepare-null-device", - "verify-null-device", - "dump-null-device" - ], + "includedOperations": ["prepare-system-drive", "unprepare-system-drive"], + "excludedOperations": ["prepare-null-device", "verify-null-device", "dump-null-device"], "buildRuntime": ".NET 10.0.10 self-contained single-file", "unsignedArtifactSha256": { - "win-x64": "452332016eaf13e09fa28e542b03e3c0c992648d693ffc9781e1e1aa15a431c6", - "win-arm64": "ee1d647f60a724fad500190ff93ca189fa481fbcecc49d9c352de4cf2654dd23" + "win-x64": "661cada5d4d32db4255e0c39d982ff7329d3a2bbc93acaf091eb5fed3c9ea205", + "win-arm64": "f6d9c09311906c60746c9a9f8e463c92deaf06c86ca564b7cfa8a64fe6f6e5bd" }, "artifactOwnership": "Built and packaged by MicroClaw; not an official or Microsoft-signed MXC binary" } diff --git a/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj b/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj index 332cdaf..241bc35 100644 --- a/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj +++ b/third_party/mxc-host-prep-patch/source/MicroClaw.MxcHostPrep.csproj @@ -14,6 +14,7 @@ app.manifest 0.7.0 0.7.0-microclaw-pr649.695c2b89 + false MicroClaw MicroClaw MXC host-preparation patch MicroClaw-built target-only system-drive preparation helper derived from microsoft/mxc PR 649 diff --git a/windows-node-host.Tests/CwdPolicyTests.cs b/windows-node-host.Tests/CwdPolicyTests.cs index ce9f6b2..58d3fd0 100644 --- a/windows-node-host.Tests/CwdPolicyTests.cs +++ b/windows-node-host.Tests/CwdPolicyTests.cs @@ -1,6 +1,8 @@ using MicroClaw.WindowsNodeHost; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; using Xunit; namespace MicroClaw.WindowsNodeHost.Tests; @@ -179,6 +181,11 @@ public void AttestationRequiresEverySecurityProperty() Assert.True(attestation.LaunchTimeRevalidation); Assert.True(attestation.OmittedCwdUsesIsolatedScratch); Assert.True(attestation.HostFallbackAbsent); + Assert.Equal("microclaw.windows-activation.v1", attestation.ActivationLeaseContract); + Assert.True(attestation.GenerationBoundActivation); + Assert.True(attestation.PolicyBoundActivation); + Assert.True(attestation.LaunchTimeLeaseRevalidation); + Assert.False(attestation.DurableApprovalsPresent); } [Fact] @@ -196,9 +203,151 @@ public void AttestationUsesTheVersionedCamelCaseWireContract() Assert.True(root.GetProperty("launchTimeRevalidation").GetBoolean()); Assert.True(root.GetProperty("omittedCwdUsesIsolatedScratch").GetBoolean()); Assert.True(root.GetProperty("hostFallbackAbsent").GetBoolean()); + Assert.Equal( + "microclaw.windows-activation.v1", + root.GetProperty("activationLeaseContract").GetString()); + Assert.True(root.GetProperty("generationBoundActivation").GetBoolean()); + Assert.True(root.GetProperty("policyBoundActivation").GetBoolean()); + Assert.True(root.GetProperty("launchTimeLeaseRevalidation").GetBoolean()); + Assert.False(root.GetProperty("durableApprovalsPresent").GetBoolean()); Assert.False(root.TryGetProperty("Contract", out _)); } + [Fact] + public async Task LoadedPolicyMustMatchTheBootstrapFingerprint() + { + var policyPath = Path.Combine(_root, "verified-policy.json"); + var json = System.Text.Json.JsonSerializer.Serialize(new + { + approvedRoots = new[] { new { path = _root, access = "ReadOnly" } }, + deniedRoots = Array.Empty(), + wxcExecPath = Path.Combine(Environment.SystemDirectory, "hostname.exe"), + networkAllowed = false, + allowWindowsUi = true, + clipboard = "none", + inputInjection = false, + strictNoHostFallback = true, + }); + await File.WriteAllTextAsync(policyPath, json, TestContext.Current.CancellationToken); + var fingerprint = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(json))).ToLowerInvariant(); + + var policy = await HostPolicy.LoadVerifiedAsync(policyPath, fingerprint); + + Assert.True(policy.StrictNoHostFallback); + await File.AppendAllTextAsync(policyPath, " ", TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync( + () => HostPolicy.LoadVerifiedAsync(policyPath, fingerprint)); + Assert.Equal("policy-fingerprint-mismatch", error.Code); + } + + [Fact] + public async Task ActivationLeaseIsGenerationPolicyExpiryAndSignatureBound() + { + var leasePath = Path.Combine(_root, "activation.json"); + var secret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds(); + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Active, + "gateway-1", + "policy-1", + expiresAt); + var guard = new ActivationLeaseGuard(leasePath, secret, "gateway-1", "policy-1"); + var argv = new[] { Path.Combine(Environment.SystemDirectory, "hostname.exe") }; + + var validated = guard.Validate(argv); + + Assert.Equal(ActivationLeaseMode.Active, validated.Mode); + Assert.Equal( + "activation-lease-generation", + Assert.Throws( + () => new ActivationLeaseGuard(leasePath, secret, "gateway-2", "policy-1").Validate(argv)).Code); + Assert.Equal( + "activation-lease-policy", + Assert.Throws( + () => new ActivationLeaseGuard(leasePath, secret, "gateway-1", "policy-2").Validate(argv)).Code); + await File.AppendAllTextAsync(leasePath, " ", TestContext.Current.CancellationToken); + var record = System.Text.Json.JsonSerializer.Deserialize( + await File.ReadAllTextAsync(leasePath, TestContext.Current.CancellationToken), + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, + })!; + await File.WriteAllTextAsync( + leasePath, + System.Text.Json.JsonSerializer.Serialize(record with { Signature = new string('0', 64) }), + TestContext.Current.CancellationToken); + Assert.Equal( + "activation-lease-signature", + Assert.Throws(() => guard.Validate(argv)).Code); + } + + [Fact] + public async Task DiagnosticLeasePermitsOnlyTheFixedSmokesAndRevalidatesBeforeLaunch() + { + var leasePath = Path.Combine(_root, "diagnostic-activation.json"); + var secret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeMilliseconds(); + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Diagnostic, + "gateway-1", + "policy-1", + expiresAt); + var guard = new ActivationLeaseGuard(leasePath, secret, "gateway-1", "policy-1"); + var smoke = new[] + { + @"C:\Windows\System32\cmd.exe", + "/d", + "/s", + "/c", + @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", + }; + var validated = guard.Validate(smoke); + + Assert.Equal( + "activation-lease-diagnostic-scope", + Assert.Throws( + () => guard.Validate([Path.Combine(Environment.SystemDirectory, "hostname.exe")])).Code); + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Active, + "gateway-1", + "policy-1", + expiresAt); + Assert.Equal( + "activation-lease-changed", + Assert.Throws(() => guard.Revalidate(validated, smoke)).Code); + } + + [Fact] + public async Task MissingAndExpiredActivationLeasesFailClosed() + { + var leasePath = Path.Combine(_root, "expired-activation.json"); + var secret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + var guard = new ActivationLeaseGuard(leasePath, secret, "gateway-1", "policy-1"); + var argv = new[] { Path.Combine(Environment.SystemDirectory, "hostname.exe") }; + + Assert.Equal( + "activation-lease-unavailable", + Assert.Throws(() => guard.Validate(argv)).Code); + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Active, + "gateway-1", + "policy-1", + DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeMilliseconds()); + Assert.Equal( + "activation-lease-expired", + Assert.Throws(() => guard.Validate(argv)).Code); + } + [Fact] public async Task LoadsElectronPolicyWithNamedFolderAccess() { @@ -323,4 +472,29 @@ private static HostPolicy Policy(IReadOnlyList roots, IReadOnlyLis AllowWindowsUi = true, StrictNoHostFallback = true, }; + + private static Task WriteActivationLease( + string path, + string secret, + ActivationLeaseMode mode, + string gatewayGeneration, + string policyFingerprint, + long expiresAtUnixMs) + { + var record = new ActivationLeaseRecord( + ActivationLeaseContract.Version, + mode, + gatewayGeneration, + policyFingerprint, + expiresAtUnixMs, + ActivationLeaseGuard.ComputeSignature( + secret, + mode, + gatewayGeneration, + policyFingerprint, + expiresAtUnixMs)); + var options = new System.Text.Json.JsonSerializerOptions(); + options.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + return File.WriteAllTextAsync(path, System.Text.Json.JsonSerializer.Serialize(record, options)); + } } diff --git a/windows-node-host/ActivationLease.cs b/windows-node-host/ActivationLease.cs new file mode 100644 index 0000000..2067d85 --- /dev/null +++ b/windows-node-host/ActivationLease.cs @@ -0,0 +1,215 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MicroClaw.WindowsNodeHost; + +public static class ActivationLeaseContract +{ + public const string Version = "microclaw.windows-activation.v1"; +} + +public enum ActivationLeaseMode +{ + Diagnostic, + Active, +} + +public sealed record ActivationLeaseRecord( + [property: JsonPropertyName("contract")] string Contract, + [property: JsonPropertyName("mode")] ActivationLeaseMode Mode, + [property: JsonPropertyName("gatewayGeneration")] string GatewayGeneration, + [property: JsonPropertyName("policyFingerprint")] string PolicyFingerprint, + [property: JsonPropertyName("expiresAtUnixMs")] long ExpiresAtUnixMs, + [property: JsonPropertyName("signature")] string Signature); + +public sealed record ValidatedActivationLease( + ActivationLeaseMode Mode, + string GatewayGeneration, + string PolicyFingerprint, + long ExpiresAtUnixMs, + string Signature); + +public sealed class ActivationLeaseGuard( + string leasePath, + string secretBase64, + string expectedGatewayGeneration, + string expectedPolicyFingerprint) +{ + private static readonly string[] HostnameSmoke = + [ + @"C:\Windows\System32\cmd.exe", + "/d", + "/s", + "/c", + @"C:\Windows\System32\hostname.exe && echo MICROCLAW_MXC_HOSTNAME_OK", + ]; + + private static readonly string[] PowerShellSmoke = + [ + @"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Console]::Out.Write('MICROCLAW_MXC_POWERSHELL_OK')", + ]; + + private readonly byte[] _secret = DecodeSecret(secretBase64); + + public ValidatedActivationLease Validate(IReadOnlyList argv) + { + ActivationLeaseRecord lease; + try + { + var json = File.ReadAllText(leasePath); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter()); + lease = JsonSerializer.Deserialize(json, options) + ?? throw new HostPolicyException( + "activation-lease-invalid", + "The MicroClaw activation lease is empty."); + } + catch (HostPolicyException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + throw new HostPolicyException( + "activation-lease-unavailable", + $"A current MicroClaw activation lease is required: {ex.GetType().Name}"); + } + + if (!string.Equals(lease.Contract, ActivationLeaseContract.Version, StringComparison.Ordinal)) + throw new HostPolicyException( + "activation-lease-contract", + "The MicroClaw activation lease contract is unsupported."); + if (!string.Equals( + lease.GatewayGeneration, + expectedGatewayGeneration, + StringComparison.Ordinal)) + throw new HostPolicyException( + "activation-lease-generation", + "The activation lease belongs to a different Gateway generation."); + if (!string.Equals( + lease.PolicyFingerprint, + expectedPolicyFingerprint, + StringComparison.Ordinal)) + throw new HostPolicyException( + "activation-lease-policy", + "The activation lease belongs to a different sandbox policy."); + if (lease.ExpiresAtUnixMs <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) + throw new HostPolicyException( + "activation-lease-expired", + "The MicroClaw activation lease expired."); + + byte[] actualSignature; + try + { + actualSignature = Convert.FromHexString(lease.Signature); + } + catch (FormatException) + { + throw new HostPolicyException( + "activation-lease-signature", + "The activation lease signature is malformed."); + } + var expectedSignature = Sign( + _secret, + lease.Mode, + lease.GatewayGeneration, + lease.PolicyFingerprint, + lease.ExpiresAtUnixMs); + if (!CryptographicOperations.FixedTimeEquals(actualSignature, expectedSignature)) + throw new HostPolicyException( + "activation-lease-signature", + "The activation lease signature is invalid."); + + if (lease.Mode is ActivationLeaseMode.Diagnostic + && !Matches(argv, HostnameSmoke) + && !Matches(argv, PowerShellSmoke)) + { + throw new HostPolicyException( + "activation-lease-diagnostic-scope", + "The diagnostic activation lease permits only MicroClaw's fixed contained smokes."); + } + + return new ValidatedActivationLease( + lease.Mode, + lease.GatewayGeneration, + lease.PolicyFingerprint, + lease.ExpiresAtUnixMs, + lease.Signature); + } + + public void Revalidate(ValidatedActivationLease expected, IReadOnlyList argv) + { + var current = Validate(argv); + if (current != expected) + throw new HostPolicyException( + "activation-lease-changed", + "The MicroClaw activation lease changed before process launch."); + } + + public static string ComputeSignature( + string secretBase64, + ActivationLeaseMode mode, + string gatewayGeneration, + string policyFingerprint, + long expiresAtUnixMs) => + Convert.ToHexString( + Sign( + DecodeSecret(secretBase64), + mode, + gatewayGeneration, + policyFingerprint, + expiresAtUnixMs)) + .ToLowerInvariant(); + + private static byte[] Sign( + byte[] secret, + ActivationLeaseMode mode, + string gatewayGeneration, + string policyFingerprint, + long expiresAtUnixMs) + { + var payload = string.Join( + "\n", + ActivationLeaseContract.Version, + mode.ToString().ToLowerInvariant(), + gatewayGeneration, + policyFingerprint, + expiresAtUnixMs.ToString(System.Globalization.CultureInfo.InvariantCulture)); + return HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(payload)); + } + + private static bool Matches(IReadOnlyList actual, IReadOnlyList expected) => + actual.Count == expected.Count + && actual.Select((value, index) => + string.Equals( + value, + expected[index], + index == 0 ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + .All(matches => matches); + + private static byte[] DecodeSecret(string secretBase64) + { + try + { + var secret = Convert.FromBase64String(secretBase64); + if (secret.Length != 32) + throw new HostPolicyException( + "activation-secret-invalid", + "The activation lease secret must contain exactly 256 bits."); + return secret; + } + catch (FormatException) + { + throw new HostPolicyException( + "activation-secret-invalid", + "The activation lease secret is malformed."); + } + } +} diff --git a/windows-node-host/BundledSystemCapability.cs b/windows-node-host/BundledSystemCapability.cs index 6960f14..e677750 100644 --- a/windows-node-host/BundledSystemCapability.cs +++ b/windows-node-host/BundledSystemCapability.cs @@ -12,7 +12,8 @@ namespace MicroClaw.WindowsNodeHost; internal sealed class BundledSystemCapability( HostPolicy policy, string approvalPipeName, - string approvalsPath) : INodeCapability + string approvalsPath, + ActivationLeaseGuard activationLease) : INodeCapability { private readonly SemaphoreSlim _runGate = new(1, 1); private static readonly string[] SupportedCommands = @@ -37,7 +38,11 @@ public async Task ExecuteAsync( { return request.Command switch { - "system.run.cwd-policy" => Success(CwdPolicyAttestation.Current), + "system.run.cwd-policy" => Success( + CwdPolicyAttestation.Current with + { + DurableApprovalsPresent = DurableApprovalIdentity.Load(approvalsPath).Count > 0, + }), "system.which" => Success(new { bins = ResolveBins(request.Args) }), "system.run.prepare" => Success(Prepare(request.Args)), "system.run" => await RunAsync(request.Args, cancellationToken), @@ -91,6 +96,7 @@ private async Task RunExclusiveAsync( { var request = ParseRun(args); var cwd = policy.ResolveCwd(request.Cwd); + var validatedActivation = activationLease.Validate(request.Argv); var executable = ResolveExecutable(request.Argv[0]) ?? throw new HostPolicyException("executable-not-found", "The requested executable was not found."); var exactArgs = request.Argv.Skip(1).ToArray(); @@ -140,6 +146,7 @@ private async Task RunExclusiveAsync( .Append(currentCwd.LaunchPath) .Append(scratch)); currentCwd = policy.RevalidateCwd(request.Cwd, cwd.ApprovalBinding); + activationLease.Revalidate(validatedActivation, request.Argv); var launchCwd = string.IsNullOrEmpty(currentCwd.LaunchPath) ? scratch : currentCwd.LaunchPath; var readOnly = policy.ApprovedRoots .Where(root => root.Access == FolderAccess.ReadOnly) diff --git a/windows-node-host/CwdPolicy.cs b/windows-node-host/CwdPolicy.cs index 06ddadd..ac8bebe 100644 --- a/windows-node-host/CwdPolicy.cs +++ b/windows-node-host/CwdPolicy.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Win32.SafeHandles; @@ -19,7 +20,12 @@ public sealed record CwdPolicyAttestation( [property: JsonPropertyName("durableApprovalBindsCwd")] bool DurableApprovalBindsCwd, [property: JsonPropertyName("launchTimeRevalidation")] bool LaunchTimeRevalidation, [property: JsonPropertyName("omittedCwdUsesIsolatedScratch")] bool OmittedCwdUsesIsolatedScratch, - [property: JsonPropertyName("hostFallbackAbsent")] bool HostFallbackAbsent) + [property: JsonPropertyName("hostFallbackAbsent")] bool HostFallbackAbsent, + [property: JsonPropertyName("activationLeaseContract")] string ActivationLeaseContract, + [property: JsonPropertyName("generationBoundActivation")] bool GenerationBoundActivation, + [property: JsonPropertyName("policyBoundActivation")] bool PolicyBoundActivation, + [property: JsonPropertyName("launchTimeLeaseRevalidation")] bool LaunchTimeLeaseRevalidation, + [property: JsonPropertyName("durableApprovalsPresent")] bool DurableApprovalsPresent) { public static readonly CwdPolicyAttestation Current = new( CwdPolicyContract.Version, @@ -29,7 +35,12 @@ public sealed record CwdPolicyAttestation( DurableApprovalBindsCwd: true, LaunchTimeRevalidation: true, OmittedCwdUsesIsolatedScratch: true, - HostFallbackAbsent: true); + HostFallbackAbsent: true, + ActivationLeaseContract: global::MicroClaw.WindowsNodeHost.ActivationLeaseContract.Version, + GenerationBoundActivation: true, + PolicyBoundActivation: true, + LaunchTimeLeaseRevalidation: true, + DurableApprovalsPresent: false); } public enum FolderAccess @@ -61,7 +72,23 @@ public sealed class HostPolicy public static async Task LoadAsync(string path) { - var json = await File.ReadAllTextAsync(path); + var json = await File.ReadAllBytesAsync(path); + return Parse(json); + } + + public static async Task LoadVerifiedAsync(string path, string expectedSha256) + { + var json = await File.ReadAllBytesAsync(path); + var actualSha256 = Convert.ToHexString(SHA256.HashData(json)).ToLowerInvariant(); + if (!string.Equals(actualSha256, expectedSha256, StringComparison.Ordinal)) + throw new HostPolicyException( + "policy-fingerprint-mismatch", + "The loaded sandbox policy does not match MicroClaw's activation fingerprint."); + return Parse(json); + } + + private static HostPolicy Parse(ReadOnlySpan json) + { HostPolicyInput input; try { diff --git a/windows-node-host/Program.cs b/windows-node-host/Program.cs index 08672da..aff8b8a 100644 --- a/windows-node-host/Program.cs +++ b/windows-node-host/Program.cs @@ -64,7 +64,9 @@ private static async Task Main(string[] args) if (string.IsNullOrWhiteSpace(bootstrap.GatewayToken)) throw new HostPolicyException("gateway-token-missing", "The app-owned Gateway credential is missing."); - var policy = await HostPolicy.LoadAsync(bootstrap.PolicyPath); + var policy = await HostPolicy.LoadVerifiedAsync( + bootstrap.PolicyPath, + bootstrap.PolicyFingerprint); SecureStateDirectory.Ensure(bootstrap.IdentityDirectory); using var client = new WindowsNodeClient( gatewayUrl: gatewayUri.ToString(), @@ -86,7 +88,12 @@ Task AuthorizeGatewayAsync(CancellationToken _) => client.RegisterCapability(new BundledSystemCapability( policy, bootstrap.ApprovalPipeName, - bootstrap.ApprovalsPath)); + bootstrap.ApprovalsPath, + new ActivationLeaseGuard( + bootstrap.ActivationLeasePath, + bootstrap.ActivationLeaseSecret, + bootstrap.GatewayGeneration, + bootstrap.PolicyFingerprint))); await client.ConnectAsync(); await Task.Delay(Timeout.InfiniteTimeSpan); return 0; @@ -108,6 +115,10 @@ internal sealed class HostBootstrap public string IdentityDirectory { get; init; } = string.Empty; public string ApprovalPipeName { get; init; } = string.Empty; public string ApprovalsPath { get; init; } = string.Empty; + public string ActivationLeasePath { get; init; } = string.Empty; + public string ActivationLeaseSecret { get; init; } = string.Empty; + public string GatewayGeneration { get; init; } = string.Empty; + public string PolicyFingerprint { get; init; } = string.Empty; } internal sealed class StderrLogger : IOpenClawLogger From 43985fcfe0a4ab1abff0d357ea18542435fade8d Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:45:33 +0800 Subject: [PATCH 10/23] Fix Windows Node MXC approval flow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/electron-builder.yml | 2 + desktop/renderer/env.d.ts | 6 + desktop/renderer/src/App.vue | 52 ++- .../src/components/PermissionDialog.test.ts | 36 ++ .../src/components/PermissionDialog.vue | 23 +- desktop/renderer/src/views/SettingsView.vue | 59 --- .../prepare-windows-node-resources.mjs | 2 + desktop/src/bundled-windows-node-host.test.ts | 27 ++ desktop/src/bundled-windows-node-host.ts | 16 +- desktop/src/gateway-client.ts | 14 +- desktop/src/gateway-protocol.test.ts | 9 +- desktop/src/gateway-protocol.ts | 8 +- desktop/src/main.ts | 105 +++++- .../src/openclaw-approval-replay-compat.mjs | 135 +++++++ .../openclaw-approval-replay-compat.test.ts | 29 ++ desktop/src/windows-node-mxc-service.ts | 30 +- desktop/src/windows-node-mxc.test.ts | 83 +++++ desktop/src/windows-node-mxc.ts | 83 +++++ docs/experimental-windows-node-mxc.md | 24 ++ windows-node-host.Tests/CwdPolicyTests.cs | 341 ++++++++++++++++++ windows-node-host/BundledSystemCapability.cs | 249 ++++++++++++- .../MicroClaw.WindowsNodeHost.csproj | 1 + 22 files changed, 1227 insertions(+), 107 deletions(-) create mode 100644 desktop/src/openclaw-approval-replay-compat.mjs create mode 100644 desktop/src/openclaw-approval-replay-compat.test.ts diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 5ad9e74..aa35d52 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -49,6 +49,8 @@ nsis: extraResources: - from: dist/github-copilot-auth-worker.js to: github-copilot-auth-worker.js + - from: src/openclaw-approval-replay-compat.mjs + to: openclaw-approval-replay-compat.mjs - from: resources/node.exe to: node.exe - from: resources/openclaw/ diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 86ac369..2a8c22f 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -432,6 +432,12 @@ interface OpenClawAPI { arguments: string[]; agent: string | null; canonicalCwd: string; + approvalLayer: "gateway" | "node"; + allowedDecisions: Array<"deny" | "allow-once" | "allow-always">; + declaredAccess: Array<{ + access: "ro" | "rw"; + path: string; + }>; } | null, ) => void, ): () => void; diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index 19d68ed..a57ded1 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -231,16 +231,31 @@ interface PermissionRequestData { dirPath: string; command?: string; accessNeeded?: string; + app?: string; + source?: "sandbox" | "windows-node-mxc"; + allowedDecisions?: Array<"deny" | "allow-once" | "allow-always">; } const permissionQueue = ref([]); const currentPermission = computed(() => permissionQueue.value.length > 0 ? permissionQueue.value[0] : null, ); -function handlePermissionResponse(decision: string) { +async function handlePermissionResponse(decision: string) { const req = permissionQueue.value[0]; if (!req) return; - window.openclaw.sandbox.respondPermission(req.requestId, decision); + if (req.source === "windows-node-mxc") { + try { + await window.openclaw.windowsNodeMxc.respondApproval({ + requestId: req.requestId, + decision: decision as "deny" | "allow-once" | "allow-always", + }); + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : String(error)); + return; + } + } else { + window.openclaw.sandbox.respondPermission(req.requestId, decision); + } // Show immediate permission decision in exec panel if (decision === "deny") { chatStore.completeToolCall(req.requestId, t("perm.denied"), true); @@ -327,6 +342,7 @@ let unsubChatEvent: (() => void) | null = null; let unsubToolEvent: (() => void) | null = null; let unsubIntegrityAlert: (() => void) | null = null; let unsubPermission: (() => void) | null = null; +let unsubWindowsNodeMxcApproval: (() => void) | null = null; let unsubAclTimeout: (() => void) | null = null; let unsubAclIneffective: (() => void) | null = null; let unsubPermCompleted: (() => void) | null = null; @@ -388,7 +404,7 @@ onMounted(async () => { // Listen for sandbox permission requests unsubPermission = window.openclaw.sandbox.onPermissionRequest((data: PermissionRequestData) => { - permissionQueue.value.push(data); + permissionQueue.value.push({ ...data, source: "sandbox" }); // Add a pending entry to the exec panel so user sees what's waiting for permission if (chatStore.streaming) { const path = data.targetPath || data.dirPath; @@ -398,6 +414,35 @@ onMounted(async () => { } }); + unsubWindowsNodeMxcApproval = window.openclaw.windowsNodeMxc.onApprovalRequest((request) => { + if (!request) { + permissionQueue.value = permissionQueue.value.filter( + (pending) => pending.source !== "windows-node-mxc", + ); + return; + } + const declaredAccess = request.declaredAccess + .map((declaration) => `${declaration.access.toUpperCase()}: ${declaration.path}`) + .join("\n"); + const command = [request.executable, ...request.arguments].join(" "); + permissionQueue.value.push({ + requestId: request.id, + type: "app-approval", + targetPath: request.executable, + dirPath: request.canonicalCwd, + command: declaredAccess ? `${command}\n\nDeclared access:\n${declaredAccess}` : command, + app: + request.approvalLayer === "gateway" + ? "OpenClaw Gateway node command" + : "Contained Windows Node / MXC command", + source: "windows-node-mxc", + allowedDecisions: request.allowedDecisions, + }); + if (chatStore.streaming) { + chatStore.addPendingToolCall(request.id, "Contained MXC command approval"); + } + }); + // Listen for ACL verification timeout after user approved permission unsubAclTimeout = window.openclaw.sandbox.onAclTimeout?.((data: { dir: string; access: string }) => { @@ -553,6 +598,7 @@ onUnmounted(() => { unsubToolEvent?.(); unsubIntegrityAlert?.(); unsubPermission?.(); + unsubWindowsNodeMxcApproval?.(); unsubAclTimeout?.(); unsubAclIneffective?.(); unsubPermCompleted?.(); diff --git a/desktop/renderer/src/components/PermissionDialog.test.ts b/desktop/renderer/src/components/PermissionDialog.test.ts index 48031a9..410a878 100644 --- a/desktop/renderer/src/components/PermissionDialog.test.ts +++ b/desktop/renderer/src/components/PermissionDialog.test.ts @@ -24,6 +24,42 @@ describe("PermissionDialog", () => { ]); }); + it("shows only decisions advertised by a Gateway approval", () => { + const wrapper = mount(PermissionDialog, { + props: { + request: { + requestId: "gateway-request", + type: "app-approval", + app: "OpenClaw Gateway node command", + command: "Set-Content test.txt ok", + allowedDecisions: ["deny", "allow-once"], + }, + }, + }); + + expect(wrapper.findAll(".perm-actions button").map((button) => button.text())).toEqual([ + "Deny", + "Allow once", + ]); + }); + + it("shows the complete approval command and declared access", () => { + const command = `${"x".repeat(350)}\n\nDeclared access:\nRW C:\\Users\\test\\Desktop`; + const wrapper = mount(PermissionDialog, { + props: { + request: { + requestId: "gateway-request", + type: "app-approval", + app: "OpenClaw Gateway node command", + command, + allowedDecisions: ["deny", "allow-once"], + }, + }, + }); + + expect(wrapper.find(".perm-command-code").text()).toBe(command); + }); + it("keeps high-risk actions visible without optional semantic color tokens", () => { const globalStyles = readFileSync(resolve(process.cwd(), "src/styles/global.css"), "utf8"); diff --git a/desktop/renderer/src/components/PermissionDialog.vue b/desktop/renderer/src/components/PermissionDialog.vue index 5e4462a..15c4fc4 100644 --- a/desktop/renderer/src/components/PermissionDialog.vue +++ b/desktop/renderer/src/components/PermissionDialog.vue @@ -17,6 +17,7 @@ interface PermissionRequest { callerStack?: string; app?: string; accessNeeded?: string; + allowedDecisions?: Array<"deny" | "allow-once" | "allow-always">; } const props = defineProps<{ @@ -84,12 +85,16 @@ const descriptionHtml = computed(() => { const commandHtml = computed(() => { if (!props.request?.command) return ""; - const raw = props.request.command; - const truncated = raw.length > 300 ? raw.slice(0, 297) + "\u2026" : raw; - return escapeHtml(truncated); + return escapeHtml(props.request.command); }); const isAppApproval = computed(() => props.request?.type === "app-approval"); +const allowsOnce = computed( + () => !props.request?.allowedDecisions || props.request.allowedDecisions.includes("allow-once"), +); +const allowsAlways = computed( + () => !props.request?.allowedDecisions || props.request.allowedDecisions.includes("allow-always"), +); const isShellRequest = computed( () => props.request?.type === "shell" || props.request?.type === "shell-async", ); @@ -186,10 +191,18 @@ function respond(decision: string) { {{ t("perm.deny") }} diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue index a6e19e8..b43d854 100644 --- a/desktop/renderer/src/views/SettingsView.vue +++ b/desktop/renderer/src/views/SettingsView.vue @@ -1139,8 +1139,6 @@ const windowsNodeMxcApplying = ref(false); const windowsNodeMxcRefreshing = ref(false); const windowsNodeMxcSmokeRunning = ref(false); const windowsNodeMxcActivating = ref(false); -let windowsNodeMxcApprovalUnsubscribe: (() => void) | null = null; -let activeWindowsNodeMxcApprovalId: string | null = null; function updateWindowsNodeMxcStatus(status: WindowsNodeMxcStatus) { windowsNodeMxcStatus.value = status; @@ -1824,62 +1822,6 @@ watch(showProviderSetup, (visible, wasVisible) => { onMounted(async () => { window.addEventListener("focus", handleSettingsWindowFocus); - const subscribeApproval = window.openclaw.windowsNodeMxc.onApprovalRequest; - if (typeof subscribeApproval === "function") { - windowsNodeMxcApprovalUnsubscribe = subscribeApproval(async (request) => { - if (!request) { - if (activeWindowsNodeMxcApprovalId) { - activeWindowsNodeMxcApprovalId = null; - ElMessageBox.close(); - } - return; - } - activeWindowsNodeMxcApprovalId = request.id; - const command = [request.executable, ...request.arguments].join(" "); - let decision: "deny" | "allow-once" | "allow-always" = "deny"; - try { - await ElMessageBox.confirm( - `${command}\n\nAgent: ${request.agent ?? "unknown"}\nCWD: ${request.canonicalCwd}`, - "Allow contained MXC command?", - { - confirmButtonText: "Allow once", - cancelButtonText: "Deny", - distinguishCancelAndClose: true, - type: "warning", - }, - ); - decision = "allow-once"; - try { - await ElMessageBox.confirm( - "Remember this exact executable, argv, and canonical CWD for future contained runs?", - "Durable approval", - { - confirmButtonText: "Allow always", - cancelButtonText: "Allow once", - distinguishCancelAndClose: true, - type: "warning", - }, - ); - decision = "allow-always"; - } catch { - decision = "allow-once"; - } - } catch { - decision = "deny"; - } - if (activeWindowsNodeMxcApprovalId !== request.id) return; - activeWindowsNodeMxcApprovalId = null; - try { - await window.openclaw.windowsNodeMxc.respondApproval({ - requestId: request.id, - decision, - }); - } catch (error) { - ElMessage.error(error instanceof Error ? error.message : String(error)); - } - }); - } - // Load persisted app settings const saved = await window.openclaw.settings.get(); if (saved) { @@ -1912,7 +1854,6 @@ onMounted(async () => { onUnmounted(() => { window.removeEventListener("focus", handleSettingsWindowFocus); - windowsNodeMxcApprovalUnsubscribe?.(); copilotModelsGeneration += 1; }); diff --git a/desktop/scripts/prepare-windows-node-resources.mjs b/desktop/scripts/prepare-windows-node-resources.mjs index d6a9d0d..7bf4c58 100644 --- a/desktop/scripts/prepare-windows-node-resources.mjs +++ b/desktop/scripts/prepare-windows-node-resources.mjs @@ -119,6 +119,8 @@ const publish = spawnSync( "-p:DebugType=None", "-p:ImportDirectoryBuildProps=false", "-p:ImportDirectoryBuildTargets=false", + "-p:DisableGitVersionTask=true", + "-m:1", ], { stdio: "inherit" }, ); diff --git a/desktop/src/bundled-windows-node-host.test.ts b/desktop/src/bundled-windows-node-host.test.ts index 1f39afc..53be93b 100644 --- a/desktop/src/bundled-windows-node-host.test.ts +++ b/desktop/src/bundled-windows-node-host.test.ts @@ -9,6 +9,7 @@ import { createBundledWindowsNodeEnvironment, findBundledDevicePairRequest, findBundledNodePairRequest, + validateApprovalRequest, } from "./bundled-windows-node-host"; describe("bundled Windows node host", () => { @@ -141,6 +142,32 @@ describe("bundled Windows node host", () => { expect(() => assertApprovalResponseMatches("request-1", "request-2")).toThrow(/does not match/); }); + it("accepts canonical declared access in an attended approval request", () => { + expect( + validateApprovalRequest({ + id: "request-1", + executable: String.raw`C:\Windows\System32\cmd.exe`, + arguments: ["/c", "echo hello"], + agent: "main", + canonicalCwd: "isolated-scratch:v1", + declaredAccess: [{ access: "rw", path: String.raw`C:\Users\test\Desktop` }], + }).declaredAccess, + ).toEqual([{ access: "rw", path: String.raw`C:\Users\test\Desktop` }]); + }); + + it("rejects malformed declared access before surfacing an approval", () => { + expect(() => + validateApprovalRequest({ + id: "request-1", + executable: String.raw`C:\Windows\System32\cmd.exe`, + arguments: [], + agent: null, + canonicalCwd: "isolated-scratch:v1", + declaredAccess: [{ access: "write", path: String.raw`C:\Users\test\Desktop` }], + }), + ).toThrow(/Malformed/); + }); + it("uses a controlled system-only helper environment", () => { const environment = createBundledWindowsNodeEnvironment( String.raw`C:\Windows`, diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index f0b9c3d..cd70df0 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -57,6 +57,10 @@ export interface BundledApprovalRequest { arguments: string[]; agent: string | null; canonicalCwd: string; + declaredAccess: Array<{ + access: "ro" | "rw"; + path: string; + }>; } interface StartOptions { @@ -667,14 +671,21 @@ function sensitiveWindowsRoots(stateRoot: string): string[] { ]; } -function validateApprovalRequest(value: unknown): BundledApprovalRequest { +export function validateApprovalRequest(value: unknown): BundledApprovalRequest { const request = value as Partial; if ( typeof request.id !== "string" || typeof request.executable !== "string" || !Array.isArray(request.arguments) || request.arguments.some((argument) => typeof argument !== "string") || - typeof request.canonicalCwd !== "string" + typeof request.canonicalCwd !== "string" || + !Array.isArray(request.declaredAccess) || + request.declaredAccess.some( + (declaration) => + !declaration || + (declaration.access !== "ro" && declaration.access !== "rw") || + typeof declaration.path !== "string", + ) ) { throw new Error("Malformed bundled Windows node approval request"); } @@ -684,5 +695,6 @@ function validateApprovalRequest(value: unknown): BundledApprovalRequest { arguments: request.arguments, agent: typeof request.agent === "string" ? request.agent : null, canonicalCwd: request.canonicalCwd, + declaredAccess: request.declaredAccess, }; } diff --git a/desktop/src/gateway-client.ts b/desktop/src/gateway-client.ts index ccb48ac..2a9fbba 100644 --- a/desktop/src/gateway-client.ts +++ b/desktop/src/gateway-client.ts @@ -26,7 +26,7 @@ import { WS_RECONNECT_MULTIPLIER, WS_REQUEST_TIMEOUT_MS, } from "./constants"; -import { buildGatewayConnectParams } from "./gateway-protocol"; +import { buildGatewayConnectParams, GATEWAY_OPERATOR_SCOPES } from "./gateway-protocol"; import type { ChatAttachment } from "./chat-attachments"; // ── Types ─────────────────────────────────────────────────────────────── @@ -252,7 +252,11 @@ export class GatewayClient { // ── Public API ── - async request(method: string, params?: unknown): Promise { + async request( + method: string, + params?: unknown, + timeoutMs = WS_REQUEST_TIMEOUT_MS, + ): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error("gateway not connected"); } @@ -262,9 +266,9 @@ export class GatewayClient { // Auto-reject after timeout to prevent hung promises const timer = setTimeout(() => { if (this.pending.delete(id)) { - reject(new Error(`request '${method}' timed out after ${WS_REQUEST_TIMEOUT_MS}ms`)); + reject(new Error(`request '${method}' timed out after ${timeoutMs}ms`)); } - }, WS_REQUEST_TIMEOUT_MS); + }, timeoutMs); this.pending.set(id, { resolve: (v) => { clearTimeout(timer); @@ -596,7 +600,7 @@ export class GatewayClient { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; const role = "operator"; - const scopes = ["operator.admin", "operator.read", "operator.write"]; + const scopes = [...GATEWAY_OPERATOR_SCOPES]; const clientId = "gateway-client"; const clientMode = "backend"; const nonce = this.connectNonce ?? ""; diff --git a/desktop/src/gateway-protocol.test.ts b/desktop/src/gateway-protocol.test.ts index ffc5255..2cc82aa 100644 --- a/desktop/src/gateway-protocol.test.ts +++ b/desktop/src/gateway-protocol.test.ts @@ -3,6 +3,7 @@ import { buildGatewayConnectParams, GATEWAY_MAX_PROTOCOL_VERSION, GATEWAY_MIN_PROTOCOL_VERSION, + GATEWAY_OPERATOR_SCOPES, } from "./gateway-protocol"; describe("buildGatewayConnectParams", () => { @@ -27,7 +28,7 @@ describe("buildGatewayConnectParams", () => { mode: "backend", }, role: "operator", - scopes: ["operator.admin", "operator.read", "operator.write"], + scopes: ["operator.admin", "operator.read", "operator.write", "operator.approvals"], device: { id: "device", publicKey: "public", @@ -40,6 +41,12 @@ describe("buildGatewayConnectParams", () => { }); expect(GATEWAY_MIN_PROTOCOL_VERSION).toBe(3); expect(GATEWAY_MAX_PROTOCOL_VERSION).toBe(4); + expect(GATEWAY_OPERATOR_SCOPES).toEqual([ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + ]); }); it("omits auth when no token is configured", () => { diff --git a/desktop/src/gateway-protocol.ts b/desktop/src/gateway-protocol.ts index 4bc48c1..598ad4e 100644 --- a/desktop/src/gateway-protocol.ts +++ b/desktop/src/gateway-protocol.ts @@ -1,5 +1,11 @@ export const GATEWAY_MIN_PROTOCOL_VERSION = 3; export const GATEWAY_MAX_PROTOCOL_VERSION = 4; +export const GATEWAY_OPERATOR_SCOPES = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", +] as const; export type GatewayConnectInput = { token: string; @@ -22,7 +28,7 @@ export function buildGatewayConnectParams(input: GatewayConnectInput): Record | null = null; // This gate covers MicroClaw-owned ingress. Independently configured upstream Gateway ingress is // outside this experimental mode's accepted boundary and must not share the app-owned Gateway. @@ -2235,6 +2243,8 @@ function terminateGatewayProcessTree(pid: number): void { } async function stopGatewayForSecurityTransition(port: number): Promise { + pendingWindowsNodeMxcGatewayApproval = null; + mainWindow?.webContents.send("windows-node-mxc:approval-request", null); stopBundledWindowsNodeHost(); const managedPid = gatewaySpawnedByUs && isManagedGatewayProcessAlive() ? gatewayProcess?.pid : undefined; @@ -2978,7 +2988,24 @@ async function startGatewayInner(): Promise { console.log(`[sandbox] Preload: ${preloadForward}`); } + const windowsNodeMxcDesired = isWindowsNodeMxcDesired(); + const approvalCompatPath = app.isPackaged + ? path.join(process.resourcesPath, "openclaw-approval-replay-compat.mjs") + : path.join(__dirname, "..", "src", "openclaw-approval-replay-compat.mjs"); + if (windowsNodeMxcDesired) { + if (!fs.existsSync(approvalCompatPath)) { + const msg = `[error] Windows Node + MXC approval compatibility preload is missing: ${approvalCompatPath}`; + console.error(msg); + mainWindow?.webContents.send("gateway:log", msg); + setGatewayStatus("failed"); + return; + } + gwEnv.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT = "1"; + gwEnv.MICROCLAW_OPENCLAW_PACKAGE_DIR = openClawPackageDir; + } + const gwArgs = [ + ...(windowsNodeMxcDesired ? ["--import", pathToFileURL(approvalCompatPath).href] : []), entryPath, "gateway", "run", @@ -3433,7 +3460,16 @@ function connectGatewayWs(): void { gatewayGeneration: gatewayGenerationId, folders: getBundledWindowsNodeFolders(), onApproval: (approval: BundledApprovalRequest | null) => - mainWindow?.webContents.send("windows-node-mxc:approval-request", approval), + mainWindow?.webContents.send( + "windows-node-mxc:approval-request", + approval + ? { + ...approval, + approvalLayer: "node", + allowedDecisions: ["deny", "allow-once", "allow-always"], + } + : null, + ), }; const hostGeneration = ++bundledWindowsNodeGeneration; bundledWindowsNodeHost.stop(); @@ -3554,6 +3590,50 @@ function connectGatewayWs(): void { } }, onEvent: (evt) => { + if (evt.event === "exec.approval.requested" && isWindowsNodeMxcDesired()) { + const approval = normalizeWindowsNodeMxcGatewayApproval( + evt.payload, + settingsStore.get("windowsNodeMxcNodeId"), + ); + if (!approval || !isWindowsNodeMxcIngressReleased( + true, + gatewayGenerationId, + windowsNodeMxcIngressGeneration, + windowsNodeMxcActivationInProgress, + )) { + const id = + evt.payload && typeof evt.payload === "object" && !Array.isArray(evt.payload) + ? (evt.payload as Record).id + : null; + if (typeof id === "string" && id) { + void gwClient?.request("exec.approval.resolve", { id, decision: "deny" }); + } + return; + } + pendingWindowsNodeMxcGatewayApproval = { + request: approval, + gatewayGeneration: gatewayGenerationId, + }; + mainWindow?.webContents.send("windows-node-mxc:approval-request", { + ...approval, + executable: "OpenClaw Gateway node exec approval", + arguments: [approval.command], + declaredAccess: [], + approvalLayer: "gateway", + }); + return; + } + if (evt.event === "exec.approval.resolved") { + const id = + evt.payload && typeof evt.payload === "object" && !Array.isArray(evt.payload) + ? (evt.payload as Record).id + : null; + if (id === pendingWindowsNodeMxcGatewayApproval?.request.id) { + pendingWindowsNodeMxcGatewayApproval = null; + mainWindow?.webContents.send("windows-node-mxc:approval-request", null); + } + return; + } if (evt.event === "agent") { const p = evt.payload as Record | undefined; if (p && p.stream === "tool") { @@ -5328,7 +5408,7 @@ function registerIpcHandlers(): void { ipcMain.handle( "windows-node-mxc:approval-respond", - ( + async ( _event, params: { requestId?: unknown; @@ -5346,7 +5426,26 @@ function registerIpcHandlers(): void { ) { throw new Error("Invalid Windows node approval decision"); } - bundledWindowsNodeHost.respond(requestId, decision as "deny" | "allow-once" | "allow-always"); + const normalizedDecision = decision as WindowsNodeMxcApprovalDecision; + const gatewayApproval = pendingWindowsNodeMxcGatewayApproval; + if (gatewayApproval?.request.id === requestId) { + if (gatewayApproval.gatewayGeneration !== gatewayGenerationId) { + throw new Error("The Gateway approval belongs to a stale security generation"); + } + if (!gatewayApproval.request.allowedDecisions.includes(normalizedDecision)) { + throw new Error("The requested Gateway approval decision is unavailable"); + } + if (normalizedDecision !== "deny") await requireEffectiveWindowsNodeMxc(); + if (!gwClient?.connected) throw new Error("The managed Gateway is not connected"); + await gwClient.request("exec.approval.resolve", { + id: requestId, + decision: normalizedDecision, + }); + pendingWindowsNodeMxcGatewayApproval = null; + mainWindow?.webContents.send("windows-node-mxc:approval-request", null); + return; + } + bundledWindowsNodeHost.respond(requestId, normalizedDecision); }, ); diff --git a/desktop/src/openclaw-approval-replay-compat.mjs b/desktop/src/openclaw-approval-replay-compat.mjs new file mode 100644 index 0000000..77fcb17 --- /dev/null +++ b/desktop/src/openclaw-approval-replay-compat.mjs @@ -0,0 +1,135 @@ +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { isAbsolute, join, relative } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const EXPECTED_VERSION = "2026.7.1-1"; +const EXPECTED_MODULE = "gateway-tPQsEkmF.js"; +const EXPECTED_SHA256 = "6eed85ef8b377cffa593578227758443b37fc98f87c74848f9ddb4ad8db46bb5"; + +export const PINNED_FUNCTION_SOURCE = `function resolveApprovalRequesterDeviceIdentityForGatewayTool(params) { +\tif (!APPROVAL_RUNTIME_METHODS.has(params.method)) return; +\tif (trimToUndefined(params.opts.gatewayUrl) !== void 0) return; +\ttry { +\t\tconst identity = loadOrCreateDeviceIdentity(); +\t\tif (loadDeviceIdentityIfPresent()?.deviceId !== identity.deviceId) throw new Error("device identity is not persisted"); +\t\treturn identity; +\t} catch (error) { +\t\tif (params.target === "local") return; +\t\tthrow new Error(["remote approval gateway calls require a stable device identity.", "Fix the OpenClaw state directory permissions or use the local approval-runtime gateway."].join(" "), { cause: error }); +\t} +}`; + +const PATCHED_FUNCTION_SOURCE = `function isApprovalReplayNodeSystemRun(method, callParams) { +\tconst invoke = method === "node.invoke" && callParams && typeof callParams === "object" && !Array.isArray(callParams) ? callParams : null; +\tconst run = invoke?.command === "system.run" && invoke.params && typeof invoke.params === "object" && !Array.isArray(invoke.params) ? invoke.params : null; +\tconst decision = normalizeOptionalString(run?.approvalDecision); +\treturn run?.approved === true || decision === "allow-once" || decision === "allow-always"; +} +function resolveApprovalRequesterDeviceIdentityForGatewayTool(params) { +\tconst isApprovalRuntimeMethod = APPROVAL_RUNTIME_METHODS.has(params.method); +\tconst isNodeApprovalReplay = isApprovalReplayNodeSystemRun(params.method, params.callParams); +\tif (!isApprovalRuntimeMethod && !isNodeApprovalReplay) return; +\tif (isApprovalRuntimeMethod && trimToUndefined(params.opts.gatewayUrl) !== void 0) return; +\ttry { +\t\tif (isNodeApprovalReplay) { +\t\t\tconst identity = loadDeviceIdentityIfPresent(); +\t\t\tif (!identity) throw new Error("device identity is not persisted"); +\t\t\treturn identity; +\t\t} +\t\tconst identity = loadOrCreateDeviceIdentity(); +\t\tif (loadDeviceIdentityIfPresent()?.deviceId !== identity.deviceId) throw new Error("device identity is not persisted"); +\t\treturn identity; +\t} catch (error) { +\t\tif (isNodeApprovalReplay) throw new Error(["approved node gateway calls require a stable device identity.", "Fix the OpenClaw state directory permissions and retry the approval."].join(" "), { cause: error }); +\t\tif (params.target === "local") return; +\t\tthrow new Error(["remote approval gateway calls require a stable device identity.", "Fix the OpenClaw state directory permissions or use the local approval-runtime gateway."].join(" "), { cause: error }); +\t} +}`; + +export const PINNED_CALL_SOURCE = `const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({ +\t\tmethod, +\t\topts, +\t\ttarget: gateway.target +\t});`; + +const PATCHED_CALL_SOURCE = `const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({ +\t\tmethod, +\t\tcallParams: params, +\t\topts, +\t\ttarget: gateway.target +\t});`; + +function replaceExactlyOnce(source, expected, replacement, label) { + const first = source.indexOf(expected); + if (first < 0 || source.indexOf(expected, first + expected.length) >= 0) { + throw new Error(`Pinned OpenClaw ${label} did not match exactly once`); + } + return source.slice(0, first) + replacement + source.slice(first + expected.length); +} + +export function patchPinnedOpenClawGateway(source) { + const functionPatched = replaceExactlyOnce( + source, + PINNED_FUNCTION_SOURCE, + PATCHED_FUNCTION_SOURCE, + "approval identity function", + ); + return replaceExactlyOnce( + functionPatched, + PINNED_CALL_SOURCE, + PATCHED_CALL_SOURCE, + "approval identity call", + ); +} + +function initialize() { + const packageDir = process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR; + if (!packageDir || !isAbsolute(packageDir)) { + throw new Error("MICROCLAW_OPENCLAW_PACKAGE_DIR must be an absolute path"); + } + + const canonicalPackageDir = realpathSync(packageDir); + const packageJson = JSON.parse(readFileSync(join(canonicalPackageDir, "package.json"), "utf8")); + if (packageJson.version !== EXPECTED_VERSION) { + throw new Error( + `Windows Node + MXC approval compatibility requires OpenClaw ${EXPECTED_VERSION}; found ${packageJson.version ?? "unknown"}`, + ); + } + + const targetPath = realpathSync(join(canonicalPackageDir, "dist", EXPECTED_MODULE)); + const targetRelative = relative(canonicalPackageDir, targetPath); + if (targetRelative.startsWith("..") || isAbsolute(targetRelative)) { + throw new Error("Pinned OpenClaw approval module escaped its package directory"); + } + const original = readFileSync(targetPath); + const hash = createHash("sha256").update(original).digest("hex"); + if (hash !== EXPECTED_SHA256) { + throw new Error(`Pinned OpenClaw approval module hash mismatch: ${hash}`); + } + + registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + const loadedPath = url.startsWith("file:") ? realpathSync(fileURLToPath(url)) : null; + if (loadedPath === targetPath) { + const source = + typeof result.source === "string" + ? result.source + : Buffer.from(result.source ?? original).toString("utf8"); + return { ...result, source: patchPinnedOpenClawGateway(source) }; + } + return result; + }, + }); + globalThis.console.log( + "[microclaw-openclaw-compat] enabled approval replay identity backport openclaw/openclaw#103886", + ); +} + +if (process.env.MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT === "1") { + initialize(); +} diff --git a/desktop/src/openclaw-approval-replay-compat.test.ts b/desktop/src/openclaw-approval-replay-compat.test.ts new file mode 100644 index 0000000..51b7705 --- /dev/null +++ b/desktop/src/openclaw-approval-replay-compat.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +// @ts-expect-error The compatibility preload is intentionally external ESM for the Gateway child. +import * as replayCompat from "./openclaw-approval-replay-compat.mjs"; + +const { PINNED_CALL_SOURCE, PINNED_FUNCTION_SOURCE, patchPinnedOpenClawGateway } = replayCompat; + +describe("OpenClaw node approval replay compatibility", () => { + it("backports the upstream replay identity binding", () => { + const patched = patchPinnedOpenClawGateway( + `${PINNED_FUNCTION_SOURCE}\nfixture\n${PINNED_CALL_SOURCE}`, + ); + + expect(patched).toContain("isApprovalReplayNodeSystemRun"); + expect(patched).toContain("callParams: params"); + expect(patched).toContain("loadDeviceIdentityIfPresent()"); + expect(patched).not.toContain(PINNED_FUNCTION_SOURCE); + }); + + it("fails closed when the pinned source shape drifts", () => { + expect(() => patchPinnedOpenClawGateway("unrecognized source")).toThrow( + "did not match exactly once", + ); + expect(() => + patchPinnedOpenClawGateway( + `${PINNED_FUNCTION_SOURCE}\n${PINNED_FUNCTION_SOURCE}\n${PINNED_CALL_SOURCE}`, + ), + ).toThrow("did not match exactly once"); + }); +}); diff --git a/desktop/src/windows-node-mxc-service.ts b/desktop/src/windows-node-mxc-service.ts index b45f211..d3b1b05 100644 --- a/desktop/src/windows-node-mxc-service.ts +++ b/desktop/src/windows-node-mxc-service.ts @@ -33,10 +33,12 @@ import type { const WINDOWS_NODE_SETTINGS_FILENAME = "settings.json"; const HOSTNAME_MARKER = "MICROCLAW_MXC_HOSTNAME_OK"; const POWERSHELL_MARKER = "MICROCLAW_MXC_POWERSHELL_OK"; +const ATTENDED_SMOKE_GATEWAY_TIMEOUT_MS = 90_000; +const ATTENDED_SMOKE_CLIENT_TIMEOUT_MS = 95_000; export interface WindowsNodeMxcGateway { connected: boolean; - request(method: string, params?: unknown): Promise; + request(method: string, params?: unknown, timeoutMs?: number): Promise; } export interface StoredWindowsNodeMxcSmoke { @@ -615,18 +617,22 @@ async function invokeSmoke( marker: string, ): Promise { try { - const result = await gateway.request("node.invoke", { - nodeId, - command: "system.run", - params: { - command, - timeoutMs: 15_000, - agentId: "main", - sessionKey: "agent:main:main", + const result = await gateway.request( + "node.invoke", + { + nodeId, + command: "system.run", + params: { + command, + timeoutMs: 15_000, + agentId: "main", + sessionKey: "agent:main:main", + }, + timeoutMs: ATTENDED_SMOKE_GATEWAY_TIMEOUT_MS, + idempotencyKey: randomUUID(), }, - timeoutMs: 60_000, - idempotencyKey: randomUUID(), - }); + ATTENDED_SMOKE_CLIENT_TIMEOUT_MS, + ); return classifyMxcSmoke(result, marker); } catch (error) { return classifyMxcSmoke({ error: messageOf(error) }, marker); diff --git a/desktop/src/windows-node-mxc.test.ts b/desktop/src/windows-node-mxc.test.ts index cf2c4e6..804e7a7 100644 --- a/desktop/src/windows-node-mxc.test.ts +++ b/desktop/src/windows-node-mxc.test.ts @@ -12,6 +12,7 @@ import { getWindowsNodeMxcGatewayPolicyState, isWindowsNodeMxcIngressReleased, listAgentSessionKeys, + normalizeWindowsNodeMxcGatewayApproval, normalizeWindowsNodeRecord, restoreWindowsNodeMxcGatewayPolicy, validateEffectiveToolNames, @@ -46,6 +47,88 @@ const strictSettings = { SandboxClipboard: 0, }; +describe("Gateway node exec approval bridge", () => { + const nodeId = "a".repeat(64); + const payload = { + id: "approval-1", + allowedDecisions: ["allow-once", "deny"], + request: { + host: "node", + nodeId, + systemRunPlan: { + argv: ["powershell.exe", "-Command", "Set-Content test.txt ok"], + cwd: null, + commandText: 'powershell.exe -Command "Set-Content test.txt ok"', + commandPreview: "Set-Content test.txt ok", + agentId: "main", + sessionKey: "agent:main:approval-regression", + }, + }, + }; + + it("accepts only a canonical approval for the selected node", () => { + expect(normalizeWindowsNodeMxcGatewayApproval(payload, nodeId)).toEqual({ + id: "approval-1", + command: "Set-Content test.txt ok", + canonicalCwd: "isolated-scratch:v1", + agent: "main", + allowedDecisions: ["allow-once", "deny"], + }); + }); + + it("rejects another node, host execution, and a malformed canonical plan", () => { + expect(normalizeWindowsNodeMxcGatewayApproval(payload, "b".repeat(64))).toBeNull(); + expect( + normalizeWindowsNodeMxcGatewayApproval( + { ...payload, request: { ...payload.request, host: "gateway" } }, + nodeId, + ), + ).toBeNull(); + expect( + normalizeWindowsNodeMxcGatewayApproval( + { + ...payload, + request: { + ...payload.request, + systemRunPlan: { ...payload.request.systemRunPlan, argv: [] }, + }, + }, + nodeId, + ), + ).toBeNull(); + for (const invalidPlan of [ + { ...payload.request.systemRunPlan, argv: ["powershell.exe", 1] }, + { ...payload.request.systemRunPlan, commandText: undefined }, + { ...payload.request.systemRunPlan, cwd: undefined }, + { ...payload.request.systemRunPlan, agentId: undefined }, + { ...payload.request.systemRunPlan, sessionKey: undefined }, + ]) { + expect( + normalizeWindowsNodeMxcGatewayApproval( + { + ...payload, + request: { + ...payload.request, + command: "unrelated fallback", + systemRunPlan: invalidPlan, + }, + }, + nodeId, + ), + ).toBeNull(); + } + }); + + it("never invents allow-always when the Gateway does not advertise it", () => { + expect( + normalizeWindowsNodeMxcGatewayApproval( + { ...payload, allowedDecisions: undefined }, + nodeId, + )?.allowedDecisions, + ).toEqual(["allow-once", "deny"]); + }); +}); + describe("bundled Windows Node CWD attestation", () => { it("requires every exact fail-closed contract property", () => { expect( diff --git a/desktop/src/windows-node-mxc.ts b/desktop/src/windows-node-mxc.ts index 96a3277..67226a8 100644 --- a/desktop/src/windows-node-mxc.ts +++ b/desktop/src/windows-node-mxc.ts @@ -49,6 +49,89 @@ export interface WindowsNodeMxcFolder { access: SandboxFolderAccess; } +export type WindowsNodeMxcApprovalDecision = "deny" | "allow-once" | "allow-always"; + +export interface WindowsNodeMxcGatewayApproval { + id: string; + command: string; + canonicalCwd: string; + agent: string | null; + allowedDecisions: WindowsNodeMxcApprovalDecision[]; +} + +export function normalizeWindowsNodeMxcGatewayApproval( + payload: unknown, + selectedNodeId: string, +): WindowsNodeMxcGatewayApproval | null { + const envelope = asRecord(payload); + const request = asRecord(envelope.request); + const plan = asRecord(request.systemRunPlan); + const id = typeof envelope.id === "string" ? envelope.id.trim() : ""; + const host = typeof request.host === "string" ? request.host : ""; + const nodeId = typeof request.nodeId === "string" ? request.nodeId : ""; + const argv = plan.argv; + const commandText = typeof plan.commandText === "string" ? plan.commandText.trim() : ""; + const commandPreview = + typeof plan.commandPreview === "string" ? plan.commandPreview.trim() : ""; + const hasCanonicalCwd = + Object.hasOwn(plan, "cwd") && + (plan.cwd === null || (typeof plan.cwd === "string" && plan.cwd.trim().length > 0)); + const hasCanonicalAgent = + Object.hasOwn(plan, "agentId") && + (plan.agentId === null || + (typeof plan.agentId === "string" && plan.agentId.trim().length > 0)); + const hasCanonicalSession = + Object.hasOwn(plan, "sessionKey") && + typeof plan.sessionKey === "string" && + plan.sessionKey.trim().length > 0; + const hasCanonicalPreview = + !Object.hasOwn(plan, "commandPreview") || + plan.commandPreview === null || + typeof plan.commandPreview === "string"; + const command = + commandPreview || commandText; + if ( + !id || + host !== "node" || + nodeId.toLowerCase() !== selectedNodeId.trim().toLowerCase() || + !Array.isArray(argv) || + argv.length === 0 || + !argv.every((value) => typeof value === "string" && value.length > 0) || + !commandText || + !hasCanonicalCwd || + !hasCanonicalAgent || + !hasCanonicalSession || + !hasCanonicalPreview + ) { + return null; + } + + const advertised = Array.isArray(envelope.allowedDecisions) + ? envelope.allowedDecisions + : Array.isArray(request.allowedDecisions) + ? request.allowedDecisions + : []; + const allowed = advertised.filter( + (decision): decision is WindowsNodeMxcApprovalDecision => + decision === "deny" || decision === "allow-once" || decision === "allow-always", + ); + if (!allowed.includes("deny")) allowed.push("deny"); + if (!allowed.includes("allow-once") && !allowed.includes("allow-always")) { + allowed.unshift("allow-once"); + } + + return { + id, + command, + canonicalCwd: + typeof plan.cwd === "string" && plan.cwd.trim() ? plan.cwd : CWD_POLICY_SCRATCH_BINDING, + agent: typeof plan.agentId === "string" && plan.agentId.trim() ? plan.agentId : null, + allowedDecisions: [...new Set(allowed)], + }; +} + +const CWD_POLICY_SCRATCH_BINDING = "isolated-scratch:v1"; + export interface WindowsNodeMxcSettings { EnableNodeMode?: boolean; NodeSystemRunEnabled?: boolean; diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index ffa6faa..1fc7b77 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -64,6 +64,13 @@ read-only handle that denies write/delete sharing from pre-approval hashing thro Runs are serialized, so attended approvals cannot overlap, and each contained child inherits `TEMP`/`TMP`/`TMPDIR` pointing at its own writable scratch grant. +Optional `[declare-access]ro:;rw:[/declare-access]` metadata is accepted only on leading +metadata lines (with an optional `#`, `REM`, or `::` comment prefix). It can describe only canonical +paths already covered by the global folder policy and cannot upgrade RO to RW. The helper removes +the metadata line from the executable shell payload, binds approval to the cleaned argv, and carries +the original declaration only as the approved plan's display preview. Replay must contain that exact +Gateway-approved plan; mismatched plan argv or command text is denied before the node prompt. + ## Packaging and provenance `openclaw/openclaw-windows-node` is pinned as a submodule at @@ -142,6 +149,23 @@ helper reconnects; timeout or a Gateway-generation change stops the helper. Tran query effective tools is recorded as unverified and does not kill an otherwise statically locked Gateway; confirmed drift still stops it. +OpenClaw 2026.7.1-1 also binds a node approval request to a persisted operator device but omits that +identity when replaying the approved `node.invoke system.run`, causing +`approval id not valid for this device`. Upstream fixed the defect in +[openclaw/openclaw#103886](https://github.com/openclaw/openclaw/pull/103886), commit +`7a38f140a2cf2c99dd08f92db3ea1b291d5b10c4`. MXC mode enables a MicroClaw-owned Node load hook that +backports only that replay-identity change in memory. The installed OpenClaw package is not modified. +The hook requires OpenClaw `2026.7.1-1` and SHA-256 +`6eed85ef8b377cffa593578227758443b37fc98f87c74848f9ddb4ad8db46bb5` for the affected compiled +module; any version, hash, or source-shape mismatch prevents the managed Gateway from starting. + +The same pinned Gateway binds approval registration and replay to the prepared plan's exact argv, +CWD, agent, and session. The bundled host therefore returns the canonical +`{ plan: { argv, commandText, cwd, agentId, sessionKey } }` response and copies `sessionKey` from the +inner `system.run.prepare` parameters, not the node envelope. Dropping it makes registration fall +back to the chat session while replay normalizes the plan to `null`, which the Gateway correctly +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 diff --git a/windows-node-host.Tests/CwdPolicyTests.cs b/windows-node-host.Tests/CwdPolicyTests.cs index 58d3fd0..b5dd733 100644 --- a/windows-node-host.Tests/CwdPolicyTests.cs +++ b/windows-node-host.Tests/CwdPolicyTests.cs @@ -1,8 +1,10 @@ using MicroClaw.WindowsNodeHost; +using OpenClaw.Shared; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Xunit; namespace MicroClaw.WindowsNodeHost.Tests; @@ -457,6 +459,296 @@ await File.WriteAllTextAsync( Assert.Equal("approved-root-overlaps-sensitive-root", error.Code); } + [Fact] + public async Task RunPrepareReturnsPinnedOpenClawApprovalPlan() + { + var policy = Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]); + var capability = Capability(policy); + var args = Parse( + """ + { + "command": ["cmd.exe", "/d", "/s", "/c", "echo hello"], + "rawCommand": "echo hello", + "agentId": "main", + "sessionKey": "agent:main:approval-regression" + } + """); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = args, + }, + TestContext.Current.CancellationToken); + + Assert.True(response.Ok); + var payload = JsonSerializer.SerializeToElement(response.Payload); + var plan = payload.GetProperty("plan"); + Assert.Equal( + ["cmd.exe", "/d", "/s", "/c", "echo hello"], + plan.GetProperty("argv").EnumerateArray().Select(value => value.GetString()!).ToArray()); + Assert.Equal( + WindowsCommandLine.Join(["cmd.exe", "/d", "/s", "/c", "echo hello"]), + plan.GetProperty("commandText").GetString()); + Assert.Equal("echo hello", plan.GetProperty("commandPreview").GetString()); + Assert.Equal("main", plan.GetProperty("agentId").GetString()); + Assert.Equal("agent:main:approval-regression", plan.GetProperty("sessionKey").GetString()); + Assert.Equal(plan.GetProperty("commandText").GetString(), payload.GetProperty("cmdText").GetString()); + } + + [Fact] + public async Task RunPrepareValidatesDeclaredReadAndWriteAccess() + { + var child = Directory.CreateDirectory(Path.Combine(_root, "child")).FullName; + var policy = Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)]); + var rawCommand = $"# [declare-access]ro:{_root};rw:{child}[/declare-access]\necho hello"; + var capability = Capability(policy); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = Parse(JsonSerializer.Serialize(new + { + command = new[] { "cmd.exe", "/d", "/s", "/c", rawCommand }, + rawCommand, + })), + }, + TestContext.Current.CancellationToken); + + Assert.True(response.Ok); + var payload = JsonSerializer.SerializeToElement(response.Payload); + var access = payload.GetProperty("declaredAccess").EnumerateArray().ToArray(); + Assert.Equal(2, access.Length); + Assert.Contains(access, item => + item.GetProperty("access").GetString() == "ro" + && string.Equals(item.GetProperty("path").GetString(), _root, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(access, item => + item.GetProperty("access").GetString() == "rw" + && string.Equals(item.GetProperty("path").GetString(), child, StringComparison.OrdinalIgnoreCase)); + var plan = payload.GetProperty("plan"); + Assert.Equal(rawCommand, plan.GetProperty("commandPreview").GetString()); + Assert.Equal( + "echo hello", + plan.GetProperty("argv").EnumerateArray().Last().GetString()); + Assert.DoesNotContain("declare-access", plan.GetProperty("commandText").GetString()); + } + + [Theory] + [InlineData("# [declare-access]rw:C:\\Temp")] + [InlineData("# [declare-access][/declare-access]")] + [InlineData("# [declare-access]execute:C:\\Temp[/declare-access]")] + [InlineData("# [declare-access]rw:[/declare-access]")] + public async Task RunPrepareRejectsMalformedDeclarations(string rawCommand) + { + var capability = Capability(Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)])); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = Parse(JsonSerializer.Serialize(new + { + command = new[] { "cmd.exe", "/d", "/s", "/c", rawCommand }, + rawCommand, + })), + }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("declare-access-", response.Error); + } + + [Fact] + public async Task RunPrepareRejectsOutOfRootAndReadWriteEscalation() + { + var outside = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))).FullName; + try + { + var capability = Capability(Policy([new ApprovedRoot(_root, FolderAccess.ReadOnly)])); + var outsideResponse = await PrepareDeclared(capability, "ro", outside); + var escalationResponse = await PrepareDeclared(capability, "rw", _root); + + Assert.False(outsideResponse.Ok); + Assert.Contains("declare-access-outside-approved-roots", outsideResponse.Error); + Assert.False(escalationResponse.Ok); + Assert.Contains("declare-access-exceeds-approved-root", escalationResponse.Error); + } + finally + { + Directory.Delete(outside); + } + } + + [Fact] + public async Task DeclaredDesktopCommandReachesAttendedPromptAndDenyStopsExecution() + { + var desktop = Directory.CreateDirectory(Path.Combine(_root, "Desktop")).FullName; + var leasePath = Path.Combine(_root, "active-lease.json"); + var approvalsPath = Path.Combine(_root, "approvals.json"); + var secret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + const string generation = "prepare-regression"; + const string fingerprint = "policy-fingerprint"; + await WriteActivationLease( + leasePath, + secret, + ActivationLeaseMode.Active, + generation, + fingerprint, + DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds()); + var pipeName = "microclaw-approval-test-" + Guid.NewGuid().ToString("N"); + using var server = new System.IO.Pipes.NamedPipeServerStream( + pipeName, + System.IO.Pipes.PipeDirection.InOut, + 1, + System.IO.Pipes.PipeTransmissionMode.Byte, + System.IO.Pipes.PipeOptions.Asynchronous); + var observedRequest = ReadApprovalAndRespond(server, "deny"); + var capability = new BundledSystemCapability( + Policy([new ApprovedRoot(desktop, FolderAccess.ReadWrite)]), + pipeName, + approvalsPath, + new ActivationLeaseGuard(leasePath, secret, generation, fingerprint)); + var rawCommand = $"# [declare-access]rw:{desktop}[/declare-access]\necho test"; + var command = new[] { Path.Combine(Environment.SystemDirectory, "cmd.exe"), "/d", "/s", "/c", "echo test" }; + var commandText = WindowsCommandLine.Join(command); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run", + Args = Parse(JsonSerializer.Serialize(new + { + command, + rawCommand = commandText, + agentId = "main", + sessionKey = "agent:main:approval-regression", + systemRunPlan = new + { + argv = command, + commandText, + commandPreview = rawCommand, + agentId = "main", + sessionKey = "agent:main:approval-regression", + }, + })), + }, + TestContext.Current.CancellationToken); + var approval = await observedRequest; + + Assert.False(response.Ok); + Assert.Contains("approval-denied", response.Error); + Assert.Equal(1, approval.GetProperty("declaredAccess").GetArrayLength()); + Assert.Equal("rw", approval.GetProperty("declaredAccess")[0].GetProperty("access").GetString()); + Assert.Equal(desktop, approval.GetProperty("declaredAccess")[0].GetProperty("path").GetString(), ignoreCase: true); + Assert.Equal("echo test", approval.GetProperty("arguments").EnumerateArray().Last().GetString()); + Assert.False(File.Exists(approvalsPath)); + } + + [Fact] + public async Task RunPrepareRejectsDeclarationAfterExecutableContent() + { + var rawCommand = $"echo before\n# [declare-access]rw:{_root}[/declare-access]\necho after"; + var capability = Capability(Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)])); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = Parse(JsonSerializer.Serialize(new + { + command = new[] { "cmd.exe", "/d", "/s", "/c", rawCommand }, + rawCommand, + })), + }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("declare-access-position-invalid", response.Error); + } + + [Fact] + public async Task RunReplayRejectsDeclarationAfterExecutableContent() + { + var rawCommand = $"echo before\n# [declare-access]rw:{_root}[/declare-access]\necho after"; + var command = new[] { "cmd.exe", "/d", "/s", "/c", "echo before\necho after" }; + var commandText = WindowsCommandLine.Join(command); + var capability = Capability(Policy([new ApprovedRoot(_root, FolderAccess.ReadWrite)])); + + var response = await capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run", + Args = Parse(JsonSerializer.Serialize(new + { + command, + rawCommand = commandText, + agentId = "main", + sessionKey = "agent:main:approval-regression", + systemRunPlan = new + { + argv = command, + commandText, + commandPreview = rawCommand, + agentId = "main", + sessionKey = "agent:main:approval-regression", + }, + })), + }, + TestContext.Current.CancellationToken); + + Assert.False(response.Ok); + Assert.Contains("declare-access-position-invalid", response.Error); + } + + [Theory] + [InlineData("deny", "Deny")] + [InlineData("allow-once", "AllowOnce")] + [InlineData("allow-always", "AllowAlways")] + public async Task ApprovalPipePreservesAttendedDecision( + string responseDecision, + string expectedDecision) + { + var pipeName = "microclaw-approval-decision-" + Guid.NewGuid().ToString("N"); + using var server = new System.IO.Pipes.NamedPipeServerStream( + pipeName, + System.IO.Pipes.PipeDirection.InOut, + 1, + System.IO.Pipes.PipeTransmissionMode.Byte, + System.IO.Pipes.PipeOptions.Asynchronous); + var serverTask = ReadApprovalAndRespond(server, responseDecision); + var decision = await ApprovalPipeClient.RequestAsync( + pipeName, + new ApprovalRequest( + "request", + Path.Combine(Environment.SystemDirectory, "hostname.exe"), + [], + "main", + CwdPolicyContract.ScratchBinding, + []), + TestContext.Current.CancellationToken); + await serverTask; + + Assert.Equal(expectedDecision, decision.ToString()); + } + + [Fact] + public void DurableAllowAlwaysIdentityRemainsBoundToCanonicalCwd() + { + var approvalsPath = Path.Combine(_root, "durable", "approvals.json"); + var first = Directory.CreateDirectory(Path.Combine(_root, "first-cwd")).FullName; + var second = Directory.CreateDirectory(Path.Combine(_root, "second-cwd")).FullName; + var executable = Path.Combine(Environment.SystemDirectory, "hostname.exe"); + var approval = DurableApprovalIdentity.Create(executable, [], first); + + DurableApprovalFile.Add(approvalsPath, approval); + var loaded = Assert.Single(DurableApprovalIdentity.Load(approvalsPath)); + + Assert.True(DurableApprovalIdentity.Matches(loaded, executable, [], first)); + Assert.False(DurableApprovalIdentity.Matches(loaded, executable, [], second)); + } + public void Dispose() { if (Directory.Exists(_root)) @@ -473,6 +765,55 @@ private static HostPolicy Policy(IReadOnlyList roots, IReadOnlyLis StrictNoHostFallback = true, }; + private BundledSystemCapability Capability(HostPolicy policy) => + new( + policy, + string.Empty, + Path.Combine(_root, "approvals-v2.json"), + new ActivationLeaseGuard( + Path.Combine(_root, "unused-lease.json"), + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + "unused-generation", + "unused-fingerprint")); + + private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + private static Task PrepareDeclared( + BundledSystemCapability capability, + string access, + string path) + { + var rawCommand = $"# [declare-access]{access}:{path}[/declare-access]\necho test"; + return capability.ExecuteAsync( + new NodeInvokeRequest + { + Command = "system.run.prepare", + Args = Parse(JsonSerializer.Serialize(new + { + command = new[] { "cmd.exe", "/d", "/s", "/c", rawCommand }, + rawCommand, + })), + }, + TestContext.Current.CancellationToken); + } + + private static async Task ReadApprovalAndRespond( + System.IO.Pipes.NamedPipeServerStream server, + string decision) + { + await server.WaitForConnectionAsync(TestContext.Current.CancellationToken); + using var reader = new StreamReader(server, Encoding.UTF8, leaveOpen: true); + using var writer = new StreamWriter(server, new UTF8Encoding(false), leaveOpen: true) + { + AutoFlush = true, + }; + var line = await reader.ReadLineAsync(TestContext.Current.CancellationToken); + await writer.WriteLineAsync( + JsonSerializer.Serialize(new { decision }).AsMemory(), + TestContext.Current.CancellationToken); + return JsonDocument.Parse(line ?? "{}").RootElement.Clone(); + } + private static Task WriteActivationLease( string path, string secret, diff --git a/windows-node-host/BundledSystemCapability.cs b/windows-node-host/BundledSystemCapability.cs index e677750..e36eb6f 100644 --- a/windows-node-host/BundledSystemCapability.cs +++ b/windows-node-host/BundledSystemCapability.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using OpenClaw.Shared; using OpenClaw.Shared.Mxc; @@ -44,8 +45,8 @@ CwdPolicyAttestation.Current with DurableApprovalsPresent = DurableApprovalIdentity.Load(approvalsPath).Count > 0, }), "system.which" => Success(new { bins = ResolveBins(request.Args) }), - "system.run.prepare" => Success(Prepare(request.Args)), - "system.run" => await RunAsync(request.Args, cancellationToken), + "system.run.prepare" => Success(Prepare(request)), + "system.run" => await RunAsync(request, cancellationToken), _ => Error("Unsupported bundled node command."), }; } @@ -63,26 +64,40 @@ CwdPolicyAttestation.Current with } } - private object Prepare(JsonElement args) + private object Prepare(NodeInvokeRequest request) { - var request = ParseRun(args); - var cwd = policy.ResolveCwd(request.Cwd); + var run = ParseRun(request.Args); + var cwd = policy.ResolveCwd(run.Cwd); + var commandText = WindowsCommandLine.Join(run.Argv); return new { - argv = request.Argv, - cwd = string.IsNullOrEmpty(cwd.LaunchPath) ? null : cwd.LaunchPath, + cmdText = commandText, + plan = new + { + argv = run.Argv, + cwd = string.IsNullOrEmpty(cwd.LaunchPath) ? null : cwd.LaunchPath, + commandText, + commandPreview = string.Equals(run.CommandPreview, commandText, StringComparison.Ordinal) + ? null + : run.CommandPreview, + agentId = run.AgentId, + sessionKey = run.SessionKey, + }, cwdBinding = cwd.ApprovalBinding, cwdAccess = cwd.Access.ToString(), contract = CwdPolicyContract.Version, + declaredAccess = run.DeclaredAccess, }; } - private async Task RunAsync(JsonElement args, CancellationToken cancellationToken) + private async Task RunAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) { await _runGate.WaitAsync(cancellationToken); try { - return await RunExclusiveAsync(args, cancellationToken); + return await RunExclusiveAsync(request.Args, cancellationToken); } finally { @@ -116,7 +131,8 @@ private async Task RunExclusiveAsync( executable, exactArgs, request.AgentId, - cwd.ApprovalBinding), + cwd.ApprovalBinding, + request.DeclaredAccess), cancellationToken); if (decision is ApprovalDecision.Deny) return Error("approval-denied: The operator denied this command."); @@ -221,17 +237,18 @@ private async Task RunExclusiveAsync( } } - private static RunRequest ParseRun(JsonElement args) + private RunRequest ParseRun(JsonElement args) { var hasArgv = args.TryGetProperty("argv", out var argvElement); if (!hasArgv) hasArgv = args.TryGetProperty("command", out argvElement); if (!hasArgv || argvElement.ValueKind != JsonValueKind.Array) throw new HostPolicyException("argv-required", "system.run requires a direct command argv array."); - var argv = argvElement.EnumerateArray() + var parsedArgv = argvElement.EnumerateArray() .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null) .ToArray(); - if (argv.Length == 0 || argv.Any(string.IsNullOrEmpty)) + if (parsedArgv.Length == 0 || parsedArgv.Any(string.IsNullOrEmpty)) throw new HostPolicyException("argv-invalid", "argv must contain non-empty strings."); + var argv = parsedArgv.Select(argument => argument!).ToArray(); if (args.TryGetProperty("env", out var env) && env.ValueKind == JsonValueKind.Object && env.EnumerateObject().Any()) throw new HostPolicyException("environment-denied", "Custom command environments are not supported."); @@ -244,7 +261,91 @@ private static RunRequest ParseRun(JsonElement args) var agentId = args.TryGetProperty("agentId", out var agentElement) && agentElement.ValueKind == JsonValueKind.String ? agentElement.GetString() : null; - return new RunRequest(argv!, cwd, timeout, agentId); + var sessionKey = args.TryGetProperty("sessionKey", out var sessionKeyElement) + && sessionKeyElement.ValueKind == JsonValueKind.String + ? sessionKeyElement.GetString() + : null; + var rawCommand = args.TryGetProperty("rawCommand", out var rawCommandElement) + && rawCommandElement.ValueKind == JsonValueKind.String + ? rawCommandElement.GetString() + : null; + var commandPreview = GetSystemRunPlanCommandPreview(args) ?? rawCommand; + var declaredAccess = DeclaredAccessParser.ParseAndValidate(commandPreview, policy); + var executableArgv = argv; + var executableRawCommand = rawCommand; + if (declaredAccess.Count > 0) + { + if (string.Equals(commandPreview, rawCommand, StringComparison.Ordinal)) + { + executableRawCommand = DeclaredAccessParser.StripLeadingDeclarations(rawCommand!); + var matchingArguments = executableArgv + .Select((argument, index) => (argument, index)) + .Where(item => string.Equals(item.argument, rawCommand, StringComparison.Ordinal)) + .ToArray(); + if (matchingArguments.Length != 1) + throw new HostPolicyException( + "declare-access-command-mismatch", + "The declared-access command did not match exactly one shell payload."); + executableArgv = [.. executableArgv]; + executableArgv[matchingArguments[0].index] = executableRawCommand; + } + else + { + var executablePreview = DeclaredAccessParser.StripLeadingDeclarations(commandPreview!); + if (executableArgv.Count(argument => + string.Equals(argument, executablePreview, StringComparison.Ordinal)) != 1) + throw new HostPolicyException( + "declare-access-command-mismatch", + "The declared-access command did not match exactly one approved shell payload."); + ValidatePreparedPlanBinding(args, executableArgv, rawCommand); + } + } + return new RunRequest( + executableArgv, + cwd, + timeout, + agentId, + sessionKey, + executableRawCommand, + commandPreview, + declaredAccess); + } + + private static string? GetSystemRunPlanCommandPreview(JsonElement args) + { + if (!args.TryGetProperty("systemRunPlan", out var plan) + || plan.ValueKind != JsonValueKind.Object + || !plan.TryGetProperty("commandPreview", out var preview) + || preview.ValueKind != JsonValueKind.String) + return null; + var value = preview.GetString(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static void ValidatePreparedPlanBinding( + JsonElement args, + IReadOnlyList argv, + string? rawCommand) + { + if (!args.TryGetProperty("systemRunPlan", out var plan) + || plan.ValueKind != JsonValueKind.Object + || !plan.TryGetProperty("argv", out var planArgv) + || planArgv.ValueKind != JsonValueKind.Array + || !plan.TryGetProperty("commandText", out var planCommandText) + || planCommandText.ValueKind != JsonValueKind.String) + throw new HostPolicyException( + "declare-access-plan-invalid", + "Declared-access replay requires the approved system.run plan."); + var approvedArgv = planArgv.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null) + .ToArray(); + if (approvedArgv.Length != argv.Count + || approvedArgv.Where((argument, index) => + !string.Equals(argument, argv[index], StringComparison.Ordinal)).Any() + || !string.Equals(planCommandText.GetString(), rawCommand, StringComparison.Ordinal)) + throw new HostPolicyException( + "declare-access-plan-mismatch", + "Declared-access metadata did not match the approved system.run plan."); } private static Dictionary ResolveBins(JsonElement args) @@ -282,7 +383,122 @@ private static Dictionary ResolveBins(JsonElement args) private static NodeInvokeResponse Success(object payload) => new() { Ok = true, Payload = payload }; private static NodeInvokeResponse Error(string error) => new() { Ok = false, Error = error }; - private sealed record RunRequest(string[] Argv, string? Cwd, int TimeoutMs, string? AgentId); + private sealed record RunRequest( + string[] Argv, + string? Cwd, + int TimeoutMs, + string? AgentId, + string? SessionKey, + string? RawCommand, + string? CommandPreview, + IReadOnlyList DeclaredAccess); +} + +internal sealed record DeclaredAccess( + [property: JsonPropertyName("access")] string Access, + [property: JsonPropertyName("path")] string Path); + +internal static partial class DeclaredAccessParser +{ + [GeneratedRegex( + @"\[declare-access\](.*?)\[/declare-access\]", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] + private static partial Regex DeclarationPattern(); + + [GeneratedRegex( + @"\A[ \t]*(?:(?:#|::|REM\b)[ \t]*)?\[declare-access\](.*?)\[/declare-access\][ \t]*(?:\r\n|\n|\r|$)", + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant)] + private static partial Regex LeadingDeclarationPattern(); + + public static IReadOnlyList ParseAndValidate(string? rawCommand, HostPolicy policy) + { + if (string.IsNullOrEmpty(rawCommand)) + return []; + + var matches = DeclarationPattern().Matches(rawCommand); + var unmatched = DeclarationPattern().Replace(rawCommand, string.Empty); + if (unmatched.Contains("[declare-access]", StringComparison.OrdinalIgnoreCase) + || unmatched.Contains("[/declare-access]", StringComparison.OrdinalIgnoreCase)) + throw new HostPolicyException( + "declare-access-malformed", + "The declare-access tag is incomplete or nested."); + + var declarations = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match match in matches) + { + var payload = match.Groups[1].Value; + if (string.IsNullOrWhiteSpace(payload)) + throw new HostPolicyException( + "declare-access-malformed", + "The declare-access tag must contain at least one access:path entry."); + + foreach (var entry in payload.Split(';', StringSplitOptions.TrimEntries)) + { + var separator = entry.IndexOf(':'); + if (separator <= 0 || separator == entry.Length - 1) + throw new HostPolicyException( + "declare-access-malformed", + "Each declare-access entry must use ro: or rw:."); + var access = entry[..separator].Trim().ToLowerInvariant(); + var requestedPath = entry[(separator + 1)..].Trim(); + if (access is not ("ro" or "rw")) + throw new HostPolicyException( + "declare-access-malformed", + "Declare-access supports only ro and rw access."); + if (!Path.IsPathFullyQualified(requestedPath) + || Path.GetPathRoot(requestedPath)?.Length != 3) + throw new HostPolicyException( + "declare-access-path-invalid", + "Declared access paths must be absolute local drive paths."); + + var canonical = WindowsPathCanonicalizer.CanonicalizeDirectory(requestedPath); + if (policy.DeniedRoots.Any(root => + WindowsPathCanonicalizer.IsEqualOrNested(canonical, root))) + throw new HostPolicyException( + "declare-access-sensitive-root", + "Declared access overlaps a protected root."); + var approved = policy.ApprovedRoots + .Where(root => WindowsPathCanonicalizer.IsEqualOrNested(canonical, root.Path)) + .OrderByDescending(root => root.Path.Length) + .FirstOrDefault() + ?? throw new HostPolicyException( + "declare-access-outside-approved-roots", + "Declared access is outside the globally approved folder policy."); + if (access == "rw" && approved.Access != FolderAccess.ReadWrite) + throw new HostPolicyException( + "declare-access-exceeds-approved-root", + "Declared read-write access exceeds the globally approved folder policy."); + + if (!declarations.TryGetValue(canonical, out var existing) + || (existing.Access == "ro" && access == "rw")) + declarations[canonical] = new DeclaredAccess(access, canonical); + } + } + return declarations.Values + .OrderBy(declaration => declaration.Path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static string StripLeadingDeclarations(string rawCommand) + { + var executable = rawCommand; + while (true) + { + var match = LeadingDeclarationPattern().Match(executable); + if (!match.Success) + break; + executable = executable[match.Length..]; + } + if (DeclarationPattern().IsMatch(executable)) + throw new HostPolicyException( + "declare-access-position-invalid", + "Declare-access metadata must appear on leading metadata lines."); + if (string.IsNullOrWhiteSpace(executable)) + throw new HostPolicyException( + "declare-access-command-empty", + "Declare-access metadata must be followed by a command."); + return executable; + } } internal sealed class ProcessEnvironmentOverride : IDisposable @@ -318,7 +534,8 @@ internal sealed record ApprovalRequest( [property: JsonPropertyName("executable")] string Executable, [property: JsonPropertyName("arguments")] IReadOnlyList Arguments, [property: JsonPropertyName("agent")] string? Agent, - [property: JsonPropertyName("canonicalCwd")] string CanonicalCwd); + [property: JsonPropertyName("canonicalCwd")] string CanonicalCwd, + [property: JsonPropertyName("declaredAccess")] IReadOnlyList DeclaredAccess); internal static class ApprovalPipeClient { diff --git a/windows-node-host/MicroClaw.WindowsNodeHost.csproj b/windows-node-host/MicroClaw.WindowsNodeHost.csproj index 42cf9f1..b63c155 100644 --- a/windows-node-host/MicroClaw.WindowsNodeHost.csproj +++ b/windows-node-host/MicroClaw.WindowsNodeHost.csproj @@ -18,5 +18,6 @@ + From 3371bb230d8a205fe87f432c56144b7dcb0626e6 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:43:49 +0800 Subject: [PATCH 11/23] Clarify contained MXC approvals Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- desktop/renderer/env.d.ts | 1 + desktop/renderer/src/App.vue | 18 ++-- .../src/components/PermissionDialog.test.ts | 64 ++++++++++++-- .../src/components/PermissionDialog.vue | 85 +++++++++++++++++-- desktop/renderer/src/i18n/en-US.ts | 15 ++++ desktop/renderer/src/i18n/index.test.ts | 20 +++++ desktop/renderer/src/i18n/zh-CN.ts | 14 +++ desktop/src/main.ts | 4 +- desktop/src/windows-node-mxc-service.ts | 1 + desktop/src/windows-node-mxc.test.ts | 65 +++++++++++++- desktop/src/windows-node-mxc.ts | 85 +++++++++++++++++-- docs/experimental-windows-node-mxc.md | 14 ++- windows-node-host.Tests/CwdPolicyTests.cs | 47 +++++++--- windows-node-host/Approvals.cs | 45 ++++++++-- windows-node-host/BundledSystemCapability.cs | 36 ++++++-- windows-node-host/CwdPolicy.cs | 2 + 16 files changed, 451 insertions(+), 65 deletions(-) diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 2a8c22f..ea9869c 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -430,6 +430,7 @@ interface OpenClawAPI { id: string; executable: string; arguments: string[]; + commandText?: string; agent: string | null; canonicalCwd: string; approvalLayer: "gateway" | "node"; diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index a57ded1..59af31d 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -226,7 +226,7 @@ const integrityLoading = ref(false); // ── Permission dialog state (queue of pending requests) ── interface PermissionRequestData { requestId: string; - type: "file" | "shell" | "shell-async" | "app-approval"; + type: "file" | "shell" | "shell-async" | "app-approval" | "mxc-approval"; targetPath: string; dirPath: string; command?: string; @@ -234,6 +234,7 @@ interface PermissionRequestData { app?: string; source?: "sandbox" | "windows-node-mxc"; allowedDecisions?: Array<"deny" | "allow-once" | "allow-always">; + declaredAccess?: Array<{ access: "ro" | "rw"; path: string }>; } const permissionQueue = ref([]); const currentPermission = computed(() => @@ -421,20 +422,15 @@ onMounted(async () => { ); return; } - const declaredAccess = request.declaredAccess - .map((declaration) => `${declaration.access.toUpperCase()}: ${declaration.path}`) - .join("\n"); - const command = [request.executable, ...request.arguments].join(" "); + const command = + request.commandText ?? [request.executable, ...request.arguments].join(" "); permissionQueue.value.push({ requestId: request.id, - type: "app-approval", + type: "mxc-approval", targetPath: request.executable, dirPath: request.canonicalCwd, - command: declaredAccess ? `${command}\n\nDeclared access:\n${declaredAccess}` : command, - app: - request.approvalLayer === "gateway" - ? "OpenClaw Gateway node command" - : "Contained Windows Node / MXC command", + command, + declaredAccess: request.declaredAccess, source: "windows-node-mxc", allowedDecisions: request.allowedDecisions, }); diff --git a/desktop/renderer/src/components/PermissionDialog.test.ts b/desktop/renderer/src/components/PermissionDialog.test.ts index 410a878..632900d 100644 --- a/desktop/renderer/src/components/PermissionDialog.test.ts +++ b/desktop/renderer/src/components/PermissionDialog.test.ts @@ -22,6 +22,9 @@ describe("PermissionDialog", () => { "Allow once", "Always allow", ]); + expect(wrapper.find(".perm-body").text()).toBe( + "AI is trying to launch soffice. This app is not in the sandbox whitelist and needs to run outside the sandbox.", + ); }); it("shows only decisions advertised by a Gateway approval", () => { @@ -43,21 +46,70 @@ describe("PermissionDialog", () => { ]); }); - it("shows the complete approval command and declared access", () => { - const command = `${"x".repeat(350)}\n\nDeclared access:\nRW C:\\Users\\test\\Desktop`; + it("shows an MXC-contained command with separate RO and RW access", () => { + const command = `${"x".repeat(350)} `; const wrapper = mount(PermissionDialog, { props: { request: { - requestId: "gateway-request", - type: "app-approval", - app: "OpenClaw Gateway node command", + requestId: "mxc-request", + type: "mxc-approval", command, - allowedDecisions: ["deny", "allow-once"], + dirPath: "isolated-scratch:v1", + declaredAccess: [ + { access: "ro", path: String.raw`C:\Users\test\Documents` }, + { access: "rw", path: String.raw`C:\Users\test\Desktop