jishushell 0.4.17 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/Dockerfile.hermes-slim +193 -0
  2. package/apps/hermes-container.yaml +35 -0
  3. package/apps/ollama-binary.yaml +200 -0
  4. package/apps/ollama-cpu-container.yaml +37 -0
  5. package/apps/ollama-with-hollama-binary.yaml +195 -0
  6. package/apps/openclaw-binary.yaml +69 -0
  7. package/apps/openclaw-container.yaml +37 -0
  8. package/apps/openclaw-with-ollama-container.yaml +42 -0
  9. package/apps/openclaw-with-searxng-container.yaml +136 -0
  10. package/apps/openwebui-container.yaml +53 -0
  11. package/apps/playwright-container.yaml +120 -0
  12. package/apps/searxng-container.yaml +115 -0
  13. package/dist/auth.d.ts +1 -0
  14. package/dist/auth.js +15 -14
  15. package/dist/auth.js.map +1 -1
  16. package/dist/cli/app.d.ts +1 -0
  17. package/dist/cli/app.js +710 -52
  18. package/dist/cli/app.js.map +1 -1
  19. package/dist/cli/backup.d.ts +3 -0
  20. package/dist/cli/backup.js +434 -0
  21. package/dist/cli/backup.js.map +1 -0
  22. package/dist/cli/doctor.d.ts +1 -0
  23. package/dist/cli/doctor.js +61 -35
  24. package/dist/cli/doctor.js.map +1 -1
  25. package/dist/cli/job.d.ts +1 -0
  26. package/dist/cli/job.js +37 -99
  27. package/dist/cli/job.js.map +1 -1
  28. package/dist/cli/llm.d.ts +1 -0
  29. package/dist/cli/llm.js +20 -14
  30. package/dist/cli/llm.js.map +1 -1
  31. package/dist/cli/managed-list.d.ts +30 -0
  32. package/dist/cli/managed-list.js +129 -0
  33. package/dist/cli/managed-list.js.map +1 -0
  34. package/dist/cli/panel.d.ts +4 -3
  35. package/dist/cli/panel.js +94 -24
  36. package/dist/cli/panel.js.map +1 -1
  37. package/dist/cli/version.d.ts +1 -0
  38. package/dist/cli/version.js +12 -0
  39. package/dist/cli/version.js.map +1 -0
  40. package/dist/cli.js +47 -516
  41. package/dist/cli.js.map +1 -1
  42. package/dist/config.d.ts +68 -0
  43. package/dist/config.js +266 -12
  44. package/dist/config.js.map +1 -1
  45. package/dist/control.d.ts +10 -6
  46. package/dist/control.js +87 -6
  47. package/dist/control.js.map +1 -1
  48. package/dist/install.d.ts +16 -0
  49. package/dist/install.js +75 -26
  50. package/dist/install.js.map +1 -1
  51. package/dist/routes/agent-apps.d.ts +15 -0
  52. package/dist/routes/agent-apps.js +78 -0
  53. package/dist/routes/agent-apps.js.map +1 -0
  54. package/dist/routes/apps.js +186 -7
  55. package/dist/routes/apps.js.map +1 -1
  56. package/dist/routes/backup.js +3 -3
  57. package/dist/routes/backup.js.map +1 -1
  58. package/dist/routes/instances.d.ts +6 -0
  59. package/dist/routes/instances.js +862 -879
  60. package/dist/routes/instances.js.map +1 -1
  61. package/dist/routes/llm.js +9 -8
  62. package/dist/routes/llm.js.map +1 -1
  63. package/dist/routes/runtime.d.ts +15 -0
  64. package/dist/routes/runtime.js +69 -0
  65. package/dist/routes/runtime.js.map +1 -0
  66. package/dist/routes/setup.js +103 -8
  67. package/dist/routes/setup.js.map +1 -1
  68. package/dist/routes/system.js +25 -3
  69. package/dist/routes/system.js.map +1 -1
  70. package/dist/server.js +71 -7
  71. package/dist/server.js.map +1 -1
  72. package/dist/services/agent-apps/catalog.d.ts +30 -0
  73. package/dist/services/agent-apps/catalog.js +60 -0
  74. package/dist/services/agent-apps/catalog.js.map +1 -0
  75. package/dist/services/agent-apps/index.d.ts +36 -0
  76. package/dist/services/agent-apps/index.js +171 -0
  77. package/dist/services/agent-apps/index.js.map +1 -0
  78. package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
  79. package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
  80. package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
  81. package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
  82. package/dist/services/agent-apps/installers/adapter.js +171 -0
  83. package/dist/services/agent-apps/installers/adapter.js.map +1 -0
  84. package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
  85. package/dist/services/agent-apps/installers/registry-probe.js +183 -0
  86. package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
  87. package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
  88. package/dist/services/agent-apps/installers/shell-script.js +471 -0
  89. package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
  90. package/dist/services/agent-apps/types.d.ts +125 -0
  91. package/dist/services/agent-apps/types.js +17 -0
  92. package/dist/services/agent-apps/types.js.map +1 -0
  93. package/dist/services/{app-compiler.d.ts → app/app-compiler.d.ts} +3 -3
  94. package/dist/services/{app-compiler.js → app/app-compiler.js} +10 -7
  95. package/dist/services/app/app-compiler.js.map +1 -0
  96. package/dist/services/app/app-manager.d.ts +142 -0
  97. package/dist/services/app/app-manager.js +1988 -0
  98. package/dist/services/app/app-manager.js.map +1 -0
  99. package/dist/services/app/custom-manager.d.ts +27 -0
  100. package/dist/services/app/custom-manager.js +285 -0
  101. package/dist/services/app/custom-manager.js.map +1 -0
  102. package/dist/services/app/hermes-agent-manager.d.ts +20 -0
  103. package/dist/services/app/hermes-agent-manager.js +289 -0
  104. package/dist/services/app/hermes-agent-manager.js.map +1 -0
  105. package/dist/services/app/id-normalizer.d.ts +27 -0
  106. package/dist/services/app/id-normalizer.js +77 -0
  107. package/dist/services/app/id-normalizer.js.map +1 -0
  108. package/dist/services/app/ollama-manager.d.ts +18 -0
  109. package/dist/services/app/ollama-manager.js +207 -0
  110. package/dist/services/app/ollama-manager.js.map +1 -0
  111. package/dist/services/app/openclaw-manager.d.ts +63 -0
  112. package/dist/services/app/openclaw-manager.js +1178 -0
  113. package/dist/services/app/openclaw-manager.js.map +1 -0
  114. package/dist/services/app/paths.d.ts +47 -0
  115. package/dist/services/app/paths.js +68 -0
  116. package/dist/services/app/paths.js.map +1 -0
  117. package/dist/services/app/registry.d.ts +17 -0
  118. package/dist/services/app/registry.js +31 -0
  119. package/dist/services/app/registry.js.map +1 -0
  120. package/dist/services/app/remote-spec.d.ts +14 -0
  121. package/dist/services/app/remote-spec.js +58 -0
  122. package/dist/services/app/remote-spec.js.map +1 -0
  123. package/dist/services/app/terminal-session-manager.d.ts +27 -0
  124. package/dist/services/app/terminal-session-manager.js +157 -0
  125. package/dist/services/app/terminal-session-manager.js.map +1 -0
  126. package/dist/services/app/types.d.ts +72 -0
  127. package/dist/services/app/types.js +16 -0
  128. package/dist/services/app/types.js.map +1 -0
  129. package/dist/services/backup-manager.js +60 -22
  130. package/dist/services/backup-manager.js.map +1 -1
  131. package/dist/services/instance-manager.d.ts +82 -39
  132. package/dist/services/instance-manager.js +575 -1142
  133. package/dist/services/instance-manager.js.map +1 -1
  134. package/dist/services/llm-proxy/circuit-breaker.js +10 -2
  135. package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
  136. package/dist/services/llm-proxy/index.d.ts +14 -1
  137. package/dist/services/llm-proxy/index.js +51 -6
  138. package/dist/services/llm-proxy/index.js.map +1 -1
  139. package/dist/services/nomad-manager.d.ts +260 -3
  140. package/dist/services/nomad-manager.js +2866 -449
  141. package/dist/services/nomad-manager.js.map +1 -1
  142. package/dist/services/panel-manager.d.ts +10 -0
  143. package/dist/services/panel-manager.js +97 -0
  144. package/dist/services/panel-manager.js.map +1 -1
  145. package/dist/services/plugin-installer.js +28 -2
  146. package/dist/services/plugin-installer.js.map +1 -1
  147. package/dist/services/process-manager.js +22 -0
  148. package/dist/services/process-manager.js.map +1 -1
  149. package/dist/services/runtime/adapters/custom.d.ts +20 -0
  150. package/dist/services/runtime/adapters/custom.js +90 -0
  151. package/dist/services/runtime/adapters/custom.js.map +1 -0
  152. package/dist/services/runtime/adapters/hermes.d.ts +174 -0
  153. package/dist/services/runtime/adapters/hermes.js +1316 -0
  154. package/dist/services/runtime/adapters/hermes.js.map +1 -0
  155. package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
  156. package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
  157. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
  158. package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
  159. package/dist/services/runtime/adapters/openclaw.js +2195 -0
  160. package/dist/services/runtime/adapters/openclaw.js.map +1 -0
  161. package/dist/services/runtime/errors.d.ts +28 -0
  162. package/dist/services/runtime/errors.js +31 -0
  163. package/dist/services/runtime/errors.js.map +1 -0
  164. package/dist/services/runtime/index.d.ts +34 -0
  165. package/dist/services/runtime/index.js +51 -0
  166. package/dist/services/runtime/index.js.map +1 -0
  167. package/dist/services/runtime/instance.d.ts +24 -0
  168. package/dist/services/runtime/instance.js +143 -0
  169. package/dist/services/runtime/instance.js.map +1 -0
  170. package/dist/services/runtime/migrations.d.ts +15 -0
  171. package/dist/services/runtime/migrations.js +25 -0
  172. package/dist/services/runtime/migrations.js.map +1 -0
  173. package/dist/services/runtime/registry.d.ts +13 -0
  174. package/dist/services/runtime/registry.js +32 -0
  175. package/dist/services/runtime/registry.js.map +1 -0
  176. package/dist/services/runtime/types.d.ts +545 -0
  177. package/dist/services/runtime/types.js +14 -0
  178. package/dist/services/runtime/types.js.map +1 -0
  179. package/dist/services/setup-manager.d.ts +70 -29
  180. package/dist/services/setup-manager.js +278 -597
  181. package/dist/services/setup-manager.js.map +1 -1
  182. package/dist/services/task-registry.d.ts +44 -0
  183. package/dist/services/task-registry.js +74 -0
  184. package/dist/services/task-registry.js.map +1 -0
  185. package/dist/services/telemetry/heartbeat.d.ts +6 -6
  186. package/dist/services/telemetry/heartbeat.js +29 -30
  187. package/dist/services/telemetry/heartbeat.js.map +1 -1
  188. package/dist/types.d.ts +162 -2
  189. package/dist/utils/docker-host.d.ts +15 -0
  190. package/dist/utils/docker-host.js +64 -0
  191. package/dist/utils/docker-host.js.map +1 -0
  192. package/install/jishu-install.sh +25 -1
  193. package/package.json +14 -4
  194. package/public/assets/Dashboard-B-JoOjBQ.js +1 -0
  195. package/public/assets/HermesChatPanel-mFSureyc.js +1 -0
  196. package/public/assets/HermesConfigForm-DvR05LK1.js +4 -0
  197. package/public/assets/InitPassword-CVA8wQA6.js +1 -0
  198. package/public/assets/InstanceDetail-DcZW2QGO.js +91 -0
  199. package/public/assets/{Login-D1Bt-Lyk.js → Login-BWsZH2mu.js} +1 -1
  200. package/public/assets/NewInstance-BCIrAd86.js +1 -0
  201. package/public/assets/Settings-xkDcduFz.js +1 -0
  202. package/public/assets/Setup-Cfuwj4gV.js +1 -0
  203. package/public/assets/WeixinLoginPanel-CnjR8xMu.js +9 -0
  204. package/public/assets/index-CPhVFEsx.css +1 -0
  205. package/public/assets/index-DQsM6Joa.js +19 -0
  206. package/public/assets/input-paste-CrNVAyOy.js +1 -0
  207. package/public/assets/registry-B4UFJdpA.js +2 -0
  208. package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
  209. package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
  210. package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
  211. package/public/favicon.png +0 -0
  212. package/public/index.html +9 -4
  213. package/public/logos/hermes.png +0 -0
  214. package/public/logos/ollama.png +0 -0
  215. package/public/logos/openclaw.svg +60 -0
  216. package/scripts/build-hermes-image.sh +21 -0
  217. package/scripts/build-local.sh +54 -0
  218. package/scripts/check-adapter-isolation.ts +293 -0
  219. package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
  220. package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
  221. package/scripts/smoke/hermes-bootstrap.sh +195 -0
  222. package/templates/hermes-entrypoint.sh +154 -0
  223. package/dist/cli/openclaw.d.ts +0 -12
  224. package/dist/cli/openclaw.js +0 -156
  225. package/dist/cli/openclaw.js.map +0 -1
  226. package/dist/services/app-compiler.js.map +0 -1
  227. package/dist/services/app-manager.d.ts +0 -17
  228. package/dist/services/app-manager.js +0 -168
  229. package/dist/services/app-manager.js.map +0 -1
  230. package/dist/services/job-manager.d.ts +0 -22
  231. package/dist/services/job-manager.js +0 -102
  232. package/dist/services/job-manager.js.map +0 -1
  233. package/public/assets/Dashboard-CQsp1Mr9.js +0 -1
  234. package/public/assets/InitPassword-BEC8SE4A.js +0 -1
  235. package/public/assets/InstanceDetail-B5wTgNEg.js +0 -17
  236. package/public/assets/NewInstance-GQzm3K9D.js +0 -1
  237. package/public/assets/Settings-ByjGlqhP.js +0 -1
  238. package/public/assets/Setup-cMF21Y-8.js +0 -1
  239. package/public/assets/index-B6qQP4mH.css +0 -1
  240. package/public/assets/index-BuTQtuNy.js +0 -16
  241. package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
