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,13 @@
1
1
  import { execFileSync, execSync, spawn as nodeSpawn } from "child_process";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "fs";
3
- import { userInfo, homedir } from "node:os";
2
+ import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, unlinkSync } from "fs";
3
+ import { userInfo } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "path";
7
7
  import { StringDecoder } from "string_decoder";
8
8
  import { fileURLToPath } from "url";
9
- import { JISHUSHELL_HOME, getPanelConfig, savePanelConfig, setOpenclawDockerImage, getOpenclawDockerImage, isOfficialImage, CUSTOM_IMAGE_PREFIX, DEFAULT_OPENCLAW_DOCKER_IMAGE } from "../config.js";
9
+ import { JISHUSHELL_HOME, getPanelConfig, savePanelConfig, setOpenclawDockerImage, getOpenclawDockerImage, DEFAULT_OPENCLAW_DOCKER_IMAGE } from "../config.js";
10
+ import { ensureDirContainer, ensureDirHost, writeConfigFile, writeSecretFile, writeExecutableFile, writeSystemTmpFile } from "../utils/fs.js";
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
12
13
  // ── Paths ──────────────────────────────────────────────────────────
@@ -20,7 +21,10 @@ const NOMAD_BIN = join(BIN_DIR, "nomad");
20
21
  const NOMAD_CONFIG_DIR = join(JISHUSHELL_HOME, "nomad");
21
22
  const NOMAD_DATA_DIR = join(JISHUSHELL_HOME, "nomad", "data");
22
23
  const NOMAD_ALLOC_DIR = join(JISHUSHELL_HOME, "nomad", "data", "alloc");
23
- const NOMAD_VERSION = "1.11.3";
24
+ const COLIMA_DIR = join(JISHUSHELL_HOME, "colima");
25
+ const COLIMA_PROFILE = "jishushell";
26
+ const COLIMA_SOCKET = join(COLIMA_DIR, COLIMA_PROFILE, "docker.sock");
27
+ const NOMAD_VERSION = "1.6.5";
24
28
  let _serverPort = 8090;
25
29
  export function setServerPort(port) { _serverPort = port; }
26
30
  // ── Resolve non-root service user (board-agnostic) ─────────────────
