jishushell 0.4.2 → 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 (83) hide show
  1. package/Dockerfile.openclaw-slim +58 -0
  2. package/INSTALL-NOTICE +47 -0
  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/services/telemetry/activation.js +10 -7
  44. package/dist/services/telemetry/activation.js.map +1 -1
  45. package/dist/services/telemetry/client.js +7 -18
  46. package/dist/services/telemetry/client.js.map +1 -1
  47. package/dist/services/telemetry/heartbeat.js +12 -6
  48. package/dist/services/telemetry/heartbeat.js.map +1 -1
  49. package/dist/utils/fs.d.ts +85 -0
  50. package/dist/utils/fs.js +111 -0
  51. package/dist/utils/fs.js.map +1 -0
  52. package/dist/utils/safe-json.d.ts +2 -0
  53. package/dist/utils/safe-json.js +22 -16
  54. package/dist/utils/safe-json.js.map +1 -1
  55. package/install/jishu-install-china.sh +3092 -0
  56. package/install/jishu-install.sh +310 -108
  57. package/install/jishu-uninstall.sh +276 -391
  58. package/install/post-install.sh +23 -0
  59. package/openclaw-entry.sh +15 -0
  60. package/package.json +7 -4
  61. package/public/assets/Dashboard-DhsrzJ4F.js +1 -0
  62. package/public/assets/{InitPassword-CkehIkJG.js → InitPassword-BjubiVdd.js} +1 -1
  63. package/public/assets/InstanceDetail-DMcywsof.js +17 -0
  64. package/public/assets/{Login-RkjzTNWg.js → Login-CUoEZOWR.js} +1 -1
  65. package/public/assets/NewInstance-Bk0G4EiJ.js +1 -0
  66. package/public/assets/Settings-D5tHL_h5.js +1 -0
  67. package/public/assets/Setup-4t6E3Rut.js +1 -0
  68. package/public/assets/index-BJ47MWpF.css +1 -0
  69. package/public/assets/index-DbX85irc.js +16 -0
  70. package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
  71. package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
  72. package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
  73. package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
  74. package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
  75. package/public/index.html +4 -4
  76. package/public/assets/Dashboard-CAOQDYDR.js +0 -1
  77. package/public/assets/InstanceDetail-CzW2S95J.js +0 -14
  78. package/public/assets/NewInstance-DdbErdjA.js +0 -1
  79. package/public/assets/Settings-BUD7zwv9.js +0 -1
  80. package/public/assets/Setup-RRTIERGG.js +0 -1
  81. package/public/assets/index-77Ug7feY.css +0 -1
  82. package/public/assets/index-DfRnVUQR.js +0 -16
  83. package/public/assets/vendor-react-DONn7uBV.js +0 -59
@@ -1,12 +1,13 @@
1
1
  import { execFile, execFileSync } from "child_process";
2
2
  import { randomBytes } from "crypto";
3
- import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
3
+ import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
4
4
  import { createServer as netCreateServer } from "net";
5
5
  import { userInfo } from "os";
6
6
  import { dirname, join, resolve } from "path";