@@ -1,15 +1,32 @@
1
- import { execFile, execFileSync } from "child_process";
2
- import { randomBytes } from "crypto";
3
- import { chmodSync, chownSync, closeSync, copyFileSync, cpSync, existsSync, openSync, 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";
4
3
  import { rm as rmAsync } from "fs/promises";
5
4
  import { createServer as netCreateServer } from "net";
6
- import { userInfo } from "os";
5
+ import { networkInterfaces, userInfo } from "os";
7
6
  import { dirname, join, resolve } from "path";
8
- import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
9
- import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
7
+ import * as config from "../config.js";
10
8
  import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
11
- import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
12
- import { compileTaskRuntime } from "./app-compiler.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
+ : () => ({});
13
30
  const _configChangeListeners = [];
14
31
  export function onConfigChange(listener) {
15
32
  _configChangeListeners.push(listener);
@@ -19,41 +36,66 @@ export function onConfigChange(listener) {
19
36
  _configChangeListeners.splice(idx, 1);
20
37
  };
21
38
  }
22
- const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23
- const DEFAULT_GATEWAY_PORT = 18789;
24
- const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
25
- const INSTANCE_MODEL_ENV_FILENAME = "model.env";
26
- const OPENCLAW_STATE_DIRNAME = ".openclaw";
27
- const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
28
- // ── Path helpers ──
29
- function instanceDir(instanceId) {
30
- 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
+ }
31
50
  }
32
- function instanceMetaPath(instanceId) {
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);
59
+ }
60
+ function hasAppInstallMarkers(dir) {
61
+ return existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "app-spec.yaml"));
62
+ }
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);
67
+ }
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;
81
+ }
82
+ export function instanceDir(instanceId) {
83
+ return resolveInstanceRoot(instanceId);
84
+ }
85
+ export function instanceMetaPath(instanceId) {
33
86
  return join(instanceDir(instanceId), "instance.json");
34
87
  }
35
- function defaultOpenclawHome(instanceId) {
36
- return join(instanceDir(instanceId), INSTANCE_OPENCLAW_HOME_DIRNAME);
37
- }
38
- function defaultModelEnvFile(instanceId) {
39
- return join(instanceDir(instanceId), INSTANCE_MODEL_ENV_FILENAME);
40
- }
41
- function getOpenclawHomeInternal(instanceId) {
42
- const meta = getInstance(instanceId);
43
- if (meta?.openclaw_home)
44
- return meta.openclaw_home;
45
- return defaultOpenclawHome(instanceId);
46
- }
47
- function openclawStateDir(instanceId) {
48
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME);
49
- }
50
- function legacyOpenclawConfigPath(instanceId) {
51
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_CONFIG_FILENAME);
52
- }
53
- function openclawConfigPathInternal(instanceId) {
54
- return join(openclawStateDir(instanceId), OPENCLAW_CONFIG_FILENAME);
55
- }
56
- function normalizePath(p) {
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");
97
+ }
98
+ export function normalizePath(p) {
57
99
  return resolve(p.replace(/^~/, userInfo().homedir));
58
100
  }
59
101
  // ── JSON / deep merge ──
@@ -82,27 +124,32 @@ function deepMerge(base, overlay) {
82
124
  // Track in-flight port allocations to prevent race conditions
83
125
  // between concurrent createInstance() calls.
84
126
  const _pendingPorts = new Set();
85
- 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) {
86
134
  if (!runtime)
87
135
  return null;
88
- const envPort = runtime.env?.OPENCLAW_GATEWAY_PORT;
89
- if (envPort) {
90
- const p = parseInt(envPort, 10);
91
- if (!isNaN(p))
92
- return p;
93
- }
94
- const args = runtime.args || [];
95
- for (let i = 0; i < args.length; i++) {
96
- const arg = String(args[i]);
97
- if (arg === "--port" && i + 1 < args.length) {
98
- const p = parseInt(args[i + 1], 10);
99
- 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;
100
141
  }
101
- if (arg.startsWith("--port=")) {
102
- const p = parseInt(arg.split("=")[1], 10);
103
- 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);
104
148
  }
105
149
  }
150
+ catch {
151
+ /* adapter not registered — no fallback */
152
+ }
106
153
  return null;
107
154
  }