@@ -340,7 +344,7 @@ export function ensureCgroupMemory() {
340
344
  execFileSync("sudo", ["cp", f, f + ".bak"], { timeout: 5000 });
341
345
  // Write to tmp file then sudo cp to avoid shell interpolation of file content
342
346
  const tmpPath = join(dirname(f), ".cmdline.tmp");
343
- writeFileSync(tmpPath, patched + "\n");
347
+ writeSystemTmpFile(tmpPath, patched + "\n");
344
348
  execFileSync("sudo", ["cp", tmpPath, f], { timeout: 5000 });
345
349
  try {
346
350
  unlinkSync(tmpPath);
@@ -356,13 +360,16 @@ export function ensureCgroupMemory() {
356
360
  return false;
357
361
  }
358
362
  function canAccessDockerDaemon(timeout = 10000) {
363
+ const env = process.platform === "darwin" && existsSync(COLIMA_SOCKET)
364
+ ? { ...process.env, DOCKER_HOST: `unix://${COLIMA_SOCKET}` }
365
+ : undefined;
359
366
  try {
360
- execFileSync("docker", ["info"], { timeout, stdio: "ignore" });
367
+ execFileSync("docker", ["info"], { timeout, stdio: "ignore", env });
361
368
  return true;
362
369
  }
363
370
  catch { }
364
371
  try {
365
- execFileSync("sudo", ["-n", "docker", "info"], { timeout, stdio: "ignore" });
372
+ execFileSync("sudo", ["-n", "docker", "info"], { timeout, stdio: "ignore", env });
366
373
  return true;
367
374
  }
368
375
  catch { }
@@ -386,10 +393,7 @@ export function getSetupStatus() {
386
393
  const localBin = join(OPENCLAW_BIN_DIR, "openclaw");
387
394
  const localBinOk = existsSync(localBin);
388
395
  const fastDockerImageReady = checkDockerImageExists();
389
- const baseTag = resolveDockerImageTag();
390
- const official = isOfficialImage(baseTag);
391
- // Official image: Docker image alone is sufficient. Slim base: also needs npm package.
392
- const openclawOk = official ? fastDockerImageReady : (localBinOk || fastDockerImageReady);
396
+ const openclawOk = localBinOk || fastDockerImageReady;
393
397
  // Lightweight validation: verify critical services are actually available
394
398
  const dockerOk = canAccessDockerDaemon(5000);
395
399
  const nomadOk = isPortListening(4646);
@@ -412,34 +416,14 @@ export function getSetupStatus() {
412
416
  catch { }
413
417
  let openclawVer = "installed";
414
418
  let openclawPath = localBin;
415
- if (official && fastDockerImageReady) {
416
- // Official image: extract version from Docker image tag
417
- openclawVer = baseTag.split(":").pop() || "local";
418
- openclawPath = baseTag;
419
- }
420
- else {
421
- // Prefer npm package.json for accurate OpenClaw version.
422
- try {
423
- const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
424
- if (existsSync(pkg))
425
- openclawVer = JSON.parse(readFileSync(pkg, "utf-8")).version || openclawVer;
426
- }
427
- catch { }
428
- // Fallback: extract version from old-style openclaw:* image tag (legacy migration path).
429
- if (openclawVer === "installed" && fastDockerImageReady) {
430
- const imageTag = resolveDockerImageTag();
431
- if (/^openclaw:/i.test(imageTag)) {
432
- openclawVer = imageTag.replace(/^openclaw:/i, "");
433
- openclawPath = imageTag;
434
- }
435
- }
419
+ // Prefer npm package.json for accurate OpenClaw version.
420
+ try {
421
+ const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
422
+ if (existsSync(pkg))
423
+ openclawVer = JSON.parse(readFileSync(pkg, "utf-8")).version || openclawVer;
436
424
  }
437
- // Official image: only Docker image needed.
438
- // Slim base (jishushell-base:*): both npm package and Docker image required.
439
- const needsNpmForMount = /^jishushell-base:/i.test(baseTag);
440
- const ready = official
441
- ? fastDockerImageReady
442
- : (openclawOk && fastDockerImageReady && (!needsNpmForMount || localBinOk));
425
+ catch { }
426
+ const ready = openclawOk && fastDockerImageReady;
443
427
  return {
444
428
  node: { name: "Node.js", installed: true, running: true, version: process.version, path: process.execPath },
445
429
  docker: { name: "Docker", installed: true, running: true, version: dockerVer, path: "" },
@@ -510,11 +494,7 @@ export function getSetupStatus() {
510
494
  // the local npm package is absent — the image is all that's needed to run instances.
511
495
  if (!openclaw.ok && dockerImageReady) {
512
496
  const imageTag = resolveDockerImageTag();
513
- // Official image: extract version from tag (e.g. ghcr.io/openclaw/openclaw:2026.3.31 2026.3.31)
514
- // Legacy image: strip openclaw: prefix
515
- const tagVersion = isOfficialImage(imageTag)
516
- ? (imageTag.split(":").pop() || "local")
517
- : imageTag.replace(/^openclaw:/i, "");
497
+ const tagVersion = imageTag.split(":").pop() || "local";
518
498
  openclaw = { ok: true, version: tagVersion, path: imageTag };
519
499
  }
520
500
  const openclawStatus = {
@@ -789,6 +769,227 @@ function getNomadDownloadUrl() {
789
769
  const os = process.platform === "linux" ? "linux" : "darwin";
790
770
  return `https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_${os}_${arch}.zip`;
791
771
  }
772
+ /**
773
+ * Signal nomad agents by exact process name (pgrep -x nomad) to avoid the
774
+ * classic pkill -f self-match bug: a command line like "pkill -f 'nomad agent'"
775
+ * literally contains the pattern and pkill kills itself before reaching the
776
+ * real nomad process. pgrep's own comm is "pgrep" (not "nomad") so -x nomad
777
+ * cannot self-match. Unprivileged kill is tried first; sudo -n as a fallback
778
+ * if the running nomad is owned by root (1.6.5 User=root unit).
779
+ */
780
+ function killNomadByProcName() {
781
+ const collect = () => {
782
+ try {
783
+ const out = execSync("pgrep -x nomad 2>/dev/null || true", { encoding: "utf-8" }).trim();
784
+ return out.split("\n").filter(Boolean);
785
+ }
786
+ catch {
787
+ return [];
788
+ }
789
+ };
790
+ const sendSignal = (sig, pids) => {
791
+ if (pids.length === 0)
792
+ return;
793
+ try {
794
+ execFileSync("sudo", ["-n", "kill", `-${sig}`, ...pids], { timeout: 5000, stdio: "pipe" });
795
+ return;
796
+ }
797
+ catch { }
798
+ try {
799
+ execSync(`kill -${sig} ${pids.join(" ")} 2>/dev/null || true`, { timeout: 5000 });
800
+ }
801
+ catch { }
802
+ };
803
+ let pids = collect();
804
+ sendSignal("TERM", pids);
805
+ if (pids.length > 0) {
806
+ // Short grace period, then SIGKILL any survivors.
807
+ execSync("sleep 2");
808
+ pids = collect();
809
+ sendSignal("KILL", pids);
810
+ }
811
+ }
812
+ /** Compare two "a.b.c" semver strings; returns a > b. */
813
+ function isNomadVersionGreater(a, b) {
814
+ const parse = (v) => v.replace(/^v/, "").split(".").map(n => parseInt(n, 10) || 0);
815
+ const [aMaj, aMin, aPat] = parse(a);
816
+ const [bMaj, bMin, bPat] = parse(b);
817
+ if (aMaj !== bMaj)
818
+ return aMaj > bMaj;
819
+ if (aMin !== bMin)
820
+ return aMin > bMin;
821
+ return aPat > bPat;
822
+ }
823
+ /**
824
+ * Auto-migrate from a higher Nomad version (e.g. 1.11.3 BSL) back to the
825
+ * jishushell target (1.6.5 MPL). Called when installNomad detects a local
826
+ * binary whose semver is > NOMAD_VERSION. Destructive to Nomad's raft state
827
+ * (schema is not backward compatible) but preserves instance configs under
828
+ * ~/.jishushell/instances/*. A single tar.gz snapshot of the old data_dir
829
+ * is kept under ~/.jishushell/nomad/backups/ for forensic inspection.
830
+ *
831
+ * Safe-first: the new binary is downloaded and verified BEFORE any existing
832
+ * state is touched. If any stage 1 step fails, state is untouched.
833
+ *
834
+ * Throws on failure so the caller's outer catch reports the error.
835
+ */
836
+ async function migrateNomadToTarget(currentVersion) {
837
+ console.log(`[nomad] Auto-migrating v${currentVersion} → v${NOMAD_VERSION} (BSL → MPL)`);
838
+ console.log("[nomad] Raft state is not backward-compatible; allocation history will be reset.");
839
+ console.log("[nomad] Instance configs under ~/.jishushell/instances/ are preserved.");
840
+ // ── Stage 1: download + verify new binary into a staging dir ─────────
841
+ const stageDir = mkdtempSync(join(tmpdir(), "nomad-migrate-"));
842
+ let backupFile = "";
843
+ try {
844
+ const stagedBin = join(stageDir, "nomad");
845
+ const zipPath = join(stageDir, "nomad.zip");
846
+ const url = getNomadDownloadUrl();
847
+ const arch = process.arch === "arm64" ? "arm64" : "amd64";
848
+ const os = process.platform === "linux" ? "linux" : "darwin";
849
+ const sumsUrl = `https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_SHA256SUMS`;
850
+ const sumsPath = join(stageDir, "SHA256SUMS");
851
+ console.log(`[nomad] Staging v${NOMAD_VERSION} (${os}/${arch})...`);
852
+ execFileSync("curl", ["-fsSL", url, "-o", zipPath], { timeout: 300000, stdio: "pipe" });
853
+ execFileSync("curl", ["-fsSL", sumsUrl, "-o", sumsPath], { timeout: 30000, stdio: "pipe" });
854
+ const sums = readFileSync(sumsPath, "utf-8");
855
+ const sumLine = sums.split("\n").find(l => l.includes(`nomad_${NOMAD_VERSION}_${os}_${arch}.zip`));
856
+ if (!sumLine)
857
+ throw new Error(`No checksum entry for nomad_${NOMAD_VERSION}_${os}_${arch}.zip`);
858
+ const expected = sumLine.split(/\s+/)[0];
859
+ // Match the bash installer: prefer sha256sum (GNU coreutils, Linux),
860
+ // fall back to shasum -a 256 (BSD, macOS default — sha256sum is not
861
+ // preinstalled there). Without this, triggering auto-migration from
862
+ // the WebUI / Node path on macOS would fail even though the shell
863
+ // installer works fine.
864
+ let actual;
865
+ try {
866
+ actual = execSync(`sha256sum "${zipPath}" | awk '{print $1}'`, { encoding: "utf-8" }).trim();
867
+ }
868
+ catch {
869
+ actual = execSync(`shasum -a 256 "${zipPath}" | awk '{print $1}'`, { encoding: "utf-8" }).trim();
870
+ }
871
+ if (expected !== actual) {
872
+ throw new Error(`Nomad checksum mismatch: expected ${expected}, got ${actual}`);
873
+ }
874
+ console.log("[nomad] Checksum verified");
875
+ execFileSync("unzip", ["-o", zipPath, "-d", stageDir], { timeout: 30000 });
876
+ chmodSync(stagedBin, 0o755);
877
+ const stagedVersionLine = execFileSync(stagedBin, ["version"], { encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
878
+ if (!stagedVersionLine.includes(`v${NOMAD_VERSION}`)) {
879
+ throw new Error(`Staged binary reports "${stagedVersionLine}", expected v${NOMAD_VERSION}`);
880
+ }
881
+ console.log(`[nomad] Staged ${stagedVersionLine}`);
882
+ // ── Stage 2: destructive state changes begin ───────────────────────
883
+ console.log("[nomad] Stopping services...");
884
+ try {
885
+ execFileSync("sudo", ["-n", "systemctl", "stop", "jishushell"], { timeout: 15000, stdio: "pipe" });
886
+ }
887
+ catch { }
888
+ try {
889
+ execFileSync("sudo", ["-n", "systemctl", "stop", "nomad"], { timeout: 15000, stdio: "pipe" });
890
+ }
891
+ catch { }
892
+ // pkill -f 'nomad agent' matches pkill's own cmdline and self-terminates
893
+ // before reaching the real nomad process. Use pgrep -x nomad (exact proc
894
+ // name match; pgrep's comm is "pgrep") to avoid the self-match bug.
895
+ killNomadByProcName();
896
+ await new Promise(r => setTimeout(r, 2000));
897
+ // ── Stage 3: tar backup (single snapshot, overwrite any previous) ──
898
+ const backupDir = join(NOMAD_CONFIG_DIR, "backups");
899
+ if (existsSync(NOMAD_DATA_DIR)) {
900
+ try {
901
+ ensureDirHost(backupDir);
902
+ }
903
+ catch { }
904
+ const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
905
+ const candidate = join(backupDir, `data-${ts}.tar.gz`);
906
+ console.log(`[nomad] Backing up raft state → ${candidate}`);
907
+ try {
908
+ execSync(`tar czf "${candidate}" -C "${NOMAD_CONFIG_DIR}" data 2>/dev/null`, { timeout: 120000 });
909
+ backupFile = candidate;
910
+ // Keep only the most recent snapshot
911
+ try {
912
+ const list = execSync(`ls -t "${backupDir}"/data-*.tar.gz 2>/dev/null | tail -n +2 || true`, { encoding: "utf-8" }).trim();
913
+ for (const old of list.split("\n").filter(Boolean)) {
914
+ try {
915
+ unlinkSync(old);
916
+ }
917
+ catch { }
918
+ }
919
+ }
920
+ catch { }
921
+ }
922
+ catch {
923
+ console.warn("[nomad] Backup tar failed — continuing (raft state will still be wiped)");
924
+ }
925
+ }
926
+ // ── Stage 4: wipe raft state + env files (schema incompatible) ─────
927
+ try {
928
+ execFileSync("sudo", ["-n", "rm", "-rf", NOMAD_DATA_DIR], { timeout: 15000, stdio: "pipe" });
929
+ }
930
+ catch {
931
+ try {
932
+ rmSync(NOMAD_DATA_DIR, { recursive: true, force: true });
933
+ }
934
+ catch { }
935
+ }
936
+ try {
937
+ unlinkSync(join(JISHUSHELL_HOME, "nomad.env"));
938
+ }
939
+ catch { }
940
+ try {
941
+ execFileSync("sudo", ["-n", "rm", "-f", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
942
+ }
943
+ catch { }
944
+ // ── Stage 5: orphaned gateway containers ───────────────────────────
945
+ // Panel normally has docker group via jishushell.service SupplementaryGroups,
946
+ // but postinstall may run this helper from a shell where the invoking
947
+ // user is not in docker group. Probe first, fall back to sudo docker.
948
+ try {
949
+ let dockerCmd = "docker";
950
+ try {
951
+ execSync("docker ps >/dev/null 2>&1", { timeout: 5000 });
952
+ }
953
+ catch {
954
+ dockerCmd = "sudo -n docker";
955
+ }
956
+ const names = execSync(`${dockerCmd} ps -a --format '{{.Names}}' 2>/dev/null | grep '^gateway-' || true`, { encoding: "utf-8" }).trim();
957
+ if (names) {
958
+ const rows = names.split("\n").filter(Boolean);
959
+ for (const name of rows) {
960
+ try {
961
+ execSync(`${dockerCmd} rm -f "${name}" 2>/dev/null`, { timeout: 10000 });
962
+ }
963
+ catch { }
964
+ }
965
+ console.log(`[nomad] Removed ${rows.length} orphaned gateway container(s)`);
966
+ }
967
+ }
968
+ catch { }
969
+ // ── Stage 6: swap binary into place (atomic via temp name + rename)
970
+ ensureDirHost(BIN_DIR);
971
+ const destTmp = `${NOMAD_BIN}.tmp.${process.pid}`;
972
+ copyFileSync(stagedBin, destTmp);
973
+ chmodSync(destTmp, 0o755);
974
+ renameSync(destTmp, NOMAD_BIN);
975
+ console.log(`[nomad] Migrated to v${NOMAD_VERSION}`);
976
+ if (backupFile)
977
+ console.log(`[nomad] Backup (forensic, not self-recovery): ${backupFile}`);
978
+ console.log("[nomad] JishuShell will re-bootstrap ACL and resubmit jobs on next start.");
979
+ }
980
+ catch (err) {
981
+ if (backupFile) {
982
+ console.error(`[nomad] Migration failed — backup preserved at ${backupFile}`);
983
+ }
984
+ throw err;
985
+ }
986
+ finally {
987
+ try {
988
+ rmSync(stageDir, { recursive: true, force: true });
989
+ }
990
+ catch { }
991
+ }
992
+ }
792
993
  export async function installNomad() {
793
994
  try {
794
995
  if (existsSync(NOMAD_BIN)) {
@@ -810,8 +1011,38 @@ export async function installNomad() {
810
1011
  }
811
1012
  catch { }
812
1013
  // Boundary check 3: does it actually run?
1014
+ let versionLine = "";
813
1015
  try {
814
- const version = execSync(`"${NOMAD_BIN}" version`, { encoding: "utf-8", timeout: 5000 }).trim();
1016
+ versionLine = execSync(`"${NOMAD_BIN}" version`, { encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
1017
+ }
1018
+ catch {
1019
+ // Binary is corrupt or wrong arch — remove and reinstall
1020
+ try {
1021
+ unlinkSync(NOMAD_BIN);
1022
+ }
1023
+ catch { }
1024
+ }
1025
+ if (versionLine) {
1026
+ const match = versionLine.match(/v(\d+\.\d+\.\d+)/);
1027
+ const currentVersion = match ? match[1] : "";
1028
+ if (currentVersion && isNomadVersionGreater(currentVersion, NOMAD_VERSION)) {
1029
+ // Current > target — auto-migrate (nomad 1.11.3 BSL → 1.6.5 MPL).
1030
+ // Migration failure is a hard stop: the old state has been
1031
+ // partially mutated (or about to be), returning falls through
1032
+ // to the reinstall path which would make a bad situation worse.
1033
+ try {
1034
+ await migrateNomadToTarget(currentVersion);
1035
+ }
1036
+ catch (migErr) {
1037
+ return {
1038
+ ok: false,
1039
+ message: "Nomad auto-migration failed",
1040
+ error: migErr?.message || String(migErr),
1041
+ };
1042
+ }
1043
+ const newLine = execSync(`"${NOMAD_BIN}" version`, { encoding: "utf-8", timeout: 5000 }).trim().split("\n")[0];
1044
+ return { ok: true, message: `Nomad migrated to ${newLine}` };
1045
+ }
815
1046
  // Ensure Nomad is started even if already installed
816
1047
  if (!isPortListening(4646)) {
817
1048
  try {
@@ -822,14 +1053,7 @@ export async function installNomad() {
822
1053
  await startNomad();
823
1054
  }
824
1055
  }
825
- return { ok: true, message: `Nomad already installed: ${version.split("\n")[0]}` };
826
- }
827
- catch {
828
- // Binary is corrupt or wrong arch — remove and reinstall
829
- try {
830
- unlinkSync(NOMAD_BIN);
831
- }
832
- catch { }
1056
+ return { ok: true, message: `Nomad already installed: ${versionLine}` };
833
1057
  }
834
1058
  }
835
1059
  }
@@ -839,7 +1063,7 @@ export async function installNomad() {
839
1063
  try {
840
1064
  const systemNomad = execSync("which nomad 2>/dev/null", { encoding: "utf-8" }).trim();
841
1065
  if (systemNomad && existsSync(systemNomad)) {
842
- mkdirSync(BIN_DIR, { recursive: true });
1066
+ ensureDirHost(BIN_DIR);
843
1067
  symlinkSync(systemNomad, NOMAD_BIN);
844
1068
  const version = execSync(`${NOMAD_BIN} version`, { encoding: "utf-8", timeout: 5000 }).trim();
845
1069
  console.log(`[nomad] Linked system nomad ${systemNomad} → ${NOMAD_BIN}`);
@@ -849,7 +1073,7 @@ export async function installNomad() {
849
1073
  catch { /* system nomad not found — proceed to download */ }
850
1074
  }
851
1075
  const task = createTask("nomad");
852
- mkdirSync(BIN_DIR, { recursive: true });
1076
+ ensureDirHost(BIN_DIR);
853
1077
  const url = getNomadDownloadUrl();
854
1078
  const zipPath = join(BIN_DIR, "nomad.zip");
855
1079
  emitTask(task, { type: "progress", message: "下载 Nomad...", progress: 0 });
@@ -917,12 +1141,10 @@ function fixNomadDirOwnership() {
917
1141
  }
918
1142
  function writeNomadConfig() {
919
1143
  fixNomadDirOwnership();
920
- mkdirSync(NOMAD_CONFIG_DIR, { recursive: true, mode: 0o755 });
921
- chmodSync(NOMAD_CONFIG_DIR, 0o755);
922
- mkdirSync(NOMAD_DATA_DIR, { recursive: true, mode: 0o755 });
923
- chmodSync(NOMAD_DATA_DIR, 0o755);
924
- mkdirSync(NOMAD_ALLOC_DIR, { recursive: true, mode: 0o755 });
925
- chmodSync(NOMAD_ALLOC_DIR, 0o755);
1144
+ ensureDirHost(NOMAD_CONFIG_DIR);
1145
+ ensureDirContainer(NOMAD_DATA_DIR);
1146
+ ensureDirContainer(NOMAD_ALLOC_DIR);
1147
+ const loopbackIface = process.platform === "darwin" ? "lo0" : "lo";
926
1148
  const config = `
927
1149
  data_dir = "${NOMAD_DATA_DIR}"
928
1150
 
@@ -944,18 +1166,18 @@ server {
944
1166
  client {
945
1167
  enabled = true
946
1168
  servers = ["127.0.0.1:4647"]
1169
+ network_interface = "${loopbackIface}"
947
1170
  alloc_dir = "${NOMAD_ALLOC_DIR}"
948
1171
 
949
- drain_on_shutdown {
950
- deadline = "30s"
951
- force = true
952
- ignore_system_jobs = true
953
- }
1172
+ # drain_on_shutdown intentionally omitted: on single-node Pi there is
1173
+ # nowhere to drain workloads to, and draining on every systemctl restart
1174
+ # would kill every running OpenClaw instance. Without this block Nomad
1175
+ # leaves allocations running across client restarts — the docker driver
1176
+ # re-attaches to the existing containers on startup.
954
1177
  }
955
1178
 
956
1179
  plugin "docker" {
957
1180
  config {
958
- disable_log_collection = true
959
1181
  volumes {
960
1182
  enabled = true
961
1183
  }
@@ -966,7 +1188,7 @@ acl {
966
1188
  enabled = true
967
1189
  }
968
1190
  `;
969
- writeFileSync(join(NOMAD_CONFIG_DIR, "nomad.hcl"), config);
1191
+ writeConfigFile(join(NOMAD_CONFIG_DIR, "nomad.hcl"), config);
970
1192
  }
971
1193
  export function loadNomadToken() {
972
1194
  if (process.env.NOMAD_TOKEN)
@@ -1077,10 +1299,10 @@ async function bootstrapNomadACL() {
1077
1299
  const saveToken = (token) => {
1078
1300
  const envFile = join(JISHUSHELL_HOME, "nomad.env");
1079
1301
  const envContent = `NOMAD_TOKEN=${token}\n`;
1080
- writeFileSync(envFile, envContent, { mode: 0o600 });
1302
+ writeSecretFile(envFile, envContent);
1081
1303
  try {
1082
1304
  execFileSync("sudo", ["-n", "mkdir", "-p", "/etc/jishushell"], { timeout: 5000, stdio: "pipe" });
1083
- writeFileSync("/tmp/.nomad-env-tmp", envContent, { mode: 0o600 });
1305
+ writeSystemTmpFile("/tmp/.nomad-env-tmp", envContent);
1084
1306
  execFileSync("sudo", ["-n", "cp", "/tmp/.nomad-env-tmp", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1085
1307
  execFileSync("sudo", ["-n", "chmod", "600", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1086
1308
  try {
@@ -1111,14 +1333,27 @@ async function bootstrapNomadACL() {
1111
1333
  }
1112
1334
  const resetIndex = resetMatch[1];
1113
1335
  console.log(`[nomad] Bootstrap already done (reset index: ${resetIndex}). Performing ACL bootstrap reset...`);
1114
- // Write the reset trigger file (Nomad reads this on startup to allow re-bootstrap)
1336
+ // Write the reset trigger file (Nomad reads this on startup to allow re-bootstrap).
1337
+ // NOMAD_DATA_DIR/server is owned by root because nomad.service runs as User=root
1338
+ // (docker driver on 1.6.5 requires euid==0). The panel runs as a non-root user, so
1339
+ // plain writeConfigFile would fail with EACCES — route through `sudo tee` instead.
1115
1340
  const resetFile = join(NOMAD_DATA_DIR, "server", "acl-bootstrap-reset");
1116
1341
  try {
1117
- writeFileSync(resetFile, resetIndex, { encoding: "utf-8" });
1342
+ writeConfigFile(resetFile, resetIndex);
1118
1343
  }
1119
1344
  catch (writeErr) {
1120
- console.warn("[nomad] Could not write acl-bootstrap-reset file:", writeErr.message);
1121
- return;
1345
+ try {
1346
+ execFileSync("sudo", ["-n", "mkdir", "-p", dirname(resetFile)], { timeout: 5000, stdio: "pipe" });
1347
+ execFileSync("sudo", ["-n", "tee", resetFile], {
1348
+ timeout: 5000,
1349
+ input: resetIndex,
1350
+ stdio: ["pipe", "ignore", "pipe"],
1351
+ });
1352
+ }
1353
+ catch (sudoErr) {
1354
+ console.warn("[nomad] Could not write acl-bootstrap-reset file:", sudoErr.message || writeErr.message);
1355
+ return;
1356
+ }
1122
1357
  }
1123
1358
  // Restart Nomad so it picks up the reset file
1124
1359
  try {
@@ -1127,9 +1362,14 @@ async function bootstrapNomadACL() {
1127
1362
  catch {
1128
1363
  // No passwordless sudo — try pkill/re-spawn path (best effort)
1129
1364
  try {
1130
- execFileSync("pkill", ["-TERM", "-f", "nomad agent"], { stdio: "pipe" });
1365
+ execFileSync("sudo", ["-n", "pkill", "-TERM", "-f", "nomad agent"], { stdio: "pipe" });
1366
+ }
1367
+ catch {
1368
+ try {
1369
+ execFileSync("pkill", ["-TERM", "-f", "nomad agent"], { stdio: "pipe" });
1370
+ }
1371
+ catch { }
1131
1372
  }
1132
- catch { }
1133
1373
  }
1134
1374
  // Wait for Nomad to come back
1135
1375
  for (let i = 0; i < 20; i++) {
@@ -1257,14 +1497,15 @@ export async function stopNomad() {
1257
1497
  try {
1258
1498
  if (!isPortListening(4646))
1259
1499
  return { ok: true, message: "Nomad not running" };
1260
- // SIGTERM: graceful shutdown — Nomad flushes state, drains allocs (force=true in HCL)
1261
- // Nomad runs as the current user; no sudo needed to send SIGTERM
1262
- try {
1263
- execSync("pkill -TERM -f 'nomad agent'", { timeout: 5000 });
1264
- }
1265
- catch { }
1266
- // Wait up to 35s for process to exit (drain_on_shutdown deadline + grace)
1267
- for (let i = 0; i < 35; i++) {
1500
+ // SIGTERM: graceful shutdown — Nomad flushes state and detaches from
1501
+ // running allocs without killing them (drain_on_shutdown is deliberately
1502
+ // not configured, so the docker containers keep running and will be
1503
+ // re-attached when Nomad comes back).
1504
+ // Use killNomadByProcName (pgrep -x) to avoid pkill -f self-matching.
1505
+ killNomadByProcName();
1506
+ // Wait up to 10s for the process to exit. No drain means shutdown is
1507
+ // near-instant most of this budget is slack for slow disks on Pi.
1508
+ for (let i = 0; i < 10; i++) {
1268
1509
  await new Promise(r => setTimeout(r, 1000));
1269
1510
  if (!isPortListening(4646))
1270
1511
  return { ok: true, message: "Nomad stopped" };
@@ -1292,8 +1533,7 @@ export function installNomadSystemd() {
1292
1533
  if (process.platform === "darwin") {
1293
1534
  const plistLabel = "com.jishushell.nomad";
1294
1535
  const logPath = join(NOMAD_CONFIG_DIR, "nomad.log");
1295
- const primarySocket = join(homedir(), ".docker", "run", "docker.sock");
1296
- const dockerSock = existsSync(primarySocket) ? primarySocket : "/var/run/docker.sock";
1536
+ const dockerSock = COLIMA_SOCKET;
1297
1537
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1298
1538
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1299
1539
  <plist version="1.0">
@@ -1317,8 +1557,7 @@ export function installNomadSystemd() {
1317
1557
  </dict>
1318
1558
  </plist>`;
1319
1559
  const plistPath = join(process.env.HOME || dirname(JISHUSHELL_HOME), `Library/LaunchAgents/${plistLabel}.plist`);
1320
- mkdirSync(dirname(plistPath), { recursive: true });
1321
- writeFileSync(plistPath, plistContent);
1560
+ writeConfigFile(plistPath, plistContent);
1322
1561
  try {
1323
1562
  execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
1324
1563
  }
@@ -1326,17 +1565,18 @@ export function installNomadSystemd() {
1326
1565
  execSync(`launchctl load -w "${plistPath}"`, { timeout: 15000 });
1327
1566
  return { ok: true, message: "Nomad launchd agent installed and started" };
1328
1567
  }
1329
- // Nomad runs as the current (non-root) user so all ~/.jishushell/ files remain accessible.
1330
- // Group=docker gives the process access to /var/run/docker.sock for the Docker driver.
1331
- const currentUser = process.env.USER || process.env.LOGNAME || process.env.SUDO_USER || "jishu";
1568
+ // Nomad 1.6.5's docker driver fingerprint requires euid==0 PR #18197 lifted
1569
+ // that restriction only in 1.7+, and we intentionally stay on the 1.6 MPL line.
1570
+ // The panel stays as the installing user via a separate jishushell.service unit;
1571
+ // it talks to this agent over HTTP, so no files under ~/.jishushell/nomad/data/
1572
+ // are read directly by the panel.
1332
1573
  const serviceContent = `[Unit]
1333
1574
  Description=Nomad Agent
1334
1575
  After=network-online.target docker.service
1335
1576
  Wants=network-online.target
1336
1577
 
1337
1578
  [Service]
1338
- User=${currentUser}
1339
- SupplementaryGroups=docker
1579
+ User=root
1340
1580
  Type=simple
1341
1581
  EnvironmentFile=-/etc/jishushell/nomad.env
1342
1582
  ExecStart=${nomadPath} agent -config=${configPath}
@@ -1348,7 +1588,7 @@ WantedBy=multi-user.target
1348
1588
  `;
1349
1589
  const servicePath = "/etc/systemd/system/nomad.service";
1350
1590
  execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1351
- writeFileSync("/tmp/nomad.service", serviceContent);
1591
+ writeSystemTmpFile("/tmp/nomad.service", serviceContent);
1352
1592
  execFileSync("sudo", ["cp", "/tmp/nomad.service", servicePath], { timeout: 5000 });
1353
1593
  execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1354
1594
  execFileSync("sudo", ["systemctl", "enable", "--now", "nomad"], { timeout: 15000 });
@@ -1384,11 +1624,11 @@ export function installJishushellSystemd(port) {
1384
1624
  export JISHUSHELL_HOME="${JISHUSHELL_HOME}"
1385
1625
  export HOME="${realHome}"
1386
1626
  export NODE_ENV=production
1387
- export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin:${dirname(nodeBin)}:\${PATH}"
1627
+ export DOCKER_HOST="unix://${COLIMA_SOCKET}"
1628
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:${dirname(nodeBin)}:\${PATH}"
1388
1629
  exec "${nodeBin}" "${cliBin}" serve --port ${resolvedPort}
1389
1630
  `;
1390
- mkdirSync(dirname(wrapperPath), { recursive: true });
1391
- writeFileSync(wrapperPath, wrapperContent, { mode: 0o755 });
1631
+ writeExecutableFile(wrapperPath, wrapperContent);
1392
1632
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1393
1633
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1394
1634
  <plist version="1.0">
@@ -1406,8 +1646,7 @@ exec "${nodeBin}" "${cliBin}" serve --port ${resolvedPort}
1406
1646
  </dict>
1407
1647
  </plist>`;
1408
1648
  const plistPath = join(process.env.HOME || realHome, `Library/LaunchAgents/${plistLabel}.plist`);
1409
- mkdirSync(dirname(plistPath), { recursive: true });
1410
- writeFileSync(plistPath, plistContent);
1649
+ writeConfigFile(plistPath, plistContent);
1411
1650
  const panelAlreadyRunning = isPortListening(resolvedPort);
1412
1651
  if (!panelAlreadyRunning) {
1413
1652
  try {
@@ -1441,7 +1680,7 @@ Environment=NODE_ENV=production
1441
1680
  WantedBy=multi-user.target
1442
1681
  `;
1443
1682
  execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1444
- writeFileSync("/tmp/jishushell.service", serviceContent);
1683
+ writeSystemTmpFile("/tmp/jishushell.service", serviceContent);
1445
1684
  execFileSync("sudo", ["cp", "/tmp/jishushell.service", "/etc/systemd/system/jishushell.service"], { timeout: 5000 });
1446
1685
  execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1447
1686
  execFileSync("sudo", ["systemctl", "enable", "jishushell"], { timeout: 15000 });
@@ -1463,7 +1702,7 @@ export async function installOpenclaw(version = "latest") {
1463
1702
  return { ok: true, message: `OpenClaw already installed: ${ver}` };
1464
1703
  }
1465
1704
  const task = createTask("openclaw");
1466
- mkdirSync(OPENCLAW_PKG_DIR, { recursive: true });
1705
+ ensureDirHost(OPENCLAW_PKG_DIR);
1467
1706
  emitTask(task, { type: "progress", message: "开始安装 OpenClaw...", progress: 0 });
1468
1707
  // Monitor directory size for progress estimation
1469
1708
  const sizeTracker = setInterval(() => {
@@ -1532,40 +1771,47 @@ function checkDockerImageExists() {
1532
1771
  return true;
1533
1772
  }
1534
1773
  catch {
1774
+ // Fallback scan: list all local images and try to find any known runtime image.
1775
+ // This handles two scenarios:
1776
+ // 1. panel.json was wiped (e.g. after `jishushell reset`) and the pinned version
1777
+ // tag is no longer stored, causing getOpenclawDockerImage() to return the default
1778
+ // `:latest` tag which may have been removed locally during the first-run migration.
1779
+ // 2. The environment uses a locally built runtime image (e.g. jishushell-hermes:latest)
1780
+ // that differs from the registry default.
1781
+ // When a candidate is found we restore panel.json (self-heal) so the fast path works next time.
1782
+ try {
1783
+ const invocation = resolveDockerInvocation();
1784
+ const out = execFileSync(invocation.cmd, [...invocation.argsPrefix, "images", "--format", "{{.Repository}}:{{.Tag}}"], { encoding: "utf8", timeout: 5000 });
1785
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
1786
+ // 1. Same repository as DEFAULT_OPENCLAW_DOCKER_IMAGE (e.g. after pinned-tag migration).
1787
+ const defaultImage = DEFAULT_OPENCLAW_DOCKER_IMAGE;
1788
+ const repoColonIdx = defaultImage.lastIndexOf(":");
1789
+ const repoSlashIdx = defaultImage.lastIndexOf("/");
1790
+ if (repoColonIdx > repoSlashIdx) {
1791
+ const repo = defaultImage.slice(0, repoColonIdx);
1792
+ const repoPrefix = repo + ":";
1793
+ const found = lines.find((l) => l.startsWith(repoPrefix) && !l.endsWith(":<none>") && !l.endsWith(":none"));
1794
+ if (found) {
1795
+ setOpenclawDockerImage(found);
1796
+ return true;
1797
+ }
1798
+ }
1799
+ // 2. Backward compat: older locally-built jishushell-openclaw:* image names.
1800
+ // These use the same slim base architecture as ghcr.io/x-aijishu/openclaw-runtime:*
1801
+ // and are fully compatible. Self-heal panel.json so the tag stored there becomes
1802
+ // the concrete existing tag, preventing repeated DEFAULT-migration side effects.
1803
+ const legacyFound = lines.find((l) => /^jishushell-openclaw:[^\s]+/.test(l) && !l.endsWith(":<none>") && !l.endsWith(":none"));
1804
+ if (legacyFound) {
1805
+ setOpenclawDockerImage(legacyFound);
1806
+ return true;
1807
+ }
1808
+ }
1809
+ catch { }
1535
1810
  return false;
1536
1811
  }
1537
1812
  }
1538
1813
  // The stable tag for the JishuShell base image (slim — no OpenClaw binary baked in).
1539
- // This tag does NOT change when OpenClaw is upgraded; the binary is bind-mounted from
1540
- // the host at runtime. Rebuild this image only when system packages (Node.js, python3…)
1541
- // need to change.
1542
- export const BASE_IMAGE_TAG = "jishushell-base:v1";
1543
1814
  function resolveDockerImageTag() {
1544
- // 1. Environment variable takes precedence (same as runtime getOpenclawDockerImage)
1545
- if (process.env.OPENCLAW_DOCKER_IMAGE)
1546
- return process.env.OPENCLAW_DOCKER_IMAGE;
1547
- // 2. Stored in panel.json — honours custom images and pre-existing openclaw:*
1548
- // images that haven't been migrated yet (backward compatibility).
1549
- const stored = getPanelConfig().openclaw_image;
1550
- if (typeof stored === "string" && stored.trim())
1551
- return stored;
1552
- // 3. Scan local daemon — prefer official ghcr.io image, fall back to legacy jishushell-base.
1553
- try {
1554
- const invocation = resolveDockerInvocation();
1555
- const output = execFileSync(invocation.cmd, [...invocation.argsPrefix, "images", "--format", "{{.Repository}}:{{.Tag}}"], { encoding: "utf-8", timeout: 5000 });
1556
- const lines = output.split("\n").map(l => l.trim());
1557
- // Prefer custom image (built with Python), then official, then legacy
1558
- const custom = lines.find(l => l.startsWith(CUSTOM_IMAGE_PREFIX + ":"));
1559
- if (custom)
1560
- return custom;
1561
- const official = lines.find(l => l.startsWith("ghcr.io/openclaw/openclaw:"));
1562
- if (official)
1563
- return official;
1564
- const legacy = lines.find(l => /^jishushell-base:/i.test(l));
1565
- if (legacy)
1566
- return legacy;
1567
- }
1568
- catch { }
1569
1815
  return getOpenclawDockerImage();
1570
1816
  }
1571
1817
  // Base image and mirror list for the OpenClaw Docker build.
@@ -1615,7 +1861,7 @@ function resolveVersionedBuildTag() {
1615
1861
  if (existsSync(pkg)) {
1616
1862
  const ver = JSON.parse(readFileSync(pkg, "utf-8")).version;
1617
1863
  if (ver)
1618
- return `${CUSTOM_IMAGE_PREFIX}:${ver}`;
1864
+ return `jishushell-openclaw:${ver}`;
1619
1865
  }
1620
1866
  }
1621
1867
  catch { }
@@ -1665,9 +1911,9 @@ CMD ["gateway", "run", "--port", "18789", "--allow-unconfigured"]
1665
1911
  `;
1666
1912
  // Use a temp dir as build context — no files to COPY means no large transfer.
1667
1913
  const buildDir = join(tmpdir(), `jishushell-base-build-${Date.now()}`);
1668
- mkdirSync(buildDir, { recursive: true });
1914
+ ensureDirHost(buildDir);
1669
1915
  const dockerfilePath = join(buildDir, "Dockerfile");
1670
- writeFileSync(dockerfilePath, dockerfile);
1916
+ writeConfigFile(dockerfilePath, dockerfile);
1671
1917
  emitTask(task, { type: "progress", message: `构建基础镜像 ${targetTag}(无需拷贝二进制,速度极快)...`, progress: 10 });
1672
1918
  let result;
1673
1919
  try {
@@ -1694,7 +1940,7 @@ CMD ["gateway", "run", "--port", "18789", "--allow-unconfigured"]
1694
1940
  task.status = "error";
1695
1941
  return { ok: false, message: "Docker image build failed", error: result.output, taskId: task.id };
1696
1942
  }
1697
- const localTag = `${CUSTOM_IMAGE_PREFIX}:local`;
1943
+ const localTag = "jishushell-openclaw:local";
1698
1944
  if (targetTag !== localTag) {
1699
1945
  try {
1700
1946
  execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, localTag], { timeout: 10000, stdio: "ignore" });
@@ -1787,7 +2033,7 @@ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
1787
2033
  `;
1788
2034
  // Write Dockerfile into the npm package directory (build context)
1789
2035
  const dockerfilePath = join(OPENCLAW_PKG_DIR, "Dockerfile");
1790
- writeFileSync(dockerfilePath, dockerfile);
2036
+ writeConfigFile(dockerfilePath, dockerfile);
1791
2037
  let buildResult;
1792
2038
  try {
1793
2039
  buildResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "build", "--network=host", "-t", targetTag, OPENCLAW_PKG_DIR], { timeout: 1800000, progressParser: dockerBuildProgressParser });
@@ -1818,24 +2064,212 @@ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
1818
2064
  return { ok: false, message: "Docker image build failed", error: e.message, taskId: task.id };
1819
2065
  }
1820
2066
  }
1821
- export async function buildCustomOpenclawImage(tag) {
1822
- const task = createTask("openclaw-docker-build");
1823
- return buildCustomOpenclawImageWithTask(task, tag);
2067
+ // ── Pull or build OpenClaw Docker image ───────────────────────────
2068
+ /** Matches a semver-ish tag suffix, e.g. "...:2026.4.9" or "...:v1.2.3-beta". */
2069
+ const PINNED_IMAGE_TAG_RE = /:[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/;
2070
+ /**
2071
+ * Query the npm registry for the current OpenClaw version. Used to bust the
2072
+ * Docker layer cache for `RUN npm install openclaw@${ver}` during local build.
2073
+ * Returns "latest" when npm is unreachable so the build can still proceed.
2074
+ */
2075
+ function resolveOpenclawNpmVersion() {
2076
+ try {
2077
+ const out = execFileSync("npm", ["view", "openclaw", "version"], {
2078
+ timeout: 15000,
2079
+ encoding: "utf-8",
2080
+ stdio: ["ignore", "pipe", "ignore"],
2081
+ }).trim();
2082
+ if (/^\d+\.\d+\.\d+/.test(out))
2083
+ return out;
2084
+ }
2085
+ catch { /* npm not reachable */ }
2086
+ return "latest";
1824
2087
  }
1825
- export function startBuildCustomOpenclawImage(tag) {
1826
- const task = createTask("openclaw-docker-build");
1827
- void buildCustomOpenclawImageWithTask(task, tag).catch((err) => {
1828
- emitTask(task, { type: "error", message: `镜像构建失败: ${err?.message || err}` });
2088
+ /**
2089
+ * Read the OpenClaw version actually bundled at /app/ inside a Docker image,
2090
+ * bypassing `openclaw-entry.sh`'s `.npm-global/` override. This is the
2091
+ * authoritative source of truth the image's OCI label can be wrong
2092
+ * (CI bugs, layer cache reuse), but `/app/node_modules/openclaw/package.json`
2093
+ * is the exact content that ran through `npm install`.
2094
+ *
2095
+ * Spawns a throw-away container with `--entrypoint node` so Node prints the
2096
+ * version directly. Returns "" when docker is unavailable or the path is
2097
+ * missing (e.g. a non-openclaw image).
2098
+ */
2099
+ function readBundledOpenclawVersion(invocation, image) {
2100
+ try {
2101
+ const out = execFileSync(invocation.cmd, [
2102
+ ...invocation.argsPrefix,
2103
+ "run", "--rm",
2104
+ "--entrypoint", "node",
2105
+ image,
2106
+ "-p",
2107
+ "require('/app/node_modules/openclaw/package.json').version",
2108
+ ], { timeout: 20000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2109
+ if (/^\d+\.\d+\.\d+/.test(out))
2110
+ return out;
2111
+ }
2112
+ catch { /* docker unavailable, image missing, or path not present */ }
2113
+ return "";
2114
+ }
2115
+ /**
2116
+ * After a successful pull or build, capture the image's real OpenClaw version
2117
+ * and return a pinned tag of the form `ghcr.io/.../openclaw-runtime:<version>`.
2118
+ * The pinned tag is added as a local alias via `docker tag` so subsequent
2119
+ * Nomad allocations see an immutable reference and never re-pull on restart.
2120
+ *
2121
+ * Version discovery order:
2122
+ * 1. `explicitVersion` when the caller already knows it (e.g. the local build
2123
+ * path, which queries npm for the version and passes it as `--build-arg`).
2124
+ * 2. Bundled `/app/node_modules/openclaw/package.json` inside the image
2125
+ * (authoritative — bypasses both the `.npm-global/` override layer and a
2126
+ * potentially stale OCI label).
2127
+ *
2128
+ * Returns the original tag unchanged when:
2129
+ * - the target is already a pinned version tag
2130
+ * - no version can be discovered
2131
+ * - docker tag fails for any reason
2132
+ */
2133
+ function capturePinnedImageTag(invocation, targetTag, explicitVersion) {
2134
+ // Already pinned? Nothing to do.
2135
+ if (PINNED_IMAGE_TAG_RE.test(targetTag))
2136
+ return targetTag;
2137
+ let version = explicitVersion && /^\d+\.\d+\.\d+/.test(explicitVersion) ? explicitVersion : "";
2138
+ if (!version) {
2139
+ version = readBundledOpenclawVersion(invocation, targetTag);
2140
+ }
2141
+ if (!version || !/^\d+\.\d+\.\d+/.test(version))
2142
+ return targetTag;
2143
+ // Build the pinned tag by replacing the mutable tag portion.
2144
+ // "ghcr.io/foo/bar:latest" → "ghcr.io/foo/bar:2026.4.9"
2145
+ // "ghcr.io/foo/bar" → "ghcr.io/foo/bar:2026.4.9"
2146
+ const colonIdx = targetTag.lastIndexOf(":");
2147
+ const slashIdx = targetTag.lastIndexOf("/");
2148
+ const hasTag = colonIdx > slashIdx;
2149
+ const repo = hasTag ? targetTag.slice(0, colonIdx) : targetTag;
2150
+ const pinnedTag = `${repo}:${version}`;
2151
+ if (pinnedTag === targetTag)
2152
+ return targetTag;
2153
+ try {
2154
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, pinnedTag], { timeout: 10000, stdio: "ignore" });
2155
+ }
2156
+ catch {
2157
+ // Could not create the local alias — fall back to original tag.
2158
+ return targetTag;
2159
+ }
2160
+ // Drop the mutable original alias (`:latest` / `:slim`) now that the pinned
2161
+ // tag is in place. Removing a tag is cheap and leaves the underlying image
2162
+ // alive because the new pinned reference still points to it. Best-effort:
2163
+ // silent when the tag is already gone or in use.
2164
+ if (/:(latest|slim)$/.test(targetTag)) {
2165
+ try {
2166
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", targetTag], { timeout: 10000, stdio: "ignore" });
2167
+ }
2168
+ catch { /* best-effort cleanup */ }
2169
+ }
2170
+ return pinnedTag;
2171
+ }
2172
+ async function pullOrBuildOpenclawImageWithTask(task, tag) {
2173
+ const targetTag = tag || DEFAULT_OPENCLAW_DOCKER_IMAGE;
2174
+ try {
2175
+ const invocation = resolveDockerInvocation();
2176
+ // Fast check: if image already exists locally, skip
2177
+ try {
2178
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", targetTag], {
2179
+ timeout: 10000,
2180
+ stdio: "ignore",
2181
+ });
2182
+ const pinned = capturePinnedImageTag(invocation, targetTag);
2183
+ setOpenclawDockerImage(pinned);
2184
+ emitTask(task, { type: "done", message: `Docker 镜像已存在: ${pinned}`, progress: 100 });
2185
+ task.status = "done";
2186
+ return { ok: true, message: `Docker image ${pinned} already exists`, taskId: task.id };
2187
+ }
2188
+ catch { /* image not found, proceed */ }
2189
+ // ── Step 1: Try docker pull from registry ─────────────────────
2190
+ emitTask(task, { type: "progress", message: `正在拉取镜像: ${targetTag} ...`, progress: 10 });
2191
+ const pullResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", targetTag], { timeout: 600000 });
2192
+ if (pullResult.ok) {
2193
+ const pinned = capturePinnedImageTag(invocation, targetTag);
2194
+ setOpenclawDockerImage(pinned);
2195
+ emitTask(task, { type: "done", message: `镜像拉取成功: ${pinned}`, progress: 100 });
2196
+ task.status = "done";
2197
+ return { ok: true, message: `Docker image ${pinned} pulled`, taskId: task.id };
2198
+ }
2199
+ // ── Step 2: Fallback to local build ───────────────────────────
2200
+ console.log(`[setup] docker pull failed for ${targetTag}, falling back to local build...`);
2201
+ emitTask(task, { type: "progress", message: `拉取失败,正在本地构建镜像: ${targetTag} ...`, progress: 20 });
2202
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
2203
+ const dockerfilePath = join(projectRoot, "Dockerfile.openclaw-slim");
2204
+ if (!existsSync(dockerfilePath)) {
2205
+ emitTask(task, { type: "error", message: "Dockerfile.openclaw-slim not found, cannot fallback to local build" });
2206
+ task.status = "error";
2207
+ return { ok: false, message: "Docker pull failed and Dockerfile.openclaw-slim not found", taskId: task.id };
2208
+ }
2209
+ // Resolve the OpenClaw version from npm so the build-arg busts the Docker
2210
+ // layer cache for `RUN npm install openclaw@${ver}`. The Dockerfile's
2211
+ // ARG OPENCLAW_VERSION=latest default would otherwise cause the layer to
2212
+ // be silently reused across releases.
2213
+ const openclawVersion = resolveOpenclawNpmVersion();
2214
+ console.log(`[setup] building openclaw image with OPENCLAW_VERSION=${openclawVersion}`);
2215
+ const buildResult = await spawnWithTask(task, invocation.cmd, [
2216
+ ...invocation.argsPrefix,
2217
+ "build",
2218
+ "--network=host",
2219
+ "--build-arg", `OPENCLAW_VERSION=${openclawVersion}`,
2220
+ "-f", dockerfilePath,
2221
+ "-t", targetTag,
2222
+ projectRoot,
2223
+ ], { timeout: 1800000, progressParser: dockerBuildProgressParser });
2224
+ if (!buildResult.ok) {
2225
+ try {
2226
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
2227
+ }
2228
+ catch { }
2229
+ emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
2230
+ task.status = "error";
2231
+ return { ok: false, message: "Docker image build failed", error: buildResult.output, taskId: task.id };
2232
+ }
2233
+ // Local builds don't get labels from the GitHub Action's `labels:` field,
2234
+ // so pass the npm version we already know to let capturePinnedImageTag
2235
+ // mint the pinned tag without relying on docker inspect.
2236
+ const pinned = capturePinnedImageTag(invocation, targetTag, openclawVersion);
2237
+ setOpenclawDockerImage(pinned);
2238
+ emitTask(task, { type: "done", message: `OpenClaw 镜像就绪 (本地构建): ${pinned}`, progress: 100 });
2239
+ task.status = "done";
2240
+ return { ok: true, message: `Docker image ${pinned} built locally`, taskId: task.id };
2241
+ }
2242
+ catch (e) {
2243
+ emitTask(task, { type: "error", message: `镜像获取失败: ${e.message}` });
2244
+ task.status = "error";
2245
+ return { ok: false, message: "Docker image pull/build failed", error: e.message, taskId: task.id };
2246
+ }
2247
+ }
2248
+ export async function buildSlimOpenclawImage(tag) {
2249
+ const task = createTask("openclaw-docker-pull");
2250
+ return pullOrBuildOpenclawImageWithTask(task, tag);
2251
+ }
2252
+ export function startBuildSlimOpenclawImage(tag) {
2253
+ const task = createTask("openclaw-docker-pull");
2254
+ void pullOrBuildOpenclawImageWithTask(task, tag).catch((err) => {
2255
+ emitTask(task, { type: "error", message: `镜像获取失败: ${err?.message || err}` });
1829
2256
  task.status = "error";
1830
2257
  });
1831
- return { ok: true, message: "Docker image build started", taskId: task.id };
2258
+ return { ok: true, message: "Docker image pull started", taskId: task.id };
2259
+ }
2260
+ /** @deprecated Use buildSlimOpenclawImage instead */
2261
+ export async function buildCustomOpenclawImage(tag) {
2262
+ return buildSlimOpenclawImage(tag);
2263
+ }
2264
+ /** @deprecated Use startBuildSlimOpenclawImage instead */
2265
+ export function startBuildCustomOpenclawImage(tag) {
2266
+ return startBuildSlimOpenclawImage(tag);
1832
2267
  }
1833
2268
  export async function runFullSetup(options = {}) {
1834
2269
  const steps = [];
1835
2270
  let allOk = true;
1836
2271
  const defaults = {
1837
2272
  installNomad: true,
1838
- installOpenclaw: true,
1839
2273
  buildDockerImage: true,
1840
2274
  ...options,
1841
2275
  };
@@ -1872,14 +2306,6 @@ export async function runFullSetup(options = {}) {
1872
2306
  }
1873
2307
  }
1874
2308
  }
1875
- if (defaults.installOpenclaw) {
1876
- steps.push({ step: "openclaw", status: "running", message: "Installing OpenClaw..." });
1877
- const result = await installOpenclaw();
1878
- steps[steps.length - 1].status = result.ok ? "done" : "error";
1879
- steps[steps.length - 1].message = result.message;
1880
- if (!result.ok)
1881
- allOk = false;
1882
- }
1883
2309
  // Prepare Docker image: pull official image or build slim base (legacy).
1884
2310
  if (defaults.buildDockerImage) {
1885
2311
  // Restart Nomad so it re-detects Docker driver after Docker was installed
@@ -1893,9 +2319,7 @@ export async function runFullSetup(options = {}) {
1893
2319
  }
1894
2320
  catch { }
1895
2321
  steps.push({ step: "docker-image", status: "running", message: "Building OpenClaw Docker image..." });
1896
- const imgResult = isOfficialImage()
1897
- ? await buildCustomOpenclawImage()
1898
- : await buildOpenclawDockerImage();
2322
+ const imgResult = await buildSlimOpenclawImage();
1899
2323
  steps[steps.length - 1].status = imgResult.ok ? "done" : "error";
1900
2324
  steps[steps.length - 1].message = imgResult.message;
1901
2325
  if (!imgResult.ok)