jishushell 0.4.2 → 0.4.17

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 (136) hide show
  1. package/Dockerfile.openclaw-slim +58 -0
  2. package/INSTALL-NOTICE +45 -0
  3. package/dist/auth.js +3 -3
  4. package/dist/auth.js.map +1 -1
  5. package/dist/cli/app.d.ts +3 -0
  6. package/dist/cli/app.js +156 -0
  7. package/dist/cli/app.js.map +1 -0
  8. package/dist/{doctor.d.ts → cli/doctor.d.ts} +6 -1
  9. package/dist/{doctor.js → cli/doctor.js} +389 -27
  10. package/dist/cli/doctor.js.map +1 -0
  11. package/dist/cli/helpers.d.ts +4 -0
  12. package/dist/cli/helpers.js +32 -0
  13. package/dist/cli/helpers.js.map +1 -0
  14. package/dist/cli/job.d.ts +3 -0
  15. package/dist/cli/job.js +260 -0
  16. package/dist/cli/job.js.map +1 -0
  17. package/dist/cli/llm.d.ts +24 -0
  18. package/dist/cli/llm.js +593 -0
  19. package/dist/cli/llm.js.map +1 -0
  20. package/dist/cli/openclaw.d.ts +12 -0
  21. package/dist/cli/openclaw.js +156 -0
  22. package/dist/cli/openclaw.js.map +1 -0
  23. package/dist/cli/panel.d.ts +25 -0
  24. package/dist/cli/panel.js +734 -0
  25. package/dist/cli/panel.js.map +1 -0
  26. package/dist/cli.js +476 -219
  27. package/dist/cli.js.map +1 -1
  28. package/dist/config.d.ts +22 -4
  29. package/dist/config.js +96 -55
  30. package/dist/config.js.map +1 -1
  31. package/dist/control.d.ts +13 -41
  32. package/dist/control.js +12 -1355
  33. package/dist/control.js.map +1 -1
  34. package/dist/install.d.ts +1 -1
  35. package/dist/install.js +15 -29
  36. package/dist/install.js.map +1 -1
  37. package/dist/routes/apps.d.ts +3 -0
  38. package/dist/routes/apps.js +99 -0
  39. package/dist/routes/apps.js.map +1 -0
  40. package/dist/routes/backup.d.ts +2 -0
  41. package/dist/routes/backup.js +370 -0
  42. package/dist/routes/backup.js.map +1 -0
  43. package/dist/routes/instances.d.ts +1 -0
  44. package/dist/routes/instances.js +61 -15
  45. package/dist/routes/instances.js.map +1 -1
  46. package/dist/routes/llm.d.ts +15 -0
  47. package/dist/routes/llm.js +246 -0
  48. package/dist/routes/llm.js.map +1 -0
  49. package/dist/routes/setup.js +32 -7
  50. package/dist/routes/setup.js.map +1 -1
  51. package/dist/routes/system.js +31 -6
  52. package/dist/routes/system.js.map +1 -1
  53. package/dist/server.js +69 -5
  54. package/dist/server.js.map +1 -1
  55. package/dist/services/app-compiler.d.ts +15 -0
  56. package/dist/services/app-compiler.js +169 -0
  57. package/dist/services/app-compiler.js.map +1 -0
  58. package/dist/services/app-manager.d.ts +17 -0
  59. package/dist/services/app-manager.js +168 -0
  60. package/dist/services/app-manager.js.map +1 -0
  61. package/dist/services/backup-manager.d.ts +253 -0
  62. package/dist/services/backup-manager.js +2014 -0
  63. package/dist/services/backup-manager.js.map +1 -0
  64. package/dist/services/backup-verify.d.ts +26 -0
  65. package/dist/services/backup-verify.js +240 -0
  66. package/dist/services/backup-verify.js.map +1 -0
  67. package/dist/services/instance-manager.d.ts +73 -5
  68. package/dist/services/instance-manager.js +446 -74
  69. package/dist/services/instance-manager.js.map +1 -1
  70. package/dist/services/job-manager.d.ts +22 -0
  71. package/dist/services/job-manager.js +102 -0
  72. package/dist/services/job-manager.js.map +1 -0
  73. package/dist/services/llm-proxy/adapters.js +5 -1
  74. package/dist/services/llm-proxy/adapters.js.map +1 -1
  75. package/dist/services/llm-proxy/index.d.ts +30 -0
  76. package/dist/services/llm-proxy/index.js +71 -1
  77. package/dist/services/llm-proxy/index.js.map +1 -1
  78. package/dist/services/llm-proxy/ssrf.js +1 -1
  79. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  80. package/dist/services/nomad-manager.js +263 -159
  81. package/dist/services/nomad-manager.js.map +1 -1
  82. package/dist/services/panel-manager.d.ts +40 -0
  83. package/dist/services/panel-manager.js +346 -0
  84. package/dist/services/panel-manager.js.map +1 -0
  85. package/dist/services/process-manager.js +24 -10
  86. package/dist/services/process-manager.js.map +1 -1
  87. package/dist/services/setup-manager.d.ts +4 -2
  88. package/dist/services/setup-manager.js +578 -154
  89. package/dist/services/setup-manager.js.map +1 -1
  90. package/dist/services/telemetry/activation.js +10 -7
  91. package/dist/services/telemetry/activation.js.map +1 -1
  92. package/dist/services/telemetry/client.js +7 -18
  93. package/dist/services/telemetry/client.js.map +1 -1
  94. package/dist/services/telemetry/heartbeat.js +12 -6
  95. package/dist/services/telemetry/heartbeat.js.map +1 -1
  96. package/dist/services/update-manager.d.ts +47 -0
  97. package/dist/services/update-manager.js +305 -0
  98. package/dist/services/update-manager.js.map +1 -0
  99. package/dist/types.d.ts +62 -0
  100. package/dist/utils/fs.d.ts +85 -0
  101. package/dist/utils/fs.js +111 -0
  102. package/dist/utils/fs.js.map +1 -0
  103. package/dist/utils/safe-json.d.ts +2 -0
  104. package/dist/utils/safe-json.js +22 -16
  105. package/dist/utils/safe-json.js.map +1 -1
  106. package/install/jishu-install.sh +582 -138
  107. package/install/jishu-uninstall.sh +276 -391
  108. package/install/post-install.sh +85 -3
  109. package/openclaw-entry.sh +15 -0
  110. package/package.json +12 -5
  111. package/public/assets/Dashboard-CQsp1Mr9.js +1 -0
  112. package/public/assets/InitPassword-BEC8SE4A.js +1 -0
  113. package/public/assets/InstanceDetail-B5wTgNEg.js +17 -0
  114. package/public/assets/{Login-RkjzTNWg.js → Login-D1Bt-Lyk.js} +1 -1
  115. package/public/assets/NewInstance-GQzm3K9D.js +1 -0
  116. package/public/assets/Settings-ByjGlqhP.js +1 -0
  117. package/public/assets/Setup-cMF21Y-8.js +1 -0
  118. package/public/assets/index-B6qQP4mH.css +1 -0
  119. package/public/assets/index-BuTQtuNy.js +16 -0
  120. package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
  121. package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
  122. package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
  123. package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
  124. package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
  125. package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
  126. package/public/index.html +4 -4
  127. package/dist/doctor.js.map +0 -1
  128. package/public/assets/Dashboard-CAOQDYDR.js +0 -1
  129. package/public/assets/InitPassword-CkehIkJG.js +0 -1
  130. package/public/assets/InstanceDetail-CzW2S95J.js +0 -14
  131. package/public/assets/NewInstance-DdbErdjA.js +0 -1
  132. package/public/assets/Settings-BUD7zwv9.js +0 -1
  133. package/public/assets/Setup-RRTIERGG.js +0 -1
  134. package/public/assets/index-77Ug7feY.css +0 -1
  135. package/public/assets/index-DfRnVUQR.js +0 -16
  136. package/public/assets/vendor-react-DONn7uBV.js +0 -59