108
155
  function usedGatewayPorts(excludeId) {
@@ -124,41 +171,78 @@ function safePort(port) {
124
171
  /**
125
172
  * Probes whether a port is currently held by any process on the host.
126
173
  *
127
- * Binds `0.0.0.0:port` under the Linux default socket semantics, this fails
128
- * with EADDRINUSE whenever another listener holds the port on `0.0.0.0`, on
129
- * any specific interface (e.g. `127.0.0.1`), or via Docker's published port
130
- * map. An IPv6-only listener on `::1` is not detected, but that edge case
131
- * never comes up in the Nomad+Docker path this project uses.
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.
132
184
  */
133
185
  export function isPortInUse(port) {
134
186
  if (!Number.isInteger(port) || port < 1 || port > 65535)
135
187
  return Promise.resolve(false);
136
- return new Promise((resolve) => {
188
+ const probeAt = (host, opts = {}) => new Promise((resolve) => {
137
189
  const server = netCreateServer();
138
- 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
+ });
139
204
  server.once("listening", () => {
140
205
  server.close(() => resolve(false));
141
206
  });
142
- server.listen(port, "0.0.0.0");
207
+ server.listen({ port, host, ...opts });
143
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
+ })();
144
227
  }
145
228
  /**
146
- * Picks a gateway port for an instance, starting at {@link DEFAULT_GATEWAY_PORT}
147
- * and walking upward until a port is both unknown to jishushell's instance
148
- * metadata and reports as free on the host. Concurrent callers coordinate
149
- * through `_pendingPorts` so they never hand out the same port.
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`.
150
234
  *
151
- * Intentionally does not hold a reservation indefinitely the caller is
152
- * expected to either persist the port into instance metadata (which then
153
- * shows up in `usedGatewayPorts`) or clear `_pendingPorts` on failure.
235
+ * Caller must release the allocated port via {@link releasePendingPort}
236
+ * after persisting it into instance metadata (or on failure).
154
237
  */
155
- async function allocateGatewayPort(instanceId) {
238
+ export async function allocateGatewayPort(instanceId, defaultPort) {
156
239
  const used = usedGatewayPorts(instanceId);
157
240
  const skipped = [];
158
- let port = DEFAULT_GATEWAY_PORT;
241
+ let port = defaultPort;
159
242
  while (true) {
160
- if (port > 65535)
161
- 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
+ }
162
246
  if (used.has(port) || _pendingPorts.has(port)) {
163
247
  skipped.push(port);
164
248
  port++;
@@ -187,34 +271,21 @@ async function allocateGatewayPort(instanceId) {
187
271
  }
188
272
  }
189
273
  }
190
- // ── Runtime / config builders ──
191
- function resolveOpenclawBin() {
192
- const candidates = [
193
- join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
194
- "/usr/local/bin/openclaw",
195
- "/usr/bin/openclaw",
196
- ];
197
- for (const p of candidates) {
198
- if (existsSync(p)) {
199
- // Ensure executable permission (npm install may strip +x on some platforms)
200
- try {
201
- chmodSync(p, 0o755);
202
- }
203
- catch { /* best effort — may be a symlink */ }
204
- // If symlink, also chmod the target
205
- try {
206
- const real = realpathSync(p);
207
- if (real !== p)
208
- chmodSync(real, 0o755);
209
- }
210
- catch { /* best effort */ }
211
- return p;
212
- }
213
- }
214
- 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);
215
277
  }
278
+ // ── Runtime / config builders ──
216
279
  export function getResolvedOpenclawBin() {
217
- 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
+ }
218
289
  }
219
290
  /**
220
291
  * When jishushell runs as root (e.g. systemd service), returns the actual
@@ -254,7 +325,7 @@ export function resolveServiceUser() {
254
325
  * openclaw process (running as that user) can read/write its own data files.
255
326
  * No-op when not running as root.
256
327
  */
257
- function chownToServiceUser(...paths) {
328
+ export function chownToServiceUser(...paths) {
258
329
  const svc = resolveServiceUser();
259
330
  if (!svc)
260
331
  return;
@@ -268,89 +339,9 @@ function chownToServiceUser(...paths) {
268
339
  }
269
340
  }
270
341
  }
271
- function buildDefaultRuntime(instanceId, port, openclawHome) {
272
- const home = openclawHome || defaultOpenclawHome(instanceId);
273
- return {
274
- command: resolveOpenclawBin(),
275
- args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
276
- cwd: home,
277
- user: resolveServiceUser()?.username ?? userInfo().username,
278
- env_files: [defaultModelEnvFile(instanceId)],
279
- env: {
280
- OPENCLAW_GATEWAY_PORT: String(port),
281
- NODE_OPTIONS: "--max-old-space-size=2048",
282
- },
283
- resources: { CPU: 1000, MemoryMB: 2048 },
284
- };
285
- }
286
- function starterConfig() {
287
- const dp = getPanelConfig().default_provider;
288
- let providerName = "minimax";
289
- let providerConfig = {
290
- baseUrl: "https://api.minimaxi.com/v1",
291
- api: "openai-completions",
292
- models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
293
- };
294
- let defaultModel = "minimax/MiniMax-M2.7";
295
- if (dp?.providerId) {
296
- providerName = dp.providerId;
297
- providerConfig = {
298
- baseUrl: dp.baseUrl,
299
- api: dp.api,
300
- ...(dp.authHeader ? { authHeader: true } : {}),
301
- models: dp.models || [],
302
- };
303
- const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
304
- defaultModel = `${providerName}/${modelId}`;
305
- }
306
- const config = {
307
- models: { providers: { [providerName]: providerConfig } },
308
- agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
309
- channels: {},
310
- gateway: {
311
- mode: "local",
312
- auth: { mode: "token", token: randomBytes(24).toString("hex") },
313
- controlUi: { dangerouslyDisableDeviceAuth: true },
314
- },
315
- plugins: { entries: { feishu: { enabled: false } } },
316
- };
317
- // Store upstream proxy config so LLM proxy knows where to forward
318
- if (dp?.providerId) {
319
- config["x-jishushell"] = {
320
- proxy: {
321
- upstream: {
322
- providerId: dp.providerId,
323
- baseUrl: dp.baseUrl,
324
- api: dp.api,
325
- authHeader: dp.authHeader || false,
326
- models: dp.models || [],
327
- selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
328
- hasApiKey: !!dp.apiKey,
329
- },
330
- },
331
- };
332
- }
333
- return config;
334
- }
335
- // ── Config loading ──
336
- function loadEffectiveConfig(instanceId) {
337
- const runtimePath = openclawConfigPathInternal(instanceId);
338
- const legacyPath = legacyOpenclawConfigPath(instanceId);
339
- const rExists = existsSync(runtimePath);
340
- const lExists = existsSync(legacyPath);
341
- if (rExists && lExists) {
342
- const legacy = loadJson(legacyPath);
343
- const runtime = loadJson(runtimePath);
344
- if (legacy && runtime)
345
- return deepMerge(legacy, runtime);
346
- return runtime || legacy || null;
347
- }
348
- if (rExists)
349
- return loadJson(runtimePath);
350
- if (lExists)
351
- return loadJson(legacyPath);
352
- return null;
353
- }
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.
354
345
  // ── Env file helpers ──
355
346
  export function parseEnvFile(path) {
356
347
  const env = {};
@@ -424,360 +415,12 @@ export function inferProviderApiKeyEnvName(providerId) {
424
415
  normalized = "OPENCLAW_PROVIDER";
425
416
  return `${normalized}_API_KEY`;
426
417
  }
427
- function hasConfiguredValue(value) {
428
- if (typeof value === "string")
429
- return value.trim().length > 0;
430
- if (typeof value === "object" && value !== null)
431
- return Object.keys(value).length > 0;
432
- return value != null;
433
- }
434
- function injectProviderApiKeys(instanceId, config) {
435
- const merged = structuredClone(config);
436
- const runtimeEnv = getRuntimeEnv(instanceId);
437
- const providers = merged.models?.providers || {};
438
- for (const [providerId, provider] of Object.entries(providers)) {
439
- if (typeof provider !== "object" || provider === null)
440
- continue;
441
- const p = provider;
442
- const api = p.api;
443
- if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
444
- p.api = LEGACY_PROVIDER_API_ALIASES[api];
445
- }
446
- const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
447
- if (apiKey)
448
- p.apiKey = apiKey;
449
- }
450
- return merged;
451
- }
452
- function applyFeishuDebugAccessDefaults(channel) {
453
- if (channel.enabled === false)
454
- return;
455
- if (!hasConfiguredValue(channel.appId))
456
- return;
457
- if (!hasConfiguredValue(channel.appSecret))
458
- return;
459
- let dmPolicy = channel.dmPolicy;
460
- if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
461
- channel.dmPolicy = "open";
462
- dmPolicy = "open";
463
- }
464
- if (dmPolicy !== "open")
465
- return;
466
- if (!("resolveSenderNames" in channel))
467
- channel.resolveSenderNames = false;
468
- let accounts = channel.accounts;
469
- if (typeof accounts !== "object" || accounts === null) {
470
- accounts = {};
471
- channel.accounts = accounts;
472
- }
473
- let defaultAccount = accounts.default;
474
- if (typeof defaultAccount !== "object" || defaultAccount === null) {
475
- defaultAccount = {};
476
- accounts.default = defaultAccount;
477
- }
478
- if (!("resolveSenderNames" in defaultAccount))
479
- defaultAccount.resolveSenderNames = false;
480
- const allowFrom = channel.allowFrom;
481
- if (Array.isArray(allowFrom)) {
482
- const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
483
- if (!normalized.includes("*"))
484
- normalized.push("*");
485
- channel.allowFrom = normalized;
486
- return;
487
- }
488
- channel.allowFrom = ["*"];
489
- }
490
- function prepareConfigForSave(instanceId, config) {
491
- const configToWrite = structuredClone(config);
492
- // Remove JishuShell metadata — OpenClaw rejects unrecognized keys
493
- delete configToWrite["x-jishushell"];
494
- const envUpdates = {};
495
- const providers = configToWrite.models?.providers || {};
496
- const envFiles = getRuntimeEnvFiles(instanceId);
497
- const channels = configToWrite.channels || {};
498
- const plugins = configToWrite.plugins ??= {};
499
- const pluginEntries = plugins.entries ??= {};
500
- for (const [providerId, provider] of Object.entries(providers)) {
501
- if (typeof provider !== "object" || provider === null)
502
- continue;
503
- const p = provider;
504
- if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
505
- p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
506
- }
507
- if (!("apiKey" in p))
508
- continue;
509
- // Keep proxy provider apiKey in config — OpenClaw reads it from config directly.
510
- // Only real upstream provider keys get moved to env files for security.
511
- // Detect proxy by baseUrl (provider ID now uses upstream name for display).
512
- if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
513
- continue;
514
- const apiKey = p.apiKey;
515
- delete p.apiKey;
516
- if (envFiles.length) {
517
- envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
518
- }
519
- else {
520
- p.apiKey = apiKey;
521
- }
522
- }
523
- for (const [channelId, channel] of Object.entries(channels)) {
524
- if (typeof channel !== "object" || channel === null)
525
- continue;
526
- const ch = channel;
527
- if (channelId === "feishu" || channelId === "lark")
528
- applyFeishuDebugAccessDefaults(ch);
529
- let pluginEntry = pluginEntries[channelId];
530
- if (pluginEntry == null) {
531
- pluginEntry = {};
532
- pluginEntries[channelId] = pluginEntry;
533
- }
534
- if (typeof pluginEntry === "object") {
535
- pluginEntry.enabled = ch.enabled !== false;
536
- }
537
- }
538
- return [configToWrite, envUpdates];
539
- }
540
- // ── Channel plugin helpers ──
541
- // Channel → plugin package mapping for auto-install.
542
- // Stock plugins (bundled with newer OpenClaw) are detected via extensions/{id} dir;
543
- // if missing (older OpenClaw), they get installed as fallback.
544
- // @larksuite/openclaw-lark installs as "openclaw-lark" dir but registers channel "feishu"
545
- const CHANNEL_EXT_DIR_ALIAS = {
546
- feishu: "openclaw-lark",
547
- lark: "openclaw-lark",
548
- };
549
- export const CHANNEL_PLUGIN_MAP = {
550
- // Official vendor plugins (ByteDance Feishu/Lark)
551
- feishu: "@larksuite/openclaw-lark",
552
- lark: "@larksuite/openclaw-lark",
553
- // Built-in (stock) — fallback install for older OpenClaw versions
554
- telegram: "@openclaw/telegram",
555
- discord: "@openclaw/discord",
556
- slack: "@openclaw/slack",
557
- whatsapp: "@openclaw/whatsapp",
558
- signal: "@openclaw/signal",
559
- line: "@openclaw/line",
560
- msteams: "@openclaw/msteams",
561
- // Official vendor plugins — need install (not bundled)
562
- "openclaw-weixin": "@tencent-weixin/openclaw-weixin",
563
- };
564
- /**
565
- * Known IM plugin entry IDs as they appear under `config.plugins.entries`.
566
- * This is the union of channel IDs and the dir-alias names (e.g. `feishu` may
567
- * register the plugin as `openclaw-lark`), which is what must be scrubbed when
568
- * dissociating an instance from its inherited IM bindings.
569
- */
570
- const IM_PLUGIN_ENTRY_IDS = new Set([
571
- ...Object.keys(CHANNEL_PLUGIN_MAP),
572
- ...Object.values(CHANNEL_EXT_DIR_ALIAS),
573
- ]);
574
- /**
575
- * Dissociate a cloned/imported config from its source instance's IM bindings.
576
- *
577
- * Mutates the given config in place:
578
- * - Deletes the entire `channels` block (same channel cannot serve multiple
579
- * instances, so every inherited enabled/credential/account entry must go).
580
- * - Deletes matching IM entries from `plugins.entries` so the plugin loader
581
- * does not try to boot a channel whose config no longer exists.
582
- *
583
- * Used by both domain clone (`createInstance`'s `cloneFrom` path) and the
584
- * backup import paths (`importInstance`, `createFromBackup`) so that a new
585
- * instance never inherits a half-configured IM binding.
586
- */
587
- export function stripImBindings(config) {
588
- if (config?.channels)
589
- delete config.channels;
590
- const entries = config?.plugins?.entries;
591
- if (entries && typeof entries === "object") {
592
- for (const key of Object.keys(entries)) {
593
- if (IM_PLUGIN_ENTRY_IDS.has(key))
594
- delete entries[key];
595
- }
596
- }
597
- }
598
- /** Check if a channel plugin is installed for an instance. */
599
- export function isChannelPluginInstalled(instanceId, channelId) {
600
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
601
- const stockExtDir = getStockExtensionsDir();
602
- return existsSync(join(getChannelExtensionsDir(instanceId), extDirName))
603
- || existsSync(join(stockExtDir, extDirName))
604
- // Also accept the built-in directory named after the raw channelId (e.g. "feishu/" in stock)
605
- || (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
606
- }
607
- /**
608
- * Install a single channel plugin.
609
- * Docker mode: runs install inside the running container via docker exec.
610
- * Host mode (fallback): spawns the host openclaw binary directly.
611
- */
612
- export async function installChannelPlugin(instanceId, channelId) {
613
- const pkg = CHANNEL_PLUGIN_MAP[channelId];
614
- if (!pkg)
615
- throw new Error(`Unknown channel: ${channelId}`);
616
- if (isChannelPluginInstalled(instanceId, channelId))
617
- return;
618
- const openclawHome = getOpenclawHomeInternal(instanceId);
619
- const extensionsDir = getChannelExtensionsDir(instanceId);
620
- // Docker mode: always install inside container via docker exec
621
- const { getNomadDriver } = await import("../config.js");
622
- if (getNomadDriver() === "docker") {
623
- await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
624
- return;
625
- }
626
- const openclawBin = resolveOpenclawBin();
627
- // Host mode: spawn openclaw binary directly
628
- const nodeBinDir = dirname(process.execPath);
629
- const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
630
- const proxyEnvKeys = [
631
- "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
632
- "no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
633
- ];
634
- const proxyEnv = {};
635
- for (const key of proxyEnvKeys) {
636
- if (process.env[key])
637
- proxyEnv[key] = process.env[key];
638
- }
639
- const childEnv = {
640
- PATH: childPath,
641
- HOME: process.env.HOME,
642
- LANG: process.env.LANG,
643
- OPENCLAW_HOME: openclawHome,
644
- ...proxyEnv,
645
- };
646
- const MAX_ATTEMPTS = 3;
647
- const RETRY_DELAY_MS = 5_000;
648
- const attemptInstall = () => new Promise((resolve, reject) => {
649
- execFile(openclawBin, ["plugins", "install", pkg], {
650
- cwd: openclawHome,
651
- env: childEnv,
652
- timeout: 300_000,
653
- }, (err, stdout, stderr) => {
654
- if (err && !isChannelPluginInstalled(instanceId, channelId)) {
655
- const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
656
- console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
657
- try {
658
- if (existsSync(extensionsDir)) {
659
- for (const entry of readdirSync(extensionsDir)) {
660
- if (entry.startsWith(".openclaw-install-stage-")) {
661
- rmSync(join(extensionsDir, entry), { recursive: true, force: true });
662
- console.log(`[plugins] Cleaned up stage dir: ${entry}`);
663
- }
664
- }
665
- }
666
- }
667
- catch (_) { }
668
- reject(new Error(msg));
669
- }
670
- else {
671
- if (err)
672
- console.log(`[plugins] ${pkg} installed (ignored non-zero exit: warning only)`);
673
- else
674
- console.log(`[plugins] ${pkg} installed`);
675
- resolve();
676
- }
677
- });
678
- });
679
- console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
680
- let lastErr;
681
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
682
- try {
683
- await attemptInstall();
684
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
685
- const installedExtDir = join(extensionsDir, extDirName);
686
- if (existsSync(installedExtDir)) {
687
- ensureDirContainer(installedExtDir);
688
- try {
689
- for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
690
- if (entry.isDirectory()) {
691
- ensureDirContainer(join(installedExtDir, entry.name));
692
- }
693
- }
694
- }
695
- catch { /* best effort */ }
696
- }
697
- ensureDirContainer(extensionsDir);
698
- return;
699
- }
700
- catch (err) {
701
- lastErr = err;
702
- const isFetchError = /fetch failed/i.test(err.message ?? "");
703
- if (isFetchError && attempt < MAX_ATTEMPTS) {
704
- console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed with fetch error, retrying in ${RETRY_DELAY_MS / 1000}s...`);
705
- await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
706
- continue;
707
- }
708
- console.error(`[plugins] Failed to install ${pkg}:`, err.message);
709
- break;
710
- }
711
- }
712
- throw lastErr;
713
- }
714
- /**
715
- * Install a channel plugin inside the running Docker container via nomad-manager.exec().
716
- * Requires the instance to be running — the extensions dir is bind-mounted so
717
- * the install persists on the host filesystem.
718
- */
719
- async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
720
- const { exec } = await import("./nomad-manager.js");
721
- const MAX_ATTEMPTS = 3;
722
- const RETRY_DELAY_MS = 5_000;
723
- console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
724
- let lastErr;
725
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
726
- try {
727
- const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
728
- // Check if plugin was actually installed (openclaw may exit non-zero with warnings)
729
- if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
730
- const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
731
- console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
732
- throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
733
- }
734
- if (result.exitCode !== 0) {
735
- console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit: warning only)`);
736
- }
737
- else {
738
- console.log(`[plugins] ${pkg} installed via docker`);
739
- }
740
- // Fix ownership on host side
741
- const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
742
- const installedExtDir = join(extensionsDir, extDirName);
743
- if (existsSync(installedExtDir)) {
744
- ensureDirContainer(installedExtDir);
745
- try {
746
- for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
747
- if (entry.isDirectory()) {
748
- ensureDirContainer(join(installedExtDir, entry.name));
749
- }
750
- }
751
- }
752
- catch { /* best effort */ }
753
- }
754
- ensureDirContainer(extensionsDir);
755
- return;
756
- }
757
- catch (err) {
758
- lastErr = err;
759
- // "Instance is not running" from nomad-manager.exec() — give a clear user-facing message
760
- if (/not running/i.test(err.message ?? "")) {
761
- throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
762
- }
763
- const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
764
- if (isTransient && attempt < MAX_ATTEMPTS) {
765
- console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
766
- await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
767
- continue;
768
- }
769
- console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
770
- break;
771
- }
772
- }
773
- throw lastErr;
774
- }
775
- function getChannelExtensionsDir(instanceId) {
776
- return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
777
- }
778
- function getStockExtensionsDir() {
779
- return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
780
- }
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`.
781
424
  // ── Public API ──
782
425
  /**
783
426
  * Probe whether a file is readable by the current process. Used to
@@ -800,45 +443,53 @@ function probeReadable(path) {
800
443
  }
801
444
  }
802
445
  export function listInstances() {
803
- if (!existsSync(INSTANCES_DIR))
804
- return [];
805
- const entries = readdirSync(INSTANCES_DIR).sort();
806
- const instances = [];
807
- for (const name of entries) {
808
- const metaPath = join(INSTANCES_DIR, name, "instance.json");
809
- const dirPath = join(INSTANCES_DIR, name);
810
- try {
811
- if (!statSync(dirPath).isDirectory())
812
- continue;
813
- // Use safeReadJson so primary instance.json that was corrupted or
814
- // deleted mid-rename falls back to the .bak chain maintained by
815
- // safeWriteJson. Without this, an interrupted safeWriteJson call
816
- // would silently drop the instance from every list/get hot path
817
- // even though the backup chain still holds valid content on disk.
818
- const meta = safeReadJson(metaPath, `instance:${name}`);
819
- if (meta) {
820
- instances.push(meta);
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))
821
453
  continue;
454
+ const metaPath = join(rootDir, name, "instance.json");
455
+ const dirPath = join(rootDir, name);
456
+ try {
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}`);
482
+ }
822
483
  }
