jishushell 0.4.10 → 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 (100) hide show
  1. package/INSTALL-NOTICE +10 -12
  2. package/dist/cli/app.d.ts +3 -0
  3. package/dist/cli/app.js +156 -0
  4. package/dist/cli/app.js.map +1 -0
  5. package/dist/{doctor.d.ts → cli/doctor.d.ts} +6 -1
  6. package/dist/{doctor.js → cli/doctor.js} +343 -14
  7. package/dist/cli/doctor.js.map +1 -0
  8. package/dist/cli/helpers.d.ts +4 -0
  9. package/dist/cli/helpers.js +32 -0
  10. package/dist/cli/helpers.js.map +1 -0
  11. package/dist/cli/job.d.ts +3 -0
  12. package/dist/cli/job.js +260 -0
  13. package/dist/cli/job.js.map +1 -0
  14. package/dist/cli/llm.d.ts +24 -0
  15. package/dist/cli/llm.js +593 -0
  16. package/dist/cli/llm.js.map +1 -0
  17. package/dist/cli/openclaw.d.ts +12 -0
  18. package/dist/cli/openclaw.js +156 -0
  19. package/dist/cli/openclaw.js.map +1 -0
  20. package/dist/cli/panel.d.ts +25 -0
  21. package/dist/cli/panel.js +734 -0
  22. package/dist/cli/panel.js.map +1 -0
  23. package/dist/cli.js +67 -326
  24. package/dist/cli.js.map +1 -1
  25. package/dist/config.d.ts +1 -0
  26. package/dist/config.js +11 -4
  27. package/dist/config.js.map +1 -1
  28. package/dist/control.d.ts +13 -41
  29. package/dist/control.js +12 -1355
  30. package/dist/control.js.map +1 -1
  31. package/dist/routes/apps.d.ts +3 -0
  32. package/dist/routes/apps.js +99 -0
  33. package/dist/routes/apps.js.map +1 -0
  34. package/dist/routes/instances.js +12 -6
  35. package/dist/routes/instances.js.map +1 -1
  36. package/dist/routes/llm.d.ts +15 -0
  37. package/dist/routes/llm.js +246 -0
  38. package/dist/routes/llm.js.map +1 -0
  39. package/dist/routes/setup.js +29 -2
  40. package/dist/routes/setup.js.map +1 -1
  41. package/dist/routes/system.js +31 -6
  42. package/dist/routes/system.js.map +1 -1
  43. package/dist/server.js +40 -4
  44. package/dist/server.js.map +1 -1
  45. package/dist/services/app-compiler.d.ts +15 -0
  46. package/dist/services/app-compiler.js +169 -0
  47. package/dist/services/app-compiler.js.map +1 -0
  48. package/dist/services/app-manager.d.ts +17 -0
  49. package/dist/services/app-manager.js +168 -0
  50. package/dist/services/app-manager.js.map +1 -0
  51. package/dist/services/instance-manager.d.ts +51 -3
  52. package/dist/services/instance-manager.js +233 -30
  53. package/dist/services/instance-manager.js.map +1 -1
  54. package/dist/services/job-manager.d.ts +22 -0
  55. package/dist/services/job-manager.js +102 -0
  56. package/dist/services/job-manager.js.map +1 -0
  57. package/dist/services/llm-proxy/adapters.js +5 -1
  58. package/dist/services/llm-proxy/adapters.js.map +1 -1
  59. package/dist/services/llm-proxy/index.d.ts +30 -0
  60. package/dist/services/llm-proxy/index.js +71 -1
  61. package/dist/services/llm-proxy/index.js.map +1 -1
  62. package/dist/services/llm-proxy/ssrf.js +1 -1
  63. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  64. package/dist/services/nomad-manager.js +192 -29
  65. package/dist/services/nomad-manager.js.map +1 -1
  66. package/dist/services/panel-manager.d.ts +40 -0
  67. package/dist/services/panel-manager.js +346 -0
  68. package/dist/services/panel-manager.js.map +1 -0
  69. package/dist/services/process-manager.js +20 -7
  70. package/dist/services/process-manager.js.map +1 -1
  71. package/dist/services/setup-manager.js +316 -31
  72. package/dist/services/setup-manager.js.map +1 -1
  73. package/dist/services/update-manager.d.ts +47 -0
  74. package/dist/services/update-manager.js +305 -0
  75. package/dist/services/update-manager.js.map +1 -0
  76. package/dist/types.d.ts +62 -0
  77. package/install/jishu-install.sh +279 -37
  78. package/install/post-install.sh +64 -5
  79. package/package.json +6 -2
  80. package/public/assets/Dashboard-CQsp1Mr9.js +1 -0
  81. package/public/assets/InitPassword-BEC8SE4A.js +1 -0
  82. package/public/assets/InstanceDetail-B5wTgNEg.js +17 -0
  83. package/public/assets/{Login-CUoEZOWR.js → Login-D1Bt-Lyk.js} +1 -1
  84. package/public/assets/NewInstance-GQzm3K9D.js +1 -0
  85. package/public/assets/Settings-ByjGlqhP.js +1 -0
  86. package/public/assets/Setup-cMF21Y-8.js +1 -0
  87. package/public/assets/index-B6qQP4mH.css +1 -0
  88. package/public/assets/index-BuTQtuNy.js +16 -0
  89. package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
  90. package/public/index.html +2 -2
  91. package/dist/doctor.js.map +0 -1
  92. package/install/jishu-install-china.sh +0 -3092
  93. package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
  94. package/public/assets/InitPassword-BjubiVdd.js +0 -1
  95. package/public/assets/InstanceDetail-DMcywsof.js +0 -17
  96. package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
  97. package/public/assets/Settings-D5tHL_h5.js +0 -1
  98. package/public/assets/Setup-4t6E3Rut.js +0 -1
  99. package/public/assets/index-BJ47MWpF.css +0 -1
  100. package/public/assets/index-DbX85irc.js +0 -16
