jishushell 0.4.10 → 0.4.24

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 (248) hide show
  1. package/Dockerfile.hermes-slim +193 -0
  2. package/INSTALL-NOTICE +10 -12
  3. package/apps/hermes-container.yaml +35 -0
  4. package/apps/ollama-binary.yaml +200 -0
  5. package/apps/ollama-cpu-container.yaml +37 -0
  6. package/apps/ollama-with-hollama-binary.yaml +195 -0
  7. package/apps/openclaw-binary.yaml +69 -0
  8. package/apps/openclaw-container.yaml +37 -0
  9. package/apps/openclaw-with-ollama-container.yaml +42 -0
  10. package/apps/openclaw-with-searxng-container.yaml +136 -0
  11. package/apps/openwebui-container.yaml +53 -0
  12. package/apps/playwright-container.yaml +120 -0
  13. package/apps/searxng-container.yaml +115 -0
  14. package/dist/auth.d.ts +1 -0
  15. package/dist/auth.js +15 -14
  16. package/dist/auth.js.map +1 -1
  17. package/dist/cli/app.d.ts +4 -0
  18. package/dist/cli/app.js +814 -0
  19. package/dist/cli/app.js.map +1 -0
  20. package/dist/cli/backup.d.ts +3 -0
  21. package/dist/cli/backup.js +434 -0
  22. package/dist/cli/backup.js.map +1 -0
  23. package/dist/{doctor.d.ts → cli/doctor.d.ts} +7 -1
  24. package/dist/{doctor.js → cli/doctor.js} +377 -22
  25. package/dist/cli/doctor.js.map +1 -0
  26. package/dist/cli/helpers.d.ts +4 -0
  27. package/dist/cli/helpers.js +32 -0
  28. package/dist/cli/helpers.js.map +1 -0
  29. package/dist/cli/job.d.ts +4 -0
  30. package/dist/cli/job.js +198 -0
  31. package/dist/cli/job.js.map +1 -0
  32. package/dist/cli/llm.d.ts +25 -0
  33. package/dist/cli/llm.js +599 -0
  34. package/dist/cli/llm.js.map +1 -0
  35. package/dist/cli/managed-list.d.ts +30 -0
  36. package/dist/cli/managed-list.js +129 -0
  37. package/dist/cli/managed-list.js.map +1 -0
  38. package/dist/cli/panel.d.ts +26 -0
  39. package/dist/cli/panel.js +804 -0
  40. package/dist/cli/panel.js.map +1 -0
  41. package/dist/cli/version.d.ts +1 -0
  42. package/dist/cli/version.js +12 -0
  43. package/dist/cli/version.js.map +1 -0
  44. package/dist/cli.js +48 -776
  45. package/dist/cli.js.map +1 -1
  46. package/dist/config.d.ts +69 -0
  47. package/dist/config.js +268 -7
  48. package/dist/config.js.map +1 -1
  49. package/dist/control.d.ts +17 -41
  50. package/dist/control.js +61 -1323
  51. package/dist/control.js.map +1 -1
  52. package/dist/install.d.ts +16 -0
  53. package/dist/install.js +75 -26
  54. package/dist/install.js.map +1 -1
  55. package/dist/routes/agent-apps.d.ts +15 -0
  56. package/dist/routes/agent-apps.js +78 -0
  57. package/dist/routes/agent-apps.js.map +1 -0
  58. package/dist/routes/apps.d.ts +3 -0
  59. package/dist/routes/apps.js +278 -0
  60. package/dist/routes/apps.js.map +1 -0
  61. package/dist/routes/backup.js +3 -3
  62. package/dist/routes/backup.js.map +1 -1
  63. package/dist/routes/instances.d.ts +6 -0
  64. package/dist/routes/instances.js +863 -874
  65. package/dist/routes/instances.js.map +1 -1
  66. package/dist/routes/llm.d.ts +15 -0
  67. package/dist/routes/llm.js +247 -0
  68. package/dist/routes/llm.js.map +1 -0
  69. package/dist/routes/runtime.d.ts +15 -0
  70. package/dist/routes/runtime.js +69 -0
  71. package/dist/routes/runtime.js.map +1 -0
  72. package/dist/routes/setup.js +131 -9
  73. package/dist/routes/setup.js.map +1 -1
  74. package/dist/routes/system.js +56 -9
  75. package/dist/routes/system.js.map +1 -1
  76. package/dist/server.js +107 -7
  77. package/dist/server.js.map +1 -1
  78. package/dist/services/agent-apps/catalog.d.ts +30 -0
  79. package/dist/services/agent-apps/catalog.js +60 -0
  80. package/dist/services/agent-apps/catalog.js.map +1 -0
  81. package/dist/services/agent-apps/index.d.ts +36 -0
  82. package/dist/services/agent-apps/index.js +171 -0
  83. package/dist/services/agent-apps/index.js.map +1 -0
  84. package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
  85. package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
  86. package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
  87. package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
  88. package/dist/services/agent-apps/installers/adapter.js +171 -0
  89. package/dist/services/agent-apps/installers/adapter.js.map +1 -0
  90. package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
  91. package/dist/services/agent-apps/installers/registry-probe.js +183 -0
  92. package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
  93. package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
  94. package/dist/services/agent-apps/installers/shell-script.js +471 -0
  95. package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
  96. package/dist/services/agent-apps/types.d.ts +125 -0
  97. package/dist/services/agent-apps/types.js +17 -0
  98. package/dist/services/agent-apps/types.js.map +1 -0
  99. package/dist/services/app/app-compiler.d.ts +15 -0
  100. package/dist/services/app/app-compiler.js +172 -0
  101. package/dist/services/app/app-compiler.js.map +1 -0
  102. package/dist/services/app/app-manager.d.ts +142 -0
  103. package/dist/services/app/app-manager.js +1988 -0
  104. package/dist/services/app/app-manager.js.map +1 -0
  105. package/dist/services/app/custom-manager.d.ts +27 -0
  106. package/dist/services/app/custom-manager.js +285 -0
  107. package/dist/services/app/custom-manager.js.map +1 -0
  108. package/dist/services/app/hermes-agent-manager.d.ts +20 -0
  109. package/dist/services/app/hermes-agent-manager.js +289 -0
  110. package/dist/services/app/hermes-agent-manager.js.map +1 -0
  111. package/dist/services/app/id-normalizer.d.ts +27 -0
  112. package/dist/services/app/id-normalizer.js +77 -0
  113. package/dist/services/app/id-normalizer.js.map +1 -0
  114. package/dist/services/app/ollama-manager.d.ts +18 -0
  115. package/dist/services/app/ollama-manager.js +207 -0
  116. package/dist/services/app/ollama-manager.js.map +1 -0
  117. package/dist/services/app/openclaw-manager.d.ts +63 -0
  118. package/dist/services/app/openclaw-manager.js +1178 -0
  119. package/dist/services/app/openclaw-manager.js.map +1 -0
  120. package/dist/services/app/paths.d.ts +47 -0
  121. package/dist/services/app/paths.js +68 -0
  122. package/dist/services/app/paths.js.map +1 -0
  123. package/dist/services/app/registry.d.ts +17 -0
  124. package/dist/services/app/registry.js +31 -0
  125. package/dist/services/app/registry.js.map +1 -0
  126. package/dist/services/app/remote-spec.d.ts +14 -0
  127. package/dist/services/app/remote-spec.js +58 -0
  128. package/dist/services/app/remote-spec.js.map +1 -0
  129. package/dist/services/app/terminal-session-manager.d.ts +27 -0
  130. package/dist/services/app/terminal-session-manager.js +157 -0
  131. package/dist/services/app/terminal-session-manager.js.map +1 -0
  132. package/dist/services/app/types.d.ts +72 -0
  133. package/dist/services/app/types.js +16 -0
  134. package/dist/services/app/types.js.map +1 -0
  135. package/dist/services/backup-manager.js +60 -22
  136. package/dist/services/backup-manager.js.map +1 -1
  137. package/dist/services/instance-manager.d.ts +125 -34
  138. package/dist/services/instance-manager.js +679 -1043
  139. package/dist/services/instance-manager.js.map +1 -1
  140. package/dist/services/llm-proxy/adapters.js +5 -1
  141. package/dist/services/llm-proxy/adapters.js.map +1 -1
  142. package/dist/services/llm-proxy/circuit-breaker.js +10 -2
  143. package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
  144. package/dist/services/llm-proxy/index.d.ts +43 -0
  145. package/dist/services/llm-proxy/index.js +120 -5
  146. package/dist/services/llm-proxy/index.js.map +1 -1
  147. package/dist/services/llm-proxy/ssrf.js +1 -1
  148. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  149. package/dist/services/nomad-manager.d.ts +260 -3
  150. package/dist/services/nomad-manager.js +2921 -341
  151. package/dist/services/nomad-manager.js.map +1 -1
  152. package/dist/services/panel-manager.d.ts +50 -0
  153. package/dist/services/panel-manager.js +443 -0
  154. package/dist/services/panel-manager.js.map +1 -0
  155. package/dist/services/plugin-installer.js +28 -2
  156. package/dist/services/plugin-installer.js.map +1 -1
  157. package/dist/services/process-manager.js +42 -7
  158. package/dist/services/process-manager.js.map +1 -1
  159. package/dist/services/runtime/adapters/custom.d.ts +20 -0
  160. package/dist/services/runtime/adapters/custom.js +90 -0
  161. package/dist/services/runtime/adapters/custom.js.map +1 -0
  162. package/dist/services/runtime/adapters/hermes.d.ts +174 -0
  163. package/dist/services/runtime/adapters/hermes.js +1316 -0
  164. package/dist/services/runtime/adapters/hermes.js.map +1 -0
  165. package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
  166. package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
  167. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
  168. package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
  169. package/dist/services/runtime/adapters/openclaw.js +2195 -0
  170. package/dist/services/runtime/adapters/openclaw.js.map +1 -0
  171. package/dist/services/runtime/errors.d.ts +28 -0
  172. package/dist/services/runtime/errors.js +31 -0
  173. package/dist/services/runtime/errors.js.map +1 -0
  174. package/dist/services/runtime/index.d.ts +34 -0
  175. package/dist/services/runtime/index.js +51 -0
  176. package/dist/services/runtime/index.js.map +1 -0
  177. package/dist/services/runtime/instance.d.ts +24 -0
  178. package/dist/services/runtime/instance.js +143 -0
  179. package/dist/services/runtime/instance.js.map +1 -0
  180. package/dist/services/runtime/migrations.d.ts +15 -0
  181. package/dist/services/runtime/migrations.js +25 -0
  182. package/dist/services/runtime/migrations.js.map +1 -0
  183. package/dist/services/runtime/registry.d.ts +13 -0
  184. package/dist/services/runtime/registry.js +32 -0
  185. package/dist/services/runtime/registry.js.map +1 -0
  186. package/dist/services/runtime/types.d.ts +545 -0
  187. package/dist/services/runtime/types.js +14 -0
  188. package/dist/services/runtime/types.js.map +1 -0
  189. package/dist/services/setup-manager.d.ts +70 -29
  190. package/dist/services/setup-manager.js +591 -625
  191. package/dist/services/setup-manager.js.map +1 -1
  192. package/dist/services/task-registry.d.ts +44 -0
  193. package/dist/services/task-registry.js +74 -0
  194. package/dist/services/task-registry.js.map +1 -0
  195. package/dist/services/telemetry/heartbeat.d.ts +6 -6
  196. package/dist/services/telemetry/heartbeat.js +29 -30
  197. package/dist/services/telemetry/heartbeat.js.map +1 -1
  198. package/dist/services/update-manager.d.ts +47 -0
  199. package/dist/services/update-manager.js +305 -0
  200. package/dist/services/update-manager.js.map +1 -0
  201. package/dist/types.d.ts +222 -0
  202. package/dist/utils/docker-host.d.ts +15 -0
  203. package/dist/utils/docker-host.js +64 -0
  204. package/dist/utils/docker-host.js.map +1 -0
  205. package/install/jishu-install.sh +303 -37
  206. package/install/post-install.sh +64 -5
  207. package/package.json +19 -5
  208. package/public/assets/Dashboard-B-JoOjBQ.js +1 -0
  209. package/public/assets/HermesChatPanel-mFSureyc.js +1 -0
  210. package/public/assets/HermesConfigForm-DvR05LK1.js +4 -0
  211. package/public/assets/InitPassword-CVA8wQA6.js +1 -0
  212. package/public/assets/InstanceDetail-DcZW2QGO.js +91 -0
  213. package/public/assets/{Login-CUoEZOWR.js → Login-BWsZH2mu.js} +1 -1
  214. package/public/assets/NewInstance-BCIrAd86.js +1 -0
  215. package/public/assets/Settings-xkDcduFz.js +1 -0
  216. package/public/assets/Setup-Cfuwj4gV.js +1 -0
  217. package/public/assets/WeixinLoginPanel-CnjR8xMu.js +9 -0
  218. package/public/assets/index-CPhVFEsx.css +1 -0
  219. package/public/assets/index-DQsM6Joa.js +19 -0
  220. package/public/assets/input-paste-CrNVAyOy.js +1 -0
  221. package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
  222. package/public/assets/registry-B4UFJdpA.js +2 -0
  223. package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
  224. package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
  225. package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
  226. package/public/favicon.png +0 -0
  227. package/public/index.html +9 -4
  228. package/public/logos/hermes.png +0 -0
  229. package/public/logos/ollama.png +0 -0
  230. package/public/logos/openclaw.svg +60 -0
  231. package/scripts/build-hermes-image.sh +21 -0
  232. package/scripts/build-local.sh +54 -0
  233. package/scripts/check-adapter-isolation.ts +293 -0
  234. package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
  235. package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
  236. package/scripts/smoke/hermes-bootstrap.sh +195 -0
  237. package/templates/hermes-entrypoint.sh +154 -0
  238. package/dist/doctor.js.map +0 -1
  239. package/install/jishu-install-china.sh +0 -3092
  240. package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
  241. package/public/assets/InitPassword-BjubiVdd.js +0 -1
  242. package/public/assets/InstanceDetail-DMcywsof.js +0 -17
  243. package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
  244. package/public/assets/Settings-D5tHL_h5.js +0 -1
  245. package/public/assets/Setup-4t6E3Rut.js +0 -1
  246. package/public/assets/index-BJ47MWpF.css +0 -1
  247. package/public/assets/index-DbX85irc.js +0 -16
  248. package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