823
- // safeReadJson → null can mean any of (a) primary missing + no
824
- // backups, (b) all candidates unparseable, (c) permission denied.
825
- // (a) and (b) are legitimate "drop from list" cases; (c) is the
826
- // sudo-script footgun and must be logged loudly so the operator
827
- // doesn't just see an empty instance list with no hint why.
828
- const readErr = probeReadable(metaPath);
829
- if (readErr && readErr.code === "EACCES") {
830
- console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
831
- `Check file ownership with: ls -la ${metaPath}`);
484
+ catch (e) {
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}`);
832
489
  }
833
490
  }
834
- catch (e) {
835
- // Fallback for failures before the safeReadJson call (e.g. statSync
836
- // on a directory we can't enter). Still log instead of silently
837
- // dropping the entry.
838
- console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
839
- }
840
491
  }
841
- return instances;
492
+ return [...deduped.values()];
842
493
  }
843
494
  export function getInstance(instanceId) {
844
495
  const metaPath = instanceMetaPath(instanceId);
@@ -847,7 +498,7 @@ export function getInstance(instanceId) {
847
498
  // (no primary, no backups) keeps the existing 404 behavior intact.
848
499
  const meta = safeReadJson(metaPath, `instance:${instanceId}`);
849
500
  if (meta)
850
- return meta;
501
+ return backfillInstanceMeta(meta);
851
502
  // safeReadJson swallows every read error internally, which is exactly
852
503
  // wrong for the EACCES case — a root-owned primary would silently
853
504
  // return null and callers would report "Instance not found" instead
@@ -861,270 +512,9 @@ export function getInstance(instanceId) {
861
512
  }
862
513
  return null;
863
514
  }
864
- export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, appSpec, cloneOptions) {
865
- const d = instanceDir(instanceId);
866
- if (existsSync(d))
867
- throw new Error(`Instance '${instanceId}' already exists`);
868
- const home = openclawHome ? normalizePath(openclawHome) : defaultOpenclawHome(instanceId);
869
- // Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent path traversal.
870
- // Use realpathSync after mkdir to resolve symlinks, preventing symlink-based bypasses.
871
- if (openclawHome) {
872
- const resolved = resolve(home);
873
- if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
874
- throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
875
- }
876
- // Resolve symlinks for the parent dir to catch symlink attacks
877
- const parentDir = dirname(resolved);
878
- if (existsSync(parentDir)) {
879
- const realParent = realpathSync(parentDir);
880
- if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
881
- throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
882
- }
883
- }
884
- const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === normalizePath(home));
885
- if (shared.length) {
886
- throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared.map((i) => i.id).join(", ")}`);
887
- }
888
- }
889
- // Check for orphaned openclaw_home directory (e.g. instance.json deleted but data remains)
890
- if (existsSync(home)) {
891
- try {
892
- const entries = readdirSync(home);
893
- if (entries.length > 0) {
894
- throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. Remove it manually or choose a different path.`);
895
- }
896
- }
897
- catch (e) {
898
- if (e.message.includes("not empty"))
899
- throw e;
900
- // readdirSync failed — directory might not be readable, proceed cautiously
901
- }
902
- }
903
- ensureDirContainer(d);
904
- // Inherit group from INSTANCES_DIR so both root and the real user can access
905
- try {
906
- const parentGid = statSync(INSTANCES_DIR).gid;
907
- chownSync(d, -1, parentGid);
908
- }
909
- catch { /* non-root without CAP_CHOWN — already correct owner */ }
910
- ensureDirContainer(home);
911
- ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
912
- const portAlloc = await allocateGatewayPort(instanceId);
913
- const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
914
- let runtime = baseRuntime;
915
- if (appSpec) {
916
- const serviceTask = appSpec.tasks.find((t) => t.role === "service");
917
- if (serviceTask) {
918
- const compiled = compileTaskRuntime(serviceTask, instanceId);
919
- runtime = { ...baseRuntime, ...compiled };
920
- }
921
- }
922
- const allocatedPort = extractGatewayPort(runtime);
923
- // Port already reserved inside allocateGatewayPort; just track for cleanup
924
- try {
925
- const meta = {
926
- id: instanceId,
927
- name,
928
- description,
929
- openclaw_home: home,
930
- runtime,
931
- created_at: new Date().toISOString(),
932
- ...(appSpec ? { app_id: appSpec.id } : {}),
933
- };
934
- safeWriteJson(instanceMetaPath(instanceId), meta);
935
- const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
936
- for (const ef of envFiles) {
937
- if (!existsSync(ef))
938
- writeConfigFile(ef, "");
939
- }
940
- // After writing env files, ensure the runtime user can read them
941
- try {
942
- const runtimeUser = runtime.user;
943
- if (runtimeUser && runtimeUser !== userInfo().username) {
944
- for (const ef of envFiles) {
945
- execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
946
- }
947
- }
948
- }
949
- catch { /* ignore - same user or no permission to chown */ }
950
- const configPath = openclawConfigPathInternal(instanceId);
951
- ensureDirContainer(dirname(configPath));
952
- if (cloneFrom && !existsSync(configPath)) {
953
- const srcConfig = resolveExistingConfigPath(cloneFrom);
954
- if (existsSync(srcConfig)) {
955
- // Domain-level clone: copy config but strip proxy identity (token, jsproxy provider)
956
- // so the new instance gets its own proxy token via saveInstanceConfig later
957
- try {
958
- const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
959
- // Remove proxy provider (will be regenerated with new proxy token)
960
- // Detect by baseUrl since provider ID now uses upstream name (e.g. "js-minimax")
961
- const providers = cloned?.models?.providers;
962
- if (providers) {
963
- for (const [pid, prov] of Object.entries(providers)) {
964
- if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
965
- delete providers[pid];
966
- }
967
- }
968
- }
969
- // Remove proxy model reference from agent defaults (regenerated by bootstrap)
970
- const defaultModel = cloned?.agents?.defaults?.model;
971
- if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
972
- delete cloned.agents.defaults.model;
973
- }
974
- // Strip IM channel configs + matching plugin entries — same channel
975
- // cannot serve multiple instances and we don't want the plugin
976
- // loader to boot a half-configured binding.
977
- stripImBindings(cloned);
978
- // Copy extensions directory so plugin references in config remain valid
979
- // Copy workspace directory to preserve agent personality (.md files)
980
- const subdirs = ["extensions", "workspace"];
981
- if (cloneOptions?.include_memory !== false) {
982
- // Memory may exist at .openclaw/memory/ if created by OpenClaw runtime
983
- const memDir = join(dirname(srcConfig), "memory");
984
- if (existsSync(memDir))
985
- subdirs.push("memory");
986
- }
987
- if (cloneOptions?.include_sessions) {
988
- // Sessions at .openclaw/agents/main/sessions/
989
- const sessDir = join(dirname(srcConfig), "agents");
990
- if (existsSync(sessDir))
991
- subdirs.push("agents");
992
- }
993
- for (const subdir of subdirs) {
994
- const srcDir = join(dirname(srcConfig), subdir);
995
- const dstDir = join(dirname(configPath), subdir);
996
- if (existsSync(srcDir) && !existsSync(dstDir)) {
997
- try {
998
- cpSync(srcDir, dstDir, { recursive: true });
999
- }
1000
- catch { /* best effort */ }
1001
- }
1002
- }
1003
- writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
1004
- // Copy x-jishushell upstream metadata from source instance.json
1005
- // (saveConfig stores x-jishushell in instance.json, not openclaw.json)
1006
- const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
1007
- if (existsSync(srcMetaPath)) {
1008
- try {
1009
- const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
1010
- const srcXj = srcMeta?.["x-jishushell"];
1011
- if (srcXj?.proxy?.upstream) {
1012
- const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
1013
- // Clear instance-specific fields
1014
- delete dstXj.proxy.upstream.apiKey;
1015
- const metaPath = instanceMetaPath(instanceId);
1016
- if (existsSync(metaPath)) {
1017
- const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
1018
- dstMeta["x-jishushell"] = dstXj;
1019
- writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
1020
- }
1021
- }
1022
- }
1023
- catch { /* ignore metadata copy errors */ }
1024
- }
1025
- }
1026
- catch {
1027
- // Fallback: raw copy if parse fails
1028
- copyFileSync(srcConfig, configPath);
1029
- }
1030
- }
1031
- }
1032
- if (!existsSync(configPath)) {
1033
- writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
1034
- // Inject default provider API key from setup into both env files
1035
- const dp = getPanelConfig().default_provider;
1036
- if (dp?.apiKey && dp?.providerId && envFiles.length) {
1037
- const envKey = inferProviderApiKeyEnvName(dp.providerId);
1038
- updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
1039
- // Also write to provider.env as UPSTREAM_API_KEY (LLM proxy reads this first)
1040
- const providerEnv = join(dirname(envFiles[0]), "provider.env");
1041
- updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
1042
- }
1043
- }
1044
- // Merge App-level config_defaults into openclaw.json (shallow merge, app values win)
1045
- if (appSpec?.openclaw?.config_defaults && existsSync(configPath)) {
1046
- try {
1047
- const existing = JSON.parse(readFileSync(configPath, "utf-8"));
1048
- const defaults = appSpec.openclaw.config_defaults;
1049
- // Deep merge top-level keys
1050
- for (const [key, value] of Object.entries(defaults)) {
1051
- if (typeof value === "object" && value !== null && !Array.isArray(value) && typeof existing[key] === "object" && existing[key] !== null) {
1052
- existing[key] = { ...existing[key], ...value };
1053
- }
1054
- else {
1055
- existing[key] = value;
1056
- }
1057
- }
1058
- writeConfigFile(configPath, JSON.stringify(existing, null, 2));
1059
- }
1060
- catch { /* ignore merge errors, keep existing config */ }
1061
- }
1062
- // Record App-level skills for later installation into the instance
1063
- if (appSpec?.openclaw?.skills && Array.isArray(appSpec.openclaw.skills)) {
1064
- try {
1065
- const skillsDir = join(dirname(configPath), "skills");
1066
- ensureDirContainer(skillsDir);
1067
- const skillMeta = join(skillsDir, ".app-skills.json");
1068
- safeWriteJson(skillMeta, { app_id: appSpec.id, skills: appSpec.openclaw.skills });
1069
- }
1070
- catch { /* ignore */ }
1071
- }
1072
- // Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
1073
- if (cloneFrom && envFiles.length) {
1074
- const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
1075
- const srcEnvFile = srcEnvFiles[0];
1076
- const dstEnvFile = envFiles[0];
1077
- // Copy provider.env (upstream API key)
1078
- if (srcEnvFile) {
1079
- const srcProvider = join(dirname(srcEnvFile), "provider.env");
1080
- const dstProvider = join(dirname(dstEnvFile), "provider.env");
1081
- if (existsSync(srcProvider) && !existsSync(dstProvider)) {
1082
- copyFileSync(srcProvider, dstProvider);
1083
- }
1084
- }
1085
- // Note: model.env is NOT copied (new instance needs its own proxy token)
1086
- }
1087
- // Bootstrap proxy: generate proxy token and write model.env so instance
1088
- // is ready to run immediately without requiring a manual "save config" first
1089
- try {
1090
- const { bootstrapInstanceProxy } = await import("../services/llm-proxy/index.js");
1091
- await bootstrapInstanceProxy(instanceId);
1092
- }
1093
- catch (e) {
1094
- console.warn(`[instance] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
1095
- }
1096
- // If running as root, hand ownership of all created files to the service user
1097
- // so the openclaw process (running as that user) can read/write its own files.
1098
- const svcUser = resolveServiceUser();
1099
- if (svcUser) {
1100
- try {
1101
- execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], { timeout: 10_000 });
1102
- if (!home.startsWith(d + "/") && existsSync(home)) {
1103
- execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], { timeout: 10_000 });
1104
- }
1105
- }
1106
- catch (e) {
1107
- console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
1108
- }
1109
- }
1110
- // Attach transient port allocation info for the API response only — never
1111
- // persisted in instance.json. If the caller (e.g. the create route) sees
1112
- // skipped ports it can tell the user the default was busy.
1113
- if (portAlloc.skipped.length > 0) {
1114
- meta.port_allocation = {
1115
- assigned: portAlloc.port,
1116
- requested: DEFAULT_GATEWAY_PORT,
1117
- reason: "default_busy",
1118
- skipped: portAlloc.skipped,
1119
- };
1120
- }
1121
- return meta;
1122
- }
1123
- finally {
1124
- if (allocatedPort)
1125
- _pendingPorts.delete(allocatedPort);
1126
- }
1127
- }
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)`.
1128
518
  export function updateInstance(instanceId, name, description) {
1129
519
  const meta = getInstance(instanceId);
1130
520
  if (!meta)
@@ -1137,7 +527,10 @@ export function updateInstance(instanceId, name, description) {
1137
527
  chownToServiceUser(instanceMetaPath(instanceId));
1138
528
  return meta;
1139
529
  }
1140
- /** 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.
1141
534
  export function updateInstanceMeta(instanceId, patch) {
1142
535
  const metaPath = instanceMetaPath(instanceId);
1143
536
  const meta = safeReadJson(metaPath, "instance-meta") || {};
@@ -1159,9 +552,35 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
1159
552
  cancelJob(job.id);
1160
553
  }
1161
554
  }).catch(() => { });
1162
- // 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.
1163
556
  const meta = getInstance(instanceId);
1164
- 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
+ }
1165
584
  // Clean up Nomad Variables (async, best-effort)
1166
585
  import("./nomad-manager.js").then((nm) => {
1167
586
  nm.purgeInstanceVariables(instanceId).catch((e) => {
@@ -1188,10 +607,7 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
1188
607
  warnings.push(`Failed to delete instance directory: ${e.message}`);
1189
608
  }
1190
609
  }
1191
- // Warn if custom openclaw_home exists outside the instance dir
1192
- if (home && !home.startsWith(d) && existsSync(home)) {
1193
- warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
1194
- }
610
+ // (Custom openclaw_home orphan warning emitted by OpenClawAdapter.hooks.onDelete.)
1195
611
  // Handle backups (stored in separate directory, not affected by the
1196
612
  // instance rm above). Backups can be hundreds of MB each and accumulate
1197
613
  // across retention windows, so use the same async rm path to keep the
@@ -1210,285 +626,210 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
1210
626
  }
1211
627
  return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
1212
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).
1213
634
  export function getConfig(instanceId) {
1214
- const config = loadEffectiveConfig(instanceId);
1215
- if (!config)
1216
- return null;
1217
- // Merge x-jishushell metadata from instance.json
1218
635
  const meta = getInstance(instanceId);
1219
- if (meta?.["x-jishushell"]) {
1220
- 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;
1221
645
  }
1222
- return injectProviderApiKeys(instanceId, config);
1223
646
  }
1224
647
  export function getStoredConfig(instanceId) {
1225
- const config = loadEffectiveConfig(instanceId);
1226
- if (!config)
1227
- return null;
1228
648
  const meta = getInstance(instanceId);
1229
- if (meta?.["x-jishushell"]) {
1230
- 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;
1231
658
  }
1232
- return config;
1233
659
  }
1234
- export function saveConfig(instanceId, config) {
1235
- const configPath = openclawConfigPathInternal(instanceId);
1236
- 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}`);
1237
676
  return false;