7
- import { INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
7
+ import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
8
8
  import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
9
9
  import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
10
+ import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
10
11
  const _configChangeListeners = [];
11
12
  export function onConfigChange(listener) {
12
13
  _configChangeListeners.push(listener);
@@ -357,7 +358,7 @@ function quoteEnvValue(value) {
357
358
  return JSON.stringify(value);
358
359
  }
359
360
  export function updateEnvFile(path, updates) {
360
- mkdirSync(dirname(path), { recursive: true });
361
+ ensureDirContainer(dirname(path));
361
362
  const existing = existsSync(path) ? readFileSync(path, "utf-8").split("\n") : [];
362
363
  const remaining = { ...updates };
363
364
  const newLines = [];
@@ -389,7 +390,7 @@ export function updateEnvFile(path, updates) {
389
390
  const content = output ? output + "\n" : "";
390
391
  // Atomic write: tmp then rename to protect against RPi power loss
391
392
  const tmp = path + ".tmp";
392
- writeFileSync(tmp, content, { mode: 0o600 });
393
+ writeSecretFile(tmp, content);
393
394
  renameSync(tmp, path);
394
395
  }
395
396
  // ── Provider key helpers ──
@@ -536,6 +537,40 @@ export const CHANNEL_PLUGIN_MAP = {
536
537
  // Official vendor plugins — need install (not bundled)
537
538
  "openclaw-weixin": "@tencent-weixin/openclaw-weixin",
538
539
  };
540
+ /**
541
+ * Known IM plugin entry IDs as they appear under `config.plugins.entries`.
542
+ * This is the union of channel IDs and the dir-alias names (e.g. `feishu` may
543
+ * register the plugin as `openclaw-lark`), which is what must be scrubbed when
544
+ * dissociating an instance from its inherited IM bindings.
545
+ */
546
+ const IM_PLUGIN_ENTRY_IDS = new Set([
547
+ ...Object.keys(CHANNEL_PLUGIN_MAP),
548
+ ...Object.values(CHANNEL_EXT_DIR_ALIAS),
549
+ ]);
550
+ /**
551
+ * Dissociate a cloned/imported config from its source instance's IM bindings.
552
+ *
553
+ * Mutates the given config in place:
554
+ * - Deletes the entire `channels` block (same channel cannot serve multiple
555
+ * instances, so every inherited enabled/credential/account entry must go).
556
+ * - Deletes matching IM entries from `plugins.entries` so the plugin loader
557
+ * does not try to boot a channel whose config no longer exists.
558
+ *
559
+ * Used by both domain clone (`createInstance`'s `cloneFrom` path) and the
560
+ * backup import paths (`importInstance`, `createFromBackup`) so that a new
561
+ * instance never inherits a half-configured IM binding.
562
+ */
563
+ export function stripImBindings(config) {
564
+ if (config?.channels)
565
+ delete config.channels;
566
+ const entries = config?.plugins?.entries;
567
+ if (entries && typeof entries === "object") {
568
+ for (const key of Object.keys(entries)) {
569
+ if (IM_PLUGIN_ENTRY_IDS.has(key))
570
+ delete entries[key];
571
+ }
572
+ }
573
+ }
539
574
  /** Check if a channel plugin is installed for an instance. */
540
575
  export function isChannelPluginInstalled(instanceId, channelId) {
541
576
  const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
@@ -546,8 +581,9 @@ export function isChannelPluginInstalled(instanceId, channelId) {
546
581
  || (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
547
582
  }
548
583
  /**
549
- * Install a single channel plugin on the host.
550
- * This is the unified entry point for all plugin installation — never use docker exec.
584
+ * Install a single channel plugin.
585
+ * Docker mode: runs install inside the running container via docker exec.
586
+ * Host mode (fallback): spawns the host openclaw binary directly.
551
587
  */
552
588
  export async function installChannelPlugin(instanceId, channelId) {
553
589
  const pkg = CHANNEL_PLUGIN_MAP[channelId];
@@ -556,14 +592,17 @@ export async function installChannelPlugin(instanceId, channelId) {
556
592
  if (isChannelPluginInstalled(instanceId, channelId))
557
593
  return;
558
594
  const openclawHome = getOpenclawHomeInternal(instanceId);
559
- const openclawBin = resolveOpenclawBin();
560
595
  const extensionsDir = getChannelExtensionsDir(instanceId);
561
- // Ensure the node binary that runs jishushell is on PATH for the child process.
562
- // When running under systemd, PATH may not include the nvm node directory, which
563
- // causes the `#!/usr/bin/env node` shebang in the openclaw CLI to fail.
596
+ // Docker mode: always install inside container via docker exec
597
+ const { getNomadDriver } = await import("../config.js");
598
+ if (getNomadDriver() === "docker") {
599
+ await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
600
+ return;
601
+ }
602
+ const openclawBin = resolveOpenclawBin();
603
+ // Host mode: spawn openclaw binary directly
564
604
  const nodeBinDir = dirname(process.execPath);
565
605
  const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
566
- // Pass through proxy and TLS env vars so fetch works under systemd / restricted environments.
567
606
  const proxyEnvKeys = [
568
607
  "http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
569
608
  "no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
@@ -588,12 +627,9 @@ export async function installChannelPlugin(instanceId, channelId) {
588
627
  env: childEnv,
589
628
  timeout: 300_000,
590
629
  }, (err, stdout, stderr) => {
591
- // openclaw plugins install may exit non-zero due to "plugins.allow is empty" warning
592
- // even though the plugin was installed successfully. Check actual directory.
593
630
  if (err && !isChannelPluginInstalled(instanceId, channelId)) {
594
631
  const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
595
632
  console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
596
- // Clean up leftover stage directories
597
633
  try {
598
634
  if (existsSync(extensionsDir)) {
599
635
  for (const entry of readdirSync(extensionsDir)) {
@@ -604,7 +640,7 @@ export async function installChannelPlugin(instanceId, channelId) {
604
640
  }
605
641
  }
606
642
  }
607
- catch { /* best effort */ }
643
+ catch (_) { }
608
644
  reject(new Error(msg));
609
645
  }
610
646
  else {
@@ -621,6 +657,20 @@ export async function installChannelPlugin(instanceId, channelId) {
621
657
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
622
658
  try {
623
659
  await attemptInstall();
660
+ const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
661
+ const installedExtDir = join(extensionsDir, extDirName);
662
+ if (existsSync(installedExtDir)) {
663
+ ensureDirContainer(installedExtDir);
664
+ try {
665
+ for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
666
+ if (entry.isDirectory()) {
667
+ ensureDirContainer(join(installedExtDir, entry.name));
668
+ }
669
+ }
670
+ }
671
+ catch { /* best effort */ }
672
+ }
673
+ ensureDirContainer(extensionsDir);
624
674
  return;
625
675
  }
626
676
  catch (err) {
@@ -637,6 +687,67 @@ export async function installChannelPlugin(instanceId, channelId) {
637
687
  }
638
688
  throw lastErr;
639
689
  }
690
+ /**
691
+ * Install a channel plugin inside the running Docker container via nomad-manager.exec().
692
+ * Requires the instance to be running — the extensions dir is bind-mounted so
693
+ * the install persists on the host filesystem.
694
+ */
695
+ async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
696
+ const { exec } = await import("./nomad-manager.js");
697
+ const MAX_ATTEMPTS = 3;
698
+ const RETRY_DELAY_MS = 5_000;
699
+ console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
700
+ let lastErr;
701
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
702
+ try {
703
+ const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
704
+ // Check if plugin was actually installed (openclaw may exit non-zero with warnings)
705
+ if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
706
+ const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
707
+ console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
708
+ throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
709
+ }
710
+ if (result.exitCode !== 0) {
711
+ console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit: warning only)`);
712
+ }
713
+ else {
714
+ console.log(`[plugins] ${pkg} installed via docker`);
715
+ }
716
+ // Fix ownership on host side
717
+ const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
718
+ const installedExtDir = join(extensionsDir, extDirName);
719
+ if (existsSync(installedExtDir)) {
720
+ ensureDirContainer(installedExtDir);
721
+ try {
722
+ for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
723
+ if (entry.isDirectory()) {
724
+ ensureDirContainer(join(installedExtDir, entry.name));
725
+ }
726
+ }
727
+ }
728
+ catch { /* best effort */ }
729
+ }
730
+ ensureDirContainer(extensionsDir);
731
+ return;
732
+ }
733
+ catch (err) {
734
+ lastErr = err;
735
+ // "Instance is not running" from nomad-manager.exec() — give a clear user-facing message
736
+ if (/not running/i.test(err.message ?? "")) {
737
+ throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
738
+ }
739
+ const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
740
+ if (isTransient && attempt < MAX_ATTEMPTS) {
741
+ console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
742
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
743
+ continue;
744
+ }
745
+ console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
746
+ break;
747
+ }
748
+ }
749
+ throw lastErr;
750
+ }
640
751
  function getChannelExtensionsDir(instanceId) {
641
752
  return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
642
753
  }
@@ -678,7 +789,7 @@ export function getInstance(instanceId) {
678
789
  throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
679
790
  }
680
791
  }
681
- export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome) {
792
+ export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, cloneOptions) {
682
793
  const d = instanceDir(instanceId);
683
794
  if (existsSync(d))
684
795
  throw new Error(`Instance '${instanceId}' already exists`);
@@ -717,18 +828,15 @@ export async function createInstance(instanceId, name, description = "", cloneFr
717
828
  // readdirSync failed — directory might not be readable, proceed cautiously
718
829
  }
719
830
  }
720
- mkdirSync(d, { recursive: true, mode: 0o750 });
721
- chmodSync(d, 0o750);
831
+ ensureDirContainer(d);
722
832
  // Inherit group from INSTANCES_DIR so both root and the real user can access
723
833
  try {
724
834
  const parentGid = statSync(INSTANCES_DIR).gid;
725
835
  chownSync(d, -1, parentGid);
726
836
  }
727
837
  catch { /* non-root without CAP_CHOWN — already correct owner */ }
728
- mkdirSync(home, { recursive: true, mode: 0o750 });
729
- chmodSync(home, 0o750);
730
- mkdirSync(join(home, OPENCLAW_STATE_DIRNAME), { recursive: true, mode: 0o750 });
731
- chmodSync(join(home, OPENCLAW_STATE_DIRNAME), 0o750);
838
+ ensureDirContainer(home);
839
+ ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
732
840
  const runtime = await defaultRuntime(instanceId, home);
733
841
  const allocatedPort = extractGatewayPort(runtime);
734
842
  // Port already reserved inside defaultGatewayPort; just track for cleanup
@@ -744,9 +852,8 @@ export async function createInstance(instanceId, name, description = "", cloneFr
744
852
  safeWriteJson(instanceMetaPath(instanceId), meta);
745
853
  const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
746
854
  for (const ef of envFiles) {
747
- mkdirSync(dirname(ef), { recursive: true });
748
855
  if (!existsSync(ef))
749
- writeFileSync(ef, "", { mode: 0o600 });
856
+ writeConfigFile(ef, "");
750
857
  }
751
858
  // After writing env files, ensure the runtime user can read them
752
859
  try {
@@ -759,7 +866,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
759
866
  }
760
867
  catch { /* ignore - same user or no permission to chown */ }
761
868
  const configPath = openclawConfigPathInternal(instanceId);
762
- mkdirSync(dirname(configPath), { recursive: true });
869
+ ensureDirContainer(dirname(configPath));
763
870
  if (cloneFrom && !existsSync(configPath)) {
764
871
  const srcConfig = resolveExistingConfigPath(cloneFrom);
765
872
  if (existsSync(srcConfig)) {
@@ -782,12 +889,26 @@ export async function createInstance(instanceId, name, description = "", cloneFr
782
889
  if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
783
890
  delete cloned.agents.defaults.model;
784
891
  }
785
- // Strip IM channel configs same channel cannot serve multiple instances
786
- if (cloned?.channels)
787
- delete cloned.channels;
892
+ // Strip IM channel configs + matching plugin entries same channel
893
+ // cannot serve multiple instances and we don't want the plugin
894
+ // loader to boot a half-configured binding.
895
+ stripImBindings(cloned);
788
896
  // Copy extensions directory so plugin references in config remain valid
789
897
  // Copy workspace directory to preserve agent personality (.md files)
790
- for (const subdir of ["extensions", "workspace", "skills"]) {
898
+ const subdirs = ["extensions", "workspace"];
899
+ if (cloneOptions?.include_memory !== false) {
900
+ // Memory may exist at .openclaw/memory/ if created by OpenClaw runtime
901
+ const memDir = join(dirname(srcConfig), "memory");
902
+ if (existsSync(memDir))
903
+ subdirs.push("memory");
904
+ }
905
+ if (cloneOptions?.include_sessions) {
906
+ // Sessions at .openclaw/agents/main/sessions/
907
+ const sessDir = join(dirname(srcConfig), "agents");
908
+ if (existsSync(sessDir))
909
+ subdirs.push("agents");
910
+ }
911
+ for (const subdir of subdirs) {
791
912
  const srcDir = join(dirname(srcConfig), subdir);
792
913
  const dstDir = join(dirname(configPath), subdir);
793
914
  if (existsSync(srcDir) && !existsSync(dstDir)) {
@@ -797,7 +918,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
797
918
  catch { /* best effort */ }
798
919
  }
799
920
  }
800
- writeFileSync(configPath, JSON.stringify(cloned, null, 2), { mode: 0o600 });
921
+ writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
801
922
  // Copy x-jishushell upstream metadata from source instance.json
802
923
  // (saveConfig stores x-jishushell in instance.json, not openclaw.json)
803
924
  const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
@@ -813,7 +934,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
813
934
  if (existsSync(metaPath)) {
814
935
  const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
815
936
  dstMeta["x-jishushell"] = dstXj;
816
- writeFileSync(metaPath, JSON.stringify(dstMeta, null, 2));
937
+ writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
817
938
  }
818
939
  }
819
940
  }
@@ -827,7 +948,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
827
948
  }
828
949
  }
829
950
  if (!existsSync(configPath)) {
830
- writeFileSync(configPath, JSON.stringify(starterConfig(), null, 2), { mode: 0o600 });
951
+ writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
831
952
  // Inject default provider API key from setup into both env files
832
953
  const dp = getPanelConfig().default_provider;
833
954
  if (dp?.apiKey && dp?.providerId && envFiles.length) {
@@ -895,11 +1016,28 @@ export function updateInstance(instanceId, name, description) {
895
1016
  chownToServiceUser(instanceMetaPath(instanceId));
896
1017
  return meta;
897
1018
  }
898
- export function deleteInstance(instanceId) {
1019
+ /** Update instance.json metadata fields (shallow merge at top level). */
1020
+ export function updateInstanceMeta(instanceId, patch) {
1021
+ const metaPath = instanceMetaPath(instanceId);
1022
+ const meta = safeReadJson(metaPath, "instance-meta") || {};
1023
+ Object.assign(meta, patch);
1024
+ safeWriteJson(metaPath, meta);
1025
+ }
1026
+ export function deleteInstance(instanceId, purgeBackups = false) {
899
1027
  const d = instanceDir(instanceId);
900
1028
  if (!existsSync(d))
901
1029
  return { ok: false, warnings: ["Instance directory not found"] };
902
1030
  const warnings = [];
1031
+ // Cancel auto-backup timer and any queued jobs for this instance
1032
+ import("./backup-manager.js").then(({ cancelAutoBackup, getQueueStatus, cancelJob }) => {
1033
+ cancelAutoBackup(instanceId);
1034
+ // Cancel queued (not yet running) jobs for this instance
1035
+ const q = getQueueStatus();
1036
+ for (const job of q.queued) {
1037
+ if (job.instanceId === instanceId)
1038
+ cancelJob(job.id);
1039
+ }
1040
+ }).catch(() => { });
903
1041
  // Cache metadata BEFORE deletion so we can check custom openclaw_home after rm
904
1042
  const meta = getInstance(instanceId);
905
1043
  const home = meta?.openclaw_home;
@@ -929,6 +1067,19 @@ export function deleteInstance(instanceId) {
929
1067
  if (home && !home.startsWith(d) && existsSync(home)) {
930
1068
  warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
931
1069
  }
1070
+ // Handle backups (stored in separate directory, not affected by instance rmSync)
1071
+ const backupDir = join(BACKUPS_DIR, instanceId);
1072
+ if (purgeBackups && existsSync(backupDir)) {
1073
+ try {
1074
+ rmSync(backupDir, { recursive: true, force: true });
1075
+ }
1076
+ catch (e) {
1077
+ warnings.push(`Failed to delete backups: ${e.message}`);
1078
+ }
1079
+ }
1080
+ else if (existsSync(backupDir)) {
1081
+ warnings.push(`Backups preserved at ${backupDir}`);
1082
+ }
932
1083
  return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
933
1084
  }
934
1085
  export function getConfig(instanceId) {
@@ -958,13 +1109,7 @@ export function saveConfig(instanceId, config) {
958
1109
  return false;
959
1110
  if (!existsSync(configPath)) {
960
1111
  const legacyPath = legacyOpenclawConfigPath(instanceId);
961
- mkdirSync(dirname(configPath), { recursive: true });
962
- // Ensure the state dir is group-writable so the Docker container (which may
963
- // run as a different UID) can create sub-directories via shared group.
964
- try {
965
- chmodSync(dirname(configPath), 0o770);
966
- }
967
- catch { /* best effort */ }
1112
+ ensureDirContainer(dirname(configPath));
968
1113
  if (existsSync(legacyPath))
969
1114
  copyFileSync(legacyPath, configPath);
970
1115
  }
@@ -1000,9 +1145,10 @@ export function saveConfig(instanceId, config) {
1000
1145
  configToWrite.plugins.entries.feishu = { enabled: false };
1001
1146
  }
1002
1147
  }
1003
- // Preserve plugins.installs from existing config on disk —
1004
- // these are managed by `openclaw plugins install` and must not be overwritten
1005
- // by the frontend config which doesn't track them.
1148
+ // Preserve backend-managed fields from existing config on disk —
1149
+ // plugins.installs, plugins.entries, and channels written by scan-to-bind
1150
+ // flows (saveWeixinCredentials / saveFeishuCredentials) are not tracked by
1151
+ // the frontend and would be lost on a frontend config save.
1006
1152
  if (existsSync(configPath)) {
1007
1153
  try {
1008
1154
  const existing = JSON.parse(readFileSync(configPath, "utf-8"));
@@ -1010,6 +1156,28 @@ export function saveConfig(instanceId, config) {
1010
1156
  configToWrite.plugins ??= {};
1011
1157
  configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
1012
1158
  }
1159
+ // Merge plugin entries: for keys present in configToWrite, deep-merge
1160
+ // backend-written sub-fields from disk. Keys absent from configToWrite
1161
+ // (intentionally deleted) are NOT resurrected from existing.
1162
+ if (existing.plugins?.entries && configToWrite.plugins?.entries) {
1163
+ for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
1164
+ const old = existing.plugins.entries[key];
1165
+ if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1166
+ configToWrite.plugins.entries[key] = { ...old, ...val };
1167
+ }
1168
+ }
1169
+ }
1170
+ // Merge channels: for keys present in configToWrite, deep-merge
1171
+ // backend-written sub-fields (e.g. openclaw-weixin accounts) from disk.
1172
+ // Keys absent from configToWrite (user-deleted channels) stay deleted.
1173
+ if (existing.channels && configToWrite.channels) {
1174
+ for (const [key, val] of Object.entries(configToWrite.channels)) {
1175
+ const old = existing.channels[key];
1176
+ if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
1177
+ configToWrite.channels[key] = { ...old, ...val };
1178
+ }
1179
+ }
1180
+ }
1013
1181
  }
1014
1182
  catch { /* best effort */ }
1015
1183
  }
@@ -1018,18 +1186,18 @@ export function saveConfig(instanceId, config) {
1018
1186
  copyFileSync(configPath, configPath + ".bak");
1019
1187
  }
1020
1188
  const configJson = JSON.stringify(configToWrite, null, 2);
1021
- mkdirSync(dirname(configPath), { recursive: true });
1022
- writeFileSync(configPath + ".tmp", configJson, { mode: 0o600 });
1189
+ ensureDirContainer(dirname(configPath));
1190
+ writeConfigFile(configPath + ".tmp", configJson);
1023
1191
  // Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
1024
1192
  JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
1025
1193
  renameSync(configPath + ".tmp", configPath);
1026
1194
  chownToServiceUser(configPath);
1027
- // also write to legacy path (with restricted permissions)
1195
+ // also write to legacy path
1028
1196
  const legacyPath = legacyOpenclawConfigPath(instanceId);
1029
1197
  if (existsSync(legacyPath)) {
1030
1198
  copyFileSync(legacyPath, legacyPath + ".bak");
1031
1199
  }
1032
- writeFileSync(legacyPath + ".tmp", configJson, { mode: 0o600 });
1200
+ writeConfigFile(legacyPath + ".tmp", configJson);
1033
1201
  JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
1034
1202
  renameSync(legacyPath + ".tmp", legacyPath);
1035
1203
  chownToServiceUser(legacyPath);
@@ -1081,7 +1249,8 @@ export function saveFeishuCredentials(instanceId, creds) {
1081
1249
  appId: creds.appId,
1082
1250
  appSecret: creds.appSecret,
1083
1251
  domain: creds.domain,
1084
- dmPolicy: "pairing",
1252
+ dmPolicy: "open",
1253
+ allowFrom: ["*"],
1085
1254
  };
1086
1255
  safeWriteJson(configPath, config);
1087
1256
  chownToServiceUser(configPath);
@@ -1099,7 +1268,7 @@ export function saveWeixinCredentials(instanceId, creds) {
1099
1268
  const home = getOpenclawHomeInternal(instanceId);
1100
1269
  const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
1101
1270
  const accountsDir = join(stateDir, "accounts");
1102
- mkdirSync(accountsDir, { recursive: true });
1271
+ ensureDirContainer(accountsDir);
1103
1272
  // Save account credentials file (via safeWriteJson for atomic + .bak protection)
1104
1273
  const credObj = {
1105
1274
  token: creds.token,