@@ -1,6 +1,7 @@
1
1
  import { execFile, execFileSync } from "child_process";
2
2
  import { randomBytes } from "crypto";
3
- import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } 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";
@@ -8,6 +9,7 @@ import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../
8
9
  import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
9
10
  import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
10
11
  import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
12
+ import { compileTaskRuntime } from "./app-compiler.js";
11
13
  const _configChangeListeners = [];
12
14
  export function onConfigChange(listener) {
13
15
  _configChangeListeners.push(listener);
@@ -119,7 +121,16 @@ function safePort(port) {
119
121
  throw new Error(`Invalid port: ${port}`);
120
122
  return String(port);
121
123
  }
122
- 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) {
123
134
  if (!Number.isInteger(port) || port < 1 || port > 65535)
124
135
  return Promise.resolve(false);
125
136
  return new Promise((resolve) => {
@@ -131,13 +142,25 @@ function isPortInUse(port) {
131
142
  server.listen(port, "0.0.0.0");
132
143
  });
133
144
  }
134
- 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) {
135
156
  const used = usedGatewayPorts(instanceId);
157
+ const skipped = [];
136
158
  let port = DEFAULT_GATEWAY_PORT;
137
159
  while (true) {
138
160
  if (port > 65535)
139
161
  throw new Error("No available gateway port found (all ports 18789-65535 in use)");
140
162
  if (used.has(port) || _pendingPorts.has(port)) {
163
+ skipped.push(port);
141
164
  port++;
142
165
  continue;
143
166
  }
@@ -147,16 +170,18 @@ async function defaultGatewayPort(instanceId) {
147
170
  try {
148
171
  if (await isPortInUse(port)) {
149
172
  _pendingPorts.delete(port);
173
+ skipped.push(port);
150
174
  port++;
151
175
  continue;
152
176
  }
153
- return port;
177
+ return { port, skipped };
154
178
  }
155
179
  catch {
156
180
  _pendingPorts.delete(port);
157
181
  // Skip this port on a transient OS error rather than failing the entire
158
182
  // allocation — a single bad port check should not prevent instance creation.
159
183
  console.warn(`[instance] Port ${port} availability check failed, trying next port`);
184
+ skipped.push(port);
160
185
  port++;
161
186
  continue;
162
187
  }
@@ -243,8 +268,7 @@ function chownToServiceUser(...paths) {
243
268
  }
244
269
  }
245
270
  }
246
- async function defaultRuntime(instanceId, openclawHome) {
247
- const port = await defaultGatewayPort(instanceId);
271
+ function buildDefaultRuntime(instanceId, port, openclawHome) {
248
272
  const home = openclawHome || defaultOpenclawHome(instanceId);
249
273
  return {
250
274
  command: resolveOpenclawBin(),
@@ -755,6 +779,26 @@ function getStockExtensionsDir() {
755
779
  return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
756
780
  }
757
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
+ }
758
802
  export function listInstances() {
759
803
  if (!existsSync(INSTANCES_DIR))
760
804
  return [];
@@ -764,14 +808,33 @@ export function listInstances() {
764
808
  const metaPath = join(INSTANCES_DIR, name, "instance.json");
765
809
  const dirPath = join(INSTANCES_DIR, name);
766
810
  try {
767
- if (statSync(dirPath).isDirectory() && existsSync(metaPath)) {
768
- 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}`);
769
832
  }
770
833
  }
771
834
  catch (e) {
772
- // Permission errors (EACCES) caused by root-owned files are a common deployment
773
- // mistake (e.g. running a maintenance script as sudo). Log clearly instead of
774
- // silently dropping the instance from the list.
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.
775
838
  console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
776
839
  }
777
840
  }
@@ -779,17 +842,26 @@ export function listInstances() {
779
842
  }
780
843
  export function getInstance(instanceId) {
781
844
  const metaPath = instanceMetaPath(instanceId);
782
- if (!existsSync(metaPath))
783
- return null;
784
- try {
785
- return JSON.parse(readFileSync(metaPath, "utf-8"));
786
- }
787
- catch (e) {
788
- // Surface permission errors clearly (EACCES: instance.json owned by root after a sudo script)
789
- throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
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}`);
790
861
  }
862
+ return null;
791
863
  }
792
- export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, cloneOptions) {
864
+ export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, appSpec, cloneOptions) {
793
865
  const d = instanceDir(instanceId);
794
866
  if (existsSync(d))
795
867
  throw new Error(`Instance '${instanceId}' already exists`);
@@ -837,9 +909,18 @@ export async function createInstance(instanceId, name, description = "", cloneFr
837
909
  catch { /* non-root without CAP_CHOWN — already correct owner */ }
838
910
  ensureDirContainer(home);
839
911
  ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
840
- const runtime = await defaultRuntime(instanceId, home);
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
+ }
841
922
  const allocatedPort = extractGatewayPort(runtime);
842
- // Port already reserved inside defaultGatewayPort; just track for cleanup
923
+ // Port already reserved inside allocateGatewayPort; just track for cleanup
843
924
  try {
844
925
  const meta = {
845
926
  id: instanceId,
@@ -848,6 +929,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
848
929
  openclaw_home: home,
849
930
  runtime,
850
931
  created_at: new Date().toISOString(),
932
+ ...(appSpec ? { app_id: appSpec.id } : {}),
851
933
  };
852
934
  safeWriteJson(instanceMetaPath(instanceId), meta);
853
935
  const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
@@ -959,6 +1041,34 @@ export async function createInstance(instanceId, name, description = "", cloneFr
959
1041
  updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
960
1042
  }
961
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
+ }
962
1072
  // Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
963
1073
  if (cloneFrom && envFiles.length) {
964
1074
  const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
@@ -997,6 +1107,17 @@ export async function createInstance(instanceId, name, description = "", cloneFr
997
1107
  console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
998
1108
  }
999
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
+ }
1000
1121
  return meta;
1001
1122
  }
1002
1123
  finally {
@@ -1023,7 +1144,7 @@ export function updateInstanceMeta(instanceId, patch) {
1023
1144
  Object.assign(meta, patch);
1024
1145
  safeWriteJson(metaPath, meta);
1025
1146
  }
1026
- export function deleteInstance(instanceId, purgeBackups = false) {
1147
+ export async function deleteInstance(instanceId, purgeBackups = false) {
1027
1148
  const d = instanceDir(instanceId);
1028
1149
  if (!existsSync(d))
1029
1150
  return { ok: false, warnings: ["Instance directory not found"] };
@@ -1049,14 +1170,18 @@ export function deleteInstance(instanceId, purgeBackups = false) {
1049
1170
  }).catch((e) => {
1050
1171
  console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
1051
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.
1052
1177
  let dirDeleted = false;
1053
1178
  try {
1054
- rmSync(d, { recursive: true, force: true });
1179
+ await rmAsync(d, { recursive: true, force: true });
1055
1180
  dirDeleted = true;
1056
1181
  }
1057
1182
  catch {
1058
1183
  try {
1059
- execFileSync("sudo", ["rm", "-rf", d], { timeout: 10000 });
1184
+ execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
1060
1185
  dirDeleted = true;
1061
1186
  }
1062
1187
  catch (e) {
@@ -1067,11 +1192,14 @@ export function deleteInstance(instanceId, purgeBackups = false) {
1067
1192
  if (home && !home.startsWith(d) && existsSync(home)) {
1068
1193
  warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
1069
1194
  }
1070
- // Handle backups (stored in separate directory, not affected by instance rmSync)
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.
1071
1199
  const backupDir = join(BACKUPS_DIR, instanceId);
1072
1200
  if (purgeBackups && existsSync(backupDir)) {
1073
1201
  try {
1074
- rmSync(backupDir, { recursive: true, force: true });
1202
+ await rmAsync(backupDir, { recursive: true, force: true });
1075
1203
  }
1076
1204
  catch (e) {
1077
1205
  warnings.push(`Failed to delete backups: ${e.message}`);
@@ -1416,11 +1544,30 @@ export async function getGatewayHost(instanceId) {
1416
1544
  const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
1417
1545
  if (detail.ok) {
1418
1546
  const d = await detail.json();
1419
- const ports = d?.AllocatedResources?.Shared?.Ports ?? [];
1420
- 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");
1421
1550
  if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
1422
1551
  result = gwPort.HostIP;
1423
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
+ }
1424
1571
  }
1425
1572
  }
1426
1573
  }
@@ -1436,7 +1583,11 @@ export async function getGatewayHost(instanceId) {
1436
1583
  try {
1437
1584
  const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
1438
1585
  for (const line of out.split("\n")) {
1439
- 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/);
1440
1591
  if (match && match[2] === String(port)) {
1441
1592
  const addr = match[1];
1442
1593
  result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
@@ -1448,6 +1599,16 @@ export async function getGatewayHost(instanceId) {
1448
1599
  _gwHostCache.set(instanceId, { host: result, ts: Date.now() });
1449
1600
  return result;
1450
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
+ }
1451
1612
  export function findInstancesSharingOpenclawHome(instanceId) {
1452
1613
  const targetHome = normalizePath(getOpenclawHome(instanceId));
1453
1614
  return listInstances()
@@ -1455,6 +1616,48 @@ export function findInstancesSharingOpenclawHome(instanceId) {
1455
1616
  .filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
1456
1617
  .map((inst) => inst.id);
1457
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
+ }
1458
1661
  export function findInstancesSharingGatewayPort(instanceId) {
1459
1662
  const targetPort = getGatewayPort(instanceId);
1460
1663
  return listInstances()