1238
- if (!existsSync(configPath)) {
1239
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1240
- ensureDirContainer(dirname(configPath));
1241
- if (existsSync(legacyPath))
1242
- copyFileSync(legacyPath, configPath);
1243
677
  }
1244
- // Save x-jishushell metadata to instance.json (not openclaw.json)
1245
- if (config["x-jishushell"]) {
1246
- const metaPath = instanceMetaPath(instanceId);
1247
- if (existsSync(metaPath)) {
1248
- const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
1249
- meta["x-jishushell"] = config["x-jishushell"];
1250
- safeWriteJson(metaPath, meta);
1251
- 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];
1252
697
  }
1253
- }
1254
- const [configToWrite, envUpdates] = prepareConfigForSave(instanceId, config);
1255
- // If openclaw-lark is configured as enabled, resolve which feishu plugin should actually be used:
1256
- // - If built-in feishu/ exists in stock AND openclaw-lark/ is not installed anywhere → switch to
1257
- // built-in feishu (removes the stale openclaw-lark reference that breaks container startup).
1258
- // - If both exist → keep openclaw-lark but disable built-in feishu to avoid conflict.
1259
- if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
1260
- const stockExtDir = getStockExtensionsDir();
1261
- const stockFeishu = join(stockExtDir, "feishu");
1262
- const stockOcl = join(stockExtDir, "openclaw-lark");
1263
- const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
1264
- if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
1265
- // Built-in available, community package absent → switch to built-in
1266
- configToWrite.plugins.entries.feishu = { enabled: true };
1267
- delete configToWrite.plugins.entries["openclaw-lark"];
698
+ catch {
699
+ return undefined;
1268
700
  }
