From 9ef9e30db59169e08484d7b4135a194361747b75 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:40:12 +0800 Subject: [PATCH] Add experimental Microsoft MXC sandbox Route the minimal agent file and process tool surface through a pinned MXC worker, enforce fail-closed Gateway startup, add per-agent path policy UX, and package the native runtime for EXE and MSIX builds. Related: #202 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- README.md | 50 +- README.zh-CN.md | 32 +- build.ps1 | 67 +- deploy.py | 2 - deployer/webview_bridge.py | 32 +- deployer/windows_setup.py | 233 +------ desktop/electron-builder.yml | 28 +- desktop/mxc-plugin/index.mjs | 125 ++++ desktop/mxc-plugin/openclaw.plugin.json | 69 ++ desktop/mxc-plugin/package.json | 20 + desktop/mxc-plugin/runtime.mjs | 224 ++++++ desktop/mxc-plugin/worker.mjs | 124 ++++ desktop/package-lock.json | 42 ++ desktop/package.json | 1 + desktop/renderer/env.d.ts | 106 +-- desktop/renderer/src/App.vue | 132 +--- desktop/renderer/src/browser-openclaw.test.ts | 2 +- desktop/renderer/src/browser-openclaw.ts | 58 +- desktop/renderer/src/i18n/en-US.ts | 27 + desktop/renderer/src/i18n/zh-CN.ts | 27 + desktop/renderer/src/stores/chat.test.ts | 2 - .../renderer/src/views/SettingsView.test.ts | 47 ++ desktop/renderer/src/views/SettingsView.vue | 303 +++++++- desktop/src/main.ts | 644 +++++++++++------- desktop/src/mxc-sandbox.test.ts | 266 ++++++++ desktop/src/mxc-sandbox.ts | 449 ++++++++++++ desktop/src/preload.ts | 110 +-- scripts/windows/validate-msix.ps1 | 19 +- tests/test_webview_bridge.py | 43 +- tests/test_windows_setup_upgrade.py | 70 +- 30 files changed, 2359 insertions(+), 995 deletions(-) create mode 100644 desktop/mxc-plugin/index.mjs create mode 100644 desktop/mxc-plugin/openclaw.plugin.json create mode 100644 desktop/mxc-plugin/package.json create mode 100644 desktop/mxc-plugin/runtime.mjs create mode 100644 desktop/mxc-plugin/worker.mjs create mode 100644 desktop/src/mxc-sandbox.test.ts create mode 100644 desktop/src/mxc-sandbox.ts diff --git a/README.md b/README.md index 78d58b8..ba88472 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [中文版](README.zh-CN.md) -**MicroClaw** makes [OpenClaw](https://github.com/openclaw) instantly available on Windows through a familiar, low-friction install experience. It packages the desktop client, local Gateway, managed runtime, preloaded skills, and permission-controlled sandbox into one product so users can get to real tasks quickly. You bring the LLM connection; MicroClaw brings the Windows app, local runtime, and trust boundary. +**MicroClaw** makes [OpenClaw](https://github.com/openclaw) instantly available on Windows through a familiar, low-friction install experience. This branch contains an experimental Microsoft MXC sandbox POC. > [!WARNING] > **AI & Security Notice** @@ -19,7 +19,7 @@ MicroClaw is designed to remove the usual Windows setup friction around OpenClaw ### Instant Availability - **Familiar Windows install flow**: packaged installer with desktop shortcut, Start menu entry, and one-click uninstall -- **One run sets up the runtime**: Git, managed Node.js, OpenClaw Gateway, the MicroClaw desktop app, managed skills, and AppContainer provisioning +- **One run sets up the runtime**: Git, managed Node.js, OpenClaw Gateway, the MicroClaw desktop app, managed skills, and the pinned MXC package - **Ready after install**: launch the app immediately after setup instead of building a local OpenClaw environment by hand ### Ready-to-Use Experience @@ -31,9 +31,9 @@ MicroClaw is designed to remove the usual Windows setup friction around OpenClaw ### Built-In Trust -- **Transparent, permission-controlled actions**: file and tool access requests are surfaced to the user instead of being silently granted -- **Sandboxed with Windows AppContainer**: tool execution runs inside an OS-enforced AppContainer boundary on supported systems -- **Hooks plus sandbox, defense in depth**: hook-based prechecks improve UX while AppContainer ACL enforcement remains the actual security boundary +- **Native folder policy**: approved read-only/read-write folders are selected through the desktop's native picker +- **Microsoft MXC experiment**: dedicated Node workers run through `@microsoft/mxc-sdk@0.7.0` +- **Fail closed**: OpenClaw host file/runtime, elevated, browser, canvas, and native code-execution tools are denied; MXC tools have no host fallback - **Hard blocks for sensitive paths**: credential-heavy locations such as `.ssh` and cloud config folders are denied rather than merely warned about --- @@ -88,7 +88,7 @@ graph TB | Gateway → LLM | HTTPS | Streaming API calls to Claude / OpenAI / Gemini | | Gateway → Skills | In-process | Agent loop invokes tool calls; results fed back to LLM | | WeChat Plugin → WeChat | HTTPS | Long-poll `getUpdates` + `sendMessage` | -| Permission Manager → AppContainer | Win32 API | AppContainer security capabilities + Job Object resource limits | +| MXC plugin → MXC worker | Native process containment | Hash-verified `wxc-exec` launches a secret-stripped Node worker | --- @@ -97,7 +97,7 @@ graph TB | Component | Path | Stack | Description | |---|---|---|---| | **Desktop App** | `desktop/` | Electron 33 + TypeScript + Vue 3 + Element Plus | Chat UI, Gateway lifecycle management, system tray | -| **AppContainer Sandbox** | `appcontainer/` | .NET 9 + Node.js preload hooks | Windows AppContainer launcher + permission hooks for tool isolation | +| **MXC tool plugin** | `desktop/mxc-plugin/` | OpenClaw Plugin SDK + MXC 0.7.0 | Registers non-conflicting `mxc_*` tools and launches the contained worker | | **Installer** | `deploy.py` + `deployer/` | Python 3 + Tkinter | Wizard-style graphical installer (can be packaged as a single exe) | | **Skill Packs** | `skills/` | Markdown + JSON + Python/Node | Office, search, browser automation managed skills | | **WeChat Plugin** | `plugins/openclaw-weixin/` | TypeScript + OpenClaw Plugin SDK | WeChat channel integration | @@ -127,7 +127,7 @@ The installer handles the Windows-side setup in a single run: - Node.js 22+ via the official signed `.msi` (per-machine install to `%ProgramFiles%\nodejs\`, UAC-elevated; an existing system Node ≥22.16 at that path is reused as-is) - OpenClaw Gateway (`npm install -g openclaw`) - Configures the npm registry mirror and V8 compile cache -- Installs the MicroClaw desktop client, managed skills, AppContainer sandbox, WeChat plugin +- Installs the MicroClaw desktop client, managed skills, pinned MXC runtime, and WeChat plugin - Adds Windows Defender exclusions, creates desktop shortcuts (including one-click Uninstall) After install, launch **MicroClaw** from the Start menu or the desktop shortcut. The desktop app auto-starts the Gateway. @@ -196,13 +196,29 @@ npm run lint:sarif # outputs eslint-results.sarif ## Sandbox Isolation -`appcontainer/` provides a Windows AppContainer-based sandbox for isolating tool execution: +This branch pins [`@microsoft/mxc-sdk@0.7.0`](https://github.com/microsoft/mxc/tree/v0.7.0) +(tag commit `34d7fe2b4b3226bd4d11dc4a32419b7ec198a88b`) and stable policy +`0.7.0-alpha`. The packaged OpenClaw plugin exposes `mxc_read`, `mxc_write`, +`mxc_edit`, and bounded `mxc_exec`. Each call launches a dedicated Node worker +inside MXC with network/UI denied and a secret-stripped environment; descendants +remain in the same containment. Chat is blocked unless package version, official +`wxc-exec` hash, OS tier, policy, and worker proof all succeed. -- **.NET launcher** (`AppContainerLauncher.exe`) runs child processes inside an AppContainer with restricted ACLs -- **Node.js preload hooks** (`sandbox-preload.js`, `sandbox-fs-hooks.js`, …) intercept `fs.*` / `child_process.*` calls and prompt the user for permission -- **Sensitive-path shield** hard-denies access to `~/.ssh`, `~/.azure`, and other credential directories — no override +POC scope is intentionally narrow: file operations are UTF-8 and capped at 1 MiB, +`mxc_edit` replaces one unique string, and `mxc_exec` is non-interactive with a +30-second/1 MiB output limit. There is no background/process-control tool, +`apply_patch`, unrestricted host fallback, or automatic host preparation. -See [appcontainer/README.md](appcontainer/README.md) for details. +> [!WARNING] +> MXC is a **public preview**. Policies may be overly permissive and no current +> profile is a production security boundary. This POC sets +> `fallback.allowDaclMutation=false`. If Settings reports that host preparation is +> required, Microsoft documents elevated `wxc-host-prep prepare-system-drive` +> (machine-wide metadata ACEs) and `wxc-host-prep prepare-null-device`. MSIX cannot +> chain those steps. MicroClaw never invokes them, elevates, or mutates ACLs +> automatically. This POC cannot attest preparation or collect consent for +> temporary folder-DACL mutation, so it remains blocked on `appcontainer-dacl`; +> legacy AppContainer ACLs are left unchanged. --- @@ -255,7 +271,7 @@ Installed to `~/.openclaw/skills/`, these are custom advanced skills included in | **Skill Integrity Checks** | SHA-256 hashes + Ed25519 signatures — detects tampering of all skill files at startup | | **Device Authentication** | Each device generates an Ed25519 key pair; Gateway connections are signature-authenticated | | **Skill Allowlist** | `allowBundled` / `allowManaged` control the available skill scope | -| **Sandbox Isolation** | OS-native AppContainer + Job Object isolation for file system, network, and resource limits | +| **Sandbox Isolation** | Experimental MXC 0.7.0 worker with hash verification, network/UI denial, strict folder policy, and no host fallback | | **Local Gateway** | Binds to loopback only — does not accept remote connections | --- @@ -278,7 +294,7 @@ This script sequentially: The CI and release workflows also build `desktop/release/MicroClawDesktop--x64.msix` with Electron Builder. The package includes the Electron app, the pinned OpenClaw -runtime, Node.js, and the AppContainer launcher resources. On first launch, the +runtime, Node.js, and MXC SDK/native resources. On first launch, the read-only bundled runtime archive is expanded under Electron's per-user `userData` directory; configuration and other writable state remain in per-user application data. @@ -310,7 +326,6 @@ download-update path. - Node.js 22+ - Python 3.10+ — install build deps with `pip install -r requirements.txt` (includes PyInstaller) -- .NET 9 SDK (for the AppContainer launcher) - npm dependencies installed (`cd desktop && npm install`) --- @@ -330,7 +345,8 @@ Operational Windows scripts now live under `scripts/windows/`. Root `.bat` and ` ├── desktop/ # Electron desktop app │ ├── src/ # Main process (TypeScript) │ └── renderer/ # Vue 3 renderer process -├── appcontainer/ # Windows AppContainer sandbox (.NET + preload hooks) +├── appcontainer/ # Legacy inactive AppContainer sources (not packaged) +├── desktop/mxc-plugin/ # Experimental MXC-backed OpenClaw tools ├── skills/ # Managed skill definitions ├── plugins/openclaw-weixin/ # WeChat channel plugin ├── scripts/ diff --git a/README.zh-CN.md b/README.zh-CN.md index c869320..eefcd37 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](README.md) -**MicroClaw** 的目标是让 [OpenClaw](https://github.com/openclaw) 在 Windows 上做到“装好就能用”。它把桌面客户端、本地 Gateway、运行时、预装技能和带权限控制的沙箱整合成一个熟悉、低摩擦的安装体验,让用户尽快进入真实任务。你只需要提供大模型连接信息,Windows 侧运行环境、桌面应用和信任边界都由 MicroClaw 预置完成。 +**MicroClaw** 的目标是让 [OpenClaw](https://github.com/openclaw) 在 Windows 上做到“装好就能用”。此分支包含 Microsoft MXC 实验性沙箱 POC。 > [!WARNING] > **AI 与安全声明** @@ -19,7 +19,7 @@ MicroClaw 的设计重点,是把 OpenClaw 在 Windows 上常见的安装和配 ### 即装即用 - **熟悉的 Windows 安装流程**:带桌面快捷方式、开始菜单入口和一键卸载 -- **一次安装补齐运行环境**:自动准备 Git、受管 Node.js、OpenClaw Gateway、MicroClaw 桌面端、托管技能和 AppContainer 配置 +- **一次安装补齐运行环境**:自动准备 Git、受管 Node.js、OpenClaw Gateway、MicroClaw 桌面端、托管技能和固定版本 MXC 包 - **安装完成即可启动**:不需要先手动搭本地 OpenClaw 运行环境 ### 真正可用,而不是只装了个壳 @@ -31,11 +31,23 @@ MicroClaw 的设计重点,是把 OpenClaw 在 Windows 上常见的安装和配 ### 内建信任 -- **动作透明、权限可控**:文件和工具访问需要明确向用户申请,而不是静默放行 -- **基于 Windows AppContainer 的沙箱隔离**:在支持的系统上,工具执行运行在操作系统强制边界内 -- **Hook 加沙箱,形成纵深防御**:Hook 负责更好的交互体验,真正的安全边界仍由 AppContainer ACL 强制执行 +- **原生文件夹策略**:通过桌面端原生选择器配置只读/读写文件夹 +- **Microsoft MXC 实验**:专用 Node 工作进程通过 `@microsoft/mxc-sdk@0.7.0` 启动 +- **故障关闭**:禁用 OpenClaw 主机文件/运行时、提权、浏览器、画布和原生代码执行工具;MXC 工具没有主机回退 - **敏感路径直接硬拦截**:例如 `.ssh`、云凭据目录等不会只是提醒,而是直接拒绝访问 +> [!WARNING] +> 本分支固定 [`@microsoft/mxc-sdk@0.7.0`](https://github.com/microsoft/mxc/tree/v0.7.0) +>(提交 `34d7fe2b4b3226bd4d11dc4a32419b7ec198a88b`)和稳定策略 +> `0.7.0-alpha`。MXC 仍是**公共预览**:策略可能过度宽松,当前没有任何配置可作为生产安全边界。 +> POC 设置 `fallback.allowDaclMutation=false`。Microsoft 文档要求管理员明确同意运行 +> `wxc-host-prep prepare-system-drive`(机器级元数据 ACE)及 `prepare-null-device`,MSIX 无法串联这些步骤。 +> MicroClaw 不会自动运行、提权或修改 ACL;本 POC 也无法验证准备状态或收集临时文件夹 DACL 修改同意, +> 因此在 `appcontainer-dacl` 层级仍保持阻止,且不会静默清理旧 AppContainer ACL。 +> +> POC 范围有意收窄:文件工具仅支持 UTF-8 且上限 1 MiB,`mxc_edit` 只替换一个唯一字符串, +> `mxc_exec` 为非交互式(最多 30 秒、输出 1 MiB)。不提供后台进程控制、`apply_patch`、无限制主机回退或自动主机准备。 + --- ## 项目组成 @@ -43,7 +55,7 @@ MicroClaw 的设计重点,是把 OpenClaw 在 Windows 上常见的安装和配 | 组件 | 路径 | 技术栈 | 说明 | |---|---|---|---| | **桌面应用** | `desktop/` | Electron 33 + TypeScript + Vue 3 + Element Plus | 聊天界面、Gateway 生命周期管理、托盘菜单 | -| **AppContainer 沙盒** | `appcontainer/` | .NET 9 + Node.js preload hooks | Windows AppContainer 启动器 + 权限钩子,隔离工具执行 | +| **MXC 工具插件** | `desktop/mxc-plugin/` | OpenClaw Plugin SDK + MXC 0.7.0 | 注册 `mxc_*` 工具并启动受隔离工作进程 | | **安装器** | `deploy.py` + `deployer/` | Python 3 + Tkinter | 向导式图形化安装器(可打包为单文件 exe) | | **技能包** | `skills/` | Markdown + JSON + Python/Node | Office、搜索、浏览器自动化等托管技能 | | **微信插件** | `plugins/openclaw-weixin/` | TypeScript + OpenClaw Plugin SDK | 微信频道接入 | @@ -73,7 +85,7 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 - Node.js 22+,通过官方签名 `.msi` 以 per-machine 方式安装到 `%ProgramFiles%\nodejs\`(UAC 提权;若该路径已存在 ≥22.16 的系统 Node 则直接复用) - OpenClaw Gateway(`npm install -g openclaw`) - 配置 npm 镜像源与 V8 编译缓存 -- 安装 MicroClaw 桌面客户端、托管技能、AppContainer 沙箱、微信插件 +- 安装 MicroClaw 桌面客户端、托管技能、固定版本 MXC 运行时和微信插件 - 添加 Windows Defender 排除项,创建桌面快捷方式(含一键卸载) 安装完成后,从开始菜单或桌面快捷方式启动 **MicroClaw**。桌面应用会自动拉起 Gateway。 @@ -170,7 +182,7 @@ npm run dev | **技能完整性校验** | SHA-256 哈希 + Ed25519 签名,启动时检测所有技能文件是否被篡改 | | **设备认证** | 每台设备生成 Ed25519 密钥对,Gateway 连接时签名认证 | | **技能白名单** | `allowBundled` / `allowManaged` 控制可用技能范围 | -| **沙盒隔离** | Windows AppContainer 沙盒,限制 AI 工具执行环境 | +| **沙盒隔离** | 实验性 MXC 0.7.0 工作进程;校验哈希、禁用网络/UI、严格文件夹策略且无主机回退 | | **本地 Gateway** | 仅绑定 loopback,不接受远程连接 | --- @@ -193,7 +205,6 @@ npm run dev - Node.js 22+ - Python 3.10+ —— 通过 `pip install -r requirements.txt` 安装构建依赖(已包含 PyInstaller) -- .NET 9 SDK(用于构建 AppContainer 启动器) - npm 依赖已安装(`cd desktop && npm install`) --- @@ -213,7 +224,8 @@ Windows 侧运维脚本现在统一放在 `scripts/windows/` 下。仓库根目 ├── desktop/ # Electron 桌面应用 │ ├── src/ # 主进程(TypeScript) │ └── renderer/ # Vue 3 渲染进程 -├── appcontainer/ # Windows AppContainer 沙盒(.NET + preload hooks) +├── appcontainer/ # 旧版未激活 AppContainer 源码(不再打包) +├── desktop/mxc-plugin/ # 实验性 MXC OpenClaw 工具 ├── skills/ # 托管技能定义 ├── plugins/openclaw-weixin/ # 微信频道插件 ├── scripts/ diff --git a/build.ps1 b/build.ps1 index ebe8043..5ee138f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,4 +1,4 @@ -# One-click build: desktop app + appcontainer launcher -> portable zip -> installer exe +# One-click build: desktop app -> portable zip -> installer exe Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" @@ -67,49 +67,8 @@ if (-not $nodeFound) { exit 1 } -# -- Step 1: Build AppContainerLauncher.exe (.NET 9) -- -Write-Host "`n=== Step 1/7: Build AppContainerLauncher ===" -ForegroundColor Cyan -$acProject = "$root\appcontainer" -if (-not (Test-Path "$acProject\AppContainerLauncher.csproj")) { - Write-Host " ERROR: appcontainer project not found at $acProject" -ForegroundColor Red - exit 1 -} - -# Pipe to ForEach-Object loses the native exit code in $LASTEXITCODE detection -# under StrictMode, so temporarily relax ErrorActionPreference (matches the -# npm/pyinstaller invocation style below) and then check $LASTEXITCODE. -$prev = $ErrorActionPreference -$ErrorActionPreference = "Continue" -dotnet publish $acProject -c Release -o "$acProject\bin\Release\net9.0-windows\win-x64" 2>&1 | - ForEach-Object { Write-Host " $_" } -$publishExit = $LASTEXITCODE -$ErrorActionPreference = $prev -if ($publishExit -ne 0) { - Write-Host " ERROR: dotnet publish failed with exit code $publishExit" -ForegroundColor Red - exit 1 -} - -$acExe = "$acProject\bin\Release\net9.0-windows\win-x64\AppContainerLauncher.exe" -if (-not (Test-Path $acExe)) { - Write-Host " ERROR: AppContainerLauncher.exe not found after build at $acExe" -ForegroundColor Red - exit 1 -} -Write-Host " AppContainerLauncher.exe built" -ForegroundColor Green - -# Copy sandbox-preload.js and its modules alongside launcher (used by electron-builder extraResources) -$preloadSrc = "$acProject\sandbox-preload.js" -if (Test-Path $preloadSrc) { - $releaseDir = "$acProject\bin\Release\net9.0-windows\win-x64" - Copy-Item $preloadSrc "$releaseDir\sandbox-preload.js" -Force - foreach ($mod in @('sandbox-state.js','sandbox-permission.js','sandbox-fs-hooks.js','sandbox-cp-hooks.js','sandbox-sensitive.js','path-extraction.js')) { - $modSrc = "$acProject\$mod" - if (Test-Path $modSrc) { Copy-Item $modSrc "$releaseDir\$mod" -Force } - } - Write-Host " sandbox-preload.js + modules copied" -ForegroundColor Green -} - -# -- Step 2: Clean dist/ to prevent stale TypeScript output -- -Write-Host "`n=== Step 2/7: Clean stale build artifacts ===" -ForegroundColor Cyan +# -- Step 1: Clean dist/ to prevent stale TypeScript output -- +Write-Host "`n=== Step 1/6: Clean stale build artifacts ===" -ForegroundColor Cyan $distDir = "$root\desktop\dist" if (Test-Path $distDir) { Remove-Item "$distDir\*.js" -Force -ErrorAction SilentlyContinue @@ -124,8 +83,8 @@ if (-not (Test-Path $outDist)) { Write-Host " Created $outDist" } -# -- Step 3: Build & pack desktop -- -Write-Host "`n=== Step 3/7: Build & pack desktop ===" -ForegroundColor Cyan +# -- Step 2: Build & pack desktop -- +Write-Host "`n=== Step 2/6: Build & pack desktop ===" -ForegroundColor Cyan Push-Location "$root\desktop" try { # First-run bootstrap: install npm deps (including renderer via postinstall) @@ -181,8 +140,8 @@ if (Test-Path $desktopAsar) { } } -# Step 4: Create portable zip -Write-Host "`n=== Step 4/7: Create portable zip ===" -ForegroundColor Cyan +# Step 3: Create portable zip +Write-Host "`n=== Step 3/6: Create portable zip ===" -ForegroundColor Cyan $zipPath = "$root\dist\microclaw-portable.zip" if (Test-Path $zipPath) { Remove-Item $zipPath -Force } Compress-Archive -Path "$root\desktop\release\win-unpacked\*" -DestinationPath $zipPath @@ -217,8 +176,8 @@ $installManifest = [ordered]@{ $installManifest | ConvertTo-Json | Set-Content $manifestPath -Encoding utf8 Write-Host " Install manifest: $manifestPath" -ForegroundColor DarkGray -# Step 5: Build installer (onedir mode to avoid WDAC blocking DLLs from temp) -Write-Host "`n=== Step 5/7: Build installer ===" -ForegroundColor Cyan +# Step 4: Build installer (onedir mode to avoid WDAC blocking DLLs from temp) +Write-Host "`n=== Step 4/6: Build installer ===" -ForegroundColor Cyan Push-Location $root $installerBuilt = $false @@ -339,8 +298,8 @@ if (-not $installerBuilt) { exit 1 } -# Step 6: Pack onedir output into a single distributable zip -Write-Host "`n=== Step 6/7: Pack installer directory ===" -ForegroundColor Cyan +# Step 5: Pack onedir output into a single distributable zip +Write-Host "`n=== Step 5/6: Pack installer directory ===" -ForegroundColor Cyan $installerDir = "$root\dist\MicroClawInstaller" $installerZip = "$root\dist\MicroClawInstaller.zip" if (-not (Test-Path $installerDir)) { @@ -353,12 +312,12 @@ Compress-Archive -Path "$installerDir\*" -DestinationPath $installerZip $instZipSizeMB = [math]::Round((Get-Item $installerZip).Length / 1MB, 1) Write-Host " -> $installerZip ${instZipSizeMB} MB" -ForegroundColor Green -# Step 7: Build the single-exe setup (NSIS self-extractor) and code-sign it. +# Step 6: Build the single-exe setup (NSIS self-extractor) and code-sign it. # This is the ONE file end users download. It extracts the onedir installer to a # real directory under %LOCALAPPDATA% (not %TEMP%, preserving WDAC safety) and # auto-launches MicroClawInstaller.exe. Only this stub needs signing to clear # SmartScreen (it is the only file that carries Mark-of-the-Web on download). -Write-Host "`n=== Step 7/7: Build single-exe setup + sign ===" -ForegroundColor Cyan +Write-Host "`n=== Step 6/6: Build single-exe setup + sign ===" -ForegroundColor Cyan $setupExe = "$root\dist\MicroClawSetup.exe" $nsiScript = "$root\installer\microclaw-setup.nsi" $setupIcon = "$root\deployer\assets\microclaw.ico" diff --git a/deploy.py b/deploy.py index 1e17f77..2036e9b 100644 --- a/deploy.py +++ b/deploy.py @@ -927,8 +927,6 @@ def _build_install_steps(self, ws): (62, "Copying bundled assets...", lambda: self._copy_bundled_assets(ws)), (65, "Writing API keys...", lambda: self._write_env_file()), (70, "Writing MicroClaw configuration...", ws.write_config), - (80, "Installing web search provider...", ws.install_search_provider_plugin), - (85, "Provisioning AppContainer sandbox...", ws.provision_appcontainer), (94, "Validating MicroClaw update...", ws.verify_openclaw_upgrade), (95, "Installing uninstaller...", ws.install_uninstaller_bundle), (97, "Creating desktop shortcut...", ws.create_desktop_shortcut), diff --git a/deployer/webview_bridge.py b/deployer/webview_bridge.py index 3a38252..5f6d4c5 100644 --- a/deployer/webview_bridge.py +++ b/deployer/webview_bridge.py @@ -9,8 +9,6 @@ import time import tkinter as tk import traceback -import urllib.error -import urllib.request import webbrowser from pathlib import Path from tkinter import filedialog @@ -280,7 +278,6 @@ def _assets_dir(): "config": "正在写入 MicroClaw 配置…", "compileCache": "正在预热启动缓存…", "searchProvider": "正在安装网络搜索插件…", - "sandbox": "正在配置 AppContainer 沙箱…", "verifyUpgrade": "正在验证 MicroClaw 更新…", "uninstaller": "正在安装卸载程序…", "shortcut": "正在创建桌面快捷方式…", @@ -334,7 +331,6 @@ def _assets_dir(): "config": "Writing MicroClaw configuration...", "compileCache": "Warming up V8 compile cache...", "searchProvider": "Installing web search provider...", - "sandbox": "Provisioning AppContainer sandbox...", "verifyUpgrade": "Validating MicroClaw update...", "uninstaller": "Installing uninstaller...", "shortcut": "Creating desktop shortcut...", @@ -583,14 +579,6 @@ def _build_install_steps(self, ws): (62, steps["assets"], lambda: self._copy_bundled_assets(), LOCAL_RETRIES), (65, steps["apiKeys"], lambda: self._write_env_file(), LOCAL_RETRIES), (70, steps["config"], ws.write_config, LOCAL_RETRIES), - (75, steps["compileCache"], ws.warmup_compile_cache, LOCAL_RETRIES), - ( - 80, - steps["searchProvider"], - ws.install_search_provider_plugin, - NETWORK_RETRIES, - ), - (85, steps["sandbox"], ws.provision_appcontainer, LOCAL_RETRIES), (95, steps["uninstaller"], ws.install_uninstaller_bundle, LOCAL_RETRIES), (97, steps["shortcut"], ws.create_desktop_shortcut, LOCAL_RETRIES), ] @@ -669,7 +657,7 @@ def _start_and_validate_desktop(self, ws): raise RuntimeError("MicroClaw could not be launched") if not self._wait_for_desktop_service(transaction_id): raise RuntimeError("MicroClaw service did not become ready before timeout") - return ws.validate_running_gateway() + return True def _run_step_with_retry(self, pct, label, fn, retries, progress_key=""): """Execute one install step, retrying transient failures. @@ -867,9 +855,7 @@ def _finish_ok(self): ) def _wait_for_desktop_service(self, transaction_id, timeout_seconds=300): - port = int(self._config.get("gateway.port", 18789)) deadline = time.monotonic() + timeout_seconds - url = f"http://127.0.0.1:{port}/health" ready_path = DEFAULT_DESKTOP_DIR / "upgrade" / f"desktop-ready-{transaction_id}.json" while time.monotonic() < deadline: if not self.get_state()["running"]: @@ -887,18 +873,16 @@ def _wait_for_desktop_service(self, transaction_id, timeout_seconds=300): payload = json.loads(ready_path.read_text(encoding="utf-8")) if payload.get("transactionId") == transaction_id and payload.get("error"): raise RuntimeError(f"MicroClaw startup rejected handoff: {payload['error']}") - ready = payload.get("transactionId") == transaction_id and ( - process is None or payload.get("pid") == process.pid + ready = ( + payload.get("transactionId") == transaction_id + and payload.get("controlPlaneReady") is True + and (process is None or payload.get("pid") == process.pid) ) except (OSError, json.JSONDecodeError, TypeError): pass - try: - with urllib.request.urlopen(url, timeout=2) as response: - if ready and response.status == 200: - ready_path.unlink(missing_ok=True) - return True - except (OSError, urllib.error.URLError): - pass + if ready: + ready_path.unlink(missing_ok=True) + return True time.sleep(0.5) return False diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index 79410ef..ad28f10 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -423,7 +423,7 @@ def __init__(self, config, logger: DeployerLogger): # Optional UI hook forwarded to upgrade transactions so long backup / # restore file operations can report progress instead of looking frozen. self.progress_callback: Callable[[str], None] | None = None - self.appcontainer_enabled = True # AppContainer sandbox (built-in) + self.appcontainer_enabled = False # Legacy AppContainer is inactive in the MXC experiment. # Respect an explicit registry immediately. Otherwise start with the # fallback and defer network probing until a download is required. @@ -1909,7 +1909,6 @@ def _discard_failed_transaction( def _rollback_openclaw_transaction(self, transaction: OpenClawUpgradeTransaction) -> bool: transaction.progress_callback = self.progress_callback - process: subprocess.Popen | None = None try: original_phase = transaction.manifest.phase transaction.rollback() @@ -1918,17 +1917,6 @@ def _rollback_openclaw_transaction(self, transaction: OpenClawUpgradeTransaction if original_phase == UpgradePhase.BACKING_UP: transaction.complete_rollback() return True - source_version = transaction.manifest.source_version - if source_version is not None: - process = self._start_validation_gateway(expected_version=source_version) - if not self._validate_gateway_health(): - self.log.error( - "Previous OpenClaw Gateway did not become healthy after rollback" - ) - self._discard_failed_transaction( - transaction, "restored gateway did not become healthy" - ) - return False transaction.complete_rollback() return True except Exception as error: @@ -1937,8 +1925,6 @@ def _rollback_openclaw_transaction(self, transaction: OpenClawUpgradeTransaction ) self._discard_failed_transaction(transaction, str(error) or error.__class__.__name__) return False - finally: - self._stop_validation_gateway(process) def recover_interrupted_openclaw_upgrade(self) -> bool: if not self._gateway_is_stopped_for_upgrade(): @@ -2271,83 +2257,6 @@ def _load_openclaw_state_env(self, state_dir: Path) -> dict[str, str]: pass return values - def _resolve_validation_node(self) -> Path: - candidates = [] - if self._node_bin is not None: - candidates.append(Path(self._node_bin) / "node.exe") - candidates.append(self.node_dir / "node.exe") - path_node = shutil.which("node") - if path_node: - candidates.append(Path(path_node)) - for candidate in candidates: - if candidate.exists(): - return candidate - raise FileNotFoundError("node.exe not found for OpenClaw validation") - - def _start_validation_gateway( - self, expected_version: str | None = OPENCLAW_TARGET_VERSION - ) -> subprocess.Popen: - installation = self._detect_openclaw_installation() - if installation is None or ( - expected_version is not None and installation.version != expected_version - ): - raise RuntimeError(f"Expected OpenClaw package is not installed: {expected_version}") - node = self._resolve_validation_node() - state_dir = Path.home() / ".openclaw" - cache_dir = state_dir / "compile-cache" - cache_dir.mkdir(parents=True, exist_ok=True) - env = self._get_env() - env.update(self._load_openclaw_state_env(state_dir)) - env.update( - { - "OPENCLAW_STATE_DIR": str(state_dir), - "NODE_COMPILE_CACHE": str(cache_dir), - "NODE_ENV": "production", - "OPENCLAW_NO_RESPAWN": "1", - } - ) - return subprocess.Popen( - [ - str(node), - str(installation.entry_path), - "gateway", - "run", - "--port", - str(self.cfg.get("gateway.port", 18789)), - "--bind", - "loopback", - "--allow-unconfigured", - ], - cwd=str(installation.package_dir), - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=_CREATE_NO_WINDOW, - ) - - def _stop_validation_gateway(self, process: subprocess.Popen | None) -> None: - if process is None or process.poll() is not None: - return - if platform.system() == "Windows" and process.pid: - result = self._run( - ["taskkill", "/pid", str(process.pid), "/T", "/F"], - capture_output=True, - text=True, - timeout=15, - ) - if result.returncode not in (0, 128): - self.log.warn( - f"Could not stop validation Gateway process tree: " - f"{result.stderr.strip() or result.stdout.strip()}" - ) - else: - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - def _validate_installed_version(self) -> bool: installation = self._detect_openclaw_installation() return bool( @@ -2561,10 +2470,7 @@ def begin_openclaw_upgrade_validation(self) -> bool: if transaction is not None: transaction.mark_verifying() - checks = [ - ("version", self._validate_installed_version), - ("appcontainer", self._validate_appcontainer_smoke), - ] + checks = [("version", self._validate_installed_version)] for name, check in checks: passed = bool(check()) if transaction is not None: @@ -2603,18 +2509,9 @@ def record(name: str, passed: bool) -> None: return True def verify_openclaw_upgrade(self) -> bool: - if not self.begin_openclaw_upgrade_validation(): - return False - - process = None - try: - process = self._start_validation_gateway() - return self.validate_running_gateway() - except Exception as error: - self.log.error(str(error)) - return False - finally: - self._stop_validation_gateway(process) + # The desktop applies and proves MXC policy before it starts the Gateway. + # Starting a validation Gateway here would create a host-tool execution window. + return self.begin_openclaw_upgrade_validation() def commit_openclaw_upgrade(self) -> bool: transaction = self._openclaw_transaction @@ -2748,121 +2645,9 @@ def _patch_pi_ai_usage_streaming(self) -> None: self.log.warn(f" Failed to write pi-ai usage-streaming patch: {exc}") def warmup_compile_cache(self) -> bool: - """Warm up Node.js compile cache by briefly starting the gateway. - - Node 22's NODE_COMPILE_CACHE stores V8 compiled bytecode so that - subsequent starts skip JS parsing. We run the gateway for a few - seconds during install so the cache is pre-populated and the user's - first real launch is fast. - """ - self.log.step("Warming up compile cache for faster startup…") - - node = self.node_dir / "node.exe" - # Search both node_dir and the npm install prefix actually used - # (these differ when we fell back to %APPDATA%\npm). - entry_roots: list[Path] = [self.node_dir] - install_prefix = getattr(self, "install_prefix", None) - if install_prefix and Path(install_prefix) not in entry_roots: - entry_roots.append(Path(install_prefix)) - appdata = os.environ.get("APPDATA") - if appdata: - appdata_npm = Path(appdata) / "npm" - if appdata_npm not in entry_roots: - entry_roots.append(appdata_npm) - entry: Path | None = None - for root in entry_roots: - for sub in ( - ("node_modules", "openclaw", "openclaw.mjs"), - ("node_modules", "openclaw", "dist", "index.js"), - ("lib", "node_modules", "openclaw", "openclaw.mjs"), - ("lib", "node_modules", "openclaw", "dist", "index.js"), - ): - candidate = root.joinpath(*sub) - if candidate.exists(): - entry = candidate - break - if entry: - break - if not node.exists() or entry is None: - self.log.info(" Node or openclaw entry not found — skipping warmup") - return True - - state_dir = Path.home() / ".openclaw" - cache_dir = state_dir / "compile-cache" - cache_dir.mkdir(parents=True, exist_ok=True) - version_marker = cache_dir / ".microclaw-version" - try: - marker_matches = version_marker.read_text(encoding="utf-8").strip() == ( - OPENCLAW_TARGET_VERSION - ) - except OSError: - marker_matches = False - cache_has_files = any( - path.is_file() and path != version_marker for path in cache_dir.rglob("*") - ) - if not self._openclaw_upgrade_required and cache_has_files: - if not marker_matches: - version_marker.write_text(OPENCLAW_TARGET_VERSION, encoding="utf-8") - self.log.info(" Compile cache is current; skipping warmup") - return True - - env = self._get_env() - env["NODE_COMPILE_CACHE"] = str(cache_dir) - env["NODE_OPTIONS"] = "--dns-result-order=ipv4first" - env["NODE_ENV"] = "production" - env["OPENCLAW_STATE_DIR"] = str(state_dir) - - try: - # Start gateway, let it initialize (populates compile cache), then stop it - proc = subprocess.Popen( - [ - str(node), - str(entry), - "gateway", - "run", - "--port", - "18789", - "--bind", - "loopback", - "--force", - "--allow-unconfigured", - ], - env=env, - cwd=str(entry.parent), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - creationflags=0x08000000, # CREATE_NO_WINDOW - ) - # Wait enough time for Node to parse and compile all modules - time.sleep(8) - # Kill the entire process tree (terminate alone may leave child node processes) - proc.kill() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - pass - # Also kill any node processes still listening on the warmup port - try: - subprocess.run( - [ - "cmd", - "/c", - "for /f \"tokens=5\" %a in ('netstat -ano ^| findstr LISTENING ^| findstr :18789') do taskkill /PID %a /T /F >nul 2>&1", - ], - shell=True, - timeout=10, - creationflags=0x08000000, - ) - except Exception: - pass - - cached = sum(1 for _ in cache_dir.glob("**/*") if _.is_file()) - version_marker.write_text(OPENCLAW_TARGET_VERSION, encoding="utf-8") - self.log.success(f"Compile cache warmed up ({cached} files in {cache_dir})") - return True - except Exception as e: - self.log.warn(f"Compile cache warmup failed (non-fatal): {e}") - return True # non-fatal + """Defer Gateway warmup until the desktop can prove MXC readiness.""" + self.log.info(" Compile-cache warmup deferred until MXC-secured desktop startup") + return True # ────────────────────── Managed Skills ────────────────────── @@ -5222,7 +5007,7 @@ def uninstall(self) -> bool: self._uninstall_kill_desktop() self._uninstall_plugins() self._uninstall_clean_official() - self._uninstall_appcontainer() + # Legacy AppContainer profiles and ACLs are intentionally left untouched. self._uninstall_clean_desktop() self._uninstall_clean_config() self._uninstall_clean_shortcuts() diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index d596183..1af578a 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -43,22 +43,18 @@ extraResources: to: openclaw/ - from: resources/openclaw.asar to: openclaw.asar - - from: ../appcontainer/bin/Release/net9.0-windows/win-x64/AppContainerLauncher.exe - to: AppContainerLauncher.exe - - from: ../appcontainer/sandbox-preload.js - to: sandbox-preload.js - - from: ../appcontainer/sandbox-state.js - to: sandbox-state.js - - from: ../appcontainer/sandbox-permission.js - to: sandbox-permission.js - - from: ../appcontainer/sandbox-fs-hooks.js - to: sandbox-fs-hooks.js - - from: ../appcontainer/sandbox-cp-hooks.js - to: sandbox-cp-hooks.js - - from: ../appcontainer/sandbox-sensitive.js - to: sandbox-sensitive.js - - from: ../appcontainer/path-extraction.js - to: path-extraction.js + - from: mxc-plugin/ + to: mxc-plugin/ + - from: node_modules/@microsoft/mxc-sdk/ + to: mxc-plugin/node_modules/@microsoft/mxc-sdk/ + - from: node_modules/node-pty/ + to: mxc-plugin/node_modules/node-pty/ + - from: node_modules/semver/ + to: mxc-plugin/node_modules/semver/ + +asarUnpack: + - node_modules/@microsoft/mxc-sdk/bin/**/* + - node_modules/node-pty/**/* files: - dist/**/* diff --git a/desktop/mxc-plugin/index.mjs b/desktop/mxc-plugin/index.mjs new file mode 100644 index 0000000..d63da4b --- /dev/null +++ b/desktop/mxc-plugin/index.mjs @@ -0,0 +1,125 @@ +import { runMxcWorker } from "./runtime.mjs"; +import process from "node:process"; + +const PLUGIN_ID = "microclaw-mxc"; + +function textResult(value, isError = false) { + return { + content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value) }], + ...(isError ? { isError: true } : {}), + }; +} + +function createTool(api, context, definition) { + const agentId = context.agentId || "main"; + const config = api.pluginConfig || {}; + const policy = config.agents?.[agentId]; + if ( + process.env.MICROCLAW_MXC_READY !== "1" || + config.sdkVersion !== "0.7.0" || + config.policyVersion !== "0.7.0-alpha" || + config.upstreamCommit !== "34d7fe2b4b3226bd4d11dc4a32419b7ec198a88b" || + config.fallback?.allowDaclMutation !== false || + !policy + ) { + return null; + } + return { + name: definition.name, + description: definition.description, + parameters: definition.parameters, + async execute(_id, params) { + const result = await runMxcWorker(policy, definition.request(params)); + if (!result.ok) return textResult(result.error || "MXC operation failed.", true); + return textResult(result.content ?? result); + }, + }; +} + +const definitions = [ + { + name: "mxc_read", + description: "Read one UTF-8 file through the fail-closed Microsoft MXC worker.", + parameters: { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { path: { type: "string" } }, + }, + request: (params) => ({ operation: "read", path: params.path }), + }, + { + name: "mxc_write", + description: "Create one UTF-8 file through the fail-closed Microsoft MXC worker.", + parameters: { + type: "object", + additionalProperties: false, + required: ["path", "content"], + properties: { + path: { type: "string" }, + content: { type: "string" }, + overwrite: { type: "boolean", default: false }, + }, + }, + request: (params) => ({ + operation: "write", + path: params.path, + content: params.content, + overwrite: params.overwrite === true, + }), + }, + { + name: "mxc_edit", + description: "Replace one unique string in a UTF-8 file through the Microsoft MXC worker.", + parameters: { + type: "object", + additionalProperties: false, + required: ["path", "oldText", "newText"], + properties: { + path: { type: "string" }, + oldText: { type: "string" }, + newText: { type: "string" }, + }, + }, + request: (params) => ({ + operation: "edit", + path: params.path, + oldText: params.oldText, + newText: params.newText, + }), + }, + { + name: "mxc_exec", + description: + "Run a bounded non-interactive command in the Microsoft MXC worker. There is no host fallback.", + parameters: { + type: "object", + additionalProperties: false, + required: ["command"], + properties: { + command: { type: "string", maxLength: 8192 }, + cwd: { type: "string" }, + timeoutMs: { type: "integer", minimum: 1000, maximum: 30000 }, + }, + }, + request: (params) => ({ + operation: "exec", + command: params.command, + cwd: params.cwd, + timeoutMs: params.timeoutMs, + }), + }, +]; + +export default { + id: PLUGIN_ID, + name: "MicroClaw Microsoft MXC Tools", + description: "Experimental fail-closed tools backed by Microsoft MXC.", + register(api) { + for (const definition of definitions) { + api.registerTool((context) => createTool(api, context, definition), { + name: definition.name, + }); + } + }, +}; diff --git a/desktop/mxc-plugin/openclaw.plugin.json b/desktop/mxc-plugin/openclaw.plugin.json new file mode 100644 index 0000000..9b7d9b6 --- /dev/null +++ b/desktop/mxc-plugin/openclaw.plugin.json @@ -0,0 +1,69 @@ +{ + "id": "microclaw-mxc", + "name": "MicroClaw Microsoft MXC Tools", + "description": "Experimental fail-closed filesystem and command tools backed by Microsoft MXC.", + "version": "0.1.0", + "activation": { + "onStartup": true + }, + "contracts": { + "tools": ["mxc_read", "mxc_write", "mxc_edit", "mxc_exec"] + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "required": ["sdkVersion", "policyVersion", "upstreamCommit", "fallback", "agents"], + "properties": { + "sdkVersion": { + "const": "0.7.0" + }, + "policyVersion": { + "const": "0.7.0-alpha" + }, + "upstreamCommit": { + "const": "34d7fe2b4b3226bd4d11dc4a32419b7ec198a88b" + }, + "fallback": { + "type": "object", + "additionalProperties": false, + "required": ["allowDaclMutation"], + "properties": { + "allowDaclMutation": { + "const": false + } + } + }, + "agents": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["workspace", "readonlyPaths", "readwritePaths", "deniedPaths"], + "properties": { + "workspace": { + "type": "string" + }, + "readonlyPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "readwritePaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "deniedPaths": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/desktop/mxc-plugin/package.json b/desktop/mxc-plugin/package.json new file mode 100644 index 0000000..699fa0e --- /dev/null +++ b/desktop/mxc-plugin/package.json @@ -0,0 +1,20 @@ +{ + "name": "@microclaw/openclaw-mxc-tools", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "index.mjs", + "runtime.mjs", + "worker.mjs", + "openclaw.plugin.json" + ], + "peerDependencies": { + "openclaw": "2026.7.1-1" + }, + "openclaw": { + "extensions": [ + "./index.mjs" + ] + } +} diff --git a/desktop/mxc-plugin/runtime.mjs b/desktop/mxc-plugin/runtime.mjs new file mode 100644 index 0000000..77ba906 --- /dev/null +++ b/desktop/mxc-plugin/runtime.mjs @@ -0,0 +1,224 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const SDK_VERSION = "0.7.0"; +const POLICY_VERSION = "0.7.0-alpha"; +const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url)); +const EXPECTED_HASHES = { + x64: "2wo0Ir6eGzlswbJUfHD/FrJ0EkOKMcEKRavzcMrIauI=", + arm64: "5DDQ5PRPYW6R22hPjYJabck+BqEmK40AvKrHUioxeqs=", +}; +const MAX_OUTPUT_BYTES = 1024 * 1024; + +// The executable is resolved only from application-owned package resources. +// Do not let the SDK's debugging override redirect either probes or launches. +delete process.env.MXC_BIN_DIR; + +function resolveSdkRoot() { + const candidates = [ + path.join(PLUGIN_ROOT, "node_modules", "@microsoft", "mxc-sdk"), + path.join(PLUGIN_ROOT, "..", "node_modules", "@microsoft", "mxc-sdk"), + ]; + const sdkRoot = candidates.find((candidate) => existsSync(path.join(candidate, "package.json"))); + if (!sdkRoot) { + throw new Error( + "Pinned @microsoft/mxc-sdk@0.7.0 is missing from the packaged MXC plugin resources.", + ); + } + return sdkRoot; +} + +function resolveRuntimePaths() { + const sdkRoot = resolveSdkRoot(); + const architecture = process.arch === "arm64" ? "arm64" : "x64"; + const wxcPath = path.join(sdkRoot, "bin", architecture, "wxc-exec.exe"); + const workerPath = path.join(PLUGIN_ROOT, "worker.mjs"); + const packageJson = JSON.parse(readFileSync(path.join(sdkRoot, "package.json"), "utf8")); + if (packageJson.version !== SDK_VERSION) { + throw new Error(`Expected @microsoft/mxc-sdk ${SDK_VERSION}, found ${packageJson.version}.`); + } + if (!existsSync(wxcPath) || !statSync(wxcPath).isFile()) { + throw new Error(`Packaged wxc-exec is missing for ${architecture}.`); + } + const binaryHash = createHash("sha256").update(readFileSync(wxcPath)).digest("base64"); + if (binaryHash !== EXPECTED_HASHES[architecture]) { + throw new Error("Packaged wxc-exec failed the pinned SHA-256 check."); + } + return { sdkRoot, architecture, wxcPath, workerPath, binaryHash }; +} + +function runProbe(wxcPath) { + return new Promise((resolve, reject) => { + execFile( + wxcPath, + ["--probe"], + { windowsHide: true, timeout: 5000, maxBuffer: 256 * 1024 }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`wxc-exec --probe failed: ${stderr || error.message}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch { + reject(new Error("wxc-exec --probe returned malformed JSON.")); + } + }, + ); + }); +} + +function parseProbe(value) { + const tiers = new Set(["base-container", "appcontainer-bfs", "appcontainer-dacl"]); + return { + tier: tiers.has(value?.tier) ? value.tier : undefined, + warnings: Array.isArray(value?.warnings) + ? value.warnings.filter((warning) => typeof warning === "string") + : [], + uiCapabilities: + value?.probes?.uiCapabilities && typeof value.probes.uiCapabilities === "object" + ? value.probes.uiCapabilities + : undefined, + }; +} + +function stripWorkerEnvironment(workspace, nodeExecutable) { + const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows"; + const temp = path.join(workspace, ".mxc-tmp"); + return { + SystemRoot: systemRoot, + WINDIR: systemRoot, + ComSpec: path.join(systemRoot, "System32", "cmd.exe"), + COMSPEC: path.join(systemRoot, "System32", "cmd.exe"), + PATHEXT: ".COM;.EXE;.BAT;.CMD", + PATH: `${path.dirname(nodeExecutable)};${path.join(systemRoot, "System32")}`, + TEMP: temp, + TMP: temp, + HOME: workspace, + USERPROFILE: workspace, + }; +} + +function quoteWindowsArgument(value) { + return `"${String(value).replaceAll('"', '\\"')}"`; +} + +async function loadSdk(sdkRoot) { + return import(pathToFileURL(path.join(sdkRoot, "dist", "index.js")).href); +} + +export async function probeMxcRuntime() { + if (process.platform !== "win32") { + throw new Error("The MicroClaw MXC POC currently supports Windows only."); + } + const paths = resolveRuntimePaths(); + const probe = parseProbe(await runProbe(paths.wxcPath)); + return { + packageVersion: SDK_VERSION, + binaryHash: paths.binaryHash, + platform: process.platform, + architecture: paths.architecture, + probe, + }; +} + +export async function runMxcWorker(policy, request, options = {}) { + const paths = resolveRuntimePaths(); + const sdk = await loadSdk(paths.sdkRoot); + const nodeExecutable = options.nodeExecutable || process.execPath; + if ( + path.basename(nodeExecutable).toLowerCase() !== "node.exe" || + !existsSync(nodeExecutable) || + !statSync(nodeExecutable).isFile() + ) { + return { ok: false, error: "The trusted packaged Node runtime is unavailable." }; + } + const timeoutMs = Math.min(Math.max(Number(options.timeoutMs) || 30000, 1000), 30000); + const filesystem = { + readonlyPaths: [ + path.dirname(nodeExecutable), + PLUGIN_ROOT, + ...(Array.isArray(policy.readonlyPaths) ? policy.readonlyPaths : []), + ], + readwritePaths: [ + policy.workspace, + ...(Array.isArray(policy.readwritePaths) ? policy.readwritePaths : []), + ], + deniedPaths: Array.isArray(policy.deniedPaths) ? policy.deniedPaths : [], + clearPolicyOnExit: true, + }; + const config = sdk.createConfigFromPolicy( + { + version: POLICY_VERSION, + filesystem, + network: { allowOutbound: false, allowLocalNetwork: false }, + ui: { allowWindows: false, clipboard: "none", allowInputInjection: false }, + timeoutMs, + }, + "process", + ); + config.fallback = { allowDaclMutation: false }; + config.processContainer.leastPrivilege = true; + config.process.commandLine = `${quoteWindowsArgument(nodeExecutable)} ${quoteWindowsArgument( + paths.workerPath, + )}`; + config.process.cwd = policy.workspace; + config.process.env = Object.entries(stripWorkerEnvironment(policy.workspace, nodeExecutable)).map( + ([key, value]) => `${key}=${value}`, + ); + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let child; + try { + child = sdk.spawnSandboxFromConfig( + config, + { usePty: false, executablePath: paths.wxcPath }, + policy.workspace, + ); + } catch (error) { + resolve({ ok: false, error: error instanceof Error ? error.message : String(error) }); + return; + } + const finish = (value) => { + if (!settled) { + settled = true; + resolve(value); + } + }; + child.stdout?.on("data", (chunk) => { + if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk.toString(); + }); + child.on("error", (error) => finish({ ok: false, error: error.message })); + child.on("close", (code) => { + if (code !== 0) { + finish({ ok: false, error: stderr.trim() || `MXC worker exited with code ${code}.` }); + return; + } + try { + finish(JSON.parse(stdout.trim())); + } catch { + finish({ ok: false, error: "MXC worker returned malformed output." }); + } + }); + child.stdin?.end( + JSON.stringify({ + request, + policy: { + workspace: policy.workspace, + readonlyPaths: policy.readonlyPaths, + readwritePaths: policy.readwritePaths, + }, + }), + ); + }); +} diff --git a/desktop/mxc-plugin/worker.mjs b/desktop/mxc-plugin/worker.mjs new file mode 100644 index 0000000..e1b2636 --- /dev/null +++ b/desktop/mxc-plugin/worker.mjs @@ -0,0 +1,124 @@ +import { Buffer } from "node:buffer"; +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; + +const MAX_READ_BYTES = 1024 * 1024; +const MAX_WRITE_BYTES = 1024 * 1024; +const MAX_EXEC_OUTPUT = 1024 * 1024; + +function isInside(candidate, root) { + const child = path.resolve(candidate).toLowerCase(); + const parent = path.resolve(root).toLowerCase(); + return child === parent || child.startsWith(`${parent}${path.sep}`); +} + +async function canonicalTarget(input, policy, write = false) { + const requested = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(policy.workspace, input); + let probe = requested; + if (write) { + while (!fs.existsSync(probe)) { + const parent = path.dirname(probe); + if (parent === probe) break; + probe = parent; + } + } + const canonicalProbe = await fs.promises.realpath(probe); + const canonical = + write && probe !== requested + ? path.resolve(canonicalProbe, path.relative(probe, requested)) + : canonicalProbe; + const rwRoots = [policy.workspace, ...(policy.readwritePaths || [])]; + const roRoots = [...rwRoots, ...(policy.readonlyPaths || [])]; + const roots = write ? rwRoots : roRoots; + const canonicalRoots = await Promise.all(roots.map((root) => fs.promises.realpath(root))); + if (!canonicalRoots.some((root) => isInside(canonical, root))) { + throw new Error(`Path is outside the effective ${write ? "read-write" : "read"} policy.`); + } + return canonical; +} + +function execCommand(command, cwd, timeoutMs) { + return new Promise((resolve) => { + execFile( + process.env.ComSpec || "cmd.exe", + ["/d", "/s", "/c", command], + { + cwd, + env: process.env, + windowsHide: true, + timeout: Math.min(Math.max(Number(timeoutMs) || 30000, 1000), 30000), + maxBuffer: MAX_EXEC_OUTPUT, + }, + (error, stdout, stderr) => { + resolve({ + ok: !error, + exitCode: typeof error?.code === "number" ? error.code : error ? 1 : 0, + stdout: String(stdout).slice(0, MAX_EXEC_OUTPUT), + stderr: String(stderr || error?.message || "").slice(0, MAX_EXEC_OUTPUT), + }); + }, + ); + }); +} + +async function handle(request, policy) { + switch (request.operation) { + case "ping": + return { ok: true }; + case "read": { + const target = await canonicalTarget(String(request.path || ""), policy); + const stat = await fs.promises.stat(target); + if (!stat.isFile() || stat.size > MAX_READ_BYTES) + throw new Error("File is not readable or exceeds 1 MiB."); + return { ok: true, content: await fs.promises.readFile(target, "utf8") }; + } + case "write": { + const content = String(request.content ?? ""); + if (Buffer.byteLength(content) > MAX_WRITE_BYTES) throw new Error("Write exceeds 1 MiB."); + const target = await canonicalTarget(String(request.path || ""), policy, true); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, content, { + encoding: "utf8", + flag: request.overwrite ? "w" : "wx", + }); + return { ok: true, bytes: Buffer.byteLength(content) }; + } + case "edit": { + const target = await canonicalTarget(String(request.path || ""), policy, true); + const original = await fs.promises.readFile(target, "utf8"); + const oldText = String(request.oldText ?? ""); + if (!oldText || !original.includes(oldText)) throw new Error("oldText was not found."); + if (original.indexOf(oldText) !== original.lastIndexOf(oldText)) { + throw new Error("oldText must identify exactly one occurrence."); + } + const updated = original.replace(oldText, String(request.newText ?? "")); + if (Buffer.byteLength(updated) > MAX_WRITE_BYTES) + throw new Error("Edited file exceeds 1 MiB."); + await fs.promises.writeFile(target, updated, "utf8"); + return { ok: true }; + } + case "exec": { + const command = String(request.command || ""); + if (!command || command.length > 8192) throw new Error("Command must be 1-8192 characters."); + const cwd = await canonicalTarget(String(request.cwd || policy.workspace), policy); + return execCommand(command, cwd, request.timeoutMs); + } + default: + throw new Error("Unsupported MXC worker operation."); + } +} + +let input = ""; +for await (const chunk of process.stdin) input += chunk; +try { + const message = JSON.parse(input); + process.stdout.write(JSON.stringify(await handle(message.request || {}, message.policy || {}))); +} catch (error) { + process.stdout.write( + JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }), + ); +} diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 69a2335..5d9c50c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@electron/asar": "^3.4.1", + "@microsoft/mxc-sdk": "0.7.0", "electron-store": "^8.2.0", "node-addon-api": "^8.7.0", "ws": "^8.19.0" @@ -101,6 +102,31 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@microsoft/mxc-sdk": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.7.0.tgz", + "integrity": "sha512-EWhz2pKkcPluZHAOI1yneV+JK0NO/3hdh7nABBb0x4PjyUWMeSWPTBAjtIu+BhtMYOqlaijSPZMZ0Hs/J4ZL4A==", + "license": "MIT", + "dependencies": { + "node-pty": "^1.2.0-beta.12", + "semver": "^7.7.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@microsoft/mxc-sdk/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", @@ -5236,6 +5262,22 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-api-version": { "version": "0.2.1", "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", diff --git a/desktop/package.json b/desktop/package.json index 4d76726..b3ca928 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@electron/asar": "^3.4.1", + "@microsoft/mxc-sdk": "0.7.0", "electron-store": "^8.2.0", "node-addon-api": "^8.7.0", "ws": "^8.19.0" diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 2bebb1f..0be334b 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -32,6 +32,37 @@ interface AppSettings { }; } +interface MxcFolderPolicy { + readonlyPaths: string[]; + readwritePaths: string[]; + workspace?: string | null; + deniedPaths?: string[]; +} + +interface MxcStatus { + ready: boolean; + packageReady: boolean; + hashReady: boolean; + osSupported: boolean; + policyReady: boolean; + workerReady: boolean; + sdkVersion: string; + policyVersion: string; + upstreamCommit: string; + architecture: string; + binaryHash: string | null; + isolationTier: "base-container" | "appcontainer-bfs" | "appcontainer-dacl" | null; + isolationWarnings: string[]; + requiresHostPreparation: boolean; + lastError: string | null; + agents: Array<{ + id: string; + name: string; + desired: MxcFolderPolicy; + effective: MxcFolderPolicy; + }>; +} + interface ChatAttachment { type: "image" | "file"; mimeType: string; @@ -249,7 +280,6 @@ interface OpenClawAPI { }; }) => void, ): () => void; - onExecCommand?(callback: (data: { shell: string; command: string }) => void): () => void; }; cron: { list(): Promise<{ jobs?: unknown[] }>; @@ -339,74 +369,12 @@ interface OpenClawAPI { logs: { exportGateway(lines: string[]): Promise<{ canceled: boolean; filePath?: string }>; }; - sandbox: { - getStatus(): Promise<{ - available: boolean; - enabled: boolean; - launcherPath: string | null; - containerName: string; - capabilities: string[]; - sandboxDirsRW: string[]; - sandboxDirsRO: string[]; - externalApps: string[]; - }>; - setEnabled(enabled: boolean): Promise<{ ok: boolean }>; - getExternalApps(): Promise; - setExternalApps(apps: string[]): Promise<{ ok: boolean; apps: string[] }>; - applyExternalApps(): Promise<{ ok: boolean; restarted: boolean }>; - getCapabilities(): Promise; - setCapabilities( - caps: string[], - ): Promise<{ ok: boolean; caps: string[]; needsRestart: boolean }>; - provision(): Promise; - getUserDirs(): Promise<{ rw: string[]; ro: string[] }>; - addUserDir(params: { access: "rw" | "ro" }): Promise<{ - ok: boolean; - reason?: string; - parentDir?: string; - parentAccess?: string; - removedChildren?: string[]; - dirs: { rw: string[]; ro: string[] }; - }>; - removeUserDir(params: { - dir: string; - access: "rw" | "ro"; - }): Promise<{ ok: boolean; dirs: { rw: string[]; ro: string[] } }>; - onPermissionRequest( - callback: (data: { - requestId: string; - type: "file" | "shell" | "shell-async"; - targetPath: string; - dirPath: string; - command?: string; - }) => void, - ): () => void; - respondPermission(requestId: string, decision: string): Promise; - onAclTimeout?(callback: (data: { dir: string; access: string }) => void): () => void; - onAclIneffective?( - callback: (data: { - dir: string; - deniedPath: string; - access: string; - command?: string; - }) => void, - ): () => void; - onPermissionCompleted?( - callback: (data: { - requestId: string; - result: "verified" | "verify-timeout" | "failed"; - dir: string; - access: string; - }) => void, - ): () => void; - verifyAcls(): Promise<{ - missing: Array<{ dir: string; access: string; reason: string }>; - stale: Array<{ dir: string; rights: string }>; - ok: Array<{ dir: string; access: string }>; - errors: Array<{ dir: string; error: string }>; - }>; - repairAcl(params: { dir: string; access: "rw" | "ro" }): Promise<{ ok: boolean }>; - revokeStaleAcl(dir: string): Promise<{ ok: boolean }>; + mxc: { + getStatus(): Promise; + chooseFolder(agentId: string, access: "ro" | "rw"): Promise; + removeFolder(agentId: string, access: "ro" | "rw", path: string): Promise; + retry(): Promise; + cleanup(): Promise; }; usage: { getStats(): Promise; diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index 19d68ed..62ed01e 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -4,7 +4,7 @@ - - -