@@ -1,12 +1,15 @@
1
1
  import { execFile, execFileSync } from "child_process";
2
2
  import { randomBytes } from "crypto";
3
- import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
3
+ import { chmodSync, chownSync, closeSync, copyFileSync, cpSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
4
+ import { rm as rmAsync } from "fs/promises";
4
5
  import { createServer as netCreateServer } from "net";
5
6
  import { userInfo } from "os";
6
7
  import { dirname, join, resolve } from "path";
7
- import { INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
8
+ import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
8
9
  import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
9
10
  import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
11
+ import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
12
+ import { compileTaskRuntime } from "./app-compiler.js";
10
13
  const _configChangeListeners = [];
11
14
  export function onConfigChange(listener) {
12
15
  _configChangeListeners.push(listener);
@@ -118,7 +121,16 @@ function safePort(port) {
118
121
  throw new Error(`Invalid port: ${port}`);
119
122
  return String(port);
120
123
  }
121
- function isPortInUse(port) {
124
+ /**
125
+ * Probes whether a port is currently held by any process on the host.
126
+ *
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.
132
+ */
133
+ export function isPortInUse(port) {
122
134
  if (!Number.isInteger(port) || port < 1 || port > 65535)
123
135
  return Promise.resolve(false);
124
136
  return new Promise((resolve) => {
@@ -130,13 +142,25 @@ function isPortInUse(port) {
130
142
  server.listen(port, "0.0.0.0");
131
143
  });
132
144
  }
133
- async function defaultGatewayPort(instanceId) {
145
+ /**
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.
150
+ *
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.
154
+ */
155
+ async function allocateGatewayPort(instanceId) {
134
156
  const used = usedGatewayPorts(instanceId);
157
+ const skipped = [];
135
158
  let port = DEFAULT_GATEWAY_PORT;
136
159
  while (true) {
137
160
  if (port > 65535)
138
161
  throw new Error("No available gateway port found (all ports 18789-65535 in use)");
139
162
  if (used.has(port) || _pendingPorts.has(port)) {
163
+ skipped.push(port);
140
164
  port++;
141
165
  continue;
142
166
  }
@@ -146,16 +170,18 @@ async function defaultGatewayPort(instanceId) {
146
170
  try {
147
171
  if (await isPortInUse(port)) {
148
172
  _pendingPorts.delete(port);
173
+ skipped.push(port);
149
174
  port++;
150
175
  continue;
151
176
  }
152
- return port;
177
+ return { port, skipped };
153
178
  }
154
179
  catch {
155
180
  _pendingPorts.delete(port);
156
181
  // Skip this port on a transient OS error rather than failing the entire
157
182
  // allocation — a single bad port check should not prevent instance creation.
158
183
  console.warn(`[instance] Port ${port} availability check failed, trying next port`);
184
+ skipped.push(port);
159
185
  port++;
160
186
  continue;
161
187
  }
@@ -242,8 +268,7 @@ function chownToServiceUser(...paths) {
242
268
  }
243
269
  }
244
270
  }
245
- async function defaultRuntime(instanceId, openclawHome) {
246
- const port = await defaultGatewayPort(instanceId);
271
+ function buildDefaultRuntime(instanceId, port, openclawHome) {
247
272
  const home = openclawHome || defaultOpenclawHome(instanceId);
248
273
  return {
249
274
  command: resolveOpenclawBin(),
@@ -357,7 +382,7 @@ function quoteEnvValue(value) {
357
382
  return JSON.stringify(value);
358
383
  }
359
384
  export function updateEnvFile(path, updates) {
360
- mkdirSync(dirname(path), { recursive: true });
385
+ ensureDirContainer(dirname(path));
361
386
  const existing = existsSync(path) ? readFileSync(path, "utf-8").split("\n") : [];
362
387
  const remaining = { ...updates };
363
388
  const newLines = [];
@@ -389,7 +414,7 @@ export function updateEnvFile(path, updates) {
389
414
  const content = output ? output + "\n" : "";
390
415
  // Atomic write: tmp then rename to protect against RPi power loss
391
416
  const tmp = path + ".tmp";
392
- writeFileSync(tmp, content, { mode: 0o600 });
417
+ writeSecretFile(tmp, content);
393
418
  renameSync(tmp, path);
394
419
  }
395
420
  // ── Provider key helpers ──
@@ -536,6 +561,40 @@ export const CHANNEL_PLUGIN_MAP = {
536
561
  // Official vendor plugins — need install (not bundled)
537
562
  "openclaw-weixin": "@tencent-weixin/openclaw-weixin",
538
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
+ }
539
598
  /** Check if a channel plugin is installed for an instance. */
540
599
  export function isChannelPluginInstalled(instanceId, channelId) {
541
600
  const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
@@ -546,8 +605,9 @@ export function isChannelPluginInstalled(instanceId, channelId) {
546
605
  || (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
547
606
  }
548
607
  /**
549
- * Install a single channel plugin on the host.
550
- * This is the unified entry point for all plugin installation — never use docker exec.
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.
551
611
  */
552
612
  export async function installChannelPlugin(instanceId, channelId) {
553
613
  const pkg = CHANNEL_PLUGIN_MAP[channelId];
@@ -556,14 +616,17 @@ export async function installChannelPlugin(instanceId, channelId) {
556
616
  if (isChannelPluginInstalled(instanceId, channelId))
557
617
  return;
558
618
  const openclawHome = getOpenclawHomeInternal(instanceId);
559
- const openclawBin = resolveOpenclawBin();
560
619
  const extensionsDir = getChannelExtensionsDir(instanceId);
561
- // Ensure the node binary that runs jishushell is on PATH for the child process.
562
- // When running under systemd, PATH may not include the nvm node directory, which
563
- // causes the `#!/usr/bin/env node` shebang in the openclaw CLI to fail.
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
564
628
  const nodeBinDir = dirname(process.execPath);
565
629
  const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
566
- // Pass through proxy and TLS env vars so fetch works under systemd / restricted environments.
567
630
  const proxyEnvKeys = [
568
631
  "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
569
632
  "no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
@@ -588,12 +651,9 @@ export async function installChannelPlugin(instanceId, channelId) {
588
651
  env: childEnv,
589
652
  timeout: 300_000,
590
653
  }, (err, stdout, stderr) => {
591
- // openclaw plugins install may exit non-zero due to "plugins.allow is empty" warning
592
- // even though the plugin was installed successfully. Check actual directory.
593
654
  if (err && !isChannelPluginInstalled(instanceId, channelId)) {
594
655
  const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
595
656
  console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
596
- // Clean up leftover stage directories
597
657
  try {
598
658
  if (existsSync(extensionsDir)) {
599
659
  for (const entry of readdirSync(extensionsDir)) {
@@ -604,7 +664,7 @@ export async function installChannelPlugin(instanceId, channelId) {
604
664
  }
605
665
  }
606
666
  }
607
- catch { /* best effort */ }
667
+ catch (_) { }
608
668
  reject(new Error(msg));
609
669
  }
610
670
  else {
@@ -621,6 +681,20 @@ export async function installChannelPlugin(instanceId, channelId) {
621
681
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
622
682
  try {
623
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);
624
698
  return;
625
699
  }
626
700
  catch (err) {
@@ -637,6 +711,67 @@ export async function installChannelPlugin(instanceId, channelId) {
637
711
  }
638
712
  throw lastErr;
639
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
+ }
640
775
  function getChannelExtensionsDir(instanceId) {
641
776
  return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
642
777
  }
@@ -644,6 +779,26 @@ function getStockExtensionsDir() {
644
779
  return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
645
780
  }
646
781
  // ── Public API ──
782
+ /**
783
+ * Probe whether a file is readable by the current process. Used to
784
+ * distinguish "primary missing / corrupted" (recoverable via safeReadJson's
785
+ * .bak chain) from "primary exists but permission denied" (EACCES — the
786
+ * common sudo-script footgun that leaves root-owned files). safeReadJson
787
+ * swallows every read error internally and returns null, so without this
788
+ * probe an unreadable primary looks identical to a truly-gone instance.
789
+ */
790
+ function probeReadable(path) {
791
+ if (!existsSync(path))
792
+ return null;
793
+ try {
794
+ const fd = openSync(path, "r");
795
+ closeSync(fd);
796
+ return null;
797
+ }
798
+ catch (e) {
799
+ return e;
800
+ }
801
+ }
647
802
  export function listInstances() {
648
803
  if (!existsSync(INSTANCES_DIR))
649
804
  return [];
@@ -653,14 +808,33 @@ export function listInstances() {
653
808
  const metaPath = join(INSTANCES_DIR, name, "instance.json");
654
809
  const dirPath = join(INSTANCES_DIR, name);
655
810
  try {
656
- if (statSync(dirPath).isDirectory() && existsSync(metaPath)) {
657
- instances.push(JSON.parse(readFileSync(metaPath, "utf-8")));
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);
821
+ continue;
822
+ }
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}`);
658
832
  }
659
833
  }
660
834
  catch (e) {
661
- // Permission errors (EACCES) caused by root-owned files are a common deployment
662
- // mistake (e.g. running a maintenance script as sudo). Log clearly instead of
663
- // silently dropping the instance from the list.
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.
664
838
  console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
665
839
  }
666
840
  }
@@ -668,17 +842,26 @@ export function listInstances() {
668
842
  }
669
843
  export function getInstance(instanceId) {
670
844
  const metaPath = instanceMetaPath(instanceId);
671
- if (!existsSync(metaPath))
672
- return null;
673
- try {
674
- return JSON.parse(readFileSync(metaPath, "utf-8"));
675
- }
676
- catch (e) {
677
- // Surface permission errors clearly (EACCES: instance.json owned by root after a sudo script)
678
- throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
845
+ // Go through safeReadJson so primary missing/corrupted instance.json
846
+ // is still served from the .bak chain. Returning null on "truly gone"
847
+ // (no primary, no backups) keeps the existing 404 behavior intact.
848
+ const meta = safeReadJson(metaPath, `instance:${instanceId}`);
849
+ if (meta)
850
+ return meta;
851
+ // safeReadJson swallows every read error internally, which is exactly
852
+ // wrong for the EACCES case a root-owned primary would silently
853
+ // return null and callers would report "Instance not found" instead
854
+ // of the actionable "check file ownership" message. Re-probe to
855
+ // distinguish and throw on permission denial. Missing/corrupted with
856
+ // no backup still returns null (→ 404 upstream).
857
+ const readErr = probeReadable(metaPath);
858
+ if (readErr && readErr.code === "EACCES") {
859
+ throw new Error(`Cannot read instance '${instanceId}' metadata: ${readErr.message}. ` +
860
+ `Check file ownership with: ls -la ${metaPath}`);
679
861
  }
862
+ return null;
680
863
  }
681
- export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome) {
864
+ export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, appSpec, cloneOptions) {
682
865
  const d = instanceDir(instanceId);
683
866
  if (existsSync(d))
684
867
  throw new Error(`Instance '${instanceId}' already exists`);
@@ -717,21 +900,27 @@ export async function createInstance(instanceId, name, description = "", cloneFr
717
900
  // readdirSync failed — directory might not be readable, proceed cautiously
718
901
  }
719
902
  }
720
- mkdirSync(d, { recursive: true, mode: 0o750 });
721
- chmodSync(d, 0o750);
903
+ ensureDirContainer(d);
722
904
  // Inherit group from INSTANCES_DIR so both root and the real user can access
723
905
  try {
724
906
  const parentGid = statSync(INSTANCES_DIR).gid;
725
907
  chownSync(d, -1, parentGid);
726
908
  }
727
909
  catch { /* non-root without CAP_CHOWN — already correct owner */ }
728
- mkdirSync(home, { recursive: true, mode: 0o750 });
729
- chmodSync(home, 0o750);
730
- mkdirSync(join(home, OPENCLAW_STATE_DIRNAME), { recursive: true, mode: 0o750 });
731
- chmodSync(join(home, OPENCLAW_STATE_DIRNAME), 0o750);
732
- const runtime = await defaultRuntime(instanceId, home);
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
+ }
733
922
  const allocatedPort = extractGatewayPort(runtime);
734
- // Port already reserved inside defaultGatewayPort; just track for cleanup
923
+ // Port already reserved inside allocateGatewayPort; just track for cleanup
735
924
  try {
736
925
  const meta = {
737
926
  id: instanceId,
@@ -740,13 +929,13 @@ export async function createInstance(instanceId, name, description = "", cloneFr
740
929
  openclaw_home: home,
741
930
  runtime,
742
931
  created_at: new Date().toISOString(),
932
+ ...(appSpec ? { app_id: appSpec.id } : {}),
743
933
  };
744
934
  safeWriteJson(instanceMetaPath(instanceId), meta);
745
935
  const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
746
936
  for (const ef of envFiles) {
747
- mkdirSync(dirname(ef), { recursive: true });
748
937
  if (!existsSync(ef))
749
- writeFileSync(ef, "", { mode: 0o600 });
938
+ writeConfigFile(ef, "");
750
939
  }
751
940
  // After writing env files, ensure the runtime user can read them
752
941
  try {
@@ -759,7 +948,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
759
948
  }
760
949
  catch { /* ignore - same user or no permission to chown */ }
761
950
  const configPath = openclawConfigPathInternal(instanceId);
762
- mkdirSync(dirname(configPath), { recursive: true });
951
+ ensureDirContainer(dirname(configPath));
763
952
  if (cloneFrom && !existsSync(configPath)) {
764
953
  const srcConfig = resolveExistingConfigPath(cloneFrom);
765
954
  if (existsSync(srcConfig)) {
@@ -782,12 +971,26 @@ export async function createInstance(instanceId, name, description = "", cloneFr
782
971
  if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
783
972
  delete cloned.agents.defaults.model;
784
973
  }
785
- // Strip IM channel configs same channel cannot serve multiple instances
786
- if (cloned?.channels)
787
- delete cloned.channels;
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);
788
978
  // Copy extensions directory so plugin references in config remain valid
789
979
  // Copy workspace directory to preserve agent personality (.md files)
790
- for (const subdir of ["extensions", "workspace", "skills"]) {
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) {
791
994
  const srcDir = join(dirname(srcConfig), subdir);
792
995
  const dstDir = join(dirname(configPath), subdir);
793
996
  if (existsSync(srcDir) && !existsSync(dstDir)) {
@@ -797,7 +1000,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
797
1000
  catch { /* best effort */ }
798
1001
  }
799
1002
  }
800
- writeFileSync(configPath, JSON.stringify(cloned, null, 2), { mode: 0o600 });
1003
+ writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
801
1004
  // Copy x-jishushell upstream metadata from source instance.json
802
1005
  // (saveConfig stores x-jishushell in instance.json, not openclaw.json)
803
1006
  const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
@@ -813,7 +1016,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
813
1016
  if (existsSync(metaPath)) {
814
1017
  const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
815
1018
  dstMeta["x-jishushell"] = dstXj;
816
- writeFileSync(metaPath, JSON.stringify(dstMeta, null, 2));
1019
+ writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
817
1020
  }
818
1021
  }
819
1022
  }
@@ -827,7 +1030,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
827
1030
  }
828
1031
  }
829
1032
  if (!existsSync(configPath)) {
830
- writeFileSync(configPath, JSON.stringify(starterConfig(), null, 2), { mode: 0o600 });
1033
+ writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
831
1034
  // Inject default provider API key from setup into both env files
832
1035
  const dp = getPanelConfig().default_provider;
833
1036
  if (dp?.apiKey && dp?.providerId && envFiles.length) {
@@ -838,6 +1041,34 @@ export async function createInstance(instanceId, name, description = "", cloneFr
838
1041
  updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
839
1042
  }
840
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
+ }
841
1072
  // Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
842
1073
  if (cloneFrom && envFiles.length) {
843
1074
  const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
@@ -876,6 +1107,17 @@ export async function createInstance(instanceId, name, description = "", cloneFr
876
1107
  console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
877
1108
  }
878
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
+ }
879
1121
  return meta;
880
1122
  }
881
1123
  finally {
@@ -895,11 +1137,28 @@ export function updateInstance(instanceId, name, description) {
895
1137
  chownToServiceUser(instanceMetaPath(instanceId));
896
1138
  return meta;
897
1139
  }
898
- export function deleteInstance(instanceId) {
1140
+ /** Update instance.json metadata fields (shallow merge at top level). */
1141
+ export function updateInstanceMeta(instanceId, patch) {
1142
+ const metaPath = instanceMetaPath(instanceId);
1143
+ const meta = safeReadJson(metaPath, "instance-meta") || {};
1144
+ Object.assign(meta, patch);
1145
+ safeWriteJson(metaPath, meta);
1146
+ }
1147
+ export async function deleteInstance(instanceId, purgeBackups = false) {
899
1148
  const d = instanceDir(instanceId);
900
1149
  if (!existsSync(d))
901
1150
  return { ok: false, warnings: ["Instance directory not found"] };
902
1151
  const warnings = [];
1152
+ // Cancel auto-backup timer and any queued jobs for this instance
1153
+ import("./backup-manager.js").then(({ cancelAutoBackup, getQueueStatus, cancelJob }) => {
1154
+ cancelAutoBackup(instanceId);
1155
+ // Cancel queued (not yet running) jobs for this instance
1156
+ const q = getQueueStatus();
1157
+ for (const job of q.queued) {
1158
+ if (job.instanceId === instanceId)
1159
+ cancelJob(job.id);
1160
+ }
1161
+ }).catch(() => { });
903
1162
  // Cache metadata BEFORE deletion so we can check custom openclaw_home after rm
904
1163
  const meta = getInstance(instanceId);
905
1164
  const home = meta?.openclaw_home;
@@ -911,14 +1170,18 @@ export function deleteInstance(instanceId) {
911
1170
  }).catch((e) => {
912
1171
  console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
913
1172
  });
1173
+ // Async rm so the Node event loop stays responsive during large deletes:
1174
+ // a fresh instance with a just-installed openclaw package can be 1+ GB
1175
+ // with hundreds of nested dirs, which takes 30-60s to unlink on SD storage.
1176
+ // rmSync would block every other HTTP request for that whole window.
914
1177
  let dirDeleted = false;
915
1178
  try {
916
- rmSync(d, { recursive: true, force: true });
1179
+ await rmAsync(d, { recursive: true, force: true });
917
1180
  dirDeleted = true;
918
1181
  }
919
1182
  catch {
920
1183
  try {
921
- execFileSync("sudo", ["rm", "-rf", d], { timeout: 10000 });
1184
+ execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
922
1185
  dirDeleted = true;
923
1186
  }
924
1187
  catch (e) {
@@ -929,6 +1192,22 @@ export function deleteInstance(instanceId) {
929
1192
  if (home && !home.startsWith(d) && existsSync(home)) {
930
1193
  warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
931
1194
  }
1195
+ // Handle backups (stored in separate directory, not affected by the
1196
+ // instance rm above). Backups can be hundreds of MB each and accumulate
1197
+ // across retention windows, so use the same async rm path to keep the
1198
+ // event loop responsive.
1199
+ const backupDir = join(BACKUPS_DIR, instanceId);
1200
+ if (purgeBackups && existsSync(backupDir)) {
1201
+ try {
1202
+ await rmAsync(backupDir, { recursive: true, force: true });
1203
+ }
1204
+ catch (e) {
1205
+ warnings.push(`Failed to delete backups: ${e.message}`);
1206
+ }
1207
+ }
1208
+ else if (existsSync(backupDir)) {
1209
+ warnings.push(`Backups preserved at ${backupDir}`);
1210
+ }
932
1211
  return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
933
1212
  }
934
1213
  export function getConfig(instanceId) {
@@ -958,13 +1237,7 @@ export function saveConfig(instanceId, config) {
958
1237
  return false;
959
1238
  if (!existsSync(configPath)) {
960
1239
  const legacyPath = legacyOpenclawConfigPath(instanceId);
961
- mkdirSync(dirname(configPath), { recursive: true });
962
- // Ensure the state dir is group-writable so the Docker container (which may
963
- // run as a different UID) can create sub-directories via shared group.
964
- try {
965
- chmodSync(dirname(configPath), 0o770);
966
- }
967
- catch { /* best effort */ }
1240
+ ensureDirContainer(dirname(configPath));
968
1241
  if (existsSync(legacyPath))
969
1242
  copyFileSync(legacyPath, configPath);
970
1243
  }
@@ -1000,9 +1273,10 @@ export function saveConfig(instanceId, config) {
1000
1273
  configToWrite.plugins.entries.feishu = { enabled: false };
1001
1274
  }
1002
1275
  }
1003
- // Preserve plugins.installs from existing config on disk —
1004
- // these are managed by `openclaw plugins install` and must not be overwritten
1005
- // by the frontend config which doesn't track them.
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.
1006
1280
  if (existsSync(configPath)) {
1007
1281
  try {
1008
1282
  const existing = JSON.parse(readFileSync(configPath, "utf-8"));
@@ -1010,6 +1284,28 @@ export function saveConfig(instanceId, config) {
1010
1284
  configToWrite.plugins ??= {};
1011
1285
  configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
1012
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
+ }
1013
1309
  }
1014
1310
  catch { /* best effort */ }
1015
1311
  }
@@ -1018,18 +1314,18 @@ export function saveConfig(instanceId, config) {
1018
1314
  copyFileSync(configPath, configPath + ".bak");
1019
1315
  }
1020
1316
  const configJson = JSON.stringify(configToWrite, null, 2);
1021
- mkdirSync(dirname(configPath), { recursive: true });
1022
- writeFileSync(configPath + ".tmp", configJson, { mode: 0o600 });
1317
+ ensureDirContainer(dirname(configPath));
1318
+ writeConfigFile(configPath + ".tmp", configJson);
1023
1319
  // Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
1024
1320
  JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
1025
1321
  renameSync(configPath + ".tmp", configPath);
1026
1322
  chownToServiceUser(configPath);
1027
- // also write to legacy path (with restricted permissions)
1323
+ // also write to legacy path
1028
1324
  const legacyPath = legacyOpenclawConfigPath(instanceId);
1029
1325
  if (existsSync(legacyPath)) {
1030
1326
  copyFileSync(legacyPath, legacyPath + ".bak");
1031
1327
  }
1032
- writeFileSync(legacyPath + ".tmp", configJson, { mode: 0o600 });
1328
+ writeConfigFile(legacyPath + ".tmp", configJson);
1033
1329
  JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
1034
1330
  renameSync(legacyPath + ".tmp", legacyPath);
1035
1331
  chownToServiceUser(legacyPath);
@@ -1081,7 +1377,8 @@ export function saveFeishuCredentials(instanceId, creds) {
1081
1377
  appId: creds.appId,
1082
1378
  appSecret: creds.appSecret,
1083
1379
  domain: creds.domain,
1084
- dmPolicy: "pairing",
1380
+ dmPolicy: "open",
1381
+ allowFrom: ["*"],
1085
1382
  };
1086
1383
  safeWriteJson(configPath, config);
1087
1384
  chownToServiceUser(configPath);
@@ -1099,7 +1396,7 @@ export function saveWeixinCredentials(instanceId, creds) {
1099
1396
  const home = getOpenclawHomeInternal(instanceId);
1100
1397
  const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1101
1398
  const accountsDir = join(stateDir, "accounts");
1102
- mkdirSync(accountsDir, { recursive: true });
1399
+ ensureDirContainer(accountsDir);
1103
1400
  // Save account credentials file (via safeWriteJson for atomic + .bak protection)
1104
1401
  const credObj = {
1105
1402
  token: creds.token,
@@ -1247,11 +1544,30 @@ export async function getGatewayHost(instanceId) {
1247
1544
  const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
1248
1545
  if (detail.ok) {
1249
1546
  const d = await detail.json();
1250
- const ports = d?.AllocatedResources?.Shared?.Ports ?? [];
1251
- const gwPort = ports.find((p) => p.Label === "gateway");
1547
+ // Preferred source: AllocatedResources.Shared.Ports (bridge mode).
1548
+ const sharedPorts = d?.AllocatedResources?.Shared?.Ports ?? [];
1549
+ const gwPort = sharedPorts.find((p) => p.Label === "gateway");
1252
1550
  if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
1253
1551
  result = gwPort.HostIP;
1254
1552
  }
1553
+ else {
1554
+ // Host mode / task-level reservation: address lives under
1555
+ // AllocatedResources.Tasks.<task>.Networks[*].IP. On Nomad
1556
+ // 1.6.5 with `network_interface = "lo"`, the IP is whichever
1557
+ // address the OS enumerates first — which can be IPv6 `::1`
1558
+ // on systems where lo has both `127.0.0.1/8` and `::1/128`
1559
+ // (the default on most modern Linux distros). Reading it from
1560
+ // here is the authoritative source and matches what nomad
1561
+ // 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;
1569
+ }
1570
+ }
1255
1571
  }
1256
1572
  }
1257
1573
  }
@@ -1267,7 +1583,11 @@ export async function getGatewayHost(instanceId) {
1267
1583
  try {
1268
1584
  const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1269
1585
  for (const line of out.split("\n")) {
1270
- const match = line.match(/\s([\d.]+):(\d+)\s/);
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/);
1271
1591
  if (match && match[2] === String(port)) {
1272
1592
  const addr = match[1];
1273
1593
  result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
@@ -1279,6 +1599,16 @@ export async function getGatewayHost(instanceId) {
1279
1599
  _gwHostCache.set(instanceId, { host: result, ts: Date.now() });
1280
1600
  return result;
1281
1601
  }
1602
+ /**
1603
+ * Wrap an IPv6 literal in brackets for safe URL host-component / Host-header
1604
+ * use. Bare names ("gateway.local") and IPv4 ("127.0.0.1") contain no colon
1605
+ * and pass through unchanged; anything with a colon is an IPv6 literal and
1606
+ * MUST be bracketed before being concatenated with a port, otherwise
1607
+ * `http://::1:18789/` is unparseable.
1608
+ */
1609
+ export function urlHost(host) {
1610
+ return host.includes(":") ? `[${host}]` : host;
1611
+ }
1282
1612
  export function findInstancesSharingOpenclawHome(instanceId) {
1283
1613
  const targetHome = normalizePath(getOpenclawHome(instanceId));
1284
1614
  return listInstances()
@@ -1286,6 +1616,48 @@ export function findInstancesSharingOpenclawHome(instanceId) {
1286
1616
  .filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
1287
1617
  .map((inst) => inst.id);
1288
1618
  }
1619
+ /**
1620
+ * Re-pick a gateway port for an existing instance and rewrite its persisted
1621
+ * runtime metadata (`runtime.args` and `runtime.env.OPENCLAW_GATEWAY_PORT`).
1622
+ *
1623
+ * Used when {@link isPortInUse} reports that the previously-assigned port has
1624
+ * been taken by something else between create-time and start-time (e.g. a
1625
+ * host-side openclaw started by the user, an unrelated service that grabbed
1626
+ * the port at boot, or a Docker race on the next allocation). The Nomad job
1627
+ * spec is rebuilt from instance metadata on every submit, so updating
1628
+ * `instance.json` here is sufficient — no other files need patching.
1629
+ */
1630
+ export async function reallocateGatewayPort(instanceId) {
1631
+ const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
1632
+ if (!meta)
1633
+ 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);
1636
+ try {
1637
+ 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
+ }
1646
+ }
1647
+ runtime.args = args;
1648
+ const env = (runtime.env ?? {});
1649
+ env.OPENCLAW_GATEWAY_PORT = String(alloc.port);
1650
+ runtime.env = env;
1651
+ meta.runtime = runtime;
1652
+ safeWriteJson(instanceMetaPath(instanceId), meta);
1653
+ chownToServiceUser(instanceMetaPath(instanceId));
1654
+ console.log(`[instance] ${instanceId}: gateway port reallocated ${fromPort} -> ${alloc.port}`);
1655
+ return { from: fromPort, to: alloc.port, skipped: alloc.skipped };
1656
+ }
1657
+ finally {
1658
+ _pendingPorts.delete(alloc.port);
1659
+ }
1660
+ }
1289
1661
  export function findInstancesSharingGatewayPort(instanceId) {
1290
1662
  const targetPort = getGatewayPort(instanceId);
1291
1663
  return listInstances()