jishushell 0.4.17 → 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/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 +1 -0
- package/dist/cli/app.js +710 -52
- package/dist/cli/app.js.map +1 -1
- 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/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +61 -35
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/job.d.ts +1 -0
- package/dist/cli/job.js +37 -99
- package/dist/cli/job.js.map +1 -1
- package/dist/cli/llm.d.ts +1 -0
- package/dist/cli/llm.js +20 -14
- package/dist/cli/llm.js.map +1 -1
- 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 +4 -3
- package/dist/cli/panel.js +94 -24
- package/dist/cli/panel.js.map +1 -1
- 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 +47 -516
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.js +266 -12
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +10 -6
- package/dist/control.js +87 -6
- 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.js +186 -7
- package/dist/routes/apps.js.map +1 -1
- 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 +862 -879
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.js +9 -8
- package/dist/routes/llm.js.map +1 -1
- 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 +103 -8
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +25 -3
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +71 -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-compiler.d.ts → app/app-compiler.d.ts} +3 -3
- package/dist/services/{app-compiler.js → app/app-compiler.js} +10 -7
- 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 +82 -39
- package/dist/services/instance-manager.js +575 -1142
- package/dist/services/instance-manager.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 +14 -1
- package/dist/services/llm-proxy/index.js +51 -6
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/nomad-manager.d.ts +260 -3
- package/dist/services/nomad-manager.js +2866 -449
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +10 -0
- package/dist/services/panel-manager.js +97 -0
- package/dist/services/panel-manager.js.map +1 -1
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +22 -0
- 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 +278 -597
- 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/types.d.ts +162 -2
- 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 +25 -1
- package/package.json +14 -4
- 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-D1Bt-Lyk.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/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/cli/openclaw.d.ts +0 -12
- package/dist/cli/openclaw.js +0 -156
- package/dist/cli/openclaw.js.map +0 -1
- package/dist/services/app-compiler.js.map +0 -1
- package/dist/services/app-manager.d.ts +0 -17
- package/dist/services/app-manager.js +0 -168
- package/dist/services/app-manager.js.map +0 -1
- package/dist/services/job-manager.d.ts +0 -22
- package/dist/services/job-manager.js +0 -102
- package/dist/services/job-manager.js.map +0 -1
- package/public/assets/Dashboard-CQsp1Mr9.js +0 -1
- package/public/assets/InitPassword-BEC8SE4A.js +0 -1
- package/public/assets/InstanceDetail-B5wTgNEg.js +0 -17
- package/public/assets/NewInstance-GQzm3K9D.js +0 -1
- package/public/assets/Settings-ByjGlqhP.js +0 -1
- package/public/assets/Setup-cMF21Y-8.js +0 -1
- package/public/assets/index-B6qQP4mH.css +0 -1
- package/public/assets/index-BuTQtuNy.js +0 -16
- package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { chmodSync, chownSync, closeSync, copyFileSync, cpSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, } from "fs";
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import { chownSync, closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, statSync, } from "fs";
|
|
4
3
|
import { rm as rmAsync } from "fs/promises";
|
|
5
4
|
import { createServer as netCreateServer } from "net";
|
|
6
|
-
import { userInfo } from "os";
|
|
5
|
+
import { networkInterfaces, userInfo } from "os";
|
|
7
6
|
import { dirname, join, resolve } from "path";
|
|
8
|
-
import
|
|
9
|
-
import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
|
|
7
|
+
import * as config from "../config.js";
|
|
10
8
|
import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
|
|
11
|
-
import { ensureDirContainer,
|
|
12
|
-
|
|
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
|
+
: () => ({});
|
|
13
30
|
const _configChangeListeners = [];
|
|
14
31
|
export function onConfigChange(listener) {
|
|
15
32
|
_configChangeListeners.push(listener);
|
|
@@ -19,41 +36,66 @@ export function onConfigChange(listener) {
|
|
|
19
36
|
_configChangeListeners.splice(idx, 1);
|
|
20
37
|
};
|
|
21
38
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
}
|
|
31
50
|
}
|
|
32
|
-
|
|
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);
|
|
59
|
+
}
|
|
60
|
+
function hasAppInstallMarkers(dir) {
|
|
61
|
+
return existsSync(join(dir, "manifest.json")) && existsSync(join(dir, "app-spec.yaml"));
|
|
62
|
+
}
|
|
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);
|
|
67
|
+
}
|
|
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;
|
|
81
|
+
}
|
|
82
|
+
export function instanceDir(instanceId) {
|
|
83
|
+
return resolveInstanceRoot(instanceId);
|
|
84
|
+
}
|
|
85
|
+
export function instanceMetaPath(instanceId) {
|
|
33
86
|
return join(instanceDir(instanceId), "instance.json");
|
|
34
87
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
function openclawStateDir(instanceId) {
|
|
48
|
-
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME);
|
|
49
|
-
}
|
|
50
|
-
function legacyOpenclawConfigPath(instanceId) {
|
|
51
|
-
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_CONFIG_FILENAME);
|
|
52
|
-
}
|
|
53
|
-
function openclawConfigPathInternal(instanceId) {
|
|
54
|
-
return join(openclawStateDir(instanceId), OPENCLAW_CONFIG_FILENAME);
|
|
55
|
-
}
|
|
56
|
-
function normalizePath(p) {
|
|
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");
|
|
97
|
+
}
|
|
98
|
+
export function normalizePath(p) {
|
|
57
99
|
return resolve(p.replace(/^~/, userInfo().homedir));
|
|
58
100
|
}
|
|
59
101
|
// ── JSON / deep merge ──
|
|
@@ -82,27 +124,32 @@ function deepMerge(base, overlay) {
|
|
|
82
124
|
// Track in-flight port allocations to prevent race conditions
|
|
83
125
|
// between concurrent createInstance() calls.
|
|
84
126
|
const _pendingPorts = new Set();
|
|
85
|
-
|
|
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) {
|
|
86
134
|
if (!runtime)
|
|
87
135
|
return null;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
return
|
|
93
|
-
}
|
|
94
|
-
const args = runtime.args || [];
|
|
95
|
-
for (let i = 0; i < args.length; i++) {
|
|
96
|
-
const arg = String(args[i]);
|
|
97
|
-
if (arg === "--port" && i + 1 < args.length) {
|
|
98
|
-
const p = parseInt(args[i + 1], 10);
|
|
99
|
-
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;
|
|
100
141
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
|
104
148
|
}
|
|
105
149
|
}
|
|
150
|
+
catch {
|
|
151
|
+
/* adapter not registered — no fallback */
|
|
152
|
+
}
|
|
106
153
|
return null;
|
|
107
154
|
}
|
|
108
155
|
function usedGatewayPorts(excludeId) {
|
|
@@ -124,41 +171,78 @@ function safePort(port) {
|
|
|
124
171
|
/**
|
|
125
172
|
* Probes whether a port is currently held by any process on the host.
|
|
126
173
|
*
|
|
127
|
-
* Binds `0.0.0.0
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
132
184
|
*/
|
|
133
185
|
export function isPortInUse(port) {
|
|
134
186
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
135
187
|
return Promise.resolve(false);
|
|
136
|
-
|
|
188
|
+
const probeAt = (host, opts = {}) => new Promise((resolve) => {
|
|
137
189
|
const server = netCreateServer();
|
|
138
|
-
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
|
+
});
|
|
139
204
|
server.once("listening", () => {
|
|
140
205
|
server.close(() => resolve(false));
|
|
141
206
|
});
|
|
142
|
-
server.listen(port,
|
|
207
|
+
server.listen({ port, host, ...opts });
|
|
143
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
|
+
})();
|
|
144
227
|
}
|
|
145
228
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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`.
|
|
150
234
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* shows up in `usedGatewayPorts`) or clear `_pendingPorts` on failure.
|
|
235
|
+
* Caller must release the allocated port via {@link releasePendingPort}
|
|
236
|
+
* after persisting it into instance metadata (or on failure).
|
|
154
237
|
*/
|
|
155
|
-
async function allocateGatewayPort(instanceId) {
|
|
238
|
+
export async function allocateGatewayPort(instanceId, defaultPort) {
|
|
156
239
|
const used = usedGatewayPorts(instanceId);
|
|
157
240
|
const skipped = [];
|
|
158
|
-
let port =
|
|
241
|
+
let port = defaultPort;
|
|
159
242
|
while (true) {
|
|
160
|
-
if (port > 65535)
|
|
161
|
-
throw new Error(
|
|
243
|
+
if (port > 65535) {
|
|
244
|
+
throw new Error(`No available gateway port found (all ports ${defaultPort}-65535 in use)`);
|
|
245
|
+
}
|
|
162
246
|
if (used.has(port) || _pendingPorts.has(port)) {
|
|
163
247
|
skipped.push(port);
|
|
164
248
|
port++;
|
|
@@ -187,34 +271,21 @@ async function allocateGatewayPort(instanceId) {
|
|
|
187
271
|
}
|
|
188
272
|
}
|
|
189
273
|
}
|
|
190
|
-
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
|
|
194
|
-
"/usr/local/bin/openclaw",
|
|
195
|
-
"/usr/bin/openclaw",
|
|
196
|
-
];
|
|
197
|
-
for (const p of candidates) {
|
|
198
|
-
if (existsSync(p)) {
|
|
199
|
-
// Ensure executable permission (npm install may strip +x on some platforms)
|
|
200
|
-
try {
|
|
201
|
-
chmodSync(p, 0o755);
|
|
202
|
-
}
|
|
203
|
-
catch { /* best effort — may be a symlink */ }
|
|
204
|
-
// If symlink, also chmod the target
|
|
205
|
-
try {
|
|
206
|
-
const real = realpathSync(p);
|
|
207
|
-
if (real !== p)
|
|
208
|
-
chmodSync(real, 0o755);
|
|
209
|
-
}
|
|
210
|
-
catch { /* best effort */ }
|
|
211
|
-
return p;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
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);
|
|
215
277
|
}
|
|
278
|
+
// ── Runtime / config builders ──
|
|
216
279
|
export function getResolvedOpenclawBin() {
|
|
217
|
-
|
|
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
|
+
}
|
|
218
289
|
}
|
|
219
290
|
/**
|
|
220
291
|
* When jishushell runs as root (e.g. systemd service), returns the actual
|
|
@@ -254,7 +325,7 @@ export function resolveServiceUser() {
|
|
|
254
325
|
* openclaw process (running as that user) can read/write its own data files.
|
|
255
326
|
* No-op when not running as root.
|
|
256
327
|
*/
|
|
257
|
-
function chownToServiceUser(...paths) {
|
|
328
|
+
export function chownToServiceUser(...paths) {
|
|
258
329
|
const svc = resolveServiceUser();
|
|
259
330
|
if (!svc)
|
|
260
331
|
return;
|
|
@@ -268,89 +339,9 @@ function chownToServiceUser(...paths) {
|
|
|
268
339
|
}
|
|
269
340
|
}
|
|
270
341
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
command: resolveOpenclawBin(),
|
|
275
|
-
args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
|
|
276
|
-
cwd: home,
|
|
277
|
-
user: resolveServiceUser()?.username ?? userInfo().username,
|
|
278
|
-
env_files: [defaultModelEnvFile(instanceId)],
|
|
279
|
-
env: {
|
|
280
|
-
OPENCLAW_GATEWAY_PORT: String(port),
|
|
281
|
-
NODE_OPTIONS: "--max-old-space-size=2048",
|
|
282
|
-
},
|
|
283
|
-
resources: { CPU: 1000, MemoryMB: 2048 },
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
function starterConfig() {
|
|
287
|
-
const dp = getPanelConfig().default_provider;
|
|
288
|
-
let providerName = "minimax";
|
|
289
|
-
let providerConfig = {
|
|
290
|
-
baseUrl: "https://api.minimaxi.com/v1",
|
|
291
|
-
api: "openai-completions",
|
|
292
|
-
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
|
|
293
|
-
};
|
|
294
|
-
let defaultModel = "minimax/MiniMax-M2.7";
|
|
295
|
-
if (dp?.providerId) {
|
|
296
|
-
providerName = dp.providerId;
|
|
297
|
-
providerConfig = {
|
|
298
|
-
baseUrl: dp.baseUrl,
|
|
299
|
-
api: dp.api,
|
|
300
|
-
...(dp.authHeader ? { authHeader: true } : {}),
|
|
301
|
-
models: dp.models || [],
|
|
302
|
-
};
|
|
303
|
-
const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
|
|
304
|
-
defaultModel = `${providerName}/${modelId}`;
|
|
305
|
-
}
|
|
306
|
-
const config = {
|
|
307
|
-
models: { providers: { [providerName]: providerConfig } },
|
|
308
|
-
agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
|
|
309
|
-
channels: {},
|
|
310
|
-
gateway: {
|
|
311
|
-
mode: "local",
|
|
312
|
-
auth: { mode: "token", token: randomBytes(24).toString("hex") },
|
|
313
|
-
controlUi: { dangerouslyDisableDeviceAuth: true },
|
|
314
|
-
},
|
|
315
|
-
plugins: { entries: { feishu: { enabled: false } } },
|
|
316
|
-
};
|
|
317
|
-
// Store upstream proxy config so LLM proxy knows where to forward
|
|
318
|
-
if (dp?.providerId) {
|
|
319
|
-
config["x-jishushell"] = {
|
|
320
|
-
proxy: {
|
|
321
|
-
upstream: {
|
|
322
|
-
providerId: dp.providerId,
|
|
323
|
-
baseUrl: dp.baseUrl,
|
|
324
|
-
api: dp.api,
|
|
325
|
-
authHeader: dp.authHeader || false,
|
|
326
|
-
models: dp.models || [],
|
|
327
|
-
selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
|
|
328
|
-
hasApiKey: !!dp.apiKey,
|
|
329
|
-
},
|
|
330
|
-
},
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
return config;
|
|
334
|
-
}
|
|
335
|
-
// ── Config loading ──
|
|
336
|
-
function loadEffectiveConfig(instanceId) {
|
|
337
|
-
const runtimePath = openclawConfigPathInternal(instanceId);
|
|
338
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
339
|
-
const rExists = existsSync(runtimePath);
|
|
340
|
-
const lExists = existsSync(legacyPath);
|
|
341
|
-
if (rExists && lExists) {
|
|
342
|
-
const legacy = loadJson(legacyPath);
|
|
343
|
-
const runtime = loadJson(runtimePath);
|
|
344
|
-
if (legacy && runtime)
|
|
345
|
-
return deepMerge(legacy, runtime);
|
|
346
|
-
return runtime || legacy || null;
|
|
347
|
-
}
|
|
348
|
-
if (rExists)
|
|
349
|
-
return loadJson(runtimePath);
|
|
350
|
-
if (lExists)
|
|
351
|
-
return loadJson(legacyPath);
|
|
352
|
-
return null;
|
|
353
|
-
}
|
|
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.
|
|
354
345
|
// ── Env file helpers ──
|
|
355
346
|
export function parseEnvFile(path) {
|
|
356
347
|
const env = {};
|
|
@@ -424,360 +415,12 @@ export function inferProviderApiKeyEnvName(providerId) {
|
|
|
424
415
|
normalized = "OPENCLAW_PROVIDER";
|
|
425
416
|
return `${normalized}_API_KEY`;
|
|
426
417
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
function injectProviderApiKeys(instanceId, config) {
|
|
435
|
-
const merged = structuredClone(config);
|
|
436
|
-
const runtimeEnv = getRuntimeEnv(instanceId);
|
|
437
|
-
const providers = merged.models?.providers || {};
|
|
438
|
-
for (const [providerId, provider] of Object.entries(providers)) {
|
|
439
|
-
if (typeof provider !== "object" || provider === null)
|
|
440
|
-
continue;
|
|
441
|
-
const p = provider;
|
|
442
|
-
const api = p.api;
|
|
443
|
-
if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
|
|
444
|
-
p.api = LEGACY_PROVIDER_API_ALIASES[api];
|
|
445
|
-
}
|
|
446
|
-
const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
|
|
447
|
-
if (apiKey)
|
|
448
|
-
p.apiKey = apiKey;
|
|
449
|
-
}
|
|
450
|
-
return merged;
|
|
451
|
-
}
|
|
452
|
-
function applyFeishuDebugAccessDefaults(channel) {
|
|
453
|
-
if (channel.enabled === false)
|
|
454
|
-
return;
|
|
455
|
-
if (!hasConfiguredValue(channel.appId))
|
|
456
|
-
return;
|
|
457
|
-
if (!hasConfiguredValue(channel.appSecret))
|
|
458
|
-
return;
|
|
459
|
-
let dmPolicy = channel.dmPolicy;
|
|
460
|
-
if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
|
|
461
|
-
channel.dmPolicy = "open";
|
|
462
|
-
dmPolicy = "open";
|
|
463
|
-
}
|
|
464
|
-
if (dmPolicy !== "open")
|
|
465
|
-
return;
|
|
466
|
-
if (!("resolveSenderNames" in channel))
|
|
467
|
-
channel.resolveSenderNames = false;
|
|
468
|
-
let accounts = channel.accounts;
|
|
469
|
-
if (typeof accounts !== "object" || accounts === null) {
|
|
470
|
-
accounts = {};
|
|
471
|
-
channel.accounts = accounts;
|
|
472
|
-
}
|
|
473
|
-
let defaultAccount = accounts.default;
|
|
474
|
-
if (typeof defaultAccount !== "object" || defaultAccount === null) {
|
|
475
|
-
defaultAccount = {};
|
|
476
|
-
accounts.default = defaultAccount;
|
|
477
|
-
}
|
|
478
|
-
if (!("resolveSenderNames" in defaultAccount))
|
|
479
|
-
defaultAccount.resolveSenderNames = false;
|
|
480
|
-
const allowFrom = channel.allowFrom;
|
|
481
|
-
if (Array.isArray(allowFrom)) {
|
|
482
|
-
const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
|
|
483
|
-
if (!normalized.includes("*"))
|
|
484
|
-
normalized.push("*");
|
|
485
|
-
channel.allowFrom = normalized;
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
channel.allowFrom = ["*"];
|
|
489
|
-
}
|
|
490
|
-
function prepareConfigForSave(instanceId, config) {
|
|
491
|
-
const configToWrite = structuredClone(config);
|
|
492
|
-
// Remove JishuShell metadata — OpenClaw rejects unrecognized keys
|
|
493
|
-
delete configToWrite["x-jishushell"];
|
|
494
|
-
const envUpdates = {};
|
|
495
|
-
const providers = configToWrite.models?.providers || {};
|
|
496
|
-
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
497
|
-
const channels = configToWrite.channels || {};
|
|
498
|
-
const plugins = configToWrite.plugins ??= {};
|
|
499
|
-
const pluginEntries = plugins.entries ??= {};
|
|
500
|
-
for (const [providerId, provider] of Object.entries(providers)) {
|
|
501
|
-
if (typeof provider !== "object" || provider === null)
|
|
502
|
-
continue;
|
|
503
|
-
const p = provider;
|
|
504
|
-
if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
|
|
505
|
-
p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
|
|
506
|
-
}
|
|
507
|
-
if (!("apiKey" in p))
|
|
508
|
-
continue;
|
|
509
|
-
// Keep proxy provider apiKey in config — OpenClaw reads it from config directly.
|
|
510
|
-
// Only real upstream provider keys get moved to env files for security.
|
|
511
|
-
// Detect proxy by baseUrl (provider ID now uses upstream name for display).
|
|
512
|
-
if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
|
|
513
|
-
continue;
|
|
514
|
-
const apiKey = p.apiKey;
|
|
515
|
-
delete p.apiKey;
|
|
516
|
-
if (envFiles.length) {
|
|
517
|
-
envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
|
|
518
|
-
}
|
|
519
|
-
else {
|
|
520
|
-
p.apiKey = apiKey;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
for (const [channelId, channel] of Object.entries(channels)) {
|
|
524
|
-
if (typeof channel !== "object" || channel === null)
|
|
525
|
-
continue;
|
|
526
|
-
const ch = channel;
|
|
527
|
-
if (channelId === "feishu" || channelId === "lark")
|
|
528
|
-
applyFeishuDebugAccessDefaults(ch);
|
|
529
|
-
let pluginEntry = pluginEntries[channelId];
|
|
530
|
-
if (pluginEntry == null) {
|
|
531
|
-
pluginEntry = {};
|
|
532
|
-
pluginEntries[channelId] = pluginEntry;
|
|
533
|
-
}
|
|
534
|
-
if (typeof pluginEntry === "object") {
|
|
535
|
-
pluginEntry.enabled = ch.enabled !== false;
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return [configToWrite, envUpdates];
|
|
539
|
-
}
|
|
540
|
-
// ── Channel plugin helpers ──
|
|
541
|
-
// Channel → plugin package mapping for auto-install.
|
|
542
|
-
// Stock plugins (bundled with newer OpenClaw) are detected via extensions/{id} dir;
|
|
543
|
-
// if missing (older OpenClaw), they get installed as fallback.
|
|
544
|
-
// @larksuite/openclaw-lark installs as "openclaw-lark" dir but registers channel "feishu"
|
|
545
|
-
const CHANNEL_EXT_DIR_ALIAS = {
|
|
546
|
-
feishu: "openclaw-lark",
|
|
547
|
-
lark: "openclaw-lark",
|
|
548
|
-
};
|
|
549
|
-
export const CHANNEL_PLUGIN_MAP = {
|
|
550
|
-
// Official vendor plugins (ByteDance Feishu/Lark)
|
|
551
|
-
feishu: "@larksuite/openclaw-lark",
|
|
552
|
-
lark: "@larksuite/openclaw-lark",
|
|
553
|
-
// Built-in (stock) — fallback install for older OpenClaw versions
|
|
554
|
-
telegram: "@openclaw/telegram",
|
|
555
|
-
discord: "@openclaw/discord",
|
|
556
|
-
slack: "@openclaw/slack",
|
|
557
|
-
whatsapp: "@openclaw/whatsapp",
|
|
558
|
-
signal: "@openclaw/signal",
|
|
559
|
-
line: "@openclaw/line",
|
|
560
|
-
msteams: "@openclaw/msteams",
|
|
561
|
-
// Official vendor plugins — need install (not bundled)
|
|
562
|
-
"openclaw-weixin": "@tencent-weixin/openclaw-weixin",
|
|
563
|
-
};
|
|
564
|
-
/**
|
|
565
|
-
* Known IM plugin entry IDs as they appear under `config.plugins.entries`.
|
|
566
|
-
* This is the union of channel IDs and the dir-alias names (e.g. `feishu` may
|
|
567
|
-
* register the plugin as `openclaw-lark`), which is what must be scrubbed when
|
|
568
|
-
* dissociating an instance from its inherited IM bindings.
|
|
569
|
-
*/
|
|
570
|
-
const IM_PLUGIN_ENTRY_IDS = new Set([
|
|
571
|
-
...Object.keys(CHANNEL_PLUGIN_MAP),
|
|
572
|
-
...Object.values(CHANNEL_EXT_DIR_ALIAS),
|
|
573
|
-
]);
|
|
574
|
-
/**
|
|
575
|
-
* Dissociate a cloned/imported config from its source instance's IM bindings.
|
|
576
|
-
*
|
|
577
|
-
* Mutates the given config in place:
|
|
578
|
-
* - Deletes the entire `channels` block (same channel cannot serve multiple
|
|
579
|
-
* instances, so every inherited enabled/credential/account entry must go).
|
|
580
|
-
* - Deletes matching IM entries from `plugins.entries` so the plugin loader
|
|
581
|
-
* does not try to boot a channel whose config no longer exists.
|
|
582
|
-
*
|
|
583
|
-
* Used by both domain clone (`createInstance`'s `cloneFrom` path) and the
|
|
584
|
-
* backup import paths (`importInstance`, `createFromBackup`) so that a new
|
|
585
|
-
* instance never inherits a half-configured IM binding.
|
|
586
|
-
*/
|
|
587
|
-
export function stripImBindings(config) {
|
|
588
|
-
if (config?.channels)
|
|
589
|
-
delete config.channels;
|
|
590
|
-
const entries = config?.plugins?.entries;
|
|
591
|
-
if (entries && typeof entries === "object") {
|
|
592
|
-
for (const key of Object.keys(entries)) {
|
|
593
|
-
if (IM_PLUGIN_ENTRY_IDS.has(key))
|
|
594
|
-
delete entries[key];
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
/** Check if a channel plugin is installed for an instance. */
|
|
599
|
-
export function isChannelPluginInstalled(instanceId, channelId) {
|
|
600
|
-
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
601
|
-
const stockExtDir = getStockExtensionsDir();
|
|
602
|
-
return existsSync(join(getChannelExtensionsDir(instanceId), extDirName))
|
|
603
|
-
|| existsSync(join(stockExtDir, extDirName))
|
|
604
|
-
// Also accept the built-in directory named after the raw channelId (e.g. "feishu/" in stock)
|
|
605
|
-
|| (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Install a single channel plugin.
|
|
609
|
-
* Docker mode: runs install inside the running container via docker exec.
|
|
610
|
-
* Host mode (fallback): spawns the host openclaw binary directly.
|
|
611
|
-
*/
|
|
612
|
-
export async function installChannelPlugin(instanceId, channelId) {
|
|
613
|
-
const pkg = CHANNEL_PLUGIN_MAP[channelId];
|
|
614
|
-
if (!pkg)
|
|
615
|
-
throw new Error(`Unknown channel: ${channelId}`);
|
|
616
|
-
if (isChannelPluginInstalled(instanceId, channelId))
|
|
617
|
-
return;
|
|
618
|
-
const openclawHome = getOpenclawHomeInternal(instanceId);
|
|
619
|
-
const extensionsDir = getChannelExtensionsDir(instanceId);
|
|
620
|
-
// Docker mode: always install inside container via docker exec
|
|
621
|
-
const { getNomadDriver } = await import("../config.js");
|
|
622
|
-
if (getNomadDriver() === "docker") {
|
|
623
|
-
await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
const openclawBin = resolveOpenclawBin();
|
|
627
|
-
// Host mode: spawn openclaw binary directly
|
|
628
|
-
const nodeBinDir = dirname(process.execPath);
|
|
629
|
-
const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
|
|
630
|
-
const proxyEnvKeys = [
|
|
631
|
-
"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
|
|
632
|
-
"no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
|
|
633
|
-
];
|
|
634
|
-
const proxyEnv = {};
|
|
635
|
-
for (const key of proxyEnvKeys) {
|
|
636
|
-
if (process.env[key])
|
|
637
|
-
proxyEnv[key] = process.env[key];
|
|
638
|
-
}
|
|
639
|
-
const childEnv = {
|
|
640
|
-
PATH: childPath,
|
|
641
|
-
HOME: process.env.HOME,
|
|
642
|
-
LANG: process.env.LANG,
|
|
643
|
-
OPENCLAW_HOME: openclawHome,
|
|
644
|
-
...proxyEnv,
|
|
645
|
-
};
|
|
646
|
-
const MAX_ATTEMPTS = 3;
|
|
647
|
-
const RETRY_DELAY_MS = 5_000;
|
|
648
|
-
const attemptInstall = () => new Promise((resolve, reject) => {
|
|
649
|
-
execFile(openclawBin, ["plugins", "install", pkg], {
|
|
650
|
-
cwd: openclawHome,
|
|
651
|
-
env: childEnv,
|
|
652
|
-
timeout: 300_000,
|
|
653
|
-
}, (err, stdout, stderr) => {
|
|
654
|
-
if (err && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
655
|
-
const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
|
|
656
|
-
console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
|
|
657
|
-
try {
|
|
658
|
-
if (existsSync(extensionsDir)) {
|
|
659
|
-
for (const entry of readdirSync(extensionsDir)) {
|
|
660
|
-
if (entry.startsWith(".openclaw-install-stage-")) {
|
|
661
|
-
rmSync(join(extensionsDir, entry), { recursive: true, force: true });
|
|
662
|
-
console.log(`[plugins] Cleaned up stage dir: ${entry}`);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
catch (_) { }
|
|
668
|
-
reject(new Error(msg));
|
|
669
|
-
}
|
|
670
|
-
else {
|
|
671
|
-
if (err)
|
|
672
|
-
console.log(`[plugins] ${pkg} installed (ignored non-zero exit: warning only)`);
|
|
673
|
-
else
|
|
674
|
-
console.log(`[plugins] ${pkg} installed`);
|
|
675
|
-
resolve();
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
});
|
|
679
|
-
console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
|
|
680
|
-
let lastErr;
|
|
681
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
682
|
-
try {
|
|
683
|
-
await attemptInstall();
|
|
684
|
-
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
685
|
-
const installedExtDir = join(extensionsDir, extDirName);
|
|
686
|
-
if (existsSync(installedExtDir)) {
|
|
687
|
-
ensureDirContainer(installedExtDir);
|
|
688
|
-
try {
|
|
689
|
-
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
690
|
-
if (entry.isDirectory()) {
|
|
691
|
-
ensureDirContainer(join(installedExtDir, entry.name));
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
catch { /* best effort */ }
|
|
696
|
-
}
|
|
697
|
-
ensureDirContainer(extensionsDir);
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
catch (err) {
|
|
701
|
-
lastErr = err;
|
|
702
|
-
const isFetchError = /fetch failed/i.test(err.message ?? "");
|
|
703
|
-
if (isFetchError && attempt < MAX_ATTEMPTS) {
|
|
704
|
-
console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed with fetch error, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
705
|
-
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
console.error(`[plugins] Failed to install ${pkg}:`, err.message);
|
|
709
|
-
break;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
throw lastErr;
|
|
713
|
-
}
|
|
714
|
-
/**
|
|
715
|
-
* Install a channel plugin inside the running Docker container via nomad-manager.exec().
|
|
716
|
-
* Requires the instance to be running — the extensions dir is bind-mounted so
|
|
717
|
-
* the install persists on the host filesystem.
|
|
718
|
-
*/
|
|
719
|
-
async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
|
|
720
|
-
const { exec } = await import("./nomad-manager.js");
|
|
721
|
-
const MAX_ATTEMPTS = 3;
|
|
722
|
-
const RETRY_DELAY_MS = 5_000;
|
|
723
|
-
console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
|
|
724
|
-
let lastErr;
|
|
725
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
726
|
-
try {
|
|
727
|
-
const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
|
|
728
|
-
// Check if plugin was actually installed (openclaw may exit non-zero with warnings)
|
|
729
|
-
if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
730
|
-
const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
|
|
731
|
-
console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
|
|
732
|
-
throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
|
|
733
|
-
}
|
|
734
|
-
if (result.exitCode !== 0) {
|
|
735
|
-
console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit: warning only)`);
|
|
736
|
-
}
|
|
737
|
-
else {
|
|
738
|
-
console.log(`[plugins] ${pkg} installed via docker`);
|
|
739
|
-
}
|
|
740
|
-
// Fix ownership on host side
|
|
741
|
-
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
742
|
-
const installedExtDir = join(extensionsDir, extDirName);
|
|
743
|
-
if (existsSync(installedExtDir)) {
|
|
744
|
-
ensureDirContainer(installedExtDir);
|
|
745
|
-
try {
|
|
746
|
-
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
747
|
-
if (entry.isDirectory()) {
|
|
748
|
-
ensureDirContainer(join(installedExtDir, entry.name));
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
catch { /* best effort */ }
|
|
753
|
-
}
|
|
754
|
-
ensureDirContainer(extensionsDir);
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
catch (err) {
|
|
758
|
-
lastErr = err;
|
|
759
|
-
// "Instance is not running" from nomad-manager.exec() — give a clear user-facing message
|
|
760
|
-
if (/not running/i.test(err.message ?? "")) {
|
|
761
|
-
throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
|
|
762
|
-
}
|
|
763
|
-
const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
|
|
764
|
-
if (isTransient && attempt < MAX_ATTEMPTS) {
|
|
765
|
-
console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
766
|
-
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
|
|
770
|
-
break;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
throw lastErr;
|
|
774
|
-
}
|
|
775
|
-
function getChannelExtensionsDir(instanceId) {
|
|
776
|
-
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
|
|
777
|
-
}
|
|
778
|
-
function getStockExtensionsDir() {
|
|
779
|
-
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
780
|
-
}
|
|
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`.
|
|
781
424
|
// ── Public API ──
|
|
782
425
|
/**
|
|
783
426
|
* Probe whether a file is readable by the current process. Used to
|
|
@@ -800,45 +443,53 @@ function probeReadable(path) {
|
|
|
800
443
|
}
|
|
801
444
|
}
|
|
802
445
|
export function listInstances() {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
try {
|
|
811
|
-
if (!statSync(dirPath).isDirectory())
|
|
812
|
-
continue;
|
|
813
|
-
// Use safeReadJson so primary instance.json that was corrupted or
|
|
814
|
-
// deleted mid-rename falls back to the .bak chain maintained by
|
|
815
|
-
// safeWriteJson. Without this, an interrupted safeWriteJson call
|
|
816
|
-
// would silently drop the instance from every list/get hot path
|
|
817
|
-
// even though the backup chain still holds valid content on disk.
|
|
818
|
-
const meta = safeReadJson(metaPath, `instance:${name}`);
|
|
819
|
-
if (meta) {
|
|
820
|
-
instances.push(meta);
|
|
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))
|
|
821
453
|
continue;
|
|
454
|
+
const metaPath = join(rootDir, name, "instance.json");
|
|
455
|
+
const dirPath = join(rootDir, name);
|
|
456
|
+
try {
|
|
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}`);
|
|
482
|
+
}
|
|
822
483
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
const readErr = probeReadable(metaPath);
|
|
829
|
-
if (readErr && readErr.code === "EACCES") {
|
|
830
|
-
console.error(`[instance-manager] cannot read instance '${name}': ${readErr.message}. ` +
|
|
831
|
-
`Check file ownership with: ls -la ${metaPath}`);
|
|
484
|
+
catch (e) {
|
|
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}`);
|
|
832
489
|
}
|
|
833
490
|
}
|
|
834
|
-
catch (e) {
|
|
835
|
-
// Fallback for failures before the safeReadJson call (e.g. statSync
|
|
836
|
-
// on a directory we can't enter). Still log instead of silently
|
|
837
|
-
// dropping the entry.
|
|
838
|
-
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
839
|
-
}
|
|
840
491
|
}
|
|
841
|
-
return
|
|
492
|
+
return [...deduped.values()];
|
|
842
493
|
}
|
|
843
494
|
export function getInstance(instanceId) {
|
|
844
495
|
const metaPath = instanceMetaPath(instanceId);
|
|
@@ -847,7 +498,7 @@ export function getInstance(instanceId) {
|
|
|
847
498
|
// (no primary, no backups) keeps the existing 404 behavior intact.
|
|
848
499
|
const meta = safeReadJson(metaPath, `instance:${instanceId}`);
|
|
849
500
|
if (meta)
|
|
850
|
-
return meta;
|
|
501
|
+
return backfillInstanceMeta(meta);
|
|
851
502
|
// safeReadJson swallows every read error internally, which is exactly
|
|
852
503
|
// wrong for the EACCES case — a root-owned primary would silently
|
|
853
504
|
// return null and callers would report "Instance not found" instead
|
|
@@ -861,270 +512,9 @@ export function getInstance(instanceId) {
|
|
|
861
512
|
}
|
|
862
513
|
return null;
|
|
863
514
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
throw new Error(`Instance '${instanceId}' already exists`);
|
|
868
|
-
const home = openclawHome ? normalizePath(openclawHome) : defaultOpenclawHome(instanceId);
|
|
869
|
-
// Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent path traversal.
|
|
870
|
-
// Use realpathSync after mkdir to resolve symlinks, preventing symlink-based bypasses.
|
|
871
|
-
if (openclawHome) {
|
|
872
|
-
const resolved = resolve(home);
|
|
873
|
-
if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
|
|
874
|
-
throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
|
|
875
|
-
}
|
|
876
|
-
// Resolve symlinks for the parent dir to catch symlink attacks
|
|
877
|
-
const parentDir = dirname(resolved);
|
|
878
|
-
if (existsSync(parentDir)) {
|
|
879
|
-
const realParent = realpathSync(parentDir);
|
|
880
|
-
if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
|
|
881
|
-
throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === normalizePath(home));
|
|
885
|
-
if (shared.length) {
|
|
886
|
-
throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared.map((i) => i.id).join(", ")}`);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
// Check for orphaned openclaw_home directory (e.g. instance.json deleted but data remains)
|
|
890
|
-
if (existsSync(home)) {
|
|
891
|
-
try {
|
|
892
|
-
const entries = readdirSync(home);
|
|
893
|
-
if (entries.length > 0) {
|
|
894
|
-
throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. Remove it manually or choose a different path.`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
catch (e) {
|
|
898
|
-
if (e.message.includes("not empty"))
|
|
899
|
-
throw e;
|
|
900
|
-
// readdirSync failed — directory might not be readable, proceed cautiously
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
ensureDirContainer(d);
|
|
904
|
-
// Inherit group from INSTANCES_DIR so both root and the real user can access
|
|
905
|
-
try {
|
|
906
|
-
const parentGid = statSync(INSTANCES_DIR).gid;
|
|
907
|
-
chownSync(d, -1, parentGid);
|
|
908
|
-
}
|
|
909
|
-
catch { /* non-root without CAP_CHOWN — already correct owner */ }
|
|
910
|
-
ensureDirContainer(home);
|
|
911
|
-
ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
|
|
912
|
-
const portAlloc = await allocateGatewayPort(instanceId);
|
|
913
|
-
const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
|
|
914
|
-
let runtime = baseRuntime;
|
|
915
|
-
if (appSpec) {
|
|
916
|
-
const serviceTask = appSpec.tasks.find((t) => t.role === "service");
|
|
917
|
-
if (serviceTask) {
|
|
918
|
-
const compiled = compileTaskRuntime(serviceTask, instanceId);
|
|
919
|
-
runtime = { ...baseRuntime, ...compiled };
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
const allocatedPort = extractGatewayPort(runtime);
|
|
923
|
-
// Port already reserved inside allocateGatewayPort; just track for cleanup
|
|
924
|
-
try {
|
|
925
|
-
const meta = {
|
|
926
|
-
id: instanceId,
|
|
927
|
-
name,
|
|
928
|
-
description,
|
|
929
|
-
openclaw_home: home,
|
|
930
|
-
runtime,
|
|
931
|
-
created_at: new Date().toISOString(),
|
|
932
|
-
...(appSpec ? { app_id: appSpec.id } : {}),
|
|
933
|
-
};
|
|
934
|
-
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
935
|
-
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
936
|
-
for (const ef of envFiles) {
|
|
937
|
-
if (!existsSync(ef))
|
|
938
|
-
writeConfigFile(ef, "");
|
|
939
|
-
}
|
|
940
|
-
// After writing env files, ensure the runtime user can read them
|
|
941
|
-
try {
|
|
942
|
-
const runtimeUser = runtime.user;
|
|
943
|
-
if (runtimeUser && runtimeUser !== userInfo().username) {
|
|
944
|
-
for (const ef of envFiles) {
|
|
945
|
-
execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
catch { /* ignore - same user or no permission to chown */ }
|
|
950
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
951
|
-
ensureDirContainer(dirname(configPath));
|
|
952
|
-
if (cloneFrom && !existsSync(configPath)) {
|
|
953
|
-
const srcConfig = resolveExistingConfigPath(cloneFrom);
|
|
954
|
-
if (existsSync(srcConfig)) {
|
|
955
|
-
// Domain-level clone: copy config but strip proxy identity (token, jsproxy provider)
|
|
956
|
-
// so the new instance gets its own proxy token via saveInstanceConfig later
|
|
957
|
-
try {
|
|
958
|
-
const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
|
|
959
|
-
// Remove proxy provider (will be regenerated with new proxy token)
|
|
960
|
-
// Detect by baseUrl since provider ID now uses upstream name (e.g. "js-minimax")
|
|
961
|
-
const providers = cloned?.models?.providers;
|
|
962
|
-
if (providers) {
|
|
963
|
-
for (const [pid, prov] of Object.entries(providers)) {
|
|
964
|
-
if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
|
|
965
|
-
delete providers[pid];
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
// Remove proxy model reference from agent defaults (regenerated by bootstrap)
|
|
970
|
-
const defaultModel = cloned?.agents?.defaults?.model;
|
|
971
|
-
if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
|
|
972
|
-
delete cloned.agents.defaults.model;
|
|
973
|
-
}
|
|
974
|
-
// Strip IM channel configs + matching plugin entries — same channel
|
|
975
|
-
// cannot serve multiple instances and we don't want the plugin
|
|
976
|
-
// loader to boot a half-configured binding.
|
|
977
|
-
stripImBindings(cloned);
|
|
978
|
-
// Copy extensions directory so plugin references in config remain valid
|
|
979
|
-
// Copy workspace directory to preserve agent personality (.md files)
|
|
980
|
-
const subdirs = ["extensions", "workspace"];
|
|
981
|
-
if (cloneOptions?.include_memory !== false) {
|
|
982
|
-
// Memory may exist at .openclaw/memory/ if created by OpenClaw runtime
|
|
983
|
-
const memDir = join(dirname(srcConfig), "memory");
|
|
984
|
-
if (existsSync(memDir))
|
|
985
|
-
subdirs.push("memory");
|
|
986
|
-
}
|
|
987
|
-
if (cloneOptions?.include_sessions) {
|
|
988
|
-
// Sessions at .openclaw/agents/main/sessions/
|
|
989
|
-
const sessDir = join(dirname(srcConfig), "agents");
|
|
990
|
-
if (existsSync(sessDir))
|
|
991
|
-
subdirs.push("agents");
|
|
992
|
-
}
|
|
993
|
-
for (const subdir of subdirs) {
|
|
994
|
-
const srcDir = join(dirname(srcConfig), subdir);
|
|
995
|
-
const dstDir = join(dirname(configPath), subdir);
|
|
996
|
-
if (existsSync(srcDir) && !existsSync(dstDir)) {
|
|
997
|
-
try {
|
|
998
|
-
cpSync(srcDir, dstDir, { recursive: true });
|
|
999
|
-
}
|
|
1000
|
-
catch { /* best effort */ }
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
|
|
1004
|
-
// Copy x-jishushell upstream metadata from source instance.json
|
|
1005
|
-
// (saveConfig stores x-jishushell in instance.json, not openclaw.json)
|
|
1006
|
-
const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
|
|
1007
|
-
if (existsSync(srcMetaPath)) {
|
|
1008
|
-
try {
|
|
1009
|
-
const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
|
|
1010
|
-
const srcXj = srcMeta?.["x-jishushell"];
|
|
1011
|
-
if (srcXj?.proxy?.upstream) {
|
|
1012
|
-
const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
|
|
1013
|
-
// Clear instance-specific fields
|
|
1014
|
-
delete dstXj.proxy.upstream.apiKey;
|
|
1015
|
-
const metaPath = instanceMetaPath(instanceId);
|
|
1016
|
-
if (existsSync(metaPath)) {
|
|
1017
|
-
const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
1018
|
-
dstMeta["x-jishushell"] = dstXj;
|
|
1019
|
-
writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
catch { /* ignore metadata copy errors */ }
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
catch {
|
|
1027
|
-
// Fallback: raw copy if parse fails
|
|
1028
|
-
copyFileSync(srcConfig, configPath);
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
}
|
|
1032
|
-
if (!existsSync(configPath)) {
|
|
1033
|
-
writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
|
|
1034
|
-
// Inject default provider API key from setup into both env files
|
|
1035
|
-
const dp = getPanelConfig().default_provider;
|
|
1036
|
-
if (dp?.apiKey && dp?.providerId && envFiles.length) {
|
|
1037
|
-
const envKey = inferProviderApiKeyEnvName(dp.providerId);
|
|
1038
|
-
updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
|
|
1039
|
-
// Also write to provider.env as UPSTREAM_API_KEY (LLM proxy reads this first)
|
|
1040
|
-
const providerEnv = join(dirname(envFiles[0]), "provider.env");
|
|
1041
|
-
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
// Merge App-level config_defaults into openclaw.json (shallow merge, app values win)
|
|
1045
|
-
if (appSpec?.openclaw?.config_defaults && existsSync(configPath)) {
|
|
1046
|
-
try {
|
|
1047
|
-
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1048
|
-
const defaults = appSpec.openclaw.config_defaults;
|
|
1049
|
-
// Deep merge top-level keys
|
|
1050
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
1051
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value) && typeof existing[key] === "object" && existing[key] !== null) {
|
|
1052
|
-
existing[key] = { ...existing[key], ...value };
|
|
1053
|
-
}
|
|
1054
|
-
else {
|
|
1055
|
-
existing[key] = value;
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
writeConfigFile(configPath, JSON.stringify(existing, null, 2));
|
|
1059
|
-
}
|
|
1060
|
-
catch { /* ignore merge errors, keep existing config */ }
|
|
1061
|
-
}
|
|
1062
|
-
// Record App-level skills for later installation into the instance
|
|
1063
|
-
if (appSpec?.openclaw?.skills && Array.isArray(appSpec.openclaw.skills)) {
|
|
1064
|
-
try {
|
|
1065
|
-
const skillsDir = join(dirname(configPath), "skills");
|
|
1066
|
-
ensureDirContainer(skillsDir);
|
|
1067
|
-
const skillMeta = join(skillsDir, ".app-skills.json");
|
|
1068
|
-
safeWriteJson(skillMeta, { app_id: appSpec.id, skills: appSpec.openclaw.skills });
|
|
1069
|
-
}
|
|
1070
|
-
catch { /* ignore */ }
|
|
1071
|
-
}
|
|
1072
|
-
// Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
|
|
1073
|
-
if (cloneFrom && envFiles.length) {
|
|
1074
|
-
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
1075
|
-
const srcEnvFile = srcEnvFiles[0];
|
|
1076
|
-
const dstEnvFile = envFiles[0];
|
|
1077
|
-
// Copy provider.env (upstream API key)
|
|
1078
|
-
if (srcEnvFile) {
|
|
1079
|
-
const srcProvider = join(dirname(srcEnvFile), "provider.env");
|
|
1080
|
-
const dstProvider = join(dirname(dstEnvFile), "provider.env");
|
|
1081
|
-
if (existsSync(srcProvider) && !existsSync(dstProvider)) {
|
|
1082
|
-
copyFileSync(srcProvider, dstProvider);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
// Note: model.env is NOT copied (new instance needs its own proxy token)
|
|
1086
|
-
}
|
|
1087
|
-
// Bootstrap proxy: generate proxy token and write model.env so instance
|
|
1088
|
-
// is ready to run immediately without requiring a manual "save config" first
|
|
1089
|
-
try {
|
|
1090
|
-
const { bootstrapInstanceProxy } = await import("../services/llm-proxy/index.js");
|
|
1091
|
-
await bootstrapInstanceProxy(instanceId);
|
|
1092
|
-
}
|
|
1093
|
-
catch (e) {
|
|
1094
|
-
console.warn(`[instance] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
|
|
1095
|
-
}
|
|
1096
|
-
// If running as root, hand ownership of all created files to the service user
|
|
1097
|
-
// so the openclaw process (running as that user) can read/write its own files.
|
|
1098
|
-
const svcUser = resolveServiceUser();
|
|
1099
|
-
if (svcUser) {
|
|
1100
|
-
try {
|
|
1101
|
-
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], { timeout: 10_000 });
|
|
1102
|
-
if (!home.startsWith(d + "/") && existsSync(home)) {
|
|
1103
|
-
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], { timeout: 10_000 });
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
catch (e) {
|
|
1107
|
-
console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
// Attach transient port allocation info for the API response only — never
|
|
1111
|
-
// persisted in instance.json. If the caller (e.g. the create route) sees
|
|
1112
|
-
// skipped ports it can tell the user the default was busy.
|
|
1113
|
-
if (portAlloc.skipped.length > 0) {
|
|
1114
|
-
meta.port_allocation = {
|
|
1115
|
-
assigned: portAlloc.port,
|
|
1116
|
-
requested: DEFAULT_GATEWAY_PORT,
|
|
1117
|
-
reason: "default_busy",
|
|
1118
|
-
skipped: portAlloc.skipped,
|
|
1119
|
-
};
|
|
1120
|
-
}
|
|
1121
|
-
return meta;
|
|
1122
|
-
}
|
|
1123
|
-
finally {
|
|
1124
|
-
if (allocatedPort)
|
|
1125
|
-
_pendingPorts.delete(allocatedPort);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
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)`.
|
|
1128
518
|
export function updateInstance(instanceId, name, description) {
|
|
1129
519
|
const meta = getInstance(instanceId);
|
|
1130
520
|
if (!meta)
|
|
@@ -1137,7 +527,10 @@ export function updateInstance(instanceId, name, description) {
|
|
|
1137
527
|
chownToServiceUser(instanceMetaPath(instanceId));
|
|
1138
528
|
return meta;
|
|
1139
529
|
}
|
|
1140
|
-
|
|
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.
|
|
1141
534
|
export function updateInstanceMeta(instanceId, patch) {
|
|
1142
535
|
const metaPath = instanceMetaPath(instanceId);
|
|
1143
536
|
const meta = safeReadJson(metaPath, "instance-meta") || {};
|
|
@@ -1159,9 +552,35 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1159
552
|
cancelJob(job.id);
|
|
1160
553
|
}
|
|
1161
554
|
}).catch(() => { });
|
|
1162
|
-
// Cache metadata BEFORE deletion so
|
|
555
|
+
// Cache metadata BEFORE deletion so adapters can inspect it after rm.
|
|
1163
556
|
const meta = getInstance(instanceId);
|
|
1164
|
-
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
|
+
}
|
|
1165
584
|
// Clean up Nomad Variables (async, best-effort)
|
|
1166
585
|
import("./nomad-manager.js").then((nm) => {
|
|
1167
586
|
nm.purgeInstanceVariables(instanceId).catch((e) => {
|
|
@@ -1188,10 +607,7 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1188
607
|
warnings.push(`Failed to delete instance directory: ${e.message}`);
|
|
1189
608
|
}
|
|
1190
609
|
}
|
|
1191
|
-
//
|
|
1192
|
-
if (home && !home.startsWith(d) && existsSync(home)) {
|
|
1193
|
-
warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
|
|
1194
|
-
}
|
|
610
|
+
// (Custom openclaw_home orphan warning emitted by OpenClawAdapter.hooks.onDelete.)
|
|
1195
611
|
// Handle backups (stored in separate directory, not affected by the
|
|
1196
612
|
// instance rm above). Backups can be hundreds of MB each and accumulate
|
|
1197
613
|
// across retention windows, so use the same async rm path to keep the
|
|
@@ -1210,285 +626,210 @@ export async function deleteInstance(instanceId, purgeBackups = false) {
|
|
|
1210
626
|
}
|
|
1211
627
|
return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
|
|
1212
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).
|
|
1213
634
|
export function getConfig(instanceId) {
|
|
1214
|
-
const config = loadEffectiveConfig(instanceId);
|
|
1215
|
-
if (!config)
|
|
1216
|
-
return null;
|
|
1217
|
-
// Merge x-jishushell metadata from instance.json
|
|
1218
635
|
const meta = getInstance(instanceId);
|
|
1219
|
-
|
|
1220
|
-
|
|
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;
|
|
1221
645
|
}
|
|
1222
|
-
return injectProviderApiKeys(instanceId, config);
|
|
1223
646
|
}
|
|
1224
647
|
export function getStoredConfig(instanceId) {
|
|
1225
|
-
const config = loadEffectiveConfig(instanceId);
|
|
1226
|
-
if (!config)
|
|
1227
|
-
return null;
|
|
1228
648
|
const meta = getInstance(instanceId);
|
|
1229
|
-
|
|
1230
|
-
|
|
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;
|
|
1231
658
|
}
|
|
1232
|
-
return config;
|
|
1233
659
|
}
|
|
1234
|
-
export function saveConfig(instanceId, config) {
|
|
1235
|
-
const
|
|
1236
|
-
|
|
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}`);
|
|
1237
676
|
return false;
|
|
1238
|
-
if (!existsSync(configPath)) {
|
|
1239
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1240
|
-
ensureDirContainer(dirname(configPath));
|
|
1241
|
-
if (existsSync(legacyPath))
|
|
1242
|
-
copyFileSync(legacyPath, configPath);
|
|
1243
677
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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];
|
|
1252
697
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
// If openclaw-lark is configured as enabled, resolve which feishu plugin should actually be used:
|
|
1256
|
-
// - If built-in feishu/ exists in stock AND openclaw-lark/ is not installed anywhere → switch to
|
|
1257
|
-
// built-in feishu (removes the stale openclaw-lark reference that breaks container startup).
|
|
1258
|
-
// - If both exist → keep openclaw-lark but disable built-in feishu to avoid conflict.
|
|
1259
|
-
if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
|
|
1260
|
-
const stockExtDir = getStockExtensionsDir();
|
|
1261
|
-
const stockFeishu = join(stockExtDir, "feishu");
|
|
1262
|
-
const stockOcl = join(stockExtDir, "openclaw-lark");
|
|
1263
|
-
const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
|
|
1264
|
-
if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
|
|
1265
|
-
// Built-in available, community package absent → switch to built-in
|
|
1266
|
-
configToWrite.plugins.entries.feishu = { enabled: true };
|
|
1267
|
-
delete configToWrite.plugins.entries["openclaw-lark"];
|
|
698
|
+
catch {
|
|
699
|
+
return undefined;
|
|
1268
700
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
configToWrite.plugins.entries.feishu = { enabled: false };
|
|
701
|
+
},
|
|
702
|
+
ownKeys() {
|
|
703
|
+
try {
|
|
704
|
+
return Reflect.ownKeys(getAdapter("openclaw").channelPluginMap ?? {});
|
|
1274
705
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
706
|
+
catch {
|
|
707
|
+
return [];
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
has(_target, key) {
|
|
711
|
+
if (typeof key !== "string")
|
|
712
|
+
return false;
|
|
1281
713
|
try {
|
|
1282
|
-
|
|
1283
|
-
if (existing.plugins?.installs) {
|
|
1284
|
-
configToWrite.plugins ??= {};
|
|
1285
|
-
configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
|
|
1286
|
-
}
|
|
1287
|
-
// Merge plugin entries: for keys present in configToWrite, deep-merge
|
|
1288
|
-
// backend-written sub-fields from disk. Keys absent from configToWrite
|
|
1289
|
-
// (intentionally deleted) are NOT resurrected from existing.
|
|
1290
|
-
if (existing.plugins?.entries && configToWrite.plugins?.entries) {
|
|
1291
|
-
for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
|
|
1292
|
-
const old = existing.plugins.entries[key];
|
|
1293
|
-
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
1294
|
-
configToWrite.plugins.entries[key] = { ...old, ...val };
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
// Merge channels: for keys present in configToWrite, deep-merge
|
|
1299
|
-
// backend-written sub-fields (e.g. openclaw-weixin accounts) from disk.
|
|
1300
|
-
// Keys absent from configToWrite (user-deleted channels) stay deleted.
|
|
1301
|
-
if (existing.channels && configToWrite.channels) {
|
|
1302
|
-
for (const [key, val] of Object.entries(configToWrite.channels)) {
|
|
1303
|
-
const old = existing.channels[key];
|
|
1304
|
-
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
1305
|
-
configToWrite.channels[key] = { ...old, ...val };
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
714
|
+
return key in (getAdapter("openclaw").channelPluginMap ?? {});
|
|
1309
715
|
}
|
|
1310
|
-
catch {
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
writeConfigFile(configPath + ".tmp", configJson);
|
|
1319
|
-
// Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
|
|
1320
|
-
JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
|
|
1321
|
-
renameSync(configPath + ".tmp", configPath);
|
|
1322
|
-
chownToServiceUser(configPath);
|
|
1323
|
-
// also write to legacy path
|
|
1324
|
-
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1325
|
-
if (existsSync(legacyPath)) {
|
|
1326
|
-
copyFileSync(legacyPath, legacyPath + ".bak");
|
|
1327
|
-
}
|
|
1328
|
-
writeConfigFile(legacyPath + ".tmp", configJson);
|
|
1329
|
-
JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
|
|
1330
|
-
renameSync(legacyPath + ".tmp", legacyPath);
|
|
1331
|
-
chownToServiceUser(legacyPath);
|
|
1332
|
-
if (Object.keys(envUpdates).length) {
|
|
1333
|
-
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
1334
|
-
if (envFiles.length)
|
|
1335
|
-
updateEnvFile(envFiles[0], envUpdates);
|
|
1336
|
-
}
|
|
1337
|
-
// Plugins are installed inside the container — no host-side auto-install on config save.
|
|
1338
|
-
// Notify listeners (e.g. llm-proxy cache invalidation)
|
|
1339
|
-
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;
|
|
1340
724
|
try {
|
|
1341
|
-
|
|
725
|
+
value = getAdapter("openclaw").channelPluginMap?.[key];
|
|
1342
726
|
}
|
|
1343
|
-
catch {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
727
|
+
catch {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
return value !== undefined
|
|
731
|
+
? { configurable: true, enumerable: true, writable: false, value }
|
|
732
|
+
: undefined;
|
|
733
|
+
},
|
|
734
|
+
});
|
|
1350
735
|
/**
|
|
1351
|
-
*
|
|
1352
|
-
* Save Feishu/Lark credentials from OAuth Device Code flow.
|
|
736
|
+
* @deprecated Use `getAdapter(agentType).isChannelPluginInstalled(id, channelId)` instead.
|
|
1353
737
|
*/
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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;
|
|
1360
746
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
747
|
+
catch {
|
|
748
|
+
return false;
|
|
1363
749
|
}
|
|
1364
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
1365
|
-
let config = safeReadJson(configPath, "feishu-creds") || {};
|
|
1366
|
-
// Enable @larksuite/openclaw-lark plugin (installed inside Docker container),
|
|
1367
|
-
// disable built-in @openclaw/feishu to avoid conflict.
|
|
1368
|
-
config.plugins ??= {};
|
|
1369
|
-
config.plugins.entries ??= {};
|
|
1370
|
-
config.plugins.entries.feishu = { enabled: false };
|
|
1371
|
-
config.plugins.entries["openclaw-lark"] = { enabled: true };
|
|
1372
|
-
// Set channel config — official plugin reads from channels.feishu
|
|
1373
|
-
config.channels ??= {};
|
|
1374
|
-
config.channels.feishu = {
|
|
1375
|
-
...config.channels.feishu,
|
|
1376
|
-
enabled: true,
|
|
1377
|
-
appId: creds.appId,
|
|
1378
|
-
appSecret: creds.appSecret,
|
|
1379
|
-
domain: creds.domain,
|
|
1380
|
-
dmPolicy: "open",
|
|
1381
|
-
allowFrom: ["*"],
|
|
1382
|
-
};
|
|
1383
|
-
safeWriteJson(configPath, config);
|
|
1384
|
-
chownToServiceUser(configPath);
|
|
1385
|
-
console.log(`[instance-manager] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
|
|
1386
750
|
}
|
|
1387
751
|
/**
|
|
1388
|
-
*
|
|
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.
|
|
1389
755
|
*/
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
// Update accounts.json index (required by the plugin to discover accounts)
|
|
1412
|
-
const indexPath = join(stateDir, "accounts.json");
|
|
1413
|
-
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);
|
|
1414
777
|
try {
|
|
1415
|
-
const
|
|
1416
|
-
|
|
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");
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return meta?.openclaw_home || join(INSTANCES_DIR, instanceId, "openclaw-home");
|
|
1417
785
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
const configPath = openclawConfigPathInternal(instanceId);
|
|
1426
|
-
let config = safeReadJson(configPath, "weixin-creds") || {};
|
|
1427
|
-
// Enable plugin
|
|
1428
|
-
config.plugins ??= {};
|
|
1429
|
-
config.plugins.entries ??= {};
|
|
1430
|
-
config.plugins.entries["openclaw-weixin"] ??= {};
|
|
1431
|
-
config.plugins.entries["openclaw-weixin"].enabled = true;
|
|
1432
|
-
// Enable channel with account
|
|
1433
|
-
config.channels ??= {};
|
|
1434
|
-
config.channels["openclaw-weixin"] ??= {};
|
|
1435
|
-
config.channels["openclaw-weixin"].enabled = true;
|
|
1436
|
-
// Register account with both original and normalized IDs (OpenClaw normalizes @ and . to -)
|
|
1437
|
-
const normalizedId = creds.accountId.replace(/[@.]/g, "-");
|
|
1438
|
-
const accounts = config.channels["openclaw-weixin"].accounts ??= {};
|
|
1439
|
-
accounts[creds.accountId] = { enabled: true };
|
|
1440
|
-
if (normalizedId !== creds.accountId)
|
|
1441
|
-
accounts[normalizedId] = { enabled: true };
|
|
1442
|
-
accounts["default"] = { enabled: true };
|
|
1443
|
-
// Set defaultAccount (required by OpenClaw)
|
|
1444
|
-
if (!config.channels["openclaw-weixin"].defaultAccount) {
|
|
1445
|
-
config.channels["openclaw-weixin"].defaultAccount = "default";
|
|
786
|
+
}
|
|
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`);
|
|
1446
793
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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);
|
|
1450
804
|
}
|
|
1451
|
-
/**
|
|
1452
|
-
* Get connected WeChat accounts for an instance.
|
|
1453
|
-
*/
|
|
1454
805
|
export function getWeixinAccounts(instanceId) {
|
|
1455
|
-
const
|
|
1456
|
-
const
|
|
1457
|
-
const accountsDir = join(stateDir, "accounts");
|
|
1458
|
-
if (!existsSync(accountsDir))
|
|
1459
|
-
return [];
|
|
1460
|
-
// Only return accounts listed in the index (skip default.json and other auxiliary files)
|
|
1461
|
-
let indexedIds = [];
|
|
806
|
+
const meta = getInstance(instanceId);
|
|
807
|
+
const agentType = resolveAgentType(meta);
|
|
1462
808
|
try {
|
|
1463
|
-
|
|
809
|
+
const a = getAdapter(agentType);
|
|
810
|
+
return typeof a.getWeixinAccounts === "function"
|
|
811
|
+
? a.getWeixinAccounts(instanceId)
|
|
812
|
+
: [];
|
|
1464
813
|
}
|
|
1465
|
-
catch {
|
|
1466
|
-
|
|
1467
|
-
for (const f of readdirSync(accountsDir)) {
|
|
1468
|
-
if (!f.endsWith(".json"))
|
|
1469
|
-
continue;
|
|
1470
|
-
const id = f.replace(/\.json$/, "");
|
|
1471
|
-
if (indexedIds.length > 0 && !indexedIds.includes(id))
|
|
1472
|
-
continue; // skip auxiliary files
|
|
1473
|
-
if (id === "default")
|
|
1474
|
-
continue; // always skip default alias
|
|
1475
|
-
try {
|
|
1476
|
-
const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
|
|
1477
|
-
results.push({
|
|
1478
|
-
accountId: id,
|
|
1479
|
-
userId: data.userId,
|
|
1480
|
-
savedAt: data.savedAt,
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
catch { /* skip */ }
|
|
814
|
+
catch {
|
|
815
|
+
return [];
|
|
1484
816
|
}
|
|
1485
|
-
return results;
|
|
1486
817
|
}
|
|
1487
818
|
export function getOpenclawConfigPath(instanceId) {
|
|
1488
|
-
|
|
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");
|
|
1489
825
|
}
|
|
1490
826
|
export function getLegacyOpenclawConfigPath(instanceId) {
|
|
1491
|
-
|
|
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");
|
|
1492
833
|
}
|
|
1493
834
|
export function getInstanceRuntime(instanceId) {
|
|
1494
835
|
const meta = getInstance(instanceId);
|
|
@@ -1498,11 +839,28 @@ export function getInstanceRuntime(instanceId) {
|
|
|
1498
839
|
}
|
|
1499
840
|
export function getRuntimeEnvFiles(instanceId) {
|
|
1500
841
|
const runtime = getInstanceRuntime(instanceId);
|
|
1501
|
-
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);
|
|
1502
848
|
return envFiles.length ? envFiles : [defaultModelEnvFile(instanceId)];
|
|
1503
849
|
}
|
|
1504
850
|
export function getGatewayPort(instanceId) {
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
864
|
}
|
|
1507
865
|
/**
|
|
1508
866
|
* Detect the host address where the gateway port is actually listening.
|
|
@@ -1517,6 +875,47 @@ export function getGatewayPort(instanceId) {
|
|
|
1517
875
|
*/
|
|
1518
876
|
const _gwHostCache = new Map();
|
|
1519
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
|
+
}
|
|
1520
919
|
export async function getGatewayHost(instanceId) {
|
|
1521
920
|
const cached = _gwHostCache.get(instanceId);
|
|
1522
921
|
if (cached && Date.now() - cached.ts < GW_HOST_CACHE_TTL)
|
|
@@ -1527,7 +926,22 @@ export async function getGatewayHost(instanceId) {
|
|
|
1527
926
|
const { getNomadDriver } = await import("../config.js");
|
|
1528
927
|
if (getNomadDriver() === "docker") {
|
|
1529
928
|
const { getNomadAddr, getNomadToken } = await import("../config.js");
|
|
1530
|
-
|
|
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}`;
|
|
1531
945
|
const headers = { "Content-Type": "application/json" };
|
|
1532
946
|
const token = getNomadToken();
|
|
1533
947
|
if (token)
|
|
@@ -1559,13 +973,22 @@ export async function getGatewayHost(instanceId) {
|
|
|
1559
973
|
// (the default on most modern Linux distros). Reading it from
|
|
1560
974
|
// here is the authoritative source and matches what nomad
|
|
1561
975
|
// configures the docker-proxy bind to use.
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
+
}
|
|
1569
992
|
}
|
|
1570
993
|
}
|
|
1571
994
|
}
|
|
@@ -1580,22 +1003,7 @@ export async function getGatewayHost(instanceId) {
|
|
|
1580
1003
|
}
|
|
1581
1004
|
}
|
|
1582
1005
|
catch { /* fall through */ }
|
|
1583
|
-
|
|
1584
|
-
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
1585
|
-
for (const line of out.split("\n")) {
|
|
1586
|
-
// IPv4 dotted-quad: "... 127.0.0.1:18789 ..."
|
|
1587
|
-
// IPv6 bracketed: "... [::1]:18789 ..."
|
|
1588
|
-
let match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
1589
|
-
if (!match)
|
|
1590
|
-
match = line.match(/\s\[([0-9a-fA-F:]+)\]:(\d+)\s/);
|
|
1591
|
-
if (match && match[2] === String(port)) {
|
|
1592
|
-
const addr = match[1];
|
|
1593
|
-
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
1594
|
-
break;
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
catch { /* fall through */ }
|
|
1006
|
+
result = getListeningHostForPort(port);
|
|
1599
1007
|
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1600
1008
|
return result;
|
|
1601
1009
|
}
|
|
@@ -1610,11 +1018,17 @@ export function urlHost(host) {
|
|
|
1610
1018
|
return host.includes(":") ? `[${host}]` : host;
|
|
1611
1019
|
}
|
|
1612
1020
|
export function findInstancesSharingOpenclawHome(instanceId) {
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
.
|
|
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
|
+
}
|
|
1618
1032
|
}
|
|
1619
1033
|
/**
|
|
1620
1034
|
* Re-pick a gateway port for an existing instance and rewrite its persisted
|
|
@@ -1631,23 +1045,23 @@ export async function reallocateGatewayPort(instanceId) {
|
|
|
1631
1045
|
const meta = safeReadJson(instanceMetaPath(instanceId), "instance-meta");
|
|
1632
1046
|
if (!meta)
|
|
1633
1047
|
throw new Error(`Cannot reallocate port for unknown instance '${instanceId}'`);
|
|
1634
|
-
const
|
|
1635
|
-
|
|
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);
|
|
1636
1059
|
try {
|
|
1637
1060
|
const runtime = (meta.runtime ?? {});
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
args[i + 1] = String(alloc.port);
|
|
1642
|
-
}
|
|
1643
|
-
else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
|
|
1644
|
-
args[i] = `--port=${alloc.port}`;
|
|
1645
|
-
}
|
|
1061
|
+
// Delegate the kind-specific runtime rewrite to the adapter.
|
|
1062
|
+
if (typeof adapter.reallocateRuntimePort === "function") {
|
|
1063
|
+
adapter.reallocateRuntimePort(runtime, alloc.port);
|
|
1646
1064
|
}
|
|
1647
|
-
runtime.args = args;
|
|
1648
|
-
const env = (runtime.env ?? {});
|
|
1649
|
-
env.OPENCLAW_GATEWAY_PORT = String(alloc.port);
|
|
1650
|
-
runtime.env = env;
|
|
1651
1065
|
meta.runtime = runtime;
|
|
1652
1066
|
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
1653
1067
|
chownToServiceUser(instanceMetaPath(instanceId));
|
|
@@ -1677,15 +1091,34 @@ export function getRuntimeEnv(instanceId) {
|
|
|
1677
1091
|
}
|
|
1678
1092
|
return env;
|
|
1679
1093
|
}
|
|
1680
|
-
// Re-export instanceDir for nomad-manager
|
|
1094
|
+
// Re-export instanceDir for nomad-manager under its getInstanceDir alias.
|
|
1681
1095
|
export { instanceDir as getInstanceDir };
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
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);
|
|
1690
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).
|
|
1691
1124
|
//# sourceMappingURL=instance-manager.js.map
|