jishushell 0.4.10 → 0.4.24-beta.2

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 +164 -0
  5. package/apps/ollama-cpu-container.yaml +37 -0
  6. package/apps/ollama-with-hollama-binary.yaml +159 -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 +874 -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 +2148 -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 +224 -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 -38
  206. package/install/post-install.sh +64 -5
  207. package/package.json +19 -5
  208. package/public/assets/Dashboard-rh9qpYRR.js +1 -0
  209. package/public/assets/HermesChatPanel-D6JI6lLY.js +1 -0
  210. package/public/assets/HermesConfigForm-DcbSemaj.js +4 -0
  211. package/public/assets/InitPassword-CFTKsED4.js +1 -0
  212. package/public/assets/InstanceDetail-BhNIKA6Z.js +91 -0
  213. package/public/assets/{Login-CUoEZOWR.js → Login-KB9qrtM0.js} +1 -1
  214. package/public/assets/NewInstance-CxkO8Hlq.js +1 -0
  215. package/public/assets/Settings-BVWJvOkU.js +1 -0
  216. package/public/assets/Setup-X-lzuaUT.js +1 -0
  217. package/public/assets/WeixinLoginPanel-gca0QTic.js +9 -0
  218. package/public/assets/index-C8B0cFJM.js +19 -0
  219. package/public/assets/index-CPhVFEsx.css +1 -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-fVUSujib.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
@@ -1,13 +1,32 @@
1
- import { execFile, execFileSync } from "child_process";
2
- import { randomBytes } from "crypto";
3
- import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
1
+ import { execFileSync } from "child_process";
2
+ import { chownSync, closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, statSync, } from "fs";
3
+ import { rm as rmAsync } from "fs/promises";
4
4
  import { createServer as netCreateServer } from "net";
5
- import { userInfo } from "os";
5
+ import { networkInterfaces, userInfo } from "os";
6
6
  import { dirname, join, resolve } from "path";
7
- import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
8
- import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
7
+ import * as config from "../config.js";
9
8
  import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
10
- import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
9
+ import { ensureDirContainer, writeSecretFile } from "../utils/fs.js";
10
+ // runtime/index.ts gets imported only for framework-level adapter lookups
11
+ // (defaultGatewayPort fallback). Adapters statically import BACK into this
12
+ // file for primitives; that static cycle is safe because no top-level code
13
+ // in adapters references instance-manager exports.
14
+ import { getAdapter, resolveAgentType } from "./runtime/index.js";
15
+ import { backfillInstanceMeta } from "./runtime/migrations.js";
16
+ function getConfigValue(name) {
17
+ return name in config ? config[name] : undefined;
18
+ }
19
+ function resolveConfigPath(value, fallback) {
20
+ return typeof value === "string" && value.trim() ? value : fallback;
21
+ }
22
+ const JISHUSHELL_HOME = resolveConfigPath(getConfigValue("JISHUSHELL_HOME"), resolve(process.env.HOME ?? userInfo().homedir, ".jishushell"));
23
+ const APPS_DIR = resolveConfigPath(getConfigValue("APPS_DIR"), join(JISHUSHELL_HOME, "apps"));
24
+ const BACKUPS_DIR = resolveConfigPath(getConfigValue("BACKUPS_DIR"), join(JISHUSHELL_HOME, "backups"));
25
+ const INSTANCES_DIR = resolveConfigPath(getConfigValue("INSTANCES_DIR"), join(JISHUSHELL_HOME, "instances"));
26
+ const getPanelConfigValue = getConfigValue("getPanelConfig");
27
+ const getPanelConfig = typeof getPanelConfigValue === "function"
28
+ ? getPanelConfigValue
29
+ : () => ({});
11
30
  const _configChangeListeners = [];
12
31
  export function onConfigChange(listener) {
13
32
  _configChangeListeners.push(listener);
@@ -17,41 +36,66 @@ export function onConfigChange(listener) {
17
36
  _configChangeListeners.splice(idx, 1);
18
37
  };
19
38
  }