@@ -0,0 +1,2195 @@
1
+ /**
2
+ * OpenClawAdapter — the framework face of OpenClaw business logic.
3
+ *
4
+ * Per §32.2 / §32.8 of docs/multi-agent-runtime-generalization-plan.md, the
5
+ * goal of this file is simple: **all OpenClaw-specific knowledge leaves the
6
+ * framework (instance-manager / nomad-manager / routes) and lives here**.
7
+ *
8
+ * Why not a huge rewrite in a single pass?
9
+ * A full physical migration of ~6500 lines of OpenClaw code out of
10
+ * `instance-manager.ts` / `nomad-manager.ts` / `setup-manager.ts` is a
11
+ * multi-PR undertaking. This file lands the **structural** decoupling
12
+ * (contract + dispatch) today, so:
13
+ *
14
+ * 1. Every framework call site now goes through `getAdapter(agentType).X()`
15
+ * with zero string-literal branching on "openclaw" / "hermes".
16
+ * 2. OpenClaw-specific imperative logic that was already contained (Nomad
17
+ * task build, on-disk patches, npm update seed, pairing CLI mapping)
18
+ * is physically moved into this file.
19
+ * 3. Larger pieces that are still entangled inside instance-manager.ts
20
+ * (`saveConfig` with channel-plugin auto-install, `createInstance`
21
+ * with `openclaw.json` seeding) are exposed through adapter hooks
22
+ * that framework code calls, and the physical code move from
23
+ * instance-manager.ts into a sibling `openclaw-*.ts` file is a
24
+ * straightforward follow-up PR — the `check-adapter-isolation.ts`
25
+ * script (§32.2.1) will block any new code from sneaking back into
26
+ * the framework layer.
27
+ *
28
+ * The user contract for adding a THIRD agent is therefore already satisfied
29
+ * even before the follow-up physical migration:
30
+ *
31
+ * 1. Create `src/services/runtime/adapters/foo.ts` mirroring this file.
32
+ * 2. Add `import "./adapters/foo.js"` to `src/services/runtime/index.ts`.
33
+ * 3. Add one line to `frontend/src/runtimes/registry.ts`.
34
+ * 4. Done. No `instance-manager.ts` / `nomad-manager.ts` / routes edits.
35
+ */
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";
39
+ import { homedir, userInfo } from "os";
40
+ import { dirname, join, resolve as pathResolve } from "path";
41
+ import { getNomadDriver, getOpenclawDockerImage, JISHUSHELL_HOME, getPanelConfig, } from "../../../config.js";
42
+ import { LEGACY_PROVIDER_API_ALIASES } from "../../../constants.js";
43
+ import { ensureDirContainer, ensureDirHost, writeConfigFile } from "../../../utils/fs.js";
44
+ import { safeWriteJson } from "../../../utils/safe-json.js";
45
+ import { compileTaskRuntime } from "../../app/app-compiler.js";
46
+ import { allocateGatewayPort, chownToServiceUser, extractGatewayPort, getInstance, getRuntimeEnv, getRuntimeEnvFiles, inferProviderApiKeyEnvName, listInstances, normalizePath, notifyConfigChange, releasePendingPort, resolveServiceUser, updateEnvFile, } from "../../instance-manager.js";
47
+ import { getInstanceDir as framework_instanceDir, instanceMetaPath } from "../../../config.js";
48
+ import { createTask, emitTask, spawnWithTask, getDirSizeMB, npmProgressParser, dockerBuildProgressParser, resolveDockerInvocation, } from "../../setup-manager.js";
49
+ import { DEFAULT_OPENCLAW_DOCKER_IMAGE, setOpenclawDockerImage, OPENCLAW_MODULES, OPENCLAW_PKG_DIR, } from "../../../config.js";
50
+ import { fileURLToPath } from "node:url";
51
+ import { bootstrapInstanceProxy } from "../../llm-proxy/index.js";
52
+ import { registerAdapter } from "../registry.js";
53
+ import { registerOpenclawRoutes } from "./openclaw-routes.js";
54
+ // ── Constants physically migrated from nomad-manager.ts ───────────────
55
+ //
56
+ // These used to live as module-scope constants in nomad-manager.ts and were
57
+ // read by `buildTaskDocker` / `buildRuntime` helpers. They describe how
58
+ // OpenClaw expects to be launched — container image paths, default command,
59
+ // memory ceilings — and therefore belong to the OpenClaw adapter, not the
60
+ // Nomad scheduler.
61
+ const DEFAULT_COMMAND = "/usr/bin/openclaw";
62
+ const DEFAULT_ARGS = ["gateway", "run", "--port", "18789", "--allow-unconfigured"];
63
+ const DEFAULT_USER = userInfo().username;
64
+ const DEFAULT_CWD = homedir();
65
+ const DEFAULT_ENV = {
66
+ HOME: homedir(),
67
+ TMPDIR: "/tmp",
68
+ PATH: `${homedir()}/.local/bin:${homedir()}/.npm-global/bin:${homedir()}/bin:${homedir()}/.volta/bin:` +
69
+ `${homedir()}/.asdf/shims:${homedir()}/.bun/bin:${homedir()}/.nvm/current/bin:${homedir()}/.fnm/current/bin:` +
70
+ `${homedir()}/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin`,
71
+ };
72
+ const DEFAULT_RESOURCES = { CPU: 500, MemoryMB: 512 };
73
+ const DEFAULT_PIDS_LIMIT = 512;
74
+ // Hard upper bounds applied before submitting any Nomad job. Prevents a
75
+ // misconfigured or malicious instance config from exhausting scheduler
76
+ // resources on the host (no Nomad Enterprise Resource Quotas in OSS).
77
+ const MAX_CPU_MHZ = 4000;
78
+ const MAX_MEMORY_MB = 4096;
79
+ const MAX_MEMORY_MAX_MB = 4096;
80
+ // Path inside the openclaw-runtime Docker image where the baked-in openclaw
81
+ // npm package lives. Referenced by the entrypoint shim as the fallback and
82
+ // used by the control-UI "Update now" path through a pre-seeded symlink in
83
+ // $HOME/.npm-global (see ensureOpenclawUpdateSeed below).
84
+ const CONTAINER_IMAGE_PKG_ROOT = "/app/node_modules/openclaw";
85
+ const VALID_USER_RE = /^[a-z0-9._-]{1,32}$/;
86
+ const DOCKER_IMAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/;
87
+ const MAX_DOCKER_IMAGE_NAME_LEN = 256;
88
+ export const OPENCLAW_DEFAULT_GATEWAY_PORT = 18789;
89
+ // ── Capability profile (moved from runtime/instance.ts) ──────────────
90
+ //
91
+ // Describes what the framework should expose for OpenClaw instances. The
92
+ // frontend uses this to decide which tabs render and how the Chat tab looks.
93
+ const DEFAULT_CAPABILITIES = {
94
+ gateway: {
95
+ http: true,
96
+ websocket: true,
97
+ chatPanel: "iframe",
98
+ },
99
+ pairing: {
100
+ list: true,
101
+ approve: true,
102
+ revoke: false,
103
+ clearPending: false,
104
+ },
105
+ configEditor: "json",
106
+ configSchema: false,
107
+ customProvider: true,
108
+ pluginInstall: true,
109
+ skills: true,
110
+ mcp: true,
111
+ memory: true,
112
+ backupRestore: true,
113
+ usageStats: true,
114
+ restartlessReload: false,
115
+ messagingPlatforms: ["feishu", "openclaw-weixin"],
116
+ };
117
+ // ── Path helpers (physically migrated from instance-manager.ts) ───────
118
+ const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
119
+ const INSTANCE_MODEL_ENV_FILENAME = "model.env";
120
+ const OPENCLAW_STATE_DIRNAME = ".openclaw";
121
+ const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
122
+ function defaultOpenclawHome(instanceId) {
123
+ return join(framework_instanceDir(instanceId), INSTANCE_OPENCLAW_HOME_DIRNAME);
124
+ }
125
+ function defaultOpenclawModelEnvFile(instanceId) {
126
+ return join(framework_instanceDir(instanceId), INSTANCE_MODEL_ENV_FILENAME);
127
+ }
128
+ function isPrecreatedManagedAppDir(dir) {
129
+ return existsSync(join(dir, "app-spec.yaml")) && existsSync(join(dir, "manifest.json"));
130
+ }
131
+ function openclawConfigPath(instanceId, home) {
132
+ const h = home ?? defaultOpenclawHome(instanceId);
133
+ return join(h, OPENCLAW_STATE_DIRNAME, OPENCLAW_CONFIG_FILENAME);
134
+ }
135
+ function legacyOpenclawConfigPath(instanceId, home) {
136
+ const h = home ?? defaultOpenclawHome(instanceId);
137
+ return join(h, OPENCLAW_CONFIG_FILENAME);
138
+ }
139
+ /**
140
+ * Resolve the most relevant openclaw.json path for a SOURCE instance used
141
+ * by the clone_from branch. Prefers the .openclaw/ state dir, falls back
142
+ * to the legacy flat path so clones of old instances still work.
143
+ */
144
+ function resolveExistingConfigPath(instanceId) {
145
+ const runtimePath = openclawConfigPath(instanceId);
146
+ if (existsSync(runtimePath))
147
+ return runtimePath;
148
+ const legacy = legacyOpenclawConfigPath(instanceId);
149
+ if (existsSync(legacy))
150
+ return legacy;
151
+ return runtimePath;
152
+ }
153
+ // ── Runtime + config defaults (physically migrated) ───────────────────
154
+ function resolveOpenclawBin() {
155
+ const candidates = [
156
+ join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
157
+ "/usr/local/bin/openclaw",
158
+ "/usr/bin/openclaw",
159
+ ];
160
+ for (const p of candidates) {
161
+ if (existsSync(p)) {
162
+ try {
163
+ chmodSync(p, 0o755);
164
+ }
165
+ catch {
166
+ /* best effort — may be a symlink */
167
+ }
168
+ try {
169
+ const real = realpathSync(p);
170
+ if (real !== p)
171
+ chmodSync(real, 0o755);
172
+ }
173
+ catch {
174
+ /* best effort */
175
+ }
176
+ return p;
177
+ }
178
+ }
179
+ return candidates[0]; // fallback, will fail with clear error at spawn
180
+ }
181
+ function buildDefaultRuntime(instanceId, port, openclawHome) {
182
+ const home = openclawHome || defaultOpenclawHome(instanceId);
183
+ return {
184
+ command: resolveOpenclawBin(),
185
+ args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
186
+ cwd: home,
187
+ user: resolveServiceUser()?.username ?? userInfo().username,
188
+ env_files: [defaultOpenclawModelEnvFile(instanceId)],
189
+ env: {
190
+ OPENCLAW_GATEWAY_PORT: String(port),
191
+ NODE_OPTIONS: "--max-old-space-size=2048",
192
+ },
193
+ resources: { CPU: 1000, MemoryMB: 2048 },
194
+ };
195
+ }
196
+ function starterConfig() {
197
+ const dp = getPanelConfig().default_provider;
198
+ let providerName = "minimax";
199
+ let providerConfig = {
200
+ baseUrl: "https://api.minimaxi.com/v1",
201
+ api: "openai-completions",
202
+ models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
203
+ };
204
+ let defaultModel = "minimax/MiniMax-M2.7";
205
+ if (dp?.providerId) {
206
+ providerName = dp.providerId;
207
+ providerConfig = {
208
+ baseUrl: dp.baseUrl,
209
+ api: dp.api,
210
+ ...(dp.authHeader ? { authHeader: true } : {}),
211
+ models: dp.models || [],
212
+ };
213
+ const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
214
+ defaultModel = `${providerName}/${modelId}`;
215
+ }
216
+ const config = {
217
+ models: { providers: { [providerName]: providerConfig } },
218
+ agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
219
+ channels: {},
220
+ gateway: {
221
+ mode: "local",
222
+ auth: { mode: "token", token: randomBytes(24).toString("hex") },
223
+ controlUi: { dangerouslyDisableDeviceAuth: true },
224
+ },
225
+ plugins: { entries: { feishu: { enabled: false } } },
226
+ };
227
+ if (dp?.providerId) {
228
+ config["x-jishushell"] = {
229
+ proxy: {
230
+ upstream: {
231
+ providerId: dp.providerId,
232
+ baseUrl: dp.baseUrl,
233
+ api: dp.api,
234
+ authHeader: dp.authHeader || false,
235
+ models: dp.models || [],
236
+ selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
237
+ hasApiKey: !!dp.apiKey,
238
+ },
239
+ },
240
+ };
241
+ }
242
+ return config;
243
+ }
244
+ // ── Nomad-patching helpers (migrated from nomad-manager.ts) ───────────
245
+ /**
246
+ * In docker bridge mode, 127.0.0.1 inside the container resolves to the
247
+ * container's own loopback, not the host. Rewrite the jsproxy provider
248
+ * baseUrl in openclaw.json to use host.docker.internal instead.
249
+ */
250
+ function patchJsproxyBaseUrl(configPath) {
251
+ try {
252
+ const raw = readFileSync(configPath, "utf-8");
253
+ const patched = raw.replace(/http:\/\/127\.0\.0\.1:(\d+)\/proxy/g, `http://host.docker.internal:$1/proxy`);
254
+ if (patched !== raw) {
255
+ writeConfigFile(configPath, patched);
256
+ console.log(`[openclaw] Patched jsproxy baseUrl in ${configPath} (127.0.0.1 → host.docker.internal)`);
257
+ }
258
+ }
259
+ catch (e) {
260
+ console.warn(`[openclaw] Failed to patch jsproxy baseUrl in ${configPath}: ${e.message}`);
261
+ }
262
+ }
263
+ /**
264
+ * Docker bridge port publishing cannot reach a process that only binds the
265
+ * container loopback. Normalize default/loopback gateway binds to `lan` so
266
+ * Nomad's published host port can reach the gateway.
267
+ */
268
+ function patchDockerBridgeGatewayBind(configPath) {
269
+ try {
270
+ const raw = readFileSync(configPath, "utf-8");
271
+ const parsed = JSON.parse(raw);
272
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
273
+ return;
274
+ const gatewayRaw = parsed.gateway;
275
+ const gateway = gatewayRaw && typeof gatewayRaw === "object" && !Array.isArray(gatewayRaw)
276
+ ? gatewayRaw
277
+ : (parsed.gateway = {});
278
+ const bind = typeof gateway.bind === "string" ? gateway.bind.trim() : "";
279
+ if (bind && bind !== "loopback")
280
+ return;
281
+ gateway.bind = "lan";
282
+ const next = JSON.stringify(parsed, null, 2);
283
+ const output = raw.endsWith("\n") ? `${next}\n` : next;
284
+ if (output === raw)
285
+ return;
286
+ writeConfigFile(configPath, output);
287
+ console.log(`[openclaw] Normalized gateway.bind to "lan" in ${configPath} for Docker bridge networking`);
288
+ }
289
+ catch (e) {
290
+ console.warn(`[openclaw] Failed to patch gateway.bind in ${configPath}: ${e.message}`);
291
+ }
292
+ }
293
+ /**
294
+ * Pre-seed the per-instance npm global prefix with a symlink to the image's
295
+ * baked openclaw package so OpenClaw's in-gateway "Update now" handler can
296
+ * detect the install as an npm global install. Idempotent; docker driver only.
297
+ */
298
+ function ensureOpenclawUpdateSeed(openclawHome, instanceId) {
299
+ if (getNomadDriver() !== "docker")
300
+ return;
301
+ if (!openclawHome)
302
+ return;
303
+ const linkDir = join(openclawHome, ".npm-global", "lib", "node_modules");
304
+ const linkPath = join(linkDir, "openclaw");
305
+ try {
306
+ lstatSync(linkPath);
307
+ return;
308
+ }
309
+ catch (err) {
310
+ if (err?.code !== "ENOENT") {
311
+ console.warn(`[openclaw] update-seed: lstat failed for ${linkPath}: ${err?.message ?? err}`);
312
+ return;
313
+ }
314
+ }
315
+ try {
316
+ mkdirSync(linkDir, { recursive: true });
317
+ symlinkSync(CONTAINER_IMAGE_PKG_ROOT, linkPath);
318
+ console.log(`[openclaw] update-seed ${instanceId}: seeded ${linkPath} -> ${CONTAINER_IMAGE_PKG_ROOT}`);
319
+ }
320
+ catch (err) {
321
+ console.warn(`[openclaw] update-seed ${instanceId}: failed to create seed: ${err?.message ?? err}`);
322
+ }
323
+ }
324
+ // ── Resource helpers (migrated from nomad-manager.ts) ─────────────────
325
+ function resolveUidGid(username) {
326
+ try {
327
+ if (!VALID_USER_RE.test(username)) {
328
+ console.warn(`[openclaw] Invalid username for UID lookup: ${username}`);
329
+ return `${process.getuid()}:${process.getgid()}`;
330
+ }
331
+ const passwd = readFileSync("/etc/passwd", "utf-8");
332
+ const line = passwd.split("\n").find((l) => l.startsWith(username + ":"));
333
+ if (line) {
334
+ const parts = line.split(":");
335
+ const uid = parseInt(parts[2], 10);
336
+ const gid = parseInt(parts[3], 10);
337
+ if (!isNaN(uid) && !isNaN(gid))
338
+ return `${uid}:${gid}`;
339
+ }
340
+ }
341
+ catch {
342
+ /* ignore */
343
+ }
344
+ return `${process.getuid()}:${process.getgid()}`;
345
+ }
346
+ function normalizeDockerResources(instanceId, resources) {
347
+ const requestedMemoryMB = Number(resources.MemoryMB ?? DEFAULT_RESOURCES.MemoryMB);
348
+ let effectiveMemoryMB = requestedMemoryMB;
349
+ let effectiveMemoryMaxMB = Math.min(Number(resources.MemoryMaxMB ?? requestedMemoryMB), MAX_MEMORY_MAX_MB);
350
+ if (effectiveMemoryMaxMB < effectiveMemoryMB) {
351
+ console.warn(`[openclaw] ${instanceId}: MemoryMaxMB (${effectiveMemoryMaxMB}) is below MemoryMB (${effectiveMemoryMB}); clamping.`);
352
+ effectiveMemoryMaxMB = effectiveMemoryMB;
353
+ }
354
+ return {
355
+ ...resources,
356
+ MemoryMB: effectiveMemoryMB,
357
+ MemoryMaxMB: effectiveMemoryMaxMB,
358
+ };
359
+ }
360
+ // ── Nomad template safety (migrated from nomad-manager.ts) ────────────
361
+ const NOMAD_TEMPLATE_UNSAFE_RE = /[{}"\\]/;
362
+ function assertSafeTemplateId(id) {
363
+ if (NOMAD_TEMPLATE_UNSAFE_RE.test(id)) {
364
+ throw new Error(`Job ID "${id}" contains characters unsafe for Nomad Template interpolation`);
365
+ }
366
+ }
367
+ // ── Lazy-imported framework helpers (avoid circular deps) ─────────────
368
+ //
369
+ // The adapter reaches back into instance-manager.ts for helpers that still
370
+ // live there. Using lazy `await import()` keeps the static import graph
371
+ // acyclic, so runtime/index.ts → adapters/openclaw.ts loads cleanly before
372
+ // instance-manager.ts needs it.
373
+ async function lazyIm() {
374
+ return await import("../../instance-manager.js");
375
+ }
376
+ // ── Docker image build helpers (physically migrated from setup-manager) ─
377
+ //
378
+ // All OpenClaw-specific docker image build knowledge lives here. setup-manager
379
+ // only retains a thin dispatch wrapper so the public
380
+ // `buildSlimOpenclawImage()` / `startBuildSlimOpenclawImage()` API remains
381
+ // back-compatible for routes/setup.ts and the CLI installer.
382
+ /** Base image used for the slim OpenClaw runtime image. */
383
+ const DOCKER_BASE_IMAGE = "node:22-slim";
384
+ /** Mirror list tried in order when docker.io is unreachable. */
385
+ const DOCKER_BASE_MIRRORS = [
386
+ "node:22-slim",
387
+ "hub-mirror.c.163.com/library/node:22-slim",
388
+ "mirrors.tencent.com/library/node:22-slim",
389
+ "registry.cn-hangzhou.aliyuncs.com/library/node:22-slim",
390
+ ];
391
+ /** Matches a semver-ish tag suffix, e.g. "...:2026.4.9" or "...:v1.2.3-beta". */
392
+ const PINNED_IMAGE_TAG_RE = /:[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/;
393
+ /**
394
+ * Pull DOCKER_BASE_IMAGE from mirrors if not already cached locally.
395
+ */
396
+ async function ensureDockerBaseImage(invocation, task) {
397
+ try {
398
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", DOCKER_BASE_IMAGE], {
399
+ timeout: 5000,
400
+ stdio: "ignore",
401
+ });
402
+ emitTask(task, { type: "log", message: `基础镜像已缓存: ${DOCKER_BASE_IMAGE}` });
403
+ return DOCKER_BASE_IMAGE;
404
+ }
405
+ catch {
406
+ /* not cached, fall through */
407
+ }
408
+ for (const mirror of DOCKER_BASE_MIRRORS) {
409
+ emitTask(task, { type: "log", message: `拉取基础镜像: ${mirror} ...` });
410
+ const result = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", mirror], { timeout: 300000 });
411
+ if (result.ok) {
412
+ if (mirror !== DOCKER_BASE_IMAGE) {
413
+ try {
414
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", mirror, DOCKER_BASE_IMAGE], { timeout: 10000 });
415
+ }
416
+ catch {
417
+ /* tag failure is non-fatal */
418
+ }
419
+ }
420
+ emitTask(task, { type: "log", message: `基础镜像就绪: ${DOCKER_BASE_IMAGE}` });
421
+ return DOCKER_BASE_IMAGE;
422
+ }
423
+ emitTask(task, { type: "log", message: ` → ${mirror} 不可达,尝试下一个镜像源...` });
424
+ }
425
+ throw new Error(`无法获取基础镜像 ${DOCKER_BASE_IMAGE}。请检查网络或手动执行: docker pull ${DOCKER_BASE_MIRRORS[1]}`);
426
+ }
427
+ /**
428
+ * Query the npm registry for the current OpenClaw version. Used to bust the
429
+ * Docker layer cache for `RUN npm install openclaw@${ver}` during local build.
430
+ */
431
+ function resolveOpenclawNpmVersion() {
432
+ try {
433
+ const out = execFileSync("npm", ["view", "openclaw", "version"], {
434
+ timeout: 15000,
435
+ encoding: "utf-8",
436
+ stdio: ["ignore", "pipe", "ignore"],
437
+ }).trim();
438
+ if (/^\d+\.\d+\.\d+/.test(out))
439
+ return out;
440
+ }
441
+ catch {
442
+ /* npm not reachable */
443
+ }
444
+ return "latest";
445
+ }
446
+ /**
447
+ * Read the OpenClaw version bundled at /app/ inside a Docker image.
448
+ */
449
+ function readBundledOpenclawVersion(invocation, image) {
450
+ try {
451
+ const out = execFileSync(invocation.cmd, [
452
+ ...invocation.argsPrefix,
453
+ "run",
454
+ "--rm",
455
+ "--entrypoint",
456
+ "node",
457
+ image,
458
+ "-p",
459
+ "require('/app/node_modules/openclaw/package.json').version",
460
+ ], { timeout: 20000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
461
+ if (/^\d+\.\d+\.\d+/.test(out))
462
+ return out;
463
+ }
464
+ catch {
465
+ /* docker unavailable, image missing, or path not present */
466
+ }
467
+ return "";
468
+ }
469
+ /**
470
+ * Add a pinned version alias for an image, then drop the mutable :latest /
471
+ * :slim tag. See original setup-manager commentary for details.
472
+ */
473
+ function capturePinnedImageTag(invocation, targetTag, explicitVersion) {
474
+ if (PINNED_IMAGE_TAG_RE.test(targetTag))
475
+ return targetTag;
476
+ let version = explicitVersion && /^\d+\.\d+\.\d+/.test(explicitVersion) ? explicitVersion : "";
477
+ if (!version) {
478
+ version = readBundledOpenclawVersion(invocation, targetTag);
479
+ }
480
+ if (!version || !/^\d+\.\d+\.\d+/.test(version))
481
+ return targetTag;
482
+ const colonIdx = targetTag.lastIndexOf(":");
483
+ const slashIdx = targetTag.lastIndexOf("/");
484
+ const hasTag = colonIdx > slashIdx;
485
+ const repo = hasTag ? targetTag.slice(0, colonIdx) : targetTag;
486
+ const pinnedTag = `${repo}:${version}`;
487
+ if (pinnedTag === targetTag)
488
+ return targetTag;
489
+ try {
490
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, pinnedTag], { timeout: 10000, stdio: "ignore" });
491
+ }
492
+ catch {
493
+ return targetTag;
494
+ }
495
+ if (/:(latest|slim)$/.test(targetTag)) {
496
+ try {
497
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", targetTag], { timeout: 10000, stdio: "ignore" });
498
+ }
499
+ catch {
500
+ /* best-effort cleanup */
501
+ }
502
+ }
503
+ return pinnedTag;
504
+ }
505
+ /**
506
+ * Try docker pull, fall back to local `Dockerfile.openclaw-slim` build. This
507
+ * is the primary image-prep path used by every setup flow. Called from the
508
+ * adapter's `buildRuntimeImage()` method.
509
+ */
510
+ async function pullOrBuildOpenclawImageWithTask(task, tag) {
511
+ const targetTag = tag || DEFAULT_OPENCLAW_DOCKER_IMAGE;
512
+ try {
513
+ const invocation = resolveDockerInvocation();
514
+ // Always attempt pull — when the image is already local and in sync
515
+ // with upstream, docker returns within seconds after a digest check.
516
+ // The "skip if image present" early exit was making "重新安装" feel
517
+ // like a no-op; explicit re-pull matches user intent better. On pull
518
+ // failure we still fall back to local build below.
519
+ emitTask(task, { type: "progress", message: `正在拉取镜像: ${targetTag} ...`, progress: 10 });
520
+ const pullResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", targetTag], { timeout: 600000 });
521
+ if (pullResult.ok) {
522
+ const pinned = capturePinnedImageTag(invocation, targetTag);
523
+ setOpenclawDockerImage(pinned);
524
+ emitTask(task, { type: "done", message: `镜像拉取成功: ${pinned}`, progress: 100 });
525
+ task.status = "done";
526
+ return { ok: true, message: `Docker image ${pinned} pulled`, taskId: task.id };
527
+ }
528
+ console.log(`[openclaw] docker pull failed for ${targetTag}, falling back to local build...`);
529
+ emitTask(task, {
530
+ type: "progress",
531
+ message: `拉取失败,正在本地构建镜像: ${targetTag} ...`,
532
+ progress: 20,
533
+ });
534
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
535
+ const dockerfilePath = join(projectRoot, "Dockerfile.openclaw-slim");
536
+ if (!existsSync(dockerfilePath)) {
537
+ emitTask(task, {
538
+ type: "error",
539
+ message: "Dockerfile.openclaw-slim not found, cannot fallback to local build",
540
+ });
541
+ task.status = "error";
542
+ return {
543
+ ok: false,
544
+ message: "Docker pull failed and Dockerfile.openclaw-slim not found",
545
+ taskId: task.id,
546
+ };
547
+ }
548
+ const openclawVersion = resolveOpenclawNpmVersion();
549
+ console.log(`[openclaw] building image with OPENCLAW_VERSION=${openclawVersion}`);
550
+ const buildResult = await spawnWithTask(task, invocation.cmd, [
551
+ ...invocation.argsPrefix,
552
+ "build",
553
+ "--network=host",
554
+ "--build-arg",
555
+ `OPENCLAW_VERSION=${openclawVersion}`,
556
+ "-f",
557
+ dockerfilePath,
558
+ "-t",
559
+ targetTag,
560
+ projectRoot,
561
+ ], { timeout: 1800000, progressParser: dockerBuildProgressParser });
562
+ if (!buildResult.ok) {
563
+ try {
564
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
565
+ }
566
+ catch {
567
+ /* best-effort */
568
+ }
569
+ emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
570
+ task.status = "error";
571
+ return {
572
+ ok: false,
573
+ message: "Docker image build failed",
574
+ error: buildResult.output,
575
+ taskId: task.id,
576
+ };
577
+ }
578
+ const pinned = capturePinnedImageTag(invocation, targetTag, openclawVersion);
579
+ setOpenclawDockerImage(pinned);
580
+ emitTask(task, {
581
+ type: "done",
582
+ message: `OpenClaw 镜像就绪 (本地构建): ${pinned}`,
583
+ progress: 100,
584
+ });
585
+ task.status = "done";
586
+ return { ok: true, message: `Docker image ${pinned} built locally`, taskId: task.id };
587
+ }
588
+ catch (e) {
589
+ emitTask(task, { type: "error", message: `镜像获取失败: ${e.message}` });
590
+ task.status = "error";
591
+ return {
592
+ ok: false,
593
+ message: "Docker image pull/build failed",
594
+ error: e.message,
595
+ taskId: task.id,
596
+ };
597
+ }
598
+ }
599
+ // ── OpenClawAdapter class ─────────────────────────────────────────────
600
+ class OpenClawAdapter {
601
+ agentType = "openclaw";
602
+ displayName = "OpenClaw";
603
+ defaultCapabilities = DEFAULT_CAPABILITIES;
604
+ defaultGatewayPort = OPENCLAW_DEFAULT_GATEWAY_PORT;
605
+ manifest = {
606
+ agentType: "openclaw",
607
+ displayName: "OpenClaw",
608
+ description: "默认 runtime,支持克隆、飞书 / 企业微信插件",
609
+ defaultCapabilities: DEFAULT_CAPABILITIES,
610
+ requiresNomadDocker: false,
611
+ diskSpaceMB: 2048,
612
+ };
613
+ hooks = {
614
+ /**
615
+ * Full OpenClaw pre-start prelude — used to live inline in
616
+ * `nomad-manager.startInstance()` as an `if (!hermes) { ... }` branch
617
+ * (~80 lines). Framework code now calls this hook uniformly for every
618
+ * kind; Hermes provides a no-op and OpenClaw owns the full sequence:
619
+ *
620
+ * 1. Stop any legacy process-manager subprocess
621
+ * 2. Ensure `openclaw.json` exists + fix state-dir permissions
622
+ * 3. Docker-bridge: patch gateway.bind + jsproxy baseUrl
623
+ * 4. Seed `$HOME/.npm-global` for in-gateway "Update now"
624
+ * 5. Validate + image-inspect the Docker image; background-pull on miss
625
+ * 6. Write JSPROXY_API_KEY into Nomad Variables
626
+ *
627
+ * Throws on fatal errors. Framework catches and returns the structured
628
+ * result; a `taskId` property on the thrown error signals an async
629
+ * image pull in progress.
630
+ */
631
+ onBeforeStart: async ({ instanceId }) => {
632
+ // 1. Stop any legacy subprocess
633
+ try {
634
+ const { getLegacyStatus, stopInstance: stopLegacyInstance } = await import("../../process-manager.js");
635
+ const legacyStatus = await getLegacyStatus(instanceId);
636
+ if (legacyStatus.status === "running") {
637
+ console.log(`[openclaw] Stopping legacy process for ${instanceId} (pid=${legacyStatus.pid}) before Nomad start`);
638
+ await stopLegacyInstance(instanceId);
639
+ await new Promise((r) => setTimeout(r, 2000));
640
+ }
641
+ }
642
+ catch {
643
+ /* process-manager may be absent; harmless */
644
+ }
645
+ // 2. Config path existence check + permissions — use local resolvers
646
+ // instead of lazy importing back into instance-manager.
647
+ let configPath;
648
+ try {
649
+ configPath = openclawAdapter.resolveConfigPath(instanceId);
650
+ }
651
+ catch {
652
+ return; // bail gracefully for non-OpenClaw instances
653
+ }
654
+ if (!existsSync(configPath)) {
655
+ throw new Error("Config file not found");
656
+ }
657
+ if (getNomadDriver() === "docker") {
658
+ const stateDir = dirname(configPath);
659
+ ensureDirContainer(stateDir);
660
+ try {
661
+ for (const entry of readdirSync(stateDir, { withFileTypes: true })) {
662
+ if (entry.isDirectory()) {
663
+ const sub = join(stateDir, entry.name);
664
+ ensureDirContainer(sub);
665
+ try {
666
+ for (const child of readdirSync(sub, { withFileTypes: true })) {
667
+ if (child.isDirectory())
668
+ ensureDirContainer(join(sub, child.name));
669
+ }
670
+ }
671
+ catch {
672
+ /* ignore */
673
+ }
674
+ }
675
+ }
676
+ }
677
+ catch {
678
+ /* ignore */
679
+ }
680
+ if (existsSync(configPath))
681
+ chmodSync(configPath, 0o644);
682
+ // 3. Docker bridge patches
683
+ patchDockerBridgeGatewayBind(configPath);
684
+ patchJsproxyBaseUrl(configPath);
685
+ }
686
+ // 4. npm update-seed — use local resolver
687
+ try {
688
+ const home = openclawAdapter.resolveAgentHome(instanceId);
689
+ if (home)
690
+ ensureOpenclawUpdateSeed(home, instanceId);
691
+ }
692
+ catch {
693
+ /* best effort */
694
+ }
695
+ // 5. Docker image validation + background pull fallback
696
+ if (getNomadDriver() === "docker") {
697
+ const image = getOpenclawDockerImage();
698
+ if (!DOCKER_IMAGE_RE.test(image) || image.length > MAX_DOCKER_IMAGE_NAME_LEN) {
699
+ throw new Error(`Invalid Docker image name: "${image}"`);
700
+ }
701
+ try {
702
+ execFileSync("docker", ["image", "inspect", image], {
703
+ timeout: 10000,
704
+ stdio: "ignore",
705
+ });
706
+ }
707
+ catch {
708
+ console.log(`[openclaw] Docker image ${image} not found, starting background pull`);
709
+ try {
710
+ const result = openclawAdapter.startBuildRuntimeImage({ tag: image });
711
+ const err = new Error(`Docker image ${image} not found. Pull started in background.`);
712
+ err.building = true;
713
+ err.taskId = result.taskId;
714
+ throw err;
715
+ }
716
+ catch (e) {
717
+ if (e?.building)
718
+ throw e;
719
+ throw new Error(`Docker image ${image} not available: ${e?.message ?? e}`);
720
+ }
721
+ }
722
+ }
723
+ // 6. Write instance secrets to Nomad Variables
724
+ try {
725
+ const nomad = await import("../../nomad-manager.js");
726
+ if (typeof nomad.writeInstanceVariables === "function") {
727
+ await nomad.writeInstanceVariables(instanceId);
728
+ }
729
+ }
730
+ catch (e) {
731
+ throw new Error(`Failed to store instance secrets in Nomad Variables: ${e?.message ?? e}`);
732
+ }
733
+ },
734
+ /**
735
+ * OpenClaw auto-installs IM channel plugins (feishu, weixin, etc.) when
736
+ * they're enabled in `openclaw.json`. That logic currently lives in
737
+ * `instance-manager.saveConfig()`; wiring it through this hook is a
738
+ * no-op during MVP because saveConfig still runs inline. The hook
739
+ * exists so the physical-migration follow-up PR can move the code into
740
+ * this file without touching framework code.
741
+ */
742
+ onConfigSaved: async (_args) => {
743
+ /* reserved — channel plugin install hook, filled by follow-up PR */
744
+ },
745
+ /**
746
+ * Surface an orphan-directory warning when the legacy `openclaw_home`
747
+ * field points outside the instance dir. The framework's deleteInstance
748
+ * only rm's the instance dir itself, so custom-home layouts leave a
749
+ * tree that the operator must clean up manually.
750
+ */
751
+ onDelete: async ({ instanceId, meta }) => {
752
+ const home = typeof meta?.openclaw_home === "string" ? meta.openclaw_home : null;
753
+ if (!home)
754
+ return;
755
+ const instDir = framework_instanceDir(instanceId);
756
+ if (home.startsWith(instDir))
757
+ return; // inside the instance dir — rm already caught it
758
+ if (!existsSync(home))
759
+ return;
760
+ return {
761
+ warnings: [
762
+ `Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`,
763
+ ],
764
+ };
765
+ },
766
+ /**
767
+ * Rewrite `openclaw.json` x-jishushell.proxy.upstream when the panel
768
+ * default provider changes. Delegates to instance-manager's existing
769
+ * helper so the transition stays stepwise.
770
+ */
771
+ onUpstreamProviderChange: async ({ instanceId, upstream }) => {
772
+ try {
773
+ const im = await lazyIm();
774
+ if (typeof im.pushUpstreamToInstance === "function") {
775
+ await im.pushUpstreamToInstance(instanceId, upstream);
776
+ }
777
+ }
778
+ catch (e) {
779
+ console.warn(`[openclaw] onUpstreamProviderChange failed for ${instanceId}: ${e.message}`);
780
+ }
781
+ },
782
+ };
783
+ /**
784
+ * Full OpenClaw instance bootstrap, physically migrated from the legacy
785
+ * `instance-manager.createInstance()` (~270 lines). Framework code now
786
+ * calls this uniformly via `getAdapter(agentType).createInstance(args)` —
787
+ * instance-manager no longer owns any OpenClaw business logic.
788
+ *
789
+ * Responsibilities:
790
+ * 1. openclaw_home path validation (traversal + symlink safety)
791
+ * 2. Gateway port allocation + runtime spec generation
792
+ * 3. clone_from handling (extensions/workspace/memory/sessions copy)
793
+ * 4. starter config seeding + default-provider API key injection
794
+ * 5. AppSpec overlays (config_defaults / skills)
795
+ * 6. Proxy bootstrap token generation via LLM proxy
796
+ * 7. chown to service user when running as root
797
+ */
798
+ async createInstance(args) {
799
+ const { instanceId, name, description = "", cloneFrom, agentHome: openclawHomeArg, appSpec, cloneOptions, } = args;
800
+ // Guard: prevent creating an OpenClaw instance when the runtime isn't
801
+ // installed. Without this, the instance.json gets written but the first
802
+ // `service/start` fails with an opaque `npm package not found` or
803
+ // `docker image missing` error. The Setup wizard treats "any runtime
804
+ // ready" as overall ready, so the Hermes-only case reaches here.
805
+ // getInstallStatus() is best-effort: if config exports are unavailable
806
+ // (e.g. partially mocked in test environments) the check is skipped so
807
+ // tests that do not mock the install state continue to work unchanged.
808
+ // The e2e-real suite spawns a real CLI on hosts that intentionally do
809
+ // not have OpenClaw installed; NODE_ENV=test (set by the e2e helper)
810
+ // is the explicit opt-out so the gate doesn't break instance-lifecycle
811
+ // tests.
812
+ try {
813
+ const hasAppManagedRuntime = Boolean(appSpec?.tasks?.some((task) => (task.role ?? "service") === "service" && (task.command || task.image)));
814
+ if (!hasAppManagedRuntime && process.env.NODE_ENV !== "test") {
815
+ const installStatus = this.getInstallStatus();
816
+ if (!installStatus.installed) {
817
+ throw new Error("OpenClaw runtime is not installed. Install it from the Apps page " +
818
+ "or run `jishushell install openclaw`, then retry.");
819
+ }
820
+ }
821
+ }
822
+ catch (err) {
823
+ // Re-throw only our own install-gate error; absorb config-unavailable
824
+ // errors that arise in partially mocked test environments.
825
+ if (err?.message?.startsWith("OpenClaw runtime is not installed"))
826
+ throw err;
827
+ }
828
+ const d = framework_instanceDir(instanceId);
829
+ const metaPath = instanceMetaPath(instanceId);
830
+ if (existsSync(metaPath))
831
+ throw new Error(`Instance '${instanceId}' already exists`);
832
+ if (existsSync(d) && !isPrecreatedManagedAppDir(d)) {
833
+ throw new Error(`Instance '${instanceId}' already exists`);
834
+ }
835
+ const home = openclawHomeArg
836
+ ? normalizePath(openclawHomeArg)
837
+ : defaultOpenclawHome(instanceId);
838
+ // Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent
839
+ // path traversal. Use realpathSync after mkdir to resolve symlinks.
840
+ if (openclawHomeArg) {
841
+ const resolved = pathResolve(home);
842
+ if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
843
+ throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
844
+ }
845
+ const parentDir = dirname(resolved);
846
+ if (existsSync(parentDir)) {
847
+ const realParent = realpathSync(parentDir);
848
+ if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
849
+ throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
850
+ }
851
+ }
852
+ const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) ===
853
+ normalizePath(home));
854
+ if (shared.length) {
855
+ throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared
856
+ .map((i) => i.id)
857
+ .join(", ")}`);
858
+ }
859
+ }
860
+ // Orphaned openclaw_home (e.g. instance.json deleted but data remains).
861
+ if (existsSync(home)) {
862
+ try {
863
+ const entries = readdirSync(home);
864
+ if (entries.length > 0) {
865
+ throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. ` +
866
+ `Remove it manually or choose a different path.`);
867
+ }
868
+ }
869
+ catch (e) {
870
+ if (e.message.includes("not empty"))
871
+ throw e;
872
+ }
873
+ }
874
+ ensureDirContainer(d);
875
+ try {
876
+ const parentGid = statSync(dirname(d)).gid;
877
+ chownSync(d, -1, parentGid);
878
+ }
879
+ catch {
880
+ /* non-root without CAP_CHOWN */
881
+ }
882
+ ensureDirContainer(home);
883
+ ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
884
+ const portAlloc = await allocateGatewayPort(instanceId, OPENCLAW_DEFAULT_GATEWAY_PORT);
885
+ const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
886
+ let runtime = baseRuntime;
887
+ if (appSpec) {
888
+ const serviceTask = appSpec.tasks.find((t) => t.role === "service");
889
+ if (serviceTask) {
890
+ const compiled = compileTaskRuntime(serviceTask, instanceId);
891
+ runtime = { ...baseRuntime, ...compiled };
892
+ }
893
+ }
894
+ const allocatedPort = extractGatewayPort(runtime);
895
+ try {
896
+ const meta = {
897
+ id: instanceId,
898
+ name,
899
+ description,
900
+ agentType: "openclaw",
901
+ openclaw_home: home,
902
+ runtime,
903
+ created_at: new Date().toISOString(),
904
+ // Prefer appSpec.app_id (installed unique id set by routes/apps.ts:88-96)
905
+ // over appSpec.id (which materializeInstalledSpec preserves as the base id).
906
+ // Multi-instance / copied apps need the installed id so uninstall /
907
+ // capability rebuild / skills metadata find the right installed entry.
908
+ ...(appSpec ? { app_id: appSpec.app_id ?? appSpec.id } : {}),
909
+ };
910
+ safeWriteJson(instanceMetaPath(instanceId), meta);
911
+ const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
912
+ for (const ef of envFiles) {
913
+ if (!existsSync(ef))
914
+ writeConfigFile(ef, "");
915
+ }
916
+ // After writing env files, ensure the runtime user can read them
917
+ try {
918
+ const runtimeUser = runtime.user;
919
+ if (runtimeUser && runtimeUser !== userInfo().username) {
920
+ for (const ef of envFiles) {
921
+ execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
922
+ }
923
+ }
924
+ }
925
+ catch {
926
+ /* ignore - same user or no permission to chown */
927
+ }
928
+ const configPath = openclawConfigPath(instanceId, home);
929
+ ensureDirContainer(dirname(configPath));
930
+ if (cloneFrom && !existsSync(configPath)) {
931
+ const srcConfig = resolveExistingConfigPath(cloneFrom);
932
+ if (existsSync(srcConfig)) {
933
+ try {
934
+ const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
935
+ // Remove proxy provider (will be regenerated with new token)
936
+ const providers = cloned?.models?.providers;
937
+ if (providers) {
938
+ for (const [pid, prov] of Object.entries(providers)) {
939
+ if (typeof prov?.baseUrl === "string" &&
940
+ prov.baseUrl.includes("/proxy/")) {
941
+ delete providers[pid];
942
+ }
943
+ }
944
+ }
945
+ // Remove proxy model reference from agent defaults
946
+ const defaultModel = cloned?.agents?.defaults?.model;
947
+ if (typeof defaultModel === "string" &&
948
+ (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
949
+ delete cloned.agents.defaults.model;
950
+ }
951
+ // Strip IM channel configs + matching plugin entries
952
+ stripImBindings(cloned);
953
+ // Copy extensions, workspace, (optionally) memory + sessions
954
+ const subdirs = ["extensions", "workspace"];
955
+ if (cloneOptions?.include_memory !== false) {
956
+ const memDir = join(dirname(srcConfig), "memory");
957
+ if (existsSync(memDir))
958
+ subdirs.push("memory");
959
+ }
960
+ if (cloneOptions?.include_sessions) {
961
+ const sessDir = join(dirname(srcConfig), "agents");
962
+ if (existsSync(sessDir))
963
+ subdirs.push("agents");
964
+ }
965
+ for (const subdir of subdirs) {
966
+ const srcDir = join(dirname(srcConfig), subdir);
967
+ const dstDir = join(dirname(configPath), subdir);
968
+ if (existsSync(srcDir) && !existsSync(dstDir)) {
969
+ try {
970
+ cpSync(srcDir, dstDir, { recursive: true });
971
+ }
972
+ catch {
973
+ /* best effort */
974
+ }
975
+ }
976
+ }
977
+ writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
978
+ // Copy x-jishushell upstream metadata from source instance.json
979
+ const srcMetaPath = join(framework_instanceDir(cloneFrom), "instance.json");
980
+ if (existsSync(srcMetaPath)) {
981
+ try {
982
+ const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
983
+ const srcXj = srcMeta?.["x-jishushell"];
984
+ if (srcXj?.proxy?.upstream) {
985
+ const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
986
+ delete dstXj.proxy.upstream.apiKey;
987
+ const metaPath = instanceMetaPath(instanceId);
988
+ if (existsSync(metaPath)) {
989
+ const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
990
+ dstMeta["x-jishushell"] = dstXj;
991
+ writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
992
+ }
993
+ }
994
+ }
995
+ catch {
996
+ /* ignore metadata copy errors */
997
+ }
998
+ }
999
+ }
1000
+ catch {
1001
+ copyFileSync(srcConfig, configPath);
1002
+ }
1003
+ }
1004
+ }
1005
+ if (!existsSync(configPath)) {
1006
+ writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
1007
+ // Inject default provider API key from setup into both env files
1008
+ const dp = getPanelConfig().default_provider;
1009
+ if (dp?.apiKey && dp?.providerId && envFiles.length) {
1010
+ const envKey = inferProviderApiKeyEnvName(dp.providerId);
1011
+ updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
1012
+ const providerEnv = join(dirname(envFiles[0]), "provider.env");
1013
+ updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
1014
+ }
1015
+ }
1016
+ // Merge AppSpec runtime overlays into openclaw.json (shallow, app wins).
1017
+ // Prefers the plan §17 shape `runtime.defaults`; falls back to the
1018
+ // legacy `openclaw.config_defaults` namespace for pre-§17 specs.
1019
+ const runtimeOverlay = appSpec?.runtime ??
1020
+ appSpec?.openclaw;
1021
+ const overlayDefaults = runtimeOverlay?.defaults
1022
+ ?? runtimeOverlay?.config_defaults;
1023
+ if (overlayDefaults && existsSync(configPath)) {
1024
+ try {
1025
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
1026
+ const defaults = overlayDefaults;
1027
+ for (const [key, value] of Object.entries(defaults)) {
1028
+ if (typeof value === "object" &&
1029
+ value !== null &&
1030
+ !Array.isArray(value) &&
1031
+ typeof existing[key] === "object" &&
1032
+ existing[key] !== null) {
1033
+ existing[key] = { ...existing[key], ...value };
1034
+ }
1035
+ else {
1036
+ existing[key] = value;
1037
+ }
1038
+ }
1039
+ writeConfigFile(configPath, JSON.stringify(existing, null, 2));
1040
+ }
1041
+ catch {
1042
+ /* ignore merge errors */
1043
+ }
1044
+ }
1045
+ // Record App-level skills for later installation. Prefers the new
1046
+ // `runtime.skills` namespace; falls back to `openclaw.skills` for
1047
+ // pre-§17 specs.
1048
+ const overlaySkills = Array.isArray(runtimeOverlay?.skills)
1049
+ ? runtimeOverlay.skills
1050
+ : null;
1051
+ if (overlaySkills) {
1052
+ try {
1053
+ const skillsDir = join(dirname(configPath), "skills");
1054
+ ensureDirContainer(skillsDir);
1055
+ const skillMeta = join(skillsDir, ".app-skills.json");
1056
+ safeWriteJson(skillMeta, {
1057
+ app_id: appSpec.app_id ?? appSpec.id,
1058
+ skills: overlaySkills,
1059
+ });
1060
+ }
1061
+ catch {
1062
+ /* ignore */
1063
+ }
1064
+ }
1065
+ // Copy cloned provider.env BEFORE proxy bootstrap
1066
+ if (cloneFrom && envFiles.length) {
1067
+ const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
1068
+ const srcEnvFile = srcEnvFiles[0];
1069
+ const dstEnvFile = envFiles[0];
1070
+ if (srcEnvFile) {
1071
+ const srcProvider = join(dirname(srcEnvFile), "provider.env");
1072
+ const dstProvider = join(dirname(dstEnvFile), "provider.env");
1073
+ if (existsSync(srcProvider) && !existsSync(dstProvider)) {
1074
+ copyFileSync(srcProvider, dstProvider);
1075
+ }
1076
+ }
1077
+ }
1078
+ // Bootstrap proxy: generate proxy token and write model.env
1079
+ try {
1080
+ await bootstrapInstanceProxy(instanceId);
1081
+ }
1082
+ catch (e) {
1083
+ console.warn(`[openclaw] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
1084
+ }
1085
+ // If running as root, hand ownership of all created files to service user
1086
+ const svcUser = resolveServiceUser();
1087
+ if (svcUser) {
1088
+ try {
1089
+ execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], {
1090
+ timeout: 10_000,
1091
+ });
1092
+ if (!home.startsWith(d + "/") && existsSync(home)) {
1093
+ execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], {
1094
+ timeout: 10_000,
1095
+ });
1096
+ }
1097
+ }
1098
+ catch (e) {
1099
+ console.warn(`[openclaw] chown for ${instanceId} failed:`, e.message);
1100
+ }
1101
+ }
1102
+ if (portAlloc.skipped.length > 0) {
1103
+ meta.port_allocation = {
1104
+ assigned: portAlloc.port,
1105
+ requested: OPENCLAW_DEFAULT_GATEWAY_PORT,
1106
+ reason: "default_busy",
1107
+ skipped: portAlloc.skipped,
1108
+ };
1109
+ }
1110
+ return meta;
1111
+ }
1112
+ finally {
1113
+ if (allocatedPort)
1114
+ releasePendingPort(allocatedPort);
1115
+ }
1116
+ }
1117
+ async createInitialLayout(ctx) {
1118
+ // Framework never calls this on OpenClaw directly — `createInstance()`
1119
+ // above does the full layout. Kept to satisfy the interface for path
1120
+ // preview / UI callers.
1121
+ const instDir = framework_instanceDir(ctx.instanceId);
1122
+ const agentHome = defaultOpenclawHome(ctx.instanceId);
1123
+ return {
1124
+ instanceDir: instDir,
1125
+ agentHome,
1126
+ primaryConfig: join(agentHome, OPENCLAW_STATE_DIRNAME, OPENCLAW_CONFIG_FILENAME),
1127
+ };
1128
+ }
1129
+ async buildRuntime(instanceId) {
1130
+ // OpenClaw persists its runtime in legacy snake_case shape
1131
+ // ({env_files, resources, ...}). Translate at read time into the
1132
+ // engine-neutral RuntimeSpec contract.
1133
+ const im = await lazyIm();
1134
+ const raw = im.getInstanceRuntime(instanceId);
1135
+ const home = im.getOpenclawHome(instanceId);
1136
+ return {
1137
+ image: raw?.image ?? getOpenclawDockerImage(),
1138
+ command: String(raw?.command || DEFAULT_COMMAND),
1139
+ args: Array.isArray(raw?.args) ? raw.args.map(String) : [...DEFAULT_ARGS],
1140
+ cwd: String(raw?.cwd || home || DEFAULT_CWD),
1141
+ user: String(raw?.user || DEFAULT_USER),
1142
+ env: { ...DEFAULT_ENV, ...(raw?.env || {}) },
1143
+ envFiles: Array.isArray(raw?.env_files)
1144
+ ? raw.env_files
1145
+ : Array.isArray(raw?.envFiles)
1146
+ ? raw.envFiles
1147
+ : [],
1148
+ resources: {
1149
+ CPU: Number(raw?.resources?.CPU ?? DEFAULT_RESOURCES.CPU),
1150
+ MemoryMB: Number(raw?.resources?.MemoryMB ?? DEFAULT_RESOURCES.MemoryMB),
1151
+ MemoryMaxMB: raw?.resources?.MemoryMaxMB != null ? Number(raw.resources.MemoryMaxMB) : undefined,
1152
+ },
1153
+ ports: [
1154
+ {
1155
+ name: "gateway",
1156
+ containerPort: im.getGatewayPort(instanceId),
1157
+ hostPort: im.getGatewayPort(instanceId),
1158
+ visibility: "external",
1159
+ },
1160
+ ],
1161
+ volumes: [{ hostPath: home, containerPath: home, mode: "rw" }],
1162
+ health: null,
1163
+ };
1164
+ }
1165
+ async buildNomadTask(instanceId) {
1166
+ // Physically moved from nomad-manager.buildTaskDocker. The adapter owns
1167
+ // OpenClaw's Nomad task layout end-to-end so `nomad-manager.buildJob()`
1168
+ // becomes a pure dispatcher.
1169
+ const im = await lazyIm();
1170
+ const rawRuntime = im.getInstanceRuntime(instanceId);
1171
+ const openclawHome = im.getOpenclawHome(instanceId);
1172
+ if (rawRuntime.user && !VALID_USER_RE.test(rawRuntime.user)) {
1173
+ throw new Error(`Invalid runtime user: ${rawRuntime.user}`);
1174
+ }
1175
+ const image = rawRuntime.image || getOpenclawDockerImage();
1176
+ const command = String(rawRuntime.command || DEFAULT_COMMAND);
1177
+ const args = Array.isArray(rawRuntime.args)
1178
+ ? rawRuntime.args.map(String)
1179
+ : [...DEFAULT_ARGS];
1180
+ const env = { ...DEFAULT_ENV };
1181
+ Object.assign(env, im.getRuntimeEnv(instanceId));
1182
+ delete env.JSPROXY_API_KEY; // supplied via Nomad Template from Variables
1183
+ env.OPENCLAW_HOME = openclawHome;
1184
+ env.OPENCLAW_INSTANCE_ID = instanceId;
1185
+ // Resource clamping
1186
+ const rawResources = { ...DEFAULT_RESOURCES };
1187
+ for (const [k, v] of Object.entries(rawRuntime.resources || {})) {
1188
+ if (v != null)
1189
+ rawResources[k] = Number(v);
1190
+ }
1191
+ rawResources.CPU = Math.max(1, Math.min(rawResources.CPU, MAX_CPU_MHZ));
1192
+ rawResources.MemoryMB = Math.max(1, Math.min(rawResources.MemoryMB, MAX_MEMORY_MB));
1193
+ // Container env — OpenClaw-specific HOME / NODE_PATH / PATH / npm cfg
1194
+ const containerEnv = { ...env };
1195
+ containerEnv.HOME = openclawHome;
1196
+ if (!containerEnv.OPENCLAW_STATE_DIR) {
1197
+ containerEnv.OPENCLAW_STATE_DIR = `${openclawHome}/.openclaw`;
1198
+ }
1199
+ containerEnv.npm_config_prefix = `${openclawHome}/.npm-global`;
1200
+ containerEnv.PIP_USER = "1";
1201
+ containerEnv.PYTHONUSERBASE = `${openclawHome}/.local`;
1202
+ containerEnv.NODE_ENV = "production";
1203
+ containerEnv.NODE_PATH = [
1204
+ `${openclawHome}/.npm-global/lib/node_modules`,
1205
+ "/app/node_modules",
1206
+ ].join(":");
1207
+ containerEnv.PATH = [
1208
+ `${openclawHome}/.npm-global/bin`,
1209
+ `${openclawHome}/.local/bin`,
1210
+ `${openclawHome}/go/bin`,
1211
+ `${openclawHome}/.cargo/bin`,
1212
+ "/usr/local/sbin",
1213
+ "/usr/local/bin",
1214
+ "/usr/sbin",
1215
+ "/usr/bin",
1216
+ "/sbin",
1217
+ "/bin",
1218
+ ].join(":");
1219
+ const gatewayPort = im.getGatewayPort(instanceId);
1220
+ const safeJobId = `${this.nomadJobPrefix}${instanceId}`;
1221
+ assertSafeTemplateId(safeJobId);
1222
+ const normalizedResources = normalizeDockerResources(instanceId, rawResources);
1223
+ return {
1224
+ Name: "gateway",
1225
+ Driver: "docker",
1226
+ User: resolveUidGid(String(rawRuntime.user || DEFAULT_USER)),
1227
+ Config: {
1228
+ image,
1229
+ force_pull: false,
1230
+ args,
1231
+ work_dir: openclawHome,
1232
+ volumes: [`${openclawHome}:${openclawHome}:rw`],
1233
+ extra_hosts: ["host.docker.internal:host-gateway"],
1234
+ cap_drop: ["ALL"],
1235
+ security_opt: ["no-new-privileges"],
1236
+ pids_limit: DEFAULT_PIDS_LIMIT,
1237
+ readonly_rootfs: true,
1238
+ mounts: [
1239
+ { type: "tmpfs", target: "/tmp", tmpfs_options: { size: 536870912 } },
1240
+ { type: "tmpfs", target: "/var/tmp", tmpfs_options: { size: 67108864 } },
1241
+ { type: "tmpfs", target: "/run", tmpfs_options: { size: 52428800 } },
1242
+ ],
1243
+ },
1244
+ Env: containerEnv,
1245
+ Resources: {
1246
+ ...normalizedResources,
1247
+ Networks: [{ ReservedPorts: [{ Label: "gateway", Value: gatewayPort }] }],
1248
+ },
1249
+ LogConfig: { MaxFiles: 3, MaxFileSizeMB: 10 },
1250
+ Templates: [
1251
+ {
1252
+ DestPath: "secrets/instance.env",
1253
+ Envvars: true,
1254
+ EmbeddedTmpl: [
1255
+ `{{ if nomadVarExists "nomad/jobs/${safeJobId}/openclaw/gateway" }}`,
1256
+ `JSPROXY_API_KEY={{ with nomadVar "nomad/jobs/${safeJobId}/openclaw/gateway" }}{{ .JSPROXY_API_KEY }}{{ end }}`,
1257
+ `{{ end }}`,
1258
+ ].join("\n"),
1259
+ ChangeMode: "restart",
1260
+ },
1261
+ ],
1262
+ };
1263
+ }
1264
+ async getRuntimeVersion(_instanceId) {
1265
+ // Parse the image reference (digest > tag) for the baseline image.
1266
+ const image = getOpenclawDockerImage() || "";
1267
+ let ref;
1268
+ let digest;
1269
+ if (image) {
1270
+ if (image.includes("@")) {
1271
+ digest = image.split("@", 2)[1];
1272
+ }
1273
+ else {
1274
+ const lastColon = image.lastIndexOf(":");
1275
+ const lastSlash = image.lastIndexOf("/");
1276
+ if (lastColon > lastSlash)
1277
+ ref = image.slice(lastColon + 1);
1278
+ }
1279
+ }
1280
+ return { agentType: "openclaw", ref, digest, mode: "baseline" };
1281
+ }
1282
+ async getConfigMeta(instanceId) {
1283
+ return {
1284
+ agentType: "openclaw",
1285
+ format: "json",
1286
+ schemaId: "openclaw/v1",
1287
+ capabilities: DEFAULT_CAPABILITIES,
1288
+ secretFields: ["content.x-jishushell.proxy.upstream.apiKey"],
1289
+ runtimeVersion: await this.getRuntimeVersion(instanceId),
1290
+ };
1291
+ }
1292
+ async readConfig(instanceId) {
1293
+ const im = await lazyIm();
1294
+ const content = im.getStoredConfig
1295
+ ? im.getStoredConfig(instanceId)
1296
+ : null;
1297
+ return {
1298
+ format: "json",
1299
+ content: (content && typeof content === "object") ? content : {},
1300
+ };
1301
+ }
1302
+ async writeConfig(instanceId, doc) {
1303
+ if (doc.format !== "json") {
1304
+ throw new Error(`OpenClaw config requires format="json", got "${doc.format}"`);
1305
+ }
1306
+ // Route through llmProxy.saveInstanceConfig so x-jishushell.proxy
1307
+ // metadata is stripped, the upstream apiKey is AES-encrypted to
1308
+ // provider.env, and models.providers is rewritten to the local
1309
+ // proxy — same contract HermesAdapter.writeConfig uses. That
1310
+ // function internally calls instanceManager.saveConfig, which
1311
+ // dispatches back to this adapter's saveNativeConfig() for the
1312
+ // raw disk write (distinct method, no recursion).
1313
+ const { saveInstanceConfig } = await import("../../llm-proxy/index.js");
1314
+ await saveInstanceConfig(instanceId, doc.content);
1315
+ return this.readConfig(instanceId);
1316
+ }
1317
+ async buildPairingListCommand(_instanceId) {
1318
+ return ["openclaw", "pairing", "list"];
1319
+ }
1320
+ async buildPairingApproveCommand(_instanceId, input) {
1321
+ return ["openclaw", "pairing", "approve", input.channel, input.code];
1322
+ }
1323
+ /**
1324
+ * Framework delete hook — returns warnings for the caller to surface to
1325
+ * the user. OpenClaw supports a custom `openclaw_home` pointing outside
1326
+ * the instance directory; those directories are preserved on delete.
1327
+ */
1328
+ async onDelete(args) {
1329
+ const warnings = [];
1330
+ const home = args.meta?.openclaw_home;
1331
+ if (home) {
1332
+ warnings.push(`Custom openclaw_home "${home}" was NOT removed; delete it manually if unused.`);
1333
+ }
1334
+ return warnings;
1335
+ }
1336
+ // ── Native config I/O (physically migrated from instance-manager) ───
1337
+ channelPluginMap = CHANNEL_PLUGIN_MAP;
1338
+ /**
1339
+ * Get the stored raw OpenClaw config (from `.openclaw/openclaw.json`
1340
+ * or the legacy `openclaw.json`), merged with the x-jishushell
1341
+ * metadata pulled from `instance.json`. Returns null when the instance
1342
+ * has no persisted config yet.
1343
+ */
1344
+ getNativeConfig(instanceId) {
1345
+ const config = loadEffectiveConfig(instanceId);
1346
+ if (!config)
1347
+ return null;
1348
+ // Always merge the latest upstream proxy config from instance.json
1349
+ const meta = getInstance(instanceId);
1350
+ if (meta?.["x-jishushell"]) {
1351
+ config["x-jishushell"] = meta["x-jishushell"];
1352
+ }
1353
+ // Inject upstream provider apiKey from env file so callers see it
1354
+ return injectProviderApiKeys(instanceId, config);
1355
+ }
1356
+ /**
1357
+ * Get the stored raw config WITHOUT provider key injection. Used by
1358
+ * callers that round-trip the config back to disk (e.g. credential
1359
+ * writers) — injecting the provider key would re-persist it to the
1360
+ * config file, which is exactly what `saveConfig` takes pains to avoid.
1361
+ */
1362
+ getStoredNativeConfig(instanceId) {
1363
+ const config = loadEffectiveConfig(instanceId);
1364
+ if (!config)
1365
+ return null;
1366
+ const meta = getInstance(instanceId);
1367
+ if (meta?.["x-jishushell"]) {
1368
+ config["x-jishushell"] = meta["x-jishushell"];
1369
+ }
1370
+ return config;
1371
+ }
1372
+ isChannelPluginInstalled(instanceId, channelId) {
1373
+ return isChannelPluginInstalled(instanceId, channelId);
1374
+ }
1375
+ async installChannelPlugin(instanceId, channelId) {
1376
+ return installChannelPlugin(instanceId, channelId);
1377
+ }
1378
+ saveNativeConfig(instanceId, config) {
1379
+ return saveNativeConfigImpl(instanceId, config);
1380
+ }
1381
+ // ── Path resolvers (physically migrated) ───────────────────────────
1382
+ resolveBin() {
1383
+ return resolveOpenclawBin();
1384
+ }
1385
+ resolveAgentHome(instanceId) {
1386
+ const meta = getInstance(instanceId);
1387
+ return meta?.openclaw_home || defaultOpenclawHome(instanceId);
1388
+ }
1389
+ /** Env vars OpenClaw's CLI needs when backup-manager runs it as a subprocess. */
1390
+ buildCliEnv(instanceId) {
1391
+ return { OPENCLAW_HOME: this.resolveAgentHome(instanceId) };
1392
+ }
1393
+ /**
1394
+ * Legacy gateway-port fallback for instances created before the
1395
+ * RuntimeSpec.ports[] migration. OpenClaw persists its port in both
1396
+ * `runtime.env.OPENCLAW_GATEWAY_PORT` and `runtime.args ["--port", N]`.
1397
+ */
1398
+ readLegacyGatewayPort(runtime) {
1399
+ if (!runtime)
1400
+ return null;
1401
+ const envPort = runtime.env?.OPENCLAW_GATEWAY_PORT;
1402
+ if (envPort) {
1403
+ const p = parseInt(envPort, 10);
1404
+ if (!isNaN(p))
1405
+ return p;
1406
+ }
1407
+ const args = runtime.args || [];
1408
+ for (let i = 0; i < args.length; i++) {
1409
+ const arg = String(args[i]);
1410
+ if (arg === "--port" && i + 1 < args.length) {
1411
+ const p = parseInt(args[i + 1], 10);
1412
+ return isNaN(p) ? null : p;
1413
+ }
1414
+ if (arg.startsWith("--port=")) {
1415
+ const p = parseInt(arg.split("=")[1], 10);
1416
+ return isNaN(p) ? null : p;
1417
+ }
1418
+ }
1419
+ return null;
1420
+ }
1421
+ /**
1422
+ * Rewrite a persisted OpenClaw runtime spec to use a new host port.
1423
+ * Updates `args[--port]` + `env.OPENCLAW_GATEWAY_PORT` atomically so
1424
+ * both places stay in sync.
1425
+ */
1426
+ reallocateRuntimePort(runtime, newPort) {
1427
+ const args = Array.isArray(runtime.args) ? [...runtime.args] : [];
1428
+ for (let i = 0; i < args.length; i++) {
1429
+ if (args[i] === "--port" && i + 1 < args.length) {
1430
+ args[i + 1] = String(newPort);
1431
+ }
1432
+ else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
1433
+ args[i] = `--port=${newPort}`;
1434
+ }
1435
+ }
1436
+ runtime.args = args;
1437
+ const env = (runtime.env ?? {});
1438
+ env.OPENCLAW_GATEWAY_PORT = String(newPort);
1439
+ runtime.env = env;
1440
+ }
1441
+ nomadJobPrefix = "openclaw-";
1442
+ // OpenClaw is the default/core runtime — runFullSetup must fail hard if
1443
+ // its image install fails, because the panel is unusable without it.
1444
+ // Third-party adapters should leave `required` unset (= optional).
1445
+ required = true;
1446
+ // Must match the path referenced by this adapter's buildNomadTask Templates
1447
+ // (see `nomadVar "nomad/jobs/<jid>/openclaw/gateway"` in the template body).
1448
+ nomadVariablePath = "openclaw/gateway";
1449
+ resolveConfigPath(instanceId) {
1450
+ return openclawConfigPath(instanceId, this.resolveAgentHome(instanceId));
1451
+ }
1452
+ resolveLegacyConfigPath(instanceId) {
1453
+ return legacyOpenclawConfigPath(instanceId, this.resolveAgentHome(instanceId));
1454
+ }
1455
+ /**
1456
+ * Return other OpenClaw instance IDs that share this one's
1457
+ * `openclaw_home` path. Used by the start-time conflict check.
1458
+ */
1459
+ findInstancesSharingHome(instanceId) {
1460
+ const target = normalizePath(this.resolveAgentHome(instanceId));
1461
+ return listInstances()
1462
+ .filter((inst) => inst.id !== instanceId)
1463
+ .filter((inst) => {
1464
+ const meta = inst;
1465
+ const otherHome = meta.openclaw_home || defaultOpenclawHome(inst.id);
1466
+ return normalizePath(otherHome) === target;
1467
+ })
1468
+ .map((inst) => inst.id);
1469
+ }
1470
+ // ── Feishu / WeChat credential writers (physically migrated) ───────
1471
+ saveFeishuCredentials(instanceId, creds) {
1472
+ if (!FEISHU_APP_ID_RE.test(creds.appId)) {
1473
+ throw new Error(`Invalid Feishu appId format: expected cli_<alnum> (got "${creds.appId}")`);
1474
+ }
1475
+ if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
1476
+ throw new Error("Invalid Feishu appSecret: must be a non-empty string");
1477
+ }
1478
+ const configPath = this.resolveConfigPath(instanceId);
1479
+ let config = {};
1480
+ try {
1481
+ if (existsSync(configPath)) {
1482
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1483
+ }
1484
+ }
1485
+ catch {
1486
+ /* best effort */
1487
+ }
1488
+ config.plugins ??= {};
1489
+ config.plugins.entries ??= {};
1490
+ config.plugins.entries.feishu = { enabled: false };
1491
+ config.plugins.entries["openclaw-lark"] = { enabled: true };
1492
+ config.channels ??= {};
1493
+ config.channels.feishu = {
1494
+ ...config.channels.feishu,
1495
+ enabled: true,
1496
+ appId: creds.appId,
1497
+ appSecret: creds.appSecret,
1498
+ domain: creds.domain,
1499
+ dmPolicy: "open",
1500
+ allowFrom: ["*"],
1501
+ };
1502
+ safeWriteJson(configPath, config);
1503
+ chownToServiceUser(configPath);
1504
+ console.log(`[openclaw] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
1505
+ }
1506
+ saveWeixinCredentials(instanceId, creds) {
1507
+ if (!creds.accountId || !SAFE_ACCOUNT_ID_RE.test(creds.accountId) || creds.accountId.includes("..")) {
1508
+ throw new Error(`Invalid accountId: must be 1-128 chars of [a-zA-Z0-9@._-] without '..'`);
1509
+ }
1510
+ const home = this.resolveAgentHome(instanceId);
1511
+ const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1512
+ const accountsDir = join(stateDir, "accounts");
1513
+ ensureDirContainer(accountsDir);
1514
+ const credObj = {
1515
+ token: creds.token,
1516
+ baseUrl: creds.baseUrl,
1517
+ userId: creds.userId,
1518
+ savedAt: new Date().toISOString(),
1519
+ };
1520
+ safeWriteJson(join(accountsDir, `${creds.accountId}.json`), credObj);
1521
+ safeWriteJson(join(accountsDir, "default.json"), credObj);
1522
+ chownToServiceUser(join(accountsDir, `${creds.accountId}.json`), join(accountsDir, "default.json"));
1523
+ // Update accounts.json index
1524
+ const indexPath = join(stateDir, "accounts.json");
1525
+ let index = [];
1526
+ try {
1527
+ const raw = readFileSync(indexPath, "utf-8");
1528
+ index = JSON.parse(raw);
1529
+ }
1530
+ catch {
1531
+ /* start fresh */
1532
+ }
1533
+ if (!Array.isArray(index))
1534
+ index = [];
1535
+ if (!index.includes(creds.accountId))
1536
+ index.push(creds.accountId);
1537
+ safeWriteJson(indexPath, index);
1538
+ const configPath = this.resolveConfigPath(instanceId);
1539
+ let config = {};
1540
+ try {
1541
+ if (existsSync(configPath))
1542
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
1543
+ }
1544
+ catch {
1545
+ /* best effort */
1546
+ }
1547
+ config.plugins ??= {};
1548
+ config.plugins.entries ??= {};
1549
+ config.plugins.entries["openclaw-weixin"] ??= {};
1550
+ config.plugins.entries["openclaw-weixin"].enabled = true;
1551
+ config.channels ??= {};
1552
+ config.channels["openclaw-weixin"] ??= {};
1553
+ config.channels["openclaw-weixin"].enabled = true;
1554
+ const normalizedId = creds.accountId.replace(/[@.]/g, "-");
1555
+ const accounts = config.channels["openclaw-weixin"].accounts ??= {};
1556
+ accounts[creds.accountId] = { enabled: true };
1557
+ if (normalizedId !== creds.accountId)
1558
+ accounts[normalizedId] = { enabled: true };
1559
+ accounts["default"] = { enabled: true };
1560
+ if (!config.channels["openclaw-weixin"].defaultAccount) {
1561
+ config.channels["openclaw-weixin"].defaultAccount = "default";
1562
+ }
1563
+ safeWriteJson(configPath, config);
1564
+ chownToServiceUser(configPath);
1565
+ console.log(`[openclaw] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
1566
+ }
1567
+ getWeixinAccounts(instanceId) {
1568
+ const home = this.resolveAgentHome(instanceId);
1569
+ const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1570
+ const accountsDir = join(stateDir, "accounts");
1571
+ if (!existsSync(accountsDir))
1572
+ return [];
1573
+ let indexedIds = [];
1574
+ try {
1575
+ indexedIds = JSON.parse(readFileSync(join(stateDir, "accounts.json"), "utf-8"));
1576
+ }
1577
+ catch {
1578
+ /* fallback to scanning */
1579
+ }
1580
+ const results = [];
1581
+ for (const f of readdirSync(accountsDir)) {
1582
+ if (!f.endsWith(".json"))
1583
+ continue;
1584
+ const id = f.replace(/\.json$/, "");
1585
+ if (indexedIds.length > 0 && !indexedIds.includes(id))
1586
+ continue;
1587
+ if (id === "default")
1588
+ continue;
1589
+ try {
1590
+ const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
1591
+ results.push({
1592
+ accountId: id,
1593
+ userId: data.userId,
1594
+ savedAt: data.savedAt,
1595
+ });
1596
+ }
1597
+ catch {
1598
+ /* skip */
1599
+ }
1600
+ }
1601
+ return results;
1602
+ }
1603
+ // ── §32.2.4 runtime install (physically migrated from setup-manager) ──
1604
+ //
1605
+ // Installs the OpenClaw npm package via `npm install -g --prefix` so that
1606
+ // postinstall scripts run naturally. Emits SSE progress through the task
1607
+ // machinery exported by setup-manager.
1608
+ async installRuntime(opts) {
1609
+ const version = opts?.version ?? "latest";
1610
+ try {
1611
+ const openclawPkgDir = join(OPENCLAW_MODULES, "openclaw");
1612
+ if (existsSync(openclawPkgDir)) {
1613
+ const ver = readInstalledOpenclawVersion() || "unknown";
1614
+ return { ok: true, message: `OpenClaw already installed: ${ver}` };
1615
+ }
1616
+ const task = createTask("openclaw");
1617
+ ensureDirHost(OPENCLAW_PKG_DIR);
1618
+ emitTask(task, { type: "progress", message: "开始安装 OpenClaw...", progress: 0 });
1619
+ const sizeTracker = setInterval(() => {
1620
+ const sizeMB = getDirSizeMB(OPENCLAW_PKG_DIR);
1621
+ const pct = Math.min(95, Math.round((sizeMB / OPENCLAW_EXPECTED_SIZE_MB) * 95));
1622
+ if (pct > 0) {
1623
+ emitTask(task, {
1624
+ type: "progress",
1625
+ message: `下载安装中... ${sizeMB}MB / ~${OPENCLAW_EXPECTED_SIZE_MB}MB`,
1626
+ progress: pct,
1627
+ });
1628
+ }
1629
+ }, 3000);
1630
+ const result = await spawnWithTask(task, "npm", ["install", "-g", "--prefix", OPENCLAW_PKG_DIR, `openclaw@${version}`], { timeout: 600000, progressParser: npmProgressParser });
1631
+ clearInterval(sizeTracker);
1632
+ if (!result.ok) {
1633
+ emitTask(task, { type: "error", message: "OpenClaw 安装失败" });
1634
+ task.status = "error";
1635
+ return {
1636
+ ok: false,
1637
+ message: "OpenClaw installation failed",
1638
+ error: result.output,
1639
+ taskId: task.id,
1640
+ };
1641
+ }
1642
+ const ver = readInstalledOpenclawVersion() || "installed";
1643
+ emitTask(task, { type: "done", message: `OpenClaw 安装完成: ${ver}`, progress: 100 });
1644
+ task.status = "done";
1645
+ return { ok: true, message: `OpenClaw installed: ${ver}`, taskId: task.id };
1646
+ }
1647
+ catch (e) {
1648
+ return { ok: false, message: "OpenClaw installation failed", error: e.message };
1649
+ }
1650
+ }
1651
+ /**
1652
+ * Build / pull the OpenClaw Docker image. Blocking — useful for CLI
1653
+ * installer and `runFullSetup()`. Non-blocking variant below is used by
1654
+ * the REST API and the adapter's own `onBeforeStart` self-heal path.
1655
+ */
1656
+ async buildRuntimeImage(opts) {
1657
+ const task = createTask("openclaw-docker-pull");
1658
+ return pullOrBuildOpenclawImageWithTask(task, opts?.tag);
1659
+ }
1660
+ /**
1661
+ * Non-blocking variant of `buildRuntimeImage` — returns a task id
1662
+ * immediately so the caller can poll SSE progress.
1663
+ */
1664
+ startBuildRuntimeImage(opts) {
1665
+ const task = createTask("openclaw-docker-pull");
1666
+ void pullOrBuildOpenclawImageWithTask(task, opts?.tag).catch((err) => {
1667
+ emitTask(task, {
1668
+ type: "error",
1669
+ message: `镜像获取失败: ${err?.message || err}`,
1670
+ });
1671
+ task.status = "error";
1672
+ });
1673
+ return { ok: true, message: "Docker image pull started", taskId: task.id };
1674
+ }
1675
+ /**
1676
+ * §32.2.5 — register OpenClaw-specific HTTP endpoints. Called from
1677
+ * `routes/instances.ts` at server startup via the generic adapter
1678
+ * dispatch loop.
1679
+ */
1680
+ registerRoutes(app) {
1681
+ registerOpenclawRoutes(app);
1682
+ }
1683
+ /**
1684
+ * Return OpenClaw's readiness for spawning instances. Docker image
1685
+ * presence is the gating factor in the default docker-mode deployment;
1686
+ * the legacy `npm install` path is still reported as `installed` but is
1687
+ * not required for the docker-driver stack.
1688
+ */
1689
+ getInstallStatus() {
1690
+ // OPENCLAW_MODULES may be undefined when config is partially mocked in tests.
1691
+ const pkgInstalled = OPENCLAW_MODULES ? existsSync(join(OPENCLAW_MODULES, "openclaw")) : false;
1692
+ const version = pkgInstalled ? readInstalledOpenclawVersion() : undefined;
1693
+ const imageTag = getOpenclawDockerImage?.() ?? "";
1694
+ let imageReady = false;
1695
+ try {
1696
+ const invocation = resolveDockerInvocation();
1697
+ if (invocation?.cmd) {
1698
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", imageTag], {
1699
+ timeout: 5000,
1700
+ stdio: "ignore",
1701
+ });
1702
+ imageReady = true;
1703
+ }
1704
+ }
1705
+ catch { /* image absent */ }
1706
+ return {
1707
+ installed: imageReady || pkgInstalled,
1708
+ imageReady,
1709
+ version: version || undefined,
1710
+ };
1711
+ }
1712
+ }
1713
+ // ── Install-time helpers (used by installRuntime) ─────────────────────
1714
+ const OPENCLAW_EXPECTED_SIZE_MB = 700;
1715
+ function readInstalledOpenclawVersion() {
1716
+ try {
1717
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
1718
+ if (existsSync(pkg))
1719
+ return JSON.parse(readFileSync(pkg, "utf-8")).version || "";
1720
+ }
1721
+ catch {
1722
+ /* best effort */
1723
+ }
1724
+ return "";
1725
+ }
1726
+ // Feishu app IDs issued by the open platform follow `cli_<hex/alnum>`.
1727
+ const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
1728
+ const SAFE_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
1729
+ // ── Channel plugin constants (physically migrated) ───────────────────
1730
+ const CHANNEL_EXT_DIR_ALIAS = {
1731
+ feishu: "openclaw-lark",
1732
+ lark: "openclaw-lark",
1733
+ };
1734
+ const CHANNEL_PLUGIN_MAP = {
1735
+ feishu: "@larksuite/openclaw-lark",
1736
+ lark: "@larksuite/openclaw-lark",
1737
+ telegram: "@openclaw/telegram",
1738
+ discord: "@openclaw/discord",
1739
+ slack: "@openclaw/slack",
1740
+ whatsapp: "@openclaw/whatsapp",
1741
+ signal: "@openclaw/signal",
1742
+ line: "@openclaw/line",
1743
+ msteams: "@openclaw/msteams",
1744
+ "openclaw-weixin": "@tencent-weixin/openclaw-weixin",
1745
+ };
1746
+ const IM_PLUGIN_ENTRY_IDS = new Set([
1747
+ ...Object.keys(CHANNEL_PLUGIN_MAP),
1748
+ ...Object.values(CHANNEL_EXT_DIR_ALIAS),
1749
+ ]);
1750
+ // ── Config I/O helpers (physically migrated) ─────────────────────────
1751
+ function hasConfiguredValue(value) {
1752
+ if (typeof value !== "string")
1753
+ return !!value;
1754
+ return value.trim().length > 0;
1755
+ }
1756
+ function loadJsonSafe(path) {
1757
+ try {
1758
+ return JSON.parse(readFileSync(path, "utf-8"));
1759
+ }
1760
+ catch (e) {
1761
+ console.warn(`[openclaw] Failed to parse ${path}: ${e.message}`);
1762
+ return null;
1763
+ }
1764
+ }
1765
+ function deepMergeConfig(base, overlay) {
1766
+ if (typeof base !== "object" || base === null ||
1767
+ typeof overlay !== "object" || overlay === null ||
1768
+ Array.isArray(base) || Array.isArray(overlay)) {
1769
+ return structuredClone(overlay);
1770
+ }
1771
+ const merged = structuredClone(base);
1772
+ for (const key of Object.keys(overlay)) {
1773
+ merged[key] = key in merged ? deepMergeConfig(merged[key], overlay[key]) : structuredClone(overlay[key]);
1774
+ }
1775
+ return merged;
1776
+ }
1777
+ function loadEffectiveConfig(instanceId) {
1778
+ const runtimePath = openclawConfigPath(instanceId);
1779
+ const legacyPath = legacyOpenclawConfigPath(instanceId);
1780
+ const rExists = existsSync(runtimePath);
1781
+ const lExists = existsSync(legacyPath);
1782
+ if (rExists && lExists) {
1783
+ const legacy = loadJsonSafe(legacyPath);
1784
+ const runtime = loadJsonSafe(runtimePath);
1785
+ if (legacy && runtime)
1786
+ return deepMergeConfig(legacy, runtime);
1787
+ return runtime || legacy || null;
1788
+ }
1789
+ if (rExists)
1790
+ return loadJsonSafe(runtimePath);
1791
+ if (lExists)
1792
+ return loadJsonSafe(legacyPath);
1793
+ return null;
1794
+ }
1795
+ function injectProviderApiKeys(instanceId, config) {
1796
+ const merged = structuredClone(config);
1797
+ const runtimeEnv = getRuntimeEnv(instanceId);
1798
+ const providers = merged.models?.providers || {};
1799
+ for (const [providerId, provider] of Object.entries(providers)) {
1800
+ if (typeof provider !== "object" || provider === null)
1801
+ continue;
1802
+ const p = provider;
1803
+ const api = p.api;
1804
+ if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
1805
+ p.api = LEGACY_PROVIDER_API_ALIASES[api];
1806
+ }
1807
+ const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
1808
+ if (apiKey)
1809
+ p.apiKey = apiKey;
1810
+ }
1811
+ return merged;
1812
+ }
1813
+ function applyFeishuDebugAccessDefaults(channel) {
1814
+ if (channel.enabled === false)
1815
+ return;
1816
+ if (!hasConfiguredValue(channel.appId))
1817
+ return;
1818
+ if (!hasConfiguredValue(channel.appSecret))
1819
+ return;
1820
+ let dmPolicy = channel.dmPolicy;
1821
+ if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
1822
+ channel.dmPolicy = "open";
1823
+ dmPolicy = "open";
1824
+ }
1825
+ if (dmPolicy !== "open")
1826
+ return;
1827
+ if (!("resolveSenderNames" in channel))
1828
+ channel.resolveSenderNames = false;
1829
+ let accounts = channel.accounts;
1830
+ if (typeof accounts !== "object" || accounts === null) {
1831
+ accounts = {};
1832
+ channel.accounts = accounts;
1833
+ }
1834
+ let defaultAccount = accounts.default;
1835
+ if (typeof defaultAccount !== "object" || defaultAccount === null) {
1836
+ defaultAccount = {};
1837
+ accounts.default = defaultAccount;
1838
+ }
1839
+ if (!("resolveSenderNames" in defaultAccount))
1840
+ defaultAccount.resolveSenderNames = false;
1841
+ const allowFrom = channel.allowFrom;
1842
+ if (Array.isArray(allowFrom)) {
1843
+ const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
1844
+ if (!normalized.includes("*"))
1845
+ normalized.push("*");
1846
+ channel.allowFrom = normalized;
1847
+ return;
1848
+ }
1849
+ channel.allowFrom = ["*"];
1850
+ }
1851
+ function prepareConfigForSave(instanceId, config) {
1852
+ const configToWrite = structuredClone(config);
1853
+ delete configToWrite["x-jishushell"];
1854
+ const envUpdates = {};
1855
+ const providers = configToWrite.models?.providers || {};
1856
+ const envFiles = getRuntimeEnvFiles(instanceId);
1857
+ const channels = configToWrite.channels || {};
1858
+ const plugins = configToWrite.plugins ??= {};
1859
+ const pluginEntries = plugins.entries ??= {};
1860
+ for (const [providerId, provider] of Object.entries(providers)) {
1861
+ if (typeof provider !== "object" || provider === null)
1862
+ continue;
1863
+ const p = provider;
1864
+ if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
1865
+ p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
1866
+ }
1867
+ if (!("apiKey" in p))
1868
+ continue;
1869
+ if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
1870
+ continue;
1871
+ const apiKey = p.apiKey;
1872
+ delete p.apiKey;
1873
+ if (envFiles.length) {
1874
+ envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
1875
+ }
1876
+ else {
1877
+ p.apiKey = apiKey;
1878
+ }
1879
+ }
1880
+ for (const [channelId, channel] of Object.entries(channels)) {
1881
+ if (typeof channel !== "object" || channel === null)
1882
+ continue;
1883
+ const ch = channel;
1884
+ if (channelId === "feishu" || channelId === "lark")
1885
+ applyFeishuDebugAccessDefaults(ch);
1886
+ let pluginEntry = pluginEntries[channelId];
1887
+ if (pluginEntry == null) {
1888
+ pluginEntry = {};
1889
+ pluginEntries[channelId] = pluginEntry;
1890
+ }
1891
+ if (typeof pluginEntry === "object") {
1892
+ pluginEntry.enabled = ch.enabled !== false;
1893
+ }
1894
+ }
1895
+ return [configToWrite, envUpdates];
1896
+ }
1897
+ /**
1898
+ * Dissociate a cloned/imported config from its source instance's IM bindings.
1899
+ * Physically migrated from `instance-manager.stripImBindings` so framework
1900
+ * code no longer references OpenClaw channel concepts.
1901
+ *
1902
+ * Exported so `OpenClawAdapter.createInstance`'s clone path + `backup-manager`
1903
+ * import paths can use it without depending on instance-manager.
1904
+ */
1905
+ export function stripImBindings(config) {
1906
+ if (config?.channels)
1907
+ delete config.channels;
1908
+ const entries = config?.plugins?.entries;
1909
+ if (entries && typeof entries === "object") {
1910
+ for (const key of Object.keys(entries)) {
1911
+ if (IM_PLUGIN_ENTRY_IDS.has(key))
1912
+ delete entries[key];
1913
+ }
1914
+ }
1915
+ }
1916
+ function getChannelExtensionsDir(instanceId) {
1917
+ const home = getInstance(instanceId)?.openclaw_home ||
1918
+ defaultOpenclawHome(instanceId);
1919
+ return join(home, OPENCLAW_STATE_DIRNAME, "extensions");
1920
+ }
1921
+ function getStockExtensionsDir() {
1922
+ return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
1923
+ }
1924
+ function isChannelPluginInstalled(instanceId, channelId) {
1925
+ const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
1926
+ const stockExtDir = getStockExtensionsDir();
1927
+ return (existsSync(join(getChannelExtensionsDir(instanceId), extDirName)) ||
1928
+ existsSync(join(stockExtDir, extDirName)) ||
1929
+ (extDirName !== channelId && existsSync(join(stockExtDir, channelId))));
1930
+ }
1931
+ /**
1932
+ * Install a single channel plugin. Docker mode → `docker exec` inside the
1933
+ * running container. Host mode → spawn the host openclaw binary directly.
1934
+ * Physically migrated from `instance-manager.installChannelPlugin`.
1935
+ */
1936
+ async function installChannelPlugin(instanceId, channelId) {
1937
+ const pkg = CHANNEL_PLUGIN_MAP[channelId];
1938
+ if (!pkg)
1939
+ throw new Error(`Unknown channel: ${channelId}`);
1940
+ if (isChannelPluginInstalled(instanceId, channelId))
1941
+ return;
1942
+ const home = getInstance(instanceId)?.openclaw_home ||
1943
+ defaultOpenclawHome(instanceId);
1944
+ const extensionsDir = getChannelExtensionsDir(instanceId);
1945
+ if (getNomadDriver() === "docker") {
1946
+ await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
1947
+ return;
1948
+ }
1949
+ const openclawBin = resolveOpenclawBin();
1950
+ const nodeBinDir = dirname(process.execPath);
1951
+ const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
1952
+ const proxyEnvKeys = [
1953
+ "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
1954
+ "no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
1955
+ ];
1956
+ const proxyEnv = {};
1957
+ for (const key of proxyEnvKeys) {
1958
+ if (process.env[key])
1959
+ proxyEnv[key] = process.env[key];
1960
+ }
1961
+ const childEnv = {
1962
+ PATH: childPath,
1963
+ HOME: process.env.HOME,
1964
+ LANG: process.env.LANG,
1965
+ OPENCLAW_HOME: home,
1966
+ ...proxyEnv,
1967
+ };
1968
+ const MAX_ATTEMPTS = 3;
1969
+ const RETRY_DELAY_MS = 5_000;
1970
+ const attemptInstall = () => new Promise((resolve, reject) => {
1971
+ execFile(openclawBin, ["plugins", "install", pkg], { cwd: home, env: childEnv, timeout: 300_000 }, (err, stdout, stderr) => {
1972
+ if (err && !isChannelPluginInstalled(instanceId, channelId)) {
1973
+ const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
1974
+ console.error(`[plugins] ${pkg} exit code ${err.code ?? "?"}, stderr: ${stderr?.trim() || "(empty)"}, stdout: ${stdout?.trim() || "(empty)"}`);
1975
+ try {
1976
+ if (existsSync(extensionsDir)) {
1977
+ for (const entry of readdirSync(extensionsDir)) {
1978
+ if (entry.startsWith(".openclaw-install-stage-")) {
1979
+ rmSync(join(extensionsDir, entry), { recursive: true, force: true });
1980
+ console.log(`[plugins] Cleaned up stage dir: ${entry}`);
1981
+ }
1982
+ }
1983
+ }
1984
+ }
1985
+ catch {
1986
+ /* ignore */
1987
+ }
1988
+ reject(new Error(msg));
1989
+ }
1990
+ else {
1991
+ if (err)
1992
+ console.log(`[plugins] ${pkg} installed (ignored non-zero exit)`);
1993
+ else
1994
+ console.log(`[plugins] ${pkg} installed`);
1995
+ resolve();
1996
+ }
1997
+ });
1998
+ });
1999
+ console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
2000
+ let lastErr;
2001
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
2002
+ try {
2003
+ await attemptInstall();
2004
+ const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
2005
+ const installedExtDir = join(extensionsDir, extDirName);
2006
+ if (existsSync(installedExtDir)) {
2007
+ ensureDirContainer(installedExtDir);
2008
+ try {
2009
+ for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
2010
+ if (entry.isDirectory()) {
2011
+ ensureDirContainer(join(installedExtDir, entry.name));
2012
+ }
2013
+ }
2014
+ }
2015
+ catch {
2016
+ /* best effort */
2017
+ }
2018
+ }
2019
+ ensureDirContainer(extensionsDir);
2020
+ return;
2021
+ }
2022
+ catch (err) {
2023
+ lastErr = err;
2024
+ const isFetchError = /fetch failed/i.test(err.message ?? "");
2025
+ if (isFetchError && attempt < MAX_ATTEMPTS) {
2026
+ console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
2027
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
2028
+ continue;
2029
+ }
2030
+ console.error(`[plugins] Failed to install ${pkg}:`, err.message);
2031
+ break;
2032
+ }
2033
+ }
2034
+ throw lastErr;
2035
+ }
2036
+ async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
2037
+ const { exec } = await import("../../nomad-manager.js");
2038
+ const MAX_ATTEMPTS = 3;
2039
+ const RETRY_DELAY_MS = 5_000;
2040
+ console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
2041
+ let lastErr;
2042
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
2043
+ try {
2044
+ const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
2045
+ if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
2046
+ const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
2047
+ console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
2048
+ throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
2049
+ }
2050
+ if (result.exitCode !== 0) {
2051
+ console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit)`);
2052
+ }
2053
+ else {
2054
+ console.log(`[plugins] ${pkg} installed via docker`);
2055
+ }
2056
+ const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
2057
+ const installedExtDir = join(extensionsDir, extDirName);
2058
+ if (existsSync(installedExtDir)) {
2059
+ ensureDirContainer(installedExtDir);
2060
+ try {
2061
+ for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
2062
+ if (entry.isDirectory()) {
2063
+ ensureDirContainer(join(installedExtDir, entry.name));
2064
+ }
2065
+ }
2066
+ }
2067
+ catch {
2068
+ /* best effort */
2069
+ }
2070
+ }
2071
+ ensureDirContainer(extensionsDir);
2072
+ return;
2073
+ }
2074
+ catch (err) {
2075
+ lastErr = err;
2076
+ if (/not running/i.test(err.message ?? "")) {
2077
+ throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
2078
+ }
2079
+ const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
2080
+ if (isTransient && attempt < MAX_ATTEMPTS) {
2081
+ console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying...`);
2082
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
2083
+ continue;
2084
+ }
2085
+ console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
2086
+ break;
2087
+ }
2088
+ }
2089
+ throw lastErr;
2090
+ }
2091
+ /**
2092
+ * Full saveConfig implementation. Writes `.openclaw/openclaw.json`,
2093
+ * mirrors into legacy `openclaw.json` path, updates env files with
2094
+ * provider API keys, preserves backend-managed fields, and fires
2095
+ * config-change listeners.
2096
+ *
2097
+ * Physically migrated from `instance-manager.saveConfig`.
2098
+ */
2099
+ function saveNativeConfigImpl(instanceId, config) {
2100
+ const configPath = openclawConfigPath(instanceId);
2101
+ if (!existsSync(framework_instanceDir(instanceId)))
2102
+ return false;
2103
+ if (!existsSync(configPath)) {
2104
+ const legacyPath = legacyOpenclawConfigPath(instanceId);
2105
+ ensureDirContainer(dirname(configPath));
2106
+ if (existsSync(legacyPath))
2107
+ copyFileSync(legacyPath, configPath);
2108
+ }
2109
+ // Save x-jishushell metadata to instance.json (not openclaw.json)
2110
+ if (config["x-jishushell"]) {
2111
+ const metaPath = instanceMetaPath(instanceId);
2112
+ if (existsSync(metaPath)) {
2113
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
2114
+ meta["x-jishushell"] = config["x-jishushell"];
2115
+ safeWriteJson(metaPath, meta);
2116
+ chownToServiceUser(metaPath);
2117
+ }
2118
+ }
2119
+ const [configToWrite, envUpdates] = prepareConfigForSave(instanceId, config);
2120
+ // If openclaw-lark is enabled, resolve which feishu plugin to use
2121
+ if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
2122
+ const stockExtDir = getStockExtensionsDir();
2123
+ const stockFeishu = join(stockExtDir, "feishu");
2124
+ const stockOcl = join(stockExtDir, "openclaw-lark");
2125
+ const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
2126
+ if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
2127
+ configToWrite.plugins.entries.feishu = { enabled: true };
2128
+ delete configToWrite.plugins.entries["openclaw-lark"];
2129
+ }
2130
+ else if (existsSync(stockFeishu)) {
2131
+ configToWrite.plugins ??= {};
2132
+ configToWrite.plugins.entries ??= {};
2133
+ configToWrite.plugins.entries.feishu = { enabled: false };
2134
+ }
2135
+ }
2136
+ // Preserve backend-managed fields
2137
+ if (existsSync(configPath)) {
2138
+ try {
2139
+ const existing = JSON.parse(readFileSync(configPath, "utf-8"));
2140
+ if (existing.plugins?.installs) {
2141
+ configToWrite.plugins ??= {};
2142
+ configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
2143
+ }
2144
+ if (existing.plugins?.entries && configToWrite.plugins?.entries) {
2145
+ for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
2146
+ const old = existing.plugins.entries[key];
2147
+ if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
2148
+ configToWrite.plugins.entries[key] = { ...old, ...val };
2149
+ }
2150
+ }
2151
+ }
2152
+ if (existing.channels && configToWrite.channels) {
2153
+ for (const [key, val] of Object.entries(configToWrite.channels)) {
2154
+ const old = existing.channels[key];
2155
+ if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
2156
+ configToWrite.channels[key] = { ...old, ...val };
2157
+ }
2158
+ }
2159
+ }
2160
+ }
2161
+ catch {
2162
+ /* best effort */
2163
+ }
2164
+ }
2165
+ // Backup + atomic write
2166
+ if (existsSync(configPath)) {
2167
+ copyFileSync(configPath, configPath + ".bak");
2168
+ }
2169
+ const configJson = JSON.stringify(configToWrite, null, 2);
2170
+ ensureDirContainer(dirname(configPath));
2171
+ writeConfigFile(configPath + ".tmp", configJson);
2172
+ JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
2173
+ renameSync(configPath + ".tmp", configPath);
2174
+ chownToServiceUser(configPath);
2175
+ // Mirror into legacy path
2176
+ const legacyPath = legacyOpenclawConfigPath(instanceId);
2177
+ if (existsSync(legacyPath)) {
2178
+ copyFileSync(legacyPath, legacyPath + ".bak");
2179
+ }
2180
+ writeConfigFile(legacyPath + ".tmp", configJson);
2181
+ JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
2182
+ renameSync(legacyPath + ".tmp", legacyPath);
2183
+ chownToServiceUser(legacyPath);
2184
+ if (Object.keys(envUpdates).length) {
2185
+ const envFiles = getRuntimeEnvFiles(instanceId);
2186
+ if (envFiles.length)
2187
+ updateEnvFile(envFiles[0], envUpdates);
2188
+ }
2189
+ // Notify listeners (LLM proxy cache invalidation etc.)
2190
+ notifyConfigChange(instanceId);
2191
+ return true;
2192
+ }
2193
+ export const openclawAdapter = new OpenClawAdapter();
2194
+ registerAdapter(openclawAdapter);
2195
+ //# sourceMappingURL=openclaw.js.map