From deaf3466fa3107bb1ca4630d6a30e8c69371624a Mon Sep 17 00:00:00 2001
From: Suzhen Zheng
Date: Wed, 14 Oct 2026 17:49:05 +0800
Subject: [PATCH 1/2] Fix OpenClaw 10.2 installation and model setup
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
README.md | 13 +-
README.zh-CN.md | 12 +-
appcontainer/ContainerManager.cs | 33 +--
appcontainer/README.md | 23 ++
appcontainer/path-extraction.js | 13 +-
appcontainer/sandbox-cp-hooks.js | 120 ++++++++-
appcontainer/sandbox-preload.js | 61 ++++-
appcontainer/sandbox-state.js | 4 +-
deployer/windows_setup.py | 110 ++++++--
.../src/components/ModelSetupDialog.test.ts | 57 ++++-
.../src/components/ModelSetupDialog.vue | 35 ++-
desktop/renderer/src/i18n/en-US.ts | 3 +
desktop/renderer/src/i18n/zh-CN.ts | 3 +
.../src/utils/managed-model-providers.ts | 4 +-
.../src/views/SettingsView.models.test.ts | 31 +++
desktop/renderer/src/views/SettingsView.vue | 27 +-
...ocal-model-preparation.integration.test.ts | 32 ++-
desktop/src/main.ts | 7 +-
desktop/src/openclaw-local-model-compat.mjs | 62 +++--
.../src/openclaw-local-model-compat.test.ts | 142 ++++++++++-
desktop/src/sandbox-logic.test.ts | 239 ++++++++++++++++++
tests/test_windows_setup_upgrade.py | 109 +++++++-
22 files changed, 1039 insertions(+), 101 deletions(-)
diff --git a/README.md b/README.md
index 2cb9752..702a996 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,8 @@ You can add or edit models any time under **Settings → Models**. Credentials a
**Settings → Models** lists cloud providers and **Local model** (llama.cpp) in compact cards. Existing saved custom API connections remain manageable, but the overview has no generic add-compatible-API button or unconfigured placeholder card. **Manage** expands the connection editor inline. Saving a connection there does not change the default model or send an AI test prompt; initial onboarding retains its existing flow. The default model is selected separately.
+The Qwen and MiniMax cards show **Get API Key** without opening an editor. The links go directly to the [Bailian Beijing API-key console](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key) and [MiniMax API-key console](https://platform.minimax.cn/user-center/basic-information/interface-key), rather than a generic landing page. The inline editors retain the same links. Opening a website preserves the selected model and form input; browser-launch failures are surfaced.
+
The model button beside the chat composer searches configured models and the two managed local presets, **Qwen3.5 4B Q4_K_M** and **Qwen3.5 9B Q4_K_M**. Selection changes **only the current conversation**, using OpenClaw session overrides. New conversations carry their selected agent's explicit session key, including the main agent, so a model can be selected before the first message in multi-agent configurations. **Use agent default** clears that override without changing agent/global defaults, fallbacks, or other conversations. First use shows the exact model download size, missing embeddings, runtime disk reserve and CPU/GPU mode. Downloads require confirmation; selection changes only after successful preparation. Cancel/failure keeps the previous selection. Already-prepared models switch directly with an inline **Switching...** indicator, without briefly opening a preparation dialog or running setup/inference tests again. Preparation, failures and interrupted-operation recovery still use a persistent dialog.
Both the chat model picker and **Settings → Models → Default model** use the same collapsible provider groups. Only the currently selected model's provider opens by default; headings can be toggled with a click, Enter or Space. Search reveals matching groups, and clearing it restores the previous folds. Folding only changes the list display, never model configuration or downloads. The settings picker still changes the global default, while the chat picker changes only its conversation.
@@ -160,6 +162,8 @@ If a conversation-model change becomes uncertain, the chat picker and model sett
The installer prepares the pinned official llama.cpp provider without downloading model weights or the inference runtime. The installer discloses the provider and its declared capabilities; explicit user-disabled plugin policies are preserved. The local card shows **Connected** when the provider is installed and enabled, independently of whether either model is downloaded. This is provider availability, not an inference health check. Older installations can explicitly review and enable the component in local settings.
+When the pinned OpenClaw version is unchanged, a desktop update refreshes managed files without rerunning database migrations outside its lightweight rollback snapshot. Runtime upgrades still run the full migration flow; both paths require the installed desktop and Gateway to become ready before committing.
+
- Opening model settings reads saved configuration, small router presets and metadata of the two pinned cache files. Plugin status reads never call ClawHub search, full hardware discovery, inference or downloads. Online component lookup occurs only during an explicit component review/install action.
- Model rows show downloaded/not-downloaded or unknown file state. Missing files go through first-use preparation rather than becoming the active model. Cached weights can be reused after cancelled preparation; merely browsing never reads GGUF contents or scans the cache. Existing external connections remain separate.
- **Download and select** immediately replaces confirmation with progress and shows the Gateway's current download/runtime message. Repeated clicks cannot resubmit consent. Preparation errors stay visible; retrying reuses complete, hash-verified cached weights rather than downloading them again.
@@ -168,13 +172,15 @@ The installer prepares the pinned official llama.cpp provider without downloadin
- **Existing server**: expand **Advanced options and information** to connect to a llama-server you already run on `localhost`, `127.0.0.1`, or `::1`. Supply a key only if that service requires it. MicroClaw does not own or stop this server.
- Cloud and local connections are retained when changing models. Selection does not add automatic cloud fallbacks; any previously authored OpenClaw fallback or agent/session override still needs to be reviewed separately. Local inference does not make network-dependent skills offline.
- Cloud setup remains accessible while a local wizard is unfinished. Only saving or switching a model asks to cancel conflicting local setup first; declining leaves it untouched. The only unconfigured catalog entries offered are the two pinned local presets; existing saved connections are retained. Local connection management stays under advanced local settings.
-- The integration checks the Gateway's advertised setup methods and supported 2026.9.x versions. If the provider is missing from its built-in official catalog, MicroClaw verifies the official ClawHub package identity and requests `@openclaw/llama-cpp-provider@2026.9.3`, not a floating latest release. Downloads, runtime prerequisites and GPU support remain subject to the official provider.
+- The integration checks the Gateway's advertised setup methods and supports OpenClaw 2026.10.2 or 2026.9.3+ within 2026.9.x. If the provider is missing from its built-in official catalog, MicroClaw verifies the official ClawHub package identity and requests `@openclaw/llama-cpp-provider@2026.9.3`, not a floating latest release. Downloads, runtime prerequisites and GPU support remain subject to the official provider.
- Actual configuration writes remain protected in Windows Node + MXC mode, while a response is active, or until conflicting setup has safely ended. Preparing a local model does not itself block existing chats; applying a model change still does. After a disconnection, resume the retained operation rather than starting it again. Cancellation can be refused while OpenClaw finishes a protected step.
**Windows compatibility:** MicroClaw includes the [upstream Windows file-open timestamp fix](https://github.com/openclaw/fs-safe/commit/6ae2e2e5086f2c48b813f78cb65fd787c9879a3f) for the exact `fs-safe@0.8.5` dependency bundled with the supported OpenClaw runtime. MicroClaw additionally handles deferred `ctime` changes after copying: only regular, single-link files with unchanged identity, size and modification time can be reconsidered, and their contents must match the SHA-256 captured during copying. Verification uses bounded buffers, rechecks the open handle and path, and checks the exact fingerprint again after mutation authorization before deletion. Genuine changes remain errors; hardlink aliases do not use this exception. A version- and SHA-256-pinned preload validates both move and cleanup modules and applies the changes in memory; installed OpenClaw files are not modified. Other dependency versions are left unchanged, and modified 0.8.5 source is rejected rather than patched speculatively. These checks run during file moves, not when opening model settings.
The pinned llama.cpp adapter also retries transient Windows runtime-directory publication failures (`EPERM`, `EACCES`, `EBUSY`) up to five times over 3.1 seconds. Each attempt checks the original directory identities and requires the destination to remain absent. Permanent errors remain visible; archive hashes, pre/post-install version checks, cancellation and sandbox enforcement are unchanged.
+Exact-model selection also applies when OpenClaw 10.2 loads an immutable provider copy under the state's `tmp/plugin-captures` directory. The adapter requires the original and captured provider identities and source bytes to match the pinned artifact, rejects redirected paths, and never modifies installed plugin files. Model identity and download-consent checks remain mandatory.
+
Official plugin installation and the real Electron setup flow have been verified through model download consent on Windows, and complete preparation has been checked with private copies of cached 4B/embedding weights and a freshly installed runtime. This does not run inference or certify its speed. GGUF weights are never downloaded automatically. The existing **Other models** OpenAI-compatible endpoint configuration also remains available for a separately managed local server.
Local CPU replies can take longer, especially with the initial agent context. While the Gateway remains connected, the chat UI allows a bounded longer wait and checks history for a completed reply. Inactivity alone is shown as a waiting notice, not a model connection failure; a successful reply clears that inferred notice without hiding actual provider errors.
@@ -190,7 +196,7 @@ Setting `MICROCLAW_TEST_INSTALL_LOCAL_PROVIDER=1` additionally installs the offi
Alternatively, set `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` to an existing official llama.cpp plugin directory to exercise direct selection against a disposable Gateway and a local endpoint that rejects all inference requests. The check requires a successful configuration switch with zero inference requests and does not touch the user's selected model.
-To exercise complete preparation, additionally set `MICROCLAW_TEST_LOCAL_CACHE` to a cache containing the pinned 4B and embedding files and run `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts`. It copies those files into a disposable state directory, requires zero model/embedding download bytes in the consent receipt, and installs/verifies the official runtime. The runtime archive may be downloaded; model weights are not. The check does not select the model, run inference or change the source cache/user configuration.
+Run `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts` with `MICROCLAW_TEST_OPENCLAW_ENTRY` and `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` to verify exact-model download consent through the real provider loader, then cancel without downloading or changing defaults. To exercise complete preparation, additionally set `MICROCLAW_TEST_LOCAL_CACHE` to a cache containing the pinned 4B and embedding files. It copies those files into a disposable state directory, requires zero model/embedding download bytes in the consent receipt, and installs/verifies the official runtime. The runtime archive may be downloaded; model weights are not. The check does not select the model, run inference or change the source cache/user configuration.
### 3. Start working immediately
@@ -417,7 +423,8 @@ See [docs/architecture/repository-layout.md](docs/architecture/repository-layout
Most users can do everything from **Settings** inside the desktop app — models, Brave API key, skill allowlist, etc. Runtime settings live in `~/.openclaw/openclaw.json`; you can hand-edit it if needed.
-Fresh installs include the official Parallel web-search plugin and select its keyless
+Fresh installs include the official Parallel web-search plugin pinned to `2026.10.2` for
+compatibility with the packaged OpenClaw target, and select its keyless
`parallel-free` provider, so web search works without additional setup. Usable existing web-search
provider choices are preserved during upgrades; Brave or Tavily without an API key falls back to
Parallel. The provider can be changed later in **Settings**.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 49e7ef4..791d690 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -94,6 +94,8 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要
**设置 → 模型** 以统一卡片展示云端提供商和“本地模型”(llama.cpp);已有自定义 API 连接仍可管理,但总览不再提供“添加兼容 API”按钮或未配置的占位卡片。点击“管理”在原卡片中展开连接编辑。这里保存连接不切换默认模型,也不发送 AI 测试消息;初次配置仍保留原有引导流程。默认模型单独选择。
+千问和 MiniMax 卡片直接显示“获取 API 密钥”,无需先展开表单,分别打开[百炼北京地域 API Key 控制台](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key)和[MiniMax 接口密钥控制台](https://platform.minimax.cn/user-center/basic-information/interface-key),不再只跳到通用落地页。内嵌表单也保留相同入口;打开网站不会重置已选模型或已填写内容,浏览器启动失败会明确提示。
+
聊天输入框旁的模型按钮支持搜索已配置模型,以及 **Qwen3.5 4B Q4_K_M**、**Qwen3.5 9B Q4_K_M** 两个托管本地预设,通过 OpenClaw 会话覆盖设置**只切换当前会话**。新建对话的会话标识会明确携带所选智能体的归属(包括主智能体),多智能体配置下也可在第一条消息发送前选模。“使用智能体默认模型”仅清除此会话覆盖,不修改智能体或全局默认值、回退模型及其他会话。首次选择会展示精确模型大小、缺少的嵌入模型、运行时预留磁盘及 CPU/GPU 模式,明确确认后才下载,准备成功后才切换;失败或取消保留原模型。已准备的模型直接切换,在原选模控件显示“切换中…”,不再闪现准备弹窗,也不运行设置或 AI 测试。需要准备、发生错误或恢复中断操作时,仍保留可阅读和操作的弹窗。
聊天选模菜单和 **设置 → 模型 → 默认模型** 共用可折叠的提供商分组,默认只展开各自当前所选模型所属的提供商。点击分组标题或使用 Enter、空格键即可展开/收起;搜索自动展开匹配分组,清空后恢复之前的折叠状态。折叠仅改变列表显示,不切换模型、不改配置、不触发下载。设置中的选择仍修改全局默认模型,聊天中的选择仍只影响当前会话。
@@ -106,6 +108,8 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要
安装器会准备固定版本的官方 llama.cpp provider,但不下载模型权重和推理运行时。安装前会披露组件及其声明能力,并保留用户明确禁用的插件策略。组件安装并启用后,本地卡片即显示“已连接”,不取决于模型是否下载;它表示提供商可用,不是推理健康检查。旧安装可在本地设置中明确审阅并启用组件。
+OpenClaw 固定版本未变化时,桌面更新只刷新托管文件,不在轻量回滚快照之外重复迁移数据库。运行时升级仍执行完整迁移;两种路径都必须等待安装后的桌面与 Gateway 就绪,才会提交安装。
+
- 打开模型设置只读取已有配置、小型路由配置和两个固定缓存文件的元数据。插件状态刷新不查询 ClawHub,不扫描硬件、不推理、不下载;只有用户明确审阅或安装组件时才查找在线安装来源。
- 模型列表显示“已下载 / 未下载”或未知状态。文件缺失时进入首次准备流程,不会立即切换当前模型;取消准备后保留的完整缓存可复用。浏览不会读取 GGUF 内容或扫描缓存目录,已有外部服务连接仍单独保留。
- 点击 **下载并选用** 后立即显示进度及 Gateway 当前的下载/运行组件安装消息,不再停留在确认页,也不能重复提交确认。准备失败会明确显示原因;重试时复用完整且通过哈希验证的缓存,不重复下载模型。
@@ -114,13 +118,15 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要
- **连接已有服务**:展开“高级选项与说明”,接入自行运行在 `localhost`、`127.0.0.1` 或 `::1` 的 llama-server,仅在服务要求时填写密钥。MicroClaw 不接管或停止该服务。
- 切换模型会保留云端及本地连接,不新增自动云端兜底。已有 OpenClaw fallback、agent 或会话模型覆盖仍需单独检查。本地推理不代表联网技能可以离线运行。
- 本地向导未结束时仍可打开云端设置。只有实际保存或切换模型时,才会询问是否先取消冲突的本地配置;拒绝后保持原状。未配置的目录项仅限这两个固定本地预设,其他已有连接仍保留;本地连接管理放在高级选项中。
-- 集成会检查 Gateway 的公开设置接口及支持的 2026.9.x 版本。内置官方目录没有该插件时,MicroClaw 核对 ClawHub 官方包身份并请求固定版本 `@openclaw/llama-cpp-provider@2026.9.3`,不追随浮动最新版。下载、运行时依赖与 GPU 支持仍以官方插件为准。
+- 集成会检查 Gateway 的公开设置接口,支持 OpenClaw 2026.10.2 或 2026.9.x 中的 2026.9.3 及以上版本。内置官方目录没有该插件时,MicroClaw 核对 ClawHub 官方包身份并请求固定版本 `@openclaw/llama-cpp-provider@2026.9.3`,不追随浮动最新版。下载、运行时依赖与 GPU 支持仍以官方插件为准。
- 实际配置写入仍受 Windows Node + MXC、当前回复和未结束配置操作的保护。准备本地模型本身不再阻止已有聊天,真正应用模型变更时仍会保护当前请求。断线后恢复原操作,不重复启动;OpenClaw 正在完成受保护步骤时可能暂不接受取消。
**Windows 兼容处理:**MicroClaw 为受支持 OpenClaw 中的指定 `fs-safe@0.8.5` 依赖回移了[上游 Windows 文件打开时间戳修复](https://github.com/openclaw/fs-safe/commit/6ae2e2e5086f2c48b813f78cb65fd787c9879a3f),并补充复制后 `ctime` 延迟更新的收尾处理:仅允许身份、大小和修改时间不变的单链接普通文件进入复核,且内容必须与复制时记录的 SHA-256 一致。复核采用固定大小缓冲区,核对打开的文件句柄和路径,并在删除授权后再次比对完整身份。真实修改仍报错,硬链接别名不使用该例外。预加载模块核对版本及移动、清理两个模块的 SHA-256 后,仅在内存中应用修复,不修改已安装的 OpenClaw 文件。其他依赖版本保持原样;修改过的 0.8.5 源码会被明确拒绝。这些检查只在文件移动时执行,不会在打开模型设置时读取模型内容。
固定版本的 llama.cpp 适配还会对 Windows 运行组件目录发布时的短暂占用(`EPERM`、`EACCES`、`EBUSY`)最多重试五次,累计等待 3.1 秒。每次重试均核对原目录身份,并要求目标仍不存在。持续失败仍明确报错;压缩包哈希、安装前后版本校验、取消和沙箱保护均不改变。
+OpenClaw 10.2 从状态目录的 `tmp/plugin-captures` 加载不可变插件副本时,精确模型选择适配仍会生效。原插件和副本的身份、源码必须匹配固定版本的校验值;路径重定向会被拒绝,不改写已安装插件文件,也不跳过模型身份和下载确认。
+
官方插件安装和真正的 Electron 桌面引导已在 Windows 上推进到下载确认步骤;完整准备流程也已使用 4B/embedding 缓存的独立副本及新安装的运行组件验证,不进行推理或承诺推理速度。不会自动下载 GGUF 模型文件。自行管理的本地服务也可继续使用原有“其他模型”中的 OpenAI 兼容接口配置。
CPU 模式处理较长的初始上下文时可能需要更多时间。Gateway 仍连接时,界面会延长有限等待并核对历史回复,不会仅凭一段时间没有输出就判断模型断网。成功回复会清除这种等待提示,但真实的模型接口错误仍会保留。
@@ -136,6 +142,8 @@ npm --prefix desktop test -- src/local-model-gateway.integration.test.ts
也可将 `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` 指向已有的官方 llama.cpp 插件目录,在一次性 Gateway 和拒绝全部推理请求的本地端点上检查直接选用流程。该检查要求配置切换成功且推理请求数为零,不改动用户当前模型。
+设置 `MICROCLAW_TEST_OPENCLAW_ENTRY` 和 `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` 后,运行 `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts` 可通过真实插件加载器检查精确模型下载确认,再取消操作;不设置 `MICROCLAW_TEST_LOCAL_CACHE` 时不会下载模型或运行推理。
+
完整准备检查还需将 `MICROCLAW_TEST_LOCAL_CACHE` 指向包含固定 4B 和 embedding 文件的缓存,再运行 `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts`。它将模型文件复制到一次性状态目录,要求确认回执中的模型和 embedding 下载量均为零,再安装、校验官方运行组件。运行组件压缩包可能需要下载,但不下载模型权重,不选用模型、不推理,也不改变原缓存或用户配置。
### 3. 安装完就能开始做事
@@ -299,7 +307,7 @@ Windows 侧运维脚本现在统一放在 `scripts/windows/` 下。仓库根目
普通用户在**桌面应用 设置**里完成所有配置即可:模型、Brave API Key、技能白名单等。运行时配置存于 `~/.openclaw/openclaw.json`,如有需要可手动编辑。
-全新安装会预装官方 Parallel 联网搜索插件,并默认选择无需 API 密钥的 `parallel-free`
+全新安装会预装固定为 `2026.10.2` 的官方 Parallel 联网搜索插件,以匹配安装包指定的 OpenClaw 版本,并默认选择无需 API 密钥的 `parallel-free`
提供商,因此安装后即可直接使用联网搜索。升级时会保留可用的联网搜索提供商;未配置 API 密钥
的 Brave 或 Tavily 会自动回退到 Parallel。之后也可在**设置**中修改。
diff --git a/appcontainer/ContainerManager.cs b/appcontainer/ContainerManager.cs
index 94f252b..0edc51a 100644
--- a/appcontainer/ContainerManager.cs
+++ b/appcontainer/ContainerManager.cs
@@ -248,6 +248,20 @@ public static void GrantAccess(string containerName, string dirPath, bool readOn
{
var sw = System.Diagnostics.Stopwatch.StartNew();
+ if (!Directory.Exists(dirPath) && !File.Exists(dirPath))
+ throw new DirectoryNotFoundException($"Path not found: {dirPath}");
+
+ // Do this before recursive ACL propagation or an already-accessible fast path.
+ try
+ {
+ GrantAncestorTraverse(containerName, dirPath);
+ Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAncestorTraverse done for: {dirPath}");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] WARNING: GrantAncestorTraverse FAILED for {dirPath}: {ex.Message}");
+ }
+
// Fast path: if ALL APPLICATION PACKAGES already has sufficient access,
// skip per-profile ACL grant (the directory is accessible to any AppContainer).
if (HasAllAppPackagesAccess(dirPath, readOnly))
@@ -316,18 +330,6 @@ public static void GrantAccess(string containerName, string dirPath, bool readOn
throw new DirectoryNotFoundException($"Path not found: {dirPath}");
}
- // Grant traverse on ancestor directories so the AC process can reach
- // this path. Failures are non-fatal (e.g. drive root may need admin).
- try
- {
- GrantAncestorTraverse(containerName, dirPath);
- Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAncestorTraverse done for: {dirPath}");
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] WARNING: GrantAncestorTraverse FAILED for {dirPath}: {ex.Message}");
- }
-
Console.Error.WriteLine($"[grant] [{sw.ElapsedMilliseconds}ms] GrantAccess complete for: {dirPath} (readOnly={readOnly})");
// NOTE: GrantProtectedChildren is NOT called here — it's invoked
@@ -601,12 +603,14 @@ public static void GrantAncestorTraverse(string containerName, string dirPath)
// Check if the SID already has traverse access
var rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier));
+ var traverseRights = FileSystemRights.ReadAttributes |
+ FileSystemRights.ReadExtendedAttributes | FileSystemRights.Traverse;
bool hasAccess = false;
foreach (FileSystemAccessRule rule in rules)
{
if (rule.IdentityReference.Value == sidStr &&
rule.AccessControlType == AccessControlType.Allow &&
- (rule.FileSystemRights & FileSystemRights.Traverse) != 0)
+ (rule.FileSystemRights & traverseRights) == traverseRights)
{
hasAccess = true;
break;
@@ -627,8 +631,7 @@ public static void GrantAncestorTraverse(string containerName, string dirPath)
// directory contents — e.g. D:\ sibling folder names are private).
security.AddAccessRule(new FileSystemAccessRule(
sid,
- FileSystemRights.ReadAttributes |
- FileSystemRights.ReadExtendedAttributes | FileSystemRights.Traverse,
+ traverseRights,
InheritanceFlags.None,
PropagationFlags.None,
AccessControlType.Allow));
diff --git a/appcontainer/README.md b/appcontainer/README.md
index a084fcc..867d50e 100644
--- a/appcontainer/README.md
+++ b/appcontainer/README.md
@@ -52,6 +52,29 @@ Loaded via `NODE_OPTIONS="--require sandbox-preload.js"` into the Gateway proces
### Activation Modes
+OpenClaw's internal SQLite snapshot integration recognizes both the 2026.9.x
+`openclaw-sqlite-readonly--` directory format and the 2026.10.2
+`openclaw-sqlite-readonly-v2-` format under `%LOCALAPPDATA%\openclaw`.
+The internal worker exception still requires the exact Node executable, worker
+path, trusted runtime caller, supported mode, and a canonical database path
+inside the OpenClaw state directory. Redirected or malformed staging roots are
+rejected; this does not allow arbitrary Node commands outside the sandbox.
+Scoped SQLite sessions validate each IPC request before the runtime handles it;
+database requests must stay inside the canonical state directory. The separate
+state-ownership reader is restricted to its exact worker, caller, and database
+argument. Worker-thread startup uses the package root captured from the desktop
+when Node has not initialized the worker's entry arguments yet.
+
+The installer grants read-only access to the actual OpenClaw npm installation
+prefix, which may differ from the Node.js directory. Ancestor grants use numeric
+SIDs and metadata/traverse rights only. Required grants allow up to five minutes
+for Windows ACL propagation. If a shared system directory cannot be changed,
+installation must still prove that sandboxed Node can read the installed runtime
+and create/remove a file in the sandbox workspace; it fails if that check fails.
+Direct Node script arguments retain quoting around paths with spaces. This
+prevents startup probes such as `npm root -g` from requesting access to a
+nonexistent `C:\Program` path while still inspecting the complete file path.
+
- **Main gateway** (`OPENCLAW_SANDBOX_BYPASS=1`): Sandbox activates on the next tick after HTTP server starts listening. Pre-listen startup commands (netstat, etc.) run with bypass; once the server is ready, the sandbox activates before any incoming requests are processed.
- **Forked workers** (bypass absent): Sandbox active immediately.
diff --git a/appcontainer/path-extraction.js b/appcontainer/path-extraction.js
index e8bab64..4cb8eca 100644
--- a/appcontainer/path-extraction.js
+++ b/appcontainer/path-extraction.js
@@ -138,6 +138,17 @@ function extractShellPayload(cmd, args) {
} else {
for (var i = 0; i < argArr.length; i++) {
if (!/^[-\/]/.test(argArr[i])) {
+ if (bn === "node" && /^[a-zA-Z]:[\\/]/.test(String(argArr[i]))) {
+ return argArr
+ .slice(i)
+ .map(function (arg) {
+ var value = String(arg);
+ return /^[a-zA-Z]:[\\/]/.test(value) && /\s/.test(value) && !/["'\r\n]/.test(value)
+ ? '"' + value + '"'
+ : value;
+ })
+ .join(" ");
+ }
payload = argArr.slice(i).join(" ");
break;
}
@@ -360,7 +371,7 @@ var READ_PATTERNS = [
// .NET read methods
/\[?System\.IO\.(?:File|Directory)\]?::(?:Read|Get|Exists|Open)\w*\s*\(\s*["']([a-zA-Z]:\\[^"']+)/gi,
// Generic fallback: quoted path
- /["']([a-zA-Z]:\\[^"'\s]+)["']/g,
+ /["']([a-zA-Z]:\\[^"'\r\n]+)["']/g,
// Generic fallback: unquoted path (comma = separator)
/(?:^|[\s,])([a-zA-Z]:\\[^\s|;>"'(),]+)/g,
];
diff --git a/appcontainer/sandbox-cp-hooks.js b/appcontainer/sandbox-cp-hooks.js
index ad33cd0..0475e19 100644
--- a/appcontainer/sandbox-cp-hooks.js
+++ b/appcontainer/sandbox-cp-hooks.js
@@ -42,6 +42,7 @@ var extractShellPayloadFromString = _pathExtraction.extractShellPayloadFromStrin
var filterSystemPaths = _pathExtraction.filterSystemPaths;
var TRUSTED_NODE_EXEC_PATH = process.execPath;
var TRUSTED_OPENCLAW_ENTRY_PATH = process.argv[1] || "";
+var TRUSTED_OPENCLAW_PACKAGE_ROOT = process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR || "";
var TRUSTED_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR || "";
var TRUSTED_PRELOAD_PATH = pathMod.join(__dirname, "sandbox-preload.js");
var TRUSTED_WORKER_ENV = (function () {
@@ -103,10 +104,18 @@ function normalizeComparablePath(value) {
}
function resolveOpenClawPackageRoot(entryPath) {
+ if (typeof entryPath !== "string" || !entryPath) return "";
var resolved = normalizeComparablePath(entryPath);
if (!resolved) return "";
if (pathMod.basename(resolved) === "openclaw.mjs") return pathMod.dirname(resolved);
var parent = pathMod.dirname(resolved);
+ if (
+ pathMod.basename(resolved) === "sqlite-store.worker.js" &&
+ pathMod.basename(parent) === "infra" &&
+ pathMod.basename(pathMod.dirname(parent)) === "dist"
+ ) {
+ return pathMod.dirname(pathMod.dirname(parent));
+ }
return pathMod.basename(parent) === "dist" ? pathMod.dirname(parent) : "";
}
@@ -132,6 +141,17 @@ function isCanonicalPathWithin(rootPath, candidatePath, runtime) {
return relative !== "" && !relative.startsWith("..") && !pathMod.isAbsolute(relative);
}
+function isOpenClawSqliteStagingParent(value, runtime) {
+ var localAppData =
+ runtime && runtime.localAppData ? runtime.localAppData : TRUSTED_WORKER_ENV.LOCALAPPDATA;
+ if (!localAppData || typeof value !== "string" || !pathMod.isAbsolute(value)) return false;
+ var expectedRoot = pathMod.resolve(localAppData, "openclaw").toLowerCase();
+ return (
+ normalizeComparablePath(value) === expectedRoot &&
+ canonicalExistingPath(value, runtime) === expectedRoot
+ );
+}
+
function isOpenClawSqliteStagingRoot(value, runtime) {
if (typeof value !== "string" || !pathMod.isAbsolute(value)) return false;
var localAppData =
@@ -143,9 +163,9 @@ function isOpenClawSqliteStagingRoot(value, runtime) {
var relative = pathMod.relative(root, candidate);
var pid = runtime && runtime.pid !== undefined ? runtime.pid : process.pid;
var expectedName = new RegExp(
- "^openclaw-sqlite-readonly-" +
+ "^openclaw-sqlite-readonly-(?:" +
pid +
- "-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
+ "|v2)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
"i",
);
return relative === pathMod.basename(value).toLowerCase() && expectedName.test(relative);
@@ -153,31 +173,59 @@ function isOpenClawSqliteStagingRoot(value, runtime) {
function isOpenClawInternalSqliteWorkerCommand(cmd, args, stack, runtime, options) {
if (options && options.shell) return false;
- if (!Array.isArray(args) || (args.length !== 4 && args.length !== 5)) return false;
+ if (!Array.isArray(args)) return false;
+ var ownership = args.length === 2;
+ var session = args.length === 3 && args[2] === "session";
+ var reclaim = args.length === 4 && args[2] === "reclaim";
+ if (!ownership && !session && args.length !== 4 && args.length !== 5) return false;
+ if (
+ session &&
+ (!options ||
+ !Array.isArray(options.stdio) ||
+ options.stdio.join(",") !== "ignore,pipe,pipe,ipc")
+ ) {
+ return false;
+ }
if (args.length === 5 && !isOpenClawSqliteStagingRoot(args[4], runtime)) return false;
var execPath = normalizeComparablePath(
runtime && runtime.execPath ? runtime.execPath : TRUSTED_NODE_EXEC_PATH,
);
if (!execPath || normalizeComparablePath(cmd) !== execPath) return false;
if (
- args[1] !== "--openclaw-sqlite-readonly-child" ||
- (args[2] !== "async" && args[2] !== "sync") ||
- !pathMod.isAbsolute(String(args[3]))
+ !ownership &&
+ (args[1] !== "--openclaw-sqlite-readonly-child" ||
+ (!session &&
+ !reclaim &&
+ ((args[2] !== "async" && args[2] !== "sync") || !pathMod.isAbsolute(String(args[3])))))
) {
return false;
}
var packageRoot = resolveOpenClawPackageRoot(
- runtime && runtime.entryPath ? runtime.entryPath : TRUSTED_OPENCLAW_ENTRY_PATH,
+ runtime && runtime.entryPath !== undefined ? runtime.entryPath : TRUSTED_OPENCLAW_ENTRY_PATH,
);
+ // Worker-thread preloads can run before Node initializes process.argv.
+ if (!packageRoot) {
+ packageRoot = canonicalExistingPath(
+ runtime && runtime.packageRoot ? runtime.packageRoot : TRUSTED_OPENCLAW_PACKAGE_ROOT,
+ runtime,
+ );
+ }
if (!packageRoot) return false;
var expectedWorker = normalizeComparablePath(
- pathMod.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js"),
+ ownership
+ ? pathMod.join(packageRoot, "dist", "state", "openclaw-state-ownership.worker.js")
+ : pathMod.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js"),
);
if (normalizeComparablePath(args[0]) !== expectedWorker) return false;
var stateDir = runtime && runtime.stateDir ? runtime.stateDir : TRUSTED_OPENCLAW_STATE_DIR;
+ var database = args[ownership ? 1 : 3];
+ if (reclaim && !isOpenClawSqliteStagingParent(database, runtime)) return false;
if (
- pathMod.extname(String(args[3])).toLowerCase() !== ".sqlite" ||
- !isCanonicalPathWithin(stateDir, args[3], runtime)
+ !session &&
+ !reclaim &&
+ (!pathMod.isAbsolute(String(database)) ||
+ pathMod.extname(String(database)).toLowerCase() !== ".sqlite" ||
+ !isCanonicalPathWithin(stateDir, database, runtime))
) {
return false;
}
@@ -185,19 +233,60 @@ function isOpenClawInternalSqliteWorkerCommand(cmd, args, stack, runtime, option
.replace(/\//g, pathMod.sep)
.toLowerCase();
var trustedDist = normalizeComparablePath(pathMod.join(packageRoot, "dist")) + pathMod.sep;
+ if (ownership) {
+ return normalizedStack.indexOf(trustedDist + "openclaw-state-ownership-") >= 0;
+ }
return (
normalizedStack.indexOf(trustedDist + "sqlite-readonly-location-") >= 0 ||
normalizedStack.indexOf(trustedDist + "sqlite-readonly-worker-") >= 0
);
}
+function isOpenClawInternalSqliteSessionMessage(message, runtime) {
+ if (message === "close") return true;
+ if (!message || typeof message !== "object" || !Number.isSafeInteger(message.id)) return false;
+ if (!Array.isArray(message.args)) {
+ return Boolean(
+ message.transfer &&
+ typeof message.transfer === "object" &&
+ (message.transfer.type === "next" || message.transfer.type === "end"),
+ );
+ }
+ var args = message.args;
+ if (args.length < 2 || args.length > 4 || !args.every((arg) => typeof arg === "string")) {
+ return false;
+ }
+ var mode = args[0];
+ var target = args[1];
+ if (!pathMod.isAbsolute(target)) return false;
+ if (mode === "staging-create" || mode === "staging-create-legacy") {
+ return args.length === 2 && isOpenClawSqliteStagingParent(target, runtime);
+ }
+ if (mode === "staging-retire" || mode === "staging-reconcile") {
+ return args.length === 2 && isOpenClawSqliteStagingRoot(target, runtime);
+ }
+ if (mode !== "sync" && mode !== "auth-profile-rows" && mode !== "operation") return false;
+ if (mode !== "sync" && args.length !== 2) return false;
+ var stateDir = runtime && runtime.stateDir ? runtime.stateDir : TRUSTED_OPENCLAW_STATE_DIR;
+ if (
+ pathMod.extname(target).toLowerCase() !== ".sqlite" ||
+ !isCanonicalPathWithin(stateDir, target, runtime)
+ ) {
+ return false;
+ }
+ return !args[2] || isOpenClawSqliteStagingRoot(args[2], runtime);
+}
+
function buildInternalSqliteWorkerInvocation(args, options) {
var environment = Object.assign({}, TRUSTED_WORKER_ENV);
- environment[INTERNAL_SQLITE_WORKER_MARKER] = "1";
+ var session = args.length === 3 && args[2] === "session";
+ environment[INTERNAL_SQLITE_WORKER_MARKER] =
+ args.length === 2 ? "ownership" : session ? "session" : "1";
var nextOptions = {
env: environment,
windowsHide: true,
};
+ if (session) nextOptions.stdio = ["ignore", "pipe", "pipe", "ipc"];
if (options && options.encoding !== undefined) nextOptions.encoding = options.encoding;
if (options && options.maxBuffer !== undefined) nextOptions.maxBuffer = options.maxBuffer;
if (options && options.timeout !== undefined) nextOptions.timeout = options.timeout;
@@ -1156,6 +1245,14 @@ function install(cp, getExternalApps) {
var _spawn = cp.spawn;
cp.spawn = function (cmd, args, opts) {
+ if (
+ S.state.sandboxActive &&
+ isOpenClawInternalSqliteWorkerCommand(cmd, args, new Error().stack, undefined, opts)
+ ) {
+ process.stderr.write("[sandbox] spawn: OpenClaw SQLite worker -> BYPASS (internal)\n");
+ var invocation = buildInternalSqliteWorkerInvocation(args, opts);
+ return _spawn.call(this, cmd, invocation.args, invocation.options);
+ }
var bn = pathMod.basename(String(cmd));
var isShell = isShellExe(cmd);
var hasShellOpt = (opts && opts.shell) || (args && !Array.isArray(args) && args.shell);
@@ -1923,6 +2020,7 @@ module.exports = {
isSafeDiagnosticCommand: isSafeDiagnosticCommand,
isSafeDiagnosticCommandStr: isSafeDiagnosticCommandStr,
isOpenClawInternalSqliteWorkerCommand: isOpenClawInternalSqliteWorkerCommand,
+ isOpenClawInternalSqliteSessionMessage: isOpenClawInternalSqliteSessionMessage,
buildInternalSqliteWorkerInvocation: buildInternalSqliteWorkerInvocation,
extractLaunchedApp: extractLaunchedApp,
tryDeclareAccess: tryDeclareAccess,
diff --git a/appcontainer/sandbox-preload.js b/appcontainer/sandbox-preload.js
index 1e197c2..af48798 100644
--- a/appcontainer/sandbox-preload.js
+++ b/appcontainer/sandbox-preload.js
@@ -116,7 +116,10 @@ function getExternalApps() {
var LAUNCHER = process.env.COMSPEC;
var INTERNAL_SQLITE_WORKER_MARKER = "MICROCLAW_OPENCLAW_INTERNAL_SQLITE_WORKER";
var internalWorkerStateDir = process.env.OPENCLAW_STATE_DIR || "";
-var internalWorkerTarget = process.argv[4] || "";
+var internalWorkerTarget =
+ (process.env[INTERNAL_SQLITE_WORKER_MARKER] === "ownership"
+ ? process.argv[2]
+ : process.argv[4]) || "";
function canonicalExistingPath(value) {
try {
var resolver = fs.realpathSync.native || fs.realpathSync;
@@ -131,19 +134,71 @@ var internalWorkerRelative =
canonicalInternalWorkerStateDir && canonicalInternalWorkerTarget
? path.relative(canonicalInternalWorkerStateDir, canonicalInternalWorkerTarget)
: "";
+var internalWorkerCacheRoot = process.env.LOCALAPPDATA
+ ? path.resolve(process.env.LOCALAPPDATA, "openclaw").toLowerCase()
+ : "";
+var isAuthorizedSqliteReclaim =
+ process.argv.length === 5 &&
+ process.argv[3] === "reclaim" &&
+ internalWorkerCacheRoot !== "" &&
+ path.resolve(internalWorkerTarget).toLowerCase() === internalWorkerCacheRoot &&
+ canonicalInternalWorkerTarget === internalWorkerCacheRoot;
var isAuthorizedInternalSqliteWorker =
process.env[INTERNAL_SQLITE_WORKER_MARKER] === "1" &&
path.basename(process.argv[1] || "").toLowerCase() === "sqlite-readonly-location.worker.js" &&
path.basename(path.dirname(process.argv[1] || "")).toLowerCase() === "infra" &&
process.argv[2] === "--openclaw-sqlite-readonly-child" &&
- (process.argv[3] === "async" || process.argv[3] === "sync") &&
+ (isAuthorizedSqliteReclaim ||
+ ((process.argv[3] === "async" || process.argv[3] === "sync") &&
+ path.extname(internalWorkerTarget).toLowerCase() === ".sqlite" &&
+ internalWorkerRelative !== "" &&
+ !internalWorkerRelative.startsWith("..") &&
+ !path.isAbsolute(internalWorkerRelative)));
+var isAuthorizedInternalSqliteSession =
+ process.env[INTERNAL_SQLITE_WORKER_MARKER] === "session" &&
+ path.basename(process.argv[1] || "").toLowerCase() === "sqlite-readonly-location.worker.js" &&
+ path.basename(path.dirname(process.argv[1] || "")).toLowerCase() === "infra" &&
+ process.argv.length === 4 &&
+ process.argv[2] === "--openclaw-sqlite-readonly-child" &&
+ process.argv[3] === "session" &&
+ typeof process.send === "function" &&
+ canonicalInternalWorkerStateDir !== "";
+var isAuthorizedInternalOwnershipWorker =
+ process.env[INTERNAL_SQLITE_WORKER_MARKER] === "ownership" &&
+ path.basename(process.argv[1] || "").toLowerCase() === "openclaw-state-ownership.worker.js" &&
+ path.basename(path.dirname(process.argv[1] || "")).toLowerCase() === "state" &&
+ process.argv.length === 3 &&
path.extname(internalWorkerTarget).toLowerCase() === ".sqlite" &&
internalWorkerRelative !== "" &&
!internalWorkerRelative.startsWith("..") &&
!path.isAbsolute(internalWorkerRelative);
+if (
+ process.env[INTERNAL_SQLITE_WORKER_MARKER] === "ownership" &&
+ !isAuthorizedInternalOwnershipWorker
+) {
+ throw new Error("Invalid OpenClaw state ownership worker launch");
+}
+if (
+ process.env[INTERNAL_SQLITE_WORKER_MARKER] === "session" &&
+ !isAuthorizedInternalSqliteSession
+) {
+ throw new Error("Invalid OpenClaw SQLite worker session launch");
+}
+if (isAuthorizedInternalSqliteSession) {
+ var sqliteSessionHooks = require(path.join(__dirname, "sandbox-cp-hooks.js"));
+ process.prependListener("message", function (message) {
+ if (!sqliteSessionHooks.isOpenClawInternalSqliteSessionMessage(message)) {
+ throw new Error("Unauthorized OpenClaw SQLite worker session request");
+ }
+ });
+}
delete process.env[INTERNAL_SQLITE_WORKER_MARKER];
var isLauncherConfigured =
- LAUNCHER && /AppContainerLauncher/i.test(LAUNCHER) && !isAuthorizedInternalSqliteWorker;
+ LAUNCHER &&
+ /AppContainerLauncher/i.test(LAUNCHER) &&
+ !isAuthorizedInternalSqliteWorker &&
+ !isAuthorizedInternalSqliteSession &&
+ !isAuthorizedInternalOwnershipWorker;
if (isLauncherConfigured) {
var S = require(path.join(__dirname, "sandbox-state.js"));
diff --git a/appcontainer/sandbox-state.js b/appcontainer/sandbox-state.js
index a53d73d..a113150 100644
--- a/appcontainer/sandbox-state.js
+++ b/appcontainer/sandbox-state.js
@@ -195,7 +195,9 @@ function isOpenClawSqliteRuntimePath(resolvedLower) {
var relative = resolvedLower.slice(_openClawLocalRuntimeRoot.length);
if (relative === "locks" || relative.indexOf("locks" + path.sep) === 0) return true;
var firstSegment = relative.split(path.sep)[0];
- return /^openclaw-sqlite-readonly-\d+-[0-9a-f-]{36}$/i.test(firstSegment);
+ return /^openclaw-sqlite-readonly-(?:\d+|v2)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ firstSegment,
+ );
}
function isSafeReadProbe(resolvedLower) {
diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py
index 708cbc9..5914ff1 100644
--- a/deployer/windows_setup.py
+++ b/deployer/windows_setup.py
@@ -70,6 +70,7 @@
NPM_REGISTRY_HUAWEI = "https://repo.huaweicloud.com/repository/npm/"
_PARALLEL_PLUGIN_ID = "parallel"
_PARALLEL_PLUGIN_PACKAGE = "@openclaw/parallel-plugin"
+_PARALLEL_PLUGIN_VERSION = "2026.10.2"
_LOCAL_MODEL_PLUGIN_ID = "llama-cpp"
_LOCAL_MODEL_PLUGIN_PACKAGE = "@openclaw/llama-cpp-provider"
_LOCAL_MODEL_PLUGIN_VERSION = "2026.9.3"
@@ -2295,6 +2296,14 @@ def migrate_openclaw_state(self) -> bool:
if command is None or installation is None:
self.log.error("OpenClaw is unavailable for state migration")
return False
+ if not self._openclaw_upgrade_required:
+ if installation.version != OPENCLAW_TARGET_VERSION:
+ self.log.error("OpenClaw version changed during the desktop-only update")
+ return False
+ # Same-version transactions back up managed files, not the databases
+ # that doctor can migrate. Leave state migration to runtime upgrades.
+ self.log.info("OpenClaw version unchanged; skipping data migrations")
+ return True
state_dir = Path.home() / ".openclaw"
env = self._get_env()
@@ -2489,11 +2498,16 @@ def _run_openclaw_json(self, args: list[str]) -> object:
raise RuntimeError(f"OpenClaw returned invalid JSON for {' '.join(args)}") from error
def _validate_gateway_status(self) -> bool:
- payload = self._run_openclaw_json(["gateway", "status", "--require-rpc", "--json"])
+ payload = self._run_openclaw_json(
+ ["gateway", "status", "--require-rpc", "--timeout", "30000", "--json"]
+ )
return isinstance(payload, dict)
def _validate_gateway_rpc(self, method: str) -> bool:
- return self._run_openclaw_json(["gateway", "call", method, "--json"]) is not None
+ return (
+ self._run_openclaw_json(["gateway", "call", method, "--timeout", "30000", "--json"])
+ is not None
+ )
@staticmethod
def _contains_enabled_weixin_plugin(value: object) -> bool:
@@ -2596,6 +2610,22 @@ def _validate_appcontainer_smoke(self) -> bool:
launcher = self._find_appcontainer_launcher()
if launcher is None:
return False
+ installation = self._detect_openclaw_installation()
+ if installation is None:
+ self.log.error("OpenClaw runtime is missing for the AppContainer execution check")
+ return False
+ try:
+ node = self._resolve_validation_node()
+ except FileNotFoundError as error:
+ self.log.error(str(error))
+ return False
+ script = (
+ 'const fs=require("node:fs"),path=require("node:path"),crypto=require("node:crypto");'
+ "fs.readFileSync(process.argv[1]);"
+ 'const p=path.join(process.argv[2],".ac-probe-"+crypto.randomUUID());'
+ 'fs.writeFileSync(p,"ok",{flag:"wx"});fs.unlinkSync(p);'
+ 'console.log("microclaw-sandbox-ok");'
+ )
result = self._run(
[
str(launcher),
@@ -2603,13 +2633,14 @@ def _validate_appcontainer_smoke(self) -> bool:
"--name",
"MicroClaw",
"--exe",
- os.environ.get("COMSPEC", "cmd.exe"),
+ str(node),
"--no-window",
"--quiet",
"--",
- "/c",
- "echo",
- "microclaw-sandbox-ok",
+ "-e",
+ script,
+ str(installation.package_dir / "package.json"),
+ str(Path.home() / ".openclaw" / "sandbox"),
],
capture_output=True,
text=True,
@@ -4017,7 +4048,12 @@ def install_search_provider_plugin(self) -> bool:
self.log.step("Installing Parallel web search plugin…")
try:
result = self._run(
- openclaw_cmd + ["plugins", "install", _PARALLEL_PLUGIN_PACKAGE],
+ openclaw_cmd
+ + [
+ "plugins",
+ "install",
+ f"{_PARALLEL_PLUGIN_PACKAGE}@{_PARALLEL_PLUGIN_VERSION}",
+ ],
capture_output=True,
text=True,
encoding="utf-8",
@@ -4580,6 +4616,9 @@ def provision_appcontainer(self) -> bool:
(str(home / ".openclaw"), "rw"),
(str(sandbox_dir), "rw"), # Tool execution sandbox workspace
]
+ install_prefix = getattr(self, "install_prefix", None)
+ if install_prefix is not None:
+ dirs_to_grant.append((str(Path(install_prefix) / "node_modules" / "openclaw"), "r"))
# Legacy zip-extract layout — still grant if present so upgrades
# from older builds keep working.
for legacy in LEGACY_NODE_DIRS:
@@ -4603,7 +4642,7 @@ def provision_appcontainer(self) -> bool:
self.log.info(f" AppContainer ACL already configured: {dir_path}")
continue
try:
- subprocess.run(
+ grant_result = subprocess.run(
[
str(launcher),
"grant",
@@ -4616,31 +4655,46 @@ def provision_appcontainer(self) -> bool:
],
capture_output=True,
text=True,
- timeout=30,
+ timeout=300,
creationflags=_CREATE_NO_WINDOW,
)
+ if grant_result.returncode != 0:
+ detail = grant_result.stderr.strip() or grant_result.stdout.strip()
+ self.log.warn(f"ACL grant failed: {dir_path} — {detail}")
+ grant_failures.append(dir_path)
except subprocess.TimeoutExpired:
- self.log.warn(f"ACL grant 超时 (30s): {dir_path} — 将在应用启动时重试")
+ self.log.error(f"ACL grant 超时 (300s): {dir_path}")
grant_failures.append(dir_path)
except Exception as e:
self.log.warn(f"ACL grant 失败: {dir_path} — {e}")
grant_failures.append(dir_path)
- # 3b. Grant minimal traverse (no inherit) on user home and C:\Users
- # so Node.js realpathSync() can lstat ancestor directories.
- # This does NOT give read access to other user profile contents.
- for ancestor in [str(home), str(home.parent)]:
- if Path(ancestor).exists():
- try:
- subprocess.run(
- ["icacls", ancestor, "/grant", f"{sid}:(REA,RA,X)"],
- capture_output=True,
- text=True,
- timeout=30,
- creationflags=_CREATE_NO_WINDOW,
- )
- except Exception as e:
- self.log.warn(f"Traverse ACL 设置失败: {ancestor} — {e}")
+ # Recheck ancestors even when the sandbox itself already has an ACL.
+ # The native helper skips existing metadata-only grants rather than
+ # rewriting the home directory DACL on every installation.
+ try:
+ traverse_result = subprocess.run(
+ [
+ str(launcher),
+ "grant",
+ "--name",
+ container_name,
+ "--dir",
+ str(sandbox_dir),
+ "--access",
+ "rw",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=300,
+ creationflags=_CREATE_NO_WINDOW,
+ )
+ if traverse_result.returncode != 0:
+ self.log.warn(f"Traverse ACL 设置失败: {traverse_result.stderr.strip()}")
+ grant_failures.append(str(sandbox_dir))
+ except (OSError, subprocess.TimeoutExpired) as error:
+ self.log.warn(f"Traverse ACL 设置失败: {error}")
+ grant_failures.append(str(sandbox_dir))
# 3c. Grant traverse on all fixed drive roots + C:\Users (needs admin elevation)
# This lets AppContainer processes reach paths on any drive (e.g. D:\Documents).
@@ -4688,8 +4742,12 @@ def provision_appcontainer(self) -> bool:
if grant_failures:
self.log.warn(
- f"部分 ACL 设置未完成 ({len(grant_failures)} 个目录),应用启动时将自动重试"
+ f"部分 ACL 设置未完成 ({len(grant_failures)} 个目录),验证实际沙箱读写权限"
)
+ if not self._validate_appcontainer_smoke():
+ self.log.error("AppContainer runtime read/write verification failed")
+ return False
+ self.log.info("Existing permissions passed the sandboxed Node runtime read/write check")
self.log.success(f"AppContainer 沙箱 '{container_name}' 配置完成")
return True
diff --git a/desktop/renderer/src/components/ModelSetupDialog.test.ts b/desktop/renderer/src/components/ModelSetupDialog.test.ts
index 291e177..93ca887 100644
--- a/desktop/renderer/src/components/ModelSetupDialog.test.ts
+++ b/desktop/renderer/src/components/ModelSetupDialog.test.ts
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ElMessageBox } from "element-plus";
import { createBrowserOpenClawMock } from "@/browser-openclaw";
import type { LocalModelOperation } from "../../../src/local-model-types";
+import { MANAGED_MODEL_PROVIDERS } from "@/utils/managed-model-providers";
import ModelSetupDialog from "./ModelSetupDialog.vue";
const InputStub = defineComponent({
@@ -266,10 +267,64 @@ describe("ModelSetupDialog", () => {
await wrapper.find(".primary-action").trigger("click");
expect(openExternal).toHaveBeenCalledOnce();
- expect(openExternal.mock.calls[0][0]).toContain("minimaxi.com");
+ expect(openExternal.mock.calls[0][0]).toBe(
+ MANAGED_MODEL_PROVIDERS.find((provider) => provider.id === "minimax")!.signupUrl,
+ );
expect(wrapper.find(".model-key-form").exists()).toBe(true);
});
+ it.each(MANAGED_MODEL_PROVIDERS)(
+ "opens $id signup from its inline editor without losing input, then saves without inference",
+ async (provider) => {
+ const before = {
+ gateway: { port: 18789 },
+ agents: { defaults: { model: { primary: "cloud/keep" } } },
+ models: { providers: { cloud: { models: [{ id: "keep" }] } } },
+ };
+ read.mockResolvedValue(before);
+ const wrapper = mountDialog({
+ inline: true,
+ manageOnly: true,
+ initialFamily: provider.id,
+ });
+ await flushPromises();
+ const model = provider.models.find((item) => item.id !== provider.defaultModel)!;
+ await wrapper.get("select").setValue(model.id);
+ await wrapper.get('input[placeholder="sk-..."]').setValue("sk-test-fixture");
+ await wrapper.get(".provider-api-key-link").trigger("click");
+ await flushPromises();
+ expect(openExternal).toHaveBeenCalledWith(provider.signupUrl);
+ expect((wrapper.get("select").element as HTMLSelectElement).value).toBe(model.id);
+ expect((wrapper.get('input[placeholder="sk-..."]').element as HTMLInputElement).value).toBe(
+ "sk-test-fixture",
+ );
+ expect(write).not.toHaveBeenCalled();
+ await wrapper.get(".primary-action").trigger("mousedown");
+ await flushPromises();
+ await vi.runAllTimersAsync();
+ expect(write).toHaveBeenCalledOnce();
+ const saved = write.mock.calls[0][0];
+ expect(saved.models.providers[provider.id].baseUrl).toBe(provider.baseUrl);
+ expect(saved.models.providers[provider.id].models[0].id).toBe(model.id);
+ expect(saved.models.providers.cloud).toEqual(before.models.providers.cloud);
+ expect(saved.agents.defaults.model).toEqual(before.agents.defaults.model);
+ expect(testConnection).not.toHaveBeenCalled();
+ expect(restart).toHaveBeenCalledOnce();
+ wrapper.unmount();
+ },
+ );
+
+ it("surfaces failures opening a provider website", async () => {
+ openExternal.mockRejectedValueOnce(new Error("Browser unavailable"));
+ const wrapper = mountDialog({ inline: true, manageOnly: true, initialFamily: "minimax" });
+ await flushPromises();
+ await wrapper.get(".provider-api-key-link").trigger("click");
+ await flushPromises();
+ expect(wrapper.text()).toContain("Could not open the provider website: Browser unavailable");
+ expect(write).not.toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
it("returns to a clean provider selection when reopened", async () => {
const wrapper = mountDialog();
diff --git a/desktop/renderer/src/components/ModelSetupDialog.vue b/desktop/renderer/src/components/ModelSetupDialog.vue
index 3f70175..3c6a5ce 100644
--- a/desktop/renderer/src/components/ModelSetupDialog.vue
+++ b/desktop/renderer/src/components/ModelSetupDialog.vue
@@ -281,6 +281,15 @@
{{ t("modelSetup.credentialHint") }}
+
{{ errorMsg }}
@@ -624,14 +633,32 @@ function handlePrimaryAction() {
}
function handleGetApiKey() {
+ void openProviderSignup();
+ goToKeyForm();
+}
+
+async function openProviderSignup() {
const signupUrl = selectedManagedProvider.value?.signupUrl;
if (!signupUrl) return;
+ const family = selectedFamilyId.value;
+ const generation = submissionGeneration;
try {
- window.openclaw?.shell?.openExternal?.(signupUrl);
- } catch {
- window.open(signupUrl, "_blank", "noopener,noreferrer");
+ if (window.openclaw?.shell?.openExternal) {
+ await window.openclaw.shell.openExternal(signupUrl);
+ } else {
+ window.open(signupUrl, "_blank", "noopener,noreferrer");
+ }
+ } catch (error) {
+ if (
+ props.modelValue &&
+ selectedFamilyId.value === family &&
+ submissionGeneration === generation
+ ) {
+ errorMsg.value = t("modelSetup.openProviderFailed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
}
- goToKeyForm();
}
function goToSelectStep() {
diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts
index b8bc5d1..c526883 100644
--- a/desktop/renderer/src/i18n/en-US.ts
+++ b/desktop/renderer/src/i18n/en-US.ts
@@ -466,6 +466,7 @@ export default {
"settings.searchNotConfigured": "Not configured",
"settings.searchDefault": "Default",
"settings.saveFailed": "Save failed: {error}",
+ "settings.openLinkFailed": "Could not open the link: {error}",
"settings.clearHistoryConfirm":
"Are you sure you want to clear all chat history? This action cannot be undone.",
"settings.confirm": "Confirm",
@@ -577,6 +578,7 @@ export default {
"The provider is connected. Select 4B or 9B from the model list; weights download only after confirmation.",
"modelManager.copilotHint": "Sign in with your GitHub Copilot account.",
"modelManager.apiHint": "Manage the endpoint, credentials and saved models.",
+ "modelManager.apiKeyHint": "Get an API key from the provider, then configure the connection.",
"modelManager.saveOnlyHint":
"Save connection settings without changing the default model or sending a test prompt.",
"modelManager.sessionModel": "Model for this conversation",
@@ -800,6 +802,7 @@ export default {
"modelSetup.otherModel": "Other model",
"modelSetup.configureCustom": "Configure custom provider",
"modelSetup.getApiKey": "Get API Key",
+ "modelSetup.openProviderFailed": "Could not open the provider website: {error}",
"modelSetup.haveApiKey": "I already have an API Key",
"modelSetup.providerId": "Provider ID",
"modelSetup.providerIdPlaceholder": "e.g. openai, deepseek, or ollama",
diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts
index 2039ece..0f48eda 100644
--- a/desktop/renderer/src/i18n/zh-CN.ts
+++ b/desktop/renderer/src/i18n/zh-CN.ts
@@ -438,6 +438,7 @@ export default {
"settings.searchNotConfigured": "未配置",
"settings.searchDefault": "默认",
"settings.saveFailed": "保存失败:{error}",
+ "settings.openLinkFailed": "无法打开链接:{error}",
"settings.clearHistoryConfirm": "确定要清除所有聊天记录吗?此操作不可撤销。",
"settings.confirm": "确认",
"settings.chatHistoryCleared": "聊天记录已清除",
@@ -541,6 +542,7 @@ export default {
"fixedLocal.connectedHint": "提供商已连接。在模型列表选择 4B 或 9B,确认后才下载模型文件。",
"modelManager.copilotHint": "使用 GitHub Copilot 账户登录。",
"modelManager.apiHint": "管理服务地址、凭据和已保存的模型。",
+ "modelManager.apiKeyHint": "先点击“获取 API 密钥”前往服务商申请,再配置连接。",
"modelManager.saveOnlyHint": "仅保存连接设置,不切换默认模型,也不发送测试消息。",
"modelManager.sessionModel": "当前会话模型",
"modelManager.switching": "切换中…",
@@ -744,6 +746,7 @@ export default {
"modelSetup.otherModel": "其他模型",
"modelSetup.configureCustom": "配置自定义供应商",
"modelSetup.getApiKey": "获取 API 密钥",
+ "modelSetup.openProviderFailed": "无法打开模型服务商网站:{error}",
"modelSetup.haveApiKey": "已获取 API 密钥",
"modelSetup.providerId": "供应商 ID",
"modelSetup.providerIdPlaceholder": "例如 openai、deepseek 或 ollama",
diff --git a/desktop/renderer/src/utils/managed-model-providers.ts b/desktop/renderer/src/utils/managed-model-providers.ts
index ace66de..23aec14 100644
--- a/desktop/renderer/src/utils/managed-model-providers.ts
+++ b/desktop/renderer/src/utils/managed-model-providers.ts
@@ -39,7 +39,7 @@ export const MANAGED_MODEL_PROVIDERS: ManagedModelProvider[] = [
],
defaultModel: "qwen3.7-plus",
apiKeyPlaceholder: "sk-...",
- signupUrl: "https://click.qianwenai.com/m/20000000741/",
+ signupUrl: "https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key",
},
{
id: "minimax",
@@ -53,7 +53,7 @@ export const MANAGED_MODEL_PROVIDERS: ManagedModelProvider[] = [
],
defaultModel: "MiniMax-M3",
apiKeyPlaceholder: "sk-...",
- signupUrl: "https://platform.minimaxi.com/byok-trial?source=microclaw",
+ signupUrl: "https://platform.minimax.cn/user-center/basic-information/interface-key",
},
];
diff --git a/desktop/renderer/src/views/SettingsView.models.test.ts b/desktop/renderer/src/views/SettingsView.models.test.ts
index a0523c0..ff2ec2e 100644
--- a/desktop/renderer/src/views/SettingsView.models.test.ts
+++ b/desktop/renderer/src/views/SettingsView.models.test.ts
@@ -14,6 +14,7 @@ import LocalModelDownloadDialog from "@/components/LocalModelDownloadDialog.vue"
import DefaultModelPicker from "@/components/DefaultModelPicker.vue";
import SessionModelRecoveryNotice from "@/components/SessionModelRecoveryNotice.vue";
import { selectPrimaryModelConfig } from "@/utils/model-provider";
+import { MANAGED_MODEL_PROVIDERS } from "@/utils/managed-model-providers";
vi.mock("vue-router", () => ({ useRoute: () => ({ params: { section: "models" } }) }));
@@ -587,6 +588,36 @@ describe("SettingsView model integration", () => {
expect(write).not.toHaveBeenCalled();
});
+ it("shows direct API-key links on cloud cards before opening any editor", async () => {
+ const openExternal = vi.spyOn(window.openclaw.shell, "openExternal").mockResolvedValue();
+ const { wrapper } = await open();
+ expect(wrapper.findComponent({ name: "ModelSetupDialog" }).exists()).toBe(false);
+ for (const provider of MANAGED_MODEL_PROVIDERS) {
+ const button = wrapper.get(`[data-testid="provider-signup-${provider.id}"]`);
+ expect(button.text()).toBe("Get API Key");
+ await button.trigger("click");
+ await flushPromises();
+ expect(openExternal).toHaveBeenLastCalledWith(provider.signupUrl);
+ }
+ expect(wrapper.find('[data-testid="provider-signup-llama-cpp"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="provider-signup-cloud"]').exists()).toBe(false);
+ expect(wrapper.findComponent({ name: "ModelSetupDialog" }).exists()).toBe(false);
+ expect(write).not.toHaveBeenCalled();
+ expect(window.openclaw.gateway.restart).not.toHaveBeenCalled();
+ });
+
+ it("reports a card link-opening failure without changing configuration", async () => {
+ vi.spyOn(window.openclaw.shell, "openExternal").mockRejectedValue(
+ new Error("Browser unavailable"),
+ );
+ const notify = vi.spyOn(ElMessage, "error").mockReturnValue({ close: vi.fn() });
+ const { wrapper } = await open();
+ await wrapper.get('[data-testid="provider-signup-qwen"]').trigger("click");
+ await flushPromises();
+ expect(notify).toHaveBeenCalledWith("Could not open the link: Browser unavailable");
+ expect(write).not.toHaveBeenCalled();
+ });
+
it("leaves local work untouched when the user declines cloud switching", async () => {
vi.mocked(window.openclaw.model.local.getOperation).mockResolvedValue({
ok: true,
diff --git a/desktop/renderer/src/views/SettingsView.vue b/desktop/renderer/src/views/SettingsView.vue
index e84244b..7ef746e 100644
--- a/desktop/renderer/src/views/SettingsView.vue
+++ b/desktop/renderer/src/views/SettingsView.vue
@@ -271,7 +271,9 @@
? t("modelManager.localHint")
: provider.key === "github-copilot"
? t("modelManager.copilotHint")
- : t("modelManager.apiHint")
+ : provider.signupUrl
+ ? t("modelManager.apiKeyHint")
+ : t("modelManager.apiHint")
}}
@@ -288,6 +290,16 @@
)
}}
+
+ {{ t("modelSetup.getApiKey") }}
+ localFileStatus.value.get(getModelRef(model)) === "missing"),
};
@@ -2688,8 +2701,16 @@ async function saveSearchSettings(): Promise {
}
}
-function openExternal(url: string) {
- window.openclaw.shell.openExternal(url);
+async function openExternal(url: string) {
+ try {
+ await window.openclaw.shell.openExternal(url);
+ } catch (error) {
+ ElMessage.error(
+ t("settings.openLinkFailed", {
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }
}
const privacyStatementUrl = "https://www.microsoft.com/privacy/privacystatement";
diff --git a/desktop/src/local-model-preparation.integration.test.ts b/desktop/src/local-model-preparation.integration.test.ts
index e2713e9..3d30379 100644
--- a/desktop/src/local-model-preparation.integration.test.ts
+++ b/desktop/src/local-model-preparation.integration.test.ts
@@ -24,8 +24,10 @@ import { parseManagedModelPaths } from "./local-model-files";
const entry = process.env.MICROCLAW_TEST_OPENCLAW_ENTRY;
const cache = process.env.MICROCLAW_TEST_LOCAL_CACHE;
const plugin = process.env.MICROCLAW_TEST_LOCAL_PLUGIN_DIR;
-it.skipIf(!entry || !cache || !plugin)(
- "completes confirmed exact-model preparation using copied cached weights without inference or changing defaults",
+it.skipIf(!entry || !plugin)(
+ cache
+ ? "completes confirmed exact-model preparation using copied cached weights without inference or changing defaults"
+ : "reaches exact-model download consent through the real provider loader without downloading or changing defaults",
async () => {
const root = mkdtempSync(path.join(tmpdir(), "microclaw-complete-local-"));
const configPath = path.join(root, "openclaw.json");
@@ -38,7 +40,9 @@ it.skipIf(!entry || !cache || !plugin)(
try {
mkdirSync(isolatedCache, { recursive: true });
const weightFiles = [model.cacheFile, "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf"];
- const originals = new Map(weightFiles.map((file) => [file, path.join(cache!, file)]));
+ const originals = new Map(
+ cache ? weightFiles.map((file) => [file, path.join(cache, file)]) : [],
+ );
for (const [file, original] of originals) {
copyFileSync(original, path.join(isolatedCache, file));
}
@@ -134,17 +138,31 @@ it.skipIf(!entry || !cache || !plugin)(
});
await vi.waitFor(
() => {
- expect(service.getOperation()?.phase, service.getOperation()?.error).toBe("step");
- expect(service.getOperation()?.step?.type).toBe("confirm");
+ expect(["step", "error"]).toContain(service.getOperation()?.phase);
},
{ timeout: 90_000, interval: 200 },
);
const consent = service.getOperation()!;
+ expect(consent.phase, consent.error).toBe("step");
+ expect(consent.step?.type, consent.error).toBe("confirm");
expect(consent.download).toMatchObject({
modelRef: model.modelRef,
- modelBytes: 0,
- embeddingBytes: 0,
});
+ if (!cache) {
+ expect(
+ request.mock.calls.some(
+ ([method, params]) =>
+ method === "wizard.next" &&
+ (params as { answer?: { value?: unknown } }).answer?.value === true,
+ ),
+ ).toBe(false);
+ await service.cancel(started.id);
+ expect(JSON.parse(readFileSync(configPath, "utf8")).agents.defaults.model).toEqual(
+ before.agents.defaults.model,
+ );
+ return;
+ }
+ expect(consent.download).toMatchObject({ modelBytes: 0, embeddingBytes: 0 });
await service.answer({ operationId: started.id, stepId: consent.step!.id, value: true });
await vi.waitFor(
() => {
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index a07f14b..8255c43 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -6619,10 +6619,11 @@ function registerIpcHandlers(): void {
});
// --- Shell ---
- ipcMain.handle("shell:open-external", (_event, url: string) => {
- if (typeof url === "string" && (url.startsWith("https://") || url.startsWith("http://"))) {
- shell.openExternal(url);
+ ipcMain.handle("shell:open-external", (_event, url: unknown) => {
+ if (typeof url !== "string" || (!url.startsWith("https://") && !url.startsWith("http://"))) {
+ throw new Error("Only HTTP(S) links can be opened");
}
+ return shell.openExternal(url);
});
ipcMain.handle("attachment:open", async (_event, request: unknown) => {
diff --git a/desktop/src/openclaw-local-model-compat.mjs b/desktop/src/openclaw-local-model-compat.mjs
index e18f3e4..ed63865 100644
--- a/desktop/src/openclaw-local-model-compat.mjs
+++ b/desktop/src/openclaw-local-model-compat.mjs
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { readFileSync, realpathSync } from "node:fs";
import { registerHooks } from "node:module";
-import { dirname, isAbsolute, join, resolve } from "node:path";
+import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
@@ -153,7 +153,10 @@ export function patchLocalModelPlugin(source) {
const end = source.indexOf("async function runLlamaCppSetup(ctx) {", start);
if (start < 0 || end < 0) throw new Error("The pinned model selection function is unavailable");
let patched = source.slice(0, start) + EXACT_SETUP_PLAN + source.slice(end);
- patched = `import { publishLlamaRuntime } from ${JSON.stringify(import.meta.url)};\n${patched}`;
+ patched = `import { setTimeout as microclawRuntimeDelay } from "node:timers/promises";
+const publishLlamaRuntime = (({ isAbsolute, resolve, dirname }, delay) =>
+ (${publishLlamaRuntime.toString()}))(path, microclawRuntimeDelay);
+${patched}`;
patched = replaceExactly(
patched,
"\t\tawait fs.rename(extractedRoot, installDir);",
@@ -191,30 +194,57 @@ export function patchLocalModelPlugin(source) {
return patched;
}
-export function initializeLocalModelCompatibility(pluginDir) {
+export function createLocalModelCompatibilityLoadHook(
+ pluginDir,
+ stateDir = process.env.OPENCLAW_STATE_DIR || undefined,
+) {
if (!pluginDir || !isAbsolute(pluginDir))
throw new Error("An absolute local provider directory is required");
+ const providerStateDir = stateDir ?? dirname(dirname(pluginDir));
+ if (!providerStateDir || !isAbsolute(providerStateDir))
+ throw new Error("An absolute OpenClaw state directory is required");
const target = join(pluginDir, "dist", "index.js");
- registerHooks({
- load(url, context, nextLoad) {
- const result = nextLoad(url, context);
- if (!url.startsWith("file:") || fileURLToPath(url) !== target) return result;
- const metadata = JSON.parse(readFileSync(join(pluginDir, "package.json"), "utf8"));
+ const captureRoot = join(providerStateDir, "tmp", "plugin-captures");
+ return function load(url, context, nextLoad) {
+ const result = nextLoad(url, context);
+ if (!url.startsWith("file:")) return result;
+ const filename = fileURLToPath(url);
+ const candidateRoot = dirname(dirname(filename));
+ const capturedPath = relative(captureRoot, filename);
+ const captured =
+ capturedPath !== "" &&
+ !capturedPath.startsWith("..") &&
+ !isAbsolute(capturedPath) &&
+ basename(filename) === "index.js" &&
+ basename(dirname(filename)) === "dist" &&
+ basename(candidateRoot) === basename(pluginDir) &&
+ basename(dirname(candidateRoot)) === "node_modules";
+ if (filename !== target && !captured) return result;
+
+ for (const root of new Set([pluginDir, candidateRoot])) {
+ const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
if (
metadata.name !== "@openclaw/llama-cpp-provider" ||
metadata.version !== PINNED_LOCAL_PLUGIN_VERSION
) {
throw new Error("Exact model setup requires the pinned official llama.cpp provider");
}
- if (realpathSync(target) !== target)
+ const entry = join(root, "dist", "index.js");
+ if (realpathSync(entry) !== entry)
throw new Error("The local provider entry must not be redirected");
- const source =
- typeof result.source === "string"
- ? result.source
- : Buffer.from(result.source ?? "").toString();
- return { ...result, source: patchLocalModelPlugin(source) };
- },
- });
+ }
+ const source =
+ typeof result.source === "string"
+ ? result.source
+ : Buffer.from(result.source ?? "").toString();
+ if (captured && source !== readFileSync(target, "utf8"))
+ throw new Error("The captured local provider differs from its installed source");
+ return { ...result, source: patchLocalModelPlugin(source) };
+ };
+}
+
+export function initializeLocalModelCompatibility(pluginDir) {
+ return registerHooks({ load: createLocalModelCompatibilityLoadHook(pluginDir) });
}
if (process.env.MICROCLAW_EXACT_LOCAL_MODELS === "1") {
diff --git a/desktop/src/openclaw-local-model-compat.test.ts b/desktop/src/openclaw-local-model-compat.test.ts
index 0979937..28241bf 100644
--- a/desktop/src/openclaw-local-model-compat.test.ts
+++ b/desktop/src/openclaw-local-model-compat.test.ts
@@ -1,5 +1,7 @@
-import { readFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
import { join } from "node:path";
+import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
import {
LOCAL_MODEL_PRESETS,
@@ -8,7 +10,133 @@ import {
} from "./local-model-catalog";
// @ts-expect-error External ESM preload runs in the supported Gateway runtime.
import * as compatibility from "./openclaw-local-model-compat.mjs";
-const { EXACT_SETUP_PLAN, EXACT_MODEL_SELECTION_PROMPT, patchLocalModelPlugin } = compatibility;
+const {
+ EXACT_SETUP_PLAN,
+ EXACT_MODEL_SELECTION_PROMPT,
+ patchLocalModelPlugin,
+ createLocalModelCompatibilityLoadHook,
+} = compatibility;
+
+function withProviderFiles(
+ run: (paths: { root: string; plugin: string; captured: string; entry: string }) => void,
+ source = "unverified provider source",
+) {
+ const root = mkdtempSync(join(tmpdir(), "microclaw-provider-load-"));
+ const plugin = join(root, "extensions", "llama-cpp");
+ const captured = join(
+ root,
+ "tmp",
+ "plugin-captures",
+ "generation",
+ "captures",
+ "openclaw-plugin-build-test",
+ "package-0",
+ "node_modules",
+ "llama-cpp",
+ );
+ try {
+ for (const directory of [plugin, captured]) {
+ mkdirSync(join(directory, "dist"), { recursive: true });
+ writeFileSync(join(directory, "dist", "index.js"), source);
+ writeFileSync(
+ join(directory, "package.json"),
+ JSON.stringify({ name: "@openclaw/llama-cpp-provider", version: "2026.9.3" }),
+ );
+ }
+ run({ root, plugin, captured, entry: join(captured, "dist", "index.js") });
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+}
+
+describe("local provider capture loading", () => {
+ it("leaves unrelated modules and lookalike capture roots unchanged", () => {
+ withProviderFiles(({ root, plugin }) => {
+ const load = createLocalModelCompatibilityLoadHook(plugin, root);
+ const result = { format: "module", source: "unrelated" };
+ const next = vi.fn().mockReturnValue(result);
+ for (const url of [
+ "node:fs",
+ pathToFileURL(join(root, "other", "node_modules", "llama-cpp", "dist", "index.js")).href,
+ pathToFileURL(
+ join(
+ root,
+ "tmp",
+ "plugin-captures-other",
+ "node_modules",
+ "llama-cpp",
+ "dist",
+ "index.js",
+ ),
+ ).href,
+ ]) {
+ expect(load(url, {}, next)).toBe(result);
+ }
+ });
+ });
+
+ it("recognizes capture entries but refuses unverified plugin bytes", () => {
+ withProviderFiles(({ root, plugin, entry }) => {
+ const load = createLocalModelCompatibilityLoadHook(plugin, root);
+ expect(() =>
+ load(pathToFileURL(entry).href, {}, () => ({
+ format: "module",
+ source: "unverified provider source",
+ })),
+ ).toThrow("pinned");
+ });
+ });
+
+ it("rejects snapshots that differ from their original installed source", () => {
+ withProviderFiles(({ root, plugin, entry }) => {
+ const load = createLocalModelCompatibilityLoadHook(plugin, root);
+ expect(() =>
+ load(pathToFileURL(entry).href, {}, () => ({
+ format: "module",
+ source: "different snapshot",
+ })),
+ ).toThrow("differs from its installed source");
+ });
+ });
+
+ it("rejects a capture whose provider identity changed", () => {
+ withProviderFiles(({ root, plugin, captured, entry }) => {
+ writeFileSync(
+ join(captured, "package.json"),
+ JSON.stringify({
+ name: "@openclaw/llama-cpp-provider",
+ version: "2026.10.3",
+ }),
+ );
+ const load = createLocalModelCompatibilityLoadHook(plugin, root);
+ expect(() =>
+ load(pathToFileURL(entry).href, {}, () => ({
+ format: "module",
+ source: "unverified provider source",
+ })),
+ ).toThrow("pinned official");
+ });
+ });
+
+ it("rejects redirected captured provider directories", () => {
+ withProviderFiles(({ root, plugin, captured, entry }) => {
+ rmSync(captured, { recursive: true });
+ symlinkSync(plugin, captured, "junction");
+ const load = createLocalModelCompatibilityLoadHook(plugin, root);
+ expect(() =>
+ load(pathToFileURL(entry).href, {}, () => ({
+ format: "module",
+ source: "unverified provider source",
+ })),
+ ).toThrow("must not be redirected");
+ });
+ });
+
+ it("requires absolute configured paths", () => {
+ expect(() => createLocalModelCompatibilityLoadHook(undefined)).toThrow("absolute");
+ expect(() => createLocalModelCompatibilityLoadHook("relative")).toThrow("absolute");
+ });
+});
function fixture(selected = LOCAL_MODEL_PRESETS[0].modelRef as string, cached = false) {
const recipes = [...LOCAL_MODEL_PRESETS].reverse().map((model) => ({
@@ -140,6 +268,16 @@ describe("pinned exact local model chooser", () => {
expect(patched).not.toContain(
"const recommendation = recommendLlamaCppModel(hardware, asset.backend",
);
+ expect(patched).not.toContain("import { publishLlamaRuntime } from");
+ withProviderFiles(({ root, plugin, entry }) => {
+ const result = createLocalModelCompatibilityLoadHook(plugin, root)(
+ pathToFileURL(entry).href,
+ {},
+ () => ({ format: "module", source: Buffer.from(source) }),
+ );
+ expect(result.source).toContain(EXACT_MODEL_SELECTION_PROMPT);
+ expect(result.source).toContain("await publishLlamaRuntime");
+ }, source);
expect(readFileSync(join(directory!, "dist", "index.js"), "utf8")).toBe(source);
},
);
diff --git a/desktop/src/sandbox-logic.test.ts b/desktop/src/sandbox-logic.test.ts
index 098dbd4..82083ba 100644
--- a/desktop/src/sandbox-logic.test.ts
+++ b/desktop/src/sandbox-logic.test.ts
@@ -2475,6 +2475,30 @@ describe("checkApproval — no auto-bypass for whitelisted apps", () => {
const pathExtraction = require("../../appcontainer/path-extraction.js");
const sandboxState = require("../../appcontainer/sandbox-state.js");
+describe("direct Node script argument paths", () => {
+ const node = "C:\\Program Files\\nodejs\\node.exe";
+ const script = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js";
+
+ it.each([[script], [script, "root", "-g"], ["--no-warnings", script, "prefix", "-g"]])(
+ "preserves script paths containing spaces without inventing C:\\Program",
+ (...args) => {
+ const payload = pathExtraction.extractShellPayload(node, args);
+ const paths = pathExtraction.extractReadPaths(payload);
+ expect(paths).toContain(script);
+ expect(paths).not.toContain("C:\\Program");
+ },
+ );
+
+ it("still extracts additional sensitive file arguments", () => {
+ const sensitivePath = "C:\\Users\\test\\.ssh\\private key";
+ const paths = pathExtraction.extractReadPaths(
+ pathExtraction.extractShellPayload(node, [script, sensitivePath]),
+ );
+ expect(paths).toContain(script);
+ expect(paths).toContain(sensitivePath);
+ });
+});
+
describe("OpenClaw internal SQLite worker", () => {
const packageRoot = "C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\openclaw";
const execPath = "C:\\Program Files\\nodejs\\node.exe";
@@ -2486,6 +2510,11 @@ describe("OpenClaw internal SQLite worker", () => {
"openclaw",
"openclaw-sqlite-readonly-2056-139531e6-3179-429e-90e2-f16d5a267869",
);
+ const stagingRootV2 = path.join(
+ localAppData,
+ "openclaw",
+ "openclaw-sqlite-readonly-v2-139531e6-3179-429e-90e2-f16d5a267869",
+ );
const workerPath = path.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js");
const args = [
workerPath,
@@ -2496,10 +2525,12 @@ describe("OpenClaw internal SQLite worker", () => {
const stack = `Error
at prepareSqliteReadOnlyLocation (file:///${packageRoot.replace(/\\/g, "/")}/dist/sqlite-readonly-location-AbCd1234.js:662:3)`;
const canonical = new Map([
+ [packageRoot.toLowerCase(), packageRoot],
[stateDir.toLowerCase(), stateDir],
[args[3].toLowerCase(), args[3]],
[path.join(localAppData, "openclaw").toLowerCase(), path.join(localAppData, "openclaw")],
[stagingRoot.toLowerCase(), stagingRoot],
+ [stagingRootV2.toLowerCase(), stagingRootV2],
]);
const runtime = {
entryPath,
@@ -2520,6 +2551,179 @@ describe("OpenClaw internal SQLite worker", () => {
);
});
+ it("limits snapshot reclamation to the canonical OpenClaw cache root", () => {
+ const cacheRoot = path.join(localAppData, "openclaw");
+ const reclaimArgs = [workerPath, args[1], "reclaim", cacheRoot];
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, reclaimArgs, stack, runtime),
+ ).toBe(true);
+ for (const target of [stateDir, localAppData, stagingRootV2, "relative"]) {
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(
+ execPath,
+ [workerPath, args[1], "reclaim", target],
+ stack,
+ runtime,
+ ),
+ ).toBe(false);
+ }
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, reclaimArgs, stack, {
+ ...runtime,
+ realpath: () => stateDir,
+ }),
+ ).toBe(false);
+ });
+
+ it("limits the state ownership reader to its exact worker, caller, and state database", () => {
+ const ownershipWorker = path.join(
+ packageRoot,
+ "dist",
+ "state",
+ "openclaw-state-ownership.worker.js",
+ );
+ const ownershipStack = stack.replace(
+ "sqlite-readonly-location-AbCd1234.js",
+ "openclaw-state-ownership-AbCd1234.mjs",
+ );
+ const ownershipArgs = [ownershipWorker, args[3]];
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(
+ execPath,
+ ownershipArgs,
+ ownershipStack,
+ runtime,
+ ),
+ ).toBe(true);
+ for (const candidate of [
+ [ownershipWorker, path.join(localAppData, "outside.sqlite")],
+ [...ownershipArgs, "--extra"],
+ [workerPath, args[3]],
+ ]) {
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, candidate, ownershipStack, runtime),
+ ).toBe(false);
+ }
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, ownershipArgs, "untrusted", runtime),
+ ).toBe(false);
+ expect(
+ cpHooks.buildInternalSqliteWorkerInvocation(ownershipArgs, {}).options.env
+ .MICROCLAW_OPENCLAW_INTERNAL_SQLITE_WORKER,
+ ).toBe("ownership");
+ });
+
+ it("recognizes the pinned SQLite store worker thread as a runtime entry", () => {
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, args, stack, {
+ ...runtime,
+ entryPath: path.join(packageRoot, "dist", "infra", "sqlite-store.worker.js"),
+ }),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, args, stack, {
+ ...runtime,
+ entryPath: path.join(packageRoot, "dist", "infra", "untrusted.worker.js"),
+ }),
+ ).toBe(false);
+ });
+
+ it("uses the captured package root when a worker preload has no entry argv yet", () => {
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, args, stack, {
+ ...runtime,
+ entryPath: "",
+ packageRoot,
+ }),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, args, stack, {
+ ...runtime,
+ entryPath: "",
+ packageRoot: path.join(stateDir, "untrusted"),
+ }),
+ ).toBe(false);
+ });
+
+ it("allows scoped sessions only from trusted callers with the fixed IPC transport", () => {
+ const sessionArgs = [workerPath, args[1], "session"];
+ const options = { stdio: ["ignore", "pipe", "pipe", "ipc"] };
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, sessionArgs, stack, runtime, options),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, sessionArgs, stack, runtime),
+ ).toBe(false);
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(
+ execPath,
+ sessionArgs,
+ "untrusted",
+ runtime,
+ options,
+ ),
+ ).toBe(false);
+ const invocation = cpHooks.buildInternalSqliteWorkerInvocation(sessionArgs, {
+ ...options,
+ env: { NODE_OPTIONS: "--require evil.js" },
+ });
+ expect(invocation.options.stdio).toEqual(options.stdio);
+ expect(invocation.options.env.MICROCLAW_OPENCLAW_INTERNAL_SQLITE_WORKER).toBe("session");
+ expect(invocation.options.env.NODE_OPTIONS).toBeUndefined();
+ });
+
+ it("validates every session database request and staging operation", () => {
+ for (const mode of ["sync", "auth-profile-rows", "operation"]) {
+ expect(
+ cpHooks.isOpenClawInternalSqliteSessionMessage({ id: 1, args: [mode, args[3]] }, runtime),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteSessionMessage(
+ { id: 1, args: [mode, path.join(localAppData, "outside.sqlite")] },
+ runtime,
+ ),
+ ).toBe(false);
+ }
+ expect(cpHooks.isOpenClawInternalSqliteSessionMessage("close", runtime)).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteSessionMessage(
+ { id: 1, args: ["staging-create", path.join(localAppData, "openclaw")] },
+ runtime,
+ ),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteSessionMessage(
+ { id: 1, args: ["staging-retire", stagingRootV2] },
+ runtime,
+ ),
+ ).toBe(true);
+ for (const message of [
+ null,
+ {},
+ { id: 1, args: ["write", args[3]] },
+ { id: 1, args: ["sync", "relative.sqlite"] },
+ { id: 1, args: ["sync", args[3], path.join(stateDir, "output")] },
+ { id: 1, args: ["staging-create", stateDir] },
+ { id: 1, args: ["staging-retire", path.join(localAppData, "openclaw", "credentials")] },
+ { id: 1, args: ["reclaim", localAppData] },
+ { id: 1, transfer: { type: "execute" } },
+ ]) {
+ expect(cpHooks.isOpenClawInternalSqliteSessionMessage(message, runtime)).toBe(false);
+ }
+ expect(
+ cpHooks.isOpenClawInternalSqliteSessionMessage(
+ { id: 1, args: ["sync", args[3]] },
+ {
+ ...runtime,
+ realpath: (value: string) =>
+ value.toLowerCase() === args[3].toLowerCase()
+ ? path.join(localAppData, "outside.sqlite")
+ : runtime.realpath(value),
+ },
+ ),
+ ).toBe(false);
+ });
+
it.each(["async", "sync"])("accepts the 9.3 %s worker with its owned staging root", (mode) => {
expect(
cpHooks.isOpenClawInternalSqliteWorkerCommand(
@@ -2540,6 +2744,9 @@ describe("OpenClaw internal SQLite worker", () => {
path.join(localAppData, "openclaw", "credentials"),
stagingRoot.replace("-2056-", "-9999-"),
path.join(stagingRoot, "nested"),
+ stagingRootV2.replace("-v2-", "-v3-"),
+ stagingRootV2.replace("139531e6", "invalid"),
+ path.join(stagingRootV2, "nested"),
])("rejects an unowned 9.3 staging root: %s", (staging) => {
expect(
cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, [...args, staging], stack, {
@@ -2549,6 +2756,31 @@ describe("OpenClaw internal SQLite worker", () => {
).toBe(false);
});
+ it.each(["async", "sync"])("accepts the 10.2 %s worker's canonical v2 staging root", (mode) => {
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(
+ execPath,
+ [workerPath, args[1], mode, args[3], stagingRootV2],
+ stack,
+ runtime,
+ ),
+ ).toBe(true);
+ expect(
+ cpHooks.isOpenClawInternalSqliteWorkerCommand(
+ execPath,
+ [workerPath, args[1], mode, args[3], stagingRootV2],
+ stack,
+ {
+ ...runtime,
+ realpath: (value: string) =>
+ value.toLowerCase() === stagingRootV2.toLowerCase()
+ ? path.join(stateDir, path.basename(stagingRootV2))
+ : runtime.realpath(value),
+ },
+ ),
+ ).toBe(false);
+ });
+
it("rejects redirected staging directories and surplus worker arguments", () => {
expect(
cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, [...args, stagingRoot], stack, {
@@ -2942,6 +3174,13 @@ describe("per-path independent enforcement", () => {
expect(sandboxState.isReadBlockedPath(lockFile, false)).toBe(false);
expect(sandboxState.isBlockedPath(snapshotFile)).toBe(false);
expect(sandboxState.isReadBlockedPath(snapshotFile, false)).toBe(false);
+ const snapshotFileV2 = snapshotFile.replace("-2056-", "-v2-");
+ expect(sandboxState.isBlockedPath(snapshotFileV2)).toBe(false);
+ expect(sandboxState.isReadBlockedPath(snapshotFileV2, false)).toBe(false);
+ expect(sandboxState.isBlockedPath(snapshotFileV2.replace("-v2-", "-v3-"))).toBe(true);
+ expect(sandboxState.isBlockedPath(snapshotFileV2.replace("139531e6", "invalid"))).toBe(
+ true,
+ );
expect(
sandboxState.isBlockedPath(path.join(localAppData, "OpenClaw", "credentials.json")),
).toBe(true);
diff --git a/tests/test_windows_setup_upgrade.py b/tests/test_windows_setup_upgrade.py
index 14c4889..6e3c084 100644
--- a/tests/test_windows_setup_upgrade.py
+++ b/tests/test_windows_setup_upgrade.py
@@ -276,6 +276,91 @@ def test_other_version_requires_upgrade(self):
self.assertFalse(self.ws.check_openclaw_windows())
+ def test_gateway_validation_uses_cold_start_rpc_timeout_and_requires_rpc(self):
+ self.ws._run_openclaw_json = unittest.mock.Mock(return_value={})
+ self.assertTrue(self.ws._validate_gateway_status())
+ self.ws._run_openclaw_json.assert_called_with(
+ ["gateway", "status", "--require-rpc", "--timeout", "30000", "--json"]
+ )
+ self.assertTrue(self.ws._validate_gateway_rpc("config.get"))
+ self.ws._run_openclaw_json.assert_called_with(
+ ["gateway", "call", "config.get", "--timeout", "30000", "--json"]
+ )
+
+ def test_appcontainer_provisions_actual_runtime_prefix_and_native_ancestor_grants(self):
+ prefix = self.appdata / "npm"
+ package = self._write_package(prefix, OPENCLAW_TARGET_VERSION)
+ self.ws.install_prefix = prefix
+ self.ws._find_appcontainer_launcher = unittest.mock.Mock(return_value=Path("launcher.exe"))
+ self.ws._appcontainer_access_is_sufficient = unittest.mock.Mock(return_value=False)
+ sid = "S-1-15-2-12345"
+ with unittest.mock.patch(
+ "deployer.windows_setup.subprocess.run",
+ return_value=subprocess.CompletedProcess([], 0, stdout=sid, stderr=""),
+ ) as run:
+ self.assertTrue(self.ws.provision_appcontainer())
+ grant = next(
+ call
+ for call in run.call_args_list
+ if "grant" in call.args[0] and str(package) in call.args[0]
+ )
+ self.assertEqual(grant.args[0][-2:], ["--access", "r"])
+ self.assertEqual(grant.kwargs["timeout"], 300)
+ self.assertFalse(any(call.args[0][0] == "icacls" for call in run.call_args_list))
+ sandbox = str(self.home / ".openclaw" / "sandbox")
+ sandbox_calls = [
+ call
+ for call in run.call_args_list
+ if "grant" in call.args[0] and sandbox in call.args[0]
+ ]
+ self.assertEqual(len(sandbox_calls), 2)
+
+ def test_appcontainer_does_not_report_success_when_required_grant_fails(self):
+ self.ws._find_appcontainer_launcher = unittest.mock.Mock(return_value=Path("launcher.exe"))
+ self.ws._appcontainer_access_is_sufficient = unittest.mock.Mock(return_value=False)
+ self.ws._validate_appcontainer_smoke = unittest.mock.Mock(return_value=False)
+
+ def run(command, **_kwargs):
+ failed = "grant" in command
+ return subprocess.CompletedProcess(
+ command,
+ 1 if failed else 0,
+ stdout="S-1-15-2-12345",
+ stderr="access denied" if failed else "",
+ )
+
+ with unittest.mock.patch("deployer.windows_setup.subprocess.run", side_effect=run):
+ self.assertFalse(self.ws.provision_appcontainer())
+ self.ws._validate_appcontainer_smoke.assert_called_once()
+ self.ws._validate_appcontainer_smoke.reset_mock(return_value=True)
+ self.ws._validate_appcontainer_smoke.return_value = True
+ with unittest.mock.patch("deployer.windows_setup.subprocess.run", side_effect=run):
+ self.assertTrue(self.ws.provision_appcontainer())
+ self.ws._validate_appcontainer_smoke.assert_called_once()
+
+ def test_appcontainer_smoke_checks_actual_node_runtime_and_sandbox_write(self):
+ prefix = self.appdata / "npm"
+ package = self._write_package(prefix, OPENCLAW_TARGET_VERSION)
+ self.ws.install_prefix = prefix
+ self.ws.appcontainer_enabled = True
+ self.ws._find_appcontainer_launcher = unittest.mock.Mock(return_value=Path("launcher.exe"))
+ node = self.program_files / "nodejs" / "node.exe"
+ self.ws._resolve_validation_node = unittest.mock.Mock(return_value=node)
+ self.ws._run = unittest.mock.Mock(
+ return_value=subprocess.CompletedProcess(
+ [], 0, stdout="microclaw-sandbox-ok", stderr=""
+ )
+ )
+ self.assertTrue(self.ws._validate_appcontainer_smoke())
+ command = self.ws._run.call_args.args[0]
+ self.assertEqual(command[command.index("--exe") + 1], str(node))
+ self.assertEqual(command[-2], str(package / "package.json"))
+ self.assertEqual(command[-1], str(self.home / ".openclaw" / "sandbox"))
+ self.assertIn("fs.readFileSync(process.argv[1])", command[command.index("-e") + 1])
+ self.assertIn("fs.writeFileSync", command[command.index("-e") + 1])
+ self.ws._run.return_value = subprocess.CompletedProcess([], 1, stdout="", stderr="denied")
+ self.assertFalse(self.ws._validate_appcontainer_smoke())
+
def test_source_installer_requires_compatible_staged_weixin_runtime(self):
project = self.root / "project"
source = project / "plugins" / "openclaw-weixin"
@@ -1110,6 +1195,28 @@ def test_migrate_openclaw_state_runs_target_doctor_with_state_environment(self):
str(self.home / ".openclaw"),
)
+ def test_same_version_desktop_update_does_not_migrate_unbacked_databases(self):
+ prefix = self.appdata / "npm"
+ self._write_package(prefix, OPENCLAW_TARGET_VERSION)
+ self.ws.install_prefix = prefix
+ self.ws._find_openclaw_cmd = unittest.mock.Mock(return_value=["openclaw.cmd"])
+ self.ws._openclaw_upgrade_required = False
+ self.ws._run = unittest.mock.Mock()
+
+ self.assertTrue(self.ws.migrate_openclaw_state())
+ self.ws._run.assert_not_called()
+
+ def test_same_version_migration_skip_rejects_runtime_version_drift(self):
+ prefix = self.appdata / "npm"
+ self._write_package(prefix, "2026.8.2")
+ self.ws.install_prefix = prefix
+ self.ws._find_openclaw_cmd = unittest.mock.Mock(return_value=["openclaw.cmd"])
+ self.ws._openclaw_upgrade_required = False
+ self.ws._run = unittest.mock.Mock()
+
+ self.assertFalse(self.ws.migrate_openclaw_state())
+ self.ws._run.assert_not_called()
+
def test_migrate_openclaw_state_fails_closed_when_doctor_rejects_state(self):
prefix = self.appdata / "npm"
self._write_package(prefix, OPENCLAW_TARGET_VERSION)
@@ -1969,7 +2076,7 @@ def test_install_search_provider_plugin_installs_parallel_package(self):
self.ws._run.assert_called_once()
self.assertEqual(
self.ws._run.call_args.args[0],
- ["openclaw.cmd", "plugins", "install", "@openclaw/parallel-plugin"],
+ ["openclaw.cmd", "plugins", "install", "@openclaw/parallel-plugin@2026.10.2"],
)
def test_local_model_provider_installs_only_pinned_component_and_preserves_model_selection(
From 0e89412c21db5721df5a99d945166f7fd05b38a4 Mon Sep 17 00:00:00 2001
From: Suzhen Zheng
Date: Thu, 15 Oct 2026 18:54:13 +0800
Subject: [PATCH 2/2] Unify live model catalogs and verify model switching
Share authenticated catalogs across provider, default and conversation controls; hot-register selected models without unnecessary Gateway restarts; preserve saved form choices and reject incompatible model APIs. Add non-activating startup/update behavior and real-provider E2E coverage.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
README.md | 22 +-
README.zh-CN.md | 20 +-
deployer/webview_bridge.py | 8 +-
desktop/renderer/env.d.ts | 7 +
desktop/renderer/src/browser-openclaw.ts | 4 +
.../src/components/DefaultModelPicker.test.ts | 47 ++-
.../src/components/DefaultModelPicker.vue | 3 +
.../src/components/ModelProviderGroups.vue | 20 +-
.../src/components/ModelSetupDialog.test.ts | 286 +++++++++++++-
.../src/components/ModelSetupDialog.vue | 369 +++++++++++++++---
.../components/chat/ChatModelPicker.test.ts | 79 +++-
.../src/components/chat/ChatModelPicker.vue | 26 +-
.../use-provider-model-catalog.test.ts | 93 +++++
.../composables/use-provider-model-catalog.ts | 136 +++++++
desktop/renderer/src/i18n/en-US.ts | 39 +-
desktop/renderer/src/i18n/zh-CN.ts | 35 +-
desktop/renderer/src/stores/chat.test.ts | 25 ++
desktop/renderer/src/stores/chat.ts | 20 +-
.../src/utils/managed-model-providers.ts | 2 +-
desktop/renderer/src/utils/model-provider.ts | 13 +
.../src/views/SettingsView.models.test.ts | 132 ++++++-
.../renderer/src/views/SettingsView.test.ts | 10 +-
desktop/renderer/src/views/SettingsView.vue | 152 ++++++--
desktop/src/default-model-selection.test.ts | 6 +
desktop/src/default-model-selection.ts | 11 +-
desktop/src/main.ts | 78 +++-
desktop/src/model-connection.ts | 29 +-
desktop/src/preload.ts | 7 +
.../src/provider-model-capabilities.test.ts | 35 ++
desktop/src/provider-model-capabilities.ts | 38 ++
desktop/src/provider-model-catalog.test.ts | 117 ++++++
desktop/src/provider-model-catalog.ts | 79 ++++
.../provider-model-live.integration.test.ts | 268 +++++++++++++
...der-model-registration.integration.test.ts | 215 ++++++++++
.../src/provider-model-registration.test.ts | 218 +++++++++++
desktop/src/provider-model-registration.ts | 147 +++++++
desktop/src/session-model-service.test.ts | 13 +
desktop/src/session-model-service.ts | 8 +
desktop/src/startup-order.test.ts | 14 +-
desktop/src/window-lifecycle.test.ts | 22 ++
desktop/src/window-lifecycle.ts | 11 +
tests/test_webview_bridge.py | 71 ++++
42 files changed, 2742 insertions(+), 193 deletions(-)
create mode 100644 desktop/renderer/src/composables/use-provider-model-catalog.test.ts
create mode 100644 desktop/renderer/src/composables/use-provider-model-catalog.ts
create mode 100644 desktop/src/provider-model-capabilities.test.ts
create mode 100644 desktop/src/provider-model-capabilities.ts
create mode 100644 desktop/src/provider-model-catalog.test.ts
create mode 100644 desktop/src/provider-model-catalog.ts
create mode 100644 desktop/src/provider-model-live.integration.test.ts
create mode 100644 desktop/src/provider-model-registration.integration.test.ts
create mode 100644 desktop/src/provider-model-registration.test.ts
create mode 100644 desktop/src/provider-model-registration.ts
diff --git a/README.md b/README.md
index 702a996..e4b664c 100644
--- a/README.md
+++ b/README.md
@@ -146,11 +146,21 @@ You can add or edit models any time under **Settings → Models**. Credentials a
#### Local models (experimental)
-**Settings → Models** lists cloud providers and **Local model** (llama.cpp) in compact cards. Existing saved custom API connections remain manageable, but the overview has no generic add-compatible-API button or unconfigured placeholder card. **Manage** expands the connection editor inline. Saving a connection there does not change the default model or send an AI test prompt; initial onboarding retains its existing flow. The default model is selected separately.
+**Settings → Models** lists cloud providers and **Local model** (llama.cpp) in compact cards. Existing saved custom API connections remain manageable, but the overview has no generic add-compatible-API button or unconfigured placeholder card. The card's arrow expands the connection editor inline. Saving a connection there does not change the default model or send an AI test prompt; initial onboarding retains its existing flow. The default model is selected separately.
-The Qwen and MiniMax cards show **Get API Key** without opening an editor. The links go directly to the [Bailian Beijing API-key console](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key) and [MiniMax API-key console](https://platform.minimax.cn/user-center/basic-information/interface-key), rather than a generic landing page. The inline editors retain the same links. Opening a website preserves the selected model and form input; browser-launch failures are surfaced.
+Provider cards expand/collapse with down/up arrow buttons. Embedded editors show the fields without repeating the provider title, setup introduction, or close button. Qwen/MiniMax use only the model dropdown, without a separate saved-model list or per-model Edit/Remove buttons above the form; saved configurations are preserved. **Get API Key** appears beside the API-key input inside the Qwen/MiniMax editor, linking to the [Bailian Beijing API-key console](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key) and [MiniMax API-key console](https://platform.minimax.cn/user-center/basic-information/interface-key). The model selector follows the API key, with a refresh icon on its right.
-The model button beside the chat composer searches configured models and the two managed local presets, **Qwen3.5 4B Q4_K_M** and **Qwen3.5 9B Q4_K_M**. Selection changes **only the current conversation**, using OpenClaw session overrides. New conversations carry their selected agent's explicit session key, including the main agent, so a model can be selected before the first message in multi-agent configurations. **Use agent default** clears that override without changing agent/global defaults, fallbacks, or other conversations. First use shows the exact model download size, missing embeddings, runtime disk reserve and CPU/GPU mode. Downloads require confirmation; selection changes only after successful preparation. Cancel/failure keeps the previous selection. Already-prepared models switch directly with an inline **Switching...** indicator, without briefly opening a preparation dialog or running setup/inference tests again. Preparation, failures and interrupted-operation recovery still use a persistent dialog.
+The Qwen/MiniMax model dropdown stays disabled until a nonempty credential is supplied and an authenticated, read-only `GET /models` succeeds. The list contains only IDs returned by that endpoint, not the preset suggestions; individual model access still depends on the provider's permissions and quota. Key/endpoint changes invalidate the previous list immediately, stale responses are ignored, and a refresh action retries discovery. Authentication, masked keys, empty catalogs and unsupported endpoints are shown explicitly with no static-list fallback. Discovery does not save configuration, restart the Gateway, or send inference requests. New MiniMax connections use the documented `https://api.minimax.cn/v1` endpoint; existing saved endpoints are preserved.
+
+The provider form, default-model picker and conversation-model picker share the same full live catalog for each configured Qwen/MiniMax connection, rather than limiting the latter two to previously saved model IDs. Refreshing a matching connection updates all three; unsaved credentials have a separate in-memory catalog. Catalog failures remain visible, with saved entries retained in the default/conversation pickers rather than presented as a complete live list.
+
+The full directory also contains models that are not compatible with ordinary chat completions. Known realtime, embedding, reranking, speech, and media-generation families remain visible in all three controls but are disabled with a reason; the main process also rejects their selection and use by an existing conversation. In particular, [Qwen3.8-Omni-Flash-Realtime requires a realtime transport](https://www.alibabacloud.com/help/en/model-studio/qwen3-8-omni-flash-realtime), not this chat endpoint. These compatibility checks do not prove account entitlement: HTTP 403 and upstream-normalized access rejections are reported as provider access failures, not automatically as an invalid API key. Check model permissions, region and billing in the provider console.
+
+Selecting a live model that is not yet registered first verifies it against the saved provider credentials, registers only that model using a revision-guarded Gateway patch, and waits for the applied revision before switching. This does not restart the Gateway, send inference, change credentials or relax model policies. The default selector changes only the default; the conversation selector changes only that conversation. Saving only a different model within an unchanged managed connection also uses this hot-registration path, without changing the default or session. Actual API-key/endpoint changes still use the connection-update path.
+
+The last model saved in each managed provider form is remembered separately in desktop preferences, so reopening the editor does not jump back to the first registered model. This preference does not select a default or change any conversation. The two grouped pickers expose named dialogs and expanded state through the real Element Plus popover, without adding a duplicate visible heading.
+
+The model button beside the chat composer searches full live cloud catalogs, saved custom connections and the two managed local presets, **Qwen3.5 4B Q4_K_M** and **Qwen3.5 9B Q4_K_M**. Selection changes **only the current conversation**, using OpenClaw session overrides. New conversations carry their selected agent's explicit session key, including the main agent, so a model can be selected before the first message in multi-agent configurations. **Use agent default** clears that override without changing agent/global defaults, fallbacks, or other conversations. First use shows the exact model download size, missing embeddings, runtime disk reserve and CPU/GPU mode. Downloads require confirmation; selection changes only after successful preparation. Cancel/failure keeps the previous selection. Already-prepared models switch directly with an inline **Switching...** indicator, without briefly opening a preparation dialog or running setup/inference tests again. Preparation, failures and interrupted-operation recovery still use a persistent dialog.
Both the chat model picker and **Settings → Models → Default model** use the same collapsible provider groups. Only the currently selected model's provider opens by default; headings can be toggled with a click, Enter or Space. Search reveals matching groups, and clearing it restores the previous folds. Folding only changes the list display, never model configuration or downloads. The settings picker still changes the global default, while the chat picker changes only its conversation.
@@ -160,6 +170,10 @@ Changing an existing cloud **default model** uses a revision-guarded `config.pat
If a conversation-model change becomes uncertain, the chat picker and model settings expose recovery rather than leaving all conversations locked. A fresh read can reconcile a completed RPC or a request whose original managed Gateway process has demonstrably stopped. Otherwise **Restart Gateway and recover** requires explicit confirmation, verifies shutdown of the owned process, and accepts the saved state without resending the patch or deleting history. A WebSocket reconnect or a matching model value alone is not proof that a delayed write has finished. Ordinary model switches still do not restart the Gateway.
+For an opt-in **real provider/Gateway** smoke test (not a desktop UI test), set `MICROCLAW_TEST_OPENCLAW_ENTRY` to an installed `openclaw.mjs` and `MICROCLAW_TEST_LIVE_QWEN_CONFIG` to a configuration containing an authorized Qwen key, then run `npx vitest run src\provider-model-live.integration.test.ts` from `desktop`. It uses isolated temporary state, changes default/session models, and requests short real replies from Qwen3.8 Max and Flash with a 512-token output budget. It can consume quota or incur provider charges, is skipped without both opt-ins, does not write the supplied configuration, and never operates desktop input or windows.
+
+For non-interrupting automation, launch the installer or desktop with the process-local environment variable `MICROCLAW_BACKGROUND=1`. The installer does not activate its window, and desktop startup uses `showInactive` while keeping renderer timers responsive for background checks. Normal launches are unchanged. This mode does not disable sandboxing or authorize keyboard/mouse control; automation should use app-local actions and verify that it never activates a window. Installer captions distinguish **MicroClaw Setup** from the desktop.
+
The installer prepares the pinned official llama.cpp provider without downloading model weights or the inference runtime. The installer discloses the provider and its declared capabilities; explicit user-disabled plugin policies are preserved. The local card shows **Connected** when the provider is installed and enabled, independently of whether either model is downloaded. This is provider availability, not an inference health check. Older installations can explicitly review and enable the component in local settings.
When the pinned OpenClaw version is unchanged, a desktop update refreshes managed files without rerunning database migrations outside its lightweight rollback snapshot. Runtime upgrades still run the full migration flow; both paths require the installed desktop and Gateway to become ready before committing.
@@ -171,7 +185,7 @@ When the pinned OpenClaw version is unchanged, a desktop update refreshes manage
- **Managed models**: 4B is listed first as the lightweight choice; 9B has higher resource requirements and can be slow on CPU. Choosing 4B always prepares 4B, never an automatic 9B replacement. OpenClaw still owns hardware admission, download hashes, runtime installation and cancellation. A version/source-hash-pinned in-memory adapter adds exact selection to the 2026.9.3 provider's wizard, preserves existing model routes, and fails closed if its source or exact-model receipts change. The underlying OpenClaw installation is not modified.
- **Existing server**: expand **Advanced options and information** to connect to a llama-server you already run on `localhost`, `127.0.0.1`, or `::1`. Supply a key only if that service requires it. MicroClaw does not own or stop this server.
- Cloud and local connections are retained when changing models. Selection does not add automatic cloud fallbacks; any previously authored OpenClaw fallback or agent/session override still needs to be reviewed separately. Local inference does not make network-dependent skills offline.
-- Cloud setup remains accessible while a local wizard is unfinished. Only saving or switching a model asks to cancel conflicting local setup first; declining leaves it untouched. The only unconfigured catalog entries offered are the two pinned local presets; existing saved connections are retained. Local connection management stays under advanced local settings.
+- Cloud setup remains accessible while a local wizard is unfinished. Only saving or switching a model asks to cancel conflicting local setup first; declining leaves it untouched. For local models, the only unconfigured catalog entries offered are the two pinned presets; existing saved connections are retained. Local connection management stays under advanced local settings.
- The integration checks the Gateway's advertised setup methods and supports OpenClaw 2026.10.2 or 2026.9.3+ within 2026.9.x. If the provider is missing from its built-in official catalog, MicroClaw verifies the official ClawHub package identity and requests `@openclaw/llama-cpp-provider@2026.9.3`, not a floating latest release. Downloads, runtime prerequisites and GPU support remain subject to the official provider.
- Actual configuration writes remain protected in Windows Node + MXC mode, while a response is active, or until conflicting setup has safely ended. Preparing a local model does not itself block existing chats; applying a model change still does. After a disconnection, resume the retained operation rather than starting it again. Cancellation can be refused while OpenClaw finishes a protected step.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 791d690..5d256ba 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -94,9 +94,19 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要
**设置 → 模型** 以统一卡片展示云端提供商和“本地模型”(llama.cpp);已有自定义 API 连接仍可管理,但总览不再提供“添加兼容 API”按钮或未配置的占位卡片。点击“管理”在原卡片中展开连接编辑。这里保存连接不切换默认模型,也不发送 AI 测试消息;初次配置仍保留原有引导流程。默认模型单独选择。
-千问和 MiniMax 卡片直接显示“获取 API 密钥”,无需先展开表单,分别打开[百炼北京地域 API Key 控制台](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key)和[MiniMax 接口密钥控制台](https://platform.minimax.cn/user-center/basic-information/interface-key),不再只跳到通用落地页。内嵌表单也保留相同入口;打开网站不会重置已选模型或已填写内容,浏览器启动失败会明确提示。
+提供商卡片通过向下/向上箭头展开和收起。内嵌编辑器直接展示字段,不再重复提供商标题、配置说明和关闭按钮。千问/MiniMax 仅使用模型下拉框,不再在表单上方另列已保存模型及逐条 Edit/Remove 按钮;已有配置仍然保留。“获取 API 密钥”放在千问/MiniMax 表单的 API Key 输入框右侧,分别打开[百炼北京地域 API Key 控制台](https://bailian.console.aliyun.com/cn-beijing/model/settings/api-key)和[MiniMax 接口密钥控制台](https://platform.minimax.cn/user-center/basic-information/interface-key)。模型下拉框排在 API Key 之后,右侧提供刷新图标。
-聊天输入框旁的模型按钮支持搜索已配置模型,以及 **Qwen3.5 4B Q4_K_M**、**Qwen3.5 9B Q4_K_M** 两个托管本地预设,通过 OpenClaw 会话覆盖设置**只切换当前会话**。新建对话的会话标识会明确携带所选智能体的归属(包括主智能体),多智能体配置下也可在第一条消息发送前选模。“使用智能体默认模型”仅清除此会话覆盖,不修改智能体或全局默认值、回退模型及其他会话。首次选择会展示精确模型大小、缺少的嵌入模型、运行时预留磁盘及 CPU/GPU 模式,明确确认后才下载,准备成功后才切换;失败或取消保留原模型。已准备的模型直接切换,在原选模控件显示“切换中…”,不再闪现准备弹窗,也不运行设置或 AI 测试。需要准备、发生错误或恢复中断操作时,仍保留可阅读和操作的弹窗。
+填写非空凭据并成功完成带认证的只读 `GET /models` 请求之前,千问/MiniMax 模型下拉框保持禁用。下拉列表只使用接口实际返回的模型 ID,不使用静态预设兜底;单个模型能否调用仍取决于服务商权限和额度。修改 Key 或地址会立即使旧列表失效,过期响应会被丢弃,可点击“刷新模型列表”重试。认证失败、脱敏 Key、空目录或不支持的接口均明确提示。获取目录不会保存配置、重启 Gateway 或发送推理请求。新建 MiniMax 连接使用官方文档的 `https://api.minimax.cn/v1`,已有保存地址不会被自动改写。
+
+提供商表单、默认模型选择器和聊天选模菜单共享已配置千问/MiniMax 连接的完整实时目录,后两处不再仅列出曾经保存过的模型。同一连接刷新后,三处同步更新;尚未保存的密钥使用独立的内存目录。目录获取失败时明确报错,默认/聊天菜单保留已保存项,但不将其冒充完整实时目录。
+
+完整目录也包含不能通过普通聊天接口调用的模型。已知的实时、向量、重排序、语音和媒体生成系列在三处仍可见,但会标明原因并禁用选择;主进程也会阻止选择及已有会话继续调用这些型号。例如 [Qwen3.8-Omni-Flash-Realtime 要求实时传输接口](https://www.alibabacloud.com/help/en/model-studio/qwen3-8-omni-flash-realtime),不能使用当前聊天接口。兼容性检查不代表账号拥有调用权限:HTTP 403 或上游统一处理后的访问拒绝会提示服务商访问问题,不再一律认定为 Key 无效;需在服务商控制台检查型号权限、地域和计费设置。
+
+选择实时目录中尚未注册的聊天型号时,先用已保存的服务商凭据核验,再通过带版本保护的 Gateway patch 仅注册该型号,并等待应用完成后切换;不重启、不推理、不修改密钥、不放宽模型权限策略。默认选择器仅改变默认模型,聊天选择器仅改变当前会话。同一连接仅修改模型并保存,也使用热注册路径,不改变默认或会话模型;真正修改 API Key 或地址仍走连接更新流程。
+
+每个托管提供商表单最后保存的型号会单独记在桌面偏好中,重开表单不会跳回第一个已注册型号;此偏好不改变默认模型或任何会话。分组选择器通过真实 Element Plus 弹层提供带名称的对话框和展开状态,不增加重复的可见标题。
+
+聊天输入框旁的模型按钮支持搜索完整云端实时目录、已保存的自定义连接,以及 **Qwen3.5 4B Q4_K_M**、**Qwen3.5 9B Q4_K_M** 两个托管本地预设,通过 OpenClaw 会话覆盖设置**只切换当前会话**。新建对话的会话标识会明确携带所选智能体的归属(包括主智能体),多智能体配置下也可在第一条消息发送前选模。“使用智能体默认模型”仅清除此会话覆盖,不修改智能体或全局默认值、回退模型及其他会话。首次选择会展示精确模型大小、缺少的嵌入模型、运行时预留磁盘及 CPU/GPU 模式,明确确认后才下载,准备成功后才切换;失败或取消保留原模型。已准备的模型直接切换,在原选模控件显示“切换中…”,不再闪现准备弹窗,也不运行设置或 AI 测试。需要准备、发生错误或恢复中断操作时,仍保留可阅读和操作的弹窗。
聊天选模菜单和 **设置 → 模型 → 默认模型** 共用可折叠的提供商分组,默认只展开各自当前所选模型所属的提供商。点击分组标题或使用 Enter、空格键即可展开/收起;搜索自动展开匹配分组,清空后恢复之前的折叠状态。折叠仅改变列表显示,不切换模型、不改配置、不触发下载。设置中的选择仍修改全局默认模型,聊天中的选择仍只影响当前会话。
@@ -106,6 +116,10 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要
会话选模结果不明时,聊天选模菜单和模型设置中会提供恢复入口,避免所有对话一直被锁住。请求已明确结束,或原受管网关进程已确认停止时,可以通过重新读取状态恢复;否则需明确确认“重启网关并恢复”,确认原进程停止后接受已保存的状态,不重发切换请求、不回滚、不删除历史。仅 WebSocket 重连或模型值碰巧相同,不能证明旧写入已结束。普通模型切换仍不重启网关。
+可选的**真实服务商/Gateway**冒烟测试(不是桌面 UI 测试):将 `MICROCLAW_TEST_OPENCLAW_ENTRY` 指向已安装的 `openclaw.mjs`,将 `MICROCLAW_TEST_LIVE_QWEN_CONFIG` 指向包含已授权千问 Key 的配置,然后在 `desktop` 运行 `npx vitest run src\provider-model-live.integration.test.ts`。测试使用隔离临时状态切换默认/会话模型,向 Qwen3.8 Max 和 Flash 发出简短真实请求,配置 512 token 输出预算。它可能消耗额度或产生服务商费用,未同时设置两个变量时跳过;不会改写传入配置,也不会操作桌面窗口或键鼠。
+
+不打断桌面的自动化可在启动安装器或客户端时设置仅限该进程的环境变量 `MICROCLAW_BACKGROUND=1`:安装器不激活窗口,客户端启动使用 `showInactive`,并保持渲染器后台计时器可用。正常启动行为不变。此模式不会关闭沙箱,也不授权控制用户键鼠;自动化应使用应用内部操作,并确认没有激活窗口。安装器窗口标题为 **MicroClaw Setup**,与客户端明确区分。
+
安装器会准备固定版本的官方 llama.cpp provider,但不下载模型权重和推理运行时。安装前会披露组件及其声明能力,并保留用户明确禁用的插件策略。组件安装并启用后,本地卡片即显示“已连接”,不取决于模型是否下载;它表示提供商可用,不是推理健康检查。旧安装可在本地设置中明确审阅并启用组件。
OpenClaw 固定版本未变化时,桌面更新只刷新托管文件,不在轻量回滚快照之外重复迁移数据库。运行时升级仍执行完整迁移;两种路径都必须等待安装后的桌面与 Gateway 就绪,才会提交安装。
@@ -117,7 +131,7 @@ OpenClaw 固定版本未变化时,桌面更新只刷新托管文件,不在
- **托管模型**:4B 排在前面作为轻量选择,9B 资源要求更高,CPU 推理可能较慢。选 4B 就只准备 4B,不会自动换成 9B。OpenClaw 继续负责硬件准入、下载哈希、运行时安装和取消。MicroClaw 对指定 2026.9.3 provider 应用版本/源文件哈希固定的内存适配,为官方向导增加精确选择并保留已有模型路由;源码或确认回执不匹配时明确拒绝,不修改已安装的 OpenClaw 文件。
- **连接已有服务**:展开“高级选项与说明”,接入自行运行在 `localhost`、`127.0.0.1` 或 `::1` 的 llama-server,仅在服务要求时填写密钥。MicroClaw 不接管或停止该服务。
- 切换模型会保留云端及本地连接,不新增自动云端兜底。已有 OpenClaw fallback、agent 或会话模型覆盖仍需单独检查。本地推理不代表联网技能可以离线运行。
-- 本地向导未结束时仍可打开云端设置。只有实际保存或切换模型时,才会询问是否先取消冲突的本地配置;拒绝后保持原状。未配置的目录项仅限这两个固定本地预设,其他已有连接仍保留;本地连接管理放在高级选项中。
+- 本地向导未结束时仍可打开云端设置。只有实际保存或切换模型时,才会询问是否先取消冲突的本地配置;拒绝后保持原状。本地模型未配置的目录项仅限这两个固定预设,其他已有连接仍保留;本地连接管理放在高级选项中。
- 集成会检查 Gateway 的公开设置接口,支持 OpenClaw 2026.10.2 或 2026.9.x 中的 2026.9.3 及以上版本。内置官方目录没有该插件时,MicroClaw 核对 ClawHub 官方包身份并请求固定版本 `@openclaw/llama-cpp-provider@2026.9.3`,不追随浮动最新版。下载、运行时依赖与 GPU 支持仍以官方插件为准。
- 实际配置写入仍受 Windows Node + MXC、当前回复和未结束配置操作的保护。准备本地模型本身不再阻止已有聊天,真正应用模型变更时仍会保护当前请求。断线后恢复原操作,不重复启动;OpenClaw 正在完成受保护步骤时可能暂不接受取消。
diff --git a/deployer/webview_bridge.py b/deployer/webview_bridge.py
index e5216a7..1a24296 100644
--- a/deployer/webview_bridge.py
+++ b/deployer/webview_bridge.py
@@ -174,7 +174,7 @@ class MONITORINFO(ctypes.Structure):
except Exception:
pass
- user32.SetWindowPos(hwnd, 0, x, y, 0, 0, 0x0001 | 0x0004)
+ user32.SetWindowPos(hwnd, 0, x, y, 0, 0, 0x0001 | 0x0004 | 0x0010)
def _normalize_lang(lang):
@@ -1144,8 +1144,9 @@ def run_web_installer(mode="install"):
else INSTALLER_WINDOW_HEIGHT
)
x, y = _get_centered_window_position(width, height)
+ window_title = "MicroClaw Uninstall" if is_uninstall_confirmation else "MicroClaw Setup"
window = webview.create_window(
- "MicroClaw",
+ window_title,
url=html_path.as_uri(),
js_api=bridge,
width=width,
@@ -1156,6 +1157,7 @@ def run_web_installer(mode="install"):
text_select=False,
frameless=True,
transparent=True,
+ focus=os.environ.get("MICROCLAW_BACKGROUND") != "1",
)
bridge.attach_window(window)
@@ -1174,7 +1176,7 @@ def _clip():
time.sleep(0.1)
# Retrieve the native window handle (HWND)
- hwnd = ctypes.windll.user32.FindWindowW(None, "MicroClaw")
+ hwnd = ctypes.windll.user32.FindWindowW(None, window_title)
if not hwnd:
return
diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts
index e45a64e..645d924 100644
--- a/desktop/renderer/env.d.ts
+++ b/desktop/renderer/env.d.ts
@@ -22,6 +22,7 @@ interface AppSettings {
minimizeToTray: boolean;
themeMode: string;
accentColor: string;
+ modelFormSelections?: Record;
privacyLevel: "basic" | "strict";
privacyControls?: {
phone: boolean;
@@ -306,6 +307,12 @@ interface OpenClawAPI {
): Promise<{ modelRef: string }>;
session: import("../src/session-model-types").SessionModelAPI;
local: import("../src/local-model-types").LocalModelAPI;
+ discoverModels(
+ params: import("../src/provider-model-catalog").ProviderCatalogInput,
+ ): Promise;
+ registerCatalogModel(
+ params: import("../src/provider-model-registration").ProviderCatalogSelection,
+ ): Promise;
testConnection(params: {
baseUrl: string;
apiKey: string;
diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts
index 4ecfea9..8590a04 100644
--- a/desktop/renderer/src/browser-openclaw.ts
+++ b/desktop/renderer/src/browser-openclaw.ts
@@ -278,6 +278,10 @@ export function createBrowserOpenClawMock(): OpenClawAPI {
}),
onEvent: noopSub,
},
+ discoverModels: async () => ({ ok: false, error: "unavailable" }),
+ registerCatalogModel: async () => {
+ throw new Error("Model registration requires the desktop Gateway");
+ },
testConnection: async () => ({ ok: true, message: "Connection successful" }),
prepareGitHubCopilot: async () => ({ restartRequired: false }),
startGitHubCopilotLogin: async () => ({ sessionId: "browser-dev" }),
diff --git a/desktop/renderer/src/components/DefaultModelPicker.test.ts b/desktop/renderer/src/components/DefaultModelPicker.test.ts
index e4552e5..0d83e14 100644
--- a/desktop/renderer/src/components/DefaultModelPicker.test.ts
+++ b/desktop/renderer/src/components/DefaultModelPicker.test.ts
@@ -1,6 +1,7 @@
import { defineComponent } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ElPopover } from "element-plus";
import { setLocale } from "@/i18n";
import DefaultModelPicker from "./DefaultModelPicker.vue";
import type { ConfiguredModel } from "@/utils/configured-models";
@@ -22,7 +23,7 @@ afterEach(() => {
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
document.body.replaceChildren();
});
-async function open() {
+async function open(realPopover = false) {
const wrapper = mount(DefaultModelPicker, {
attachTo: document.body,
props: {
@@ -30,13 +31,53 @@ async function open() {
modelValue: "github-copilot/gpt-5.5",
modelStatus: (model) => (model.providerKey === "llama-cpp" ? "Not downloaded" : ""),
},
- global: { stubs: { "el-popover": PopoverStub } },
+ global: {
+ components: { ElPopover },
+ stubs: { "el-popover": realPopover ? false : PopoverStub },
+ },
});
wrappers.push(wrapper);
await wrapper.get('[data-testid="default-model-picker"]').trigger("click");
return wrapper;
}
describe("DefaultModelPicker", () => {
+ it("keeps expanded state and a named dialog with the real Element Plus popover", async () => {
+ const wrapper = await open(true);
+ const trigger = wrapper.get('[data-testid="default-model-picker"]');
+ await vi.waitFor(() => expect(trigger.attributes("aria-expanded")).toBe("true"));
+ expect(trigger.attributes("aria-haspopup")).toBe("dialog");
+ const dialogId = trigger.attributes("aria-controls");
+ expect(dialogId).toBeTruthy();
+ const dialog = dialogId ? document.getElementById(dialogId) : null;
+ expect(dialog?.getAttribute("role")).toBe("dialog");
+ expect(dialog?.getAttribute("aria-label")).toBe("Default model");
+ });
+
+ it("keeps specialized models visible but blocks incompatible default selection", async () => {
+ const wrapper = await open();
+ await wrapper.setProps({
+ modelValue: "qwen/qwen3.7-plus",
+ models: [
+ { providerKey: "qwen", id: "qwen3.7-plus", name: "Plus", source: "managed" },
+ {
+ providerKey: "qwen",
+ id: "qwen3.8-omni-flash-realtime",
+ name: "Realtime",
+ source: "managed",
+ },
+ ],
+ });
+ expect(wrapper.findAll("[data-model-ref]")).toHaveLength(2);
+ const incompatible = wrapper.get('[data-model-ref="qwen/qwen3.8-omni-flash-realtime"]');
+ expect(incompatible.attributes("disabled")).toBeDefined();
+ expect(incompatible.text()).toContain("Requires realtime API");
+ await incompatible.trigger("click");
+ expect(wrapper.emitted("change")).toBeUndefined();
+ expect(
+ wrapper.get('[data-model-ref="qwen/qwen3.7-plus"]').attributes("disabled"),
+ ).toBeUndefined();
+ });
+
it("shows switching in the trigger without changing the selected model before confirmation", async () => {
const wrapper = await open();
await wrapper.setProps({ switching: true });
diff --git a/desktop/renderer/src/components/DefaultModelPicker.vue b/desktop/renderer/src/components/DefaultModelPicker.vue
index b99b533..df3143d 100644
--- a/desktop/renderer/src/components/DefaultModelPicker.vue
+++ b/desktop/renderer/src/components/DefaultModelPicker.vue
@@ -3,6 +3,9 @@
{{ model.name }}
- {{ modelStatus(model) }}
+
+ {{ restrictionLabel(model) || modelStatus?.(model) }}
+ ✓
@@ -63,6 +66,7 @@
import { computed, ref, useId, watch } from "vue";
import { t } from "@/i18n";
import { getModelRef, providerName, type ConfiguredModel } from "@/utils/configured-models";
+import { getChatModelRestriction } from "../../../src/provider-model-capabilities";
const props = defineProps<{
models: ConfiguredModel[];
@@ -105,7 +109,12 @@ function toggle(provider: string): void {
expansion.set(provider, !isExpanded(provider));
}
function select(model: ConfiguredModel): void {
- if (!props.disabled && !props.isModelDisabled?.(model)) emit("select", getModelRef(model));
+ if (!props.disabled && !props.isModelDisabled?.(model) && !restrictionLabel(model))
+ emit("select", getModelRef(model));
+}
+function restrictionLabel(model: ConfiguredModel): string {
+ const restriction = getChatModelRestriction(getModelRef(model));
+ return restriction ? t(`modelSetup.incompatible.${restriction}`) : "";
}
watch(search, () => searchExpandedGroups.value.clear(), { flush: "sync" });
watch(
@@ -118,6 +127,11 @@ watch(