diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f137dc7..ebadcfe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,6 +56,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL @@ -70,18 +72,22 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - # Build step for C# (compiled language) — requires .NET 9 SDK + # Build step for C# (compiled language) — requires .NET 9 and 10 SDKs - name: Setup .NET SDK if: matrix.language == 'csharp' uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: | + 9.0.x + 10.0.x - name: Build C# project if: matrix.language == 'csharp' shell: pwsh 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-build.yml b/.github/workflows/pr-build.yml index fcd434b..44f0be2 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -3,6 +3,7 @@ # Runs the end-to-end packaging script (build.ps1) on every PR. # Verifies that the entire pipeline still produces: # - AppContainerLauncher.exe (.NET 9) +# - bundled Windows Node host (.NET 10) + official MXC runtime # - desktop\release\win-unpacked\ (electron-builder) # - dist\microclaw-portable.zip # - dist\MicroClawInstaller\ + dist\MicroClawInstaller.zip (PyInstaller) @@ -134,8 +135,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - # Toolchain (Node 22, .NET 9 SDK, Python 3.12+) is pre-installed on + # Toolchain (Node 22, .NET 9/10 SDKs, Python 3.12+) is pre-installed on # the self-hosted runner. We only verify versions here; installing # via actions/setup-* is unreliable under the NetworkService account # (HKLM/tool-cache permission issues). @@ -150,9 +153,10 @@ jobs: ($parsed.Major -ge 25 -and $parsed -ge [version]'25.9.0') if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.7.1-1" } Write-Host "node $node" - $dotnet = & dotnet --version - if ([version]($dotnet -split '-')[0] -lt [version]'9.0') { throw "dotnet $dotnet < 9.0" } - Write-Host "dotnet $dotnet" + $sdks = & dotnet --list-sdks + if (-not ($sdks -match '^9\.')) { throw ".NET 9 SDK is required" } + if (-not ($sdks -match '^10\.')) { throw ".NET 10 SDK is required" } + Write-Host $sdks # Resolve a Python 3.12+ interpreter for the NetworkService account. - name: Setup Python 3.12+ diff --git a/.github/workflows/pr-security-check.yml b/.github/workflows/pr-security-check.yml index 1d3b30f..a8aad95 100644 --- a/.github/workflows/pr-security-check.yml +++ b/.github/workflows/pr-security-check.yml @@ -53,7 +53,10 @@ jobs: - 'plugins/**' csharp: - 'appcontainer/**' - - 'MicroClaw.sln' + - 'windows-node-host/**' + - 'windows-node-host.Tests/**' + - 'mxc-host-prep.Tests/**' + - 'third_party/mxc-host-prep-patch/**' python: - 'deployer/**' - 'requirements.txt' @@ -114,7 +117,7 @@ jobs: working-directory: desktop/renderer run: npm run test - # ── C# / .NET 9 ──────────────────────────────────────────────── + # ── C# / .NET 9 and 10 ───────────────────────────────────────── security-and-build-csharp: name: C# - Audit & Build needs: changes @@ -124,14 +127,21 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive - - name: Setup .NET 9 SDK + - name: Setup .NET 9 and 10 SDKs uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: | + 9.0.x + 10.0.x - name: Restore NuGet packages - run: dotnet restore appcontainer/AppContainerLauncher.csproj + 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 @@ -143,12 +153,30 @@ jobs: Write-Host "::error::Vulnerable NuGet packages detected!" exit 1 } + $output = dotnet list windows-node-host.Tests/MicroClaw.WindowsNodeHost.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 + } + $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 - name: Build AppContainerLauncher run: dotnet build appcontainer/AppContainerLauncher.csproj -c Release --no-restore + - 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/.github/workflows/release.yml b/.github/workflows/release.yml index 1df3798..9b26222 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + submodules: recursive # GitHub-hosted windows-latest ships older/other toolchain versions than # build.ps1 expects, so pin them explicitly with the setup-* actions @@ -82,10 +84,12 @@ jobs: with: node-version: "22" - - name: Setup .NET 9 SDK + - name: Setup .NET 9 and 10 SDKs uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: | + 9.0.x + 10.0.x - name: Setup Python 3.12 uses: actions/setup-python@v5 @@ -98,9 +102,10 @@ jobs: $node = (& node --version).TrimStart('v') if ([version]$node -lt [version]'22.0') { throw "Node $node < 22" } Write-Host "node $node" - $dotnet = & dotnet --version - if ([version]($dotnet -split '-')[0] -lt [version]'9.0') { throw "dotnet $dotnet < 9.0" } - Write-Host "dotnet $dotnet" + $sdks = & dotnet --list-sdks + if (-not ($sdks -match '^9\.')) { throw ".NET 9 SDK is required" } + if (-not ($sdks -match '^10\.')) { throw ".NET 10 SDK is required" } + Write-Host $sdks Write-Host "python $(& python --version)" - name: Install Python build dependencies diff --git a/.gitignore b/.gitignore index 5e8e653..79bded1 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,10 @@ desktop/studio-backend/data/ # Root-level dev tool artifacts (esbuild etc.) node_modules/ +# .NET build artifacts +**/bin/ +**/obj/ + # Vitest coverage reports (generated by `vitest --coverage`) coverage/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6bc3739 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/openclaw-windows-node/source"] + path = third_party/openclaw-windows-node/source + url = https://github.com/openclaw/openclaw-windows-node.git diff --git a/NOTICE b/NOTICE index 5a670b3..a64ac4c 100644 --- a/NOTICE +++ b/NOTICE @@ -10,6 +10,30 @@ included below or can be found in the respective project repositories. - Source: https://github.com/openclaw - License: See OpenClaw repository for license details +## OpenClaw Windows Node +- Source: https://github.com/openclaw/openclaw-windows-node +- Revision: fc9add75eda78daf548d80a55ffb64e63b159961 +- Copyright: Copyright (c) 2025 Scott Hanselman +- License: MIT License +- Use: architecture and protocol baseline for MicroClaw's minimal headless Windows node host + +## Microsoft MXC SDK and Windows runtime +- Source: https://github.com/microsoft/mxc +- Package: @microsoft/mxc-sdk@0.7.0 +- 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/README.md b/README.md index bad7340..6b0f04f 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ 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) +- .NET 10 SDK (for the bundled Windows Node host) - npm dependencies installed (`cd desktop && npm install`) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c3ee04..545635e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -202,6 +202,7 @@ npm run dev - Node.js 22+ - Python 3.10+ —— 通过 `pip install -r requirements.txt` 安装构建依赖(已包含 PyInstaller) - .NET 9 SDK(用于构建 AppContainer 启动器) +- .NET 10 SDK(用于构建内置 Windows Node 主机) - npm 依赖已安装(`cd desktop && npm install`) --- diff --git a/build.ps1 b/build.ps1 index ebe8043..20ccb8a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -67,8 +67,51 @@ if (-not $nodeFound) { exit 1 } -# -- Step 1: Build AppContainerLauncher.exe (.NET 9) -- -Write-Host "`n=== Step 1/7: Build AppContainerLauncher ===" -ForegroundColor Cyan +$windowsNodeSharedProject = "$root\third_party\openclaw-windows-node\source\src\OpenClaw.Shared\OpenClaw.Shared.csproj" +if (-not (Test-Path $windowsNodeSharedProject)) { + Write-Host " Initializing pinned OpenClaw Windows Node source..." -ForegroundColor Yellow + git -C $root submodule update --init --recursive -- third_party/openclaw-windows-node/source + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $windowsNodeSharedProject)) { + Write-Host " ERROR: unable to initialize the pinned OpenClaw Windows Node submodule" -ForegroundColor Red + exit 1 + } +} + +# Bootstrap desktop dependencies before building the Windows node, because the +# resource preparation step consumes the pinned @microsoft/mxc-sdk package. +Push-Location "$root\desktop" +try { + $needsNpmInstall = -not (Test-Path "$root\desktop\node_modules") + if (-not $needsNpmInstall) { + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + npm ls --depth=0 *> $null + $npmLsExit = $LASTEXITCODE + $ErrorActionPreference = $prev + if ($npmLsExit -ne 0) { + $needsNpmInstall = $true + Write-Host " desktop dependencies are incomplete — running 'npm install'..." -ForegroundColor Yellow + } + } else { + Write-Host " desktop\node_modules not found — running 'npm install'..." -ForegroundColor Yellow + } + + if ($needsNpmInstall) { + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + npm install 2>&1 | ForEach-Object { Write-Host " $_" } + $ErrorActionPreference = $prev + if ($LASTEXITCODE -ne 0) { + Write-Host " ERROR: npm install failed" -ForegroundColor Red + exit 1 + } + } +} finally { + Pop-Location +} + +# -- Step 1: Build native security helpers -- +Write-Host "`n=== Step 1/7: Build security helpers ===" -ForegroundColor Cyan $acProject = "$root\appcontainer" if (-not (Test-Path "$acProject\AppContainerLauncher.csproj")) { Write-Host " ERROR: appcontainer project not found at $acProject" -ForegroundColor Red @@ -96,6 +139,17 @@ if (-not (Test-Path $acExe)) { } Write-Host " AppContainerLauncher.exe built" -ForegroundColor Green +Push-Location "$root\desktop" +try { + npm run prepare-windows-node-resources + if ($LASTEXITCODE -ne 0) { + Write-Host " ERROR: bundled Windows Node/MXC resource preparation failed" -ForegroundColor Red + exit 1 + } +} finally { + Pop-Location +} + # Copy sandbox-preload.js and its modules alongside launcher (used by electron-builder extraResources) $preloadSrc = "$acProject\sandbox-preload.js" if (Test-Path $preloadSrc) { @@ -128,38 +182,9 @@ if (-not (Test-Path $outDist)) { Write-Host "`n=== Step 3/7: Build & pack desktop ===" -ForegroundColor Cyan Push-Location "$root\desktop" try { - # First-run bootstrap: install npm deps (including renderer via postinstall) - # if node_modules is missing or the installed dependency tree is incomplete. - # Without this, `npm run pack` can fail later inside electron-builder. - $needsNpmInstall = -not (Test-Path "$root\desktop\node_modules") - if (-not $needsNpmInstall) { - $prev = $ErrorActionPreference - $ErrorActionPreference = "Continue" - npm ls --depth=0 *> $null - $npmLsExit = $LASTEXITCODE - $ErrorActionPreference = $prev - if ($npmLsExit -ne 0) { - $needsNpmInstall = $true - Write-Host " desktop dependencies are incomplete — running 'npm install'..." -ForegroundColor Yellow - } - } else { - Write-Host " desktop\node_modules not found — running 'npm install'..." -ForegroundColor Yellow - } - - if ($needsNpmInstall) { - $prev = $ErrorActionPreference - $ErrorActionPreference = "Continue" - npm install 2>&1 | ForEach-Object { Write-Host " $_" } - $ErrorActionPreference = $prev - if ($LASTEXITCODE -ne 0) { - Write-Host " ERROR: npm install failed" -ForegroundColor Red - exit 1 - } - } - $prev = $ErrorActionPreference $ErrorActionPreference = "Continue" - npm run pack 2>&1 | ForEach-Object { Write-Host " $_" } + npm run pack:prepared 2>&1 | ForEach-Object { Write-Host " $_" } $ErrorActionPreference = $prev if ($LASTEXITCODE -ne 0) { Write-Host " ERROR: desktop build failed" -ForegroundColor Red diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 2bfa932..6b26da9 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -5,12 +5,24 @@ copyright: Copyright © 2026 MicroClaw directories: output: release +afterSign: scripts/update-signed-windows-node-manifest.mjs + win: target: - target: nsis arch: - 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" + - "!wxc-host-prep.exe" + - "!wxc-test-proxy.exe" + - "!wxc-windows-sandbox-daemon.exe" + - "!wxc-windows-sandbox-guest.exe" appx: artifactName: MicroClawDesktop-${version}-${arch}.msix @@ -37,12 +49,16 @@ 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/ to: openclaw/ - from: resources/openclaw.asar to: openclaw.asar + - from: resources/windows-node/ + to: windows-node/ - from: ../appcontainer/bin/Release/net9.0-windows/win-x64/AppContainerLauncher.exe to: AppContainerLauncher.exe - from: ../appcontainer/sandbox-preload.js diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 69a2335..382426d 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@microsoft/mxc-sdk": "0.7.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.1", @@ -29,6 +30,13 @@ "vitest": "^4.1.1" } }, + "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-7YGebXkWUFQxHGtw0aYNfZ09cteKOuFcHpicGSM0qCCYxyYC4iFOBT03bOzg7AV1VUd4cFac/RsxExnhyWf7FA==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", diff --git a/desktop/package.json b/desktop/package.json index 4d76726..1bc3dd0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -9,9 +9,12 @@ "dev:renderer": "cd renderer && npm run dev", "dev:electron": "tsc && electron .", "build": "tsc && cd renderer && npm run build", - "pack": "npm run build && electron-builder --win --x64 --dir", - "dist": "npm run build && electron-builder --win", + "pack": "npm run prepare-windows-node-resources && npm run pack:prepared", + "pack:prepared": "npm run build && electron-builder --win --x64 --dir", + "dist": "npm run prepare-windows-node-resources && npm run build && electron-builder --win", "dist:msix": "npm run prepare-resources && npm run build && node scripts/build-msix.mjs", + "prepare-windows-node-resources": "node scripts/prepare-windows-node-resources.mjs --arch=x64", + "prepare-windows-node-resources:arm64": "node scripts/prepare-windows-node-resources.mjs --arch=arm64", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -23,6 +26,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@microsoft/mxc-sdk": "0.7.0", "@microsoft/eslint-formatter-sarif": "^3.1.0", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.1.1", diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 2bebb1f..1f393c4 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -172,6 +172,7 @@ interface OpenClawAPI { gateway: { getPort(): Promise; getStatus(): Promise; + isServiceReady(): Promise; restart(): Promise; warmUpAgent(): Promise<{ outcome: "skipped" | "delta" | "terminal" | "timeout" | "error" | "disconnected"; @@ -181,6 +182,7 @@ interface OpenClawAPI { onLog(callback: (msg: string) => void): () => void; onWsConnected(callback: (mainSessionKey: string | null) => void): () => void; onWsDisconnected(callback: (reason: string) => void): () => void; + onServiceReady(callback: () => void): () => void; }; config: { getStateDir(): Promise; @@ -202,9 +204,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 +230,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 +314,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 +341,181 @@ interface OpenClawAPI { logs: { exportGateway(lines: string[]): Promise<{ canceled: boolean; filePath?: string }>; }; + windowsNodeMxc: { + getStatus(): Promise<{ + desiredEnabled: boolean; + effectiveEnabled: boolean; + lifecycleState: { + phase: + | "idle" + | "locking" + | "validating" + | "persisting" + | "starting-locked" + | "smoking-locked" + | "starting-active" + | "smoking-active" + | "verifying-active" + | "starting-standard" + | "active" + | "locked" + | "failed"; + detail: string | null; + updatedAt: string; + }; + folderPolicyRecovery: { + previous: { rw: string[]; ro: string[] }; + draft: { rw: string[]; ro: string[] }; + updatedAt: string; + lastError: string | null; + } | null; + durableApprovals: { + records: Array<{ + schemaVersion: 1; + id: string; + executablePath: string; + executableSha256: string; + argv: string[]; + commandText: string; + cwdBinding: string; + declaredAccess: Array<{ access: "ro" | "rw"; path: string }>; + agentId: string | null; + sessionKey: string; + policyFingerprint: string; + approvalPlanContract: "microclaw.windows-node-approval-plan.v2"; + createdAt: string; + lastUsedAt: string | null; + }>; + invalidRecords: number; + warning: string | null; + }; + 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; + 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; + effectiveToolsState: "unverified" | "verified" | "drift"; + durableApprovalsPresent: boolean | null; + probe: { + outcome: "supported" | "unsupported" | "error"; + tier: string | null; + needsDaclAugmentation: boolean; + degraded: boolean; + warnings: string[]; + reason: string | null; + }; + smoke: { + gatewayGeneration: string; + nodeId: string; + settingsFingerprint: string; + probeTier: string; + checkedAt: string; + hostname: { outcome: string; reason: string }; + powershell: { outcome: string; reason: string }; + deniedOutsideRoot: { outcome: string; reason: string }; + } | null; + blockers: string[]; + warnings: string[]; + remediation: string[]; + }>; + setEnabled(params: { + enabled: boolean; + nodeId?: string; + }): Promise>>; + validateFolderPolicy(draft: { + rw: string[]; + ro: string[]; + }): Promise<{ rw: string[]; ro: string[] }>; + applyFolderPolicy(draft: { + rw: string[]; + ro: string[]; + }): Promise>>; + listDurableApprovals(): Promise< + Awaited>["durableApprovals"]["records"] + >; + revokeDurableApproval( + id: string, + ): Promise< + Awaited>["durableApprovals"]["records"] + >; + revokeAllDurableApprovals(): Promise<[]>; + getPendingApproval(): Promise<{ + id: string; + executable: string; + arguments: string[]; + commandText?: string; + agent: string | null; + canonicalCwd: string; + approvalLayer: "gateway" | "node"; + allowedDecisions: Array<"deny" | "allow-once" | "allow-always">; + declaredAccess: Array<{ + access: "ro" | "rw"; + path: string; + }>; + } | null>; + respondApproval(params: { + requestId: string; + decision: "deny" | "allow-once" | "allow-always"; + }): Promise; + onApprovalRequest( + callback: ( + request: { + id: string; + executable: string; + arguments: string[]; + commandText?: 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; + onLifecycleState( + callback: ( + state: Awaited< + ReturnType + >["lifecycleState"], + ) => void, + ): () => void; + }; sandbox: { getStatus(): Promise<{ available: boolean; @@ -360,8 +537,20 @@ interface OpenClawAPI { ): Promise<{ ok: boolean; caps: string[]; needsRestart: boolean }>; provision(): Promise; getUserDirs(): Promise<{ rw: string[]; ro: string[] }>; - addUserDir(params: { access: "rw" | "ro" }): Promise<{ + addUserDir(params: { access: "rw" | "ro"; policy?: "windows-node-mxc" }): Promise<{ + ok: boolean; + reason?: string; + parentDir?: string; + parentAccess?: string; + removedChildren?: string[]; + dirs: { rw: string[]; ro: string[] }; + }>; + stageUserDir(params: { + access: "rw" | "ro"; + draft: { rw: string[]; ro: string[] }; + }): Promise<{ ok: boolean; + canceled?: boolean; reason?: string; parentDir?: string; parentAccess?: string; @@ -372,6 +561,14 @@ interface OpenClawAPI { dir: string; access: "rw" | "ro"; }): Promise<{ ok: boolean; dirs: { rw: string[]; ro: string[] } }>; + setUserDirAccess(params: { dir: string; access: "rw" | "ro" }): Promise<{ + ok: boolean; + reason?: string; + parentDir?: string; + parentAccess?: string; + removedChildren?: string[]; + dirs: { rw: string[]; ro: string[] }; + }>; onPermissionRequest( callback: (data: { requestId: string; diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index 19d68ed..65f8540 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -9,7 +9,11 @@ :connected="chatStore.wsConnected" :warming="gateway.warming" :errorMessage="gateway.lastError" + :mxcDesired="windowsNodeMxcStatus?.desiredEnabled ?? false" + :mxcPhase="windowsNodeMxcStatus?.lifecycleState.phase" + :mxcDetail="windowsNodeMxcStatus?.lifecycleState.detail" @retry="handleRetry" + @disable-mxc="disableWindowsNodeMxc" /> @@ -158,6 +162,7 @@ import { useSessionStore } from "@/stores/sessions"; import { useTaskStore } from "@/stores/tasks"; import { t, setLocale, locale } from "@/i18n"; import { runStartupWarmup } from "@/startup-warmup"; +import { watchStartupServiceReadiness } from "@/startup-service-readiness"; const gateway = useGatewayStore(); const chatStore = useChatStore(); @@ -166,6 +171,19 @@ const taskStore = useTaskStore(); const router = useRouter(); const route = useRoute(); const agentStore = useAgentStore(); +type WindowsNodeMxcStatus = Awaited>; +const windowsNodeMxcStatus = ref(null); + +function applyWindowsNodeMxcStatus(status: WindowsNodeMxcStatus) { + const currentUpdatedAt = windowsNodeMxcStatus.value?.lifecycleState.updatedAt; + if ( + currentUpdatedAt && + Date.parse(status.lifecycleState.updatedAt) < Date.parse(currentUpdatedAt) + ) { + return; + } + windowsNodeMxcStatus.value = status; +} // ── Setup wizard gate ── const showSetup = ref(false); @@ -194,28 +212,55 @@ const headerTitle = computed(() => { }); // Delay hiding the loading screen so the progress bar can animate to 100% -function markConnected() { +function markConnected(readinessEpoch = gateway.readinessEpoch) { if (gateway.ready) return; window.openclaw.window.expandToFull(); setTimeout(() => { - gateway.markReady(); + gateway.markReady(readinessEpoch); }, 600); } async function warmThenMarkConnected() { + const readinessEpoch = gateway.readinessEpoch; await runStartupWarmup({ showSetup: setupActive.value, needsSetup: () => window.openclaw.config.needsSetup(), beginWarming: gateway.beginWarming, warmUpAgent: () => window.openclaw.gateway.warmUpAgent(), finishWarming: gateway.finishWarming, - markConnected, + markConnected: () => markConnected(readinessEpoch), onError: (error) => console.warn("[renderer] agent warm-up unavailable:", error), }); } -function handleRetry() { - window.openclaw.gateway.restart(); +async function handleRetry() { + gateway.resetReady(); + chatStore.wsConnected = false; + if (windowsNodeMxcStatus.value?.desiredEnabled) { + try { + windowsNodeMxcStatus.value = await window.openclaw.windowsNodeMxc.setEnabled({ + enabled: true, + }); + } catch (error) { + gateway.addLog( + `[error] ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + await window.openclaw.gateway.restart(); +} + +async function disableWindowsNodeMxc() { + gateway.resetReady(); + chatStore.wsConnected = false; + try { + windowsNodeMxcStatus.value = await window.openclaw.windowsNodeMxc.setEnabled({ + enabled: false, + }); + } catch (error) { + gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`); + } } // ── Integrity check state ── @@ -226,21 +271,72 @@ 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; accessNeeded?: string; + 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(() => permissionQueue.value.length > 0 ? permissionQueue.value[0] : null, ); -function handlePermissionResponse(decision: string) { +type WindowsNodeMxcApproval = NonNullable< + Awaited> +>; + +function enqueueWindowsNodeMxcApproval(request: WindowsNodeMxcApproval | null) { + if (!request) { + permissionQueue.value = permissionQueue.value.filter( + (pending) => pending.source !== "windows-node-mxc", + ); + return; + } + if ( + permissionQueue.value.some( + (pending) => + pending.source === "windows-node-mxc" && pending.requestId === request.id, + ) + ) { + return; + } + const command = request.commandText ?? [request.executable, ...request.arguments].join(" "); + permissionQueue.value.push({ + requestId: request.id, + type: "mxc-approval", + targetPath: request.executable, + dirPath: request.canonicalCwd, + command, + declaredAccess: request.declaredAccess, + source: "windows-node-mxc", + allowedDecisions: request.allowedDecisions, + }); + if (chatStore.streaming) { + chatStore.addPendingToolCall(request.id, "Contained MXC command approval"); + } +} + +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); @@ -323,10 +419,13 @@ let unsubStatus: (() => void) | null = null; let unsubLog: (() => void) | null = null; let unsubWsConnected: (() => void) | null = null; let unsubWsDisconnected: (() => void) | null = null; +let unsubServiceReady: (() => void) | null = null; 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 unsubWindowsNodeMxcLifecycle: (() => void) | null = null; let unsubAclTimeout: (() => void) | null = null; let unsubAclIneffective: (() => void) | null = null; let unsubPermCompleted: (() => void) | null = null; @@ -388,7 +487,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 +497,15 @@ onMounted(async () => { } }); + unsubWindowsNodeMxcApproval = + window.openclaw.windowsNodeMxc.onApprovalRequest(enqueueWindowsNodeMxcApproval); + void window.openclaw.windowsNodeMxc + .getPendingApproval() + .then(enqueueWindowsNodeMxcApproval) + .catch((error) => + gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`), + ); + // Listen for ACL verification timeout after user approved permission unsubAclTimeout = window.openclaw.sandbox.onAclTimeout?.((data: { dir: string; access: string }) => { @@ -491,6 +599,27 @@ onMounted(async () => { unsubWsDisconnected = window.openclaw.gateway.onWsDisconnected(() => { chatStore.wsConnected = false; }); + unsubWindowsNodeMxcLifecycle = window.openclaw.windowsNodeMxc.onLifecycleState((state) => { + if (windowsNodeMxcStatus.value) { + windowsNodeMxcStatus.value = { ...windowsNodeMxcStatus.value, lifecycleState: state }; + } + if (!["active", "idle"].includes(state.phase)) { + gateway.resetReady(); + chatStore.wsConnected = false; + } + void window.openclaw.windowsNodeMxc + .getStatus() + .then(applyWindowsNodeMxcStatus) + .catch((error) => + gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`), + ); + }); + unsubServiceReady = watchStartupServiceReadiness({ + isServiceReady: window.openclaw.gateway.isServiceReady, + onServiceReady: window.openclaw.gateway.onServiceReady, + completeStartup: warmThenMarkConnected, + onError: (error) => console.warn("[renderer] service readiness unavailable:", error), + }); // Chat events (delta, final, aborted, error) unsubChatEvent = window.openclaw.chat.onEvent((payload) => { @@ -522,6 +651,18 @@ onMounted(async () => { agentStore.fetchAgents(); } }); + window.openclaw.windowsNodeMxc + .getStatus() + .then((status) => { + applyWindowsNodeMxcStatus(status); + if (status.desiredEnabled && status.lifecycleState.phase !== "active") { + gateway.resetReady(); + chatStore.wsConnected = false; + } + }) + .catch((error) => + gateway.addLog(`[error] ${error instanceof Error ? error.message : String(error)}`), + ); // (Theme, accent, locale already applied above before setup check) @@ -549,10 +690,13 @@ onUnmounted(() => { unsubLog?.(); unsubWsConnected?.(); unsubWsDisconnected?.(); + unsubServiceReady?.(); unsubChatEvent?.(); unsubToolEvent?.(); unsubIntegrityAlert?.(); unsubPermission?.(); + unsubWindowsNodeMxcApproval?.(); + unsubWindowsNodeMxcLifecycle?.(); unsubAclTimeout?.(); unsubAclIneffective?.(); unsubPermCompleted?.(); diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index d7830d7..5879a8c 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -24,6 +24,7 @@ export function createBrowserOpenClawMock(): OpenClawAPI { gateway: { getStatus: async () => "running", getPort: async () => 18789, + isServiceReady: async () => true, warmUpAgent: async () => ({ outcome: "skipped", transcriptDeleted: true }), restart: noopAsync, onStatus: noopSub, @@ -33,6 +34,7 @@ export function createBrowserOpenClawMock(): OpenClawAPI { }, onWsConnected: noopSub, onWsDisconnected: noopSub, + onServiceReady: noopSub, }, config: { getStateDir: async () => "", @@ -70,6 +72,71 @@ export function createBrowserOpenClawMock(): OpenClawAPI { generateSnapshot: noopAsync, onIntegrityAlert: noopSub, }, + windowsNodeMxc: { + getStatus: async () => ({ + desiredEnabled: false, + effectiveEnabled: false, + lifecycleState: { + phase: "idle" as const, + detail: null, + updatedAt: new Date(0).toISOString(), + }, + folderPolicyRecovery: null, + durableApprovals: { records: [], invalidRecords: 0, warning: null }, + selectedNodeId: "", + settingsPath: "", + companionPath: "", + companionInstalled: false, + settingsLoaded: false, + settingsFingerprint: null, + strictFallbackEffective: false, + allowWindowsUiEffective: false, + folders: [], + nodes: [], + selectedNode: null, + helperRevision: undefined, + mxcRuntimeVersion: undefined, + cwdPolicyContract: undefined, + cwdAttestationReady: false, + activationLeaseContract: undefined, + gatewayGeneration: "", + activationLeaseMode: null, + activationLeaseExpiresAt: null, + gatewayPolicyState: "drift" as const, + gatewayPolicyReady: false, + effectiveToolsReady: false, + effectiveToolsState: "unverified" as const, + 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"); + }, + validateFolderPolicy: async (draft) => draft, + applyFolderPolicy: async () => { + throw new Error("Unavailable in browser development"); + }, + listDurableApprovals: async () => [], + revokeDurableApproval: async () => [], + revokeAllDurableApprovals: async () => [], + getPendingApproval: async () => null, + respondApproval: async () => { + throw new Error("Unavailable in browser development"); + }, + onApprovalRequest: noopSub, + onLifecycleState: noopSub, + }, sandbox: { getStatus: async () => ({ available: true, @@ -90,7 +157,13 @@ export function createBrowserOpenClawMock(): OpenClawAPI { provision: async () => true, getUserDirs: async () => ({ rw: [], ro: [] }), addUserDir: async () => ({ ok: false, reason: "browser-dev", dirs: { rw: [], ro: [] } }), + stageUserDir: async ({ draft }) => ({ + ok: false, + reason: "browser-dev", + dirs: draft, + }), removeUserDir: async () => ({ ok: true, dirs: { rw: [], ro: [] } }), + setUserDirAccess: async () => ({ ok: true, dirs: { rw: [], ro: [] } }), onPermissionRequest: noopSub, respondPermission: noopAsync, onAclTimeout: noopSub, diff --git a/desktop/renderer/src/components/GatewayLoading.test.ts b/desktop/renderer/src/components/GatewayLoading.test.ts new file mode 100644 index 0000000..148ef7a --- /dev/null +++ b/desktop/renderer/src/components/GatewayLoading.test.ts @@ -0,0 +1,56 @@ +import { shallowMount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "@/i18n"; +import GatewayLoading from "./GatewayLoading.vue"; + +describe("GatewayLoading MXC lifecycle", () => { + beforeEach(() => { + setLocale("en-US"); + window.openclaw = { + window: { + isMaximized: vi.fn().mockResolvedValue(false), + onMaximizeChange: vi.fn().mockReturnValue(() => undefined), + }, + } as unknown as typeof window.openclaw; + }); + + it("shows MXC progress instead of claiming a healthy Gateway is ready", () => { + const wrapper = shallowMount(GatewayLoading, { + props: { + status: "running", + connected: false, + warming: false, + mxcDesired: true, + mxcPhase: "smoking-locked", + }, + }); + + expect(wrapper.find(".loading-status").text()).toContain( + "Running internal probes in locked generation", + ); + expect(wrapper.find(".loading-status").text()).not.toContain("Ready"); + expect(wrapper.text()).not.toMatch(/approve|permission|allow once/i); + }); + + it("shows fail-closed recovery without labeling MXC failure as a Gateway outage", async () => { + const wrapper = shallowMount(GatewayLoading, { + props: { + status: "running", + connected: false, + warming: false, + mxcDesired: true, + mxcPhase: "locked", + mxcDetail: "Internal PowerShell probe failed to start", + }, + }); + + expect(wrapper.find(".loading-status").text()).toContain("MXC readiness failed"); + expect(wrapper.find(".loading-error-detail").text()).toBe( + "Internal PowerShell probe failed to start", + ); + expect(wrapper.findAll(".loading-retry")).toHaveLength(2); + + await wrapper.find(".loading-disable-mxc").trigger("click"); + expect(wrapper.emitted("disable-mxc")).toHaveLength(1); + }); +}); diff --git a/desktop/renderer/src/components/GatewayLoading.vue b/desktop/renderer/src/components/GatewayLoading.vue index 6fe828d..c790f44 100644 --- a/desktop/renderer/src/components/GatewayLoading.vue +++ b/desktop/renderer/src/components/GatewayLoading.vue @@ -94,14 +94,21 @@ -
- {{ props.errorMessage }} +
+ {{ displayError }}
+
@@ -148,10 +155,28 @@ const props = defineProps<{ connected: boolean; warming: boolean; errorMessage?: string; + mxcDesired?: boolean; + mxcPhase?: string; + mxcDetail?: string | null; }>(); -defineEmits<{ retry: [] }>(); +defineEmits<{ retry: []; "disable-mxc": [] }>(); -const isFailed = computed(() => props.status === "timeout" || props.status === "failed"); +const isMxcFailure = computed( + () => + props.mxcDesired === true && + ["locked", "failed"].includes(props.mxcPhase ?? "") && + Boolean(props.mxcDetail), +); +const isMxcTransition = computed( + () => + props.mxcDesired === true && + Boolean(props.mxcPhase) && + !["active", "idle", "locked", "failed"].includes(props.mxcPhase ?? ""), +); +const isFailed = computed( + () => isMxcFailure.value || props.status === "timeout" || props.status === "failed", +); +const displayError = computed(() => props.mxcDetail || props.errorMessage); // ── Window controls ── @@ -173,6 +198,20 @@ function closeWindow() { // ── Progress (preserved from original) ── const targetProgress = computed(() => { + if (isMxcTransition.value) { + const progress: Record = { + locking: 20, + validating: 35, + persisting: 50, + "starting-locked": 72, + "smoking-locked": 80, + "starting-active": 86, + "smoking-active": 92, + "verifying-active": 97, + "starting-standard": 88, + }; + return progress[props.mxcPhase ?? ""] ?? 65; + } if (props.warming) return 98; if (props.connected) return 100; if (isFailed.value) return displayProgress.value; // freeze @@ -219,6 +258,10 @@ watch( ); const statusText = computed(() => { + if (isMxcFailure.value) return t("gateway.mxcFailed"); + if (isMxcTransition.value) { + return t(`settings.windowsNodeMxcLifecyclePhase.${props.mxcPhase}`); + } if (props.warming) return t("gateway.warming"); if (props.connected) return t("gateway.ready"); switch (props.status) { diff --git a/desktop/renderer/src/components/PermissionDialog.test.ts b/desktop/renderer/src/components/PermissionDialog.test.ts index 48031a9..c29714e 100644 --- a/desktop/renderer/src/components/PermissionDialog.test.ts +++ b/desktop/renderer/src/components/PermissionDialog.test.ts @@ -20,7 +20,95 @@ describe("PermissionDialog", () => { expect(wrapper.findAll(".perm-actions button").map((button) => button.text())).toEqual([ "Deny", "Allow once", - "Always allow", + "Allow always", + ]); + 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", () => { + 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 an MXC-contained command with separate RO and RW access", () => { + const command = `${"x".repeat(350)} `; + const wrapper = mount(PermissionDialog, { + props: { + request: { + requestId: "mxc-request", + type: "mxc-approval", + command, + dirPath: "isolated-scratch:v1", + declaredAccess: [ + { access: "ro", path: String.raw`C:\Users\test\Documents` }, + { access: "rw", path: String.raw`C:\Users\test\Desktop