1269
- else if (existsSync(stockFeishu)) {
1270
- // Both present → disable built-in to avoid conflict with community package
1271
- configToWrite.plugins ??= {};
1272
- configToWrite.plugins.entries ??= {};
1273
- configToWrite.plugins.entries.feishu = { enabled: false };
701
+ },
702
+ ownKeys() {
703
+ try {
704
+ return Reflect.ownKeys(getAdapter("openclaw").channelPluginMap ?? {});
1274
705
  }
1275
- }
1276
- // Preserve backend-managed fields from existing config on disk —
1277
- // plugins.installs, plugins.entries, and channels written by scan-to-bind
1278
- // flows (saveWeixinCredentials / saveFeishuCredentials) are not tracked by
1279
- // the frontend and would be lost on a frontend config save.
1280
- if (existsSync(configPath)) {
706
+ catch {
707
+ return [];
708
+ }
709
+ },
710
+ has(_target, key) {
711
+ if (typeof key !== "string")
712
+ return false;
1281
713
  try {
1282
- const existing = JSON.parse(readFileSync(configPath, "utf-8"));
1283
- if (existing.plugins?.installs) {
1284
- configToWrite.plugins ??= {};
1285
- configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
1286
- }
1287
- // Merge plugin entries: for keys present in configToWrite, deep-merge
1288
- // backend-written sub-fields from disk. Keys absent from configToWrite
1289
- // (intentionally deleted) are NOT resurrected from existing.
1290
- if (existing.plugins?.entries && configToWrite.plugins?.entries) {
1291
- for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
1292
- const old = existing.plugins.entries[key];
1293
- if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1294
- configToWrite.plugins.entries[key] = { ...old, ...val };
1295
- }
1296
- }
1297
- }
1298
- // Merge channels: for keys present in configToWrite, deep-merge
1299
- // backend-written sub-fields (e.g. openclaw-weixin accounts) from disk.
1300
- // Keys absent from configToWrite (user-deleted channels) stay deleted.
1301
- if (existing.channels && configToWrite.channels) {
1302
- for (const [key, val] of Object.entries(configToWrite.channels)) {
1303
- const old = existing.channels[key];
1304
- if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1305
- configToWrite.channels[key] = { ...old, ...val };
1306
- }
1307
- }
1308
- }
714
+ return key in (getAdapter("openclaw").channelPluginMap ?? {});
1309
715
  }
