jishushell 0.4.30 → 0.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/Dockerfile.hermes-slim +2 -5
  2. package/apps/anythingllm-container.yaml +287 -0
  3. package/apps/browserless-chromium-container.yaml +18 -6
  4. package/apps/filebrowser-container.yaml +164 -0
  5. package/apps/ollama-binary.yaml +44 -0
  6. package/apps/ollama-with-hollama-binary.yaml +45 -1
  7. package/apps/openclaw-binary.yaml +8 -0
  8. package/apps/openclaw-container.yaml +9 -1
  9. package/apps/openclaw-with-searxng-container.yaml +4 -0
  10. package/apps/searxng-container.yaml +5 -4
  11. package/apps/weknora-container.yaml +471 -0
  12. package/dist/cli/doctor.js +144 -16
  13. package/dist/cli/doctor.js.map +1 -1
  14. package/dist/cli/panel.js.map +1 -1
  15. package/dist/config.d.ts +19 -0
  16. package/dist/config.js +99 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/install.js +4 -4
  19. package/dist/install.js.map +1 -1
  20. package/dist/routes/auth.js +2 -2
  21. package/dist/routes/auth.js.map +1 -1
  22. package/dist/routes/backup.js +64 -11
  23. package/dist/routes/backup.js.map +1 -1
  24. package/dist/routes/external-mounts.d.ts +17 -0
  25. package/dist/routes/external-mounts.js +73 -0
  26. package/dist/routes/external-mounts.js.map +1 -0
  27. package/dist/routes/file-mounts.d.ts +13 -0
  28. package/dist/routes/file-mounts.js +90 -0
  29. package/dist/routes/file-mounts.js.map +1 -0
  30. package/dist/routes/files-organize.d.ts +28 -0
  31. package/dist/routes/files-organize.js +167 -0
  32. package/dist/routes/files-organize.js.map +1 -0
  33. package/dist/routes/files.d.ts +31 -0
  34. package/dist/routes/files.js +321 -0
  35. package/dist/routes/files.js.map +1 -0
  36. package/dist/routes/instances.js +87 -12
  37. package/dist/routes/instances.js.map +1 -1
  38. package/dist/routes/internal.d.ts +2 -0
  39. package/dist/routes/internal.js +59 -0
  40. package/dist/routes/internal.js.map +1 -0
  41. package/dist/routes/llm.js +29 -0
  42. package/dist/routes/llm.js.map +1 -1
  43. package/dist/routes/setup.js +9 -9
  44. package/dist/routes/setup.js.map +1 -1
  45. package/dist/routes/system.js +1 -1
  46. package/dist/routes/system.js.map +1 -1
  47. package/dist/routes/webdav.d.ts +17 -0
  48. package/dist/routes/webdav.js +114 -0
  49. package/dist/routes/webdav.js.map +1 -0
  50. package/dist/server.js +358 -6
  51. package/dist/server.js.map +1 -1
  52. package/dist/services/agent-apps/catalog.d.ts +3 -0
  53. package/dist/services/agent-apps/catalog.js +40 -13
  54. package/dist/services/agent-apps/catalog.js.map +1 -1
  55. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  56. package/dist/services/agent-apps/installers/shell-script.js +19 -2
  57. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  58. package/dist/services/agent-apps/types.d.ts +3 -0
  59. package/dist/services/app/app-compiler.d.ts +1 -1
  60. package/dist/services/app/app-compiler.js +5 -5
  61. package/dist/services/app/app-compiler.js.map +1 -1
  62. package/dist/services/app/app-manager.d.ts +9 -0
  63. package/dist/services/app/app-manager.js +248 -43
  64. package/dist/services/app/app-manager.js.map +1 -1
  65. package/dist/services/app/custom-manager.js.map +1 -1
  66. package/dist/services/app/hermes-agent-manager.js +1 -0
  67. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  68. package/dist/services/app/ollama-manager.js +1 -1
  69. package/dist/services/app/ollama-manager.js.map +1 -1
  70. package/dist/services/app/openclaw-manager.js +37 -5
  71. package/dist/services/app/openclaw-manager.js.map +1 -1
  72. package/dist/services/app/platform-transform.d.ts +32 -0
  73. package/dist/services/app/platform-transform.js +65 -0
  74. package/dist/services/app/platform-transform.js.map +1 -0
  75. package/dist/services/app-passwords.d.ts +61 -0
  76. package/dist/services/app-passwords.js +173 -0
  77. package/dist/services/app-passwords.js.map +1 -0
  78. package/dist/services/backup-manager.d.ts +11 -0
  79. package/dist/services/backup-manager.js +220 -8
  80. package/dist/services/backup-manager.js.map +1 -1
  81. package/dist/services/capability-endpoint-validator.js +26 -7
  82. package/dist/services/capability-endpoint-validator.js.map +1 -1
  83. package/dist/services/connection-apply.d.ts +2 -0
  84. package/dist/services/connection-apply.js +55 -1
  85. package/dist/services/connection-apply.js.map +1 -1
  86. package/dist/services/connection-resolver.js +1 -1
  87. package/dist/services/connection-resolver.js.map +1 -1
  88. package/dist/services/connection-transactor.d.ts +2 -0
  89. package/dist/services/connection-transactor.js +12 -2
  90. package/dist/services/connection-transactor.js.map +1 -1
  91. package/dist/services/external-mounts.d.ts +40 -0
  92. package/dist/services/external-mounts.js +187 -0
  93. package/dist/services/external-mounts.js.map +1 -0
  94. package/dist/services/files-manager.d.ts +252 -0
  95. package/dist/services/files-manager.js +1075 -0
  96. package/dist/services/files-manager.js.map +1 -0
  97. package/dist/services/files-mounts.d.ts +42 -0
  98. package/dist/services/files-mounts.js +207 -0
  99. package/dist/services/files-mounts.js.map +1 -0
  100. package/dist/services/instance-manager.js +90 -32
  101. package/dist/services/instance-manager.js.map +1 -1
  102. package/dist/services/llm-proxy/index.d.ts +28 -0
  103. package/dist/services/llm-proxy/index.js +76 -3
  104. package/dist/services/llm-proxy/index.js.map +1 -1
  105. package/dist/services/llm-proxy/ssrf.js +6 -2
  106. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  107. package/dist/services/llm-proxy/validate-key.d.ts +41 -0
  108. package/dist/services/llm-proxy/validate-key.js +672 -0
  109. package/dist/services/llm-proxy/validate-key.js.map +1 -0
  110. package/dist/services/macos-launchd.d.ts +89 -0
  111. package/dist/services/macos-launchd.js +273 -0
  112. package/dist/services/macos-launchd.js.map +1 -0
  113. package/dist/services/nomad-manager.d.ts +11 -0
  114. package/dist/services/nomad-manager.js +343 -98
  115. package/dist/services/nomad-manager.js.map +1 -1
  116. package/dist/services/organize/applier.d.ts +46 -0
  117. package/dist/services/organize/applier.js +218 -0
  118. package/dist/services/organize/applier.js.map +1 -0
  119. package/dist/services/organize/rules.d.ts +57 -0
  120. package/dist/services/organize/rules.js +286 -0
  121. package/dist/services/organize/rules.js.map +1 -0
  122. package/dist/services/organize/scanner.d.ts +50 -0
  123. package/dist/services/organize/scanner.js +366 -0
  124. package/dist/services/organize/scanner.js.map +1 -0
  125. package/dist/services/organize/store.d.ts +14 -0
  126. package/dist/services/organize/store.js +82 -0
  127. package/dist/services/organize/store.js.map +1 -0
  128. package/dist/services/panel-manager.js +40 -11
  129. package/dist/services/panel-manager.js.map +1 -1
  130. package/dist/services/process-manager.js +3 -2
  131. package/dist/services/process-manager.js.map +1 -1
  132. package/dist/services/runtime/adapters/custom.js +56 -0
  133. package/dist/services/runtime/adapters/custom.js.map +1 -1
  134. package/dist/services/runtime/adapters/hermes.d.ts +4 -3
  135. package/dist/services/runtime/adapters/hermes.js +166 -64
  136. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  137. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  138. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  139. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  140. package/dist/services/runtime/adapters/openclaw.d.ts +118 -0
  141. package/dist/services/runtime/adapters/openclaw.js +1459 -49
  142. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  143. package/dist/services/runtime/instance.d.ts +1 -1
  144. package/dist/services/runtime/instance.js +1 -1
  145. package/dist/services/runtime/instance.js.map +1 -1
  146. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  147. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  148. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  149. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  150. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  151. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  152. package/dist/services/runtime/types.d.ts +31 -0
  153. package/dist/services/setup-manager.js +190 -68
  154. package/dist/services/setup-manager.js.map +1 -1
  155. package/dist/services/suggestions.js.map +1 -1
  156. package/dist/services/update-manager.js +32 -14
  157. package/dist/services/update-manager.js.map +1 -1
  158. package/dist/services/webdav/server.d.ts +24 -0
  159. package/dist/services/webdav/server.js +420 -0
  160. package/dist/services/webdav/server.js.map +1 -0
  161. package/dist/services/webdav/xml-builder.d.ts +73 -0
  162. package/dist/services/webdav/xml-builder.js +156 -0
  163. package/dist/services/webdav/xml-builder.js.map +1 -0
  164. package/dist/services/workspace-builder.d.ts +29 -0
  165. package/dist/services/workspace-builder.js +188 -0
  166. package/dist/services/workspace-builder.js.map +1 -0
  167. package/dist/types.d.ts +61 -0
  168. package/dist/utils/path-locks.d.ts +30 -0
  169. package/dist/utils/path-locks.js +63 -0
  170. package/dist/utils/path-locks.js.map +1 -0
  171. package/dist/utils/path-safety.d.ts +41 -0
  172. package/dist/utils/path-safety.js +119 -0
  173. package/dist/utils/path-safety.js.map +1 -0
  174. package/dist/utils/safe-write.d.ts +24 -0
  175. package/dist/utils/safe-write.js +82 -0
  176. package/dist/utils/safe-write.js.map +1 -0
  177. package/install/jishu-install.sh +247 -35
  178. package/install/jishu-uninstall.sh +45 -5
  179. package/package.json +20 -2
  180. package/public/assets/ApiKeyField-CvyAOcJS.js +1 -0
  181. package/public/assets/Dashboard-AuJESBlJ.js +1 -0
  182. package/public/assets/{HermesChatPanel-_GHoklgo.js → HermesChatPanel-CByPREwb.js} +1 -1
  183. package/public/assets/HermesConfigForm-DRda8FKX.js +4 -0
  184. package/public/assets/InitPassword-ka4wNpM5.js +1 -0
  185. package/public/assets/InstanceDetail-Cg1nS8HX.js +92 -0
  186. package/public/assets/Login-aPajuQzf.js +1 -0
  187. package/public/assets/NewInstance-Dd1ebNIx.js +1 -0
  188. package/public/assets/ProviderRecommendations-DFmADQ7V.js +1 -0
  189. package/public/assets/Settings-BYQnbLYL.js +1 -0
  190. package/public/assets/Setup-D05lwDOV.js +1 -0
  191. package/public/assets/WeixinLoginPanel-D89kdhP4.js +9 -0
  192. package/public/assets/index-HSXCsceK.css +1 -0
  193. package/public/assets/index-bnBu0nlQ.js +19 -0
  194. package/public/assets/registry-C_qeFTkZ.js +2 -0
  195. package/public/assets/usePolling-Bn93fe7M.js +1 -0
  196. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-flxcMVeP.js} +2 -2
  197. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-ZC5T_huj.js} +7 -7
  198. package/public/index.html +4 -4
  199. package/scripts/check-app-spec.mjs +18 -4
  200. package/scripts/check-colima-launchd.mjs +230 -0
  201. package/scripts/check-new-file-tests.mjs +230 -0
  202. package/scripts/check-quarantine-expiry.mjs +105 -0
  203. package/scripts/perf/README.md +49 -0
  204. package/scripts/perf/auth.js +99 -0
  205. package/scripts/perf/config.js +63 -0
  206. package/scripts/perf/instances.js +143 -0
  207. package/scripts/perf/proxy.js +96 -0
  208. package/scripts/smoke/files-w1.sh +142 -0
  209. package/scripts/smoke-backend.mjs +122 -0
  210. package/scripts/smoke-post-publish.mjs +346 -0
  211. package/public/assets/Dashboard-rkWp-CXd.js +0 -1
  212. package/public/assets/HermesConfigForm-anDnwUp_.js +0 -4
  213. package/public/assets/InitPassword-ZU9_-hDr.js +0 -1
  214. package/public/assets/InstanceDetail-CN0FH1aw.js +0 -92
  215. package/public/assets/Login-BItXqYAJ.js +0 -1
  216. package/public/assets/NewInstance-BousE6kY.js +0 -1
  217. package/public/assets/ProviderRecommendations-DFYj7Fb6.js +0 -1
  218. package/public/assets/Settings-Bttc6QmM.js +0 -1
  219. package/public/assets/Setup-Bsxx1zgj.js +0 -1
  220. package/public/assets/WeixinLoginPanel-DPZpAKgO.js +0 -9
  221. package/public/assets/index-8xZy1z5k.css +0 -1
  222. package/public/assets/index-Dw3HhUYE.js +0 -19
  223. package/public/assets/input-paste-CrNVAyOy.js +0 -1
  224. package/public/assets/providers-DtNXh9JD.js +0 -1
  225. package/public/assets/registry-5s2UB6is.js +0 -2
  226. package/public/assets/usePolling-Do5Erqm_.js +0 -1
