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.
- package/Dockerfile.openclaw-slim +58 -0
- package/INSTALL-NOTICE +45 -0
- package/dist/auth.js +3 -3
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +3 -0
- package/dist/cli/app.js +156 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/{doctor.d.ts → cli/doctor.d.ts} +6 -1
- package/dist/{doctor.js → cli/doctor.js} +389 -27
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/helpers.d.ts +4 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/job.d.ts +3 -0
- package/dist/cli/job.js +260 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +24 -0
- package/dist/cli/llm.js +593 -0
- package/dist/cli/llm.js.map +1 -0
- package/dist/cli/openclaw.d.ts +12 -0
- package/dist/cli/openclaw.js +156 -0
- package/dist/cli/openclaw.js.map +1 -0
- package/dist/cli/panel.d.ts +25 -0
- package/dist/cli/panel.js +734 -0
- package/dist/cli/panel.js.map +1 -0
- package/dist/cli.js +476 -219
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +22 -4
- package/dist/config.js +96 -55
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +13 -41
- package/dist/control.js +12 -1355
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +1 -1
- package/dist/install.js +15 -29
- package/dist/install.js.map +1 -1
- package/dist/routes/apps.d.ts +3 -0
- package/dist/routes/apps.js +99 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/backup.d.ts +2 -0
- package/dist/routes/backup.js +370 -0
- package/dist/routes/backup.js.map +1 -0
- package/dist/routes/instances.d.ts +1 -0
- package/dist/routes/instances.js +61 -15
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +246 -0
- package/dist/routes/llm.js.map +1 -0
- package/dist/routes/setup.js +32 -7
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +31 -6
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +69 -5
- package/dist/server.js.map +1 -1
- package/dist/services/app-compiler.d.ts +15 -0
- package/dist/services/app-compiler.js +169 -0
- package/dist/services/app-compiler.js.map +1 -0
- package/dist/services/app-manager.d.ts +17 -0
- package/dist/services/app-manager.js +168 -0
- package/dist/services/app-manager.js.map +1 -0
- package/dist/services/backup-manager.d.ts +253 -0
- package/dist/services/backup-manager.js +2014 -0
- package/dist/services/backup-manager.js.map +1 -0
- package/dist/services/backup-verify.d.ts +26 -0
- package/dist/services/backup-verify.js +240 -0
- package/dist/services/backup-verify.js.map +1 -0
- package/dist/services/instance-manager.d.ts +73 -5
- package/dist/services/instance-manager.js +446 -74
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/job-manager.d.ts +22 -0
- package/dist/services/job-manager.js +102 -0
- package/dist/services/job-manager.js.map +1 -0
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +30 -0
- package/dist/services/llm-proxy/index.js +71 -1
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/ssrf.js +1 -1
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.js +263 -159
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +40 -0
- package/dist/services/panel-manager.js +346 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/process-manager.js +24 -10
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/setup-manager.d.ts +4 -2
- package/dist/services/setup-manager.js +578 -154
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/telemetry/activation.js +10 -7
- package/dist/services/telemetry/activation.js.map +1 -1
- package/dist/services/telemetry/client.js +7 -18
- package/dist/services/telemetry/client.js.map +1 -1
- package/dist/services/telemetry/heartbeat.js +12 -6
- package/dist/services/telemetry/heartbeat.js.map +1 -1
- package/dist/services/update-manager.d.ts +47 -0
- package/dist/services/update-manager.js +305 -0
- package/dist/services/update-manager.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/utils/fs.d.ts +85 -0
- package/dist/utils/fs.js +111 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/safe-json.d.ts +2 -0
- package/dist/utils/safe-json.js +22 -16
- package/dist/utils/safe-json.js.map +1 -1
- package/install/jishu-install.sh +582 -138
- package/install/jishu-uninstall.sh +276 -391
- package/install/post-install.sh +85 -3
- package/openclaw-entry.sh +15 -0
- package/package.json +12 -5
- package/public/assets/Dashboard-CQsp1Mr9.js +1 -0
- package/public/assets/InitPassword-BEC8SE4A.js +1 -0
- package/public/assets/InstanceDetail-B5wTgNEg.js +17 -0
- package/public/assets/{Login-RkjzTNWg.js → Login-D1Bt-Lyk.js} +1 -1
- package/public/assets/NewInstance-GQzm3K9D.js +1 -0
- package/public/assets/Settings-ByjGlqhP.js +1 -0
- package/public/assets/Setup-cMF21Y-8.js +1 -0
- package/public/assets/index-B6qQP4mH.css +1 -0
- package/public/assets/index-BuTQtuNy.js +16 -0
- package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
- package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/assets/{usePolling-CqQ8hrNc.js → usePolling-CK0DfI4h.js} +1 -1
- package/public/assets/{vendor-i18n-Bvxxh8Di.js → vendor-i18n-CfW0RvgE.js} +1 -1
- package/public/assets/vendor-react-B1-3Yrt-.js +59 -0
- package/public/index.html +4 -4
- package/dist/doctor.js.map +0 -1
- package/public/assets/Dashboard-CAOQDYDR.js +0 -1
- package/public/assets/InitPassword-CkehIkJG.js +0 -1
- package/public/assets/InstanceDetail-CzW2S95J.js +0 -14
- package/public/assets/NewInstance-DdbErdjA.js +0 -1
- package/public/assets/Settings-BUD7zwv9.js +0 -1
- package/public/assets/Setup-RRTIERGG.js +0 -1
- package/public/assets/index-77Ug7feY.css +0 -1
- package/public/assets/index-DfRnVUQR.js +0 -16
- package/public/assets/vendor-react-DONn7uBV.js +0 -59
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { execFile, execFileSync } from "child_process";
|
|
2
2
|
import { randomBytes } from "crypto";
|
|
3
|
-
import { chmodSync, chownSync, copyFileSync, cpSync, existsSync,
|
|
3
|
+
import { chmodSync, chownSync, closeSync, copyFileSync, cpSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
|
|
4
|
+
import { rm as rmAsync } from "fs/promises";
|
|
4
5
|
import { createServer as netCreateServer } from "net";
|
|
5
6
|
import { userInfo } from "os";
|
|
6
7
|
import { dirname, join, resolve } from "path";
|
|
7
|
-
import { INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
|
|
8
|
+
import { BACKUPS_DIR, INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
|
|
8
9
|
import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
|
|
9
10
|
import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
|
|
11
|
+
import { ensureDirContainer, writeConfigFile, writeSecretFile } from "../utils/fs.js";
|
|
12
|
+
import { compileTaskRuntime } from "./app-compiler.js";
|
|
10
13
|
const _configChangeListeners = [];
|
|
11
14
|
export function onConfigChange(listener) {
|
|
12
15
|
_configChangeListeners.push(listener);
|
|
@@ -118,7 +121,16 @@ function safePort(port) {
|
|
|
118
121
|
throw new Error(`Invalid port: ${port}`);
|
|
119
122
|
return String(port);
|
|
120
123
|
}
|
|
121
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Probes whether a port is currently held by any process on the host.
|
|
126
|
+
*
|
|
127
|
+
* Binds `0.0.0.0:port` — under the Linux default socket semantics, this fails
|
|
128
|
+
* with EADDRINUSE whenever another listener holds the port on `0.0.0.0`, on
|
|
129
|
+
* any specific interface (e.g. `127.0.0.1`), or via Docker's published port
|
|
130
|
+
* map. An IPv6-only listener on `::1` is not detected, but that edge case
|
|
131
|
+
* never comes up in the Nomad+Docker path this project uses.
|
|
132
|
+
*/
|
|
133
|
+
export function isPortInUse(port) {
|
|
122
134
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
123
135
|
return Promise.resolve(false);
|
|
124
136
|
return new Promise((resolve) => {
|
|
@@ -130,13 +142,25 @@ function isPortInUse(port) {
|
|
|
130
142
|
server.listen(port, "0.0.0.0");
|
|
131
143
|
});
|
|
132
144
|
}
|
|
133
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Picks a gateway port for an instance, starting at {@link DEFAULT_GATEWAY_PORT}
|
|
147
|
+
* and walking upward until a port is both unknown to jishushell's instance
|
|
148
|
+
* metadata and reports as free on the host. Concurrent callers coordinate
|
|
149
|
+
* through `_pendingPorts` so they never hand out the same port.
|
|
150
|
+
*
|
|
151
|
+
* Intentionally does not hold a reservation indefinitely — the caller is
|
|
152
|
+
* expected to either persist the port into instance metadata (which then
|
|
153
|
+
* shows up in `usedGatewayPorts`) or clear `_pendingPorts` on failure.
|
|
154
|
+
*/
|
|
155
|
+
async function allocateGatewayPort(instanceId) {
|
|
134
156
|
const used = usedGatewayPorts(instanceId);
|
|
157
|
+
const skipped = [];
|
|
135
158
|
let port = DEFAULT_GATEWAY_PORT;
|
|
136
159
|
while (true) {
|
|
137
160
|
if (port > 65535)
|
|
138
161
|
throw new Error("No available gateway port found (all ports 18789-65535 in use)");
|
|
139
162
|
if (used.has(port) || _pendingPorts.has(port)) {
|
|
163
|
+
skipped.push(port);
|
|
140
164
|
port++;
|
|
141
165
|
continue;
|
|
142
166
|
}
|
|
@@ -146,16 +170,18 @@ async function defaultGatewayPort(instanceId) {
|
|
|
146
170
|
try {
|
|
147
171
|
if (await isPortInUse(port)) {
|
|
148
172
|
_pendingPorts.delete(port);
|
|
173
|
+
skipped.push(port);
|
|
149
174
|
port++;
|
|
150
175
|
continue;
|
|
151
176
|
}
|
|
152
|
-
return port;
|
|
177
|
+
return { port, skipped };
|
|
153
178
|
}
|
|
154
179
|
catch {
|
|
155
180
|
_pendingPorts.delete(port);
|
|
156
181
|
// Skip this port on a transient OS error rather than failing the entire
|
|
157
182
|
// allocation — a single bad port check should not prevent instance creation.
|
|
158
183
|
console.warn(`[instance] Port ${port} availability check failed, trying next port`);
|
|
184
|
+
skipped.push(port);
|
|
159
185
|
port++;
|
|
160
186
|
continue;
|
|
161
187
|
}
|
|
@@ -242,8 +268,7 @@ function chownToServiceUser(...paths) {
|
|
|
242
268
|
}
|
|
243
269
|
}
|
|
244
270
|
}
|
|
245
|
-
|
|
246
|
-
const port = await defaultGatewayPort(instanceId);
|
|
271
|
+
function buildDefaultRuntime(instanceId, port, openclawHome) {
|
|
247
272
|
const home = openclawHome || defaultOpenclawHome(instanceId);
|
|
248
273
|
return {
|
|
249
274
|
command: resolveOpenclawBin(),
|
|
@@ -357,7 +382,7 @@ function quoteEnvValue(value) {
|
|
|
357
382
|
return JSON.stringify(value);
|
|
358
383
|
}
|
|
359
384
|
export function updateEnvFile(path, updates) {
|
|
360
|
-
|
|
385
|
+
ensureDirContainer(dirname(path));
|
|
361
386
|
const existing = existsSync(path) ? readFileSync(path, "utf-8").split("\n") : [];
|
|
362
387
|
const remaining = { ...updates };
|
|
363
388
|
const newLines = [];
|
|
@@ -389,7 +414,7 @@ export function updateEnvFile(path, updates) {
|
|
|
389
414
|
const content = output ? output + "\n" : "";
|
|
390
415
|
// Atomic write: tmp then rename to protect against RPi power loss
|
|
391
416
|
const tmp = path + ".tmp";
|
|
392
|
-
|
|
417
|
+
writeSecretFile(tmp, content);
|
|
393
418
|
renameSync(tmp, path);
|
|
394
419
|
}
|
|
395
420
|
// ── Provider key helpers ──
|
|
@@ -536,6 +561,40 @@ export const CHANNEL_PLUGIN_MAP = {
|
|
|
536
561
|
// Official vendor plugins — need install (not bundled)
|
|
537
562
|
"openclaw-weixin": "@tencent-weixin/openclaw-weixin",
|
|
538
563
|
};
|
|
564
|
+
/**
|
|
565
|
+
* Known IM plugin entry IDs as they appear under `config.plugins.entries`.
|
|
566
|
+
* This is the union of channel IDs and the dir-alias names (e.g. `feishu` may
|
|
567
|
+
* register the plugin as `openclaw-lark`), which is what must be scrubbed when
|
|
568
|
+
* dissociating an instance from its inherited IM bindings.
|
|
569
|
+
*/
|
|
570
|
+
const IM_PLUGIN_ENTRY_IDS = new Set([
|
|
571
|
+
...Object.keys(CHANNEL_PLUGIN_MAP),
|
|
572
|
+
...Object.values(CHANNEL_EXT_DIR_ALIAS),
|
|
573
|
+
]);
|
|
574
|
+
/**
|
|
575
|
+
* Dissociate a cloned/imported config from its source instance's IM bindings.
|
|
576
|
+
*
|
|
577
|
+
* Mutates the given config in place:
|
|
578
|
+
* - Deletes the entire `channels` block (same channel cannot serve multiple
|
|
579
|
+
* instances, so every inherited enabled/credential/account entry must go).
|
|
580
|
+
* - Deletes matching IM entries from `plugins.entries` so the plugin loader
|
|
581
|
+
* does not try to boot a channel whose config no longer exists.
|
|
582
|
+
*
|
|
583
|
+
* Used by both domain clone (`createInstance`'s `cloneFrom` path) and the
|
|
584
|
+
* backup import paths (`importInstance`, `createFromBackup`) so that a new
|
|
585
|
+
* instance never inherits a half-configured IM binding.
|
|
586
|
+
*/
|
|
587
|
+
export function stripImBindings(config) {
|
|
588
|
+
if (config?.channels)
|
|
589
|
+
delete config.channels;
|
|
590
|
+
const entries = config?.plugins?.entries;
|
|
591
|
+
if (entries && typeof entries === "object") {
|
|
592
|
+
for (const key of Object.keys(entries)) {
|
|
593
|
+
if (IM_PLUGIN_ENTRY_IDS.has(key))
|
|
594
|
+
delete entries[key];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
539
598
|
/** Check if a channel plugin is installed for an instance. */
|
|
540
599
|
export function isChannelPluginInstalled(instanceId, channelId) {
|
|
541
600
|
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
@@ -546,8 +605,9 @@ export function isChannelPluginInstalled(instanceId, channelId) {
|
|
|
546
605
|
|| (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
|
|
547
606
|
}
|
|
548
607
|
/**
|
|
549
|
-
* Install a single channel plugin
|
|
550
|
-
*
|
|
608
|
+
* Install a single channel plugin.
|
|
609
|
+
* Docker mode: runs install inside the running container via docker exec.
|
|
610
|
+
* Host mode (fallback): spawns the host openclaw binary directly.
|
|
551
611
|
*/
|
|
552
612
|
export async function installChannelPlugin(instanceId, channelId) {
|
|
553
613
|
const pkg = CHANNEL_PLUGIN_MAP[channelId];
|
|
@@ -556,14 +616,17 @@ export async function installChannelPlugin(instanceId, channelId) {
|
|
|
556
616
|
if (isChannelPluginInstalled(instanceId, channelId))
|
|
557
617
|
return;
|
|
558
618
|
const openclawHome = getOpenclawHomeInternal(instanceId);
|
|
559
|
-
const openclawBin = resolveOpenclawBin();
|
|
560
619
|
const extensionsDir = getChannelExtensionsDir(instanceId);
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
620
|
+
// Docker mode: always install inside container via docker exec
|
|
621
|
+
const { getNomadDriver } = await import("../config.js");
|
|
622
|
+
if (getNomadDriver() === "docker") {
|
|
623
|
+
await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const openclawBin = resolveOpenclawBin();
|
|
627
|
+
// Host mode: spawn openclaw binary directly
|
|
564
628
|
const nodeBinDir = dirname(process.execPath);
|
|
565
629
|
const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
|
|
566
|
-
// Pass through proxy and TLS env vars so fetch works under systemd / restricted environments.
|
|
567
630
|
const proxyEnvKeys = [
|
|
568
631
|
"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
|
|
569
632
|
"no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
|
|
@@ -588,12 +651,9 @@ export async function installChannelPlugin(instanceId, channelId) {
|
|
|
588
651
|
env: childEnv,
|
|
589
652
|
timeout: 300_000,
|
|
590
653
|
}, (err, stdout, stderr) => {
|
|
591
|
-
// openclaw plugins install may exit non-zero due to "plugins.allow is empty" warning
|
|
592
|
-
// even though the plugin was installed successfully. Check actual directory.
|
|
593
654
|
if (err && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
594
655
|
const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
|
|
595
656
|
console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
|
|
596
|
-
// Clean up leftover stage directories
|
|
597
657
|
try {
|
|
598
658
|
if (existsSync(extensionsDir)) {
|
|
599
659
|
for (const entry of readdirSync(extensionsDir)) {
|
|
@@ -604,7 +664,7 @@ export async function installChannelPlugin(instanceId, channelId) {
|
|
|
604
664
|
}
|
|
605
665
|
}
|
|
606
666
|
}
|
|
607
|
-
catch {
|
|
667
|
+
catch (_) { }
|
|
608
668
|
reject(new Error(msg));
|
|
609
669
|
}
|
|
610
670
|
else {
|
|
@@ -621,6 +681,20 @@ export async function installChannelPlugin(instanceId, channelId) {
|
|
|
621
681
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
622
682
|
try {
|
|
623
683
|
await attemptInstall();
|
|
684
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
685
|
+
const installedExtDir = join(extensionsDir, extDirName);
|
|
686
|
+
if (existsSync(installedExtDir)) {
|
|
687
|
+
ensureDirContainer(installedExtDir);
|
|
688
|
+
try {
|
|
689
|
+
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
690
|
+
if (entry.isDirectory()) {
|
|
691
|
+
ensureDirContainer(join(installedExtDir, entry.name));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch { /* best effort */ }
|
|
696
|
+
}
|
|
697
|
+
ensureDirContainer(extensionsDir);
|
|
624
698
|
return;
|
|
625
699
|
}
|
|
626
700
|
catch (err) {
|
|
@@ -637,6 +711,67 @@ export async function installChannelPlugin(instanceId, channelId) {
|
|
|
637
711
|
}
|
|
638
712
|
throw lastErr;
|
|
639
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Install a channel plugin inside the running Docker container via nomad-manager.exec().
|
|
716
|
+
* Requires the instance to be running — the extensions dir is bind-mounted so
|
|
717
|
+
* the install persists on the host filesystem.
|
|
718
|
+
*/
|
|
719
|
+
async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
|
|
720
|
+
const { exec } = await import("./nomad-manager.js");
|
|
721
|
+
const MAX_ATTEMPTS = 3;
|
|
722
|
+
const RETRY_DELAY_MS = 5_000;
|
|
723
|
+
console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
|
|
724
|
+
let lastErr;
|
|
725
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
726
|
+
try {
|
|
727
|
+
const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
|
|
728
|
+
// Check if plugin was actually installed (openclaw may exit non-zero with warnings)
|
|
729
|
+
if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
730
|
+
const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
|
|
731
|
+
console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
|
|
732
|
+
throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
|
|
733
|
+
}
|
|
734
|
+
if (result.exitCode !== 0) {
|
|
735
|
+
console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit: warning only)`);
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
console.log(`[plugins] ${pkg} installed via docker`);
|
|
739
|
+
}
|
|
740
|
+
// Fix ownership on host side
|
|
741
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
742
|
+
const installedExtDir = join(extensionsDir, extDirName);
|
|
743
|
+
if (existsSync(installedExtDir)) {
|
|
744
|
+
ensureDirContainer(installedExtDir);
|
|
745
|
+
try {
|
|
746
|
+
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
747
|
+
if (entry.isDirectory()) {
|
|
748
|
+
ensureDirContainer(join(installedExtDir, entry.name));
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch { /* best effort */ }
|
|
753
|
+
}
|
|
754
|
+
ensureDirContainer(extensionsDir);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
lastErr = err;
|
|
759
|
+
// "Instance is not running" from nomad-manager.exec() — give a clear user-facing message
|
|
760
|
+
if (/not running/i.test(err.message ?? "")) {
|
|
761
|
+
throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
|
|
762
|
+
}
|
|
763
|
+
const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
|
|
764
|
+
if (isTransient && attempt < MAX_ATTEMPTS) {
|
|
765
|
+
console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
766
|
+
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
throw lastErr;
|
|
774
|
+
}
|
|
640
775
|
function getChannelExtensionsDir(instanceId) {
|
|
641
776
|
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
|
|
642
777
|
}
|
|
@@ -644,6 +779,26 @@ function getStockExtensionsDir() {
|
|
|
644
779
|
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
645
780
|
}
|
|
646
781
|
// ── Public API ──
|
|
782
|
+
/**
|
|
783
|
+
* Probe whether a file is readable by the current process. Used to
|
|
784
|
+
* distinguish "primary missing / corrupted" (recoverable via safeReadJson's
|
|
785
|
+
* .bak chain) from "primary exists but permission denied" (EACCES — the
|
|
786
|
+
* common sudo-script footgun that leaves root-owned files). safeReadJson
|
|
787
|
+
* swallows every read error internally and returns null, so without this
|
|
788
|
+
* probe an unreadable primary looks identical to a truly-gone instance.
|
|
789
|
+
*/
|
|
790
|
+
function probeReadable(path) {
|
|
791
|
+
if (!existsSync(path))
|
|
792
|
+
return null;
|
|
793
|
+
try {
|
|
794
|
+
const fd = openSync(path, "r");
|
|
795
|
+
closeSync(fd);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
catch (e) {
|
|
799
|
+
return e;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
647
802
|
export function listInstances() {
|
|
648
803
|
if (!existsSync(INSTANCES_DIR))
|
|
649
804
|
return [];
|
|
@@ -653,14 +808,33 @@ export function listInstances() {
|
|
|
653
808
|
const metaPath = join(INSTANCES_DIR, name, "instance.json");
|
|
654
809
|
const dirPath = join(INSTANCES_DIR, name);
|
|
655
810
|
try {
|
|
656
|
-
if (statSync(dirPath).isDirectory()
|
|
657
|
-
|
|
811
|
+
if (!statSync(dirPath).isDirectory())
|
|
812
|
+
continue;
|
|
813
|
+
// Use safeReadJson so primary instance.json that was corrupted or
|
|
814
|
+
// deleted mid-rename falls back to the .bak chain maintained by
|
|
815
|
+
// safeWriteJson. Without this, an interrupted safeWriteJson call
|
|
816
|
+
// would silently drop the instance from every list/get hot path
|
|
817
|
+
// even though the backup chain still holds valid content on disk.
|
|
818
|
+
const meta = safeReadJson(metaPath, `instance:${name}`);
|
|
819
|
+
if (meta) {
|
|
820
|
+
instances.push(meta);
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
// safeReadJson → null can mean any of (a) primary missing + no
|
|
824
|
+
// backups, (b) all candidates unparseable, (c) permission denied.
|
|
825
|
+
// (a) and (b) are legitimate "drop from list" cases; (c) is the
|
|
826
|
+
// sudo-script footgun and must be logged loudly so the operator
|
|
827
|
+
// doesn't just see an empty instance list with no hint why.
|
|
828
|
+
const readErr = probeReadable(metaPath);
|
|
829
|
+
if (readErr && readErr.code === "EACCES") {
|
|
830
|
+
console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
|
|
831
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
658
832
|
}
|
|
659
833
|
}
|
|
660
834
|
catch (e) {
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
835
|
+
// Fallback for failures before the safeReadJson call (e.g. statSync
|
|
836
|
+
// on a directory we can't enter). Still log instead of silently
|
|
837
|
+
// dropping the entry.
|
|
664
838
|
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
665
839
|
}
|
|
666
840
|
}
|
|
@@ -668,17 +842,26 @@ export function listInstances() {
|
|
|
668
842
|
}
|
|
669
843
|
export function getInstance(instanceId) {
|
|
670
844
|
const metaPath = instanceMetaPath(instanceId);
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
845
|
+
// Go through safeReadJson so primary missing/corrupted instance.json
|
|
846
|
+
// is still served from the .bak chain. Returning null on "truly gone"
|
|
847
|
+
// (no primary, no backups) keeps the existing 404 behavior intact.
|
|
848
|
+
const meta = safeReadJson(metaPath, `instance:${instanceId}`);
|
|
849
|
+
if (meta)
|
|
850
|
+
return meta;
|
|
851
|
+
// safeReadJson swallows every read error internally, which is exactly
|
|
852
|
+
// wrong for the EACCES case — a root-owned primary would silently
|
|
853
|
+
// return null and callers would report "Instance not found" instead
|
|
854
|
+
// of the actionable "check file ownership" message. Re-probe to
|
|
855
|
+
// distinguish and throw on permission denial. Missing/corrupted with
|
|
856
|
+
// no backup still returns null (→ 404 upstream).
|
|
857
|
+
const readErr = probeReadable(metaPath);
|
|
858
|
+
if (readErr && readErr.code === "EACCES") {
|
|
859
|
+
throw new Error(`Cannot read instance '${instanceId}' metadata: ${readErr.message}. ` +
|
|
860
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
679
861
|
}
|
|
862
|
+
return null;
|
|
680
863
|
}
|
|
681
|
-
export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome) {
|
|
864
|
+
export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome, appSpec, cloneOptions) {
|
|
682
865
|
const d = instanceDir(instanceId);
|
|
683
866
|
if (existsSync(d))
|
|
684
867
|
throw new Error(`Instance '${instanceId}' already exists`);
|
|
@@ -717,21 +900,27 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
717
900
|
// readdirSync failed — directory might not be readable, proceed cautiously
|
|
718
901
|
}
|
|
719
902
|
}
|
|
720
|
-
|
|
721
|
-
chmodSync(d, 0o750);
|
|
903
|
+
ensureDirContainer(d);
|
|
722
904
|
// Inherit group from INSTANCES_DIR so both root and the real user can access
|
|
723
905
|
try {
|
|
724
906
|
const parentGid = statSync(INSTANCES_DIR).gid;
|
|
725
907
|
chownSync(d, -1, parentGid);
|
|
726
908
|
}
|
|
727
909
|
catch { /* non-root without CAP_CHOWN — already correct owner */ }
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
910
|
+
ensureDirContainer(home);
|
|
911
|
+
ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
|
|
912
|
+
const portAlloc = await allocateGatewayPort(instanceId);
|
|
913
|
+
const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
|
|
914
|
+
let runtime = baseRuntime;
|
|
915
|
+
if (appSpec) {
|
|
916
|
+
const serviceTask = appSpec.tasks.find((t) => t.role === "service");
|
|
917
|
+
if (serviceTask) {
|
|
918
|
+
const compiled = compileTaskRuntime(serviceTask, instanceId);
|
|
919
|
+
runtime = { ...baseRuntime, ...compiled };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
733
922
|
const allocatedPort = extractGatewayPort(runtime);
|
|
734
|
-
// Port already reserved inside
|
|
923
|
+
// Port already reserved inside allocateGatewayPort; just track for cleanup
|
|
735
924
|
try {
|
|
736
925
|
const meta = {
|
|
737
926
|
id: instanceId,
|
|
@@ -740,13 +929,13 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
740
929
|
openclaw_home: home,
|
|
741
930
|
runtime,
|
|
742
931
|
created_at: new Date().toISOString(),
|
|
932
|
+
...(appSpec ? { app_id: appSpec.id } : {}),
|
|
743
933
|
};
|
|
744
934
|
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
745
935
|
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
746
936
|
for (const ef of envFiles) {
|
|
747
|
-
mkdirSync(dirname(ef), { recursive: true });
|
|
748
937
|
if (!existsSync(ef))
|
|
749
|
-
|
|
938
|
+
writeConfigFile(ef, "");
|
|
750
939
|
}
|
|
751
940
|
// After writing env files, ensure the runtime user can read them
|
|
752
941
|
try {
|
|
@@ -759,7 +948,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
759
948
|
}
|
|
760
949
|
catch { /* ignore - same user or no permission to chown */ }
|
|
761
950
|
const configPath = openclawConfigPathInternal(instanceId);
|
|
762
|
-
|
|
951
|
+
ensureDirContainer(dirname(configPath));
|
|
763
952
|
if (cloneFrom && !existsSync(configPath)) {
|
|
764
953
|
const srcConfig = resolveExistingConfigPath(cloneFrom);
|
|
765
954
|
if (existsSync(srcConfig)) {
|
|
@@ -782,12 +971,26 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
782
971
|
if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
|
|
783
972
|
delete cloned.agents.defaults.model;
|
|
784
973
|
}
|
|
785
|
-
// Strip IM channel configs
|
|
786
|
-
|
|
787
|
-
|
|
974
|
+
// Strip IM channel configs + matching plugin entries — same channel
|
|
975
|
+
// cannot serve multiple instances and we don't want the plugin
|
|
976
|
+
// loader to boot a half-configured binding.
|
|
977
|
+
stripImBindings(cloned);
|
|
788
978
|
// Copy extensions directory so plugin references in config remain valid
|
|
789
979
|
// Copy workspace directory to preserve agent personality (.md files)
|
|
790
|
-
|
|
980
|
+
const subdirs = ["extensions", "workspace"];
|
|
981
|
+
if (cloneOptions?.include_memory !== false) {
|
|
982
|
+
// Memory may exist at .openclaw/memory/ if created by OpenClaw runtime
|
|
983
|
+
const memDir = join(dirname(srcConfig), "memory");
|
|
984
|
+
if (existsSync(memDir))
|
|
985
|
+
subdirs.push("memory");
|
|
986
|
+
}
|
|
987
|
+
if (cloneOptions?.include_sessions) {
|
|
988
|
+
// Sessions at .openclaw/agents/main/sessions/
|
|
989
|
+
const sessDir = join(dirname(srcConfig), "agents");
|
|
990
|
+
if (existsSync(sessDir))
|
|
991
|
+
subdirs.push("agents");
|
|
992
|
+
}
|
|
993
|
+
for (const subdir of subdirs) {
|
|
791
994
|
const srcDir = join(dirname(srcConfig), subdir);
|
|
792
995
|
const dstDir = join(dirname(configPath), subdir);
|
|
793
996
|
if (existsSync(srcDir) && !existsSync(dstDir)) {
|
|
@@ -797,7 +1000,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
797
1000
|
catch { /* best effort */ }
|
|
798
1001
|
}
|
|
799
1002
|
}
|
|
800
|
-
|
|
1003
|
+
writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
|
|
801
1004
|
// Copy x-jishushell upstream metadata from source instance.json
|
|
802
1005
|
// (saveConfig stores x-jishushell in instance.json, not openclaw.json)
|
|
803
1006
|
const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
|
|
@@ -813,7 +1016,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
813
1016
|
if (existsSync(metaPath)) {
|
|
814
1017
|
const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
815
1018
|
dstMeta["x-jishushell"] = dstXj;
|
|
816
|
-
|
|
1019
|
+
writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
|
|
817
1020
|
}
|
|
818
1021
|
}
|
|
819
1022
|
}
|
|
@@ -827,7 +1030,7 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
827
1030
|
}
|
|
828
1031
|
}
|
|
829
1032
|
if (!existsSync(configPath)) {
|
|
830
|
-
|
|
1033
|
+
writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
|
|
831
1034
|
// Inject default provider API key from setup into both env files
|
|
832
1035
|
const dp = getPanelConfig().default_provider;
|
|
833
1036
|
if (dp?.apiKey && dp?.providerId && envFiles.length) {
|
|
@@ -838,6 +1041,34 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
838
1041
|
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
839
1042
|
}
|
|
840
1043
|
}
|
|
1044
|
+
// Merge App-level config_defaults into openclaw.json (shallow merge, app values win)
|
|
1045
|
+
if (appSpec?.openclaw?.config_defaults && existsSync(configPath)) {
|
|
1046
|
+
try {
|
|
1047
|
+
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1048
|
+
const defaults = appSpec.openclaw.config_defaults;
|
|
1049
|
+
// Deep merge top-level keys
|
|
1050
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
1051
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && typeof existing[key] === "object" && existing[key] !== null) {
|
|
1052
|
+
existing[key] = { ...existing[key], ...value };
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
existing[key] = value;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
writeConfigFile(configPath, JSON.stringify(existing, null, 2));
|
|
1059
|
+
}
|
|
1060
|
+
catch { /* ignore merge errors, keep existing config */ }
|
|
1061
|
+
}
|
|
1062
|
+
// Record App-level skills for later installation into the instance
|
|
1063
|
+
if (appSpec?.openclaw?.skills && Array.isArray(appSpec.openclaw.skills)) {
|
|
1064
|
+
try {
|
|
1065
|
+
const skillsDir = join(dirname(configPath), "skills");
|
|
1066
|
+
ensureDirContainer(skillsDir);
|
|
1067
|
+
const skillMeta = join(skillsDir, ".app-skills.json");
|
|
1068
|
+
safeWriteJson(skillMeta, { app_id: appSpec.id, skills: appSpec.openclaw.skills });
|
|
1069
|
+
}
|
|
1070
|
+
catch { /* ignore */ }
|
|
1071
|
+
}
|
|
841
1072
|
// Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
|
|
842
1073
|
if (cloneFrom && envFiles.length) {
|
|
843
1074
|
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
@@ -876,6 +1107,17 @@ export async function createInstance(instanceId, name, description = "", cloneFr
|
|
|
876
1107
|
console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
|
|
877
1108
|
}
|
|
878
1109
|
}
|
|
1110
|
+
// Attach transient port allocation info for the API response only — never
|
|
1111
|
+
// persisted in instance.json. If the caller (e.g. the create route) sees
|
|
1112
|
+
// skipped ports it can tell the user the default was busy.
|
|
1113
|
+
if (portAlloc.skipped.length > 0) {
|
|
1114
|
+
meta.port_allocation = {
|
|
1115
|
+
assigned: portAlloc.port,
|
|
1116
|
+
requested: DEFAULT_GATEWAY_PORT,
|
|
1117
|
+
reason: "default_busy",
|
|
1118
|
+
skipped: portAlloc.skipped,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
879
1121
|
return meta;
|
|
880
1122
|
}
|
|
881
1123
|
finally {
|
|
@@ -895,11 +1137,28 @@ export function updateInstance(instanceId, name, description) {
|
|
|
895
1137
|
chownToServiceUser(instanceMetaPath(instanceId));
|
|
896
1138
|
return meta;
|
|
897
1139
|
}
|
|
898
|
-
|
|
1140
|
+
/** Update instance.json metadata fields (shallow merge at top level). */
|
|
1141
|
+
export function updateInstanceMeta(instanceId, patch) {
|
|
1142
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
1143
|
+
const meta = safeReadJson(metaPath, "instance-meta") || {};
|
|
1144
|
+
Object.assign(meta, patch);
|
|
1145
|
+
safeWriteJson(metaPath, meta);
|
|
1146
|
+
}
|
|
1147
|
+
export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
899
1148
|
const d = instanceDir(instanceId);
|
|
900
1149
|
if (!existsSync(d))
|
|
901
1150
|
return { ok: false, warnings: ["Instance directory not found"] };
|
|
902
1151
|
const warnings = [];
|
|
1152
|
+
// Cancel auto-backup timer and any queued jobs for this instance
|
|
1153
|
+
import("./backup-manager.js").then(({ cancelAutoBackup, getQueueStatus, cancelJob }) => {
|
|
1154
|
+
cancelAutoBackup(instanceId);
|
|
1155
|
+
// Cancel queued (not yet running) jobs for this instance
|
|
1156
|
+
const q = getQueueStatus();
|
|
1157
|
+
for (const job of q.queued) {
|
|
1158
|
+
if (job.instanceId === instanceId)
|
|
1159
|
+
cancelJob(job.id);
|
|
1160
|
+
}
|
|
1161
|
+
}).catch(() => { });
|
|
903
1162
|
// Cache metadata BEFORE deletion so we can check custom openclaw_home after rm
|
|
904
1163
|
const meta = getInstance(instanceId);
|
|
905
1164
|
const home = meta?.openclaw_home;
|
|
@@ -911,14 +1170,18 @@ export function deleteInstance(instanceId) {
|
|
|
911
1170
|
}).catch((e) => {
|
|
912
1171
|
console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
|
|
913
1172
|
});
|
|
1173
|
+
// Async rm so the Node event loop stays responsive during large deletes:
|
|
1174
|
+
// a fresh instance with a just-installed openclaw package can be 1+ GB
|
|
1175
|
+
// with hundreds of nested dirs, which takes 30-60s to unlink on SD storage.
|
|
1176
|
+
// rmSync would block every other HTTP request for that whole window.
|
|
914
1177
|
let dirDeleted = false;
|
|
915
1178
|
try {
|
|
916
|
-
|
|
1179
|
+
await rmAsync(d, { recursive: true, force: true });
|
|
917
1180
|
dirDeleted = true;
|
|
918
1181
|
}
|
|
919
1182
|
catch {
|
|
920
1183
|
try {
|
|
921
|
-
execFileSync("sudo", ["rm", "-rf", d], { timeout:
|
|
1184
|
+
execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
|
|
922
1185
|
dirDeleted = true;
|
|
923
1186
|
}
|
|
924
1187
|
catch (e) {
|
|
@@ -929,6 +1192,22 @@ export function deleteInstance(instanceId) {
|
|
|
929
1192
|
if (home && !home.startsWith(d) && existsSync(home)) {
|
|
930
1193
|
warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
|
|
931
1194
|
}
|
|
1195
|
+
// Handle backups (stored in separate directory, not affected by the
|
|
1196
|
+
// instance rm above). Backups can be hundreds of MB each and accumulate
|
|
1197
|
+
// across retention windows, so use the same async rm path to keep the
|
|
1198
|
+
// event loop responsive.
|
|
1199
|
+
const backupDir = join(BACKUPS_DIR, instanceId);
|
|
1200
|
+
if (purgeBackups && existsSync(backupDir)) {
|
|
1201
|
+
try {
|
|
1202
|
+
await rmAsync(backupDir, { recursive: true, force: true });
|
|
1203
|
+
}
|
|
1204
|
+
catch (e) {
|
|
1205
|
+
warnings.push(`Failed to delete backups: ${e.message}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
else if (existsSync(backupDir)) {
|
|
1209
|
+
warnings.push(`Backups preserved at ${backupDir}`);
|
|
1210
|
+
}
|
|
932
1211
|
return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
|
|
933
1212
|
}
|
|
934
1213
|
export function getConfig(instanceId) {
|
|
@@ -958,13 +1237,7 @@ export function saveConfig(instanceId, config) {
|
|
|
958
1237
|
return false;
|
|
959
1238
|
if (!existsSync(configPath)) {
|
|
960
1239
|
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
961
|
-
|
|
962
|
-
// Ensure the state dir is group-writable so the Docker container (which may
|
|
963
|
-
// run as a different UID) can create sub-directories via shared group.
|
|
964
|
-
try {
|
|
965
|
-
chmodSync(dirname(configPath), 0o770);
|
|
966
|
-
}
|
|
967
|
-
catch { /* best effort */ }
|
|
1240
|
+
ensureDirContainer(dirname(configPath));
|
|
968
1241
|
if (existsSync(legacyPath))
|
|
969
1242
|
copyFileSync(legacyPath, configPath);
|
|
970
1243
|
}
|
|
@@ -1000,9 +1273,10 @@ export function saveConfig(instanceId, config) {
|
|
|
1000
1273
|
configToWrite.plugins.entries.feishu = { enabled: false };
|
|
1001
1274
|
}
|
|
1002
1275
|
}
|
|
1003
|
-
// Preserve
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1276
|
+
// Preserve backend-managed fields from existing config on disk —
|
|
1277
|
+
// plugins.installs, plugins.entries, and channels written by scan-to-bind
|
|
1278
|
+
// flows (saveWeixinCredentials / saveFeishuCredentials) are not tracked by
|
|
1279
|
+
// the frontend and would be lost on a frontend config save.
|
|
1006
1280
|
if (existsSync(configPath)) {
|
|
1007
1281
|
try {
|
|
1008
1282
|
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
@@ -1010,6 +1284,28 @@ export function saveConfig(instanceId, config) {
|
|
|
1010
1284
|
configToWrite.plugins ??= {};
|
|
1011
1285
|
configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
|
|
1012
1286
|
}
|
|
1287
|
+
// Merge plugin entries: for keys present in configToWrite, deep-merge
|
|
1288
|
+
// backend-written sub-fields from disk. Keys absent from configToWrite
|
|
1289
|
+
// (intentionally deleted) are NOT resurrected from existing.
|
|
1290
|
+
if (existing.plugins?.entries && configToWrite.plugins?.entries) {
|
|
1291
|
+
for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
|
|
1292
|
+
const old = existing.plugins.entries[key];
|
|
1293
|
+
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
1294
|
+
configToWrite.plugins.entries[key] = { ...old, ...val };
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
// Merge channels: for keys present in configToWrite, deep-merge
|
|
1299
|
+
// backend-written sub-fields (e.g. openclaw-weixin accounts) from disk.
|
|
1300
|
+
// Keys absent from configToWrite (user-deleted channels) stay deleted.
|
|
1301
|
+
if (existing.channels && configToWrite.channels) {
|
|
1302
|
+
for (const [key, val] of Object.entries(configToWrite.channels)) {
|
|
1303
|
+
const old = existing.channels[key];
|
|
1304
|
+
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
1305
|
+
configToWrite.channels[key] = { ...old, ...val };
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1013
1309
|
}
|
|
1014
1310
|
catch { /* best effort */ }
|
|
1015
1311
|
}
|
|
@@ -1018,18 +1314,18 @@ export function saveConfig(instanceId, config) {
|
|
|
1018
1314
|
copyFileSync(configPath, configPath + ".bak");
|
|
1019
1315
|
}
|
|
1020
1316
|
const configJson = JSON.stringify(configToWrite, null, 2);
|
|
1021
|
-
|
|
1022
|
-
|
|
1317
|
+
ensureDirContainer(dirname(configPath));
|
|
1318
|
+
writeConfigFile(configPath + ".tmp", configJson);
|
|
1023
1319
|
// Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
|
|
1024
1320
|
JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
|
|
1025
1321
|
renameSync(configPath + ".tmp", configPath);
|
|
1026
1322
|
chownToServiceUser(configPath);
|
|
1027
|
-
// also write to legacy path
|
|
1323
|
+
// also write to legacy path
|
|
1028
1324
|
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1029
1325
|
if (existsSync(legacyPath)) {
|
|
1030
1326
|
copyFileSync(legacyPath, legacyPath + ".bak");
|
|
1031
1327
|
}
|
|
1032
|
-
|
|
1328
|
+
writeConfigFile(legacyPath + ".tmp", configJson);
|
|
1033
1329
|
JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
|
|
1034
1330
|
renameSync(legacyPath + ".tmp", legacyPath);
|
|
1035
1331
|
chownToServiceUser(legacyPath);
|
|
@@ -1081,7 +1377,8 @@ export function saveFeishuCredentials(instanceId, creds) {
|
|
|
1081
1377
|
appId: creds.appId,
|
|
1082
1378
|
appSecret: creds.appSecret,
|
|
1083
1379
|
domain: creds.domain,
|
|
1084
|
-
dmPolicy: "
|
|
1380
|
+
dmPolicy: "open",
|
|
1381
|
+
allowFrom: ["*"],
|
|
1085
1382
|
};
|
|
1086
1383
|
safeWriteJson(configPath, config);
|
|
1087
1384
|
chownToServiceUser(configPath);
|
|
@@ -1099,7 +1396,7 @@ export function saveWeixinCredentials(instanceId, creds) {
|
|
|
1099
1396
|
const home = getOpenclawHomeInternal(instanceId);
|
|
1100
1397
|
const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
|
|
1101
1398
|
const accountsDir = join(stateDir, "accounts");
|
|
1102
|
-
|
|
1399
|
+
ensureDirContainer(accountsDir);
|
|
1103
1400
|
// Save account credentials file (via safeWriteJson for atomic + .bak protection)
|
|
1104
1401
|
const credObj = {
|
|
1105
1402
|
token: creds.token,
|
|
@@ -1247,11 +1544,30 @@ export async function getGatewayHost(instanceId) {
|
|
|
1247
1544
|
const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1248
1545
|
if (detail.ok) {
|
|
1249
1546
|
const d = await detail.json();
|
|
1250
|
-
|
|
1251
|
-
const
|
|
1547
|
+
// Preferred source: AllocatedResources.Shared.Ports (bridge mode).
|
|
1548
|
+
const sharedPorts = d?.AllocatedResources?.Shared?.Ports ?? [];
|
|
1549
|
+
const gwPort = sharedPorts.find((p) => p.Label === "gateway");
|
|
1252
1550
|
if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
|
|
1253
1551
|
result = gwPort.HostIP;
|
|
1254
1552
|
}
|
|
1553
|
+
else {
|
|
1554
|
+
// Host mode / task-level reservation: address lives under
|
|
1555
|
+
// AllocatedResources.Tasks.<task>.Networks[*].IP. On Nomad
|
|
1556
|
+
// 1.6.5 with `network_interface = "lo"`, the IP is whichever
|
|
1557
|
+
// address the OS enumerates first — which can be IPv6 `::1`
|
|
1558
|
+
// on systems where lo has both `127.0.0.1/8` and `::1/128`
|
|
1559
|
+
// (the default on most modern Linux distros). Reading it from
|
|
1560
|
+
// here is the authoritative source and matches what nomad
|
|
1561
|
+
// configures the docker-proxy bind to use.
|
|
1562
|
+
const taskNets = d?.AllocatedResources?.Tasks?.gateway?.Networks ?? [];
|
|
1563
|
+
const net = taskNets.find((n) => {
|
|
1564
|
+
const rps = n?.ReservedPorts ?? [];
|
|
1565
|
+
return rps.some((p) => p.Label === "gateway");
|
|
1566
|
+
});
|
|
1567
|
+
if (net?.IP && net.IP !== "0.0.0.0") {
|
|
1568
|
+
result = net.IP;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1255
1571
|
}
|
|
1256
1572
|
}
|
|
1257
1573
|
}
|
|
@@ -1267,7 +1583,11 @@ export async function getGatewayHost(instanceId) {
|
|
|
1267
1583
|
try {
|
|
1268
1584
|
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
1269
1585
|
for (const line of out.split("\n")) {
|
|
1270
|
-
|
|
1586
|
+
// IPv4 dotted-quad: "... 127.0.0.1:18789 ..."
|
|
1587
|
+
// IPv6 bracketed: "... [::1]:18789 ..."
|
|
1588
|
+
let match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
1589
|
+
if (!match)
|
|
1590
|
+
match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
|
|
1271
1591
|
if (match && match[2] === String(port)) {
|
|
1272
1592
|
const addr = match[1];
|
|
1273
1593
|
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
@@ -1279,6 +1599,16 @@ export async function getGatewayHost(instanceId) {
|
|
|
1279
1599
|
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1280
1600
|
return result;
|
|
1281
1601
|
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Wrap an IPv6 literal in brackets for safe URL host-component / Host-header
|
|
1604
|
+
* use. Bare names ("gateway.local") and IPv4 ("127.0.0.1") contain no colon
|
|
1605
|
+
* and pass through unchanged; anything with a colon is an IPv6 literal and
|
|
1606
|
+
* MUST be bracketed before being concatenated with a port, otherwise
|
|
1607
|
+
* `http://::1:18789/` is unparseable.
|
|
1608
|
+
*/
|
|
1609
|
+
export function urlHost(host) {
|
|
1610
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
1611
|
+
}
|
|
1282
1612
|
export function findInstancesSharingOpenclawHome(instanceId) {
|
|
1283
1613
|
const targetHome = normalizePath(getOpenclawHome(instanceId));
|
|
1284
1614
|
return listInstances()
|
|
@@ -1286,6 +1616,48 @@ export function findInstancesSharingOpenclawHome(instanceId) {
|
|
|
1286
1616
|
.filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
|
|
1287
1617
|
.map((inst) => inst.id);
|
|
1288
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Re-pick a gateway port for an existing instance and rewrite its persisted
|
|
1621
|
+
* runtime metadata (`runtime.args` and `runtime.env.OPENCLAW_GATEWAY_PORT`).
|
|
1622
|
+
*
|
|
1623
|
+
* Used when {@link isPortInUse} reports that the previously-assigned port has
|
|
1624
|
+
* been taken by something else between create-time and start-time (e.g. a
|
|
1625
|
+
* host-side openclaw started by the user, an unrelated service that grabbed
|
|
1626
|
+
* the port at boot, or a Docker race on the next allocation). The Nomad job
|
|
1627
|
+
* spec is rebuilt from instance metadata on every submit, so updating
|
|
1628
|
+
* `instance.json` here is sufficient — no other files need patching.
|
|
1629
|
+
*/
|
|
1630
|
+
export async function reallocateGatewayPort(instanceId) {
|
|
1631
|
+
const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
|
|
1632
|
+
if (!meta)
|
|
1633
|
+
throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
|
|
1634
|
+
const fromPort = extractGatewayPort(meta.runtime) ?? DEFAULT_GATEWAY_PORT;
|
|
1635
|
+
const alloc = await allocateGatewayPort(instanceId);
|
|
1636
|
+
try {
|
|
1637
|
+
const runtime = (meta.runtime ?? {});
|
|
1638
|
+
const args = Array.isArray(runtime.args) ? [...runtime.args] : [];
|
|
1639
|
+
for (let i = 0; i < args.length; i++) {
|
|
1640
|
+
if (args[i] === "--port" && i + 1 < args.length) {
|
|
1641
|
+
args[i + 1] = String(alloc.port);
|
|
1642
|
+
}
|
|
1643
|
+
else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
|
|
1644
|
+
args[i] = `--port=${alloc.port}`;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
runtime.args = args;
|
|
1648
|
+
const env = (runtime.env ?? {});
|
|
1649
|
+
env.OPENCLAW_GATEWAY_PORT = String(alloc.port);
|
|
1650
|
+
runtime.env = env;
|
|
1651
|
+
meta.runtime = runtime;
|
|
1652
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
1653
|
+
chownToServiceUser(instanceMetaPath(instanceId));
|
|
1654
|
+
console.log(`[instance] ${instanceId}: gateway port reallocated ${fromPort} -> ${alloc.port}`);
|
|
1655
|
+
return { from: fromPort, to: alloc.port, skipped: alloc.skipped };
|
|
1656
|
+
}
|
|
1657
|
+
finally {
|
|
1658
|
+
_pendingPorts.delete(alloc.port);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1289
1661
|
export function findInstancesSharingGatewayPort(instanceId) {
|
|
1290
1662
|
const targetPort = getGatewayPort(instanceId);
|
|
1291
1663
|
return listInstances()
|