jishushell 0.4.2-beta2 → 0.4.10

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 (75) hide show
  1. package/Dockerfile.openclaw-slim +58 -0
  2. package/INSTALL-NOTICE +7 -1
  3. package/dist/auth.js +3 -3
  4. package/dist/auth.js.map +1 -1
  5. package/dist/cli.js +517 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +21 -4
  8. package/dist/config.js +88 -54
  9. package/dist/config.js.map +1 -1
  10. package/dist/control.js +5 -5
  11. package/dist/control.js.map +1 -1
  12. package/dist/doctor.js +47 -14
  13. package/dist/doctor.js.map +1 -1
  14. package/dist/install.d.ts +1 -1
  15. package/dist/install.js +15 -29
  16. package/dist/install.js.map +1 -1
  17. package/dist/routes/backup.d.ts +2 -0
  18. package/dist/routes/backup.js +370 -0
  19. package/dist/routes/backup.js.map +1 -0
  20. package/dist/routes/instances.d.ts +1 -0
  21. package/dist/routes/instances.js +51 -11
  22. package/dist/routes/instances.js.map +1 -1
  23. package/dist/routes/setup.js +3 -5
  24. package/dist/routes/setup.js.map +1 -1
  25. package/dist/server.js +29 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/services/backup-manager.d.ts +253 -0
  28. package/dist/services/backup-manager.js +2014 -0
  29. package/dist/services/backup-manager.js.map +1 -0
  30. package/dist/services/backup-verify.d.ts +26 -0
  31. package/dist/services/backup-verify.js +240 -0
  32. package/dist/services/backup-verify.js.map +1 -0
  33. package/dist/services/instance-manager.d.ts +24 -4
  34. package/dist/services/instance-manager.js +218 -49
  35. package/dist/services/instance-manager.js.map +1 -1
  36. package/dist/services/nomad-manager.js +72 -131
  37. package/dist/services/nomad-manager.js.map +1 -1
  38. package/dist/services/process-manager.js +4 -3
  39. package/dist/services/process-manager.js.map +1 -1
  40. package/dist/services/setup-manager.d.ts +4 -2
  41. package/dist/services/setup-manager.js +268 -129
  42. package/dist/services/setup-manager.js.map +1 -1
  43. package/dist/utils/fs.d.ts +85 -0
  44. package/dist/utils/fs.js +111 -0
  45. package/dist/utils/fs.js.map +1 -0
  46. package/dist/utils/safe-json.d.ts +2 -0
  47. package/dist/utils/safe-json.js +22 -16
  48. package/dist/utils/safe-json.js.map +1 -1
  49. package/install/jishu-install-china.sh +3092 -0
  50. package/install/jishu-install.sh +310 -108
  51. package/install/jishu-uninstall.sh +276 -391
  52. package/install/post-install.sh +9 -0
  53. package/openclaw-entry.sh +15 -0
  54. package/package.json +4 -1
  55. package/public/assets/Dashboard-DhsrzJ4F.js +1 -0
  56. package/public/assets/{InitPassword-CslWYy8G.js → InitPassword-BjubiVdd.js} +1 -1
  57. package/public/assets/InstanceDetail-DMcywsof.js +17 -0
  58. package/public/assets/{Login-d45wtgVA.js → Login-CUoEZOWR.js} +1 -1
  59. package/public/assets/NewInstance-Bk0G4EiJ.js +1 -0
  60. package/public/assets/Settings-D5tHL_h5.js +1 -0
  61. package/public/assets/Setup-4t6E3Rut.js +1 -0
  62. package/public/assets/index-BJ47MWpF.css +1 -0
  63. package/public/assets/index-DbX85irc.js +16 -0
  64. package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
  65. package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
  66. package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
  67. package/public/index.html +4 -4
  68. package/public/assets/Dashboard-Dxsq690N.js +0 -1
  69. package/public/assets/InstanceDetail-DmEkMj-t.js +0 -14
  70. package/public/assets/NewInstance-Czp5-AJe.js +0 -1
  71. package/public/assets/Settings-BKMGck05.js +0 -1
  72. package/public/assets/Setup-D3rfLWjZ.js +0 -1
  73. package/public/assets/index-77Ug7feY.css +0 -1
  74. package/public/assets/index-DkDnIohs.js +0 -16
  75. 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, existsSync, readFileSync, 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,6 +21,9 @@ 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");