@@ -34,10 +34,10 @@
34
34
  * 4. Done. No `instance-manager.ts` / `nomad-manager.ts` / routes edits.
35
35
  */
36
36
  import { execFile, execFileSync } from "child_process";
37
- import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, } from "fs";
38
- import { randomBytes } from "crypto";
37
+ import { accessSync, chmodSync, chownSync, copyFileSync, cpSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "fs";
38
+ import { createHash, randomBytes } from "crypto";
39
39
  import { homedir, userInfo } from "os";
40
- import { dirname, join, resolve as pathResolve } from "path";
40
+ import { delimiter, dirname, join, resolve as pathResolve } from "path";
41
41
  import { getNomadDriver, getOpenclawDockerImage, JISHUSHELL_HOME, getPanelConfig, } from "../../../config.js";
42
42
  import { LEGACY_PROVIDER_API_ALIASES } from "../../../constants.js";
43
43
  import { ensureDirContainer, ensureDirHost, writeConfigFile } from "../../../utils/fs.js";
@@ -48,7 +48,7 @@ import { getInstanceDir as framework_instanceDir, instanceMetaPath } from "../..
48
48
  import { createTask, emitTask, spawnWithTask, getDirSizeMB, npmProgressParser, dockerBuildProgressParser, resolveDockerInvocation, } from "../../setup-manager.js";
49
49
  import { DEFAULT_OPENCLAW_DOCKER_IMAGE, setOpenclawDockerImage, OPENCLAW_MODULES, OPENCLAW_PKG_DIR, } from "../../../config.js";
50
50
  import { fileURLToPath } from "node:url";
51
- import { bootstrapInstanceProxy } from "../../llm-proxy/index.js";
51
+ import { bootstrapInstanceProxy, decryptApiKey, getInstanceConfig as getProxyInstanceConfig } from "../../llm-proxy/index.js";
52
52
  import { registerAdapter } from "../registry.js";
53
53
  import { registerOpenclawRoutes } from "./openclaw-routes.js";
54
54
  // ── Constants physically migrated from nomad-manager.ts ───────────────
@@ -114,6 +114,55 @@ const DEFAULT_CAPABILITIES = {
114
114
  restartlessReload: false,
115
115
  messagingPlatforms: ["feishu", "openclaw-weixin"],
116
116
  };
117
+ import { FILES_ROOT } from "../../../config.js";
118
+ import { defaultMountsForNewInstance, ensureMountTargets, } from "../../files-mounts.js";
119
+ /**
120
+ * Tolerate both `fileMounts` and `file_mounts` in instance.json — some
121
+ * earlier migrations may have written snake_case.
122
+ */
123
+ function readFileMounts(runtime) {
124
+ const raw = runtime.fileMounts ?? runtime.file_mounts;
125
+ return Array.isArray(raw) ? raw : [];
126
+ }
127
+ /**
128
+ * Build the volume list for the docker driver:
129
+ * 1. The existing openclaw-home self-mount (HOME == container HOME)
130
+ * 2. The root FILES_ROOT bind when any mount has empty path
131
+ * ({"path":"","alias":"","mode":"rw"} — the default for new
132
+ * instances). Without this, in-container plugins (Feishu / WeChat
133
+ * send_file) try `fs.open("/home/.../files/...")` and hit ENOENT
134
+ * because the host path isn't bind-mounted. Verified on pi2
135
+ * 2026-05-11: claw1's drive_resolve_local_path returned a valid
136
+ * abs_path; feishu_im_user_message then ENOENT'd on it.
137
+ * 3. One additional bind per non-root FileMount, host==container so
138
+ * the workspace/{alias} symlink (placed by rebuildWorkspace)
139
+ * resolves identically inside the container.
140
+ *
141
+ * Mode "ro" is enforced by the docker bind option; raw_exec / process
142
+ * modes don't go through this path (the agent runs natively on the host
143
+ * and reads files directly).
144
+ */
145
+ function buildVolumes(openclawHome, runtime) {
146
+ const list = [`${openclawHome}:${openclawHome}:rw`];
147
+ let rootBound = false;
148
+ for (const m of readFileMounts(runtime)) {
149
+ if (!m)
150
+ continue;
151
+ const mode = m.mode === "ro" ? "ro" : "rw";
152
+ if (!m.path) {
153
+ if (rootBound)
154
+ continue;
155
+ list.push(`${FILES_ROOT}:${FILES_ROOT}:${mode}`);
156
+ rootBound = true;
157
+ continue;
158
+ }
159
+ if (rootBound)
160
+ continue; // root already covers every subtree
161
+ const abs = join(FILES_ROOT, m.path);
162
+ list.push(`${abs}:${abs}:${mode}`);
163
+ }
164
+ return list;
165
+ }
117
166
  // ── Path helpers (physically migrated from instance-manager.ts) ───────
118
167
  const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
119
168
  const INSTANCE_MODEL_ENV_FILENAME = "model.env";
@@ -176,7 +225,32 @@ function resolveOpenclawBin() {
176
225
  return p;
177
226
  }
178
227
  }
179
- return candidates[0]; // fallback, will fail with clear error at spawn
228
+ // Fallback: scan $PATH for user-installed openclaw (nvm, homebrew, etc.)
229
+ // Uses in-process lookup instead of spawning `which` for security and performance.
230
+ const fromPath = findExecutableOnPath("openclaw");
231
+ if (fromPath)
232
+ return fromPath;
233
+ return candidates[0]; // will fail with clear error at spawn
234
+ }
235
+ /**
236
+ * Search $PATH for an executable by name. Returns the first match or null.
237
+ * Does not chmod the result — PATH-discovered binaries are not managed by JishuShell.
238
+ */
239
+ function findExecutableOnPath(name) {
240
+ const pathEnv = process.env.PATH || "";
241
+ for (const dir of pathEnv.split(delimiter)) {
242
+ if (!dir)
243
+ continue;
244
+ const candidate = join(dir, name);
245
+ try {
246
+ accessSync(candidate, constants.X_OK);
247
+ return candidate;
248
+ }
249
+ catch {
250
+ /* not found or not executable in this dir */
251
+ }
252
+ }
253
+ return null;
180
254
  }
181
255
  function buildDefaultRuntime(instanceId, port, openclawHome) {
182
256
  const home = openclawHome || defaultOpenclawHome(instanceId);
@@ -189,6 +263,12 @@ function buildDefaultRuntime(instanceId, port, openclawHome) {
189
263
  env: {
190
264
  OPENCLAW_GATEWAY_PORT: String(port),
191
265
  NODE_OPTIONS: "--max-old-space-size=2048",
266
+ // Let mcporter find its config regardless of the agent's CWD. The
267
+ // gateway's CWD is openclaw-home (no config/), and `cd workspace`
268
+ // means the user-files symlink (also no config/). Without this env,
269
+ // every `mcporter call drive.*` fails with "Unknown MCP server 'drive'"
270
+ // and the agent reports a generic "network error" to the user.
271
+ MCPORTER_CONFIG: `${home}/.openclaw/workspace/config/mcporter.json`,
192
272
  },
193
273
  resources: { CPU: 1000, MemoryMB: 2048 },
194
274
  };
@@ -260,6 +340,65 @@ function patchJsproxyBaseUrl(configPath) {
260
340
  console.warn(`[openclaw] Failed to patch jsproxy baseUrl in ${configPath}: ${e.message}`);
261
341
  }
262
342
  }