1310
- catch { /* best effort */ }
1311
- }
1312
- // backup
1313
- if (existsSync(configPath)) {
1314
- copyFileSync(configPath, configPath + ".bak");
1315
- }
1316
- const configJson = JSON.stringify(configToWrite, null, 2);
1317
- ensureDirContainer(dirname(configPath));
1318
- writeConfigFile(configPath + ".tmp", configJson);
1319
- // Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
1320
- JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
1321
- renameSync(configPath + ".tmp", configPath);
1322
- chownToServiceUser(configPath);
1323
- // also write to legacy path
1324
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1325
- if (existsSync(legacyPath)) {
1326
- copyFileSync(legacyPath, legacyPath + ".bak");
1327
- }
1328
- writeConfigFile(legacyPath + ".tmp", configJson);
1329
- JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
1330
- renameSync(legacyPath + ".tmp", legacyPath);
1331
- chownToServiceUser(legacyPath);
1332
- if (Object.keys(envUpdates).length) {
1333
- const envFiles = getRuntimeEnvFiles(instanceId);
1334
- if (envFiles.length)
1335
- updateEnvFile(envFiles[0], envUpdates);
1336
- }
1337
- // Plugins are installed inside the container — no host-side auto-install on config save.
1338
- // Notify listeners (e.g. llm-proxy cache invalidation)
1339
- 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;
1340
724
  try {
1341
- listener(instanceId);
725
+ value = getAdapter("openclaw").channelPluginMap?.[key];
1342
726
  }
1343
- catch { /* ignore listener errors */ }
1344
- }
1345
- return true;
1346
- }
1347
- export function getOpenclawHome(instanceId) {
1348
- return getOpenclawHomeInternal(instanceId);
1349
- }
727
+ catch {
728
+ return undefined;
729
+ }
730
+ return value !== undefined
731
+ ? { configurable: true, enumerable: true, writable: false, value }
732
+ : undefined;
733
+ },
734
+ });
1350
735
  /**
1351
- * Save WeChat login credentials for an instance.
1352
- * Save Feishu/Lark credentials from OAuth Device Code flow.
736
+ * @deprecated Use `getAdapter(agentType).isChannelPluginInstalled(id, channelId)` instead.
1353
737
  */
1354
- // Feishu app IDs issued by the open platform follow the pattern cli_<hex/alnum>.
1355
- // Validate appId to reject malformed values sourced from OAuth API responses.
1356
- const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
1357
- export function saveFeishuCredentials(instanceId, creds) {
1358
- if (!FEISHU_APP_ID_RE.test(creds.appId)) {
1359
- 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;
1360
746
  }
1361
- if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
1362
- throw new Error("Invalid Feishu appSecret: must be a non-empty string");
747
+ catch {
748
+ return false;
1363
749
  }
1364
- const configPath = openclawConfigPathInternal(instanceId);
1365
- let config = safeReadJson(configPath, "feishu-creds") || {};
1366
- // Enable @larksuite/openclaw-lark plugin (installed inside Docker container),
1367
- // disable built-in @openclaw/feishu to avoid conflict.
1368
- config.plugins ??= {};
1369
- config.plugins.entries ??= {};
1370
- config.plugins.entries.feishu = { enabled: false };
1371
- config.plugins.entries["openclaw-lark"] = { enabled: true };
1372
- // Set channel config — official plugin reads from channels.feishu
1373
- config.channels ??= {};
1374
- config.channels.feishu = {
1375
- ...config.channels.feishu,
1376
- enabled: true,
1377
- appId: creds.appId,
1378
- appSecret: creds.appSecret,
1379
- domain: creds.domain,
1380
- dmPolicy: "open",
1381
- allowFrom: ["*"],
1382
- };
1383
- safeWriteJson(configPath, config);
1384
- chownToServiceUser(configPath);
1385
- console.log(`[instance-manager] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
1386
750
  }
1387
751
  /**
1388
- * 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.
1389
755
  */
1390
- const SAFE_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
1391
- export function saveWeixinCredentials(instanceId, creds) {
1392
- // Prevent path traversal via accountId (used as filename)
1393
- if (!creds.accountId || !SAFE_ACCOUNT_ID_RE.test(creds.accountId) || creds.accountId.includes('..')) {
1394
- throw new Error(`Invalid accountId: must be 1-128 chars of [a-zA-Z0-9@._-] without '..'`);
1395
- }
1396
- const home = getOpenclawHomeInternal(instanceId);
1397
- const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1398
- const accountsDir = join(stateDir, "accounts");
1399
- ensureDirContainer(accountsDir);
1400
- // Save account credentials file (via safeWriteJson for atomic + .bak protection)
1401
- const credObj = {
1402
- token: creds.token,
1403
- baseUrl: creds.baseUrl,
1404
- userId: creds.userId,
1405
- savedAt: new Date().toISOString(),
1406
- };
1407
- safeWriteJson(join(accountsDir, `${creds.accountId}.json`), credObj);
1408
- // OpenClaw also needs a "default" account file with the same credentials
1409
- safeWriteJson(join(accountsDir, "default.json"), credObj);
1410
- chownToServiceUser(join(accountsDir, `${creds.accountId}.json`), join(accountsDir, "default.json"));
1411
- // Update accounts.json index (required by the plugin to discover accounts)
1412
- const indexPath = join(stateDir, "accounts.json");
1413
- 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);
1414
777
  try {
1415
- const raw = readFileSync(indexPath, "utf-8");
1416
- 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");
782
+ }
783
+ catch {
784
+ return meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
1417
785
  }
1418
- catch { /* start fresh */ }
1419
- if (!Array.isArray(index))
1420
- index = [];
1421
- if (!index.includes(creds.accountId))
1422
- index.push(creds.accountId);
1423
- safeWriteJson(indexPath, index);
1424
- // Update openclaw.json: enable plugin + register account
1425
- const configPath = openclawConfigPathInternal(instanceId);
1426
- let config = safeReadJson(configPath, "weixin-creds") || {};
1427
- // Enable plugin
1428
- config.plugins ??= {};
1429
- config.plugins.entries ??= {};
1430
- config.plugins.entries["openclaw-weixin"] ??= {};
1431
- config.plugins.entries["openclaw-weixin"].enabled = true;
1432
- // Enable channel with account
1433
- config.channels ??= {};
1434
- config.channels["openclaw-weixin"] ??= {};
1435
- config.channels["openclaw-weixin"].enabled = true;
1436
- // Register account with both original and normalized IDs (OpenClaw normalizes @ and . to -)
1437
- const normalizedId = creds.accountId.replace(/[@.]/g, "-");
1438
- const accounts = config.channels["openclaw-weixin"].accounts ??= {};
1439
- accounts[creds.accountId] = { enabled: true };
1440
- if (normalizedId !== creds.accountId)
1441
- accounts[normalizedId] = { enabled: true };
1442
- accounts["default"] = { enabled: true };
1443
- // Set defaultAccount (required by OpenClaw)
1444
- if (!config.channels["openclaw-weixin"].defaultAccount) {
1445
- config.channels["openclaw-weixin"].defaultAccount = "default";
786
+ }
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`);
1446
793
  }