24
+ const COLIMA_DIR = join(JISHUSHELL_HOME, "colima");
25
+ const COLIMA_PROFILE = "jishushell";
26
+ const COLIMA_SOCKET = join(COLIMA_DIR, COLIMA_PROFILE, "docker.sock");
23
27
  const NOMAD_VERSION = "1.11.3";
24
28
  let _serverPort = 8090;
25
29
  export function setServerPort(port) { _serverPort = port; }
@@ -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 = {
@@ -839,7 +819,7 @@ export async function installNomad() {
839
819
  try {
840
820
  const systemNomad = execSync("which nomad 2>/dev/null", { encoding: "utf-8" }).trim();
841
821
  if (systemNomad && existsSync(systemNomad)) {
842
- mkdirSync(BIN_DIR, { recursive: true });
822
+ ensureDirHost(BIN_DIR);
843
823
  symlinkSync(systemNomad, NOMAD_BIN);
844
824
  const version = execSync(`${NOMAD_BIN} version`, { encoding: "utf-8", timeout: 5000 }).trim();
845
825
  console.log(`[nomad] Linked system nomad ${systemNomad} → ${NOMAD_BIN}`);
@@ -849,7 +829,7 @@ export async function installNomad() {
849
829
  catch { /* system nomad not found — proceed to download */ }
850
830
  }
851
831
  const task = createTask("nomad");
852
- mkdirSync(BIN_DIR, { recursive: true });
832
+ ensureDirHost(BIN_DIR);
853
833
  const url = getNomadDownloadUrl();
854
834
  const zipPath = join(BIN_DIR, "nomad.zip");
855
835
  emitTask(task, { type: "progress", message: "下载 Nomad...", progress: 0 });
@@ -917,12 +897,10 @@ function fixNomadDirOwnership() {
917
897
  }
918
898
  function writeNomadConfig() {
919
899
  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);
900
+ ensureDirHost(NOMAD_CONFIG_DIR);
901
+ ensureDirContainer(NOMAD_DATA_DIR);
902
+ ensureDirContainer(NOMAD_ALLOC_DIR);
903
+ const loopbackIface = process.platform === "darwin" ? "lo0" : "lo";
926
904
  const config = `
927
905
  data_dir = "${NOMAD_DATA_DIR}"
928
906
 
@@ -944,13 +922,14 @@ server {
944
922
  client {
945
923
  enabled = true
946
924
  servers = ["127.0.0.1:4647"]
925
+ network_interface = "${loopbackIface}"
947
926
  alloc_dir = "${NOMAD_ALLOC_DIR}"
948
927
 
949
- drain_on_shutdown {
950
- deadline = "30s"
951
- force = true
952
- ignore_system_jobs = true
953
- }
928
+ # drain_on_shutdown intentionally omitted: on single-node Pi there is
929
+ # nowhere to drain workloads to, and draining on every systemctl restart
930
+ # would kill every running OpenClaw instance. Without this block Nomad
931
+ # leaves allocations running across client restarts — the docker driver
932
+ # re-attaches to the existing containers on startup.
954
933
  }
955
934
 
956
935
  plugin "docker" {
@@ -966,7 +945,7 @@ acl {
966
945
  enabled = true
967
946
  }
968
947
  `;
969
- writeFileSync(join(NOMAD_CONFIG_DIR, "nomad.hcl"), config);
948
+ writeConfigFile(join(NOMAD_CONFIG_DIR, "nomad.hcl"), config);
970
949
  }
971
950
  export function loadNomadToken() {
972
951
  if (process.env.NOMAD_TOKEN)
@@ -1077,10 +1056,10 @@ async function bootstrapNomadACL() {
1077
1056
  const saveToken = (token) => {
1078
1057
  const envFile = join(JISHUSHELL_HOME, "nomad.env");
1079
1058
  const envContent = `NOMAD_TOKEN=${token}\n`;
1080
- writeFileSync(envFile, envContent, { mode: 0o600 });
1059
+ writeSecretFile(envFile, envContent);
1081
1060
  try {
1082
1061
  execFileSync("sudo", ["-n", "mkdir", "-p", "/etc/jishushell"], { timeout: 5000, stdio: "pipe" });
1083
- writeFileSync("/tmp/.nomad-env-tmp", envContent, { mode: 0o600 });
1062
+ writeSystemTmpFile("/tmp/.nomad-env-tmp", envContent);
1084
1063
  execFileSync("sudo", ["-n", "cp", "/tmp/.nomad-env-tmp", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1085
1064
  execFileSync("sudo", ["-n", "chmod", "600", "/etc/jishushell/nomad.env"], { timeout: 5000, stdio: "pipe" });
1086
1065
  try {
@@ -1114,7 +1093,7 @@ async function bootstrapNomadACL() {
1114
1093
  // Write the reset trigger file (Nomad reads this on startup to allow re-bootstrap)
1115
1094
  const resetFile = join(NOMAD_DATA_DIR, "server", "acl-bootstrap-reset");
1116
1095
  try {
1117
- writeFileSync(resetFile, resetIndex, { encoding: "utf-8" });
1096
+ writeConfigFile(resetFile, resetIndex);
1118
1097
  }
1119
1098
  catch (writeErr) {
1120
1099
  console.warn("[nomad] Could not write acl-bootstrap-reset file:", writeErr.message);
@@ -1257,14 +1236,18 @@ export async function stopNomad() {
1257
1236
  try {
1258
1237
  if (!isPortListening(4646))
1259
1238
  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
1239
+ // SIGTERM: graceful shutdown — Nomad flushes state and detaches from
1240
+ // running allocs without killing them (drain_on_shutdown is deliberately
1241
+ // not configured, so the docker containers keep running and will be
1242
+ // re-attached when Nomad comes back).
1243
+ // Nomad runs as the current user; no sudo needed to send SIGTERM.
1262
1244
  try {
1263
1245
  execSync("pkill -TERM -f 'nomad agent'", { timeout: 5000 });
1264
1246
  }
1265
1247
  catch { }
1266
- // Wait up to 35s for process to exit (drain_on_shutdown deadline + grace)
1267
- for (let i = 0; i < 35; i++) {
1248
+ // Wait up to 10s for the process to exit. No drain means shutdown is
1249
+ // near-instant most of this budget is slack for slow disks on Pi.
1250
+ for (let i = 0; i < 10; i++) {
1268
1251
  await new Promise(r => setTimeout(r, 1000));
1269
1252
  if (!isPortListening(4646))
1270
1253
  return { ok: true, message: "Nomad stopped" };
@@ -1292,8 +1275,7 @@ export function installNomadSystemd() {
1292
1275
  if (process.platform === "darwin") {
1293
1276
  const plistLabel = "com.jishushell.nomad";
1294
1277
  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";
1278
+ const dockerSock = COLIMA_SOCKET;
1297
1279
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1298
1280
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1299
1281
  <plist version="1.0">
@@ -1317,8 +1299,7 @@ export function installNomadSystemd() {
1317
1299
  </dict>
1318
1300
  </plist>`;
1319
1301
  const plistPath = join(process.env.HOME || dirname(JISHUSHELL_HOME), `Library/LaunchAgents/${plistLabel}.plist`);
1320
- mkdirSync(dirname(plistPath), { recursive: true });
1321
- writeFileSync(plistPath, plistContent);
1302
+ writeConfigFile(plistPath, plistContent);
1322
1303
  try {
1323
1304
  execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
1324
1305
  }
@@ -1348,7 +1329,7 @@ WantedBy=multi-user.target
1348
1329
  `;
1349
1330
  const servicePath = "/etc/systemd/system/nomad.service";
1350
1331
  execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1351
- writeFileSync("/tmp/nomad.service", serviceContent);
1332
+ writeSystemTmpFile("/tmp/nomad.service", serviceContent);
1352
1333
  execFileSync("sudo", ["cp", "/tmp/nomad.service", servicePath], { timeout: 5000 });
1353
1334
  execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1354
1335
  execFileSync("sudo", ["systemctl", "enable", "--now", "nomad"], { timeout: 15000 });
@@ -1384,11 +1365,11 @@ export function installJishushellSystemd(port) {
1384
1365
  export JISHUSHELL_HOME="${JISHUSHELL_HOME}"
1385
1366
  export HOME="${realHome}"
1386
1367
  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}"
1368
+ export DOCKER_HOST="unix://${COLIMA_SOCKET}"
1369
+ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin:${dirname(nodeBin)}:\${PATH}"
1388
1370
  exec "${nodeBin}" "${cliBin}" serve --port ${resolvedPort}
1389
1371
  `;
1390
- mkdirSync(dirname(wrapperPath), { recursive: true });
1391
- writeFileSync(wrapperPath, wrapperContent, { mode: 0o755 });
1372
+ writeExecutableFile(wrapperPath, wrapperContent);
1392
1373
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
1393
1374
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1394
1375
  <plist version="1.0">
@@ -1406,8 +1387,7 @@ exec "${nodeBin}" "${cliBin}" serve --port ${resolvedPort}
1406
1387
  </dict>
1407
1388
  </plist>`;
1408
1389
  const plistPath = join(process.env.HOME || realHome, `Library/LaunchAgents/${plistLabel}.plist`);
1409
- mkdirSync(dirname(plistPath), { recursive: true });
1410
- writeFileSync(plistPath, plistContent);
1390
+ writeConfigFile(plistPath, plistContent);
1411
1391
  const panelAlreadyRunning = isPortListening(resolvedPort);
1412
1392
  if (!panelAlreadyRunning) {
1413
1393
  try {
@@ -1441,7 +1421,7 @@ Environment=NODE_ENV=production
1441
1421
  WantedBy=multi-user.target
1442
1422
  `;
1443
1423
  execFileSync("sudo", ["mkdir", "-p", "/etc/jishushell"], { timeout: 5000 });
1444
- writeFileSync("/tmp/jishushell.service", serviceContent);
1424
+ writeSystemTmpFile("/tmp/jishushell.service", serviceContent);
1445
1425
  execFileSync("sudo", ["cp", "/tmp/jishushell.service", "/etc/systemd/system/jishushell.service"], { timeout: 5000 });
1446
1426
  execFileSync("sudo", ["systemctl", "daemon-reload"], { timeout: 5000 });
1447
1427
  execFileSync("sudo", ["systemctl", "enable", "jishushell"], { timeout: 15000 });
@@ -1463,7 +1443,7 @@ export async function installOpenclaw(version = "latest") {
1463
1443
  return { ok: true, message: `OpenClaw already installed: ${ver}` };
1464
1444
  }
1465
1445
  const task = createTask("openclaw");
1466
- mkdirSync(OPENCLAW_PKG_DIR, { recursive: true });
1446
+ ensureDirHost(OPENCLAW_PKG_DIR);
1467
1447
  emitTask(task, { type: "progress", message: "开始安装 OpenClaw...", progress: 0 });
1468
1448
  // Monitor directory size for progress estimation
1469
1449
  const sizeTracker = setInterval(() => {
@@ -1532,40 +1512,21 @@ function checkDockerImageExists() {
1532
1512
  return true;
1533
1513
  }
1534
1514
  catch {
1515
+ // Backward compat: after upgrading from old image names (jishushell-openclaw:*)
1516
+ // to the new slim tag, the new image may not exist yet. Check if any old image
1517
+ // is still available so we don't kick the user back to the Setup wizard.
1518
+ try {
1519
+ const invocation = resolveDockerInvocation();
1520
+ const out = execFileSync(invocation.cmd, [...invocation.argsPrefix, "images", "--format", "{{.Repository}}:{{.Tag}}"], { encoding: "utf8", timeout: 5000 });
1521
+ if (out.split("\n").some((l) => /^jishushell-openclaw:/.test(l)))
1522
+ return true;
1523
+ }
1524
+ catch { }
1535
1525
  return false;
1536
1526
  }
1537
1527
  }
1538
1528
  // 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
1529
  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
1530
  return getOpenclawDockerImage();
1570
1531
  }
1571
1532
  // Base image and mirror list for the OpenClaw Docker build.
@@ -1615,7 +1576,7 @@ function resolveVersionedBuildTag() {
1615
1576
  if (existsSync(pkg)) {
1616
1577
  const ver = JSON.parse(readFileSync(pkg, "utf-8")).version;
1617
1578
  if (ver)
1618
- return `${CUSTOM_IMAGE_PREFIX}:${ver}`;
1579
+ return `jishushell-openclaw:${ver}`;
1619
1580
  }
1620
1581
  }
1621
1582
  catch { }
@@ -1665,9 +1626,9 @@ CMD ["gateway", "run", "--port", "18789", "--allow-unconfigured"]
1665
1626
  `;
1666
1627
  // Use a temp dir as build context — no files to COPY means no large transfer.
1667
1628
  const buildDir = join(tmpdir(), `jishushell-base-build-${Date.now()}`);
1668
- mkdirSync(buildDir, { recursive: true });
1629
+ ensureDirHost(buildDir);
1669
1630
  const dockerfilePath = join(buildDir, "Dockerfile");
1670
- writeFileSync(dockerfilePath, dockerfile);
1631
+ writeConfigFile(dockerfilePath, dockerfile);
1671
1632
  emitTask(task, { type: "progress", message: `构建基础镜像 ${targetTag}(无需拷贝二进制,速度极快)...`, progress: 10 });
1672
1633
  let result;
1673
1634
  try {
@@ -1694,7 +1655,7 @@ CMD ["gateway", "run", "--port", "18789", "--allow-unconfigured"]
1694
1655
  task.status = "error";
1695
1656
  return { ok: false, message: "Docker image build failed", error: result.output, taskId: task.id };
1696
1657
  }
1697
- const localTag = `${CUSTOM_IMAGE_PREFIX}:local`;
1658
+ const localTag = "jishushell-openclaw:local";
1698
1659
  if (targetTag !== localTag) {
1699
1660
  try {
1700
1661
  execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, localTag], { timeout: 10000, stdio: "ignore" });
@@ -1787,7 +1748,7 @@ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
1787
1748
  `;
1788
1749
  // Write Dockerfile into the npm package directory (build context)
1789
1750
  const dockerfilePath = join(OPENCLAW_PKG_DIR, "Dockerfile");
1790
- writeFileSync(dockerfilePath, dockerfile);
1751
+ writeConfigFile(dockerfilePath, dockerfile);
1791
1752
  let buildResult;
1792
1753
  try {
1793
1754
  buildResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "build", "--network=host", "-t", targetTag, OPENCLAW_PKG_DIR], { timeout: 1800000, progressParser: dockerBuildProgressParser });
@@ -1818,24 +1779,212 @@ CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
1818
1779
  return { ok: false, message: "Docker image build failed", error: e.message, taskId: task.id };
1819
1780
  }
1820
1781
  }
1821
- export async function buildCustomOpenclawImage(tag) {
1822
- const task = createTask("openclaw-docker-build");
1823
- return buildCustomOpenclawImageWithTask(task, tag);
1782
+ // ── Pull or build OpenClaw Docker image ───────────────────────────
1783
+ /** Matches a semver-ish tag suffix, e.g. "...:2026.4.9" or "...:v1.2.3-beta". */
1784
+ const PINNED_IMAGE_TAG_RE = /:[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/;
1785
+ /**
1786
+ * Query the npm registry for the current OpenClaw version. Used to bust the
1787
+ * Docker layer cache for `RUN npm install openclaw@${ver}` during local build.
1788
+ * Returns "latest" when npm is unreachable so the build can still proceed.
1789
+ */
1790
+ function resolveOpenclawNpmVersion() {
1791
+ try {
1792
+ const out = execFileSync("npm", ["view", "openclaw", "version"], {
1793
+ timeout: 15000,
1794
+ encoding: "utf-8",
1795
+ stdio: ["ignore", "pipe", "ignore"],
1796
+ }).trim();
1797
+ if (/^\d+\.\d+\.\d+/.test(out))
1798
+ return out;
1799
+ }
1800
+ catch { /* npm not reachable */ }
1801
+ return "latest";
1824
1802
  }
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}` });
1803
+ /**
1804
+ * Read the OpenClaw version actually bundled at /app/ inside a Docker image,
1805
+ * bypassing `openclaw-entry.sh`'s `.npm-global/` override. This is the
1806
+ * authoritative source of truth the image's OCI label can be wrong
1807
+ * (CI bugs, layer cache reuse), but `/app/node_modules/openclaw/package.json`
1808
+ * is the exact content that ran through `npm install`.
1809
+ *
1810
+ * Spawns a throw-away container with `--entrypoint node` so Node prints the
1811
+ * version directly. Returns "" when docker is unavailable or the path is
1812
+ * missing (e.g. a non-openclaw image).
1813
+ */
1814
+ function readBundledOpenclawVersion(invocation, image) {
1815
+ try {
1816
+ const out = execFileSync(invocation.cmd, [
1817
+ ...invocation.argsPrefix,
1818
+ "run", "--rm",
1819
+ "--entrypoint", "node",
1820
+ image,
1821
+ "-p",
1822
+ "require('/app/node_modules/openclaw/package.json').version",
1823
+ ], { timeout: 20000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
1824
+ if (/^\d+\.\d+\.\d+/.test(out))
1825
+ return out;
1826
+ }
1827
+ catch { /* docker unavailable, image missing, or path not present */ }
1828
+ return "";
1829
+ }
1830
+ /**
1831
+ * After a successful pull or build, capture the image's real OpenClaw version
1832
+ * and return a pinned tag of the form `ghcr.io/.../openclaw-runtime:<version>`.
1833
+ * The pinned tag is added as a local alias via `docker tag` so subsequent
1834
+ * Nomad allocations see an immutable reference and never re-pull on restart.
1835
+ *
1836
+ * Version discovery order:
1837
+ * 1. `explicitVersion` when the caller already knows it (e.g. the local build
1838
+ * path, which queries npm for the version and passes it as `--build-arg`).
1839
+ * 2. Bundled `/app/node_modules/openclaw/package.json` inside the image
1840
+ * (authoritative — bypasses both the `.npm-global/` override layer and a
1841
+ * potentially stale OCI label).
1842
+ *
1843
+ * Returns the original tag unchanged when:
1844
+ * - the target is already a pinned version tag
1845
+ * - no version can be discovered
1846
+ * - docker tag fails for any reason
1847
+ */
1848
+ function capturePinnedImageTag(invocation, targetTag, explicitVersion) {
1849
+ // Already pinned? Nothing to do.
1850
+ if (PINNED_IMAGE_TAG_RE.test(targetTag))
1851
+ return targetTag;
1852
+ let version = explicitVersion && /^\d+\.\d+\.\d+/.test(explicitVersion) ? explicitVersion : "";
1853
+ if (!version) {
1854
+ version = readBundledOpenclawVersion(invocation, targetTag);
1855
+ }
1856
+ if (!version || !/^\d+\.\d+\.\d+/.test(version))
1857
+ return targetTag;
1858
+ // Build the pinned tag by replacing the mutable tag portion.
1859
+ // "ghcr.io/foo/bar:latest" → "ghcr.io/foo/bar:2026.4.9"
1860
+ // "ghcr.io/foo/bar" → "ghcr.io/foo/bar:2026.4.9"
1861
+ const colonIdx = targetTag.lastIndexOf(":");
1862
+ const slashIdx = targetTag.lastIndexOf("/");
1863
+ const hasTag = colonIdx > slashIdx;
1864
+ const repo = hasTag ? targetTag.slice(0, colonIdx) : targetTag;
1865
+ const pinnedTag = `${repo}:${version}`;
1866
+ if (pinnedTag === targetTag)
1867
+ return targetTag;
1868
+ try {
1869
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, pinnedTag], { timeout: 10000, stdio: "ignore" });
1870
+ }
1871
+ catch {
1872
+ // Could not create the local alias — fall back to original tag.
1873
+ return targetTag;
1874
+ }
1875
+ // Drop the mutable original alias (`:latest` / `:slim`) now that the pinned
1876
+ // tag is in place. Removing a tag is cheap and leaves the underlying image
1877
+ // alive because the new pinned reference still points to it. Best-effort:
1878
+ // silent when the tag is already gone or in use.
1879
+ if (/:(latest|slim)$/.test(targetTag)) {
1880
+ try {
1881
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", targetTag], { timeout: 10000, stdio: "ignore" });
1882
+ }
1883
+ catch { /* best-effort cleanup */ }
1884
+ }
1885
+ return pinnedTag;
1886
+ }
1887
+ async function pullOrBuildOpenclawImageWithTask(task, tag) {
1888
+ const targetTag = tag || DEFAULT_OPENCLAW_DOCKER_IMAGE;
1889
+ try {
1890
+ const invocation = resolveDockerInvocation();
1891
+ // Fast check: if image already exists locally, skip
1892
+ try {
1893
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", targetTag], {
1894
+ timeout: 10000,
1895
+ stdio: "ignore",
1896
+ });
1897
+ const pinned = capturePinnedImageTag(invocation, targetTag);
1898
+ setOpenclawDockerImage(pinned);
1899
+ emitTask(task, { type: "done", message: `Docker 镜像已存在: ${pinned}`, progress: 100 });
1900
+ task.status = "done";
1901
+ return { ok: true, message: `Docker image ${pinned} already exists`, taskId: task.id };
1902
+ }
1903
+ catch { /* image not found, proceed */ }
1904
+ // ── Step 1: Try docker pull from registry ─────────────────────
1905
+ emitTask(task, { type: "progress", message: `正在拉取镜像: ${targetTag} ...`, progress: 10 });
1906
+ const pullResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", targetTag], { timeout: 600000 });
1907
+ if (pullResult.ok) {
1908
+ const pinned = capturePinnedImageTag(invocation, targetTag);
1909
+ setOpenclawDockerImage(pinned);
1910
+ emitTask(task, { type: "done", message: `镜像拉取成功: ${pinned}`, progress: 100 });
1911
+ task.status = "done";
1912
+ return { ok: true, message: `Docker image ${pinned} pulled`, taskId: task.id };
1913
+ }
1914
+ // ── Step 2: Fallback to local build ───────────────────────────
1915
+ console.log(`[setup] docker pull failed for ${targetTag}, falling back to local build...`);
1916
+ emitTask(task, { type: "progress", message: `拉取失败,正在本地构建镜像: ${targetTag} ...`, progress: 20 });
1917
+ const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
1918
+ const dockerfilePath = join(projectRoot, "Dockerfile.openclaw-slim");
1919
+ if (!existsSync(dockerfilePath)) {
1920
+ emitTask(task, { type: "error", message: "Dockerfile.openclaw-slim not found, cannot fallback to local build" });
1921
+ task.status = "error";
1922
+ return { ok: false, message: "Docker pull failed and Dockerfile.openclaw-slim not found", taskId: task.id };
1923
+ }
1924
+ // Resolve the OpenClaw version from npm so the build-arg busts the Docker
1925
+ // layer cache for `RUN npm install openclaw@${ver}`. The Dockerfile's
1926
+ // ARG OPENCLAW_VERSION=latest default would otherwise cause the layer to
1927
+ // be silently reused across releases.
1928
+ const openclawVersion = resolveOpenclawNpmVersion();
1929
+ console.log(`[setup] building openclaw image with OPENCLAW_VERSION=${openclawVersion}`);
1930
+ const buildResult = await spawnWithTask(task, invocation.cmd, [
1931
+ ...invocation.argsPrefix,
1932
+ "build",
1933
+ "--network=host",
1934
+ "--build-arg", `OPENCLAW_VERSION=${openclawVersion}`,
1935
+ "-f", dockerfilePath,
1936
+ "-t", targetTag,
1937
+ projectRoot,
1938
+ ], { timeout: 1800000, progressParser: dockerBuildProgressParser });
1939
+ if (!buildResult.ok) {
1940
+ try {
1941
+ execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
1942
+ }
1943
+ catch { }
1944
+ emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
1945
+ task.status = "error";
1946
+ return { ok: false, message: "Docker image build failed", error: buildResult.output, taskId: task.id };
1947
+ }
1948
+ // Local builds don't get labels from the GitHub Action's `labels:` field,
1949
+ // so pass the npm version we already know to let capturePinnedImageTag
1950
+ // mint the pinned tag without relying on docker inspect.
1951
+ const pinned = capturePinnedImageTag(invocation, targetTag, openclawVersion);
1952
+ setOpenclawDockerImage(pinned);
1953
+ emitTask(task, { type: "done", message: `OpenClaw 镜像就绪 (本地构建): ${pinned}`, progress: 100 });
1954
+ task.status = "done";
1955
+ return { ok: true, message: `Docker image ${pinned} built locally`, taskId: task.id };
1956
+ }
1957
+ catch (e) {
1958
+ emitTask(task, { type: "error", message: `镜像获取失败: ${e.message}` });
1959
+ task.status = "error";
1960
+ return { ok: false, message: "Docker image pull/build failed", error: e.message, taskId: task.id };
1961
+ }
1962
+ }
1963
+ export async function buildSlimOpenclawImage(tag) {
1964
+ const task = createTask("openclaw-docker-pull");
1965
+ return pullOrBuildOpenclawImageWithTask(task, tag);
1966
+ }
1967
+ export function startBuildSlimOpenclawImage(tag) {
1968
+ const task = createTask("openclaw-docker-pull");
1969
+ void pullOrBuildOpenclawImageWithTask(task, tag).catch((err) => {
1970
+ emitTask(task, { type: "error", message: `镜像获取失败: ${err?.message || err}` });
1829
1971
  task.status = "error";
1830
1972
  });
1831
- return { ok: true, message: "Docker image build started", taskId: task.id };
1973
+ return { ok: true, message: "Docker image pull started", taskId: task.id };
1974
+ }
1975
+ /** @deprecated Use buildSlimOpenclawImage instead */
1976
+ export async function buildCustomOpenclawImage(tag) {
1977
+ return buildSlimOpenclawImage(tag);
1978
+ }
1979
+ /** @deprecated Use startBuildSlimOpenclawImage instead */
1980
+ export function startBuildCustomOpenclawImage(tag) {
1981
+ return startBuildSlimOpenclawImage(tag);
1832
1982
  }
1833
1983
  export async function runFullSetup(options = {}) {
1834
1984
  const steps = [];
1835
1985
  let allOk = true;
1836
1986
  const defaults = {
1837
1987
  installNomad: true,
1838
- installOpenclaw: true,
1839
1988
  buildDockerImage: true,
1840
1989
  ...options,
1841
1990
  };
@@ -1872,14 +2021,6 @@ export async function runFullSetup(options = {}) {
1872
2021
  }
1873
2022
  }
1874
2023
  }
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
2024
  // Prepare Docker image: pull official image or build slim base (legacy).
1884
2025
  if (defaults.buildDockerImage) {
1885
2026
  // Restart Nomad so it re-detects Docker driver after Docker was installed
@@ -1893,9 +2034,7 @@ export async function runFullSetup(options = {}) {
1893
2034
  }
1894
2035
  catch { }
1895
2036
  steps.push({ step: "docker-image", status: "running", message: "Building OpenClaw Docker image..." });
1896
- const imgResult = isOfficialImage()
1897
- ? await buildCustomOpenclawImage()
1898
- : await buildOpenclawDockerImage();
2037
+ const imgResult = await buildSlimOpenclawImage();
1899
2038
  steps[steps.length - 1].status = imgResult.ok ? "done" : "error";
1900
2039
  steps[steps.length - 1].message = imgResult.message;
1901
2040
  if (!imgResult.ok)