343
+ /** Known private/internal hostnames used by JishuShell's local proxy. */
344
+ const PRIVATE_NETWORK_HOSTS = ["host.docker.internal", "127.0.0.1", "localhost", "0.0.0.0"];
345
+ export function isPrivateNetworkBaseUrl(baseUrl) {
346
+ try {
347
+ const url = new URL(baseUrl);
348
+ if (PRIVATE_NETWORK_HOSTS.includes(url.hostname))
349
+ return true;
350
+ if (url.hostname.startsWith("10."))
351
+ return true;
352
+ if (url.hostname.startsWith("192.168."))
353
+ return true;
354
+ // RFC 1918: 172.16.0.0/12 → 172.16.x.x through 172.31.x.x
355
+ const m = url.hostname.match(/^172\.(\d+)\./);
356
+ if (m) {
357
+ const second = parseInt(m[1], 10);
358
+ if (second >= 16 && second <= 31)
359
+ return true;
360
+ }
361
+ return false;
362
+ }
363
+ catch {
364
+ return false;
365
+ }
366
+ }
367
+ /**
368
+ * Ensure `request.allowPrivateNetwork: true` is set on providers whose baseUrl
369
+ * targets a private/internal host (e.g. host.docker.internal). Without this,
370
+ * OpenClaw's SSRF guard blocks requests to the JishuShell local proxy.
371
+ */
372
+ export function patchPrivateNetworkAllowFlag(configPath) {
373
+ try {
374
+ const raw = readFileSync(configPath, "utf-8");
375
+ const config = JSON.parse(raw);
376
+ const providers = config?.models?.providers;
377
+ if (!providers || typeof providers !== "object")
378
+ return;
379
+ let changed = false;
380
+ for (const [, provider] of Object.entries(providers)) {
381
+ if (typeof provider !== "object" || provider === null)
382
+ continue;
383
+ const p = provider;
384
+ if (typeof p.baseUrl !== "string")
385
+ continue;
386
+ if (!isPrivateNetworkBaseUrl(p.baseUrl))
387
+ continue;
388
+ if (p.request?.allowPrivateNetwork === true)
389
+ continue;
390
+ p.request = { ...(p.request || {}), allowPrivateNetwork: true };
391
+ changed = true;
392
+ }
393
+ if (changed) {
394
+ writeConfigFile(configPath, JSON.stringify(config, null, 2));
395
+ console.log(`[openclaw] Patched request.allowPrivateNetwork in ${configPath} for private-network providers`);
396
+ }
397
+ }
398
+ catch (e) {
399
+ console.warn(`[openclaw] Failed to patch allowPrivateNetwork in ${configPath}: ${e.message}`);
400
+ }
401
+ }
263
402
  /**
264
403
  * Docker bridge port publishing cannot reach a process that only binds the
265
404
  * container loopback. Normalize default/loopback gateway binds to `lan` so
@@ -435,6 +574,429 @@ export function clearSearxngConnectionFromConfig(configPath) {
435
574
  }
436
575
  safeWriteJson(configPath, existing);
437
576
  }
577
+ /** Profile name used in `browser.profiles` for jishushell-managed Browserless bindings. */
578
+ const BROWSERLESS_PROFILE = "browserless";
579
+ /**
580
+ * Default `color` for the jishushell-managed browserless profile. OpenClaw's
581
+ * profile schema treats `color` as required (the field shows up as the
582
+ * profile chip tint in the UI); omitting it triggers
583
+ * "browser.profiles.browserless.color: Invalid input: expected string,
584
+ * received undefined"
585
+ * on every config reload, which crashloops the gateway. Verified against
586
+ * the runtime image `ghcr.io/x-aijishu/openclaw-runtime:2026.4.15` on Pi 2
587
+ * (2026-05-07). The tone is Browserless brand green; users can override it
588
+ * once and we preserve their override on re-bind via the `prior` spread.
589
+ */
590
+ const BROWSERLESS_DEFAULT_COLOR = "#00AA66";
591
+ /**
592
+ * Deep-merge a Browserless CDP connection into an OpenClaw config file at
593
+ * `configPath`. Mirrors `applySearxngConnectionToConfig` for the browser slot.
594
+ *
595
+ * Writes:
596
+ * browser.profiles.browserless.cdpUrl = cdpUrl // ws:// or wss://
597
+ * browser.profiles.browserless.attachOnly = true // since v2026.3.2
598
+ * browser.profiles.browserless.color = "#00AA66" // required by schema
599
+ * browser.defaultProfile = "browserless" // only if unset
600
+ * browser.enabled = true // only if unset
601
+ * gateway.nodes.browser.mode = "off" // only if unset
602
+ *
603
+ * Why per-profile `attachOnly` and not global `browser.attachOnly`: global
604
+ * attachOnly forces every profile to skip launch — that breaks the user's
605
+ * other manually-configured profiles. Per-profile attachOnly was added in
606
+ * OpenClaw v2026.3.2; older versions silently ignore the extra field and
607
+ * fall back to attach-via-cdpUrl semantics (which `cdpUrl` triggers on its
608
+ * own from v2.0.0-beta5), so the schema is forward-compatible all the way
609
+ * back to the first remote-CDP release.
610
+ *
611
+ * Why `gateway.nodes.browser.mode = "off"`: in OpenClaw 2026.5.6 the
612
+ * gateway only registers the `browser.request` WS method when this config
613
+ * block exists. Without it, `openclaw browser …` CLI and the gateway
614
+ * canvas tool both fail with `unknown method: browser.request` or
615
+ * `node required`. Setting `mode: "off"` forces gateway-local CDP
616
+ * dispatch (use `cdpUrl` directly, never look for paired nodes), which
617
+ * matches jishushell's intent: Browserless is a service-style provider,
618
+ * not a node-style provider. Verified end-to-end on Pi 2 (2026-05-07):
619
+ * Browserless `/sessions` shows `numbConnected: 1` and `openclaw browser
620
+ * navigate <allowed-host>` succeeds + screenshots render correctly.
621
+ *
622
+ * `defaultProfile`, `enabled`, and `gateway.nodes.browser.mode` are all
623
+ * set only when absent so user-customized values survive a re-bind. The
624
+ * `enabled` flag is never flipped off — Browserless binding shouldn't
625
+ * override a user who explicitly disabled the browser tool tree.
626
+ *
627
+ * SSRF policy is intentionally NOT touched here: `browser.ssrfPolicy`
628
+ * (`dangerouslyAllowPrivateNetwork`, `allowedHostnames`) is a user-level
629
+ * security decision (default deny-all is correct for an LLM-driven
630
+ * browser); jishushell would be over-reaching to silently widen it on
631
+ * binding. Users who want the agent to reach a specific host configure
632
+ * the allowlist themselves.
633
+ *
634
+ * No-op when the config file is absent (instance not yet started).
635
+ */
636
+ export function applyBrowserlessConnectionToConfig(configPath, cdpUrl) {
637
+ if (!existsSync(configPath))
638
+ return;
639
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
640
+ const browser = (existing.browser ??= {});
641
+ if (browser.enabled === undefined)
642
+ browser.enabled = true;
643
+ if (!browser.defaultProfile)
644
+ browser.defaultProfile = BROWSERLESS_PROFILE;
645
+ const profiles = (browser.profiles ??= {});
646
+ const prior = profiles[BROWSERLESS_PROFILE] ?? {};
647
+ profiles[BROWSERLESS_PROFILE] = {
648
+ ...prior,
649
+ cdpUrl,
650
+ attachOnly: true,
651
+ color: typeof prior.color === "string" && prior.color ? prior.color : BROWSERLESS_DEFAULT_COLOR,
652
+ };
653
+ const gateway = (existing.gateway ??= {});
654
+ const nodes = (gateway.nodes ??= {});
655
+ const browserPolicy = (nodes.browser ??= {});
656
+ if (browserPolicy.mode === undefined)
657
+ browserPolicy.mode = "off";
658
+ safeWriteJson(configPath, existing);
659
+ }
660
+ /**
661
+ * Counterpart to `applyBrowserlessConnectionToConfig` — invoked when the user
662
+ * unbinds the BROWSER slot in the Connections tab. Removes the
663
+ * jishushell-managed `browserless` profile and clears `defaultProfile` only
664
+ * if it still points at that profile (so user-set defaults pointing at their
665
+ * own profiles survive). Other profiles and `browser.enabled` are left alone.
666
+ *
667
+ * No-op when the config file is absent.
668
+ */
669
+ export function clearBrowserlessConnectionFromConfig(configPath) {
670
+ if (!existsSync(configPath))
671
+ return;
672
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
673
+ const browser = existing?.browser;
674
+ if (!browser || typeof browser !== "object")
675
+ return;
676
+ if (browser.profiles && typeof browser.profiles === "object") {
677
+ delete browser.profiles[BROWSERLESS_PROFILE];
678
+ }
679
+ if (browser.defaultProfile === BROWSERLESS_PROFILE) {
680
+ delete browser.defaultProfile;
681
+ }
682
+ safeWriteJson(configPath, existing);
683
+ }
684
+ /**
685
+ * Bump when a panel-side change invalidates the agent's prior reasoning
686
+ * within an existing chat session — e.g., a mount fix that turns prior
687
+ * "I can't read this file" tool failures into stale conclusions. The
688
+ * first onBeforeStart after the bump rotates the instance's session
689
+ * jsonl files (`<file>.jsonl` → `<file>.jsonl.reset.<ts>`), so the next
690
+ * user message lands on a clean context window. Old transcripts are
691
+ * preserved as .reset.* siblings — never deleted, just archived.
692
+ *
693
+ * Concrete history:
694
+ * 2026.5.11.1 — buildVolumes root-mount fix: empty-path FileMount
695
+ * finally binds FILES_ROOT into docker containers. Prior
696
+ * sessions had agent conclude "drive only has metadata,
697
+ * no file content" after ENOENT — that reasoning is
698
+ * poisoned post-fix.
699
+ * 2026.5.11.2 — WeChat target-format rule in TOOLS.md: agent was
700
+ * extrapolating Feishu's `user:` prefix onto WeChat
701
+ * chat_ids that don't carry it, causing WeChat's
702
+ * getuploadurl to return ret:-1. Prior sessions need
703
+ * rotation so the agent re-reads the corrected target
704
+ * rule and stops adding the prefix.
705
+ *
706
+ * Format: YYYY.M.D.N (date + same-day bump counter). Compare as strings;
707
+ * any difference means rotate. Stored per-instance at
708
+ * `<instanceDir>/runtime-contract.txt`
709
+ */
710
+ const RUNTIME_CONTRACT_VERSION = "2026.5.11.2";
711
+ const JISHUSHELL_DRIVE_HINT_BEGIN = "<!-- jishushell-drive: BEGIN auto-generated -->";
712
+ const JISHUSHELL_DRIVE_HINT_END = "<!-- jishushell-drive: END -->";
713
+ const JISHUSHELL_KB_HINT_BEGIN = "<!-- jishushell-kb: BEGIN auto-generated -->";
714
+ const JISHUSHELL_KB_HINT_END = "<!-- jishushell-kb: END -->";
715
+ /**
716
+ * Rotate session jsonl files when the runtime contract version has bumped
717
+ * since this instance last started. Matches OpenClaw's own .reset.<ts>
718
+ * naming convention so its existing UI/cleanup paths still apply.
719
+ *
720
+ * Why this exists: when a panel upgrade fixes a runtime bug (e.g. the
721
+ * 2026-05-11 mount-bind fix), the agent's prior turn-by-turn reasoning
722
+ * inside an existing session is anchored to the broken behaviour. Even
723
+ * after the fix is deployed and the alloc restarted, the LLM keeps
724
+ * citing past tool failures and refusing to retry. Rotating the session
725
+ * jsonl(s) gives the next user message a clean context where the agent
726
+ * sees fresh TOOLS.md + fresh tool outputs.
727
+ *
728
+ * Side effect: the user loses chat scrollback in IM/web. Acceptable
729
+ * because (a) the rotated file is preserved on disk, (b) panel upgrades
730
+ * are infrequent, (c) the alternative — agent stuck in old reasoning —
731
+ * is worse UX.
732
+ */
733
+ function rotateSessionsIfContractChanged(instanceId, openclawHome) {
734
+ try {
735
+ const markerPath = join(framework_instanceDir(instanceId), "runtime-contract.txt");
736
+ let previous = "";
737
+ try {
738
+ previous = readFileSync(markerPath, "utf-8").trim();
739
+ }
740
+ catch (e) {
741
+ if (e?.code !== "ENOENT")
742
+ throw e;
743
+ }
744
+ if (previous === RUNTIME_CONTRACT_VERSION)
745
+ return;
746
+ const sessionsDir = join(openclawHome, ".openclaw", "agents", "main", "sessions");
747
+ if (existsSync(sessionsDir)) {
748
+ const ts = new Date()
749
+ .toISOString()
750
+ .replace(/:/g, "-")
751
+ .replace(/\.\d+Z$/, ".000Z");
752
+ const entries = readdirSync(sessionsDir);
753
+ let rotated = 0;
754
+ for (const name of entries) {
755
+ if (!name.endsWith(".jsonl"))
756
+ continue; // skip already-rotated
757
+ const from = join(sessionsDir, name);
758
+ const to = `${from}.reset.${ts}`;
759
+ try {
760
+ renameSync(from, to);
761
+ rotated++;
762
+ }
763
+ catch (e) {
764
+ console.warn(`[openclaw] session rotate failed for ${from}: ${e?.message ?? e}`);
765
+ }
766
+ }
767
+ if (rotated > 0) {
768
+ console.log(`[openclaw] runtime contract ${previous || "(none)"} → ${RUNTIME_CONTRACT_VERSION}: rotated ${rotated} session(s) under ${sessionsDir}`);
769
+ }
770
+ }
771
+ writeConfigFile(markerPath, RUNTIME_CONTRACT_VERSION + "\n");
772
+ }
773
+ catch (e) {
774
+ console.warn(`[openclaw] rotateSessionsIfContractChanged failed: ${e?.message ?? e}`);
775
+ }
776
+ }
777
+ /**
778
+ * Inject a guarded section into the agent's TOOLS.md telling the LLM that
779
+ * user files live on the local filesystem (host==container bind mount),
780
+ * not on some remote "drive server", and showing the concrete
781
+ * resolve-then-send chain for IM channels (Feishu / WeChat).
782
+ *
783
+ * Why this exists: the LLM defaults to interpreting `drive` as a remote
784
+ * service, then refuses to send NAS files via IM with "I can't access the
785
+ * cloud drive" — even when `drive_list` literally just enumerated them.
786
+ * Verified on pi2 2026-05-11: claw1 saw `文档/宇树G1-D...xlsx` via
787
+ * drive_list, then told the user to "open Feishu cloud space and forward
788
+ * it back" because nothing in TOOLS.md tied drive paths to filesystem
789
+ * paths. This patcher closes that gap by spelling it out.
790
+ *
791
+ * Idempotent: section is bracketed by HTML markers; content between them
792
+ * is rewritten on every call. User edits OUTSIDE the markers are kept.
793
+ *
794
+ * filesRoot is baked in so the example abs path matches the actual install
795
+ * — third-party users on `/home/alice/.jishushell/files/` see their own
796
+ * root, not pi's.
797
+ */
798
+ export function patchToolsMdDriveHint(workspaceDir, filesRoot) {
799
+ try {
800
+ const toolsPath = join(workspaceDir, "TOOLS.md");
801
+ let original = "";
802
+ try {
803
+ original = readFileSync(toolsPath, "utf-8");
804
+ }
805
+ catch (e) {
806
+ if (e?.code !== "ENOENT")
807
+ throw e;
808
+ }
809
+ const section = [
810
+ JISHUSHELL_DRIVE_HINT_BEGIN,
811
+ "",
812
+ "### 📁 用户的 NAS / 文件库",
813
+ "",
814
+ "⚠️ **用户文件操作硬路由 — 必读**",
815
+ "",
816
+ "**单一规则(语言无关)**:用户用任何语言(中文 / English / 日本語 / ...)说「**我的 / 我的 / my / mine / our / the user's / 私の**」+ 任何文件/目录/资料/笔记/文档/代码/data/notes/docs/files → **CRUD 全部走 `drive.*` 工具,禁止用 shell 在 cwd 上操作**。判定按**语义**不按**字面**,本表只是举例。",
817
+ "",
818
+ "动作 ↔ 工具映射表(用户文件场景,中英混排示例):",
819
+ "",
820
+ "| 用户意图(多语言示例) | ✅ 必须用 | ❌ 禁止 |",
821
+ "|---|---|---|",
822
+ "| list / show / 「列一下 / 我有什么 / 看看我的 / what files do I have / show me my docs / list my files」 | `drive_list` | `ls`, `find`, `pwd` |",
823
+ "| read / open / 「打开 / 看看 / 念一下 / 内容是啥 / open my X / read the content of / what's in」 | `drive_read_preview` / `drive_read_full` | `cat`, `head`, `tail` |",
824
+ "| search / find / 「搜 / 找一下 / 哪里提到 / search my docs for / find X in my files / where did I write about」 | `drive_search` | `grep`, `rg` |",
825
+ "| write / save / create / 「写 / 存 / 记一下 / 新建 / save this as / write a note / create a file」 | `drive_write_text` / `drive_write_binary` | `echo >`, `cat <<EOF`, `tee` |",
826
+ "| delete / remove / 「删 / 扔掉 / delete X / remove the file」 | `drive_delete` | `rm` |",
827
+ "| rename / move / 「改名 / 挪到 / 移到 / rename X to Y / move X to」 | `drive_move` | `mv` |",
828
+ "| mkdir / 「建文件夹 / create a folder / make a directory」 | `drive_mkdir` | `mkdir` |",
829
+ "| quota / space / 「配额 / 空间 / how much space / quota / disk usage」 | `drive_quota` | `df`, `du` |",
830
+ "| send / share / 「发给我 / 把 X 发出去 / send me X / share the file with me」 | `drive_resolve_local_path` → IM `send_file` | (拒绝 / refuse) |",
831
+ "",
832
+ "**触发词不完全列表(再次强调:语义优先)**:",
833
+ "- 中文:我的、我那份、我之前的、我刚刚的、用户的、咱们的、文件、文档、资料、笔记、报告、合同、PDF、Excel、附件",
834
+ "- English: my, mine, our, the user's, files, file, doc(s), document(s), note(s), paper(s), report(s), spreadsheet(s), pdf(s), attachment(s)",
835
+ "- 其它语言:私の (ja) / mes (fr) / mein (de) / 我的 (variants) — 含义相同时同等处理",
836
+ "",
837
+ "**为什么硬规定**:cwd 是你自己的运行骨架(`AGENTS.md`、`TOOLS.md`、`memory/`、`state/`、`config/`),跟用户**毫无关系**。",
838
+ "- 用户问「我有什么文件 / what files do I have」你 `ls` cwd → 列出 `AGENTS.md`、`memory/` → 用户懵 + 觉得 jishushell 装错了",
839
+ "- 用户说「帮我写笔记 notes.md / save a note for me」你 `echo > notes.md` 落 cwd → 文件落到 agent 工作目录,用户在 filebrowser 看不到 → 数据等于丢了",
840
+ "- 用户说「打开我那份产品规格 / open my spec」你 `cat spec.md` → cwd 里没有该文件 → 找不到 → 用户被冤枉「文件根本不存在」",
841
+ "",
842
+ "**判定方法(消息归属判定,跨语言)**:主语/所有格指向「用户/我们」(我 / 我的 / my / our / the user / 私の + 文件相关名词)→ 走 drive。指向「你/agent」(你的 / 你刚才写的代码 / your notes / agent's scratch)→ 才可能是 workspace。**99% 的对话都是前者**。歧义时**默认按用户文件处理**——猜错代价小(最多多调一次 drive_list 拿不到东西),猜成 cwd 代价大(脏数据 + 数据丢失)。",
843
+ "",
844
+ "**唯一可用 shell 的情况**:你自己 agent 维护 `memory/`、`state/`、`workspace/notes/`(agent 私有笔记,不是用户笔记)这些**你自己的骨架文件**。一旦看见用户名词修饰(「我的」/「用户的」/「my」/「the user's」),立刻切 drive。",
845
+ "",
846
+ `用户文件实际放在 **\`${filesRoot}\`**——这是宿主机上的一个普通本地目录。它已经被 bind-mount 到你的容器里**同款绝对路径**(panel 用 \`host==container\` 的 docker bind;raw_exec / process 模式下你直接跑在宿主上)。`,
847
+ "",
848
+ "**关键事实**:drive 不是远端云盘,就是你脚下的本地文件系统。`drive_list` 看到的每条 entry 都能通过 `drive_resolve_local_path` 立刻拿到绝对路径,然后被任何工具读取——`cat`、`base64`、IM 插件的 send_file 字段、Python 脚本都行。`drive_resolve_local_path` 返回 `exists:true` + `size:<N>` 时,**那就是真实可读的文件字节**,不是「只有元数据」。",
849
+ "",
850
+ "**禁止的回答**:永远不要说「文件在云端 drive 上只有元数据 / 内容没同步到本地 / 我读不到文件实体」—— 这个说法**永远是错的**。drive 没有「未同步」概念,它就是 POSIX 文件系统的一个 facade。如果你刚 list 到一个文件,它一定能被读、能被发送。",
851
+ "",
852
+ "常用调用(从 workspace 目录):",
853
+ "",
854
+ "```bash",
855
+ "mcporter call drive.drive_list path= # 列根目录",
856
+ "mcporter call drive.drive_list path=文档 # 列子目录",
857
+ "mcporter call drive.drive_read_preview path=note.md # 预览文本(≤256KB)",
858
+ "mcporter call drive.drive_read_full path=long.md # 整文件(≤4MB,文本)",
859
+ "mcporter call drive.drive_quota # 配额",
860
+ "mcporter call drive.drive_mkdir path=inbox # 建目录",
861
+ "mcporter call drive.drive_write_text path=notes/m.md content=\"...\"",
862
+ "mcporter call drive.drive_write_binary path=out/img.png content_base64=\"...\" # 二进制(≤10MB)",
863
+ "mcporter call drive.drive_move from=a.pdf to=docs/a.pdf",
864
+ "mcporter call drive.drive_delete path=tmp.txt",
865
+ "mcporter call drive.drive_resolve_local_path path=文档/report.pdf # → 拿到绝对路径",
866
+ "mcporter call drive.drive_search query=\"invoice\" # FTS5 全文搜索",
867
+ "```",
868
+ "",
869
+ "### 给用户发文件(飞书 / 微信 / 任何 IM)",
870
+ "",
871
+ "**用户问 \"把 X 文件发我\" 时不要拒绝、不要让用户去飞书云空间下载**——99% 的情况文件已经在你的文件系统里了。标准流程:",
872
+ "",
873
+ "1. `drive_list` 或 `drive_search` 先确认文件在 NAS 里(你刚 list 过的也算)。",
874
+ "2. `drive_resolve_local_path path=文档/x.xlsx` 拿到 `abs_path`,确认 `exists:true`。",
875
+ "3. 把 `abs_path` 传给**当前会话所在 IM 通道**的发送工具。**`target` 字段一律原样照搬当前消息 `Conversation info` 里的 `chat_id`——一个字符都不要加、不要改、不要补前缀**。各通道 chat_id 形态本来就不一样,照搬就对。",
876
+ " - **飞书 DM**:metadata 给 `\"chat_id\":\"user:ou_xxx\"`(自带 `user:` 前缀,**这是飞书的格式不是通用约定**)→ `target=\"user:ou_xxx\"`。工具用 `message` (channel=feishu, msg_type=file, path=<abs_path>) 或 `openclaw-lark` 的 `feishu_im_user_message`。",
877
+ " - **微信 DM**:metadata 给 `\"chat_id\":\"o9cq...@im.wechat\"`(**裸 ID,没有 `user:` 前缀**)→ `target=\"o9cq...@im.wechat\"`。工具用 `message` (channel=openclaw-weixin, msg_type=file, path=<abs_path>)。**千万别照搬飞书的 `user:` 加上去**——会让微信服务端的 `getuploadurl` 返 `ret:-1`,文件传不出去。",
878
+ " - 通用规则:当前 inbound 消息的 `from` 就是回复 target。打开你刚收到的那条 user message 里 `Conversation info` 的 JSON,把 `chat_id` 整段复制就行。",
879
+ "4. 失败先查 `exists` 字段、文件大小、IM 通道大小上限(飞书典型 30MB、微信 20MB),再决定降级方案。",
880
+ "",
881
+ "**ENOENT / \"no such file or directory\" 处理**:IM 插件返回 ENOENT 但 `drive_resolve_local_path` 刚刚 `exists:true`,**99% 是 panel 刚升级 / mount 刚刷新,但你这个 alloc 用的是旧 spec**。无脑重试一次。还是 ENOENT 才提示用户去 panel 重启实例(stop+start,不是 restart)——但**永远不要**回答 \"文件只是元数据所以读不到\",那是错的。",
882
+ "",
883
+ "要发**新生成**的文件(PDF、图片、报表):先 `drive_write_binary path=agent-data/<instance>/outbox/x.pdf content_base64=...` 落盘,再 resolve → 发送。",
884
+ "",
885
+ "### 用户给你发文件",
886
+ "",
887
+ "目前飞书/微信通道不会自动把附件落盘到 NAS。当用户说\"文件给你了\"但你 `drive_list inbox` 看不到:",
888
+ "- 优先让用户走 panel 的 Filebrowser(`/apps/filebrowser/`)或 WebDAV 把文件上传到 `inbox/`,再告诉你路径。",
889
+ "- 飞书附件流:如果飞书 app 已配 `im:resource` 权限,可以用 `feishu_im_user_fetch_resource` 取 file_key、落到 `inbox/feishu/<date>/`。",
890
+ "",
891
+ "权限:在 panel \"关联 agent\" UI 里给实例授 ro/rw。403 时让用户去 panel 加。",
892
+ "",
893
+ JISHUSHELL_DRIVE_HINT_END,
894
+ "",
895
+ ].join("\n");
896
+ let next;
897
+ const beginIdx = original.indexOf(JISHUSHELL_DRIVE_HINT_BEGIN);
898
+ const endIdx = original.indexOf(JISHUSHELL_DRIVE_HINT_END);
899
+ if (beginIdx >= 0 && endIdx > beginIdx) {
900
+ const tail = endIdx + JISHUSHELL_DRIVE_HINT_END.length;
901
+ const after = original
902
+ .slice(tail)
903
+ .replace(/^\n+/, "\n");
904
+ next = original.slice(0, beginIdx) + section + after;
905
+ }
906
+ else {
907
+ const sep = original && !original.endsWith("\n") ? "\n\n" : "\n";
908
+ next = (original ? original + sep : "") + section;
909
+ }
910
+ if (next === original)
911
+ return;
912
+ writeConfigFile(toolsPath, next);
913
+ console.log(`[openclaw] Patched drive hint into ${toolsPath}`);
914
+ }
915
+ catch (e) {
916
+ console.warn(`[openclaw] Failed to patch TOOLS.md drive hint: ${e.message}`);
917
+ }
918
+ }
919
+ /**
920
+ * Inject a guarded section into TOOLS.md describing the kb_search MCP
921
+ * tool — only when an AnythingLLM-backed knowledge base is wired into
922
+ * this instance. Mirrors patchToolsMdDriveHint's marker model so an
923
+ * uninstall / unbind can cleanly strip the section.
924
+ *
925
+ * `mode === "install"` writes the kb hint between the markers
926
+ * (overwriting any previous content there). `mode === "remove"`
927
+ * deletes the entire bracketed section, leaving the user's
928
+ * surrounding content intact.
929
+ */
930
+ export function patchToolsMdKbHint(workspaceDir, mode) {
931
+ try {
932
+ const toolsPath = join(workspaceDir, "TOOLS.md");
933
+ let original = "";
934
+ try {
935
+ original = readFileSync(toolsPath, "utf-8");
936
+ }
937
+ catch (e) {
938
+ if (e?.code !== "ENOENT")
939
+ throw e;
940
+ if (mode === "remove")
941
+ return; // nothing to strip
942
+ }
943
+ // Strip every existing kb section first — tolerant of legacy variants
944
+ // (e.g. early manual injections that used "END auto-generated -->"
945
+ // instead of the current "END -->" marker). Without this, repeated
946
+ // re-patches would accumulate sections in TOOLS.md.
947
+ const STRIP_RE = /\n*<!-- jishushell-kb: BEGIN[^>]*-->[\s\S]*?<!-- jishushell-kb: END[^>]*-->\n*/g;
948
+ const stripped = original.replace(STRIP_RE, "\n");
949
+ let next;
950
+ if (mode === "remove") {
951
+ if (stripped === original)
952
+ return;
953
+ next = stripped;
954
+ }
955
+ else {
956
+ const section = [
957
+ JISHUSHELL_KB_HINT_BEGIN,
958
+ "",
959
+ "### 📚 知识库(AnythingLLM)",
960
+ "",
961
+ "用户长期投递的文档(手册、PDF、内部笔记、过往会议纪要等)由 **AnythingLLM** 维护索引(本地 LanceDB 向量库 + 内置 Xenova ONNX embedder),通过 `kb.kb_search` 一次调用拿「答案 + 引用来源」。",
962
+ "",
963
+ "**两个工具**:",
964
+ "",
965
+ "- `mcporter call kb.kb_search query=\"<用户原话>\"` — 在已索引文档里检索,返回答案 + 最多 5 条引用源",
966
+ "- `mcporter call kb.kb_ingest path=\"<绝对路径>\"` — 把 drive 里的文件加入知识库并 embed(用户说「加进知识库 / index this / 学习这份」时调)",
967
+ "",
968
+ "**ingest 标准链**(drive 里的文件 → 知识库):",
969
+ "1. `mcporter call drive.drive_resolve_local_path path=inbox/contract.pdf` → 拿 `abs_path`",
970
+ "2. `mcporter call kb.kb_ingest path=<abs_path>` → AnythingLLM 自动 embed",
971
+ "3. 几秒后用户问相关问题,`kb_search` 命中",
972
+ "",
973
+ "**search 什么时候调**:用户问的东西像在已上传文档里能找到——「那个 X 的手册里怎么说」、「我们之前关于 Y 的讨论」、「产品规格」、「合同条款」、「what does the doc say about X」等。",
974
+ "",
975
+ "**什么时候不要调**:寒暄、纯代码生成、数学计算、实时信息(天气/股票/新闻)、**操作 NAS 文件**(那是 `drive.*`,不是 kb)、纯创作类。",
976
+ "",
977
+ "**与 drive 的分工**:",
978
+ "- `drive.*` = 文件系统 facade(列目录、读字节、发文件)—— 要的是**文件本体**用 drive",
979
+ "- `kb.kb_search` / `kb.kb_ingest` = 语义检索 + RAG 入库 —— 要的是**答案/知识**用 kb",
980
+ "",
981
+ "两者数据**不共享**:drive 看到 `manual.pdf` ≠ kb 一定能搜到它。要让 kb 能搜到 → 先 `kb_ingest`。",
982
+ "",
983
+ "**搜不到时的标准回复**:`kb_search` 回答里说「无相关文档」或 sources 为空 → 先确认 drive 里有没有相关文件,如果有,主动建议「要我把它加入知识库吗」(用户同意就 `drive_resolve_local_path` + `kb_ingest`);drive 也没有,告诉用户「我在你的知识库里没找到相关内容」,然后**不要继续幻想答案**。",
984
+ "",
985
+ JISHUSHELL_KB_HINT_END,
986
+ "",
987
+ ].join("\n");
988
+ const sep = stripped && !stripped.endsWith("\n") ? "\n\n" : "\n";
989
+ next = (stripped ? stripped + sep : "") + section;
990
+ }
991
+ if (next === original)
992
+ return;
993
+ writeConfigFile(toolsPath, next);
994
+ console.log(`[openclaw] ${mode === "remove" ? "Removed" : "Patched"} kb hint in ${toolsPath}`);
995
+ }
996
+ catch (e) {
997
+ console.warn(`[openclaw] Failed to ${mode} TOOLS.md kb hint: ${e.message}`);
998
+ }
999
+ }
438
1000
  /**
439
1001
  * Pre-seed the per-instance npm global prefix with a symlink to the image's
440
1002
  * baked openclaw package so OpenClaw's in-gateway "Update now" handler can
@@ -466,7 +1028,368 @@ function ensureOpenclawUpdateSeed(openclawHome, instanceId) {
466
1028
  console.warn(`[openclaw] update-seed ${instanceId}: failed to create seed: ${err?.message ?? err}`);
467
1029
  }
468
1030
  }
469
- // ── Resource helpers (migrated from nomad-manager.ts) ─────────────────
1031
+ // ── Session fence patch (OpenClaw >= 5.19 regression) ─────────────────
1032
+ /**
1033
+ * The minimum OpenClaw version where the session fence bug exists.
1034
+ * The bug was introduced in v2026.5.19 — `eventMayReachTranscriptWriters()`
1035
+ * doesn't recognize `"message"` / `"custom_message"` event types, causing
1036
+ * session writes to bypass `withSessionWriteLock`, which in turn leaves
1037
+ * the file fingerprint stale and triggers `EmbeddedAttemptSessionTakeoverError`
1038
+ * on the next prompt.
1039
+ *
1040
+ * Additionally, on virtiofs mounts (macOS → Colima VM → Docker), file
1041
+ * metadata (mtime/ctime) can drift between `releaseForPrompt()` and
1042
+ * `assertSessionFileFence()` even without actual writes, due to the
1043
+ * multi-layer filesystem stack timing. Patching `assertSessionFileFence`
1044
+ * to a no-op fully disables the flawed fence check.
1045
+ */
1046
+ const SESSION_FENCE_BUG_MIN_VERSION = [2026, 5, 19];
1047
+ /**
1048
+ * Maximum version (exclusive) to apply the patch. Once OpenClaw ships a fix
1049
+ * upstream, set a concrete version here to stop patching newer releases.
1050
+ * For now, no upper bound — patch all versions >= MIN.
1051
+ */
1052
+ /**
1053
+ * Safety limit: if the extracted function body exceeds this many characters,
1054
+ * skip the patch to avoid corrupting the bundle (e.g. brace-depth counter
1055
+ * fooled by string literals containing braces in a future refactored version).
1056
+ */
1057
+ const MAX_PATCH_TARGET_BODY_LENGTH = 5000;
1058
+ /**
1059
+ * Patch targets: each entry defines a function to replace.
1060
+ * - `eventMayReachTranscriptWriters`: broad return prevents events from
1061
+ * bypassing the write lock (necessary but not sufficient on virtiofs).
1062
+ * - `assertSessionFileFence`: no-op disables the fingerprint comparison
1063
+ * that false-triggers due to virtiofs stat drift.
1064
+ */
1065
+ const SESSION_FENCE_PATCH_TARGETS = [
1066
+ {
1067
+ fnName: "function assertSessionFileFence()",
1068
+ litNeedle: "sameSessionFileFingerprint",
1069
+ replacement: "function assertSessionFileFence() { return; }",
1070
+ },
1071
+ {
1072
+ fnName: "function eventMayReachTranscriptWriters",
1073
+ litNeedle: '"message_update"',
1074
+ replacement: 'function eventMayReachTranscriptWriters(session, event) { return typeof event?.type === "string"; }',
1075
+ },
1076
+ ];
1077
+ /**
1078
+ * Patch the session fence bug in OpenClaw >= 5.19 by replacing the
1079
+ * `eventMayReachTranscriptWriters` function in the minified bundle.
1080
+ *
1081
+ * Idempotent: uses a marker file containing the SHA-256 of the patched
1082
+ * file. Skips if already patched or if the pattern is not found (future
1083
+ * fixed version or different bundle layout).
1084
+ *
1085
+ * Docker-only: the bug manifests through the virtiofs mount timing
1086
+ * discrepancy between container and host — raw_exec/process modes are
1087
+ * unaffected in practice.
1088
+ */
1089
+ export function patchSessionFenceBug(openclawHome, instanceId) {
1090
+ if (getNomadDriver() !== "docker")
1091
+ return;
1092
+ // 1. Read installed version from npm-global package.json
1093
+ const pkgPath = join(openclawHome, ".npm-global", "lib", "node_modules", "openclaw", "package.json");
1094
+ let versionStr;
1095
+ try {
1096
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1097
+ versionStr = pkg.version || "";
1098
+ }
1099
+ catch {
1100
+ return; // no npm-global install or unreadable — skip
1101
+ }
1102
+ // 2. Parse version and check minimum threshold
1103
+ const parts = versionStr.split(".").map(Number);
1104
+ if (parts.length < 3 || parts.some(Number.isNaN))
1105
+ return;
1106
+ const [major, minor, patch] = parts;
1107
+ const [minMajor, minMinor, minPatch] = SESSION_FENCE_BUG_MIN_VERSION;
1108
+ if (major < minMajor ||
1109
+ (major === minMajor && minor < minMinor) ||
1110
+ (major === minMajor && minor === minMinor && patch < minPatch)) {
1111
+ return; // version predates the bug
1112
+ }
1113
+ // 3. Find the selection-*.js bundle that contains the target function.
1114
+ // There may be multiple selection-*.js files in dist/; only one holds
1115
+ // the session management code.
1116
+ const distDir = join(openclawHome, ".npm-global", "lib", "node_modules", "openclaw", "dist");
1117
+ let bundleFiles;
1118
+ try {
1119
+ const entries = readdirSync(distDir);
1120
+ bundleFiles = entries.filter((f) => f.startsWith("selection-") && f.endsWith(".js"));
1121
+ }
1122
+ catch {
1123
+ return; // dist dir missing or unreadable
1124
+ }
1125
+ if (bundleFiles.length === 0)
1126
+ return;
1127
+ // Scan each candidate for any of the patch target signatures
1128
+ let bundlePath;
1129
+ let bundleContent;
1130
+ for (const file of bundleFiles) {
1131
+ const candidatePath = join(distDir, file);
1132
+ let content;
1133
+ try {
1134
+ content = readFileSync(candidatePath, "utf-8");
1135
+ }
1136
+ catch {
1137
+ continue;
1138
+ }
1139
+ // Match if any target's fnName or litNeedle is present
1140
+ const hasTarget = SESSION_FENCE_PATCH_TARGETS.some((t) => content.includes(t.fnName) || content.includes(t.litNeedle));
1141
+ if (hasTarget) {
1142
+ bundlePath = candidatePath;
1143
+ bundleContent = content;
1144
+ break;
1145
+ }
1146
+ }
1147
+ if (!bundlePath || !bundleContent)
1148
+ return;
1149
+ // 4. Check marker file for idempotency
1150
+ const markerPath = join(distDir, `.session-fence-patched`);
1151
+ const currentHash = createHash("sha256").update(bundleContent).digest("hex");
1152
+ try {
1153
+ const marker = readFileSync(markerPath, "utf-8").trim();
1154
+ if (marker === currentHash)
1155
+ return; // already patched, hash matches
1156
+ }
1157
+ catch {
1158
+ // marker missing — proceed
1159
+ }
1160
+ // 5. Apply each patch target using brace-depth counting to extract and
1161
+ // replace function bodies. Process targets in order; earlier patches
1162
+ // shift offsets so we re-search after each replacement.
1163
+ let patched = bundleContent;
1164
+ let patchCount = 0;
1165
+ for (const target of SESSION_FENCE_PATCH_TARGETS) {
1166
+ let fnStart = patched.indexOf(target.fnName);
1167
+ // Fallback for eventMayReachTranscriptWriters: locate by unique literal
1168
+ // combination ("message_update" near "message_end" and "agent_end")
1169
+ if (fnStart === -1 && target.litNeedle === '"message_update"') {
1170
+ let scanPos = 0;
1171
+ while (scanPos < patched.length) {
1172
+ const litIdx = patched.indexOf(target.litNeedle, scanPos);
1173
+ if (litIdx === -1)
1174
+ break;
1175
+ const window = patched.slice(litIdx, litIdx + 200);
1176
+ if (window.includes('"message_end"') && window.includes('"agent_end"')) {
1177
+ const searchStart = Math.max(0, litIdx - 300);
1178
+ const prefix = patched.slice(searchStart, litIdx);
1179
+ const fnKeywordIdx = prefix.lastIndexOf("function ");
1180
+ if (fnKeywordIdx !== -1) {
1181
+ fnStart = searchStart + fnKeywordIdx;
1182
+ break;
1183
+ }
1184
+ }
1185
+ scanPos = litIdx + 1;
1186
+ }
1187
+ }
1188
+ if (fnStart === -1)
1189
+ continue; // target not found (already patched or fixed version)
1190
+ // Check if already patched (replacement is short and contains `return;`)
1191
+ const peekEnd = Math.min(fnStart + target.replacement.length + 10, patched.length);
1192
+ const peek = patched.slice(fnStart, peekEnd);
1193
+ if (peek.startsWith(target.replacement))
1194
+ continue;
1195
+ // Extract function body with brace-depth counting
1196
+ const braceStart = patched.indexOf("{", fnStart);
1197
+ if (braceStart === -1)
1198
+ continue;
1199
+ let depth = 0;
1200
+ let fnEnd = -1;
1201
+ for (let i = braceStart; i < patched.length; i++) {
1202
+ if (patched[i] === "{")
1203
+ depth++;
1204
+ else if (patched[i] === "}") {
1205
+ depth--;
1206
+ if (depth === 0) {
1207
+ fnEnd = i + 1;
1208
+ break;
1209
+ }
1210
+ }
1211
+ }
1212
+ if (fnEnd === -1)
1213
+ continue;
1214
+ // Safety: if extracted body is suspiciously large, skip to avoid corrupting
1215
+ // the bundle (brace counter may have been fooled by string contents).
1216
+ if (fnEnd - fnStart > MAX_PATCH_TARGET_BODY_LENGTH) {
1217
+ console.warn(`[openclaw] session-fence-patch ${instanceId}: skipping ${target.fnName} — body too large (${fnEnd - fnStart} chars)`);
1218
+ continue;
1219
+ }
1220
+ patched = patched.slice(0, fnStart) + target.replacement + patched.slice(fnEnd);
1221
+ patchCount++;
1222
+ }
1223
+ if (patchCount === 0) {
1224
+ // No targets needed patching — write marker for current state
1225
+ try {
1226
+ writeFileSync(markerPath, currentHash + "\n", "utf-8");
1227
+ }
1228
+ catch {
1229
+ /* best effort */
1230
+ }
1231
+ return;
1232
+ }
1233
+ // 7. Atomic write: temp file → rename
1234
+ const tmpPath = bundlePath + `.tmp.${randomBytes(4).toString("hex")}`;
1235
+ try {
1236
+ writeFileSync(tmpPath, patched, "utf-8");
1237
+ renameSync(tmpPath, bundlePath);
1238
+ }
1239
+ catch (err) {
1240
+ // Clean up temp file on failure
1241
+ try {
1242
+ unlinkSync(tmpPath);
1243
+ }
1244
+ catch {
1245
+ /* ignore */
1246
+ }
1247
+ console.warn(`[openclaw] session-fence-patch ${instanceId}: atomic write failed: ${err?.message ?? err}`);
1248
+ return;
1249
+ }
1250
+ // 8. Write marker with hash of the PATCHED file
1251
+ const patchedHash = createHash("sha256").update(patched).digest("hex");
1252
+ try {
1253
+ writeFileSync(markerPath, patchedHash + "\n", "utf-8");
1254
+ }
1255
+ catch {
1256
+ // Non-fatal — patch was applied, marker just helps idempotency
1257
+ }
1258
+ console.log(`[openclaw] session-fence-patch ${instanceId}: patched ${bundlePath.split("/").pop()} (v${versionStr})`);
1259
+ }
1260
+ // ── Container-side patch script ───────────────────────────────────────────
1261
+ /**
1262
+ * Self-contained Node.js script that patches the session fence bug from
1263
+ * INSIDE the container. Written to `$OPENCLAW_HOME/.jishushell/session-fence-patch.mjs`
1264
+ * by onBeforeStart and executed by the custom entrypoint wrapper before
1265
+ * the OpenClaw gateway starts.
1266
+ *
1267
+ * This covers the case where an in-container `npm install openclaw@latest`
1268
+ * upgrades to a buggy version (>= 5.19) after the container was created
1269
+ * with a clean image (5.7). The host-side `patchSessionFenceBug` cannot
1270
+ * patch at onBeforeStart time because the npm-global directory may not yet
1271
+ * contain the upgraded bundle (it's a dangling symlink to a container-only path).
1272
+ */
1273
+ const CONTAINER_PATCH_SCRIPT = `#!/usr/bin/env node
1274
+ // Session fence patch — auto-generated by JishuShell.
1275
+ // Patches assertSessionFileFence + eventMayReachTranscriptWriters
1276
+ // in OpenClaw >= 2026.5.19 to work around virtiofs stat drift.
1277
+ import { readFileSync, writeFileSync, readdirSync, renameSync, existsSync } from "fs";
1278
+ import { join } from "path";
1279
+ import { createHash } from "crypto";
1280
+
1281
+ const home = process.env.HOME || process.env.OPENCLAW_HOME || "/home/openclaw";
1282
+ const distDir = join(home, ".npm-global", "lib", "node_modules", "openclaw", "dist");
1283
+ const pkgPath = join(home, ".npm-global", "lib", "node_modules", "openclaw", "package.json");
1284
+
1285
+ try {
1286
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1287
+ const parts = (pkg.version || "").split(".").map(Number);
1288
+ if (parts.length < 3 || parts[0] < 2026 || (parts[0] === 2026 && parts[1] < 5) ||
1289
+ (parts[0] === 2026 && parts[1] === 5 && parts[2] < 19)) process.exit(0);
1290
+ // No upper version cap for now — patch all versions >= 5.19
1291
+ } catch { process.exit(0); }
1292
+
1293
+ const markerPath = join(distDir, ".session-fence-patched");
1294
+ let files;
1295
+ try { files = readdirSync(distDir).filter(f => f.startsWith("selection-") && f.endsWith(".js")); }
1296
+ catch { process.exit(0); }
1297
+
1298
+ const targets = [
1299
+ { fn: "function assertSessionFileFence()", repl: "function assertSessionFileFence() { return; }" },
1300
+ { fn: "function eventMayReachTranscriptWriters", repl: 'function eventMayReachTranscriptWriters(session, event) { return typeof event?.type === "string"; }' },
1301
+ ];
1302
+
1303
+ for (const file of files) {
1304
+ const p = join(distDir, file);
1305
+ let src;
1306
+ try { src = readFileSync(p, "utf-8"); } catch { continue; }
1307
+ if (!targets.some(t => src.includes(t.fn) || src.includes("sameSessionFileFingerprint"))) continue;
1308
+
1309
+ const hash = createHash("sha256").update(src).digest("hex");
1310
+ try { if (readFileSync(markerPath, "utf-8").trim() === hash) process.exit(0); } catch {}
1311
+
1312
+ let modified = src;
1313
+ let count = 0;
1314
+ for (const t of targets) {
1315
+ const idx = modified.indexOf(t.fn);
1316
+ if (idx === -1) continue;
1317
+ if (modified.slice(idx, idx + t.repl.length + 5).startsWith(t.repl)) continue;
1318
+ const braceStart = modified.indexOf("{", idx);
1319
+ if (braceStart === -1) continue;
1320
+ let d = 0, end = -1;
1321
+ for (let i = braceStart; i < modified.length; i++) {
1322
+ if (modified[i] === "{") d++;
1323
+ else if (modified[i] === "}") { d--; if (d === 0) { end = i + 1; break; } }
1324
+ }
1325
+ if (end === -1) continue;
1326
+ if (end - idx > 5000) continue; // safety: skip if body suspiciously large
1327
+ modified = modified.slice(0, idx) + t.repl + modified.slice(end);
1328
+ count++;
1329
+ }
1330
+ if (count === 0) {
1331
+ try { writeFileSync(markerPath, hash + "\\n"); } catch {}
1332
+ process.exit(0);
1333
+ }
1334
+ const tmp = p + ".tmp." + Math.random().toString(36).slice(2, 8);
1335
+ writeFileSync(tmp, modified);
1336
+ renameSync(tmp, p);
1337
+ const newHash = createHash("sha256").update(modified).digest("hex");
1338
+ try { writeFileSync(markerPath, newHash + "\\n"); } catch {}
1339
+ console.log("[session-fence-patch] patched " + file);
1340
+ break;
1341
+ }
1342
+ `;
1343
+ /**
1344
+ * The custom entrypoint wrapper that executes the patch script before
1345
+ * deferring to the image's original entrypoint.
1346
+ */
1347
+ const CONTAINER_ENTRYPOINT_WRAPPER = `#!/bin/sh
1348
+ # JishuShell session-fence-patch entrypoint wrapper
1349
+ PATCH="$HOME/.jishushell/session-fence-patch.mjs"
1350
+ if [ -f "$PATCH" ]; then
1351
+ node "$PATCH" 2>/dev/null || true
1352
+ fi
1353
+ exec /usr/local/bin/openclaw-entry.sh "$@"
1354
+ `;
1355
+ /**
1356
+ * Write the container-side patch script and entrypoint wrapper to the
1357
+ * openclaw-home directory so they are available inside the container
1358
+ * via the bind mount.
1359
+ *
1360
+ * Called from onBeforeStart. Idempotent — only writes if content changed.
1361
+ */
1362
+ export function writeContainerPatchScript(openclawHome) {
1363
+ const scriptDir = join(openclawHome, ".jishushell");
1364
+ try {
1365
+ mkdirSync(scriptDir, { recursive: true });
1366
+ }
1367
+ catch {
1368
+ return;
1369
+ }
1370
+ const scriptPath = join(scriptDir, "session-fence-patch.mjs");
1371
+ const wrapperPath = join(scriptDir, "entrypoint.sh");
1372
+ // Write patch script (idempotent)
1373
+ try {
1374
+ const existing = readFileSync(scriptPath, "utf-8");
1375
+ if (existing === CONTAINER_PATCH_SCRIPT) {
1376
+ // Also check wrapper
1377
+ const existingWrapper = readFileSync(wrapperPath, "utf-8");
1378
+ if (existingWrapper === CONTAINER_ENTRYPOINT_WRAPPER)
1379
+ return;
1380
+ }
1381
+ }
1382
+ catch {
1383
+ // file missing — write it
1384
+ }
1385
+ try {
1386
+ writeFileSync(scriptPath, CONTAINER_PATCH_SCRIPT, { mode: 0o755 });
1387
+ writeFileSync(wrapperPath, CONTAINER_ENTRYPOINT_WRAPPER, { mode: 0o755 });
1388
+ }
1389
+ catch {
1390
+ /* best effort */
1391
+ }
1392
+ }
470
1393
  function resolveUidGid(username) {
471
1394
  try {
472
1395
  if (!VALID_USER_RE.test(username)) {
@@ -538,7 +1461,7 @@ const PINNED_IMAGE_TAG_RE = /:[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/;
538
1461
  /**
539
1462
  * Pull DOCKER_BASE_IMAGE from mirrors if not already cached locally.
540
1463
  */
541
- async function ensureDockerBaseImage(invocation, task) {
1464
+ async function _ensureDockerBaseImage(invocation, task) {
542
1465
  try {
543
1466
  execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", DOCKER_BASE_IMAGE], {
544
1467
  timeout: 5000,
@@ -827,6 +1750,7 @@ class OpenClawAdapter {
827
1750
  // 3. Docker bridge patches
828
1751
  patchDockerBridgeGatewayBind(configPath);
829
1752
  patchJsproxyBaseUrl(configPath);
1753
+ patchPrivateNetworkAllowFlag(configPath);
830
1754
  }
831
1755
  // Driver-agnostic: enable the OpenAI-compatible endpoints on every
832
1756
  // start so the `llm-agent` capability advertised by openclaw-*.yaml
@@ -842,6 +1766,374 @@ class OpenClawAdapter {
842
1766
  catch {
843
1767
  /* best effort */
844
1768
  }
1769
+ // 4a. Patch session fence bug in OpenClaw >= 5.19
1770
+ try {
1771
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1772
+ if (home) {
1773
+ patchSessionFenceBug(home, instanceId);
1774
+ // Also write the container-side patch script so that even if
1775
+ // the host-side patch couldn't apply (e.g. symlink to container
1776
+ // path), the container will self-patch on startup.
1777
+ if (getNomadDriver() === "docker") {
1778
+ writeContainerPatchScript(home);
1779
+ }
1780
+ }
1781
+ }
1782
+ catch {
1783
+ /* best effort — patch failure must not prevent start */
1784
+ }
1785
+ // 4b. Build the workspace symlink layout from this instance's
1786
+ // fileMounts (M1 W2). For docker mode, the corresponding
1787
+ // volume bindings are added in buildNomadTask below; for
1788
+ // raw_exec / process modes, the symlinks are sufficient
1789
+ // (no container layer between agent and host fs).
1790
+ try {
1791
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1792
+ const im = await lazyIm();
1793
+ const runtime = im.getInstanceRuntime(instanceId);
1794
+ const mounts = readFileMounts(runtime);
1795
+ if (home) {
1796
+ const { rebuildWorkspace } = await import("../../workspace-builder.js");
1797
+ rebuildWorkspace({
1798
+ openclawHome: home,
1799
+ filesRoot: FILES_ROOT,
1800
+ mounts,
1801
+ instanceId,
1802
+ });
1803
+ }
1804
+ }
1805
+ catch (e) {
1806
+ // Surface migration-required clearly; otherwise fall back to a
1807
+ // warning so a misconfigured mount cannot prevent instance start.
1808
+ if (e?.reason === "needs-migration") {
1809
+ throw new Error(`instance ${instanceId} workspace contains pre-W2 user data; run legacy migration first (${e.message})`);
1810
+ }
1811
+ console.warn(`[openclaw] workspace rebuild skipped: ${e?.message ?? e}`);
1812
+ }
1813
+ // 4b-bis. Patch TOOLS.md with the drive-shim hint so the agent
1814
+ // understands user files are local (host==container bind) and
1815
+ // knows the resolve→send chain for IM channels. Without this,
1816
+ // the LLM defaults to "drive = remote cloud service" and
1817
+ // refuses to send NAS files via Feishu/WeChat. Runs after the
1818
+ // workspace rebuild because that step creates the workspace
1819
+ // tree if missing.
1820
+ try {
1821
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1822
+ if (home) {
1823
+ patchToolsMdDriveHint(join(home, ".openclaw", "workspace"), FILES_ROOT);
1824
+ }
1825
+ }
1826
+ catch (e) {
1827
+ console.warn(`[openclaw] TOOLS.md drive hint skipped: ${e?.message ?? e}`);
1828
+ }
1829
+ // 4b-ter. Rotate stale session jsonl(s) when the runtime contract
1830
+ // bumps. This is the auto-recovery path for panel upgrades
1831
+ // that fix runtime bugs the agent has already "concluded
1832
+ // around" inside an existing session — without rotation the
1833
+ // LLM keeps citing past failures and refusing to retry even
1834
+ // after the underlying bug is fixed. Idempotent: after the
1835
+ // first onBeforeStart post-upgrade writes the new marker,
1836
+ // subsequent starts are no-ops.
1837
+ try {
1838
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1839
+ if (home)
1840
+ rotateSessionsIfContractChanged(instanceId, home);
1841
+ }
1842
+ catch (e) {
1843
+ console.warn(`[openclaw] session rotation skipped: ${e?.message ?? e}`);
1844
+ }
1845
+ // 4c. Install the drive MCP shim so the agent can call panel
1846
+ // file/organize APIs from chat (M1 W1.6). Idempotent — we
1847
+ // overwrite the shim file every start to pick up fixes, and
1848
+ // mergeMcporterServers marks the entry with __source so user-
1849
+ // managed mcporter entries are preserved untouched.
1850
+ try {
1851
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1852
+ if (home) {
1853
+ const { substituteDriveShimPlaceholders } = await import("../mcp-shims/drive-shim.js");
1854
+ const { mergeMcporterServers } = await import("./openclaw-mcporter.js");
1855
+ const { getInternalMcpToken } = await import("../../../config.js");
1856
+ // Pick the panel URL based on how THIS instance will actually run.
1857
+ // For a containerized instance (raw_exec/docker via Nomad with a
1858
+ // docker image), `host.docker.internal:8090` resolves through the
1859
+ // bridge gateway. For a host-process / binary spec under Nomad
1860
+ // raw_exec, the task gets its own network namespace where
1861
+ // 127.0.0.1 only reaches the task itself — must use the host's
1862
+ // LAN IPv4 so the shim's fetch crosses back into the host netns.
1863
+ // Detected via the instance's resolved runtime: container tasks
1864
+ // carry `runtime.image`, binary tasks carry only `runtime.command`.
1865
+ let drivePanelUrl = "http://host.docker.internal:8090";
1866
+ try {
1867
+ const im2 = await lazyIm();
1868
+ const rt = im2.getInstanceRuntime(instanceId);
1869
+ if (!rt?.image) {
1870
+ const { getPanelLanHost, getPanelPort } = await import("../../../config.js");
1871
+ drivePanelUrl = `http://${getPanelLanHost()}:${getPanelPort()}`;
1872
+ }
1873
+ }
1874
+ catch {
1875
+ // Best effort — fall through to host.docker.internal default
1876
+ }
1877
+ const shimDir = join(home, "__mcp_shims__", "drive");
1878
+ ensureDirContainer(shimDir);
1879
+ const shimPath = join(shimDir, "drive-shim.mjs");
1880
+ // Bake panelUrl/token/instanceId into the shim source so it works
1881
+ // even when OpenClaw scrubs env on MCP subprocess spawn (verified
1882
+ // 2026-05-11 on pi2: env scrub made the shim default to the
1883
+ // unreachable host.docker.internal and surface as "fetch failed").
1884
+ const internalToken = getInternalMcpToken();
1885
+ const shimSource = substituteDriveShimPlaceholders({
1886
+ panelUrl: drivePanelUrl,
1887
+ token: internalToken,
1888
+ instanceId,
1889
+ });
1890
+ writeFileSync(shimPath, shimSource, { mode: 0o755 });
1891
+ mergeMcporterServers(instanceId, {
1892
+ drive: {
1893
+ command: "node",
1894
+ args: [shimPath],
1895
+ env: {
1896
+ // Env still set as a belt-and-suspenders. With baked-in
1897
+ // values in the shim source itself, these become a fallback
1898
+ // for dev/manual testing — production never depends on them.
1899
+ JISHUSHELL_INTERNAL_TOKEN: internalToken,
1900
+ JISHUSHELL_INSTANCE_ID: instanceId,
1901
+ JISHUSHELL_PANEL_URL: drivePanelUrl,
1902
+ },
1903
+ __source: {
1904
+ kind: "connection",
1905
+ slot: "drive",
1906
+ consumerInstanceId: instanceId,
1907
+ },
1908
+ },
1909
+ });
1910
+ }
1911
+ }
1912
+ catch (e) {
1913
+ console.warn(`[openclaw] drive shim install skipped: ${e?.message ?? e}`);
1914
+ }
1915
+ // 4c-quater. Auto-wire AnythingLLM kb shim. The Connections-tab
1916
+ // knowledge slot (declared as `requires: knowledge` on the
1917
+ // OpenClaw spec, persisted under `instance.connections.KNOWLEDGE_BASE_URL`)
1918
+ // is honored here, so unbinding in the UI actually takes effect.
1919
+ // Three states (matching connection-resolver.ts):
1920
+ //
1921
+ // - `null` → user explicitly disconnected → DO NOT
1922
+ // inject (and strip any prior shim).
1923
+ // - explicit binding → inject only if it points at the
1924
+ // anythingllm-container provider; any
1925
+ // other choice means user wants a
1926
+ // different kb provider that we don't
1927
+ // yet ship a shim for.
1928
+ // - undefined → no opinion → fall back to the
1929
+ // historical "auto-on when AnythingLLM
1930
+ // credentials.json exists" UX.
1931
+ //
1932
+ // Secrets handling (defense-in-depth):
1933
+ // - shim source (mode 0o644) carries baseUrl + workspace only;
1934
+ // the API key lives in a sibling `secret.json` (0o600).
1935
+ // - mcporter.json (0o644) env carries only non-secret hints
1936
+ // for hand-running. Production shim reads the secret file.
1937
+ try {
1938
+ const home = openclawAdapter.resolveAgentHome(instanceId);
1939
+ if (home) {
1940
+ const instMeta = getInstance(instanceId);
1941
+ const kbBinding = instMeta?.connections?.KNOWLEDGE_BASE_URL;
1942
+ let bindingAllowsInject = true;
1943
+ if (kbBinding === null) {
1944
+ bindingAllowsInject = false;
1945
+ }
1946
+ else if (kbBinding && typeof kbBinding === "object") {
1947
+ if (kbBinding.kind === "single") {
1948
+ bindingAllowsInject = kbBinding.providerId === "anythingllm-container";
1949
+ }
1950
+ else if (kbBinding.kind === "many") {
1951
+ const providers = Array.isArray(kbBinding.providers) ? kbBinding.providers : [];
1952
+ bindingAllowsInject = providers.some((p) => p?.providerId === "anythingllm-container");
1953
+ }
1954
+ }
1955
+ const credPath = join(JISHUSHELL_HOME, "apps", "anythingllm-container", "credentials.json");
1956
+ let kbCreds = null;
1957
+ if (bindingAllowsInject && existsSync(credPath)) {
1958
+ try {
1959
+ kbCreds = JSON.parse(readFileSync(credPath, "utf-8"));
1960
+ }
1961
+ catch (e) {
1962
+ console.warn(`[openclaw] kb: invalid credentials.json: ${e?.message ?? e}`);
1963
+ }
1964
+ }
1965
+ const wsDir = join(home, ".openclaw", "workspace");
1966
+ const shimDir = join(home, "__mcp_shims__", "anythingllm");
1967
+ const shimPath = join(shimDir, "anythingllm-shim.js");
1968
+ const secretPath = join(shimDir, "secret.json");
1969
+ const { mergeMcporterServers, removeMcporterServers } = await import("./openclaw-mcporter.js");
1970
+ if (kbCreds?.apiKey && kbCreds?.baseUrl) {
1971
+ const { substituteAnythingllmShimPlaceholders } = await import("../mcp-shims/anythingllm-shim.js");
1972
+ ensureDirContainer(shimDir);
1973
+ const shimSource = substituteAnythingllmShimPlaceholders({
1974
+ baseUrl: kbCreds.baseUrl,
1975
+ workspace: kbCreds.workspace || "default",
1976
+ });
1977
+ writeFileSync(shimPath, shimSource, { mode: 0o644 });
1978
+ // chmod after write to dodge umask; secret.json must be 0o600.
1979
+ writeFileSync(secretPath, JSON.stringify({ apiKey: kbCreds.apiKey }), { mode: 0o600 });
1980
+ try {
1981
+ chmodSync(secretPath, 0o600);
1982
+ }
1983
+ catch { /* best effort */ }
1984
+ mergeMcporterServers(instanceId, {
1985
+ kb: {
1986
+ command: "node",
1987
+ args: [shimPath],
1988
+ env: {
1989
+ // Belt-and-suspenders fallback for hand-running. Baked
1990
+ // values in the shim source are the production source
1991
+ // of truth for baseUrl + workspace; the API key is
1992
+ // intentionally NOT placed here — it lives in
1993
+ // `secret.json` (0o600) next to the shim so this 0o644
1994
+ // file stays free of secrets.
1995
+ ANYTHINGLLM_BASE_URL: kbCreds.baseUrl,
1996
+ ANYTHINGLLM_WORKSPACE: kbCreds.workspace || "default",
1997
+ },
1998
+ __source: {
1999
+ kind: "connection",
2000
+ slot: "knowledge",
2001
+ consumerInstanceId: instanceId,
2002
+ },
2003
+ },
2004
+ });
2005
+ patchToolsMdKbHint(wsDir, "install");
2006
+ }
2007
+ else {
2008
+ // Clean removal path (covers "AnythingLLM uninstalled / not
2009
+ // yet ready" AND "user explicitly unbound knowledge in
2010
+ // Connections tab" AND "user bound a different kb provider").
2011
+ removeMcporterServers(instanceId, {
2012
+ source: { kind: "connection", slot: "knowledge", consumerInstanceId: instanceId },
2013
+ });
2014
+ patchToolsMdKbHint(wsDir, "remove");
2015
+ try {
2016
+ if (existsSync(shimPath)) {
2017
+ writeFileSync(shimPath, "// removed: AnythingLLM not installed or knowledge unbound\n", { mode: 0o644 });
2018
+ }
2019
+ }
2020
+ catch { /* best effort */ }
2021
+ try {
2022
+ if (existsSync(secretPath))
2023
+ unlinkSync(secretPath);
2024
+ }
2025
+ catch { /* best effort */ }
2026
+ }
2027
+ }
2028
+ }
2029
+ catch (e) {
2030
+ console.warn(`[openclaw] kb shim wiring skipped: ${e?.message ?? e}`);
2031
+ }
2032
+ // 4c-bis. Self-heal MCPORTER_CONFIG env on existing instances. Without
2033
+ // this, mcporter can't find its config when invoked from the
2034
+ // gateway's CWD (openclaw-home/) or from the workspace symlink
2035
+ // (which points at user files, also no config/), so every drive
2036
+ // tool call fails with "Unknown MCP server 'drive'" and the agent
2037
+ // degrades into reporting a generic "network error". New instances
2038
+ // get this env via the binary/container runtime template; this
2039
+ // block back-fills it for instances created before that template
2040
+ // update so users don't have to recreate them.
2041
+ try {
2042
+ const im2 = await lazyIm();
2043
+ const rt = im2.getInstanceRuntime(instanceId);
2044
+ const home = openclawAdapter.resolveAgentHome(instanceId);
2045
+ if (home && rt && (!rt.env || !rt.env.MCPORTER_CONFIG)) {
2046
+ const desired = `${home}/.openclaw/workspace/config/mcporter.json`;
2047
+ const nextEnv = { ...(rt.env || {}), MCPORTER_CONFIG: desired };
2048
+ im2.updateInstanceMeta(instanceId, { runtime: { ...rt, env: nextEnv } });
2049
+ console.log(`[openclaw] self-healed MCPORTER_CONFIG env for ${instanceId}`);
2050
+ }
2051
+ }
2052
+ catch (e) {
2053
+ console.warn(`[openclaw] MCPORTER_CONFIG self-heal failed: ${e?.message ?? e}`);
2054
+ }
2055
+ // 4d. Self-heal mcporter bin: chmod cli.js + replace the npm-installed
2056
+ // symlink at .npm-global/bin/mcporter with a wrapper that pins
2057
+ // `--config <abs path>`. The wrapper is required because:
2058
+ // (a) npm install on Pi/ARM64 sometimes leaves cli.js as 0644
2059
+ // instead of 0755 (despite a valid shebang). Without +x,
2060
+ // spawning the bin returns "Permission denied".
2061
+ // (b) The OpenClaw bash tool scrubs env when spawning agent tool
2062
+ // subprocesses, so MCPORTER_CONFIG env doesn't reach mcporter,
2063
+ // and mcporter falls back to CWD-relative `config/mcporter.json`.
2064
+ // The agent's CWD is openclaw-home/ (no config/) or the
2065
+ // workspace symlink (also no config/) → "Unknown MCP server
2066
+ // 'drive'" → user sees "drive not configured" / "network
2067
+ // error" in chat.
2068
+ // The wrapper hardcodes both the cli.js path and the config path,
2069
+ // so it works regardless of CWD or env state.
2070
+ // Marker in wrapper body (`# jishushell mcporter wrapper`) lets us
2071
+ // detect when it's already installed and skip the rewrite.
2072
+ try {
2073
+ const home = openclawAdapter.resolveAgentHome(instanceId);
2074
+ if (home) {
2075
+ const mcporterCli = join(home, ".npm-global", "lib", "node_modules", "mcporter", "dist", "cli.js");
2076
+ const mcporterBin = join(home, ".npm-global", "bin", "mcporter");
2077
+ const mcporterConfig = join(home, ".openclaw", "workspace", "config", "mcporter.json");
2078
+ if (existsSync(mcporterCli)) {
2079
+ const st = statSync(mcporterCli);
2080
+ if (!(st.mode & 0o111)) {
2081
+ chmodSync(mcporterCli, 0o755);
2082
+ console.log(`[openclaw] +x ${mcporterCli} (mcporter cli.js self-heal)`);
2083
+ }
2084
+ }
2085
+ // Wrapper install: only proceed when cli.js exists; otherwise
2086
+ // mcporter isn't installed and there's nothing to wrap.
2087
+ if (existsSync(mcporterCli)) {
2088
+ const wrapperMarker = "# jishushell mcporter wrapper";
2089
+ let needsInstall = true;
2090
+ try {
2091
+ if (existsSync(mcporterBin)) {
2092
+ const lst = lstatSync(mcporterBin);
2093
+ if (lst.isFile() && !lst.isSymbolicLink()) {
2094
+ const first200 = readFileSync(mcporterBin, "utf8").slice(0, 200);
2095
+ if (first200.includes(wrapperMarker))
2096
+ needsInstall = false;
2097
+ }
2098
+ }
2099
+ }
2100
+ catch { /* fall through to install */ }
2101
+ if (needsInstall) {
2102
+ const wrapperSrc = `#!/bin/bash\n` +
2103
+ `${wrapperMarker} — pins --config so OpenClaw bash-tool env\n` +
2104
+ `# scrubbing or unexpected CWD cannot detach mcporter from the\n` +
2105
+ `# drive MCP server. Auto-installed by adapter onBeforeStart\n` +
2106
+ `# (src/services/runtime/adapters/openclaw.ts).\n` +
2107
+ `exec node ${JSON.stringify(mcporterCli)} --config ${JSON.stringify(mcporterConfig)} "$@"\n`;
2108
+ // Remove first to handle symlink → regular file transition cleanly.
2109
+ try {
2110
+ unlinkSync(mcporterBin);
2111
+ }
2112
+ catch { /* may not exist */ }
2113
+ writeFileSync(mcporterBin, wrapperSrc, { mode: 0o755 });
2114
+ console.log(`[openclaw] installed mcporter wrapper at ${mcporterBin}`);
2115
+ }
2116
+ // UNCONDITIONAL chmod regardless of whether we just wrote or
2117
+ // detected an existing wrapper. writeFileSync's mode option is
2118
+ // ignored when the file already exists, and a prior run that
2119
+ // hit an umask issue may have left it 0644. Always force 0755
2120
+ // so the agent can exec the wrapper.
2121
+ try {
2122
+ if (existsSync(mcporterBin)) {
2123
+ const wst = statSync(mcporterBin);
2124
+ if (!(wst.mode & 0o111)) {
2125
+ chmodSync(mcporterBin, 0o755);
2126
+ console.log(`[openclaw] +x ${mcporterBin} (wrapper chmod self-heal)`);
2127
+ }
2128
+ }
2129
+ }
2130
+ catch { /* best effort */ }
2131
+ }
2132
+ }
2133
+ }
2134
+ catch (e) {
2135
+ console.warn(`[openclaw] mcporter self-heal failed: ${e?.message ?? e}`);
2136
+ }
845
2137
  // 5. Docker image validation + background pull fallback
846
2138
  if (getNomadDriver() === "docker") {
847
2139
  const image = getOpenclawDockerImage();
@@ -1041,6 +2333,21 @@ class OpenClawAdapter {
1041
2333
  runtime = { ...baseRuntime, ...compiled };
1042
2334
  }
1043
2335
  }
2336
+ // W2: every new instance gets a default rw mount on its own
2337
+ // agent-data/{id} subtree (alias _out). Cloned instances inherit
2338
+ // their source's mounts but have agent-data path rewritten to the
2339
+ // new instance id; we keep this simple here and just plant the
2340
+ // default — clone-from semantics for additional mounts can be
2341
+ // tightened in PR-7+ when migration arrives.
2342
+ if (!runtime.fileMounts && !runtime.file_mounts) {
2343
+ runtime.fileMounts = defaultMountsForNewInstance(instanceId);
2344
+ }
2345
+ try {
2346
+ ensureMountTargets(FILES_ROOT, readFileMounts(runtime));
2347
+ }
2348
+ catch (e) {
2349
+ console.warn(`[openclaw] could not pre-create mount targets for ${instanceId}: ${e?.message ?? e}`);
2350
+ }
1044
2351
  const allocatedPort = extractGatewayPort(runtime);
1045
2352
  try {
1046
2353
  const meta = {
@@ -1323,12 +2630,14 @@ class OpenClawAdapter {
1323
2630
  throw new Error(`Invalid runtime user: ${rawRuntime.user}`);
1324
2631
  }
1325
2632
  const image = rawRuntime.image || getOpenclawDockerImage();
1326
- const command = String(rawRuntime.command || DEFAULT_COMMAND);
2633
+ const _command = String(rawRuntime.command || DEFAULT_COMMAND);
1327
2634
  const args = Array.isArray(rawRuntime.args)
1328
2635
  ? rawRuntime.args.map(String)
1329
2636
  : [...DEFAULT_ARGS];
1330
2637
  const env = { ...DEFAULT_ENV };
1331
2638
  Object.assign(env, im.getRuntimeEnv(instanceId));
2639
+ decryptRuntimeProviderEnv(env);
2640
+ injectProviderHostEnv(env, instanceId);
1332
2641
  delete env.JSPROXY_API_KEY; // supplied via Nomad Template from Variables
1333
2642
  env.OPENCLAW_HOME = openclawHome;
1334
2643
  env.OPENCLAW_INSTANCE_ID = instanceId;
@@ -1387,9 +2696,14 @@ class OpenClawAdapter {
1387
2696
  // timeout is too short for Pi-class networks pulling a 1+ GiB
1388
2697
  // openclaw runtime image; bump to 15 minutes.
1389
2698
  image_pull_timeout: "15m",
2699
+ // Use the JishuShell entrypoint wrapper that applies the session
2700
+ // fence patch before deferring to the image's original entrypoint.
2701
+ // The wrapper script is written to $HOME/.jishushell/entrypoint.sh
2702
+ // by onBeforeStart (writeContainerPatchScript).
2703
+ entrypoint: [`${openclawHome}/.jishushell/entrypoint.sh`],
1390
2704
  args,
1391
2705
  work_dir: openclawHome,
1392
- volumes: [`${openclawHome}:${openclawHome}:rw`],
2706
+ volumes: buildVolumes(openclawHome, im.getInstanceRuntime(instanceId)),
1393
2707
  // Tell the docker driver to publish the labeled "gateway" port so
1394
2708
  // it routes via the host_network IP rather than the 127.0.0.1
1395
2709
  // default.
@@ -1583,47 +2897,71 @@ class OpenClawAdapter {
1583
2897
  * dist bundle).
1584
2898
  */
1585
2899
  async applyConnectionEnv(instanceId, env) {
2900
+ const configPath = openclawConfigPath(instanceId);
1586
2901
  const searchUrl = env.SEARCH_API_BASE_URL;
1587
- if (typeof searchUrl !== "string")
1588
- return;
1589
- if (searchUrl === "") {
1590
- // Empty value connection-transactor's UNPERSIST_HOOKS uses this as
1591
- // the "unbind" signal. Clear the searxng plugin config so the next
1592
- // start doesn't keep routing web_search through a now-disconnected
1593
- // provider.
1594
- try {
1595
- clearSearxngConnectionFromConfig(openclawConfigPath(instanceId));
2902
+ if (typeof searchUrl === "string") {
2903
+ if (searchUrl === "") {
2904
+ // Empty value connection-transactor's UNPERSIST_HOOKS uses this as
2905
+ // the "unbind" signal. Clear the searxng plugin config so the next
2906
+ // start doesn't keep routing web_search through a now-disconnected
2907
+ // provider.
2908
+ try {
2909
+ clearSearxngConnectionFromConfig(configPath);
2910
+ }
2911
+ catch (e) {
2912
+ console.warn(`[openclaw] applyConnectionEnv search unbind failed for ${instanceId}: ${e.message}`);
2913
+ }
1596
2914
  }
1597
- catch (e) {
1598
- console.warn(`[openclaw] applyConnectionEnv unbind failed for ${instanceId}: ${e.message}`);
2915
+ else {
2916
+ // SEARCH_API_BASE_URL points at "<base>/search" (the SearXNG search
2917
+ // endpoint). The plugin's webSearch.baseUrl wants the bare origin —
2918
+ // strip the trailing "/search" path segment if present.
2919
+ // baseUrl stays at the registry-resolved host:port snapshot from
2920
+ // when the user PUT /connections; the framework re-runs this hook
2921
+ // on every instance start (PR 9 phaseRefreshConnections), so host
2922
+ // IP changes propagate automatically on next agent restart.
2923
+ let baseUrl = searchUrl;
2924
+ try {
2925
+ const u = new URL(searchUrl);
2926
+ if (u.pathname === "/search" || u.pathname === "/search/") {
2927
+ u.pathname = "";
2928
+ baseUrl = u.toString().replace(/\/$/, "");
2929
+ }
2930
+ try {
2931
+ applySearxngConnectionToConfig(configPath, baseUrl);
2932
+ }
2933
+ catch (e) {
2934
+ console.warn(`[openclaw] applyConnectionEnv search merge failed for ${instanceId}: ${e.message}`);
2935
+ }
2936
+ }
2937
+ catch {
2938
+ // not a URL — skip silently; the openclaw plugin would break
2939
+ // with a non-URL baseUrl, and start should still proceed.
2940
+ }
1599
2941
  }
1600
- return;
1601
2942
  }
1602
- // SEARCH_API_BASE_URL points at "<base>/search" (the SearXNG search
1603
- // endpoint). The plugin's webSearch.baseUrl wants the bare origin —
1604
- // strip the trailing "/search" path segment if present.
1605
- // baseUrl stays at the registry-resolved host:port snapshot from
1606
- // when the user PUT /connections; the framework re-runs this hook
1607
- // on every instance start (PR 9 phaseRefreshConnections), so host
1608
- // IP changes propagate automatically on next agent restart.
1609
- let baseUrl = searchUrl;
1610
- try {
1611
- const u = new URL(searchUrl);
1612
- if (u.pathname === "/search" || u.pathname === "/search/") {
1613
- u.pathname = "";
1614
- baseUrl = u.toString().replace(/\/$/, "");
2943
+ const cdpUrl = env.BROWSER_CDP_URL;
2944
+ if (typeof cdpUrl === "string") {
2945
+ if (cdpUrl === "") {
2946
+ try {
2947
+ clearBrowserlessConnectionFromConfig(configPath);
2948
+ }
2949
+ catch (e) {
2950
+ console.warn(`[openclaw] applyConnectionEnv browser unbind failed for ${instanceId}: ${e.message}`);
2951
+ }
1615
2952
  }
1616
- }
1617
- catch {
1618
- // not a URL — abort, the openclaw plugin would silently break with
1619
- // a non-URL baseUrl and the instance still starts but search fails.
1620
- return;
1621
- }
1622
- try {
1623
- applySearxngConnectionToConfig(openclawConfigPath(instanceId), baseUrl);
1624
- }
1625
- catch (e) {
1626
- console.warn(`[openclaw] applyConnectionEnv merge failed for ${instanceId}: ${e.message}`);
2953
+ else if (/^wss?:\/\//.test(cdpUrl)) {
2954
+ try {
2955
+ applyBrowserlessConnectionToConfig(configPath, cdpUrl);
2956
+ }
2957
+ catch (e) {
2958
+ console.warn(`[openclaw] applyConnectionEnv browser merge failed for ${instanceId}: ${e.message}`);
2959
+ }
2960
+ }
2961
+ // Non-ws scheme: skip silently. The connection-apply browser hook
2962
+ // already builds ws:// from the capability protocol; an http:// here
2963
+ // would mean a misconfigured provider — better to no-op than to
2964
+ // write a URL OpenClaw can't dial.
1627
2965
  }
1628
2966
  }
1629
2967
  // ── Path resolvers (physically migrated) ───────────────────────────
@@ -2112,6 +3450,10 @@ function prepareConfigForSave(instanceId, config) {
2112
3450
  if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
2113
3451
  p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
2114
3452
  }
3453
+ // Ensure allowPrivateNetwork for providers targeting the local proxy
3454
+ if (typeof p.baseUrl === "string" && isPrivateNetworkBaseUrl(p.baseUrl)) {
3455
+ p.request = { ...(p.request || {}), allowPrivateNetwork: true };
3456
+ }
2115
3457
  if (!("apiKey" in p))
2116
3458
  continue;
2117
3459
  if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
@@ -2119,7 +3461,7 @@ function prepareConfigForSave(instanceId, config) {
2119
3461
  const apiKey = p.apiKey;
2120
3462
  delete p.apiKey;
2121
3463
  if (envFiles.length) {
2122
- envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
3464
+ envUpdates[inferProviderApiKeyEnvName(providerId)] = decryptRuntimeProviderApiKey(apiKey);
2123
3465
  }
2124
3466
  else {
2125
3467
  p.apiKey = apiKey;
@@ -2142,6 +3484,56 @@ function prepareConfigForSave(instanceId, config) {
2142
3484
  }
2143
3485
  return [configToWrite, envUpdates];
2144
3486
  }
3487
+ function decryptRuntimeProviderApiKey(apiKey) {
3488
+ const value = String(apiKey || "");
3489
+ return value.startsWith("enc:") ? decryptApiKey(value) : value;
3490
+ }
3491
+ function decryptRuntimeProviderEnv(env) {
3492
+ for (const [key, value] of Object.entries(env)) {
3493
+ if (!key.endsWith("_API_KEY"))
3494
+ continue;
3495
+ if (!String(value || "").startsWith("enc:"))
3496
+ continue;
3497
+ env[key] = decryptApiKey(value);
3498
+ }
3499
+ }
3500
+ /**
3501
+ * Auto-injects provider-specific host env vars that OpenClaw tools need
3502
+ * at runtime, if the user hasn't already set them explicitly.
3503
+ *
3504
+ * Currently handles:
3505
+ * - MINIMAX_API_HOST: Derived from MINIMAX_BASE_URL or upstream proxy config
3506
+ * to ensure the VLM tool calls the correct MiniMax API domain.
3507
+ *
3508
+ * Note: Only MiniMax needs this because its VLM tool reads MINIMAX_API_HOST
3509
+ * to determine the endpoint. DeepSeek does not have a dedicated VLM tool
3510
+ * in OpenClaw that requires a similar host env var.
3511
+ */
3512
+ function injectProviderHostEnv(env, instanceId) {
3513
+ // MINIMAX_API_HOST — needed by OpenClaw's VLM tool to call the correct domain
3514
+ if (!env.MINIMAX_API_HOST) {
3515
+ const baseUrl = env.MINIMAX_BASE_URL || "";
3516
+ if (baseUrl) {
3517
+ try {
3518
+ const parsed = new URL(baseUrl);
3519
+ env.MINIMAX_API_HOST = `${parsed.protocol}//${parsed.host}`;
3520
+ }
3521
+ catch { /* invalid URL, skip */ }
3522
+ }
3523
+ else {
3524
+ // Infer from upstream proxy config if available
3525
+ const config = getProxyInstanceConfig(instanceId);
3526
+ const upstream = config?.["x-jishushell"]?.proxy?.upstream;
3527
+ if (upstream?.providerId === "minimax" && upstream.baseUrl) {
3528
+ try {
3529
+ const parsed = new URL(upstream.baseUrl);
3530
+ env.MINIMAX_API_HOST = `${parsed.protocol}//${parsed.host}`;
3531
+ }
3532
+ catch { /* invalid URL, skip */ }
3533
+ }
3534
+ }
3535
+ }
3536
+ }
2145
3537
  /**
2146
3538
  * Dissociate a cloned/imported config from its source instance's IM bindings.
2147
3539
  * Physically migrated from `instance-manager.stripImBindings` so framework
@@ -2172,9 +3564,27 @@ function getStockExtensionsDir() {
2172
3564
  function isChannelPluginInstalled(instanceId, channelId) {
2173
3565
  const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
2174
3566
  const stockExtDir = getStockExtensionsDir();
2175
- return (existsSync(join(getChannelExtensionsDir(instanceId), extDirName)) ||
2176
- existsSync(join(stockExtDir, extDirName)) ||
2177
- (extDirName !== channelId && existsSync(join(stockExtDir, channelId))));
3567
+ if (existsSync(join(getChannelExtensionsDir(instanceId), extDirName)))
3568
+ return true;
3569
+ if (existsSync(join(stockExtDir, extDirName)))
3570
+ return true;
3571
+ if (extDirName !== channelId && existsSync(join(stockExtDir, channelId)))
3572
+ return true;
3573
+ // OpenClaw's npm-backed plugin store lives at
3574
+ // `<home>/.openclaw/npm/node_modules/<pkg>`. The CLI refuses to reinstall
3575
+ // when that path is present ("plugin already exists ... delete it first"),
3576
+ // even if the per-instance extensions/<name> dir is missing (e.g. the
3577
+ // first install was interrupted, or the dir was manually cleaned). Treat
3578
+ // the npm path as authoritative so re-saves stay idempotent.
3579
+ const pkg = CHANNEL_PLUGIN_MAP[channelId];
3580
+ if (pkg) {
3581
+ const home = getInstance(instanceId)?.openclaw_home ||
3582
+ defaultOpenclawHome(instanceId);
3583
+ const npmPath = join(home, OPENCLAW_STATE_DIRNAME, "npm", "node_modules", ...pkg.split("/"));
3584
+ if (existsSync(npmPath))
3585
+ return true;
3586
+ }
3587
+ return false;
2178
3588
  }
2179
3589
  /**
2180
3590
  * Install a single channel plugin. Docker mode → `docker exec` inside the