20
- const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
- const DEFAULT_GATEWAY_PORT = 18789;
22
- const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
23
- const INSTANCE_MODEL_ENV_FILENAME = "model.env";
24
- const OPENCLAW_STATE_DIRNAME = ".openclaw";
25
- const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
26
- // ── Path helpers ──
27
- function instanceDir(instanceId) {
28
- return join(INSTANCES_DIR, instanceId);
39
+ /**
40
+ * Fire the config-change listener fan-out. Adapters call this after
41
+ * `saveNativeConfig()` so LLM proxy / config editors pick up the change.
42
+ */
43
+ export function notifyConfigChange(instanceId) {
44
+ for (const listener of _configChangeListeners) {
45
+ try {
46
+ listener(instanceId);
47
+ }
48
+ catch { /* ignore listener errors */ }
49
+ }
29
50
  }
30
- function instanceMetaPath(instanceId) {
31
- return join(instanceDir(instanceId), "instance.json");
51
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
52
+ // ── Path helpers (framework-level) ──
53
+ //
54
+ // Physical definitions live in config.ts alongside INSTANCES_DIR so
55
+ // tests that mock instance-manager.js don't lose path resolution. The
56
+ // re-exports below preserve the existing import surface.
57
+ function appInstanceDir(instanceId) {
58
+ return join(APPS_DIR, instanceId);
32
59
  }
33
- function defaultOpenclawHome(instanceId) {
34
- return join(instanceDir(instanceId), INSTANCE_OPENCLAW_HOME_DIRNAME);
60
+ function hasAppInstallMarkers(dir) {
61
+ return existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "app-spec.yaml"));
35
62
  }
36
- function defaultModelEnvFile(instanceId) {
37
- return join(instanceDir(instanceId), INSTANCE_MODEL_ENV_FILENAME);
63
+ function hasIncompleteAppInstallShadow(dir) {
64
+ const hasManifest = existsSync(join(dir, "manifest.json"));
65
+ const hasSpec = existsSync(join(dir, "app-spec.yaml"));
66
+ return (hasManifest || hasSpec) && !(hasManifest && hasSpec);
38
67
  }
39
- function getOpenclawHomeInternal(instanceId) {
40
- const meta = getInstance(instanceId);
41
- if (meta?.openclaw_home)
42
- return meta.openclaw_home;
43
- return defaultOpenclawHome(instanceId);
68
+ function resolveInstanceRoot(instanceId) {
69
+ const appDir = appInstanceDir(instanceId);
70
+ if (hasAppInstallMarkers(appDir) || (existsSync(join(appDir, "instance.json")) && !hasIncompleteAppInstallShadow(appDir))) {
71
+ return appDir;
72
+ }
73
+ // V1 legacy grandfathered path: fall through to `instances/<id>/` only when
74
+ // the legacy dir has real content. New instances (neither dir exists) get
75
+ // the V2 `apps/<id>/` default so they land in the new layout from day one.
76
+ const legacyDir = join(INSTANCES_DIR, instanceId);
77
+ if (existsSync(join(legacyDir, "instance.json"))) {
78
+ return legacyDir;
79
+ }
80
+ return appDir;
44
81
  }
45
- function openclawStateDir(instanceId) {
46
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME);
82
+ export function instanceDir(instanceId) {
83
+ return resolveInstanceRoot(instanceId);
47
84
  }
48
- function legacyOpenclawConfigPath(instanceId) {
49
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_CONFIG_FILENAME);
85
+ export function instanceMetaPath(instanceId) {
86
+ return join(instanceDir(instanceId), "instance.json");
50
87
  }
51
- function openclawConfigPathInternal(instanceId) {
52
- return join(openclawStateDir(instanceId), OPENCLAW_CONFIG_FILENAME);
88
+ // §32.2 / §32.8: `defaultOpenclawHome` / `openclawStateDir` /
89
+ // `openclawConfigPathInternal` / `legacyOpenclawConfigPath` /
90
+ // `getOpenclawHomeInternal` / `defaultModelEnvFile` physically migrated
91
+ // into `src/services/runtime/adapters/openclaw.ts`. Callers reach them
92
+ // through `getAdapter("openclaw").resolve*()` methods.
93
+ // Re-export `defaultModelEnvFile` as a dispatch wrapper for any
94
+ // remaining framework callers that need an env file path.
95
+ export function defaultModelEnvFile(instanceId) {
96
+ return join(instanceDir(instanceId), "model.env");
53
97
  }
54
- function normalizePath(p) {
98
+ export function normalizePath(p) {
55
99
  return resolve(p.replace(/^~/, userInfo().homedir));
56
100
  }
57
101
  // ── JSON / deep merge ──
@@ -80,27 +124,32 @@ function deepMerge(base, overlay) {
80
124
  // Track in-flight port allocations to prevent race conditions
81
125
  // between concurrent createInstance() calls.
82
126
  const _pendingPorts = new Set();
83
- function extractGatewayPort(runtime) {
127
+ /**
128
+ * Read the gateway port out of a persisted runtime record. Tries
129
+ * `runtime.ports[]` first (generic framework contract); on miss,
130
+ * asks the runtime adapter for its legacy fallback (e.g. OpenClaw's
131
+ * `env.OPENCLAW_GATEWAY_PORT` / `args --port N`).
132
+ */
133
+ export function extractGatewayPort(runtime, agentType) {
84
134
  if (!runtime)
85
135
  return null;
86
- const envPort = runtime.env?.OPENCLAW_GATEWAY_PORT;
87
- if (envPort) {
88
- const p = parseInt(envPort, 10);
89
- if (!isNaN(p))
90
- return p;
91
- }
92
- const args = runtime.args || [];
93
- for (let i = 0; i < args.length; i++) {
94
- const arg = String(args[i]);
95
- if (arg === "--port" && i + 1 < args.length) {
96
- const p = parseInt(args[i + 1], 10);
97
- return isNaN(p) ? null : p;
136
+ // Primary: RuntimeSpec.ports[] declaration — first gateway-labeled port wins.
137
+ const ports = Array.isArray(runtime.ports) ? runtime.ports : [];
138
+ for (const port of ports) {
139
+ if (port?.name === "gateway" && Number.isInteger(port.hostPort) && port.hostPort > 0) {
140
+ return port.hostPort;
98
141
  }
99
- if (arg.startsWith("--port=")) {
100
- const p = parseInt(arg.split("=")[1], 10);
101
- return isNaN(p) ? null : p;
142
+ }
143
+ // Fall back to the adapter's legacy reader. Default agentType "openclaw".
144
+ try {
145
+ const adapter = getAdapter(agentType || "openclaw");
146
+ if (typeof adapter.readLegacyGatewayPort === "function") {
147
+ return adapter.readLegacyGatewayPort(runtime);
102
148
  }
103
149
  }
150
+ catch {
151
+ /* adapter not registered — no fallback */
152
+ }
104
153
  return null;
105
154
  }
106
155
  function usedGatewayPorts(excludeId) {
@@ -119,25 +168,83 @@ function safePort(port) {
119
168
  throw new Error(`Invalid port: ${port}`);
120
169
  return String(port);
121
170
  }
122
- function isPortInUse(port) {
171
+ /**
172
+ * Probes whether a port is currently held by any process on the host.
173
+ *
174
+ * Binds four addresses concurrently — `0.0.0.0`, `127.0.0.1`, `::` (v6-only),
175
+ * and `::1` — and treats the port as busy if any probe returns EADDRINUSE.
176
+ * The single-`0.0.0.0` probe used by the earlier revision was sufficient on
177
+ * Linux (where binding `0.0.0.0` conflicts with any pre-existing loopback
178
+ * listener on the same port) but silently passed on macOS, where BSD socket
179
+ * semantics let a wildcard v4 bind coexist with a `127.0.0.1` listener. A
180
+ * user running a natively-installed openclaw bound to `127.0.0.1:18789`
181
+ * would then be invisible to jishushell, so port allocation would assign
182
+ * 18789 to a new instance and the gateway would silently fail to bind at
183
+ * start time. Probing the loopback addresses directly closes that gap.
184
+ */
185
+ export function isPortInUse(port) {
123
186
  if (!Number.isInteger(port) || port < 1 || port > 65535)
124
187
  return Promise.resolve(false);
125
- return new Promise((resolve) => {
188
+ const probeAt = (host, opts = {}) => new Promise((resolve) => {
126
189
  const server = netCreateServer();
127
- server.once("error", () => resolve(true));
190
+ server.once("error", (err) => {
191
+ if (err?.code === "EADDRINUSE")
192
+ return resolve(true);
193
+ // EACCES / EADDRNOTAVAIL / ENOTSUP / EINVAL mean we could not even
194
+ // attempt the bind (restricted port, address family unsupported on
195
+ // this host, etc). Report "not busy" from this probe so one bad
196
+ // locus doesn't falsely block the whole port — the other probes
197
+ // still cover their respective loci.
198
+ if (err?.code && err.code !== "EACCES" && err.code !== "EADDRNOTAVAIL"
199
+ && err.code !== "ENOTSUP" && err.code !== "EINVAL" && err.code !== "EAFNOSUPPORT") {
200
+ console.warn(`[port-probe] bind ${host}:${port} failed with ${err.code}: ${err.message}; treating locus as free`);
201
+ }
202
+ resolve(false);
203
+ });
128
204
  server.once("listening", () => {
129
205
  server.close(() => resolve(false));
130
206
  });
131
- server.listen(port, "0.0.0.0");
207
+ server.listen({ port, host, ...opts });
132
208
  });
209
+ // Run probes sequentially, not in parallel: two probes on the same port
210
+ // collide with each other (the first bind on 0.0.0.0 makes the second
211
+ // bind on 127.0.0.1 hit EADDRINUSE), which would make every port look
212
+ // busy. Short-circuit on the first busy locus to keep the common case
213
+ // (port free) close to single-probe latency.
214
+ return (async () => {
215
+ const loci = [
216
+ ["0.0.0.0", {}],
217
+ ["127.0.0.1", {}],
218
+ ["::", { ipv6Only: true }],
219
+ ["::1", {}],
220
+ ];
221
+ for (const [host, opts] of loci) {
222
+ if (await probeAt(host, opts))
223
+ return true;
224
+ }
225
+ return false;
226
+ })();
133
227
  }
134
- async function defaultGatewayPort(instanceId) {
228
+ /**
229
+ * Kind-agnostic gateway port allocator. Callers (adapters) pass their own
230
+ * `defaultPort` — the framework no longer hardcodes OpenClaw / Hermes base
231
+ * ports here. Walks upward from `defaultPort` until a free port is found
232
+ * that is neither held by an existing instance nor by any process on the
233
+ * host. Concurrent callers coordinate via `_pendingPorts`.
234
+ *
235
+ * Caller must release the allocated port via {@link releasePendingPort}
236
+ * after persisting it into instance metadata (or on failure).
237
+ */
238
+ export async function allocateGatewayPort(instanceId, defaultPort) {
135
239
  const used = usedGatewayPorts(instanceId);
136
- let port = DEFAULT_GATEWAY_PORT;
240
+ const skipped = [];
241
+ let port = defaultPort;
137
242
  while (true) {
138
- if (port > 65535)
139
- throw new Error("No available gateway port found (all ports 18789-65535 in use)");
243
+ if (port > 65535) {
244
+ throw new Error(`No available gateway port found (all ports ${defaultPort}-65535 in use)`);
245
+ }
140
246
  if (used.has(port) || _pendingPorts.has(port)) {
247
+ skipped.push(port);
141
248
  port++;
142
249
  continue;
143
250
  }
@@ -147,49 +254,38 @@ async function defaultGatewayPort(instanceId) {
147
254
  try {
148
255
  if (await isPortInUse(port)) {
149
256
  _pendingPorts.delete(port);
257
+ skipped.push(port);
150
258
  port++;
151
259
  continue;
152
260
  }
153
- return port;
261
+ return { port, skipped };
154
262
  }
155
263
  catch {
156
264
  _pendingPorts.delete(port);
157
265
  // Skip this port on a transient OS error rather than failing the entire
158
266
  // allocation — a single bad port check should not prevent instance creation.
159
267
  console.warn(`[instance] Port ${port} availability check failed, trying next port`);
268
+ skipped.push(port);
160
269
  port++;
161
270
  continue;
162
271
  }
163
272
  }
164
273
  }
165
- // ── Runtime / config builders ──
166
- function resolveOpenclawBin() {
167
- const candidates = [
168
- join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
169
- "/usr/local/bin/openclaw",
170
- "/usr/bin/openclaw",
171
- ];
172
- for (const p of candidates) {
173
- if (existsSync(p)) {
174
- // Ensure executable permission (npm install may strip +x on some platforms)
175
- try {
176
- chmodSync(p, 0o755);
177
- }
178
- catch { /* best effort — may be a symlink */ }
179
- // If symlink, also chmod the target
180
- try {
181
- const real = realpathSync(p);
182
- if (real !== p)
183
- chmodSync(real, 0o755);
184
- }
185
- catch { /* best effort */ }
186
- return p;
187
- }
188
- }
189
- return candidates[0]; // fallback, will fail with clear error at spawn
274
+ /** Release a port previously reserved by {@link allocateGatewayPort}. */
275
+ export function releasePendingPort(port) {
276
+ _pendingPorts.delete(port);
190
277
  }
278
+ // ── Runtime / config builders ──
191
279
  export function getResolvedOpenclawBin() {
192
- return resolveOpenclawBin();
280
+ try {
281
+ const a = getAdapter("openclaw");
282
+ return typeof a.resolveBin === "function"
283
+ ? a.resolveBin()
284
+ : "/usr/local/bin/openclaw";
285
+ }
286
+ catch {
287
+ return "/usr/local/bin/openclaw";
288
+ }
193
289
  }
194
290
  /**
195
291
  * When jishushell runs as root (e.g. systemd service), returns the actual
@@ -229,7 +325,7 @@ export function resolveServiceUser() {
229
325
  * openclaw process (running as that user) can read/write its own data files.
230
326
  * No-op when not running as root.
231
327
  */
232
- function chownToServiceUser(...paths) {
328
+ export function chownToServiceUser(...paths) {
233
329
  const svc = resolveServiceUser();
234
330
  if (!svc)
235
331
  return;
@@ -243,90 +339,9 @@ function chownToServiceUser(...paths) {
243
339
  }
244
340
  }
245
341
  }
246
- async function defaultRuntime(instanceId, openclawHome) {
247
- const port = await defaultGatewayPort(instanceId);
248
- const home = openclawHome || defaultOpenclawHome(instanceId);
249
- return {
250
- command: resolveOpenclawBin(),
251
- args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
252
- cwd: home,
253
- user: resolveServiceUser()?.username ?? userInfo().username,
254
- env_files: [defaultModelEnvFile(instanceId)],
255
- env: {
256
- OPENCLAW_GATEWAY_PORT: String(port),
257
- NODE_OPTIONS: "--max-old-space-size=2048",
258
- },
259
- resources: { CPU: 1000, MemoryMB: 2048 },
260
- };
261
- }
262
- function starterConfig() {
263
- const dp = getPanelConfig().default_provider;
264
- let providerName = "minimax";
265
- let providerConfig = {
266
- baseUrl: "https://api.minimaxi.com/v1",
267
- api: "openai-completions",
268
- models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
269
- };
270
- let defaultModel = "minimax/MiniMax-M2.7";
271
- if (dp?.providerId) {
272
- providerName = dp.providerId;
273
- providerConfig = {
274
- baseUrl: dp.baseUrl,
275
- api: dp.api,
276
- ...(dp.authHeader ? { authHeader: true } : {}),
277
- models: dp.models || [],
278
- };
279
- const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
280
- defaultModel = `${providerName}/${modelId}`;
281
- }
282
- const config = {
283
- models: { providers: { [providerName]: providerConfig } },
284
- agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
285
- channels: {},
286
- gateway: {
287
- mode: "local",
288
- auth: { mode: "token", token: randomBytes(24).toString("hex") },
289
- controlUi: { dangerouslyDisableDeviceAuth: true },
290
- },
291
- plugins: { entries: { feishu: { enabled: false } } },
292
- };
293
- // Store upstream proxy config so LLM proxy knows where to forward
294
- if (dp?.providerId) {
295
- config["x-jishushell"] = {
296
- proxy: {
297
- upstream: {
298
- providerId: dp.providerId,
299
- baseUrl: dp.baseUrl,
300
- api: dp.api,
301
- authHeader: dp.authHeader || false,
302
- models: dp.models || [],
303
- selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
304
- hasApiKey: !!dp.apiKey,
305
- },
306
- },
307
- };
308
- }
309
- return config;
310
- }
311
- // ── Config loading ──
312
- function loadEffectiveConfig(instanceId) {
313
- const runtimePath = openclawConfigPathInternal(instanceId);
314
- const legacyPath = legacyOpenclawConfigPath(instanceId);
315
- const rExists = existsSync(runtimePath);
316
- const lExists = existsSync(legacyPath);
317
- if (rExists && lExists) {
318
- const legacy = loadJson(legacyPath);
319
- const runtime = loadJson(runtimePath);
320
- if (legacy && runtime)
321
- return deepMerge(legacy, runtime);
322
- return runtime || legacy || null;
323
- }
324
- if (rExists)
325
- return loadJson(runtimePath);
326
- if (lExists)
327
- return loadJson(legacyPath);
328
- return null;
329
- }
342
+ // §32.2 / §32.8: buildDefaultRuntime + starterConfig physically migrated
343
+ // into src/services/runtime/adapters/openclaw.ts. Framework layer no
344
+ // longer owns OpenClaw runtime templates or default config shape.
330
345
  // ── Env file helpers ──
331
346
  export function parseEnvFile(path) {
332
347
  const env = {};
@@ -400,610 +415,106 @@ export function inferProviderApiKeyEnvName(providerId) {
400
415
  normalized = "OPENCLAW_PROVIDER";
401
416
  return `${normalized}_API_KEY`;
402
417
  }
403
- function hasConfiguredValue(value) {
404
- if (typeof value === "string")
405
- return value.trim().length > 0;
406
- if (typeof value === "object" && value !== null)
407
- return Object.keys(value).length > 0;
408
- return value != null;
409
- }
410
- function injectProviderApiKeys(instanceId, config) {
411
- const merged = structuredClone(config);
412
- const runtimeEnv = getRuntimeEnv(instanceId);
413
- const providers = merged.models?.providers || {};
414
- for (const [providerId, provider] of Object.entries(providers)) {
415
- if (typeof provider !== "object" || provider === null)
416
- continue;
417
- const p = provider;
418
- const api = p.api;
419
- if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
420
- p.api = LEGACY_PROVIDER_API_ALIASES[api];
421
- }
422
- const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
423
- if (apiKey)
424
- p.apiKey = apiKey;
425
- }
426
- return merged;
427
- }
428
- function applyFeishuDebugAccessDefaults(channel) {
429
- if (channel.enabled === false)
430
- return;
431
- if (!hasConfiguredValue(channel.appId))
432
- return;
433
- if (!hasConfiguredValue(channel.appSecret))
434
- return;
435
- let dmPolicy = channel.dmPolicy;
436
- if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
437
- channel.dmPolicy = "open";
438
- dmPolicy = "open";
439
- }
440
- if (dmPolicy !== "open")
441
- return;
442
- if (!("resolveSenderNames" in channel))
443
- channel.resolveSenderNames = false;
444
- let accounts = channel.accounts;
445
- if (typeof accounts !== "object" || accounts === null) {
446
- accounts = {};
447
- channel.accounts = accounts;
448
- }
449
- let defaultAccount = accounts.default;
450
- if (typeof defaultAccount !== "object" || defaultAccount === null) {
451
- defaultAccount = {};
452
- accounts.default = defaultAccount;
453
- }
454
- if (!("resolveSenderNames" in defaultAccount))
455
- defaultAccount.resolveSenderNames = false;
456
- const allowFrom = channel.allowFrom;
457
- if (Array.isArray(allowFrom)) {
458
- const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
459
- if (!normalized.includes("*"))
460
- normalized.push("*");
461
- channel.allowFrom = normalized;
462
- return;
463
- }
464
- channel.allowFrom = ["*"];
465
- }
466
- function prepareConfigForSave(instanceId, config) {
467
- const configToWrite = structuredClone(config);
468
- // Remove JishuShell metadata — OpenClaw rejects unrecognized keys
469
- delete configToWrite["x-jishushell"];
470
- const envUpdates = {};
471
- const providers = configToWrite.models?.providers || {};
472
- const envFiles = getRuntimeEnvFiles(instanceId);
473
- const channels = configToWrite.channels || {};
474
- const plugins = configToWrite.plugins ??= {};
475
- const pluginEntries = plugins.entries ??= {};
476
- for (const [providerId, provider] of Object.entries(providers)) {
477
- if (typeof provider !== "object" || provider === null)
478
- continue;
479
- const p = provider;
480
- if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
481
- p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
482
- }
483
- if (!("apiKey" in p))
484
- continue;
485
- // Keep proxy provider apiKey in config — OpenClaw reads it from config directly.
486
- // Only real upstream provider keys get moved to env files for security.
487
- // Detect proxy by baseUrl (provider ID now uses upstream name for display).
488
- if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
489
- continue;
490
- const apiKey = p.apiKey;
491
- delete p.apiKey;
492
- if (envFiles.length) {
493
- envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
494
- }
495
- else {
496
- p.apiKey = apiKey;
497
- }
498
- }
499
- for (const [channelId, channel] of Object.entries(channels)) {
500
- if (typeof channel !== "object" || channel === null)
501
- continue;
502
- const ch = channel;
503
- if (channelId === "feishu" || channelId === "lark")
504
- applyFeishuDebugAccessDefaults(ch);
505
- let pluginEntry = pluginEntries[channelId];
506
- if (pluginEntry == null) {
507
- pluginEntry = {};
508
- pluginEntries[channelId] = pluginEntry;
509
- }
510
- if (typeof pluginEntry === "object") {
511
- pluginEntry.enabled = ch.enabled !== false;
512
- }
513
- }
514
- return [configToWrite, envUpdates];
515
- }
516
- // ── Channel plugin helpers ──
517
- // Channel → plugin package mapping for auto-install.
518
- // Stock plugins (bundled with newer OpenClaw) are detected via extensions/{id} dir;
519
- // if missing (older OpenClaw), they get installed as fallback.
520
- // @larksuite/openclaw-lark installs as "openclaw-lark" dir but registers channel "feishu"
521
- const CHANNEL_EXT_DIR_ALIAS = {
522
- feishu: "openclaw-lark",
523
- lark: "openclaw-lark",
524
- };
525
- export const CHANNEL_PLUGIN_MAP = {
526
- // Official vendor plugins (ByteDance Feishu/Lark)
527
- feishu: "@larksuite/openclaw-lark",
528
- lark: "@larksuite/openclaw-lark",
529
- // Built-in (stock) — fallback install for older OpenClaw versions
530
- telegram: "@openclaw/telegram",
531
- discord: "@openclaw/discord",
532
- slack: "@openclaw/slack",
533
- whatsapp: "@openclaw/whatsapp",
534
- signal: "@openclaw/signal",
535
- line: "@openclaw/line",
536
- msteams: "@openclaw/msteams",
537
- // Official vendor plugins — need install (not bundled)
538
- "openclaw-weixin": "@tencent-weixin/openclaw-weixin",
539
- };
540
- /**
541
- * Known IM plugin entry IDs as they appear under `config.plugins.entries`.
542
- * This is the union of channel IDs and the dir-alias names (e.g. `feishu` may
543
- * register the plugin as `openclaw-lark`), which is what must be scrubbed when
544
- * dissociating an instance from its inherited IM bindings.
545
- */
546
- const IM_PLUGIN_ENTRY_IDS = new Set([
547
- ...Object.keys(CHANNEL_PLUGIN_MAP),
548
- ...Object.values(CHANNEL_EXT_DIR_ALIAS),
549
- ]);
550
- /**
551
- * Dissociate a cloned/imported config from its source instance's IM bindings.
552
- *
553
- * Mutates the given config in place:
554
- * - Deletes the entire `channels` block (same channel cannot serve multiple
555
- * instances, so every inherited enabled/credential/account entry must go).
556
- * - Deletes matching IM entries from `plugins.entries` so the plugin loader
557
- * does not try to boot a channel whose config no longer exists.
558
- *
559
- * Used by both domain clone (`createInstance`'s `cloneFrom` path) and the
560
- * backup import paths (`importInstance`, `createFromBackup`) so that a new
561
- * instance never inherits a half-configured IM binding.
562
- */
563
- export function stripImBindings(config) {
564
- if (config?.channels)
565
- delete config.channels;
566
- const entries = config?.plugins?.entries;
567
- if (entries && typeof entries === "object") {
568
- for (const key of Object.keys(entries)) {
569
- if (IM_PLUGIN_ENTRY_IDS.has(key))
570
- delete entries[key];
571
- }
572
- }
573
- }
574
- /** Check if a channel plugin is installed for an instance. */
575
- export function isChannelPluginInstalled(instanceId, channelId) {
576
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
577
- const stockExtDir = getStockExtensionsDir();
578
- return existsSync(join(getChannelExtensionsDir(instanceId), extDirName))
579
- || existsSync(join(stockExtDir, extDirName))
580
- // Also accept the built-in directory named after the raw channelId (e.g. "feishu/" in stock)
581
- || (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
582
- }
583
- /**
584
- * Install a single channel plugin.
585
- * Docker mode: runs install inside the running container via docker exec.
586
- * Host mode (fallback): spawns the host openclaw binary directly.
587
- */
588
- export async function installChannelPlugin(instanceId, channelId) {
589
- const pkg = CHANNEL_PLUGIN_MAP[channelId];
590
- if (!pkg)
591
- throw new Error(`Unknown channel: ${channelId}`);
592
- if (isChannelPluginInstalled(instanceId, channelId))
593
- return;
594
- const openclawHome = getOpenclawHomeInternal(instanceId);
595
- const extensionsDir = getChannelExtensionsDir(instanceId);
596
- // Docker mode: always install inside container via docker exec
597
- const { getNomadDriver } = await import("../config.js");
598
- if (getNomadDriver() === "docker") {
599
- await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
600
- return;
601
- }
602
- const openclawBin = resolveOpenclawBin();
603
- // Host mode: spawn openclaw binary directly
604
- const nodeBinDir = dirname(process.execPath);
605
- const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
606
- const proxyEnvKeys = [
607
- "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
608
- "no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
609
- ];
610
- const proxyEnv = {};
611
- for (const key of proxyEnvKeys) {
612
- if (process.env[key])
613
- proxyEnv[key] = process.env[key];
614
- }
615
- const childEnv = {
616
- PATH: childPath,
617
- HOME: process.env.HOME,
618
- LANG: process.env.LANG,
619
- OPENCLAW_HOME: openclawHome,
620
- ...proxyEnv,
621
- };
622
- const MAX_ATTEMPTS = 3;
623
- const RETRY_DELAY_MS = 5_000;
624
- const attemptInstall = () => new Promise((resolve, reject) => {
625
- execFile(openclawBin, ["plugins", "install", pkg], {
626
- cwd: openclawHome,
627
- env: childEnv,
628
- timeout: 300_000,
629
- }, (err, stdout, stderr) => {
630
- if (err && !isChannelPluginInstalled(instanceId, channelId)) {
631
- const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
632
- console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
633
- try {
634
- if (existsSync(extensionsDir)) {
635
- for (const entry of readdirSync(extensionsDir)) {
636
- if (entry.startsWith(".openclaw-install-stage-")) {
637
- rmSync(join(extensionsDir, entry), { recursive: true, force: true });
638
- console.log(`[plugins] Cleaned up stage dir: ${entry}`);
639
- }
640
- }
641
- }
642
- }
643
- catch (_) { }
644
- reject(new Error(msg));
645
- }
646
- else {
647
- if (err)
648
- console.log(`[plugins] ${pkg} installed (ignored non-zero exit: warning only)`);
649
- else
650
- console.log(`[plugins] ${pkg} installed`);
651
- resolve();
652
- }
653
- });
654
- });
655
- console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
656
- let lastErr;
657
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
658
- try {
659
- await attemptInstall();
660
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
661
- const installedExtDir = join(extensionsDir, extDirName);
662
- if (existsSync(installedExtDir)) {
663
- ensureDirContainer(installedExtDir);
664
- try {
665
- for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
666
- if (entry.isDirectory()) {
667
- ensureDirContainer(join(installedExtDir, entry.name));
668
- }
669
- }
670
- }
671
- catch { /* best effort */ }
672
- }
673
- ensureDirContainer(extensionsDir);
674
- return;
675
- }
676
- catch (err) {
677
- lastErr = err;
678
- const isFetchError = /fetch failed/i.test(err.message ?? "");
679
- if (isFetchError && attempt < MAX_ATTEMPTS) {
680
- console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed with fetch error, retrying in ${RETRY_DELAY_MS / 1000}s...`);
681
- await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
682
- continue;
683
- }
684
- console.error(`[plugins] Failed to install ${pkg}:`, err.message);
685
- break;
686
- }
687
- }
688
- throw lastErr;
689
- }
418
+ // §32.2 / §32.8: hasConfiguredValue / injectProviderApiKeys /
419
+ // applyFeishuDebugAccessDefaults / prepareConfigForSave physically
420
+ // migrated into `src/services/runtime/adapters/openclaw.ts`.
421
+ // §32.2 / §32.8: Channel plugin helpers (CHANNEL_PLUGIN_MAP,
422
+ // installChannelPlugin, stripImBindings, etc.) physically migrated
423
+ // into `src/services/runtime/adapters/openclaw.ts`.
424
+ // ── Public API ──
690
425
  /**
691
- * Install a channel plugin inside the running Docker container via nomad-manager.exec().
692
- * Requires the instance to be running the extensions dir is bind-mounted so
693
- * the install persists on the host filesystem.
426
+ * Probe whether a file is readable by the current process. Used to
427
+ * distinguish "primary missing / corrupted" (recoverable via safeReadJson's
428
+ * .bak chain) from "primary exists but permission denied" (EACCES — the
429
+ * common sudo-script footgun that leaves root-owned files). safeReadJson
430
+ * swallows every read error internally and returns null, so without this
431
+ * probe an unreadable primary looks identical to a truly-gone instance.
694
432
  */
695
- async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
696
- const { exec } = await import("./nomad-manager.js");
697
- const MAX_ATTEMPTS = 3;
698
- const RETRY_DELAY_MS = 5_000;
699
- console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
700
- let lastErr;
701
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
702
- try {
703
- const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
704
- // Check if plugin was actually installed (openclaw may exit non-zero with warnings)
705
- if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
706
- const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
707
- console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
708
- throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
709
- }
710
- if (result.exitCode !== 0) {
711
- console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit: warning only)`);
712
- }
713
- else {
714
- console.log(`[plugins] ${pkg} installed via docker`);
715
- }
716
- // Fix ownership on host side
717
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
718
- const installedExtDir = join(extensionsDir, extDirName);
719
- if (existsSync(installedExtDir)) {
720
- ensureDirContainer(installedExtDir);
721
- try {
722
- for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
723
- if (entry.isDirectory()) {
724
- ensureDirContainer(join(installedExtDir, entry.name));
725
- }
726
- }
727
- }
728
- catch { /* best effort */ }
729
- }
730
- ensureDirContainer(extensionsDir);
731
- return;
732
- }
733
- catch (err) {
734
- lastErr = err;
735
- // "Instance is not running" from nomad-manager.exec() — give a clear user-facing message
736
- if (/not running/i.test(err.message ?? "")) {
737
- throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
738
- }
739
- const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
740
- if (isTransient && attempt < MAX_ATTEMPTS) {
741
- console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
742
- await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
743
- continue;
744
- }
745
- console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
746
- break;
747
- }
748
- }
749
- throw lastErr;
750
- }
751
- function getChannelExtensionsDir(instanceId) {
752
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
753
- }
754
- function getStockExtensionsDir() {
755
- return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
756
- }
757
- // ── Public API ──
758
- export function listInstances() {
759
- if (!existsSync(INSTANCES_DIR))
760
- return [];
761
- const entries = readdirSync(INSTANCES_DIR).sort();
762
- const instances = [];
763
- for (const name of entries) {
764
- const metaPath = join(INSTANCES_DIR, name, "instance.json");
765
- const dirPath = join(INSTANCES_DIR, name);
766
- try {
767
- if (statSync(dirPath).isDirectory() && existsSync(metaPath)) {
768
- instances.push(JSON.parse(readFileSync(metaPath, "utf-8")));
769
- }
770
- }
771
- catch (e) {
772
- // Permission errors (EACCES) caused by root-owned files are a common deployment
773
- // mistake (e.g. running a maintenance script as sudo). Log clearly instead of
774
- // silently dropping the instance from the list.
775
- console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
776
- }
777
- }
778
- return instances;
779
- }
780
- export function getInstance(instanceId) {
781
- const metaPath = instanceMetaPath(instanceId);
782
- if (!existsSync(metaPath))
433
+ function probeReadable(path) {
434
+ if (!existsSync(path))
783
435
  return null;
784
436
  try {
785
- return JSON.parse(readFileSync(metaPath, "utf-8"));
437
+ const fd = openSync(path, "r");
438
+ closeSync(fd);
439
+ return null;
786
440
  }
787
441
  catch (e) {
788
- // Surface permission errors clearly (EACCES: instance.json owned by root after a sudo script)
789
- throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
442
+ return e;
790
443
  }
791
444
  }
792
- export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, cloneOptions) {
793
- const d = instanceDir(instanceId);
794
- if (existsSync(d))
795
- throw new Error(`Instance '${instanceId}' already exists`);
796
- const home = openclawHome ? normalizePath(openclawHome) : defaultOpenclawHome(instanceId);
797
- // Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent path traversal.
798
- // Use realpathSync after mkdir to resolve symlinks, preventing symlink-based bypasses.
799
- if (openclawHome) {
800
- const resolved = resolve(home);
801
- if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
802
- throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
803
- }
804
- // Resolve symlinks for the parent dir to catch symlink attacks
805
- const parentDir = dirname(resolved);
806
- if (existsSync(parentDir)) {
807
- const realParent = realpathSync(parentDir);
808
- if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
809
- throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
810
- }
811
- }
812
- const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === normalizePath(home));
813
- if (shared.length) {
814
- throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared.map((i) => i.id).join(", ")}`);
815
- }
816
- }
817
- // Check for orphaned openclaw_home directory (e.g. instance.json deleted but data remains)
818
- if (existsSync(home)) {
819
- try {
820
- const entries = readdirSync(home);
821
- if (entries.length > 0) {
822
- throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. Remove it manually or choose a different path.`);
823
- }
824
- }
825
- catch (e) {
826
- if (e.message.includes("not empty"))
827
- throw e;
828
- // readdirSync failed — directory might not be readable, proceed cautiously
829
- }
830
- }
831
- ensureDirContainer(d);
832
- // Inherit group from INSTANCES_DIR so both root and the real user can access
833
- try {
834
- const parentGid = statSync(INSTANCES_DIR).gid;
835
- chownSync(d, -1, parentGid);
836
- }
837
- catch { /* non-root without CAP_CHOWN — already correct owner */ }
838
- ensureDirContainer(home);
839
- ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
840
- const runtime = await defaultRuntime(instanceId, home);
841
- const allocatedPort = extractGatewayPort(runtime);
842
- // Port already reserved inside defaultGatewayPort; just track for cleanup
843
- try {
844
- const meta = {
845
- id: instanceId,
846
- name,
847
- description,
848
- openclaw_home: home,
849
- runtime,
850
- created_at: new Date().toISOString(),
851
- };
852
- safeWriteJson(instanceMetaPath(instanceId), meta);
853
- const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
854
- for (const ef of envFiles) {
855
- if (!existsSync(ef))
856
- writeConfigFile(ef, "");
857
- }
858
- // After writing env files, ensure the runtime user can read them
859
- try {
860
- const runtimeUser = runtime.user;
861
- if (runtimeUser && runtimeUser !== userInfo().username) {
862
- for (const ef of envFiles) {
863
- execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
864
- }
865
- }
866
- }
867
- catch { /* ignore - same user or no permission to chown */ }
868
- const configPath = openclawConfigPathInternal(instanceId);
869
- ensureDirContainer(dirname(configPath));
870
- if (cloneFrom && !existsSync(configPath)) {
871
- const srcConfig = resolveExistingConfigPath(cloneFrom);
872
- if (existsSync(srcConfig)) {
873
- // Domain-level clone: copy config but strip proxy identity (token, jsproxy provider)
874
- // so the new instance gets its own proxy token via saveInstanceConfig later
875
- try {
876
- const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
877
- // Remove proxy provider (will be regenerated with new proxy token)
878
- // Detect by baseUrl since provider ID now uses upstream name (e.g. "js-minimax")
879
- const providers = cloned?.models?.providers;
880
- if (providers) {
881
- for (const [pid, prov] of Object.entries(providers)) {
882
- if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
883
- delete providers[pid];
884
- }
885
- }
886
- }
887
- // Remove proxy model reference from agent defaults (regenerated by bootstrap)
888
- const defaultModel = cloned?.agents?.defaults?.model;
889
- if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
890
- delete cloned.agents.defaults.model;
891
- }
892
- // Strip IM channel configs + matching plugin entries — same channel
893
- // cannot serve multiple instances and we don't want the plugin
894
- // loader to boot a half-configured binding.
895
- stripImBindings(cloned);
896
- // Copy extensions directory so plugin references in config remain valid
897
- // Copy workspace directory to preserve agent personality (.md files)
898
- const subdirs = ["extensions", "workspace"];
899
- if (cloneOptions?.include_memory !== false) {
900
- // Memory may exist at .openclaw/memory/ if created by OpenClaw runtime
901
- const memDir = join(dirname(srcConfig), "memory");
902
- if (existsSync(memDir))
903
- subdirs.push("memory");
904
- }
905
- if (cloneOptions?.include_sessions) {
906
- // Sessions at .openclaw/agents/main/sessions/
907
- const sessDir = join(dirname(srcConfig), "agents");
908
- if (existsSync(sessDir))
909
- subdirs.push("agents");
910
- }
911
- for (const subdir of subdirs) {
912
- const srcDir = join(dirname(srcConfig), subdir);
913
- const dstDir = join(dirname(configPath), subdir);
914
- if (existsSync(srcDir) && !existsSync(dstDir)) {
915
- try {
916
- cpSync(srcDir, dstDir, { recursive: true });
917
- }
918
- catch { /* best effort */ }
919
- }
920
- }
921
- writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
922
- // Copy x-jishushell upstream metadata from source instance.json
923
- // (saveConfig stores x-jishushell in instance.json, not openclaw.json)
924
- const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
925
- if (existsSync(srcMetaPath)) {
926
- try {
927
- const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
928
- const srcXj = srcMeta?.["x-jishushell"];
929
- if (srcXj?.proxy?.upstream) {
930
- const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
931
- // Clear instance-specific fields
932
- delete dstXj.proxy.upstream.apiKey;
933
- const metaPath = instanceMetaPath(instanceId);
934
- if (existsSync(metaPath)) {
935
- const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
936
- dstMeta["x-jishushell"] = dstXj;
937
- writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
938
- }
939
- }
940
- }
941
- catch { /* ignore metadata copy errors */ }
942
- }
943
- }
944
- catch {
945
- // Fallback: raw copy if parse fails
946
- copyFileSync(srcConfig, configPath);
947
- }
948
- }
949
- }
950
- if (!existsSync(configPath)) {
951
- writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
952
- // Inject default provider API key from setup into both env files
953
- const dp = getPanelConfig().default_provider;
954
- if (dp?.apiKey && dp?.providerId && envFiles.length) {
955
- const envKey = inferProviderApiKeyEnvName(dp.providerId);
956
- updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
957
- // Also write to provider.env as UPSTREAM_API_KEY (LLM proxy reads this first)
958
- const providerEnv = join(dirname(envFiles[0]), "provider.env");
959
- updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
960
- }
961
- }
962
- // Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
963
- if (cloneFrom && envFiles.length) {
964
- const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
965
- const srcEnvFile = srcEnvFiles[0];
966
- const dstEnvFile = envFiles[0];
967
- // Copy provider.env (upstream API key)
968
- if (srcEnvFile) {
969
- const srcProvider = join(dirname(srcEnvFile), "provider.env");
970
- const dstProvider = join(dirname(dstEnvFile), "provider.env");
971
- if (existsSync(srcProvider) && !existsSync(dstProvider)) {
972
- copyFileSync(srcProvider, dstProvider);
973
- }
974
- }
975
- // Note: model.env is NOT copied (new instance needs its own proxy token)
976
- }
977
- // Bootstrap proxy: generate proxy token and write model.env so instance
978
- // is ready to run immediately without requiring a manual "save config" first
979
- try {
980
- const { bootstrapInstanceProxy } = await import("../services/llm-proxy/index.js");
981
- await bootstrapInstanceProxy(instanceId);
982
- }
983
- catch (e) {
984
- console.warn(`[instance] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
985
- }
986
- // If running as root, hand ownership of all created files to the service user
987
- // so the openclaw process (running as that user) can read/write its own files.
988
- const svcUser = resolveServiceUser();
989
- if (svcUser) {
445
+ export function listInstances() {
446
+ const deduped = new Map();
447
+ for (const rootDir of [APPS_DIR, INSTANCES_DIR]) {
448
+ if (!existsSync(rootDir))
449
+ continue;
450
+ const entries = readdirSync(rootDir).sort();
451
+ for (const name of entries) {
452
+ if (deduped.has(name))
453
+ continue;
454
+ const metaPath = join(rootDir, name, "instance.json");
455
+ const dirPath = join(rootDir, name);
990
456
  try {
991
- execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], { timeout: 10_000 });
992
- if (!home.startsWith(d + "/") && existsSync(home)) {
993
- execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], { timeout: 10_000 });
457
+ if (!statSync(dirPath).isDirectory())
458
+ continue;
459
+ if (rootDir === APPS_DIR && hasIncompleteAppInstallShadow(dirPath))
460
+ continue;
461
+ if (!existsSync(metaPath))
462
+ continue;
463
+ // Use safeReadJson so primary instance.json that was corrupted or
464
+ // deleted mid-rename falls back to the .bak chain maintained by
465
+ // safeWriteJson. Without this, an interrupted safeWriteJson call
466
+ // would silently drop the instance from every list/get hot path
467
+ // even though the backup chain still holds valid content on disk.
468
+ const meta = safeReadJson(metaPath, `instance:${name}`);
469
+ if (meta) {
470
+ deduped.set(name, backfillInstanceMeta(meta));
471
+ continue;
472
+ }
473
+ // safeReadJson → null can mean any of (a) primary missing + no
474
+ // backups, (b) all candidates unparseable, (c) permission denied.
475
+ // (a) and (b) are legitimate "drop from list" cases; (c) is the
476
+ // sudo-script footgun and must be logged loudly so the operator
477
+ // doesn't just see an empty instance list with no hint why.
478
+ const readErr = probeReadable(metaPath);
479
+ if (readErr && readErr.code === "EACCES") {
480
+ console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
481
+ `Check file ownership with: ls -la ${metaPath}`);
994
482
  }
995
483
  }
996
484
  catch (e) {
997
- console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
485
+ // Fallback for failures before the safeReadJson call (e.g. statSync
486
+ // on a directory we can't enter). Still log instead of silently
487
+ // dropping the entry.
488
+ console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
998
489
  }
999
490
  }
1000
- return meta;
1001
491
  }
1002
- finally {
1003
- if (allocatedPort)
1004
- _pendingPorts.delete(allocatedPort);
492
+ return [...deduped.values()];
493
+ }
494
+ export function getInstance(instanceId) {
495
+ const metaPath = instanceMetaPath(instanceId);
496
+ // Go through safeReadJson so primary missing/corrupted instance.json
497
+ // is still served from the .bak chain. Returning null on "truly gone"
498
+ // (no primary, no backups) keeps the existing 404 behavior intact.
499
+ const meta = safeReadJson(metaPath, `instance:${instanceId}`);
500
+ if (meta)
501
+ return backfillInstanceMeta(meta);
502
+ // safeReadJson swallows every read error internally, which is exactly
503
+ // wrong for the EACCES case — a root-owned primary would silently
504
+ // return null and callers would report "Instance not found" instead
505
+ // of the actionable "check file ownership" message. Re-probe to
506
+ // distinguish and throw on permission denial. Missing/corrupted with
507
+ // no backup still returns null (→ 404 upstream).
508
+ const readErr = probeReadable(metaPath);
509
+ if (readErr && readErr.code === "EACCES") {
510
+ throw new Error(`Cannot read instance '${instanceId}' metadata: ${readErr.message}. ` +
511
+ `Check file ownership with: ls -la ${metaPath}`);
1005
512
  }
513
+ return null;
1006
514
  }
515
+ // §32.2 / §32.8: `createInstance` physically migrated into
516
+ // `src/services/runtime/adapters/openclaw.ts:OpenClawAdapter.createInstance`.
517
+ // Framework callers now dispatch via `getAdapter(agentType).createInstance(args)`.
1007
518
  export function updateInstance(instanceId, name, description) {
1008
519
  const meta = getInstance(instanceId);
1009
520
  if (!meta)
@@ -1016,14 +527,17 @@ export function updateInstance(instanceId, name, description) {
1016
527
  chownToServiceUser(instanceMetaPath(instanceId));
1017
528
  return meta;
1018
529
  }
1019
- /** Update instance.json metadata fields (shallow merge at top level). */
530
+ // §32.2 / §32.8: `createHermesInstance` + `InstanceCreationRejected`
531
+ // physically migrated into `src/services/runtime/adapters/hermes.ts`
532
+ // and `src/services/runtime/errors.ts`. Framework callers dispatch via
533
+ // `getAdapter(agentType).createInstance(args)` uniformly.
1020
534
  export function updateInstanceMeta(instanceId, patch) {
1021
535
  const metaPath = instanceMetaPath(instanceId);
1022
536
  const meta = safeReadJson(metaPath, "instance-meta") || {};
1023
537
  Object.assign(meta, patch);
1024
538
  safeWriteJson(metaPath, meta);
1025
539
  }
1026
- export function deleteInstance(instanceId, purgeBackups = false) {
540
+ export async function deleteInstance(instanceId, purgeBackups = false) {
1027
541
  const d = instanceDir(instanceId);
1028
542
  if (!existsSync(d))
1029
543
  return { ok: false, warnings: ["Instance directory not found"] };
@@ -1038,9 +552,35 @@ export function deleteInstance(instanceId, purgeBackups = false) {
1038
552
  cancelJob(job.id);
1039
553
  }
1040
554
  }).catch(() => { });
1041
- // Cache metadata BEFORE deletion so we can check custom openclaw_home after rm
555
+ // Cache metadata BEFORE deletion so adapters can inspect it after rm.
1042
556
  const meta = getInstance(instanceId);
1043
- const home = meta?.openclaw_home;
557
+ const agentType = resolveAgentType(meta);
558
+ // Adapter-owned pre-delete hook. Adapters use this to emit advisories
559
+ // for resources that live outside the instance dir (legacy
560
+ // `openclaw_home`, named docker volumes, etc). Errors are collected
561
+ // into the response so one adapter misbehaving can't block removal.
562
+ try {
563
+ const legacyAppType = typeof meta?.app_type === "string" ? meta.app_type.trim() : "";
564
+ if ((legacyAppType === "custom" || legacyAppType === "ollama") && meta) {
565
+ const { getAppManager } = await import("./app/registry.js");
566
+ const manager = getAppManager(legacyAppType);
567
+ if (manager.onDelete) {
568
+ await manager.onDelete(instanceId);
569
+ }
570
+ }
571
+ else {
572
+ const adapter = getAdapter(agentType);
573
+ if (adapter.hooks?.onDelete && meta) {
574
+ const hookResult = await adapter.hooks.onDelete({ instanceId, meta });
575
+ if (hookResult && Array.isArray(hookResult.warnings)) {
576
+ warnings.push(...hookResult.warnings);
577
+ }
578
+ }
579
+ }
580
+ }
581
+ catch (e) {
582
+ warnings.push(`adapter onDelete hook failed: ${e.message}`);
583
+ }
1044
584
  // Clean up Nomad Variables (async, best-effort)
1045
585
  import("./nomad-manager.js").then((nm) => {
1046
586
  nm.purgeInstanceVariables(instanceId).catch((e) => {
@@ -1049,29 +589,33 @@ export function deleteInstance(instanceId, purgeBackups = false) {
1049
589
  }).catch((e) => {
1050
590
  console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
1051
591
  });
592
+ // Async rm so the Node event loop stays responsive during large deletes:
593
+ // a fresh instance with a just-installed openclaw package can be 1+ GB
594
+ // with hundreds of nested dirs, which takes 30-60s to unlink on SD storage.
595
+ // rmSync would block every other HTTP request for that whole window.
1052
596
  let dirDeleted = false;
1053
597
  try {
1054
- rmSync(d, { recursive: true, force: true });
598
+ await rmAsync(d, { recursive: true, force: true });
1055
599
  dirDeleted = true;
1056
600
  }
1057
601
  catch {
1058
602
  try {
1059
- execFileSync("sudo", ["rm", "-rf", d], { timeout: 10000 });
603
+ execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
1060
604
  dirDeleted = true;
1061
605
  }
1062
606
  catch (e) {
1063
607
  warnings.push(`Failed to delete instance directory: ${e.message}`);
1064
608
  }
1065
609
  }
1066
- // Warn if custom openclaw_home exists outside the instance dir
1067
- if (home && !home.startsWith(d) && existsSync(home)) {
1068
- warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
1069
- }
1070
- // Handle backups (stored in separate directory, not affected by instance rmSync)
610
+ // (Custom openclaw_home orphan warning emitted by OpenClawAdapter.hooks.onDelete.)
611
+ // Handle backups (stored in separate directory, not affected by the
612
+ // instance rm above). Backups can be hundreds of MB each and accumulate
613
+ // across retention windows, so use the same async rm path to keep the
614
+ // event loop responsive.
1071
615
  const backupDir = join(BACKUPS_DIR, instanceId);
1072
616
  if (purgeBackups && existsSync(backupDir)) {
1073
617
  try {
1074
- rmSync(backupDir, { recursive: true, force: true });
618
+ await rmAsync(backupDir, { recursive: true, force: true });
1075
619
  }
1076
620
  catch (e) {
1077
621
  warnings.push(`Failed to delete backups: ${e.message}`);
@@ -1082,285 +626,210 @@ export function deleteInstance(instanceId, purgeBackups = false) {
1082
626
  }
1083
627
  return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
1084
628
  }
629
+ // §32.2 / §32.8: getConfig / getStoredConfig / saveConfig
630
+ // physically migrated into OpenClawAdapter. Framework callers now
631
+ // dispatch via `getAdapter(agentType).saveNativeConfig / .getNativeConfig`.
632
+ // Back-compat wrappers below preserve the legacy sync API shape for
633
+ // existing call sites (llm-proxy, routes, backup-manager).
1085
634
  export function getConfig(instanceId) {
1086
- const config = loadEffectiveConfig(instanceId);
1087
- if (!config)
1088
- return null;
1089
- // Merge x-jishushell metadata from instance.json
1090
635
  const meta = getInstance(instanceId);
1091
- if (meta?.["x-jishushell"]) {
1092
- config["x-jishushell"] = meta["x-jishushell"];
636
+ const agentType = resolveAgentType(meta);
637
+ try {
638
+ const adapter = getAdapter(agentType);
639
+ return typeof adapter.getNativeConfig === "function"
640
+ ? adapter.getNativeConfig(instanceId)
641
+ : null;
642
+ }
643
+ catch {
644
+ return null;
1093
645
  }
1094
- return injectProviderApiKeys(instanceId, config);
1095
646
  }
1096
647
  export function getStoredConfig(instanceId) {
1097
- const config = loadEffectiveConfig(instanceId);
1098
- if (!config)
1099
- return null;
1100
648
  const meta = getInstance(instanceId);
1101
- if (meta?.["x-jishushell"]) {
1102
- config["x-jishushell"] = meta["x-jishushell"];
649
+ const agentType = resolveAgentType(meta);
650
+ try {
651
+ const adapter = getAdapter(agentType);
652
+ return typeof adapter.getStoredNativeConfig === "function"
653
+ ? adapter.getStoredNativeConfig(instanceId)
654
+ : null;
655
+ }
656
+ catch {
657
+ return null;
1103
658
  }
1104
- return config;
1105
659
  }
1106
- export function saveConfig(instanceId, config) {
1107
- const configPath = openclawConfigPathInternal(instanceId);
1108
- if (!existsSync(instanceDir(instanceId)))
660
+ export async function saveConfig(instanceId, config) {
661
+ const meta = getInstance(instanceId);
662
+ const agentType = resolveAgentType(meta);
663
+ try {
664
+ const adapter = getAdapter(agentType);
665
+ if (typeof adapter.saveNativeConfig !== "function")
666
+ return false;
667
+ // Adapters may return boolean or Promise<boolean>. Awaiting a plain
668
+ // boolean is a no-op, so this handles both. Previously we stripped the
669
+ // Promise and always reported success for async adapters — that masked
670
+ // failures and left callers unable to detect dirty state.
671
+ const result = await adapter.saveNativeConfig(instanceId, config);
672
+ return typeof result === "boolean" ? result : true;
673
+ }
674
+ catch (e) {
675
+ console.warn(`[instance-manager] saveConfig dispatch failed for ${instanceId}: ${e.message}`);
1109
676
  return false;
1110
- if (!existsSync(configPath)) {
1111
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1112
- ensureDirContainer(dirname(configPath));
1113
- if (existsSync(legacyPath))
1114
- copyFileSync(legacyPath, configPath);
1115
677
  }
1116
- // Save x-jishushell metadata to instance.json (not openclaw.json)
1117
- if (config["x-jishushell"]) {
1118
- const metaPath = instanceMetaPath(instanceId);
1119
- if (existsSync(metaPath)) {
1120
- const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
1121
- meta["x-jishushell"] = config["x-jishushell"];
1122
- safeWriteJson(metaPath, meta);
1123
- chownToServiceUser(metaPath);
678
+ }
679
+ // ── Deprecated back-compat dispatch wrappers ──────────────────────────
680
+ //
681
+ // The real implementations live in `OpenClawAdapter`. New code should
682
+ // use `getAdapter(agentType).X(...)` directly. These wrappers exist so
683
+ // existing unit tests and any stragglers in external-facing scripts
684
+ // keep working without churn. They will be removed once the test suite
685
+ // migrates to adapter-scoped imports.
686
+ /**
687
+ * @deprecated Use `getAdapter("openclaw").channelPluginMap` instead.
688
+ * Provides the OpenClaw channel-plugin map via a Proxy so old direct-
689
+ * property reads (e.g. `CHANNEL_PLUGIN_MAP.feishu`) keep working.
690
+ */
691
+ export const CHANNEL_PLUGIN_MAP = new Proxy({}, {
692
+ get(_target, key) {
693
+ if (typeof key !== "string")
694
+ return undefined;
695
+ try {
696
+ return getAdapter("openclaw").channelPluginMap?.[key];
1124
697
  }
1125
- }
1126
- const [configToWrite, envUpdates] = prepareConfigForSave(instanceId, config);
1127
- // If openclaw-lark is configured as enabled, resolve which feishu plugin should actually be used:
1128
- // - If built-in feishu/ exists in stock AND openclaw-lark/ is not installed anywhere → switch to
1129
- // built-in feishu (removes the stale openclaw-lark reference that breaks container startup).
1130
- // - If both exist → keep openclaw-lark but disable built-in feishu to avoid conflict.
1131
- if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
1132
- const stockExtDir = getStockExtensionsDir();
1133
- const stockFeishu = join(stockExtDir, "feishu");
1134
- const stockOcl = join(stockExtDir, "openclaw-lark");
1135
- const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
1136
- if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
1137
- // Built-in available, community package absent → switch to built-in
1138
- configToWrite.plugins.entries.feishu = { enabled: true };
1139
- delete configToWrite.plugins.entries["openclaw-lark"];
698
+ catch {
699
+ return undefined;
1140
700
  }
1141
- else if (existsSync(stockFeishu)) {
1142
- // Both present → disable built-in to avoid conflict with community package
1143
- configToWrite.plugins ??= {};
1144
- configToWrite.plugins.entries ??= {};
1145
- configToWrite.plugins.entries.feishu = { enabled: false };
701
+ },
702
+ ownKeys() {
703
+ try {
704
+ return Reflect.ownKeys(getAdapter("openclaw").channelPluginMap ?? {});
1146
705
  }
1147
- }
1148
- // Preserve backend-managed fields from existing config on disk —
1149
- // plugins.installs, plugins.entries, and channels written by scan-to-bind
1150
- // flows (saveWeixinCredentials / saveFeishuCredentials) are not tracked by
1151
- // the frontend and would be lost on a frontend config save.
1152
- if (existsSync(configPath)) {
706
+ catch {
707
+ return [];
708
+ }
709
+ },
710
+ has(_target, key) {
711
+ if (typeof key !== "string")
712
+ return false;
1153
713
  try {
1154
- const existing = JSON.parse(readFileSync(configPath, "utf-8"));
1155
- if (existing.plugins?.installs) {
1156
- configToWrite.plugins ??= {};
1157
- configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
1158
- }
1159
- // Merge plugin entries: for keys present in configToWrite, deep-merge
1160
- // backend-written sub-fields from disk. Keys absent from configToWrite
1161
- // (intentionally deleted) are NOT resurrected from existing.
1162
- if (existing.plugins?.entries && configToWrite.plugins?.entries) {
1163
- for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
1164
- const old = existing.plugins.entries[key];
1165
- if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1166
- configToWrite.plugins.entries[key] = { ...old, ...val };
1167
- }
1168
- }
1169
- }
1170
- // Merge channels: for keys present in configToWrite, deep-merge
1171
- // backend-written sub-fields (e.g. openclaw-weixin accounts) from disk.
1172
- // Keys absent from configToWrite (user-deleted channels) stay deleted.
1173
- if (existing.channels && configToWrite.channels) {
1174
- for (const [key, val] of Object.entries(configToWrite.channels)) {
1175
- const old = existing.channels[key];
1176
- if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1177
- configToWrite.channels[key] = { ...old, ...val };
1178
- }
1179
- }
1180
- }
714
+ return key in (getAdapter("openclaw").channelPluginMap ?? {});
1181
715
  }
1182
- catch { /* best effort */ }
1183
- }
1184
- // backup
1185
- if (existsSync(configPath)) {
1186
- copyFileSync(configPath, configPath + ".bak");
1187
- }
1188
- const configJson = JSON.stringify(configToWrite, null, 2);
1189
- ensureDirContainer(dirname(configPath));
1190
- writeConfigFile(configPath + ".tmp", configJson);
1191
- // Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
1192
- JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
1193
- renameSync(configPath + ".tmp", configPath);
1194
- chownToServiceUser(configPath);
1195
- // also write to legacy path
1196
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1197
- if (existsSync(legacyPath)) {
1198
- copyFileSync(legacyPath, legacyPath + ".bak");
1199
- }
1200
- writeConfigFile(legacyPath + ".tmp", configJson);
1201
- JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
1202
- renameSync(legacyPath + ".tmp", legacyPath);
1203
- chownToServiceUser(legacyPath);
1204
- if (Object.keys(envUpdates).length) {
1205
- const envFiles = getRuntimeEnvFiles(instanceId);
1206
- if (envFiles.length)
1207
- updateEnvFile(envFiles[0], envUpdates);
1208
- }
1209
- // Plugins are installed inside the container — no host-side auto-install on config save.
1210
- // Notify listeners (e.g. llm-proxy cache invalidation)
1211
- for (const listener of _configChangeListeners) {
716
+ catch {
717
+ return false;
718
+ }
719
+ },
720
+ getOwnPropertyDescriptor(_target, key) {
721
+ if (typeof key !== "string")
722
+ return undefined;
723
+ let value;
1212
724
  try {
1213
- listener(instanceId);
725
+ value = getAdapter("openclaw").channelPluginMap?.[key];
1214
726
  }
1215
- catch { /* ignore listener errors */ }
1216
- }
1217
- return true;
1218
- }
1219
- export function getOpenclawHome(instanceId) {
1220
- return getOpenclawHomeInternal(instanceId);
1221
- }
727
+ catch {
728
+ return undefined;
729
+ }
730
+ return value !== undefined
731
+ ? { configurable: true, enumerable: true, writable: false, value }
732
+ : undefined;
733
+ },
734
+ });
1222
735
  /**
1223
- * Save WeChat login credentials for an instance.
1224
- * Save Feishu/Lark credentials from OAuth Device Code flow.
736
+ * @deprecated Use `getAdapter(agentType).isChannelPluginInstalled(id, channelId)` instead.
1225
737
  */
1226
- // Feishu app IDs issued by the open platform follow the pattern cli_<hex/alnum>.
1227
- // Validate appId to reject malformed values sourced from OAuth API responses.
1228
- const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
1229
- export function saveFeishuCredentials(instanceId, creds) {
1230
- if (!FEISHU_APP_ID_RE.test(creds.appId)) {
1231
- throw new Error(`Invalid Feishu appId format: expected cli_<alnum> (got "${creds.appId}")`);
738
+ export function isChannelPluginInstalled(instanceId, channelId) {
739
+ try {
740
+ const meta = getInstance(instanceId);
741
+ const agentType = resolveAgentType(meta);
742
+ const adapter = getAdapter(agentType);
743
+ return typeof adapter.isChannelPluginInstalled === "function"
744
+ ? adapter.isChannelPluginInstalled(instanceId, channelId)
745
+ : false;
1232
746
  }
1233
- if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
1234
- throw new Error("Invalid Feishu appSecret: must be a non-empty string");
747
+ catch {
748
+ return false;
1235
749
  }
1236
- const configPath = openclawConfigPathInternal(instanceId);
1237
- let config = safeReadJson(configPath, "feishu-creds") || {};
1238
- // Enable @larksuite/openclaw-lark plugin (installed inside Docker container),
1239
- // disable built-in @openclaw/feishu to avoid conflict.
1240
- config.plugins ??= {};
1241
- config.plugins.entries ??= {};
1242
- config.plugins.entries.feishu = { enabled: false };
1243
- config.plugins.entries["openclaw-lark"] = { enabled: true };
1244
- // Set channel config — official plugin reads from channels.feishu
1245
- config.channels ??= {};
1246
- config.channels.feishu = {
1247
- ...config.channels.feishu,
1248
- enabled: true,
1249
- appId: creds.appId,
1250
- appSecret: creds.appSecret,
1251
- domain: creds.domain,
1252
- dmPolicy: "open",
1253
- allowFrom: ["*"],
1254
- };
1255
- safeWriteJson(configPath, config);
1256
- chownToServiceUser(configPath);
1257
- console.log(`[instance-manager] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
1258
750
  }
1259
751
  /**
1260
- * Writes account data + updates openclaw.json to enable the plugin and register the account.
752
+ * @deprecated Use `getAdapter(agentType).createInstance({ instanceId, name, description, ... })` instead.
753
+ * Retained for unit tests and legacy scripts; forwards to the
754
+ * OpenClawAdapter's `createInstance` method.
1261
755
  */
1262
- const SAFE_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
1263
- export function saveWeixinCredentials(instanceId, creds) {
1264
- // Prevent path traversal via accountId (used as filename)
1265
- if (!creds.accountId || !SAFE_ACCOUNT_ID_RE.test(creds.accountId) || creds.accountId.includes('..')) {
1266
- throw new Error(`Invalid accountId: must be 1-128 chars of [a-zA-Z0-9@._-] without '..'`);
1267
- }
1268
- const home = getOpenclawHomeInternal(instanceId);
1269
- const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1270
- const accountsDir = join(stateDir, "accounts");
1271
- ensureDirContainer(accountsDir);
1272
- // Save account credentials file (via safeWriteJson for atomic + .bak protection)
1273
- const credObj = {
1274
- token: creds.token,
1275
- baseUrl: creds.baseUrl,
1276
- userId: creds.userId,
1277
- savedAt: new Date().toISOString(),
1278
- };
1279
- safeWriteJson(join(accountsDir, `${creds.accountId}.json`), credObj);
1280
- // OpenClaw also needs a "default" account file with the same credentials
1281
- safeWriteJson(join(accountsDir, "default.json"), credObj);
1282
- chownToServiceUser(join(accountsDir, `${creds.accountId}.json`), join(accountsDir, "default.json"));
1283
- // Update accounts.json index (required by the plugin to discover accounts)
1284
- const indexPath = join(stateDir, "accounts.json");
1285
- let index = [];
756
+ export async function createInstance(instanceId, name, description, cloneFrom, agentHome, cloneOptions) {
757
+ const adapter = getAdapter("openclaw");
758
+ if (typeof adapter.createInstance !== "function") {
759
+ throw new Error("OpenClawAdapter.createInstance is not available");
760
+ }
761
+ return adapter.createInstance({
762
+ instanceId,
763
+ name,
764
+ description,
765
+ cloneFrom,
766
+ agentHome,
767
+ cloneOptions,
768
+ });
769
+ }
770
+ // §32.2 / §32.8: saveFeishuCredentials / saveWeixinCredentials /
771
+ // getWeixinAccounts / getOpenclawHome physically migrated into
772
+ // `src/services/runtime/adapters/openclaw.ts`. Back-compat dispatch
773
+ // wrappers below keep existing callers working.
774
+ export function getOpenclawHome(instanceId) {
775
+ const meta = getInstance(instanceId);
776
+ const agentType = resolveAgentType(meta);
1286
777
  try {
1287
- const raw = readFileSync(indexPath, "utf-8");
1288
- index = JSON.parse(raw);
778
+ const a = getAdapter(agentType);
779
+ return typeof a.resolveAgentHome === "function"
780
+ ? a.resolveAgentHome(instanceId)
781
+ : meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
1289
782
  }
1290
- catch { /* start fresh */ }
1291
- if (!Array.isArray(index))
1292
- index = [];
1293
- if (!index.includes(creds.accountId))
1294
- index.push(creds.accountId);
1295
- safeWriteJson(indexPath, index);
1296
- // Update openclaw.json: enable plugin + register account
1297
- const configPath = openclawConfigPathInternal(instanceId);
1298
- let config = safeReadJson(configPath, "weixin-creds") || {};
1299
- // Enable plugin
1300
- config.plugins ??= {};
1301
- config.plugins.entries ??= {};
1302
- config.plugins.entries["openclaw-weixin"] ??= {};
1303
- config.plugins.entries["openclaw-weixin"].enabled = true;
1304
- // Enable channel with account
1305
- config.channels ??= {};
1306
- config.channels["openclaw-weixin"] ??= {};
1307
- config.channels["openclaw-weixin"].enabled = true;
1308
- // Register account with both original and normalized IDs (OpenClaw normalizes @ and . to -)
1309
- const normalizedId = creds.accountId.replace(/[@.]/g, "-");
1310
- const accounts = config.channels["openclaw-weixin"].accounts ??= {};
1311
- accounts[creds.accountId] = { enabled: true };
1312
- if (normalizedId !== creds.accountId)
1313
- accounts[normalizedId] = { enabled: true };
1314
- accounts["default"] = { enabled: true };
1315
- // Set defaultAccount (required by OpenClaw)
1316
- if (!config.channels["openclaw-weixin"].defaultAccount) {
1317
- config.channels["openclaw-weixin"].defaultAccount = "default";
783
+ catch {
784
+ return meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
1318
785
  }
1319
- safeWriteJson(configPath, config);
1320
- chownToServiceUser(configPath);
1321
- console.log(`[instance-manager] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
1322
786
  }
1323
- /**
1324
- * Get connected WeChat accounts for an instance.
1325
- */
787
+ export function saveFeishuCredentials(instanceId, creds) {
788
+ const meta = getInstance(instanceId);
789
+ const agentType = resolveAgentType(meta);
790
+ const a = getAdapter(agentType);
791
+ if (typeof a.saveFeishuCredentials !== "function") {
792
+ throw new Error(`Runtime "${agentType}" does not support Feishu credentials`);
793
+ }
794
+ a.saveFeishuCredentials(instanceId, creds);
795
+ }
796
+ export function saveWeixinCredentials(instanceId, creds) {
797
+ const meta = getInstance(instanceId);
798
+ const agentType = resolveAgentType(meta);
799
+ const a = getAdapter(agentType);
800
+ if (typeof a.saveWeixinCredentials !== "function") {
801
+ throw new Error(`Runtime "${agentType}" does not support WeChat credentials`);
802
+ }
803
+ a.saveWeixinCredentials(instanceId, creds);
804
+ }
1326
805
  export function getWeixinAccounts(instanceId) {
1327
- const home = getOpenclawHomeInternal(instanceId);
1328
- const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1329
- const accountsDir = join(stateDir, "accounts");
1330
- if (!existsSync(accountsDir))
1331
- return [];
1332
- // Only return accounts listed in the index (skip default.json and other auxiliary files)
1333
- let indexedIds = [];
806
+ const meta = getInstance(instanceId);
807
+ const agentType = resolveAgentType(meta);
1334
808
  try {
1335
- indexedIds = JSON.parse(readFileSync(join(stateDir, "accounts.json"), "utf-8"));
809
+ const a = getAdapter(agentType);
810
+ return typeof a.getWeixinAccounts === "function"
811
+ ? a.getWeixinAccounts(instanceId)
812
+ : [];
1336
813
  }
1337
- catch { /* fallback to scanning */ }
1338
- const results = [];
1339
- for (const f of readdirSync(accountsDir)) {
1340
- if (!f.endsWith(".json"))
1341
- continue;
1342
- const id = f.replace(/\.json$/, "");
1343
- if (indexedIds.length > 0 && !indexedIds.includes(id))
1344
- continue; // skip auxiliary files
1345
- if (id === "default")
1346
- continue; // always skip default alias
1347
- try {
1348
- const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
1349
- results.push({
1350
- accountId: id,
1351
- userId: data.userId,
1352
- savedAt: data.savedAt,
1353
- });
1354
- }
1355
- catch { /* skip */ }
814
+ catch {
815
+ return [];
1356
816
  }
1357
- return results;
1358
817
  }
1359
818
  export function getOpenclawConfigPath(instanceId) {
1360
- return openclawConfigPathInternal(instanceId);
819
+ const meta = getInstance(instanceId);
820
+ const agentType = resolveAgentType(meta);
821
+ const a = getAdapter(agentType);
822
+ if (typeof a.resolveConfigPath === "function")
823
+ return a.resolveConfigPath(instanceId);
824
+ return join(getOpenclawHome(instanceId), ".openclaw", "openclaw.json");
1361
825
  }
1362
826
  export function getLegacyOpenclawConfigPath(instanceId) {
1363
- return legacyOpenclawConfigPath(instanceId);
827
+ const meta = getInstance(instanceId);
828
+ const agentType = resolveAgentType(meta);
829
+ const a = getAdapter(agentType);
830
+ if (typeof a.resolveLegacyConfigPath === "function")
831
+ return a.resolveLegacyConfigPath(instanceId);
832
+ return join(getOpenclawHome(instanceId), "openclaw.json");
1364
833
  }
1365
834
  export function getInstanceRuntime(instanceId) {
1366
835
  const meta = getInstance(instanceId);
@@ -1370,11 +839,28 @@ export function getInstanceRuntime(instanceId) {
1370
839
  }
1371
840
  export function getRuntimeEnvFiles(instanceId) {
1372
841
  const runtime = getInstanceRuntime(instanceId);
1373
- const envFiles = (runtime.env_files || []).map((p) => normalizePath(p)).filter(Boolean);
842
+ const rawEnvFiles = Array.isArray(runtime.env_files)
843
+ ? runtime.env_files
844
+ : Array.isArray(runtime.envFiles)
845
+ ? runtime.envFiles
846
+ : [];
847
+ const envFiles = rawEnvFiles.map((p) => normalizePath(p)).filter(Boolean);
1374
848
  return envFiles.length ? envFiles : [defaultModelEnvFile(instanceId)];
1375
849
  }
1376
850
  export function getGatewayPort(instanceId) {
1377
- return extractGatewayPort(getInstanceRuntime(instanceId)) || DEFAULT_GATEWAY_PORT;
851
+ const recorded = extractGatewayPort(getInstanceRuntime(instanceId));
852
+ if (recorded)
853
+ return recorded;
854
+ // Fallback: look up the adapter's canonical base port. Framework does
855
+ // not hardcode per-kind defaults.
856
+ const meta = getInstance(instanceId);
857
+ const agentType = resolveAgentType(meta);
858
+ try {
859
+ return getAdapter(agentType).defaultGatewayPort ?? 18789;
860
+ }
861
+ catch {
862
+ return 18789;
863
+ }
1378
864
  }
1379
865
  /**
1380
866
  * Detect the host address where the gateway port is actually listening.
@@ -1389,6 +875,47 @@ export function getGatewayPort(instanceId) {
1389
875
  */
1390
876
  const _gwHostCache = new Map();
1391
877
  const GW_HOST_CACHE_TTL = 30000;
878
+ export function getListeningHostForPort(port) {
879
+ let result = "127.0.0.1";
880
+ try {
881
+ const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], {
882
+ encoding: "utf-8",
883
+ timeout: 3000,
884
+ stdio: ["pipe", "pipe", "pipe"],
885
+ });
886
+ for (const line of out.split("\n")) {
887
+ let match = line.match(/\s([\d.]+):(\d+)\s/);
888
+ if (!match)
889
+ match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
890
+ if (match && match[2] === String(port)) {
891
+ const addr = match[1];
892
+ result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
893
+ break;
894
+ }
895
+ }
896
+ }
897
+ catch { /* fall through */ }
898
+ return result;
899
+ }
900
+ function getPrimaryIpv4Address() {
901
+ try {
902
+ for (const list of Object.values(networkInterfaces())) {
903
+ for (const iface of list ?? []) {
904
+ if (!iface.internal && iface.family === "IPv4")
905
+ return iface.address;
906
+ }
907
+ }
908
+ }
909
+ catch { /* fall through */ }
910
+ return "127.0.0.1";
911
+ }
912
+ export function getAdvertisedHostForPort(port) {
913
+ const host = getListeningHostForPort(port);
914
+ if (host && host !== "127.0.0.1" && host !== "0.0.0.0" && host !== "::1" && host !== "::") {
915
+ return host;
916
+ }
917
+ return getPrimaryIpv4Address();
918
+ }
1392
919
  export async function getGatewayHost(instanceId) {
1393
920
  const cached = _gwHostCache.get(instanceId);
1394
921
  if (cached && Date.now() - cached.ts < GW_HOST_CACHE_TTL)
@@ -1399,7 +926,22 @@ export async function getGatewayHost(instanceId) {
1399
926
  const { getNomadDriver } = await import("../config.js");
1400
927
  if (getNomadDriver() === "docker") {
1401
928
  const { getNomadAddr, getNomadToken } = await import("../config.js");
1402
- const jid = `openclaw-${instanceId}`;
929
+ // Dispatch job-id construction through the adapter so every runtime
930
+ // owns its own Nomad job namespace (hermes-<id>, openclaw-<id>, …).
931
+ // Falls back to a generic `jishushell-` prefix only when the adapter
932
+ // lookup fails — that branch should never fire for a registered
933
+ // agent type.
934
+ const meta = getInstance(instanceId);
935
+ const agentType = resolveAgentType(meta);
936
+ let prefix = "jishushell-";
937
+ try {
938
+ const a = getAdapter(agentType);
939
+ if (typeof a.nomadJobPrefix === "string" && a.nomadJobPrefix.length) {
940
+ prefix = a.nomadJobPrefix;
941
+ }
942
+ }
943
+ catch { /* use fallback prefix */ }
944
+ const jid = `${prefix}${instanceId}`;
1403
945
  const headers = { "Content-Type": "application/json" };
1404
946
  const token = getNomadToken();
1405
947
  if (token)
@@ -1416,11 +958,39 @@ export async function getGatewayHost(instanceId) {
1416
958
  const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
1417
959
  if (detail.ok) {
1418
960
  const d = await detail.json();
1419
- const ports = d?.AllocatedResources?.Shared?.Ports ?? [];
1420
- const gwPort = ports.find((p) => p.Label === "gateway");
961
+ // Preferred source: AllocatedResources.Shared.Ports (bridge mode).
962
+ const sharedPorts = d?.AllocatedResources?.Shared?.Ports ?? [];
963
+ const gwPort = sharedPorts.find((p) => p.Label === "gateway");
1421
964
  if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
1422
965
  result = gwPort.HostIP;
1423
966
  }
967
+ else {
968
+ // Host mode / task-level reservation: address lives under
969
+ // AllocatedResources.Tasks.<task>.Networks[*].IP. On Nomad
970
+ // 1.6.5 with `network_interface = "lo"`, the IP is whichever
971
+ // address the OS enumerates first — which can be IPv6 `::1`
972
+ // on systems where lo has both `127.0.0.1/8` and `::1/128`
973
+ // (the default on most modern Linux distros). Reading it from
974
+ // here is the authoritative source and matches what nomad
975
+ // configures the docker-proxy bind to use.
976
+ //
977
+ // Scan every task instead of indexing a hardcoded "gateway"
978
+ // task — adapters are free to name the task whatever they
979
+ // like, we only require the reserved port carry the
980
+ // framework-level `gateway` label.
981
+ const tasksMap = d?.AllocatedResources?.Tasks ?? {};
982
+ for (const taskName of Object.keys(tasksMap)) {
983
+ const taskNets = tasksMap[taskName]?.Networks ?? [];
984
+ const net = taskNets.find((n) => {
985
+ const rps = n?.ReservedPorts ?? [];
986
+ return rps.some((p) => p.Label === "gateway");
987
+ });
988
+ if (net?.IP && net.IP !== "0.0.0.0") {
989
+ result = net.IP;
990
+ break;
991
+ }
992
+ }
993
+ }
1424
994
  }
1425
995
  }
1426
996
  }
@@ -1433,27 +1003,74 @@ export async function getGatewayHost(instanceId) {
1433
1003
  }
1434
1004
  }
1435
1005
  catch { /* fall through */ }
1436
- try {
1437
- const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1438
- for (const line of out.split("\n")) {
1439
- const match = line.match(/\s([\d.]+):(\d+)\s/);
1440
- if (match && match[2] === String(port)) {
1441
- const addr = match[1];
1442
- result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
1443
- break;
1444
- }
1445
- }
1446
- }
1447
- catch { /* fall through */ }
1006
+ result = getListeningHostForPort(port);
1448
1007
  _gwHostCache.set(instanceId, { host: result, ts: Date.now() });
1449
1008
  return result;
1450
1009
  }
1010
+ /**
1011
+ * Wrap an IPv6 literal in brackets for safe URL host-component / Host-header
1012
+ * use. Bare names ("gateway.local") and IPv4 ("127.0.0.1") contain no colon
1013
+ * and pass through unchanged; anything with a colon is an IPv6 literal and
1014
+ * MUST be bracketed before being concatenated with a port, otherwise
1015
+ * `http://::1:18789/` is unparseable.
1016
+ */
1017
+ export function urlHost(host) {
1018
+ return host.includes(":") ? `[${host}]` : host;
1019
+ }
1451
1020
  export function findInstancesSharingOpenclawHome(instanceId) {
1452
- const targetHome = normalizePath(getOpenclawHome(instanceId));
1453
- return listInstances()
1454
- .filter((inst) => inst.id !== instanceId)
1455
- .filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
1456
- .map((inst) => inst.id);
1021
+ const meta = getInstance(instanceId);
1022
+ const agentType = resolveAgentType(meta);
1023
+ try {
1024
+ const a = getAdapter(agentType);
1025
+ return typeof a.findInstancesSharingHome === "function"
1026
+ ? a.findInstancesSharingHome(instanceId)
1027
+ : [];
1028
+ }
1029
+ catch {
1030
+ return [];
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Re-pick a gateway port for an existing instance and rewrite its persisted
1035
+ * runtime metadata (`runtime.args` and `runtime.env.OPENCLAW_GATEWAY_PORT`).
1036
+ *
1037
+ * Used when {@link isPortInUse} reports that the previously-assigned port has
1038
+ * been taken by something else between create-time and start-time (e.g. a
1039
+ * host-side openclaw started by the user, an unrelated service that grabbed
1040
+ * the port at boot, or a Docker race on the next allocation). The Nomad job
1041
+ * spec is rebuilt from instance metadata on every submit, so updating
1042
+ * `instance.json` here is sufficient — no other files need patching.
1043
+ */
1044
+ export async function reallocateGatewayPort(instanceId) {
1045
+ const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
1046
+ if (!meta)
1047
+ throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
1048
+ const agentType = resolveAgentType(meta);
1049
+ let adapter;
1050
+ try {
1051
+ adapter = getAdapter(agentType);
1052
+ }
1053
+ catch {
1054
+ throw new Error(`Unknown runtime agentType "${agentType}" for instance ${instanceId}`);
1055
+ }
1056
+ const defaultPort = adapter.defaultGatewayPort ?? 18789;
1057
+ const fromPort = extractGatewayPort(meta.runtime, agentType) ?? defaultPort;
1058
+ const alloc = await allocateGatewayPort(instanceId, defaultPort);
1059
+ try {
1060
+ const runtime = (meta.runtime ?? {});
1061
+ // Delegate the kind-specific runtime rewrite to the adapter.
1062
+ if (typeof adapter.reallocateRuntimePort === "function") {
1063
+ adapter.reallocateRuntimePort(runtime, alloc.port);
1064
+ }
1065
+ meta.runtime = runtime;
1066
+ safeWriteJson(instanceMetaPath(instanceId), meta);
1067
+ chownToServiceUser(instanceMetaPath(instanceId));
1068
+ console.log(`[instance] ${instanceId}: gateway port reallocated ${fromPort} -> ${alloc.port}`);
1069
+ return { from: fromPort, to: alloc.port, skipped: alloc.skipped };
1070
+ }
1071
+ finally {
1072
+ _pendingPorts.delete(alloc.port);
1073
+ }
1457
1074
  }
1458
1075
  export function findInstancesSharingGatewayPort(instanceId) {
1459
1076
  const targetPort = getGatewayPort(instanceId);
@@ -1474,15 +1091,34 @@ export function getRuntimeEnv(instanceId) {
1474
1091
  }
1475
1092
  return env;
1476
1093
  }
1477
- // Re-export instanceDir for nomad-manager
1094
+ // Re-export instanceDir for nomad-manager under its getInstanceDir alias.
1478
1095
  export { instanceDir as getInstanceDir };
1479
- function resolveExistingConfigPath(instanceId) {
1480
- const runtimePath = openclawConfigPathInternal(instanceId);
1481
- if (existsSync(runtimePath))
1482
- return runtimePath;
1483
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1484
- if (existsSync(legacyPath))
1485
- return legacyPath;
1486
- return runtimePath;
1096
+ // ── Compatibility exports for app-type managers (src/services/app/) ──────────
1097
+ // `instanceMetaPath`, `chownToServiceUser`, `notifyConfigChange` already
1098
+ // exported above; §32.2/§32.8 migration kept the originals in place so the
1099
+ // app-manager layer can still depend on them. The shims below add the
1100
+ // naming aliases the cli branch's app managers import.
1101
+ /**
1102
+ * Compatibility shim: allocate a gateway port for a new instance and return
1103
+ * just the port number. Wraps `allocateGatewayPort`, which takes a seed port
1104
+ * and returns `{ port, skipped }`. Non-OpenClaw app managers don't have an
1105
+ * agentType-specific default port yet, so we seed with 18789 (OpenClaw's
1106
+ * default) — the allocator walks upward until it finds a free slot so the
1107
+ * actual port chosen is independent of the seed.
1108
+ */
1109
+ export async function defaultGatewayPort(instanceId) {
1110
+ const result = await allocateGatewayPort(instanceId, 18789);
1111
+ return result.port;
1112
+ }
1113
+ /**
1114
+ * Compatibility shim: release a pending port reservation.
1115
+ * Mirrors the cli-branch `releasePort(port)` helper on top of the framework's
1116
+ * existing `releasePendingPort`.
1117
+ */
1118
+ export function releasePort(port) {
1119
+ releasePendingPort(port);
1487
1120
  }
1121
+ // `resolveExistingConfigPath` is owned by the OpenClaw adapter after §32.2 —
1122
+ // callers that still need it live inside `src/services/runtime/adapters/` or
1123
+ // `src/services/app/openclaw-manager.ts` (which defines its own copy).
1488
1124
  //# sourceMappingURL=instance-manager.js.map