jishushell 0.4.10 → 0.4.24
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.hermes-slim +193 -0
- package/INSTALL-NOTICE +10 -12
- package/apps/hermes-container.yaml +35 -0
- package/apps/ollama-binary.yaml +200 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +195 -0
- package/apps/openclaw-binary.yaml +69 -0
- package/apps/openclaw-container.yaml +37 -0
- package/apps/openclaw-with-ollama-container.yaml +42 -0
- package/apps/openclaw-with-searxng-container.yaml +136 -0
- package/apps/openwebui-container.yaml +53 -0
- package/apps/playwright-container.yaml +120 -0
- package/apps/searxng-container.yaml +115 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +15 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +4 -0
- package/dist/cli/app.js +814 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/cli/backup.d.ts +3 -0
- package/dist/cli/backup.js +434 -0
- package/dist/cli/backup.js.map +1 -0
- package/dist/{doctor.d.ts → cli/doctor.d.ts} +7 -1
- package/dist/{doctor.js → cli/doctor.js} +377 -22
- 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 +4 -0
- package/dist/cli/job.js +198 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +25 -0
- package/dist/cli/llm.js +599 -0
- package/dist/cli/llm.js.map +1 -0
- package/dist/cli/managed-list.d.ts +30 -0
- package/dist/cli/managed-list.js +129 -0
- package/dist/cli/managed-list.js.map +1 -0
- package/dist/cli/panel.d.ts +26 -0
- package/dist/cli/panel.js +804 -0
- package/dist/cli/panel.js.map +1 -0
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +12 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli.js +48 -776
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +69 -0
- package/dist/config.js +268 -7
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +17 -41
- package/dist/control.js +61 -1323
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +16 -0
- package/dist/install.js +75 -26
- package/dist/install.js.map +1 -1
- package/dist/routes/agent-apps.d.ts +15 -0
- package/dist/routes/agent-apps.js +78 -0
- package/dist/routes/agent-apps.js.map +1 -0
- package/dist/routes/apps.d.ts +3 -0
- package/dist/routes/apps.js +278 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/backup.js +3 -3
- package/dist/routes/backup.js.map +1 -1
- package/dist/routes/instances.d.ts +6 -0
- package/dist/routes/instances.js +863 -874
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +247 -0
- package/dist/routes/llm.js.map +1 -0
- package/dist/routes/runtime.d.ts +15 -0
- package/dist/routes/runtime.js +69 -0
- package/dist/routes/runtime.js.map +1 -0
- package/dist/routes/setup.js +131 -9
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +56 -9
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +107 -7
- package/dist/server.js.map +1 -1
- package/dist/services/agent-apps/catalog.d.ts +30 -0
- package/dist/services/agent-apps/catalog.js +60 -0
- package/dist/services/agent-apps/catalog.js.map +1 -0
- package/dist/services/agent-apps/index.d.ts +36 -0
- package/dist/services/agent-apps/index.js +171 -0
- package/dist/services/agent-apps/index.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
- package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
- package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
- package/dist/services/agent-apps/installers/adapter.js +171 -0
- package/dist/services/agent-apps/installers/adapter.js.map +1 -0
- package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
- package/dist/services/agent-apps/installers/registry-probe.js +183 -0
- package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
- package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
- package/dist/services/agent-apps/installers/shell-script.js +471 -0
- package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
- package/dist/services/agent-apps/types.d.ts +125 -0
- package/dist/services/agent-apps/types.js +17 -0
- package/dist/services/agent-apps/types.js.map +1 -0
- package/dist/services/app/app-compiler.d.ts +15 -0
- package/dist/services/app/app-compiler.js +172 -0
- package/dist/services/app/app-compiler.js.map +1 -0
- package/dist/services/app/app-manager.d.ts +142 -0
- package/dist/services/app/app-manager.js +1988 -0
- package/dist/services/app/app-manager.js.map +1 -0
- package/dist/services/app/custom-manager.d.ts +27 -0
- package/dist/services/app/custom-manager.js +285 -0
- package/dist/services/app/custom-manager.js.map +1 -0
- package/dist/services/app/hermes-agent-manager.d.ts +20 -0
- package/dist/services/app/hermes-agent-manager.js +289 -0
- package/dist/services/app/hermes-agent-manager.js.map +1 -0
- package/dist/services/app/id-normalizer.d.ts +27 -0
- package/dist/services/app/id-normalizer.js +77 -0
- package/dist/services/app/id-normalizer.js.map +1 -0
- package/dist/services/app/ollama-manager.d.ts +18 -0
- package/dist/services/app/ollama-manager.js +207 -0
- package/dist/services/app/ollama-manager.js.map +1 -0
- package/dist/services/app/openclaw-manager.d.ts +63 -0
- package/dist/services/app/openclaw-manager.js +1178 -0
- package/dist/services/app/openclaw-manager.js.map +1 -0
- package/dist/services/app/paths.d.ts +47 -0
- package/dist/services/app/paths.js +68 -0
- package/dist/services/app/paths.js.map +1 -0
- package/dist/services/app/registry.d.ts +17 -0
- package/dist/services/app/registry.js +31 -0
- package/dist/services/app/registry.js.map +1 -0
- package/dist/services/app/remote-spec.d.ts +14 -0
- package/dist/services/app/remote-spec.js +58 -0
- package/dist/services/app/remote-spec.js.map +1 -0
- package/dist/services/app/terminal-session-manager.d.ts +27 -0
- package/dist/services/app/terminal-session-manager.js +157 -0
- package/dist/services/app/terminal-session-manager.js.map +1 -0
- package/dist/services/app/types.d.ts +72 -0
- package/dist/services/app/types.js +16 -0
- package/dist/services/app/types.js.map +1 -0
- package/dist/services/backup-manager.js +60 -22
- package/dist/services/backup-manager.js.map +1 -1
- package/dist/services/instance-manager.d.ts +125 -34
- package/dist/services/instance-manager.js +679 -1043
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.js.map +1 -1
- package/dist/services/llm-proxy/circuit-breaker.js +10 -2
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +43 -0
- package/dist/services/llm-proxy/index.js +120 -5
- 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.d.ts +260 -3
- package/dist/services/nomad-manager.js +2921 -341
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +50 -0
- package/dist/services/panel-manager.js +443 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +42 -7
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/runtime/adapters/custom.d.ts +20 -0
- package/dist/services/runtime/adapters/custom.js +90 -0
- package/dist/services/runtime/adapters/custom.js.map +1 -0
- package/dist/services/runtime/adapters/hermes.d.ts +174 -0
- package/dist/services/runtime/adapters/hermes.js +1316 -0
- package/dist/services/runtime/adapters/hermes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
- package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
- package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
- package/dist/services/runtime/adapters/openclaw.js +2195 -0
- package/dist/services/runtime/adapters/openclaw.js.map +1 -0
- package/dist/services/runtime/errors.d.ts +28 -0
- package/dist/services/runtime/errors.js +31 -0
- package/dist/services/runtime/errors.js.map +1 -0
- package/dist/services/runtime/index.d.ts +34 -0
- package/dist/services/runtime/index.js +51 -0
- package/dist/services/runtime/index.js.map +1 -0
- package/dist/services/runtime/instance.d.ts +24 -0
- package/dist/services/runtime/instance.js +143 -0
- package/dist/services/runtime/instance.js.map +1 -0
- package/dist/services/runtime/migrations.d.ts +15 -0
- package/dist/services/runtime/migrations.js +25 -0
- package/dist/services/runtime/migrations.js.map +1 -0
- package/dist/services/runtime/registry.d.ts +13 -0
- package/dist/services/runtime/registry.js +32 -0
- package/dist/services/runtime/registry.js.map +1 -0
- package/dist/services/runtime/types.d.ts +545 -0
- package/dist/services/runtime/types.js +14 -0
- package/dist/services/runtime/types.js.map +1 -0
- package/dist/services/setup-manager.d.ts +70 -29
- package/dist/services/setup-manager.js +591 -625
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/task-registry.d.ts +44 -0
- package/dist/services/task-registry.js +74 -0
- package/dist/services/task-registry.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +6 -6
- package/dist/services/telemetry/heartbeat.js +29 -30
- 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 +222 -0
- package/dist/utils/docker-host.d.ts +15 -0
- package/dist/utils/docker-host.js +64 -0
- package/dist/utils/docker-host.js.map +1 -0
- package/install/jishu-install.sh +303 -37
- package/install/post-install.sh +64 -5
- package/package.json +19 -5
- package/public/assets/Dashboard-B-JoOjBQ.js +1 -0
- package/public/assets/HermesChatPanel-mFSureyc.js +1 -0
- package/public/assets/HermesConfigForm-DvR05LK1.js +4 -0
- package/public/assets/InitPassword-CVA8wQA6.js +1 -0
- package/public/assets/InstanceDetail-DcZW2QGO.js +91 -0
- package/public/assets/{Login-CUoEZOWR.js → Login-BWsZH2mu.js} +1 -1
- package/public/assets/NewInstance-BCIrAd86.js +1 -0
- package/public/assets/Settings-xkDcduFz.js +1 -0
- package/public/assets/Setup-Cfuwj4gV.js +1 -0
- package/public/assets/WeixinLoginPanel-CnjR8xMu.js +9 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/index-DQsM6Joa.js +19 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/assets/registry-B4UFJdpA.js +2 -0
- package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
- package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
- package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
- package/public/favicon.png +0 -0
- package/public/index.html +9 -4
- package/public/logos/hermes.png +0 -0
- package/public/logos/ollama.png +0 -0
- package/public/logos/openclaw.svg +60 -0
- package/scripts/build-hermes-image.sh +21 -0
- package/scripts/build-local.sh +54 -0
- package/scripts/check-adapter-isolation.ts +293 -0
- package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
- package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
- package/scripts/smoke/hermes-bootstrap.sh +195 -0
- package/templates/hermes-entrypoint.sh +154 -0
- package/dist/doctor.js.map +0 -1
- package/install/jishu-install-china.sh +0 -3092
- package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
- package/public/assets/InitPassword-BjubiVdd.js +0 -1
- package/public/assets/InstanceDetail-DMcywsof.js +0 -17
- package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
- package/public/assets/Settings-D5tHL_h5.js +0 -1
- package/public/assets/Setup-4t6E3Rut.js +0 -1
- package/public/assets/index-BJ47MWpF.css +0 -1
- package/public/assets/index-DbX85irc.js +0 -16
- package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
|
@@ -1,13 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { chownSync, closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, statSync, } from "fs";
|
|
3
|
+
import { rm as rmAsync } from "fs/promises";
|
|
4
4
|
import { createServer as netCreateServer } from "net";
|
|
5
|
-
import { userInfo } from "os";
|
|
5
|
+
import { networkInterfaces, userInfo } from "os";
|
|
6
6
|
import { dirname, join, resolve } from "path";
|
|
7
|
-
import
|
|
8
|
-
import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
|
|
7
|
+
import * as config from "../config.js";
|
|
9
8
|
import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
|
|
10
|
-
import { ensureDirContainer,
|
|
9
|
+
import { ensureDirContainer, writeSecretFile } from "../utils/fs.js";
|
|
10
|
+
// runtime/index.ts gets imported only for framework-level adapter lookups
|
|
11
|
+
// (defaultGatewayPort fallback). Adapters statically import BACK into this
|
|
12
|
+
// file for primitives; that static cycle is safe because no top-level code
|
|
13
|
+
// in adapters references instance-manager exports.
|
|
14
|
+
import { getAdapter, resolveAgentType } from "./runtime/index.js";
|
|
15
|
+
import { backfillInstanceMeta } from "./runtime/migrations.js";
|
|
16
|
+
function getConfigValue(name) {
|
|
17
|
+
return name in config ? config[name] : undefined;
|
|
18
|
+
}
|
|
19
|
+
function resolveConfigPath(value, fallback) {
|
|
20
|
+
return typeof value === "string" && value.trim() ? value : fallback;
|
|
21
|
+
}
|
|
22
|
+
const JISHUSHELL_HOME = resolveConfigPath(getConfigValue("JISHUSHELL_HOME"), resolve(process.env.HOME ?? userInfo().homedir, ".jishushell"));
|
|
23
|
+
const APPS_DIR = resolveConfigPath(getConfigValue("APPS_DIR"), join(JISHUSHELL_HOME, "apps"));
|
|
24
|
+
const BACKUPS_DIR = resolveConfigPath(getConfigValue("BACKUPS_DIR"), join(JISHUSHELL_HOME, "backups"));
|
|
25
|
+
const INSTANCES_DIR = resolveConfigPath(getConfigValue("INSTANCES_DIR"), join(JISHUSHELL_HOME, "instances"));
|
|
26
|
+
const getPanelConfigValue = getConfigValue("getPanelConfig");
|
|
27
|
+
const getPanelConfig = typeof getPanelConfigValue === "function"
|
|
28
|
+
? getPanelConfigValue
|
|
29
|
+
: () => ({});
|
|
11
30
|
const _configChangeListeners = [];
|
|
12
31
|
export function onConfigChange(listener) {
|
|
13
32
|
_configChangeListeners.push(listener);
|
|
@@ -17,41 +36,66 @@ export function onConfigChange(listener) {
|
|
|
17
36
|
_configChangeListeners.splice(idx, 1);
|
|
18
37
|
};
|
|
19
38
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Fire the config-change listener fan-out. Adapters call this after
|
|
41
|
+
* `saveNativeConfig()` so LLM proxy / config editors pick up the change.
|
|
42
|
+
*/
|
|
43
|
+
export function notifyConfigChange(instanceId) {
|
|
44
|
+
for (const listener of _configChangeListeners) {
|
|
45
|
+
try {
|
|
46
|
+
listener(instanceId);
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore listener errors */ }
|
|
49
|
+
}
|
|
29
50
|
}
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
52
|
+
// ── Path helpers (framework-level) ──
|
|
53
|
+
//
|
|
54
|
+
// Physical definitions live in config.ts alongside INSTANCES_DIR so
|
|
55
|
+
// tests that mock instance-manager.js don't lose path resolution. The
|
|
56
|
+
// re-exports below preserve the existing import surface.
|
|
57
|
+
function appInstanceDir(instanceId) {
|
|
58
|
+
return join(APPS_DIR, instanceId);
|
|
32
59
|
}
|
|
33
|
-
function
|
|
34
|
-
return join(
|
|
60
|
+
function hasAppInstallMarkers(dir) {
|
|
61
|
+
return existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "app-spec.yaml"));
|
|
35
62
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
63
|
+
function hasIncompleteAppInstallShadow(dir) {
|
|
64
|
+
const hasManifest = existsSync(join(dir, "manifest.json"));
|
|
65
|
+
const hasSpec = existsSync(join(dir, "app-spec.yaml"));
|
|
66
|
+
return (hasManifest || hasSpec) && !(hasManifest && hasSpec);
|
|
38
67
|
}
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
return
|
|
43
|
-
|
|
68
|
+
function resolveInstanceRoot(instanceId) {
|
|
69
|
+
const appDir = appInstanceDir(instanceId);
|
|
70
|
+
if (hasAppInstallMarkers(appDir) || (existsSync(join(appDir, "instance.json")) && !hasIncompleteAppInstallShadow(appDir))) {
|
|
71
|
+
return appDir;
|
|
72
|
+
}
|
|
73
|
+
// V1 legacy grandfathered path: fall through to `instances/<id>/` only when
|
|
74
|
+
// the legacy dir has real content. New instances (neither dir exists) get
|
|
75
|
+
// the V2 `apps/<id>/` default so they land in the new layout from day one.
|
|
76
|
+
const legacyDir = join(INSTANCES_DIR, instanceId);
|
|
77
|
+
if (existsSync(join(legacyDir, "instance.json"))) {
|
|
78
|
+
return legacyDir;
|
|
79
|
+
}
|
|
80
|
+
return appDir;
|
|
44
81
|
}
|
|
45
|
-
function
|
|
46
|
-
return
|
|
82
|
+
export function instanceDir(instanceId) {
|
|
83
|
+
return resolveInstanceRoot(instanceId);
|
|
47
84
|
}
|
|
48
|
-
function
|
|
49
|
-
return join(
|
|
85
|
+
export function instanceMetaPath(instanceId) {
|
|
86
|
+
return join(instanceDir(instanceId), "instance.json");
|
|
50
87
|
}
|
|
51
|
-
|
|
52
|
-
|
|
88
|
+
// §32.2 / §32.8: `defaultOpenclawHome` / `openclawStateDir` /
|
|
89
|
+
// `openclawConfigPathInternal` / `legacyOpenclawConfigPath` /
|
|
90
|
+
// `getOpenclawHomeInternal` / `defaultModelEnvFile` physically migrated
|
|
91
|
+
// into `src/services/runtime/adapters/openclaw.ts`. Callers reach them
|
|
92
|
+
// through `getAdapter("openclaw").resolve*()` methods.
|
|
93
|
+
// Re-export `defaultModelEnvFile` as a dispatch wrapper for any
|
|
94
|
+
// remaining framework callers that need an env file path.
|
|
95
|
+
export function defaultModelEnvFile(instanceId) {
|
|
96
|
+
return join(instanceDir(instanceId), "model.env");
|
|
53
97
|
}
|
|
54
|
-
function normalizePath(p) {
|
|
98
|
+
export function normalizePath(p) {
|
|
55
99
|
return resolve(p.replace(/^~/, userInfo().homedir));
|
|
56
100
|
}
|
|
57
101
|
// ── JSON / deep merge ──
|
|
@@ -80,27 +124,32 @@ function deepMerge(base, overlay) {
|
|
|
80
124
|
// Track in-flight port allocations to prevent race conditions
|
|
81
125
|
// between concurrent createInstance() calls.
|
|
82
126
|
const _pendingPorts = new Set();
|
|
83
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Read the gateway port out of a persisted runtime record. Tries
|
|
129
|
+
* `runtime.ports[]` first (generic framework contract); on miss,
|
|
130
|
+
* asks the runtime adapter for its legacy fallback (e.g. OpenClaw's
|
|
131
|
+
* `env.OPENCLAW_GATEWAY_PORT` / `args --port N`).
|
|
132
|
+
*/
|
|
133
|
+
export function extractGatewayPort(runtime, agentType) {
|
|
84
134
|
if (!runtime)
|
|
85
135
|
return null;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
const args = runtime.args || [];
|
|
93
|
-
for (let i = 0; i < args.length; i++) {
|
|
94
|
-
const arg = String(args[i]);
|
|
95
|
-
if (arg === "--port" && i + 1 < args.length) {
|
|
96
|
-
const p = parseInt(args[i + 1], 10);
|
|
97
|
-
return isNaN(p) ? null : p;
|
|
136
|
+
// Primary: RuntimeSpec.ports[] declaration — first gateway-labeled port wins.
|
|
137
|
+
const ports = Array.isArray(runtime.ports) ? runtime.ports : [];
|
|
138
|
+
for (const port of ports) {
|
|
139
|
+
if (port?.name === "gateway" && Number.isInteger(port.hostPort) && port.hostPort > 0) {
|
|
140
|
+
return port.hostPort;
|
|
98
141
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
142
|
+
}
|
|
143
|
+
// Fall back to the adapter's legacy reader. Default agentType "openclaw".
|
|
144
|
+
try {
|
|
145
|
+
const adapter = getAdapter(agentType || "openclaw");
|
|
146
|
+
if (typeof adapter.readLegacyGatewayPort === "function") {
|
|
147
|
+
return adapter.readLegacyGatewayPort(runtime);
|
|
102
148
|
}
|
|
103
149
|
}
|
|
150
|
+
catch {
|
|
151
|
+
/* adapter not registered — no fallback */
|
|
152
|
+
}
|
|
104
153
|
return null;
|
|
105
154
|
}
|
|
106
155
|
function usedGatewayPorts(excludeId) {
|
|
@@ -119,25 +168,83 @@ function safePort(port) {
|
|
|
119
168
|
throw new Error(`Invalid port: ${port}`);
|
|
120
169
|
return String(port);
|
|
121
170
|
}
|
|
122
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Probes whether a port is currently held by any process on the host.
|
|
173
|
+
*
|
|
174
|
+
* Binds four addresses concurrently — `0.0.0.0`, `127.0.0.1`, `::` (v6-only),
|
|
175
|
+
* and `::1` — and treats the port as busy if any probe returns EADDRINUSE.
|
|
176
|
+
* The single-`0.0.0.0` probe used by the earlier revision was sufficient on
|
|
177
|
+
* Linux (where binding `0.0.0.0` conflicts with any pre-existing loopback
|
|
178
|
+
* listener on the same port) but silently passed on macOS, where BSD socket
|
|
179
|
+
* semantics let a wildcard v4 bind coexist with a `127.0.0.1` listener. A
|
|
180
|
+
* user running a natively-installed openclaw bound to `127.0.0.1:18789`
|
|
181
|
+
* would then be invisible to jishushell, so port allocation would assign
|
|
182
|
+
* 18789 to a new instance and the gateway would silently fail to bind at
|
|
183
|
+
* start time. Probing the loopback addresses directly closes that gap.
|
|
184
|
+
*/
|
|
185
|
+
export function isPortInUse(port) {
|
|
123
186
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
124
187
|
return Promise.resolve(false);
|
|
125
|
-
|
|
188
|
+
const probeAt = (host, opts = {}) => new Promise((resolve) => {
|
|
126
189
|
const server = netCreateServer();
|
|
127
|
-
server.once("error", () =>
|
|
190
|
+
server.once("error", (err) => {
|
|
191
|
+
if (err?.code === "EADDRINUSE")
|
|
192
|
+
return resolve(true);
|
|
193
|
+
// EACCES / EADDRNOTAVAIL / ENOTSUP / EINVAL mean we could not even
|
|
194
|
+
// attempt the bind (restricted port, address family unsupported on
|
|
195
|
+
// this host, etc). Report "not busy" from this probe so one bad
|
|
196
|
+
// locus doesn't falsely block the whole port — the other probes
|
|
197
|
+
// still cover their respective loci.
|
|
198
|
+
if (err?.code && err.code !== "EACCES" && err.code !== "EADDRNOTAVAIL"
|
|
199
|
+
&& err.code !== "ENOTSUP" && err.code !== "EINVAL" && err.code !== "EAFNOSUPPORT") {
|
|
200
|
+
console.warn(`[port-probe] bind ${host}:${port} failed with ${err.code}: ${err.message}; treating locus as free`);
|
|
201
|
+
}
|
|
202
|
+
resolve(false);
|
|
203
|
+
});
|
|
128
204
|
server.once("listening", () => {
|
|
129
205
|
server.close(() => resolve(false));
|
|
130
206
|
});
|
|
131
|
-
server.listen(port,
|
|
207
|
+
server.listen({ port, host, ...opts });
|
|
132
208
|
});
|
|
209
|
+
// Run probes sequentially, not in parallel: two probes on the same port
|
|
210
|
+
// collide with each other (the first bind on 0.0.0.0 makes the second
|
|
211
|
+
// bind on 127.0.0.1 hit EADDRINUSE), which would make every port look
|
|
212
|
+
// busy. Short-circuit on the first busy locus to keep the common case
|
|
213
|
+
// (port free) close to single-probe latency.
|
|
214
|
+
return (async () => {
|
|
215
|
+
const loci = [
|
|
216
|
+
["0.0.0.0", {}],
|
|
217
|
+
["127.0.0.1", {}],
|
|
218
|
+
["::", { ipv6Only: true }],
|
|
219
|
+
["::1", {}],
|
|
220
|
+
];
|
|
221
|
+
for (const [host, opts] of loci) {
|
|
222
|
+
if (await probeAt(host, opts))
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
})();
|
|
133
227
|
}
|
|
134
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Kind-agnostic gateway port allocator. Callers (adapters) pass their own
|
|
230
|
+
* `defaultPort` — the framework no longer hardcodes OpenClaw / Hermes base
|
|
231
|
+
* ports here. Walks upward from `defaultPort` until a free port is found
|
|
232
|
+
* that is neither held by an existing instance nor by any process on the
|
|
233
|
+
* host. Concurrent callers coordinate via `_pendingPorts`.
|
|
234
|
+
*
|
|
235
|
+
* Caller must release the allocated port via {@link releasePendingPort}
|
|
236
|
+
* after persisting it into instance metadata (or on failure).
|
|
237
|
+
*/
|
|
238
|
+
export async function allocateGatewayPort(instanceId, defaultPort) {
|
|
135
239
|
const used = usedGatewayPorts(instanceId);
|
|
136
|
-
|
|
240
|
+
const skipped = [];
|
|
241
|
+
let port = defaultPort;
|
|
137
242
|
while (true) {
|
|
138
|
-
if (port > 65535)
|
|
139
|
-
throw new Error(
|
|
243
|
+
if (port > 65535) {
|
|
244
|
+
throw new Error(`No available gateway port found (all ports ${defaultPort}-65535 in use)`);
|
|
245
|
+
}
|
|
140
246
|
if (used.has(port) || _pendingPorts.has(port)) {
|
|
247
|
+
skipped.push(port);
|
|
141
248
|
port++;
|
|
142
249
|
continue;
|
|
143
250
|
}
|
|
@@ -147,49 +254,38 @@ async function defaultGatewayPort(instanceId) {
|
|
|
147
254
|
try {
|
|
148
255
|
if (await isPortInUse(port)) {
|
|
149
256
|
_pendingPorts.delete(port);
|
|
257
|
+
skipped.push(port);
|
|
150
258
|
port++;
|
|
151
259
|
continue;
|
|
152
260
|
}
|
|
153
|
-
return port;
|
|
261
|
+
return { port, skipped };
|
|
154
262
|
}
|
|
155
263
|
catch {
|
|
156
264
|
_pendingPorts.delete(port);
|
|
157
265
|
// Skip this port on a transient OS error rather than failing the entire
|
|
158
266
|
// allocation — a single bad port check should not prevent instance creation.
|
|
159
267
|
console.warn(`[instance] Port ${port} availability check failed, trying next port`);
|
|
268
|
+
skipped.push(port);
|
|
160
269
|
port++;
|
|
161
270
|
continue;
|
|
162
271
|
}
|
|
163
272
|
}
|
|
164
273
|
}
|
|
165
|
-
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
|
|
169
|
-
"/usr/local/bin/openclaw",
|
|
170
|
-
"/usr/bin/openclaw",
|
|
171
|
-
];
|
|
172
|
-
for (const p of candidates) {
|
|
173
|
-
if (existsSync(p)) {
|
|
174
|
-
// Ensure executable permission (npm install may strip +x on some platforms)
|
|
175
|
-
try {
|
|
176
|
-
chmodSync(p, 0o755);
|
|
177
|
-
}
|
|
178
|
-
catch { /* best effort — may be a symlink */ }
|
|
179
|
-
// If symlink, also chmod the target
|
|
180
|
-
try {
|
|
181
|
-
const real = realpathSync(p);
|
|
182
|
-
if (real !== p)
|
|
183
|
-
chmodSync(real, 0o755);
|
|
184
|
-
}
|
|
185
|
-
catch { /* best effort */ }
|
|
186
|
-
return p;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
return candidates[0]; // fallback, will fail with clear error at spawn
|
|
274
|
+
/** Release a port previously reserved by {@link allocateGatewayPort}. */
|
|
275
|
+
export function releasePendingPort(port) {
|
|
276
|
+
_pendingPorts.delete(port);
|
|
190
277
|
}
|
|
278
|
+
// ── Runtime / config builders ──
|
|
191
279
|
export function getResolvedOpenclawBin() {
|
|
192
|
-
|
|
280
|
+
try {
|
|
281
|
+
const a = getAdapter("openclaw");
|
|
282
|
+
return typeof a.resolveBin === "function"
|
|
283
|
+
? a.resolveBin()
|
|
284
|
+
: "/usr/local/bin/openclaw";
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return "/usr/local/bin/openclaw";
|
|
288
|
+
}
|
|
193
289
|
}
|
|
194
290
|
/**
|
|
195
291
|
* When jishushell runs as root (e.g. systemd service), returns the actual
|
|
@@ -229,7 +325,7 @@ export function resolveServiceUser() {
|
|
|
229
325
|
* openclaw process (running as that user) can read/write its own data files.
|
|
230
326
|
* No-op when not running as root.
|
|
231
327
|
*/
|
|
232
|
-
function chownToServiceUser(...paths) {
|
|
328
|
+
export function chownToServiceUser(...paths) {
|
|
233
329
|
const svc = resolveServiceUser();
|
|
234
330
|
if (!svc)
|
|
235
331
|
return;
|
|
@@ -243,90 +339,9 @@ function chownToServiceUser(...paths) {
|
|
|
243
339
|
}
|
|
244
340
|
}
|
|
245
341
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return {
|
|
250
|
-
command: resolveOpenclawBin(),
|
|
251
|
-
args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
|
|
252
|
-
cwd: home,
|
|
253
|
-
user: resolveServiceUser()?.username ?? userInfo().username,
|
|
254
|
-
env_files: [defaultModelEnvFile(instanceId)],
|
|
255
|
-
env: {
|
|
256
|
-
OPENCLAW_GATEWAY_PORT: String(port),
|
|
257
|
-
NODE_OPTIONS: "--max-old-space-size=2048",
|
|
258
|
-
},
|
|
259
|
-
resources: { CPU: 1000, MemoryMB: 2048 },
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
function starterConfig() {
|
|
263
|
-
const dp = getPanelConfig().default_provider;
|
|
264
|
-
let providerName = "minimax";
|
|
265
|
-
let providerConfig = {
|
|
266
|
-
baseUrl: "https://api.minimaxi.com/v1",
|
|
267
|
-
api: "openai-completions",
|
|
268
|
-
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
|
|
269
|
-
};
|
|
270
|
-
let defaultModel = "minimax/MiniMax-M2.7";
|
|
271
|
-
if (dp?.providerId) {
|
|
272
|
-
providerName = dp.providerId;
|
|
273
|
-
providerConfig = {
|
|
274
|
-
baseUrl: dp.baseUrl,
|
|
275
|
-
api: dp.api,
|
|
276
|
-
...(dp.authHeader ? { authHeader: true } : {}),
|
|
277
|
-
models: dp.models || [],
|
|
278
|
-
};
|
|
279
|
-
const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
|
|
280
|
-
defaultModel = `${providerName}/${modelId}`;
|
|
281
|
-
}
|
|
282
|
-
const config = {
|
|
283
|
-
models: { providers: { [providerName]: providerConfig } },
|
|
284
|
-
agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
|
|
285
|
-
channels: {},
|
|
286
|
-
gateway: {
|
|
287
|
-
mode: "local",
|
|
288
|
-
auth: { mode: "token", token: randomBytes(24).toString("hex") },
|
|
289
|
-
controlUi: { dangerouslyDisableDeviceAuth: true },
|
|
290
|
-
},
|
|
291
|
-
plugins: { entries: { feishu: { enabled: false } } },
|
|
292
|
-
};
|
|
293
|
-
// Store upstream proxy config so LLM proxy knows where to forward
|
|
294
|
-
if (dp?.providerId) {
|
|
295
|
-
config["x-jishushell"] = {
|
|
296
|
-
proxy: {
|
|
297
|
-
upstream: {
|
|
298
|
-
providerId: dp.providerId,
|
|
299
|
-
baseUrl: dp.baseUrl,
|
|
300
|
-
api: dp.api,
|
|
301
|
-
authHeader: dp.authHeader || false,
|
|
302
|
-
models: dp.models || [],
|
|
303
|
-
selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
|
|
304
|
-
hasApiKey: !!dp.apiKey,
|
|
305
|
-
},
|
|
306
|
-
},
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
return config;
|
|
310
|
-
}
|
|
311
|
-
// ── Config loading ──
|
|
312
|
-
function loadEffectiveConfig(instanceId) {
|
|
313
|
-
const runtimePath = openclawConfigPathInternal(instanceId);
|
|
314
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
315
|
-
const rExists = existsSync(runtimePath);
|
|
316
|
-
const lExists = existsSync(legacyPath);
|
|
317
|
-
if (rExists && lExists) {
|
|
318
|
-
const legacy = loadJson(legacyPath);
|
|
319
|
-
const runtime = loadJson(runtimePath);
|
|
320
|
-
if (legacy && runtime)
|
|
321
|
-
return deepMerge(legacy, runtime);
|
|
322
|
-
return runtime || legacy || null;
|
|
323
|
-
}
|
|
324
|
-
if (rExists)
|
|
325
|
-
return loadJson(runtimePath);
|
|
326
|
-
if (lExists)
|
|
327
|
-
return loadJson(legacyPath);
|
|
328
|
-
return null;
|
|
329
|
-
}
|
|
342
|
+
// §32.2 / §32.8: buildDefaultRuntime + starterConfig physically migrated
|
|
343
|
+
// into src/services/runtime/adapters/openclaw.ts. Framework layer no
|
|
344
|
+
// longer owns OpenClaw runtime templates or default config shape.
|
|
330
345
|
// ── Env file helpers ──
|
|
331
346
|
export function parseEnvFile(path) {
|
|
332
347
|
const env = {};
|
|
@@ -400,610 +415,106 @@ export function inferProviderApiKeyEnvName(providerId) {
|
|
|
400
415
|
normalized = "OPENCLAW_PROVIDER";
|
|
401
416
|
return `${normalized}_API_KEY`;
|
|
402
417
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
function injectProviderApiKeys(instanceId, config) {
|
|
411
|
-
const merged = structuredClone(config);
|
|
412
|
-
const runtimeEnv = getRuntimeEnv(instanceId);
|
|
413
|
-
const providers = merged.models?.providers || {};
|
|
414
|
-
for (const [providerId, provider] of Object.entries(providers)) {
|
|
415
|
-
if (typeof provider !== "object" || provider === null)
|
|
416
|
-
continue;
|
|
417
|
-
const p = provider;
|
|
418
|
-
const api = p.api;
|
|
419
|
-
if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
|
|
420
|
-
p.api = LEGACY_PROVIDER_API_ALIASES[api];
|
|
421
|
-
}
|
|
422
|
-
const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
|
|
423
|
-
if (apiKey)
|
|
424
|
-
p.apiKey = apiKey;
|
|
425
|
-
}
|
|
426
|
-
return merged;
|
|
427
|
-
}
|
|
428
|
-
function applyFeishuDebugAccessDefaults(channel) {
|
|
429
|
-
if (channel.enabled === false)
|
|
430
|
-
return;
|
|
431
|
-
if (!hasConfiguredValue(channel.appId))
|
|
432
|
-
return;
|
|
433
|
-
if (!hasConfiguredValue(channel.appSecret))
|
|
434
|
-
return;
|
|
435
|
-
let dmPolicy = channel.dmPolicy;
|
|
436
|
-
if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
|
|
437
|
-
channel.dmPolicy = "open";
|
|
438
|
-
dmPolicy = "open";
|
|
439
|
-
}
|
|
440
|
-
if (dmPolicy !== "open")
|
|
441
|
-
return;
|
|
442
|
-
if (!("resolveSenderNames" in channel))
|
|
443
|
-
channel.resolveSenderNames = false;
|
|
444
|
-
let accounts = channel.accounts;
|
|
445
|
-
if (typeof accounts !== "object" || accounts === null) {
|
|
446
|
-
accounts = {};
|
|
447
|
-
channel.accounts = accounts;
|
|
448
|
-
}
|
|
449
|
-
let defaultAccount = accounts.default;
|
|
450
|
-
if (typeof defaultAccount !== "object" || defaultAccount === null) {
|
|
451
|
-
defaultAccount = {};
|
|
452
|
-
accounts.default = defaultAccount;
|
|
453
|
-
}
|
|
454
|
-
if (!("resolveSenderNames" in defaultAccount))
|
|
455
|
-
defaultAccount.resolveSenderNames = false;
|
|
456
|
-
const allowFrom = channel.allowFrom;
|
|
457
|
-
if (Array.isArray(allowFrom)) {
|
|
458
|
-
const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
|
|
459
|
-
if (!normalized.includes("*"))
|
|
460
|
-
normalized.push("*");
|
|
461
|
-
channel.allowFrom = normalized;
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
channel.allowFrom = ["*"];
|
|
465
|
-
}
|
|
466
|
-
function prepareConfigForSave(instanceId, config) {
|
|
467
|
-
const configToWrite = structuredClone(config);
|
|
468
|
-
// Remove JishuShell metadata — OpenClaw rejects unrecognized keys
|
|
469
|
-
delete configToWrite["x-jishushell"];
|
|
470
|
-
const envUpdates = {};
|
|
471
|
-
const providers = configToWrite.models?.providers || {};
|
|
472
|
-
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
473
|
-
const channels = configToWrite.channels || {};
|
|
474
|
-
const plugins = configToWrite.plugins ??= {};
|
|
475
|
-
const pluginEntries = plugins.entries ??= {};
|
|
476
|
-
for (const [providerId, provider] of Object.entries(providers)) {
|
|
477
|
-
if (typeof provider !== "object" || provider === null)
|
|
478
|
-
continue;
|
|
479
|
-
const p = provider;
|
|
480
|
-
if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
|
|
481
|
-
p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
|
|
482
|
-
}
|
|
483
|
-
if (!("apiKey" in p))
|
|
484
|
-
continue;
|
|
485
|
-
// Keep proxy provider apiKey in config — OpenClaw reads it from config directly.
|
|
486
|
-
// Only real upstream provider keys get moved to env files for security.
|
|
487
|
-
// Detect proxy by baseUrl (provider ID now uses upstream name for display).
|
|
488
|
-
if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
|
|
489
|
-
continue;
|
|
490
|
-
const apiKey = p.apiKey;
|
|
491
|
-
delete p.apiKey;
|
|
492
|
-
if (envFiles.length) {
|
|
493
|
-
envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
p.apiKey = apiKey;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
for (const [channelId, channel] of Object.entries(channels)) {
|
|
500
|
-
if (typeof channel !== "object" || channel === null)
|
|
501
|
-
continue;
|
|
502
|
-
const ch = channel;
|
|
503
|
-
if (channelId === "feishu" || channelId === "lark")
|
|
504
|
-
applyFeishuDebugAccessDefaults(ch);
|
|
505
|
-
let pluginEntry = pluginEntries[channelId];
|
|
506
|
-
if (pluginEntry == null) {
|
|
507
|
-
pluginEntry = {};
|
|
508
|
-
pluginEntries[channelId] = pluginEntry;
|
|
509
|
-
}
|
|
510
|
-
if (typeof pluginEntry === "object") {
|
|
511
|
-
pluginEntry.enabled = ch.enabled !== false;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return [configToWrite, envUpdates];
|
|
515
|
-
}
|
|
516
|
-
// ── Channel plugin helpers ──
|
|
517
|
-
// Channel → plugin package mapping for auto-install.
|
|
518
|
-
// Stock plugins (bundled with newer OpenClaw) are detected via extensions/{id} dir;
|
|
519
|
-
// if missing (older OpenClaw), they get installed as fallback.
|
|
520
|
-
// @larksuite/openclaw-lark installs as "openclaw-lark" dir but registers channel "feishu"
|
|
521
|
-
const CHANNEL_EXT_DIR_ALIAS = {
|
|
522
|
-
feishu: "openclaw-lark",
|
|
523
|
-
lark: "openclaw-lark",
|
|
524
|
-
};
|
|
525
|
-
export const CHANNEL_PLUGIN_MAP = {
|
|
526
|
-
// Official vendor plugins (ByteDance Feishu/Lark)
|
|
527
|
-
feishu: "@larksuite/openclaw-lark",
|
|
528
|
-
lark: "@larksuite/openclaw-lark",
|
|
529
|
-
// Built-in (stock) — fallback install for older OpenClaw versions
|
|
530
|
-
telegram: "@openclaw/telegram",
|
|
531
|
-
discord: "@openclaw/discord",
|
|
532
|
-
slack: "@openclaw/slack",
|
|
533
|
-
whatsapp: "@openclaw/whatsapp",
|
|
534
|
-
signal: "@openclaw/signal",
|
|
535
|
-
line: "@openclaw/line",
|
|
536
|
-
msteams: "@openclaw/msteams",
|
|
537
|
-
// Official vendor plugins — need install (not bundled)
|
|
538
|
-
"openclaw-weixin": "@tencent-weixin/openclaw-weixin",
|
|
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
|
-
}
|
|
574
|
-
/** Check if a channel plugin is installed for an instance. */
|
|
575
|
-
export function isChannelPluginInstalled(instanceId, channelId) {
|
|
576
|
-
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
577
|
-
const stockExtDir = getStockExtensionsDir();
|
|
578
|
-
return existsSync(join(getChannelExtensionsDir(instanceId), extDirName))
|
|
579
|
-
|| existsSync(join(stockExtDir, extDirName))
|
|
580
|
-
// Also accept the built-in directory named after the raw channelId (e.g. "feishu/" in stock)
|
|
581
|
-
|| (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
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.
|
|
587
|
-
*/
|
|
588
|
-
export async function installChannelPlugin(instanceId, channelId) {
|
|
589
|
-
const pkg = CHANNEL_PLUGIN_MAP[channelId];
|
|
590
|
-
if (!pkg)
|
|
591
|
-
throw new Error(`Unknown channel: ${channelId}`);
|
|
592
|
-
if (isChannelPluginInstalled(instanceId, channelId))
|
|
593
|
-
return;
|
|
594
|
-
const openclawHome = getOpenclawHomeInternal(instanceId);
|
|
595
|
-
const extensionsDir = getChannelExtensionsDir(instanceId);
|
|
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
|
|
604
|
-
const nodeBinDir = dirname(process.execPath);
|
|
605
|
-
const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
|
|
606
|
-
const proxyEnvKeys = [
|
|
607
|
-
"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
|
|
608
|
-
"no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
|
|
609
|
-
];
|
|
610
|
-
const proxyEnv = {};
|
|
611
|
-
for (const key of proxyEnvKeys) {
|
|
612
|
-
if (process.env[key])
|
|
613
|
-
proxyEnv[key] = process.env[key];
|
|
614
|
-
}
|
|
615
|
-
const childEnv = {
|
|
616
|
-
PATH: childPath,
|
|
617
|
-
HOME: process.env.HOME,
|
|
618
|
-
LANG: process.env.LANG,
|
|
619
|
-
OPENCLAW_HOME: openclawHome,
|
|
620
|
-
...proxyEnv,
|
|
621
|
-
};
|
|
622
|
-
const MAX_ATTEMPTS = 3;
|
|
623
|
-
const RETRY_DELAY_MS = 5_000;
|
|
624
|
-
const attemptInstall = () => new Promise((resolve, reject) => {
|
|
625
|
-
execFile(openclawBin, ["plugins", "install", pkg], {
|
|
626
|
-
cwd: openclawHome,
|
|
627
|
-
env: childEnv,
|
|
628
|
-
timeout: 300_000,
|
|
629
|
-
}, (err, stdout, stderr) => {
|
|
630
|
-
if (err && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
631
|
-
const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
|
|
632
|
-
console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
|
|
633
|
-
try {
|
|
634
|
-
if (existsSync(extensionsDir)) {
|
|
635
|
-
for (const entry of readdirSync(extensionsDir)) {
|
|
636
|
-
if (entry.startsWith(".openclaw-install-stage-")) {
|
|
637
|
-
rmSync(join(extensionsDir, entry), { recursive: true, force: true });
|
|
638
|
-
console.log(`[plugins] Cleaned up stage dir: ${entry}`);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
catch (_) { }
|
|
644
|
-
reject(new Error(msg));
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
if (err)
|
|
648
|
-
console.log(`[plugins] ${pkg} installed (ignored non-zero exit: warning only)`);
|
|
649
|
-
else
|
|
650
|
-
console.log(`[plugins] ${pkg} installed`);
|
|
651
|
-
resolve();
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
|
|
656
|
-
let lastErr;
|
|
657
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
658
|
-
try {
|
|
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);
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
catch (err) {
|
|
677
|
-
lastErr = err;
|
|
678
|
-
const isFetchError = /fetch failed/i.test(err.message ?? "");
|
|
679
|
-
if (isFetchError && attempt < MAX_ATTEMPTS) {
|
|
680
|
-
console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed with fetch error, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
681
|
-
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
682
|
-
continue;
|
|
683
|
-
}
|
|
684
|
-
console.error(`[plugins] Failed to install ${pkg}:`, err.message);
|
|
685
|
-
break;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
throw lastErr;
|
|
689
|
-
}
|
|
418
|
+
// §32.2 / §32.8: hasConfiguredValue / injectProviderApiKeys /
|
|
419
|
+
// applyFeishuDebugAccessDefaults / prepareConfigForSave physically
|
|
420
|
+
// migrated into `src/services/runtime/adapters/openclaw.ts`.
|
|
421
|
+
// §32.2 / §32.8: Channel plugin helpers (CHANNEL_PLUGIN_MAP,
|
|
422
|
+
// installChannelPlugin, stripImBindings, etc.) physically migrated
|
|
423
|
+
// into `src/services/runtime/adapters/openclaw.ts`.
|
|
424
|
+
// ── Public API ──
|
|
690
425
|
/**
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
426
|
+
* Probe whether a file is readable by the current process. Used to
|
|
427
|
+
* distinguish "primary missing / corrupted" (recoverable via safeReadJson's
|
|
428
|
+
* .bak chain) from "primary exists but permission denied" (EACCES — the
|
|
429
|
+
* common sudo-script footgun that leaves root-owned files). safeReadJson
|
|
430
|
+
* swallows every read error internally and returns null, so without this
|
|
431
|
+
* probe an unreadable primary looks identical to a truly-gone instance.
|
|
694
432
|
*/
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
}
|
|
751
|
-
function getChannelExtensionsDir(instanceId) {
|
|
752
|
-
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
|
|
753
|
-
}
|
|
754
|
-
function getStockExtensionsDir() {
|
|
755
|
-
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
756
|
-
}
|
|
757
|
-
// ── Public API ──
|
|
758
|
-
export function listInstances() {
|
|
759
|
-
if (!existsSync(INSTANCES_DIR))
|
|
760
|
-
return [];
|
|
761
|
-
const entries = readdirSync(INSTANCES_DIR).sort();
|
|
762
|
-
const instances = [];
|
|
763
|
-
for (const name of entries) {
|
|
764
|
-
const metaPath = join(INSTANCES_DIR, name, "instance.json");
|
|
765
|
-
const dirPath = join(INSTANCES_DIR, name);
|
|
766
|
-
try {
|
|
767
|
-
if (statSync(dirPath).isDirectory() && existsSync(metaPath)) {
|
|
768
|
-
instances.push(JSON.parse(readFileSync(metaPath, "utf-8")));
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
catch (e) {
|
|
772
|
-
// Permission errors (EACCES) caused by root-owned files are a common deployment
|
|
773
|
-
// mistake (e.g. running a maintenance script as sudo). Log clearly instead of
|
|
774
|
-
// silently dropping the instance from the list.
|
|
775
|
-
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
return instances;
|
|
779
|
-
}
|
|
780
|
-
export function getInstance(instanceId) {
|
|
781
|
-
const metaPath = instanceMetaPath(instanceId);
|
|
782
|
-
if (!existsSync(metaPath))
|
|
433
|
+
function probeReadable(path) {
|
|
434
|
+
if (!existsSync(path))
|
|
783
435
|
return null;
|
|
784
436
|
try {
|
|
785
|
-
|
|
437
|
+
const fd = openSync(path, "r");
|
|
438
|
+
closeSync(fd);
|
|
439
|
+
return null;
|
|
786
440
|
}
|
|
787
441
|
catch (e) {
|
|
788
|
-
|
|
789
|
-
throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
|
|
442
|
+
return e;
|
|
790
443
|
}
|
|
791
444
|
}
|
|
792
|
-
export
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
804
|
-
// Resolve symlinks for the parent dir to catch symlink attacks
|
|
805
|
-
const parentDir = dirname(resolved);
|
|
806
|
-
if (existsSync(parentDir)) {
|
|
807
|
-
const realParent = realpathSync(parentDir);
|
|
808
|
-
if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
|
|
809
|
-
throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === normalizePath(home));
|
|
813
|
-
if (shared.length) {
|
|
814
|
-
throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared.map((i) => i.id).join(", ")}`);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
// Check for orphaned openclaw_home directory (e.g. instance.json deleted but data remains)
|
|
818
|
-
if (existsSync(home)) {
|
|
819
|
-
try {
|
|
820
|
-
const entries = readdirSync(home);
|
|
821
|
-
if (entries.length > 0) {
|
|
822
|
-
throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. Remove it manually or choose a different path.`);
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
catch (e) {
|
|
826
|
-
if (e.message.includes("not empty"))
|
|
827
|
-
throw e;
|
|
828
|
-
// readdirSync failed — directory might not be readable, proceed cautiously
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
ensureDirContainer(d);
|
|
832
|
-
// Inherit group from INSTANCES_DIR so both root and the real user can access
|
|
833
|
-
try {
|
|
834
|
-
const parentGid = statSync(INSTANCES_DIR).gid;
|
|
835
|
-
chownSync(d, -1, parentGid);
|
|
836
|
-
}
|
|
837
|
-
catch { /* non-root without CAP_CHOWN — already correct owner */ }
|
|
838
|
-
ensureDirContainer(home);
|
|
839
|
-
ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
|
|
840
|
-
const runtime = await defaultRuntime(instanceId, home);
|
|
841
|
-
const allocatedPort = extractGatewayPort(runtime);
|
|
842
|
-
// Port already reserved inside defaultGatewayPort; just track for cleanup
|
|
843
|
-
try {
|
|
844
|
-
const meta = {
|
|
845
|
-
id: instanceId,
|
|
846
|
-
name,
|
|
847
|
-
description,
|
|
848
|
-
openclaw_home: home,
|
|
849
|
-
runtime,
|
|
850
|
-
created_at: new Date().toISOString(),
|
|
851
|
-
};
|
|
852
|
-
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
853
|
-
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
854
|
-
for (const ef of envFiles) {
|
|
855
|
-
if (!existsSync(ef))
|
|
856
|
-
writeConfigFile(ef, "");
|
|
857
|
-
}
|
|
858
|
-
// After writing env files, ensure the runtime user can read them
|
|
859
|
-
try {
|
|
860
|
-
const runtimeUser = runtime.user;
|
|
861
|
-
if (runtimeUser && runtimeUser !== userInfo().username) {
|
|
862
|
-
for (const ef of envFiles) {
|
|
863
|
-
execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
catch { /* ignore - same user or no permission to chown */ }
|
|
868
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
869
|
-
ensureDirContainer(dirname(configPath));
|
|
870
|
-
if (cloneFrom && !existsSync(configPath)) {
|
|
871
|
-
const srcConfig = resolveExistingConfigPath(cloneFrom);
|
|
872
|
-
if (existsSync(srcConfig)) {
|
|
873
|
-
// Domain-level clone: copy config but strip proxy identity (token, jsproxy provider)
|
|
874
|
-
// so the new instance gets its own proxy token via saveInstanceConfig later
|
|
875
|
-
try {
|
|
876
|
-
const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
|
|
877
|
-
// Remove proxy provider (will be regenerated with new proxy token)
|
|
878
|
-
// Detect by baseUrl since provider ID now uses upstream name (e.g. "js-minimax")
|
|
879
|
-
const providers = cloned?.models?.providers;
|
|
880
|
-
if (providers) {
|
|
881
|
-
for (const [pid, prov] of Object.entries(providers)) {
|
|
882
|
-
if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
|
|
883
|
-
delete providers[pid];
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// Remove proxy model reference from agent defaults (regenerated by bootstrap)
|
|
888
|
-
const defaultModel = cloned?.agents?.defaults?.model;
|
|
889
|
-
if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
|
|
890
|
-
delete cloned.agents.defaults.model;
|
|
891
|
-
}
|
|
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);
|
|
896
|
-
// Copy extensions directory so plugin references in config remain valid
|
|
897
|
-
// Copy workspace directory to preserve agent personality (.md files)
|
|
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) {
|
|
912
|
-
const srcDir = join(dirname(srcConfig), subdir);
|
|
913
|
-
const dstDir = join(dirname(configPath), subdir);
|
|
914
|
-
if (existsSync(srcDir) && !existsSync(dstDir)) {
|
|
915
|
-
try {
|
|
916
|
-
cpSync(srcDir, dstDir, { recursive: true });
|
|
917
|
-
}
|
|
918
|
-
catch { /* best effort */ }
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
|
|
922
|
-
// Copy x-jishushell upstream metadata from source instance.json
|
|
923
|
-
// (saveConfig stores x-jishushell in instance.json, not openclaw.json)
|
|
924
|
-
const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
|
|
925
|
-
if (existsSync(srcMetaPath)) {
|
|
926
|
-
try {
|
|
927
|
-
const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
|
|
928
|
-
const srcXj = srcMeta?.["x-jishushell"];
|
|
929
|
-
if (srcXj?.proxy?.upstream) {
|
|
930
|
-
const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
|
|
931
|
-
// Clear instance-specific fields
|
|
932
|
-
delete dstXj.proxy.upstream.apiKey;
|
|
933
|
-
const metaPath = instanceMetaPath(instanceId);
|
|
934
|
-
if (existsSync(metaPath)) {
|
|
935
|
-
const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
936
|
-
dstMeta["x-jishushell"] = dstXj;
|
|
937
|
-
writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
catch { /* ignore metadata copy errors */ }
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
catch {
|
|
945
|
-
// Fallback: raw copy if parse fails
|
|
946
|
-
copyFileSync(srcConfig, configPath);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
if (!existsSync(configPath)) {
|
|
951
|
-
writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
|
|
952
|
-
// Inject default provider API key from setup into both env files
|
|
953
|
-
const dp = getPanelConfig().default_provider;
|
|
954
|
-
if (dp?.apiKey && dp?.providerId && envFiles.length) {
|
|
955
|
-
const envKey = inferProviderApiKeyEnvName(dp.providerId);
|
|
956
|
-
updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
|
|
957
|
-
// Also write to provider.env as UPSTREAM_API_KEY (LLM proxy reads this first)
|
|
958
|
-
const providerEnv = join(dirname(envFiles[0]), "provider.env");
|
|
959
|
-
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
// Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
|
|
963
|
-
if (cloneFrom && envFiles.length) {
|
|
964
|
-
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
965
|
-
const srcEnvFile = srcEnvFiles[0];
|
|
966
|
-
const dstEnvFile = envFiles[0];
|
|
967
|
-
// Copy provider.env (upstream API key)
|
|
968
|
-
if (srcEnvFile) {
|
|
969
|
-
const srcProvider = join(dirname(srcEnvFile), "provider.env");
|
|
970
|
-
const dstProvider = join(dirname(dstEnvFile), "provider.env");
|
|
971
|
-
if (existsSync(srcProvider) && !existsSync(dstProvider)) {
|
|
972
|
-
copyFileSync(srcProvider, dstProvider);
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
// Note: model.env is NOT copied (new instance needs its own proxy token)
|
|
976
|
-
}
|
|
977
|
-
// Bootstrap proxy: generate proxy token and write model.env so instance
|
|
978
|
-
// is ready to run immediately without requiring a manual "save config" first
|
|
979
|
-
try {
|
|
980
|
-
const { bootstrapInstanceProxy } = await import("../services/llm-proxy/index.js");
|
|
981
|
-
await bootstrapInstanceProxy(instanceId);
|
|
982
|
-
}
|
|
983
|
-
catch (e) {
|
|
984
|
-
console.warn(`[instance] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
|
|
985
|
-
}
|
|
986
|
-
// If running as root, hand ownership of all created files to the service user
|
|
987
|
-
// so the openclaw process (running as that user) can read/write its own files.
|
|
988
|
-
const svcUser = resolveServiceUser();
|
|
989
|
-
if (svcUser) {
|
|
445
|
+
export function listInstances() {
|
|
446
|
+
const deduped = new Map();
|
|
447
|
+
for (const rootDir of [APPS_DIR, INSTANCES_DIR]) {
|
|
448
|
+
if (!existsSync(rootDir))
|
|
449
|
+
continue;
|
|
450
|
+
const entries = readdirSync(rootDir).sort();
|
|
451
|
+
for (const name of entries) {
|
|
452
|
+
if (deduped.has(name))
|
|
453
|
+
continue;
|
|
454
|
+
const metaPath = join(rootDir, name, "instance.json");
|
|
455
|
+
const dirPath = join(rootDir, name);
|
|
990
456
|
try {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
457
|
+
if (!statSync(dirPath).isDirectory())
|
|
458
|
+
continue;
|
|
459
|
+
if (rootDir === APPS_DIR && hasIncompleteAppInstallShadow(dirPath))
|
|
460
|
+
continue;
|
|
461
|
+
if (!existsSync(metaPath))
|
|
462
|
+
continue;
|
|
463
|
+
// Use safeReadJson so primary instance.json that was corrupted or
|
|
464
|
+
// deleted mid-rename falls back to the .bak chain maintained by
|
|
465
|
+
// safeWriteJson. Without this, an interrupted safeWriteJson call
|
|
466
|
+
// would silently drop the instance from every list/get hot path
|
|
467
|
+
// even though the backup chain still holds valid content on disk.
|
|
468
|
+
const meta = safeReadJson(metaPath, `instance:${name}`);
|
|
469
|
+
if (meta) {
|
|
470
|
+
deduped.set(name, backfillInstanceMeta(meta));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// safeReadJson → null can mean any of (a) primary missing + no
|
|
474
|
+
// backups, (b) all candidates unparseable, (c) permission denied.
|
|
475
|
+
// (a) and (b) are legitimate "drop from list" cases; (c) is the
|
|
476
|
+
// sudo-script footgun and must be logged loudly so the operator
|
|
477
|
+
// doesn't just see an empty instance list with no hint why.
|
|
478
|
+
const readErr = probeReadable(metaPath);
|
|
479
|
+
if (readErr && readErr.code === "EACCES") {
|
|
480
|
+
console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
|
|
481
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
994
482
|
}
|
|
995
483
|
}
|
|
996
484
|
catch (e) {
|
|
997
|
-
|
|
485
|
+
// Fallback for failures before the safeReadJson call (e.g. statSync
|
|
486
|
+
// on a directory we can't enter). Still log instead of silently
|
|
487
|
+
// dropping the entry.
|
|
488
|
+
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
998
489
|
}
|
|
999
490
|
}
|
|
1000
|
-
return meta;
|
|
1001
491
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
492
|
+
return [...deduped.values()];
|
|
493
|
+
}
|
|
494
|
+
export function getInstance(instanceId) {
|
|
495
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
496
|
+
// Go through safeReadJson so primary missing/corrupted instance.json
|
|
497
|
+
// is still served from the .bak chain. Returning null on "truly gone"
|
|
498
|
+
// (no primary, no backups) keeps the existing 404 behavior intact.
|
|
499
|
+
const meta = safeReadJson(metaPath, `instance:${instanceId}`);
|
|
500
|
+
if (meta)
|
|
501
|
+
return backfillInstanceMeta(meta);
|
|
502
|
+
// safeReadJson swallows every read error internally, which is exactly
|
|
503
|
+
// wrong for the EACCES case — a root-owned primary would silently
|
|
504
|
+
// return null and callers would report "Instance not found" instead
|
|
505
|
+
// of the actionable "check file ownership" message. Re-probe to
|
|
506
|
+
// distinguish and throw on permission denial. Missing/corrupted with
|
|
507
|
+
// no backup still returns null (→ 404 upstream).
|
|
508
|
+
const readErr = probeReadable(metaPath);
|
|
509
|
+
if (readErr && readErr.code === "EACCES") {
|
|
510
|
+
throw new Error(`Cannot read instance '${instanceId}' metadata: ${readErr.message}. ` +
|
|
511
|
+
`Check file ownership with: ls -la ${metaPath}`);
|
|
1005
512
|
}
|
|
513
|
+
return null;
|
|
1006
514
|
}
|
|
515
|
+
// §32.2 / §32.8: `createInstance` physically migrated into
|
|
516
|
+
// `src/services/runtime/adapters/openclaw.ts:OpenClawAdapter.createInstance`.
|
|
517
|
+
// Framework callers now dispatch via `getAdapter(agentType).createInstance(args)`.
|
|
1007
518
|
export function updateInstance(instanceId, name, description) {
|
|
1008
519
|
const meta = getInstance(instanceId);
|
|
1009
520
|
if (!meta)
|
|
@@ -1016,14 +527,17 @@ export function updateInstance(instanceId, name, description) {
|
|
|
1016
527
|
chownToServiceUser(instanceMetaPath(instanceId));
|
|
1017
528
|
return meta;
|
|
1018
529
|
}
|
|
1019
|
-
|
|
530
|
+
// §32.2 / §32.8: `createHermesInstance` + `InstanceCreationRejected`
|
|
531
|
+
// physically migrated into `src/services/runtime/adapters/hermes.ts`
|
|
532
|
+
// and `src/services/runtime/errors.ts`. Framework callers dispatch via
|
|
533
|
+
// `getAdapter(agentType).createInstance(args)` uniformly.
|
|
1020
534
|
export function updateInstanceMeta(instanceId, patch) {
|
|
1021
535
|
const metaPath = instanceMetaPath(instanceId);
|
|
1022
536
|
const meta = safeReadJson(metaPath, "instance-meta") || {};
|
|
1023
537
|
Object.assign(meta, patch);
|
|
1024
538
|
safeWriteJson(metaPath, meta);
|
|
1025
539
|
}
|
|
1026
|
-
export function deleteInstance(instanceId, purgeBackups = false) {
|
|
540
|
+
export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
1027
541
|
const d = instanceDir(instanceId);
|
|
1028
542
|
if (!existsSync(d))
|
|
1029
543
|
return { ok: false, warnings: ["Instance directory not found"] };
|
|
@@ -1038,9 +552,35 @@ export function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1038
552
|
cancelJob(job.id);
|
|
1039
553
|
}
|
|
1040
554
|
}).catch(() => { });
|
|
1041
|
-
// Cache metadata BEFORE deletion so
|
|
555
|
+
// Cache metadata BEFORE deletion so adapters can inspect it after rm.
|
|
1042
556
|
const meta = getInstance(instanceId);
|
|
1043
|
-
const
|
|
557
|
+
const agentType = resolveAgentType(meta);
|
|
558
|
+
// Adapter-owned pre-delete hook. Adapters use this to emit advisories
|
|
559
|
+
// for resources that live outside the instance dir (legacy
|
|
560
|
+
// `openclaw_home`, named docker volumes, etc). Errors are collected
|
|
561
|
+
// into the response so one adapter misbehaving can't block removal.
|
|
562
|
+
try {
|
|
563
|
+
const legacyAppType = typeof meta?.app_type === "string" ? meta.app_type.trim() : "";
|
|
564
|
+
if ((legacyAppType === "custom" || legacyAppType === "ollama") && meta) {
|
|
565
|
+
const { getAppManager } = await import("./app/registry.js");
|
|
566
|
+
const manager = getAppManager(legacyAppType);
|
|
567
|
+
if (manager.onDelete) {
|
|
568
|
+
await manager.onDelete(instanceId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
const adapter = getAdapter(agentType);
|
|
573
|
+
if (adapter.hooks?.onDelete && meta) {
|
|
574
|
+
const hookResult = await adapter.hooks.onDelete({ instanceId, meta });
|
|
575
|
+
if (hookResult && Array.isArray(hookResult.warnings)) {
|
|
576
|
+
warnings.push(...hookResult.warnings);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
warnings.push(`adapter onDelete hook failed: ${e.message}`);
|
|
583
|
+
}
|
|
1044
584
|
// Clean up Nomad Variables (async, best-effort)
|
|
1045
585
|
import("./nomad-manager.js").then((nm) => {
|
|
1046
586
|
nm.purgeInstanceVariables(instanceId).catch((e) => {
|
|
@@ -1049,29 +589,33 @@ export function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1049
589
|
}).catch((e) => {
|
|
1050
590
|
console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
|
|
1051
591
|
});
|
|
592
|
+
// Async rm so the Node event loop stays responsive during large deletes:
|
|
593
|
+
// a fresh instance with a just-installed openclaw package can be 1+ GB
|
|
594
|
+
// with hundreds of nested dirs, which takes 30-60s to unlink on SD storage.
|
|
595
|
+
// rmSync would block every other HTTP request for that whole window.
|
|
1052
596
|
let dirDeleted = false;
|
|
1053
597
|
try {
|
|
1054
|
-
|
|
598
|
+
await rmAsync(d, { recursive: true, force: true });
|
|
1055
599
|
dirDeleted = true;
|
|
1056
600
|
}
|
|
1057
601
|
catch {
|
|
1058
602
|
try {
|
|
1059
|
-
execFileSync("sudo", ["rm", "-rf", d], { timeout:
|
|
603
|
+
execFileSync("sudo", ["rm", "-rf", d], { timeout: 300000 });
|
|
1060
604
|
dirDeleted = true;
|
|
1061
605
|
}
|
|
1062
606
|
catch (e) {
|
|
1063
607
|
warnings.push(`Failed to delete instance directory: ${e.message}`);
|
|
1064
608
|
}
|
|
1065
609
|
}
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
//
|
|
610
|
+
// (Custom openclaw_home orphan warning emitted by OpenClawAdapter.hooks.onDelete.)
|
|
611
|
+
// Handle backups (stored in separate directory, not affected by the
|
|
612
|
+
// instance rm above). Backups can be hundreds of MB each and accumulate
|
|
613
|
+
// across retention windows, so use the same async rm path to keep the
|
|
614
|
+
// event loop responsive.
|
|
1071
615
|
const backupDir = join(BACKUPS_DIR, instanceId);
|
|
1072
616
|
if (purgeBackups && existsSync(backupDir)) {
|
|
1073
617
|
try {
|
|
1074
|
-
|
|
618
|
+
await rmAsync(backupDir, { recursive: true, force: true });
|
|
1075
619
|
}
|
|
1076
620
|
catch (e) {
|
|
1077
621
|
warnings.push(`Failed to delete backups: ${e.message}`);
|
|
@@ -1082,285 +626,210 @@ export function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1082
626
|
}
|
|
1083
627
|
return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
|
|
1084
628
|
}
|
|
629
|
+
// §32.2 / §32.8: getConfig / getStoredConfig / saveConfig
|
|
630
|
+
// physically migrated into OpenClawAdapter. Framework callers now
|
|
631
|
+
// dispatch via `getAdapter(agentType).saveNativeConfig / .getNativeConfig`.
|
|
632
|
+
// Back-compat wrappers below preserve the legacy sync API shape for
|
|
633
|
+
// existing call sites (llm-proxy, routes, backup-manager).
|
|
1085
634
|
export function getConfig(instanceId) {
|
|
1086
|
-
const config = loadEffectiveConfig(instanceId);
|
|
1087
|
-
if (!config)
|
|
1088
|
-
return null;
|
|
1089
|
-
// Merge x-jishushell metadata from instance.json
|
|
1090
635
|
const meta = getInstance(instanceId);
|
|
1091
|
-
|
|
1092
|
-
|
|
636
|
+
const agentType = resolveAgentType(meta);
|
|
637
|
+
try {
|
|
638
|
+
const adapter = getAdapter(agentType);
|
|
639
|
+
return typeof adapter.getNativeConfig === "function"
|
|
640
|
+
? adapter.getNativeConfig(instanceId)
|
|
641
|
+
: null;
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return null;
|
|
1093
645
|
}
|
|
1094
|
-
return injectProviderApiKeys(instanceId, config);
|
|
1095
646
|
}
|
|
1096
647
|
export function getStoredConfig(instanceId) {
|
|
1097
|
-
const config = loadEffectiveConfig(instanceId);
|
|
1098
|
-
if (!config)
|
|
1099
|
-
return null;
|
|
1100
648
|
const meta = getInstance(instanceId);
|
|
1101
|
-
|
|
1102
|
-
|
|
649
|
+
const agentType = resolveAgentType(meta);
|
|
650
|
+
try {
|
|
651
|
+
const adapter = getAdapter(agentType);
|
|
652
|
+
return typeof adapter.getStoredNativeConfig === "function"
|
|
653
|
+
? adapter.getStoredNativeConfig(instanceId)
|
|
654
|
+
: null;
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return null;
|
|
1103
658
|
}
|
|
1104
|
-
return config;
|
|
1105
659
|
}
|
|
1106
|
-
export function saveConfig(instanceId, config) {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
660
|
+
export async function saveConfig(instanceId, config) {
|
|
661
|
+
const meta = getInstance(instanceId);
|
|
662
|
+
const agentType = resolveAgentType(meta);
|
|
663
|
+
try {
|
|
664
|
+
const adapter = getAdapter(agentType);
|
|
665
|
+
if (typeof adapter.saveNativeConfig !== "function")
|
|
666
|
+
return false;
|
|
667
|
+
// Adapters may return boolean or Promise<boolean>. Awaiting a plain
|
|
668
|
+
// boolean is a no-op, so this handles both. Previously we stripped the
|
|
669
|
+
// Promise and always reported success for async adapters — that masked
|
|
670
|
+
// failures and left callers unable to detect dirty state.
|
|
671
|
+
const result = await adapter.saveNativeConfig(instanceId, config);
|
|
672
|
+
return typeof result === "boolean" ? result : true;
|
|
673
|
+
}
|
|
674
|
+
catch (e) {
|
|
675
|
+
console.warn(`[instance-manager] saveConfig dispatch failed for ${instanceId}: ${e.message}`);
|
|
1109
676
|
return false;
|
|
1110
|
-
if (!existsSync(configPath)) {
|
|
1111
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1112
|
-
ensureDirContainer(dirname(configPath));
|
|
1113
|
-
if (existsSync(legacyPath))
|
|
1114
|
-
copyFileSync(legacyPath, configPath);
|
|
1115
677
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
678
|
+
}
|
|
679
|
+
// ── Deprecated back-compat dispatch wrappers ──────────────────────────
|
|
680
|
+
//
|
|
681
|
+
// The real implementations live in `OpenClawAdapter`. New code should
|
|
682
|
+
// use `getAdapter(agentType).X(...)` directly. These wrappers exist so
|
|
683
|
+
// existing unit tests and any stragglers in external-facing scripts
|
|
684
|
+
// keep working without churn. They will be removed once the test suite
|
|
685
|
+
// migrates to adapter-scoped imports.
|
|
686
|
+
/**
|
|
687
|
+
* @deprecated Use `getAdapter("openclaw").channelPluginMap` instead.
|
|
688
|
+
* Provides the OpenClaw channel-plugin map via a Proxy so old direct-
|
|
689
|
+
* property reads (e.g. `CHANNEL_PLUGIN_MAP.feishu`) keep working.
|
|
690
|
+
*/
|
|
691
|
+
export const CHANNEL_PLUGIN_MAP = new Proxy({}, {
|
|
692
|
+
get(_target, key) {
|
|
693
|
+
if (typeof key !== "string")
|
|
694
|
+
return undefined;
|
|
695
|
+
try {
|
|
696
|
+
return getAdapter("openclaw").channelPluginMap?.[key];
|
|
1124
697
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
// If openclaw-lark is configured as enabled, resolve which feishu plugin should actually be used:
|
|
1128
|
-
// - If built-in feishu/ exists in stock AND openclaw-lark/ is not installed anywhere → switch to
|
|
1129
|
-
// built-in feishu (removes the stale openclaw-lark reference that breaks container startup).
|
|
1130
|
-
// - If both exist → keep openclaw-lark but disable built-in feishu to avoid conflict.
|
|
1131
|
-
if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
|
|
1132
|
-
const stockExtDir = getStockExtensionsDir();
|
|
1133
|
-
const stockFeishu = join(stockExtDir, "feishu");
|
|
1134
|
-
const stockOcl = join(stockExtDir, "openclaw-lark");
|
|
1135
|
-
const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
|
|
1136
|
-
if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
|
|
1137
|
-
// Built-in available, community package absent → switch to built-in
|
|
1138
|
-
configToWrite.plugins.entries.feishu = { enabled: true };
|
|
1139
|
-
delete configToWrite.plugins.entries["openclaw-lark"];
|
|
698
|
+
catch {
|
|
699
|
+
return undefined;
|
|
1140
700
|
}
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
configToWrite.plugins.entries.feishu = { enabled: false };
|
|
701
|
+
},
|
|
702
|
+
ownKeys() {
|
|
703
|
+
try {
|
|
704
|
+
return Reflect.ownKeys(getAdapter("openclaw").channelPluginMap ?? {});
|
|
1146
705
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
706
|
+
catch {
|
|
707
|
+
return [];
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
has(_target, key) {
|
|
711
|
+
if (typeof key !== "string")
|
|
712
|
+
return false;
|
|
1153
713
|
try {
|
|
1154
|
-
|
|
1155
|
-
if (existing.plugins?.installs) {
|
|
1156
|
-
configToWrite.plugins ??= {};
|
|
1157
|
-
configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
|
|
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
|
-
}
|
|
714
|
+
return key in (getAdapter("openclaw").channelPluginMap ?? {});
|
|
1181
715
|
}
|
|
1182
|
-
catch {
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
writeConfigFile(configPath + ".tmp", configJson);
|
|
1191
|
-
// Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
|
|
1192
|
-
JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
|
|
1193
|
-
renameSync(configPath + ".tmp", configPath);
|
|
1194
|
-
chownToServiceUser(configPath);
|
|
1195
|
-
// also write to legacy path
|
|
1196
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1197
|
-
if (existsSync(legacyPath)) {
|
|
1198
|
-
copyFileSync(legacyPath, legacyPath + ".bak");
|
|
1199
|
-
}
|
|
1200
|
-
writeConfigFile(legacyPath + ".tmp", configJson);
|
|
1201
|
-
JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
|
|
1202
|
-
renameSync(legacyPath + ".tmp", legacyPath);
|
|
1203
|
-
chownToServiceUser(legacyPath);
|
|
1204
|
-
if (Object.keys(envUpdates).length) {
|
|
1205
|
-
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
1206
|
-
if (envFiles.length)
|
|
1207
|
-
updateEnvFile(envFiles[0], envUpdates);
|
|
1208
|
-
}
|
|
1209
|
-
// Plugins are installed inside the container — no host-side auto-install on config save.
|
|
1210
|
-
// Notify listeners (e.g. llm-proxy cache invalidation)
|
|
1211
|
-
for (const listener of _configChangeListeners) {
|
|
716
|
+
catch {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
getOwnPropertyDescriptor(_target, key) {
|
|
721
|
+
if (typeof key !== "string")
|
|
722
|
+
return undefined;
|
|
723
|
+
let value;
|
|
1212
724
|
try {
|
|
1213
|
-
|
|
725
|
+
value = getAdapter("openclaw").channelPluginMap?.[key];
|
|
1214
726
|
}
|
|
1215
|
-
catch {
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
}
|
|
727
|
+
catch {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
return value !== undefined
|
|
731
|
+
? { configurable: true, enumerable: true, writable: false, value }
|
|
732
|
+
: undefined;
|
|
733
|
+
},
|
|
734
|
+
});
|
|
1222
735
|
/**
|
|
1223
|
-
*
|
|
1224
|
-
* Save Feishu/Lark credentials from OAuth Device Code flow.
|
|
736
|
+
* @deprecated Use `getAdapter(agentType).isChannelPluginInstalled(id, channelId)` instead.
|
|
1225
737
|
*/
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
738
|
+
export function isChannelPluginInstalled(instanceId, channelId) {
|
|
739
|
+
try {
|
|
740
|
+
const meta = getInstance(instanceId);
|
|
741
|
+
const agentType = resolveAgentType(meta);
|
|
742
|
+
const adapter = getAdapter(agentType);
|
|
743
|
+
return typeof adapter.isChannelPluginInstalled === "function"
|
|
744
|
+
? adapter.isChannelPluginInstalled(instanceId, channelId)
|
|
745
|
+
: false;
|
|
1232
746
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
747
|
+
catch {
|
|
748
|
+
return false;
|
|
1235
749
|
}
|
|
1236
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
1237
|
-
let config = safeReadJson(configPath, "feishu-creds") || {};
|
|
1238
|
-
// Enable @larksuite/openclaw-lark plugin (installed inside Docker container),
|
|
1239
|
-
// disable built-in @openclaw/feishu to avoid conflict.
|
|
1240
|
-
config.plugins ??= {};
|
|
1241
|
-
config.plugins.entries ??= {};
|
|
1242
|
-
config.plugins.entries.feishu = { enabled: false };
|
|
1243
|
-
config.plugins.entries["openclaw-lark"] = { enabled: true };
|
|
1244
|
-
// Set channel config — official plugin reads from channels.feishu
|
|
1245
|
-
config.channels ??= {};
|
|
1246
|
-
config.channels.feishu = {
|
|
1247
|
-
...config.channels.feishu,
|
|
1248
|
-
enabled: true,
|
|
1249
|
-
appId: creds.appId,
|
|
1250
|
-
appSecret: creds.appSecret,
|
|
1251
|
-
domain: creds.domain,
|
|
1252
|
-
dmPolicy: "open",
|
|
1253
|
-
allowFrom: ["*"],
|
|
1254
|
-
};
|
|
1255
|
-
safeWriteJson(configPath, config);
|
|
1256
|
-
chownToServiceUser(configPath);
|
|
1257
|
-
console.log(`[instance-manager] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
|
|
1258
750
|
}
|
|
1259
751
|
/**
|
|
1260
|
-
*
|
|
752
|
+
* @deprecated Use `getAdapter(agentType).createInstance({ instanceId, name, description, ... })` instead.
|
|
753
|
+
* Retained for unit tests and legacy scripts; forwards to the
|
|
754
|
+
* OpenClawAdapter's `createInstance` method.
|
|
1261
755
|
*/
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
// Update accounts.json index (required by the plugin to discover accounts)
|
|
1284
|
-
const indexPath = join(stateDir, "accounts.json");
|
|
1285
|
-
let index = [];
|
|
756
|
+
export async function createInstance(instanceId, name, description, cloneFrom, agentHome, cloneOptions) {
|
|
757
|
+
const adapter = getAdapter("openclaw");
|
|
758
|
+
if (typeof adapter.createInstance !== "function") {
|
|
759
|
+
throw new Error("OpenClawAdapter.createInstance is not available");
|
|
760
|
+
}
|
|
761
|
+
return adapter.createInstance({
|
|
762
|
+
instanceId,
|
|
763
|
+
name,
|
|
764
|
+
description,
|
|
765
|
+
cloneFrom,
|
|
766
|
+
agentHome,
|
|
767
|
+
cloneOptions,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
// §32.2 / §32.8: saveFeishuCredentials / saveWeixinCredentials /
|
|
771
|
+
// getWeixinAccounts / getOpenclawHome physically migrated into
|
|
772
|
+
// `src/services/runtime/adapters/openclaw.ts`. Back-compat dispatch
|
|
773
|
+
// wrappers below keep existing callers working.
|
|
774
|
+
export function getOpenclawHome(instanceId) {
|
|
775
|
+
const meta = getInstance(instanceId);
|
|
776
|
+
const agentType = resolveAgentType(meta);
|
|
1286
777
|
try {
|
|
1287
|
-
const
|
|
1288
|
-
|
|
778
|
+
const a = getAdapter(agentType);
|
|
779
|
+
return typeof a.resolveAgentHome === "function"
|
|
780
|
+
? a.resolveAgentHome(instanceId)
|
|
781
|
+
: meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
|
|
1289
782
|
}
|
|
1290
|
-
catch {
|
|
1291
|
-
|
|
1292
|
-
index = [];
|
|
1293
|
-
if (!index.includes(creds.accountId))
|
|
1294
|
-
index.push(creds.accountId);
|
|
1295
|
-
safeWriteJson(indexPath, index);
|
|
1296
|
-
// Update openclaw.json: enable plugin + register account
|
|
1297
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
1298
|
-
let config = safeReadJson(configPath, "weixin-creds") || {};
|
|
1299
|
-
// Enable plugin
|
|
1300
|
-
config.plugins ??= {};
|
|
1301
|
-
config.plugins.entries ??= {};
|
|
1302
|
-
config.plugins.entries["openclaw-weixin"] ??= {};
|
|
1303
|
-
config.plugins.entries["openclaw-weixin"].enabled = true;
|
|
1304
|
-
// Enable channel with account
|
|
1305
|
-
config.channels ??= {};
|
|
1306
|
-
config.channels["openclaw-weixin"] ??= {};
|
|
1307
|
-
config.channels["openclaw-weixin"].enabled = true;
|
|
1308
|
-
// Register account with both original and normalized IDs (OpenClaw normalizes @ and . to -)
|
|
1309
|
-
const normalizedId = creds.accountId.replace(/[@.]/g, "-");
|
|
1310
|
-
const accounts = config.channels["openclaw-weixin"].accounts ??= {};
|
|
1311
|
-
accounts[creds.accountId] = { enabled: true };
|
|
1312
|
-
if (normalizedId !== creds.accountId)
|
|
1313
|
-
accounts[normalizedId] = { enabled: true };
|
|
1314
|
-
accounts["default"] = { enabled: true };
|
|
1315
|
-
// Set defaultAccount (required by OpenClaw)
|
|
1316
|
-
if (!config.channels["openclaw-weixin"].defaultAccount) {
|
|
1317
|
-
config.channels["openclaw-weixin"].defaultAccount = "default";
|
|
783
|
+
catch {
|
|
784
|
+
return meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
|
|
1318
785
|
}
|
|
1319
|
-
safeWriteJson(configPath, config);
|
|
1320
|
-
chownToServiceUser(configPath);
|
|
1321
|
-
console.log(`[instance-manager] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
|
|
1322
786
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
787
|
+
export function saveFeishuCredentials(instanceId, creds) {
|
|
788
|
+
const meta = getInstance(instanceId);
|
|
789
|
+
const agentType = resolveAgentType(meta);
|
|
790
|
+
const a = getAdapter(agentType);
|
|
791
|
+
if (typeof a.saveFeishuCredentials !== "function") {
|
|
792
|
+
throw new Error(`Runtime "${agentType}" does not support Feishu credentials`);
|
|
793
|
+
}
|
|
794
|
+
a.saveFeishuCredentials(instanceId, creds);
|
|
795
|
+
}
|
|
796
|
+
export function saveWeixinCredentials(instanceId, creds) {
|
|
797
|
+
const meta = getInstance(instanceId);
|
|
798
|
+
const agentType = resolveAgentType(meta);
|
|
799
|
+
const a = getAdapter(agentType);
|
|
800
|
+
if (typeof a.saveWeixinCredentials !== "function") {
|
|
801
|
+
throw new Error(`Runtime "${agentType}" does not support WeChat credentials`);
|
|
802
|
+
}
|
|
803
|
+
a.saveWeixinCredentials(instanceId, creds);
|
|
804
|
+
}
|
|
1326
805
|
export function getWeixinAccounts(instanceId) {
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
const accountsDir = join(stateDir, "accounts");
|
|
1330
|
-
if (!existsSync(accountsDir))
|
|
1331
|
-
return [];
|
|
1332
|
-
// Only return accounts listed in the index (skip default.json and other auxiliary files)
|
|
1333
|
-
let indexedIds = [];
|
|
806
|
+
const meta = getInstance(instanceId);
|
|
807
|
+
const agentType = resolveAgentType(meta);
|
|
1334
808
|
try {
|
|
1335
|
-
|
|
809
|
+
const a = getAdapter(agentType);
|
|
810
|
+
return typeof a.getWeixinAccounts === "function"
|
|
811
|
+
? a.getWeixinAccounts(instanceId)
|
|
812
|
+
: [];
|
|
1336
813
|
}
|
|
1337
|
-
catch {
|
|
1338
|
-
|
|
1339
|
-
for (const f of readdirSync(accountsDir)) {
|
|
1340
|
-
if (!f.endsWith(".json"))
|
|
1341
|
-
continue;
|
|
1342
|
-
const id = f.replace(/\.json$/, "");
|
|
1343
|
-
if (indexedIds.length > 0 && !indexedIds.includes(id))
|
|
1344
|
-
continue; // skip auxiliary files
|
|
1345
|
-
if (id === "default")
|
|
1346
|
-
continue; // always skip default alias
|
|
1347
|
-
try {
|
|
1348
|
-
const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
|
|
1349
|
-
results.push({
|
|
1350
|
-
accountId: id,
|
|
1351
|
-
userId: data.userId,
|
|
1352
|
-
savedAt: data.savedAt,
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
catch { /* skip */ }
|
|
814
|
+
catch {
|
|
815
|
+
return [];
|
|
1356
816
|
}
|
|
1357
|
-
return results;
|
|
1358
817
|
}
|
|
1359
818
|
export function getOpenclawConfigPath(instanceId) {
|
|
1360
|
-
|
|
819
|
+
const meta = getInstance(instanceId);
|
|
820
|
+
const agentType = resolveAgentType(meta);
|
|
821
|
+
const a = getAdapter(agentType);
|
|
822
|
+
if (typeof a.resolveConfigPath === "function")
|
|
823
|
+
return a.resolveConfigPath(instanceId);
|
|
824
|
+
return join(getOpenclawHome(instanceId), ".openclaw", "openclaw.json");
|
|
1361
825
|
}
|
|
1362
826
|
export function getLegacyOpenclawConfigPath(instanceId) {
|
|
1363
|
-
|
|
827
|
+
const meta = getInstance(instanceId);
|
|
828
|
+
const agentType = resolveAgentType(meta);
|
|
829
|
+
const a = getAdapter(agentType);
|
|
830
|
+
if (typeof a.resolveLegacyConfigPath === "function")
|
|
831
|
+
return a.resolveLegacyConfigPath(instanceId);
|
|
832
|
+
return join(getOpenclawHome(instanceId), "openclaw.json");
|
|
1364
833
|
}
|
|
1365
834
|
export function getInstanceRuntime(instanceId) {
|
|
1366
835
|
const meta = getInstance(instanceId);
|
|
@@ -1370,11 +839,28 @@ export function getInstanceRuntime(instanceId) {
|
|
|
1370
839
|
}
|
|
1371
840
|
export function getRuntimeEnvFiles(instanceId) {
|
|
1372
841
|
const runtime = getInstanceRuntime(instanceId);
|
|
1373
|
-
const
|
|
842
|
+
const rawEnvFiles = Array.isArray(runtime.env_files)
|
|
843
|
+
? runtime.env_files
|
|
844
|
+
: Array.isArray(runtime.envFiles)
|
|
845
|
+
? runtime.envFiles
|
|
846
|
+
: [];
|
|
847
|
+
const envFiles = rawEnvFiles.map((p) => normalizePath(p)).filter(Boolean);
|
|
1374
848
|
return envFiles.length ? envFiles : [defaultModelEnvFile(instanceId)];
|
|
1375
849
|
}
|
|
1376
850
|
export function getGatewayPort(instanceId) {
|
|
1377
|
-
|
|
851
|
+
const recorded = extractGatewayPort(getInstanceRuntime(instanceId));
|
|
852
|
+
if (recorded)
|
|
853
|
+
return recorded;
|
|
854
|
+
// Fallback: look up the adapter's canonical base port. Framework does
|
|
855
|
+
// not hardcode per-kind defaults.
|
|
856
|
+
const meta = getInstance(instanceId);
|
|
857
|
+
const agentType = resolveAgentType(meta);
|
|
858
|
+
try {
|
|
859
|
+
return getAdapter(agentType).defaultGatewayPort ?? 18789;
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return 18789;
|
|
863
|
+
}
|
|
1378
864
|
}
|
|
1379
865
|
/**
|
|
1380
866
|
* Detect the host address where the gateway port is actually listening.
|
|
@@ -1389,6 +875,47 @@ export function getGatewayPort(instanceId) {
|
|
|
1389
875
|
*/
|
|
1390
876
|
const _gwHostCache = new Map();
|
|
1391
877
|
const GW_HOST_CACHE_TTL = 30000;
|
|
878
|
+
export function getListeningHostForPort(port) {
|
|
879
|
+
let result = "127.0.0.1";
|
|
880
|
+
try {
|
|
881
|
+
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], {
|
|
882
|
+
encoding: "utf-8",
|
|
883
|
+
timeout: 3000,
|
|
884
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
885
|
+
});
|
|
886
|
+
for (const line of out.split("\n")) {
|
|
887
|
+
let match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
888
|
+
if (!match)
|
|
889
|
+
match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
|
|
890
|
+
if (match && match[2] === String(port)) {
|
|
891
|
+
const addr = match[1];
|
|
892
|
+
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch { /* fall through */ }
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
function getPrimaryIpv4Address() {
|
|
901
|
+
try {
|
|
902
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
903
|
+
for (const iface of list ?? []) {
|
|
904
|
+
if (!iface.internal && iface.family === "IPv4")
|
|
905
|
+
return iface.address;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch { /* fall through */ }
|
|
910
|
+
return "127.0.0.1";
|
|
911
|
+
}
|
|
912
|
+
export function getAdvertisedHostForPort(port) {
|
|
913
|
+
const host = getListeningHostForPort(port);
|
|
914
|
+
if (host && host !== "127.0.0.1" && host !== "0.0.0.0" && host !== "::1" && host !== "::") {
|
|
915
|
+
return host;
|
|
916
|
+
}
|
|
917
|
+
return getPrimaryIpv4Address();
|
|
918
|
+
}
|
|
1392
919
|
export async function getGatewayHost(instanceId) {
|
|
1393
920
|
const cached = _gwHostCache.get(instanceId);
|
|
1394
921
|
if (cached && Date.now() - cached.ts < GW_HOST_CACHE_TTL)
|
|
@@ -1399,7 +926,22 @@ export async function getGatewayHost(instanceId) {
|
|
|
1399
926
|
const { getNomadDriver } = await import("../config.js");
|
|
1400
927
|
if (getNomadDriver() === "docker") {
|
|
1401
928
|
const { getNomadAddr, getNomadToken } = await import("../config.js");
|
|
1402
|
-
|
|
929
|
+
// Dispatch job-id construction through the adapter so every runtime
|
|
930
|
+
// owns its own Nomad job namespace (hermes-<id>, openclaw-<id>, …).
|
|
931
|
+
// Falls back to a generic `jishushell-` prefix only when the adapter
|
|
932
|
+
// lookup fails — that branch should never fire for a registered
|
|
933
|
+
// agent type.
|
|
934
|
+
const meta = getInstance(instanceId);
|
|
935
|
+
const agentType = resolveAgentType(meta);
|
|
936
|
+
let prefix = "jishushell-";
|
|
937
|
+
try {
|
|
938
|
+
const a = getAdapter(agentType);
|
|
939
|
+
if (typeof a.nomadJobPrefix === "string" && a.nomadJobPrefix.length) {
|
|
940
|
+
prefix = a.nomadJobPrefix;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
catch { /* use fallback prefix */ }
|
|
944
|
+
const jid = `${prefix}${instanceId}`;
|
|
1403
945
|
const headers = { "Content-Type": "application/json" };
|
|
1404
946
|
const token = getNomadToken();
|
|
1405
947
|
if (token)
|
|
@@ -1416,11 +958,39 @@ export async function getGatewayHost(instanceId) {
|
|
|
1416
958
|
const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1417
959
|
if (detail.ok) {
|
|
1418
960
|
const d = await detail.json();
|
|
1419
|
-
|
|
1420
|
-
const
|
|
961
|
+
// Preferred source: AllocatedResources.Shared.Ports (bridge mode).
|
|
962
|
+
const sharedPorts = d?.AllocatedResources?.Shared?.Ports ?? [];
|
|
963
|
+
const gwPort = sharedPorts.find((p) => p.Label === "gateway");
|
|
1421
964
|
if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
|
|
1422
965
|
result = gwPort.HostIP;
|
|
1423
966
|
}
|
|
967
|
+
else {
|
|
968
|
+
// Host mode / task-level reservation: address lives under
|
|
969
|
+
// AllocatedResources.Tasks.<task>.Networks[*].IP. On Nomad
|
|
970
|
+
// 1.6.5 with `network_interface = "lo"`, the IP is whichever
|
|
971
|
+
// address the OS enumerates first — which can be IPv6 `::1`
|
|
972
|
+
// on systems where lo has both `127.0.0.1/8` and `::1/128`
|
|
973
|
+
// (the default on most modern Linux distros). Reading it from
|
|
974
|
+
// here is the authoritative source and matches what nomad
|
|
975
|
+
// configures the docker-proxy bind to use.
|
|
976
|
+
//
|
|
977
|
+
// Scan every task instead of indexing a hardcoded "gateway"
|
|
978
|
+
// task — adapters are free to name the task whatever they
|
|
979
|
+
// like, we only require the reserved port carry the
|
|
980
|
+
// framework-level `gateway` label.
|
|
981
|
+
const tasksMap = d?.AllocatedResources?.Tasks ?? {};
|
|
982
|
+
for (const taskName of Object.keys(tasksMap)) {
|
|
983
|
+
const taskNets = tasksMap[taskName]?.Networks ?? [];
|
|
984
|
+
const net = taskNets.find((n) => {
|
|
985
|
+
const rps = n?.ReservedPorts ?? [];
|
|
986
|
+
return rps.some((p) => p.Label === "gateway");
|
|
987
|
+
});
|
|
988
|
+
if (net?.IP && net.IP !== "0.0.0.0") {
|
|
989
|
+
result = net.IP;
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
1424
994
|
}
|
|
1425
995
|
}
|
|
1426
996
|
}
|
|
@@ -1433,27 +1003,74 @@ export async function getGatewayHost(instanceId) {
|
|
|
1433
1003
|
}
|
|
1434
1004
|
}
|
|
1435
1005
|
catch { /* fall through */ }
|
|
1436
|
-
|
|
1437
|
-
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
1438
|
-
for (const line of out.split("\n")) {
|
|
1439
|
-
const match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
1440
|
-
if (match && match[2] === String(port)) {
|
|
1441
|
-
const addr = match[1];
|
|
1442
|
-
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
1443
|
-
break;
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
}
|
|
1447
|
-
catch { /* fall through */ }
|
|
1006
|
+
result = getListeningHostForPort(port);
|
|
1448
1007
|
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1449
1008
|
return result;
|
|
1450
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Wrap an IPv6 literal in brackets for safe URL host-component / Host-header
|
|
1012
|
+
* use. Bare names ("gateway.local") and IPv4 ("127.0.0.1") contain no colon
|
|
1013
|
+
* and pass through unchanged; anything with a colon is an IPv6 literal and
|
|
1014
|
+
* MUST be bracketed before being concatenated with a port, otherwise
|
|
1015
|
+
* `http://::1:18789/` is unparseable.
|
|
1016
|
+
*/
|
|
1017
|
+
export function urlHost(host) {
|
|
1018
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
1019
|
+
}
|
|
1451
1020
|
export function findInstancesSharingOpenclawHome(instanceId) {
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
.
|
|
1021
|
+
const meta = getInstance(instanceId);
|
|
1022
|
+
const agentType = resolveAgentType(meta);
|
|
1023
|
+
try {
|
|
1024
|
+
const a = getAdapter(agentType);
|
|
1025
|
+
return typeof a.findInstancesSharingHome === "function"
|
|
1026
|
+
? a.findInstancesSharingHome(instanceId)
|
|
1027
|
+
: [];
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return [];
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Re-pick a gateway port for an existing instance and rewrite its persisted
|
|
1035
|
+
* runtime metadata (`runtime.args` and `runtime.env.OPENCLAW_GATEWAY_PORT`).
|
|
1036
|
+
*
|
|
1037
|
+
* Used when {@link isPortInUse} reports that the previously-assigned port has
|
|
1038
|
+
* been taken by something else between create-time and start-time (e.g. a
|
|
1039
|
+
* host-side openclaw started by the user, an unrelated service that grabbed
|
|
1040
|
+
* the port at boot, or a Docker race on the next allocation). The Nomad job
|
|
1041
|
+
* spec is rebuilt from instance metadata on every submit, so updating
|
|
1042
|
+
* `instance.json` here is sufficient — no other files need patching.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function reallocateGatewayPort(instanceId) {
|
|
1045
|
+
const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
|
|
1046
|
+
if (!meta)
|
|
1047
|
+
throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
|
|
1048
|
+
const agentType = resolveAgentType(meta);
|
|
1049
|
+
let adapter;
|
|
1050
|
+
try {
|
|
1051
|
+
adapter = getAdapter(agentType);
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
throw new Error(`Unknown runtime agentType "${agentType}" for instance ${instanceId}`);
|
|
1055
|
+
}
|
|
1056
|
+
const defaultPort = adapter.defaultGatewayPort ?? 18789;
|
|
1057
|
+
const fromPort = extractGatewayPort(meta.runtime, agentType) ?? defaultPort;
|
|
1058
|
+
const alloc = await allocateGatewayPort(instanceId, defaultPort);
|
|
1059
|
+
try {
|
|
1060
|
+
const runtime = (meta.runtime ?? {});
|
|
1061
|
+
// Delegate the kind-specific runtime rewrite to the adapter.
|
|
1062
|
+
if (typeof adapter.reallocateRuntimePort === "function") {
|
|
1063
|
+
adapter.reallocateRuntimePort(runtime, alloc.port);
|
|
1064
|
+
}
|
|
1065
|
+
meta.runtime = runtime;
|
|
1066
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
1067
|
+
chownToServiceUser(instanceMetaPath(instanceId));
|
|
1068
|
+
console.log(`[instance] ${instanceId}: gateway port reallocated ${fromPort} -> ${alloc.port}`);
|
|
1069
|
+
return { from: fromPort, to: alloc.port, skipped: alloc.skipped };
|
|
1070
|
+
}
|
|
1071
|
+
finally {
|
|
1072
|
+
_pendingPorts.delete(alloc.port);
|
|
1073
|
+
}
|
|
1457
1074
|
}
|
|
1458
1075
|
export function findInstancesSharingGatewayPort(instanceId) {
|
|
1459
1076
|
const targetPort = getGatewayPort(instanceId);
|
|
@@ -1474,15 +1091,34 @@ export function getRuntimeEnv(instanceId) {
|
|
|
1474
1091
|
}
|
|
1475
1092
|
return env;
|
|
1476
1093
|
}
|
|
1477
|
-
// Re-export instanceDir for nomad-manager
|
|
1094
|
+
// Re-export instanceDir for nomad-manager under its getInstanceDir alias.
|
|
1478
1095
|
export { instanceDir as getInstanceDir };
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1096
|
+
// ── Compatibility exports for app-type managers (src/services/app/) ──────────
|
|
1097
|
+
// `instanceMetaPath`, `chownToServiceUser`, `notifyConfigChange` already
|
|
1098
|
+
// exported above; §32.2/§32.8 migration kept the originals in place so the
|
|
1099
|
+
// app-manager layer can still depend on them. The shims below add the
|
|
1100
|
+
// naming aliases the cli branch's app managers import.
|
|
1101
|
+
/**
|
|
1102
|
+
* Compatibility shim: allocate a gateway port for a new instance and return
|
|
1103
|
+
* just the port number. Wraps `allocateGatewayPort`, which takes a seed port
|
|
1104
|
+
* and returns `{ port, skipped }`. Non-OpenClaw app managers don't have an
|
|
1105
|
+
* agentType-specific default port yet, so we seed with 18789 (OpenClaw's
|
|
1106
|
+
* default) — the allocator walks upward until it finds a free slot so the
|
|
1107
|
+
* actual port chosen is independent of the seed.
|
|
1108
|
+
*/
|
|
1109
|
+
export async function defaultGatewayPort(instanceId) {
|
|
1110
|
+
const result = await allocateGatewayPort(instanceId, 18789);
|
|
1111
|
+
return result.port;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Compatibility shim: release a pending port reservation.
|
|
1115
|
+
* Mirrors the cli-branch `releasePort(port)` helper on top of the framework's
|
|
1116
|
+
* existing `releasePendingPort`.
|
|
1117
|
+
*/
|
|
1118
|
+
export function releasePort(port) {
|
|
1119
|
+
releasePendingPort(port);
|
|
1487
1120
|
}
|
|
1121
|
+
// `resolveExistingConfigPath` is owned by the OpenClaw adapter after §32.2 —
|
|
1122
|
+
// callers that still need it live inside `src/services/runtime/adapters/` or
|
|
1123
|
+
// `src/services/app/openclaw-manager.ts` (which defines its own copy).
|
|
1488
1124
|
//# sourceMappingURL=instance-manager.js.map
|