1447
- safeWriteJson(configPath, config);
1448
- chownToServiceUser(configPath);
1449
- console.log(`[instance-manager] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
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);
1450
804
  }
1451
- /**
1452
- * Get connected WeChat accounts for an instance.
1453
- */
1454
805
  export function getWeixinAccounts(instanceId) {
1455
- const home = getOpenclawHomeInternal(instanceId);
1456
- const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1457
- const accountsDir = join(stateDir, "accounts");
1458
- if (!existsSync(accountsDir))
1459
- return [];
1460
- // Only return accounts listed in the index (skip default.json and other auxiliary files)
1461
- let indexedIds = [];
806
+ const meta = getInstance(instanceId);
807
+ const agentType = resolveAgentType(meta);
1462
808
  try {
1463
- 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
+ : [];
1464
813
  }
1465
- catch { /* fallback to scanning */ }
1466
- const results = [];
1467
- for (const f of readdirSync(accountsDir)) {
1468
- if (!f.endsWith(".json"))
1469
- continue;
1470
- const id = f.replace(/\.json$/, "");
1471
- if (indexedIds.length > 0 && !indexedIds.includes(id))
1472
- continue; // skip auxiliary files
1473
- if (id === "default")
1474
- continue; // always skip default alias
1475
- try {
1476
- const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
1477
- results.push({
1478
- accountId: id,
1479
- userId: data.userId,
1480
- savedAt: data.savedAt,
1481
- });
1482
- }
1483
- catch { /* skip */ }
814
+ catch {
815
+ return [];
1484
816
  }
1485
- return results;
1486
817
  }
1487
818
  export function getOpenclawConfigPath(instanceId) {
1488
- 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");
1489
825
  }
1490
826
  export function getLegacyOpenclawConfigPath(instanceId) {
1491
- 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");
1492
833
  }
1493
834
  export function getInstanceRuntime(instanceId) {
1494
835
  const meta = getInstance(instanceId);
@@ -1498,11 +839,28 @@ export function getInstanceRuntime(instanceId) {
1498
839
  }
1499
840
  export function getRuntimeEnvFiles(instanceId) {
1500
841
  const runtime = getInstanceRuntime(instanceId);
1501
- 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);
1502
848
  return envFiles.length ? envFiles : [defaultModelEnvFile(instanceId)];
1503
849
  }
1504
850
  export function getGatewayPort(instanceId) {
1505
- 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
+ }
1506
864
  }
1507
865
  /**
1508
866
  * Detect the host address where the gateway port is actually listening.
@@ -1517,6 +875,47 @@ export function getGatewayPort(instanceId) {
1517
875
  */
1518
876
  const _gwHostCache = new Map();
1519
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
+ }
1520
919
  export async function getGatewayHost(instanceId) {
1521
920
  const cached = _gwHostCache.get(instanceId);
1522
921
  if (cached && Date.now() - cached.ts < GW_HOST_CACHE_TTL)
@@ -1527,7 +926,22 @@ export async function getGatewayHost(instanceId) {
1527
926
  const { getNomadDriver } = await import("../config.js");
1528
927
  if (getNomadDriver() === "docker") {
1529
928
  const { getNomadAddr, getNomadToken } = await import("../config.js");
1530
- 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}`;
1531
945
  const headers = { "Content-Type": "application/json" };
1532
946
  const token = getNomadToken();
1533
947
  if (token)
@@ -1559,13 +973,22 @@ export async function getGatewayHost(instanceId) {
1559
973
  // (the default on most modern Linux distros). Reading it from
1560
974
  // here is the authoritative source and matches what nomad
1561
975
  // configures the docker-proxy bind to use.
1562
- const taskNets = d?.AllocatedResources?.Tasks?.gateway?.Networks ?? [];
1563
- const net = taskNets.find((n) => {
1564
- const rps = n?.ReservedPorts ?? [];
1565
- return rps.some((p) => p.Label === "gateway");
1566
- });
1567
- if (net?.IP && net.IP !== "0.0.0.0") {
1568
- result = net.IP;
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
+ }
1569
992
  }
1570
993
  }
1571
994
  }
@@ -1580,22 +1003,7 @@ export async function getGatewayHost(instanceId) {
1580
1003
  }
1581
1004
  }
1582
1005
  catch { /* fall through */ }
1583
- try {
1584
- const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1585
- for (const line of out.split("\n")) {
1586
- // IPv4 dotted-quad: "... 127.0.0.1:18789 ..."
1587
- // IPv6 bracketed: "... [::1]:18789 ..."
1588
- let match = line.match(/\s([\d.]+):(\d+)\s/);
1589
- if (!match)
1590
- match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
1591
- if (match && match[2] === String(port)) {
1592
- const addr = match[1];
1593
- result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
1594
- break;
1595
- }
1596
- }
1597
- }
1598
- catch { /* fall through */ }
1006
+ result = getListeningHostForPort(port);
1599
1007
  _gwHostCache.set(instanceId, { host: result, ts: Date.now() });
1600
1008
  return result;
1601
1009
  }
@@ -1610,11 +1018,17 @@ export function urlHost(host) {
1610
1018
  return host.includes(":") ? `[${host}]` : host;
1611
1019
  }
1612
1020
  export function findInstancesSharingOpenclawHome(instanceId) {
1613
- const targetHome = normalizePath(getOpenclawHome(instanceId));
1614
- return listInstances()
1615
- .filter((inst) => inst.id !== instanceId)
1616
- .filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
1617
- .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
+ }
1618
1032
  }
1619
1033
  /**
1620
1034
  * Re-pick a gateway port for an existing instance and rewrite its persisted
@@ -1631,23 +1045,23 @@ export async function reallocateGatewayPort(instanceId) {
1631
1045
  const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
1632
1046
  if (!meta)
1633
1047
  throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
1634
- const fromPort = extractGatewayPort(meta.runtime) ?? DEFAULT_GATEWAY_PORT;
1635
- const alloc = await allocateGatewayPort(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);
1636
1059
  try {
1637
1060
  const runtime = (meta.runtime ?? {});
1638
- const args = Array.isArray(runtime.args) ? [...runtime.args] : [];
1639
- for (let i = 0; i < args.length; i++) {
1640
- if (args[i] === "--port" && i + 1 < args.length) {
1641
- args[i + 1] = String(alloc.port);
1642
- }
1643
- else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
1644
- args[i] = `--port=${alloc.port}`;
1645
- }
1061
+ // Delegate the kind-specific runtime rewrite to the adapter.
1062
+ if (typeof adapter.reallocateRuntimePort === "function") {
1063
+ adapter.reallocateRuntimePort(runtime, alloc.port);
1646
1064
  }
1647
- runtime.args = args;
1648
- const env = (runtime.env ?? {});
1649
- env.OPENCLAW_GATEWAY_PORT = String(alloc.port);
1650
- runtime.env = env;
1651
1065
  meta.runtime = runtime;
1652
1066
  safeWriteJson(instanceMetaPath(instanceId), meta);
1653
1067
  chownToServiceUser(instanceMetaPath(instanceId));
@@ -1677,15 +1091,34 @@ export function getRuntimeEnv(instanceId) {
1677
1091
  }
1678
1092
  return env;
1679
1093
  }
1680
- // Re-export instanceDir for nomad-manager
1094
+ // Re-export instanceDir for nomad-manager under its getInstanceDir alias.
1681
1095
  export { instanceDir as getInstanceDir };
1682
- function resolveExistingConfigPath(instanceId) {
1683
- const runtimePath = openclawConfigPathInternal(instanceId);
1684
- if (existsSync(runtimePath))
1685
- return runtimePath;
1686
- const legacyPath = legacyOpenclawConfigPath(instanceId);
1687
- if (existsSync(legacyPath))
1688
- return legacyPath;
1689
- 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);
1690
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).
1691
1124
  //# sourceMappingURL=instance-manager.js.map