jishushell 0.4.17 → 0.4.24-beta.2
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 +164 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +159 -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 +770 -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 +2148 -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 +164 -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 -2
- package/package.json +14 -4
- package/public/assets/Dashboard-rh9qpYRR.js +1 -0
- package/public/assets/HermesChatPanel-D6JI6lLY.js +1 -0
- package/public/assets/HermesConfigForm-DcbSemaj.js +4 -0
- package/public/assets/InitPassword-CFTKsED4.js +1 -0
- package/public/assets/InstanceDetail-BhNIKA6Z.js +91 -0
- package/public/assets/{Login-D1Bt-Lyk.js → Login-KB9qrtM0.js} +1 -1
- package/public/assets/NewInstance-CxkO8Hlq.js +1 -0
- package/public/assets/Settings-BVWJvOkU.js +1 -0
- package/public/assets/Setup-X-lzuaUT.js +1 -0
- package/public/assets/WeixinLoginPanel-gca0QTic.js +9 -0
- package/public/assets/index-C8B0cFJM.js +19 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/registry-fVUSujib.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
|
@@ -0,0 +1,2195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClawAdapter — the framework face of OpenClaw business logic.
|
|
3
|
+
*
|
|
4
|
+
* Per §32.2 / §32.8 of docs/multi-agent-runtime-generalization-plan.md, the
|
|
5
|
+
* goal of this file is simple: **all OpenClaw-specific knowledge leaves the
|
|
6
|
+
* framework (instance-manager / nomad-manager / routes) and lives here**.
|
|
7
|
+
*
|
|
8
|
+
* Why not a huge rewrite in a single pass?
|
|
9
|
+
* A full physical migration of ~6500 lines of OpenClaw code out of
|
|
10
|
+
* `instance-manager.ts` / `nomad-manager.ts` / `setup-manager.ts` is a
|
|
11
|
+
* multi-PR undertaking. This file lands the **structural** decoupling
|
|
12
|
+
* (contract + dispatch) today, so:
|
|
13
|
+
*
|
|
14
|
+
* 1. Every framework call site now goes through `getAdapter(agentType).X()`
|
|
15
|
+
* with zero string-literal branching on "openclaw" / "hermes".
|
|
16
|
+
* 2. OpenClaw-specific imperative logic that was already contained (Nomad
|
|
17
|
+
* task build, on-disk patches, npm update seed, pairing CLI mapping)
|
|
18
|
+
* is physically moved into this file.
|
|
19
|
+
* 3. Larger pieces that are still entangled inside instance-manager.ts
|
|
20
|
+
* (`saveConfig` with channel-plugin auto-install, `createInstance`
|
|
21
|
+
* with `openclaw.json` seeding) are exposed through adapter hooks
|
|
22
|
+
* that framework code calls, and the physical code move from
|
|
23
|
+
* instance-manager.ts into a sibling `openclaw-*.ts` file is a
|
|
24
|
+
* straightforward follow-up PR — the `check-adapter-isolation.ts`
|
|
25
|
+
* script (§32.2.1) will block any new code from sneaking back into
|
|
26
|
+
* the framework layer.
|
|
27
|
+
*
|
|
28
|
+
* The user contract for adding a THIRD agent is therefore already satisfied
|
|
29
|
+
* even before the follow-up physical migration:
|
|
30
|
+
*
|
|
31
|
+
* 1. Create `src/services/runtime/adapters/foo.ts` mirroring this file.
|
|
32
|
+
* 2. Add `import "./adapters/foo.js"` to `src/services/runtime/index.ts`.
|
|
33
|
+
* 3. Add one line to `frontend/src/runtimes/registry.ts`.
|
|
34
|
+
* 4. Done. No `instance-manager.ts` / `nomad-manager.ts` / routes edits.
|
|
35
|
+
*/
|
|
36
|
+
import { execFile, execFileSync } from "child_process";
|
|
37
|
+
import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, } from "fs";
|
|
38
|
+
import { randomBytes } from "crypto";
|
|
39
|
+
import { homedir, userInfo } from "os";
|
|
40
|
+
import { dirname, join, resolve as pathResolve } from "path";
|
|
41
|
+
import { getNomadDriver, getOpenclawDockerImage, JISHUSHELL_HOME, getPanelConfig, } from "../../../config.js";
|
|
42
|
+
import { LEGACY_PROVIDER_API_ALIASES } from "../../../constants.js";
|
|
43
|
+
import { ensureDirContainer, ensureDirHost, writeConfigFile } from "../../../utils/fs.js";
|
|
44
|
+
import { safeWriteJson } from "../../../utils/safe-json.js";
|
|
45
|
+
import { compileTaskRuntime } from "../../app/app-compiler.js";
|
|
46
|
+
import { allocateGatewayPort, chownToServiceUser, extractGatewayPort, getInstance, getRuntimeEnv, getRuntimeEnvFiles, inferProviderApiKeyEnvName, listInstances, normalizePath, notifyConfigChange, releasePendingPort, resolveServiceUser, updateEnvFile, } from "../../instance-manager.js";
|
|
47
|
+
import { getInstanceDir as framework_instanceDir, instanceMetaPath } from "../../../config.js";
|
|
48
|
+
import { createTask, emitTask, spawnWithTask, getDirSizeMB, npmProgressParser, dockerBuildProgressParser, resolveDockerInvocation, } from "../../setup-manager.js";
|
|
49
|
+
import { DEFAULT_OPENCLAW_DOCKER_IMAGE, setOpenclawDockerImage, OPENCLAW_MODULES, OPENCLAW_PKG_DIR, } from "../../../config.js";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
import { bootstrapInstanceProxy } from "../../llm-proxy/index.js";
|
|
52
|
+
import { registerAdapter } from "../registry.js";
|
|
53
|
+
import { registerOpenclawRoutes } from "./openclaw-routes.js";
|
|
54
|
+
// ── Constants physically migrated from nomad-manager.ts ───────────────
|
|
55
|
+
//
|
|
56
|
+
// These used to live as module-scope constants in nomad-manager.ts and were
|
|
57
|
+
// read by `buildTaskDocker` / `buildRuntime` helpers. They describe how
|
|
58
|
+
// OpenClaw expects to be launched — container image paths, default command,
|
|
59
|
+
// memory ceilings — and therefore belong to the OpenClaw adapter, not the
|
|
60
|
+
// Nomad scheduler.
|
|
61
|
+
const DEFAULT_COMMAND = "/usr/bin/openclaw";
|
|
62
|
+
const DEFAULT_ARGS = ["gateway", "run", "--port", "18789", "--allow-unconfigured"];
|
|
63
|
+
const DEFAULT_USER = userInfo().username;
|
|
64
|
+
const DEFAULT_CWD = homedir();
|
|
65
|
+
const DEFAULT_ENV = {
|
|
66
|
+
HOME: homedir(),
|
|
67
|
+
TMPDIR: "/tmp",
|
|
68
|
+
PATH: `${homedir()}/.local/bin:${homedir()}/.npm-global/bin:${homedir()}/bin:${homedir()}/.volta/bin:` +
|
|
69
|
+
`${homedir()}/.asdf/shims:${homedir()}/.bun/bin:${homedir()}/.nvm/current/bin:${homedir()}/.fnm/current/bin:` +
|
|
70
|
+
`${homedir()}/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin`,
|
|
71
|
+
};
|
|
72
|
+
const DEFAULT_RESOURCES = { CPU: 500, MemoryMB: 512 };
|
|
73
|
+
const DEFAULT_PIDS_LIMIT = 512;
|
|
74
|
+
// Hard upper bounds applied before submitting any Nomad job. Prevents a
|
|
75
|
+
// misconfigured or malicious instance config from exhausting scheduler
|
|
76
|
+
// resources on the host (no Nomad Enterprise Resource Quotas in OSS).
|
|
77
|
+
const MAX_CPU_MHZ = 4000;
|
|
78
|
+
const MAX_MEMORY_MB = 4096;
|
|
79
|
+
const MAX_MEMORY_MAX_MB = 4096;
|
|
80
|
+
// Path inside the openclaw-runtime Docker image where the baked-in openclaw
|
|
81
|
+
// npm package lives. Referenced by the entrypoint shim as the fallback and
|
|
82
|
+
// used by the control-UI "Update now" path through a pre-seeded symlink in
|
|
83
|
+
// $HOME/.npm-global (see ensureOpenclawUpdateSeed below).
|
|
84
|
+
const CONTAINER_IMAGE_PKG_ROOT = "/app/node_modules/openclaw";
|
|
85
|
+
const VALID_USER_RE = /^[a-z0-9._-]{1,32}$/;
|
|
86
|
+
const DOCKER_IMAGE_RE = /^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/;
|
|
87
|
+
const MAX_DOCKER_IMAGE_NAME_LEN = 256;
|
|
88
|
+
export const OPENCLAW_DEFAULT_GATEWAY_PORT = 18789;
|
|
89
|
+
// ── Capability profile (moved from runtime/instance.ts) ──────────────
|
|
90
|
+
//
|
|
91
|
+
// Describes what the framework should expose for OpenClaw instances. The
|
|
92
|
+
// frontend uses this to decide which tabs render and how the Chat tab looks.
|
|
93
|
+
const DEFAULT_CAPABILITIES = {
|
|
94
|
+
gateway: {
|
|
95
|
+
http: true,
|
|
96
|
+
websocket: true,
|
|
97
|
+
chatPanel: "iframe",
|
|
98
|
+
},
|
|
99
|
+
pairing: {
|
|
100
|
+
list: true,
|
|
101
|
+
approve: true,
|
|
102
|
+
revoke: false,
|
|
103
|
+
clearPending: false,
|
|
104
|
+
},
|
|
105
|
+
configEditor: "json",
|
|
106
|
+
configSchema: false,
|
|
107
|
+
customProvider: true,
|
|
108
|
+
pluginInstall: true,
|
|
109
|
+
skills: true,
|
|
110
|
+
mcp: true,
|
|
111
|
+
memory: true,
|
|
112
|
+
backupRestore: true,
|
|
113
|
+
usageStats: true,
|
|
114
|
+
restartlessReload: false,
|
|
115
|
+
messagingPlatforms: ["feishu", "openclaw-weixin"],
|
|
116
|
+
};
|
|
117
|
+
// ── Path helpers (physically migrated from instance-manager.ts) ───────
|
|
118
|
+
const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
|
|
119
|
+
const INSTANCE_MODEL_ENV_FILENAME = "model.env";
|
|
120
|
+
const OPENCLAW_STATE_DIRNAME = ".openclaw";
|
|
121
|
+
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
|
|
122
|
+
function defaultOpenclawHome(instanceId) {
|
|
123
|
+
return join(framework_instanceDir(instanceId), INSTANCE_OPENCLAW_HOME_DIRNAME);
|
|
124
|
+
}
|
|
125
|
+
function defaultOpenclawModelEnvFile(instanceId) {
|
|
126
|
+
return join(framework_instanceDir(instanceId), INSTANCE_MODEL_ENV_FILENAME);
|
|
127
|
+
}
|
|
128
|
+
function isPrecreatedManagedAppDir(dir) {
|
|
129
|
+
return existsSync(join(dir, "app-spec.yaml")) && existsSync(join(dir, "manifest.json"));
|
|
130
|
+
}
|
|
131
|
+
function openclawConfigPath(instanceId, home) {
|
|
132
|
+
const h = home ?? defaultOpenclawHome(instanceId);
|
|
133
|
+
return join(h, OPENCLAW_STATE_DIRNAME, OPENCLAW_CONFIG_FILENAME);
|
|
134
|
+
}
|
|
135
|
+
function legacyOpenclawConfigPath(instanceId, home) {
|
|
136
|
+
const h = home ?? defaultOpenclawHome(instanceId);
|
|
137
|
+
return join(h, OPENCLAW_CONFIG_FILENAME);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the most relevant openclaw.json path for a SOURCE instance used
|
|
141
|
+
* by the clone_from branch. Prefers the .openclaw/ state dir, falls back
|
|
142
|
+
* to the legacy flat path so clones of old instances still work.
|
|
143
|
+
*/
|
|
144
|
+
function resolveExistingConfigPath(instanceId) {
|
|
145
|
+
const runtimePath = openclawConfigPath(instanceId);
|
|
146
|
+
if (existsSync(runtimePath))
|
|
147
|
+
return runtimePath;
|
|
148
|
+
const legacy = legacyOpenclawConfigPath(instanceId);
|
|
149
|
+
if (existsSync(legacy))
|
|
150
|
+
return legacy;
|
|
151
|
+
return runtimePath;
|
|
152
|
+
}
|
|
153
|
+
// ── Runtime + config defaults (physically migrated) ───────────────────
|
|
154
|
+
function resolveOpenclawBin() {
|
|
155
|
+
const candidates = [
|
|
156
|
+
join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
|
|
157
|
+
"/usr/local/bin/openclaw",
|
|
158
|
+
"/usr/bin/openclaw",
|
|
159
|
+
];
|
|
160
|
+
for (const p of candidates) {
|
|
161
|
+
if (existsSync(p)) {
|
|
162
|
+
try {
|
|
163
|
+
chmodSync(p, 0o755);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* best effort — may be a symlink */
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const real = realpathSync(p);
|
|
170
|
+
if (real !== p)
|
|
171
|
+
chmodSync(real, 0o755);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* best effort */
|
|
175
|
+
}
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return candidates[0]; // fallback, will fail with clear error at spawn
|
|
180
|
+
}
|
|
181
|
+
function buildDefaultRuntime(instanceId, port, openclawHome) {
|
|
182
|
+
const home = openclawHome || defaultOpenclawHome(instanceId);
|
|
183
|
+
return {
|
|
184
|
+
command: resolveOpenclawBin(),
|
|
185
|
+
args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
|
|
186
|
+
cwd: home,
|
|
187
|
+
user: resolveServiceUser()?.username ?? userInfo().username,
|
|
188
|
+
env_files: [defaultOpenclawModelEnvFile(instanceId)],
|
|
189
|
+
env: {
|
|
190
|
+
OPENCLAW_GATEWAY_PORT: String(port),
|
|
191
|
+
NODE_OPTIONS: "--max-old-space-size=2048",
|
|
192
|
+
},
|
|
193
|
+
resources: { CPU: 1000, MemoryMB: 2048 },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function starterConfig() {
|
|
197
|
+
const dp = getPanelConfig().default_provider;
|
|
198
|
+
let providerName = "minimax";
|
|
199
|
+
let providerConfig = {
|
|
200
|
+
baseUrl: "https://api.minimaxi.com/v1",
|
|
201
|
+
api: "openai-completions",
|
|
202
|
+
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
|
|
203
|
+
};
|
|
204
|
+
let defaultModel = "minimax/MiniMax-M2.7";
|
|
205
|
+
if (dp?.providerId) {
|
|
206
|
+
providerName = dp.providerId;
|
|
207
|
+
providerConfig = {
|
|
208
|
+
baseUrl: dp.baseUrl,
|
|
209
|
+
api: dp.api,
|
|
210
|
+
...(dp.authHeader ? { authHeader: true } : {}),
|
|
211
|
+
models: dp.models || [],
|
|
212
|
+
};
|
|
213
|
+
const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
|
|
214
|
+
defaultModel = `${providerName}/${modelId}`;
|
|
215
|
+
}
|
|
216
|
+
const config = {
|
|
217
|
+
models: { providers: { [providerName]: providerConfig } },
|
|
218
|
+
agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
|
|
219
|
+
channels: {},
|
|
220
|
+
gateway: {
|
|
221
|
+
mode: "local",
|
|
222
|
+
auth: { mode: "token", token: randomBytes(24).toString("hex") },
|
|
223
|
+
controlUi: { dangerouslyDisableDeviceAuth: true },
|
|
224
|
+
},
|
|
225
|
+
plugins: { entries: { feishu: { enabled: false } } },
|
|
226
|
+
};
|
|
227
|
+
if (dp?.providerId) {
|
|
228
|
+
config["x-jishushell"] = {
|
|
229
|
+
proxy: {
|
|
230
|
+
upstream: {
|
|
231
|
+
providerId: dp.providerId,
|
|
232
|
+
baseUrl: dp.baseUrl,
|
|
233
|
+
api: dp.api,
|
|
234
|
+
authHeader: dp.authHeader || false,
|
|
235
|
+
models: dp.models || [],
|
|
236
|
+
selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
|
|
237
|
+
hasApiKey: !!dp.apiKey,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return config;
|
|
243
|
+
}
|
|
244
|
+
// ── Nomad-patching helpers (migrated from nomad-manager.ts) ───────────
|
|
245
|
+
/**
|
|
246
|
+
* In docker bridge mode, 127.0.0.1 inside the container resolves to the
|
|
247
|
+
* container's own loopback, not the host. Rewrite the jsproxy provider
|
|
248
|
+
* baseUrl in openclaw.json to use host.docker.internal instead.
|
|
249
|
+
*/
|
|
250
|
+
function patchJsproxyBaseUrl(configPath) {
|
|
251
|
+
try {
|
|
252
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
253
|
+
const patched = raw.replace(/http:\/\/127\.0\.0\.1:(\d+)\/proxy/g, `http://host.docker.internal:$1/proxy`);
|
|
254
|
+
if (patched !== raw) {
|
|
255
|
+
writeConfigFile(configPath, patched);
|
|
256
|
+
console.log(`[openclaw] Patched jsproxy baseUrl in ${configPath} (127.0.0.1 → host.docker.internal)`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
console.warn(`[openclaw] Failed to patch jsproxy baseUrl in ${configPath}: ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Docker bridge port publishing cannot reach a process that only binds the
|
|
265
|
+
* container loopback. Normalize default/loopback gateway binds to `lan` so
|
|
266
|
+
* Nomad's published host port can reach the gateway.
|
|
267
|
+
*/
|
|
268
|
+
function patchDockerBridgeGatewayBind(configPath) {
|
|
269
|
+
try {
|
|
270
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
271
|
+
const parsed = JSON.parse(raw);
|
|
272
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
273
|
+
return;
|
|
274
|
+
const gatewayRaw = parsed.gateway;
|
|
275
|
+
const gateway = gatewayRaw && typeof gatewayRaw === "object" && !Array.isArray(gatewayRaw)
|
|
276
|
+
? gatewayRaw
|
|
277
|
+
: (parsed.gateway = {});
|
|
278
|
+
const bind = typeof gateway.bind === "string" ? gateway.bind.trim() : "";
|
|
279
|
+
if (bind && bind !== "loopback")
|
|
280
|
+
return;
|
|
281
|
+
gateway.bind = "lan";
|
|
282
|
+
const next = JSON.stringify(parsed, null, 2);
|
|
283
|
+
const output = raw.endsWith("\n") ? `${next}\n` : next;
|
|
284
|
+
if (output === raw)
|
|
285
|
+
return;
|
|
286
|
+
writeConfigFile(configPath, output);
|
|
287
|
+
console.log(`[openclaw] Normalized gateway.bind to "lan" in ${configPath} for Docker bridge networking`);
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.warn(`[openclaw] Failed to patch gateway.bind in ${configPath}: ${e.message}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Pre-seed the per-instance npm global prefix with a symlink to the image's
|
|
295
|
+
* baked openclaw package so OpenClaw's in-gateway "Update now" handler can
|
|
296
|
+
* detect the install as an npm global install. Idempotent; docker driver only.
|
|
297
|
+
*/
|
|
298
|
+
function ensureOpenclawUpdateSeed(openclawHome, instanceId) {
|
|
299
|
+
if (getNomadDriver() !== "docker")
|
|
300
|
+
return;
|
|
301
|
+
if (!openclawHome)
|
|
302
|
+
return;
|
|
303
|
+
const linkDir = join(openclawHome, ".npm-global", "lib", "node_modules");
|
|
304
|
+
const linkPath = join(linkDir, "openclaw");
|
|
305
|
+
try {
|
|
306
|
+
lstatSync(linkPath);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
if (err?.code !== "ENOENT") {
|
|
311
|
+
console.warn(`[openclaw] update-seed: lstat failed for ${linkPath}: ${err?.message ?? err}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
mkdirSync(linkDir, { recursive: true });
|
|
317
|
+
symlinkSync(CONTAINER_IMAGE_PKG_ROOT, linkPath);
|
|
318
|
+
console.log(`[openclaw] update-seed ${instanceId}: seeded ${linkPath} -> ${CONTAINER_IMAGE_PKG_ROOT}`);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.warn(`[openclaw] update-seed ${instanceId}: failed to create seed: ${err?.message ?? err}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// ── Resource helpers (migrated from nomad-manager.ts) ─────────────────
|
|
325
|
+
function resolveUidGid(username) {
|
|
326
|
+
try {
|
|
327
|
+
if (!VALID_USER_RE.test(username)) {
|
|
328
|
+
console.warn(`[openclaw] Invalid username for UID lookup: ${username}`);
|
|
329
|
+
return `${process.getuid()}:${process.getgid()}`;
|
|
330
|
+
}
|
|
331
|
+
const passwd = readFileSync("/etc/passwd", "utf-8");
|
|
332
|
+
const line = passwd.split("\n").find((l) => l.startsWith(username + ":"));
|
|
333
|
+
if (line) {
|
|
334
|
+
const parts = line.split(":");
|
|
335
|
+
const uid = parseInt(parts[2], 10);
|
|
336
|
+
const gid = parseInt(parts[3], 10);
|
|
337
|
+
if (!isNaN(uid) && !isNaN(gid))
|
|
338
|
+
return `${uid}:${gid}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
/* ignore */
|
|
343
|
+
}
|
|
344
|
+
return `${process.getuid()}:${process.getgid()}`;
|
|
345
|
+
}
|
|
346
|
+
function normalizeDockerResources(instanceId, resources) {
|
|
347
|
+
const requestedMemoryMB = Number(resources.MemoryMB ?? DEFAULT_RESOURCES.MemoryMB);
|
|
348
|
+
let effectiveMemoryMB = requestedMemoryMB;
|
|
349
|
+
let effectiveMemoryMaxMB = Math.min(Number(resources.MemoryMaxMB ?? requestedMemoryMB), MAX_MEMORY_MAX_MB);
|
|
350
|
+
if (effectiveMemoryMaxMB < effectiveMemoryMB) {
|
|
351
|
+
console.warn(`[openclaw] ${instanceId}: MemoryMaxMB (${effectiveMemoryMaxMB}) is below MemoryMB (${effectiveMemoryMB}); clamping.`);
|
|
352
|
+
effectiveMemoryMaxMB = effectiveMemoryMB;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
...resources,
|
|
356
|
+
MemoryMB: effectiveMemoryMB,
|
|
357
|
+
MemoryMaxMB: effectiveMemoryMaxMB,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// ── Nomad template safety (migrated from nomad-manager.ts) ────────────
|
|
361
|
+
const NOMAD_TEMPLATE_UNSAFE_RE = /[{}"\\]/;
|
|
362
|
+
function assertSafeTemplateId(id) {
|
|
363
|
+
if (NOMAD_TEMPLATE_UNSAFE_RE.test(id)) {
|
|
364
|
+
throw new Error(`Job ID "${id}" contains characters unsafe for Nomad Template interpolation`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ── Lazy-imported framework helpers (avoid circular deps) ─────────────
|
|
368
|
+
//
|
|
369
|
+
// The adapter reaches back into instance-manager.ts for helpers that still
|
|
370
|
+
// live there. Using lazy `await import()` keeps the static import graph
|
|
371
|
+
// acyclic, so runtime/index.ts → adapters/openclaw.ts loads cleanly before
|
|
372
|
+
// instance-manager.ts needs it.
|
|
373
|
+
async function lazyIm() {
|
|
374
|
+
return await import("../../instance-manager.js");
|
|
375
|
+
}
|
|
376
|
+
// ── Docker image build helpers (physically migrated from setup-manager) ─
|
|
377
|
+
//
|
|
378
|
+
// All OpenClaw-specific docker image build knowledge lives here. setup-manager
|
|
379
|
+
// only retains a thin dispatch wrapper so the public
|
|
380
|
+
// `buildSlimOpenclawImage()` / `startBuildSlimOpenclawImage()` API remains
|
|
381
|
+
// back-compatible for routes/setup.ts and the CLI installer.
|
|
382
|
+
/** Base image used for the slim OpenClaw runtime image. */
|
|
383
|
+
const DOCKER_BASE_IMAGE = "node:22-slim";
|
|
384
|
+
/** Mirror list tried in order when docker.io is unreachable. */
|
|
385
|
+
const DOCKER_BASE_MIRRORS = [
|
|
386
|
+
"node:22-slim",
|
|
387
|
+
"hub-mirror.c.163.com/library/node:22-slim",
|
|
388
|
+
"mirrors.tencent.com/library/node:22-slim",
|
|
389
|
+
"registry.cn-hangzhou.aliyuncs.com/library/node:22-slim",
|
|
390
|
+
];
|
|
391
|
+
/** Matches a semver-ish tag suffix, e.g. "...:2026.4.9" or "...:v1.2.3-beta". */
|
|
392
|
+
const PINNED_IMAGE_TAG_RE = /:[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$/;
|
|
393
|
+
/**
|
|
394
|
+
* Pull DOCKER_BASE_IMAGE from mirrors if not already cached locally.
|
|
395
|
+
*/
|
|
396
|
+
async function ensureDockerBaseImage(invocation, task) {
|
|
397
|
+
try {
|
|
398
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", DOCKER_BASE_IMAGE], {
|
|
399
|
+
timeout: 5000,
|
|
400
|
+
stdio: "ignore",
|
|
401
|
+
});
|
|
402
|
+
emitTask(task, { type: "log", message: `基础镜像已缓存: ${DOCKER_BASE_IMAGE}` });
|
|
403
|
+
return DOCKER_BASE_IMAGE;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
/* not cached, fall through */
|
|
407
|
+
}
|
|
408
|
+
for (const mirror of DOCKER_BASE_MIRRORS) {
|
|
409
|
+
emitTask(task, { type: "log", message: `拉取基础镜像: ${mirror} ...` });
|
|
410
|
+
const result = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", mirror], { timeout: 300000 });
|
|
411
|
+
if (result.ok) {
|
|
412
|
+
if (mirror !== DOCKER_BASE_IMAGE) {
|
|
413
|
+
try {
|
|
414
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", mirror, DOCKER_BASE_IMAGE], { timeout: 10000 });
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* tag failure is non-fatal */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
emitTask(task, { type: "log", message: `基础镜像就绪: ${DOCKER_BASE_IMAGE}` });
|
|
421
|
+
return DOCKER_BASE_IMAGE;
|
|
422
|
+
}
|
|
423
|
+
emitTask(task, { type: "log", message: ` → ${mirror} 不可达,尝试下一个镜像源...` });
|
|
424
|
+
}
|
|
425
|
+
throw new Error(`无法获取基础镜像 ${DOCKER_BASE_IMAGE}。请检查网络或手动执行: docker pull ${DOCKER_BASE_MIRRORS[1]}`);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Query the npm registry for the current OpenClaw version. Used to bust the
|
|
429
|
+
* Docker layer cache for `RUN npm install openclaw@${ver}` during local build.
|
|
430
|
+
*/
|
|
431
|
+
function resolveOpenclawNpmVersion() {
|
|
432
|
+
try {
|
|
433
|
+
const out = execFileSync("npm", ["view", "openclaw", "version"], {
|
|
434
|
+
timeout: 15000,
|
|
435
|
+
encoding: "utf-8",
|
|
436
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
437
|
+
}).trim();
|
|
438
|
+
if (/^\d+\.\d+\.\d+/.test(out))
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
/* npm not reachable */
|
|
443
|
+
}
|
|
444
|
+
return "latest";
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Read the OpenClaw version bundled at /app/ inside a Docker image.
|
|
448
|
+
*/
|
|
449
|
+
function readBundledOpenclawVersion(invocation, image) {
|
|
450
|
+
try {
|
|
451
|
+
const out = execFileSync(invocation.cmd, [
|
|
452
|
+
...invocation.argsPrefix,
|
|
453
|
+
"run",
|
|
454
|
+
"--rm",
|
|
455
|
+
"--entrypoint",
|
|
456
|
+
"node",
|
|
457
|
+
image,
|
|
458
|
+
"-p",
|
|
459
|
+
"require('/app/node_modules/openclaw/package.json').version",
|
|
460
|
+
], { timeout: 20000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
461
|
+
if (/^\d+\.\d+\.\d+/.test(out))
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
/* docker unavailable, image missing, or path not present */
|
|
466
|
+
}
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Add a pinned version alias for an image, then drop the mutable :latest /
|
|
471
|
+
* :slim tag. See original setup-manager commentary for details.
|
|
472
|
+
*/
|
|
473
|
+
function capturePinnedImageTag(invocation, targetTag, explicitVersion) {
|
|
474
|
+
if (PINNED_IMAGE_TAG_RE.test(targetTag))
|
|
475
|
+
return targetTag;
|
|
476
|
+
let version = explicitVersion && /^\d+\.\d+\.\d+/.test(explicitVersion) ? explicitVersion : "";
|
|
477
|
+
if (!version) {
|
|
478
|
+
version = readBundledOpenclawVersion(invocation, targetTag);
|
|
479
|
+
}
|
|
480
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version))
|
|
481
|
+
return targetTag;
|
|
482
|
+
const colonIdx = targetTag.lastIndexOf(":");
|
|
483
|
+
const slashIdx = targetTag.lastIndexOf("/");
|
|
484
|
+
const hasTag = colonIdx > slashIdx;
|
|
485
|
+
const repo = hasTag ? targetTag.slice(0, colonIdx) : targetTag;
|
|
486
|
+
const pinnedTag = `${repo}:${version}`;
|
|
487
|
+
if (pinnedTag === targetTag)
|
|
488
|
+
return targetTag;
|
|
489
|
+
try {
|
|
490
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "tag", targetTag, pinnedTag], { timeout: 10000, stdio: "ignore" });
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return targetTag;
|
|
494
|
+
}
|
|
495
|
+
if (/:(latest|slim)$/.test(targetTag)) {
|
|
496
|
+
try {
|
|
497
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "rmi", targetTag], { timeout: 10000, stdio: "ignore" });
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
/* best-effort cleanup */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return pinnedTag;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Try docker pull, fall back to local `Dockerfile.openclaw-slim` build. This
|
|
507
|
+
* is the primary image-prep path used by every setup flow. Called from the
|
|
508
|
+
* adapter's `buildRuntimeImage()` method.
|
|
509
|
+
*/
|
|
510
|
+
async function pullOrBuildOpenclawImageWithTask(task, tag) {
|
|
511
|
+
const targetTag = tag || DEFAULT_OPENCLAW_DOCKER_IMAGE;
|
|
512
|
+
try {
|
|
513
|
+
const invocation = resolveDockerInvocation();
|
|
514
|
+
// Always attempt pull — when the image is already local and in sync
|
|
515
|
+
// with upstream, docker returns within seconds after a digest check.
|
|
516
|
+
// The "skip if image present" early exit was making "重新安装" feel
|
|
517
|
+
// like a no-op; explicit re-pull matches user intent better. On pull
|
|
518
|
+
// failure we still fall back to local build below.
|
|
519
|
+
emitTask(task, { type: "progress", message: `正在拉取镜像: ${targetTag} ...`, progress: 10 });
|
|
520
|
+
const pullResult = await spawnWithTask(task, invocation.cmd, [...invocation.argsPrefix, "pull", targetTag], { timeout: 600000 });
|
|
521
|
+
if (pullResult.ok) {
|
|
522
|
+
const pinned = capturePinnedImageTag(invocation, targetTag);
|
|
523
|
+
setOpenclawDockerImage(pinned);
|
|
524
|
+
emitTask(task, { type: "done", message: `镜像拉取成功: ${pinned}`, progress: 100 });
|
|
525
|
+
task.status = "done";
|
|
526
|
+
return { ok: true, message: `Docker image ${pinned} pulled`, taskId: task.id };
|
|
527
|
+
}
|
|
528
|
+
console.log(`[openclaw] docker pull failed for ${targetTag}, falling back to local build...`);
|
|
529
|
+
emitTask(task, {
|
|
530
|
+
type: "progress",
|
|
531
|
+
message: `拉取失败,正在本地构建镜像: ${targetTag} ...`,
|
|
532
|
+
progress: 20,
|
|
533
|
+
});
|
|
534
|
+
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
535
|
+
const dockerfilePath = join(projectRoot, "Dockerfile.openclaw-slim");
|
|
536
|
+
if (!existsSync(dockerfilePath)) {
|
|
537
|
+
emitTask(task, {
|
|
538
|
+
type: "error",
|
|
539
|
+
message: "Dockerfile.openclaw-slim not found, cannot fallback to local build",
|
|
540
|
+
});
|
|
541
|
+
task.status = "error";
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
message: "Docker pull failed and Dockerfile.openclaw-slim not found",
|
|
545
|
+
taskId: task.id,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const openclawVersion = resolveOpenclawNpmVersion();
|
|
549
|
+
console.log(`[openclaw] building image with OPENCLAW_VERSION=${openclawVersion}`);
|
|
550
|
+
const buildResult = await spawnWithTask(task, invocation.cmd, [
|
|
551
|
+
...invocation.argsPrefix,
|
|
552
|
+
"build",
|
|
553
|
+
"--network=host",
|
|
554
|
+
"--build-arg",
|
|
555
|
+
`OPENCLAW_VERSION=${openclawVersion}`,
|
|
556
|
+
"-f",
|
|
557
|
+
dockerfilePath,
|
|
558
|
+
"-t",
|
|
559
|
+
targetTag,
|
|
560
|
+
projectRoot,
|
|
561
|
+
], { timeout: 1800000, progressParser: dockerBuildProgressParser });
|
|
562
|
+
if (!buildResult.ok) {
|
|
563
|
+
try {
|
|
564
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "prune", "-f"], { timeout: 15000, stdio: "ignore" });
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
/* best-effort */
|
|
568
|
+
}
|
|
569
|
+
emitTask(task, { type: "error", message: "Docker 镜像构建失败" });
|
|
570
|
+
task.status = "error";
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
message: "Docker image build failed",
|
|
574
|
+
error: buildResult.output,
|
|
575
|
+
taskId: task.id,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const pinned = capturePinnedImageTag(invocation, targetTag, openclawVersion);
|
|
579
|
+
setOpenclawDockerImage(pinned);
|
|
580
|
+
emitTask(task, {
|
|
581
|
+
type: "done",
|
|
582
|
+
message: `OpenClaw 镜像就绪 (本地构建): ${pinned}`,
|
|
583
|
+
progress: 100,
|
|
584
|
+
});
|
|
585
|
+
task.status = "done";
|
|
586
|
+
return { ok: true, message: `Docker image ${pinned} built locally`, taskId: task.id };
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
emitTask(task, { type: "error", message: `镜像获取失败: ${e.message}` });
|
|
590
|
+
task.status = "error";
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
message: "Docker image pull/build failed",
|
|
594
|
+
error: e.message,
|
|
595
|
+
taskId: task.id,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// ── OpenClawAdapter class ─────────────────────────────────────────────
|
|
600
|
+
class OpenClawAdapter {
|
|
601
|
+
agentType = "openclaw";
|
|
602
|
+
displayName = "OpenClaw";
|
|
603
|
+
defaultCapabilities = DEFAULT_CAPABILITIES;
|
|
604
|
+
defaultGatewayPort = OPENCLAW_DEFAULT_GATEWAY_PORT;
|
|
605
|
+
manifest = {
|
|
606
|
+
agentType: "openclaw",
|
|
607
|
+
displayName: "OpenClaw",
|
|
608
|
+
description: "默认 runtime,支持克隆、飞书 / 企业微信插件",
|
|
609
|
+
defaultCapabilities: DEFAULT_CAPABILITIES,
|
|
610
|
+
requiresNomadDocker: false,
|
|
611
|
+
diskSpaceMB: 2048,
|
|
612
|
+
};
|
|
613
|
+
hooks = {
|
|
614
|
+
/**
|
|
615
|
+
* Full OpenClaw pre-start prelude — used to live inline in
|
|
616
|
+
* `nomad-manager.startInstance()` as an `if (!hermes) { ... }` branch
|
|
617
|
+
* (~80 lines). Framework code now calls this hook uniformly for every
|
|
618
|
+
* kind; Hermes provides a no-op and OpenClaw owns the full sequence:
|
|
619
|
+
*
|
|
620
|
+
* 1. Stop any legacy process-manager subprocess
|
|
621
|
+
* 2. Ensure `openclaw.json` exists + fix state-dir permissions
|
|
622
|
+
* 3. Docker-bridge: patch gateway.bind + jsproxy baseUrl
|
|
623
|
+
* 4. Seed `$HOME/.npm-global` for in-gateway "Update now"
|
|
624
|
+
* 5. Validate + image-inspect the Docker image; background-pull on miss
|
|
625
|
+
* 6. Write JSPROXY_API_KEY into Nomad Variables
|
|
626
|
+
*
|
|
627
|
+
* Throws on fatal errors. Framework catches and returns the structured
|
|
628
|
+
* result; a `taskId` property on the thrown error signals an async
|
|
629
|
+
* image pull in progress.
|
|
630
|
+
*/
|
|
631
|
+
onBeforeStart: async ({ instanceId }) => {
|
|
632
|
+
// 1. Stop any legacy subprocess
|
|
633
|
+
try {
|
|
634
|
+
const { getLegacyStatus, stopInstance: stopLegacyInstance } = await import("../../process-manager.js");
|
|
635
|
+
const legacyStatus = await getLegacyStatus(instanceId);
|
|
636
|
+
if (legacyStatus.status === "running") {
|
|
637
|
+
console.log(`[openclaw] Stopping legacy process for ${instanceId} (pid=${legacyStatus.pid}) before Nomad start`);
|
|
638
|
+
await stopLegacyInstance(instanceId);
|
|
639
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
/* process-manager may be absent; harmless */
|
|
644
|
+
}
|
|
645
|
+
// 2. Config path existence check + permissions — use local resolvers
|
|
646
|
+
// instead of lazy importing back into instance-manager.
|
|
647
|
+
let configPath;
|
|
648
|
+
try {
|
|
649
|
+
configPath = openclawAdapter.resolveConfigPath(instanceId);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return; // bail gracefully for non-OpenClaw instances
|
|
653
|
+
}
|
|
654
|
+
if (!existsSync(configPath)) {
|
|
655
|
+
throw new Error("Config file not found");
|
|
656
|
+
}
|
|
657
|
+
if (getNomadDriver() === "docker") {
|
|
658
|
+
const stateDir = dirname(configPath);
|
|
659
|
+
ensureDirContainer(stateDir);
|
|
660
|
+
try {
|
|
661
|
+
for (const entry of readdirSync(stateDir, { withFileTypes: true })) {
|
|
662
|
+
if (entry.isDirectory()) {
|
|
663
|
+
const sub = join(stateDir, entry.name);
|
|
664
|
+
ensureDirContainer(sub);
|
|
665
|
+
try {
|
|
666
|
+
for (const child of readdirSync(sub, { withFileTypes: true })) {
|
|
667
|
+
if (child.isDirectory())
|
|
668
|
+
ensureDirContainer(join(sub, child.name));
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
/* ignore */
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
/* ignore */
|
|
679
|
+
}
|
|
680
|
+
if (existsSync(configPath))
|
|
681
|
+
chmodSync(configPath, 0o644);
|
|
682
|
+
// 3. Docker bridge patches
|
|
683
|
+
patchDockerBridgeGatewayBind(configPath);
|
|
684
|
+
patchJsproxyBaseUrl(configPath);
|
|
685
|
+
}
|
|
686
|
+
// 4. npm update-seed — use local resolver
|
|
687
|
+
try {
|
|
688
|
+
const home = openclawAdapter.resolveAgentHome(instanceId);
|
|
689
|
+
if (home)
|
|
690
|
+
ensureOpenclawUpdateSeed(home, instanceId);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* best effort */
|
|
694
|
+
}
|
|
695
|
+
// 5. Docker image validation + background pull fallback
|
|
696
|
+
if (getNomadDriver() === "docker") {
|
|
697
|
+
const image = getOpenclawDockerImage();
|
|
698
|
+
if (!DOCKER_IMAGE_RE.test(image) || image.length > MAX_DOCKER_IMAGE_NAME_LEN) {
|
|
699
|
+
throw new Error(`Invalid Docker image name: "${image}"`);
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
execFileSync("docker", ["image", "inspect", image], {
|
|
703
|
+
timeout: 10000,
|
|
704
|
+
stdio: "ignore",
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
console.log(`[openclaw] Docker image ${image} not found, starting background pull`);
|
|
709
|
+
try {
|
|
710
|
+
const result = openclawAdapter.startBuildRuntimeImage({ tag: image });
|
|
711
|
+
const err = new Error(`Docker image ${image} not found. Pull started in background.`);
|
|
712
|
+
err.building = true;
|
|
713
|
+
err.taskId = result.taskId;
|
|
714
|
+
throw err;
|
|
715
|
+
}
|
|
716
|
+
catch (e) {
|
|
717
|
+
if (e?.building)
|
|
718
|
+
throw e;
|
|
719
|
+
throw new Error(`Docker image ${image} not available: ${e?.message ?? e}`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// 6. Write instance secrets to Nomad Variables
|
|
724
|
+
try {
|
|
725
|
+
const nomad = await import("../../nomad-manager.js");
|
|
726
|
+
if (typeof nomad.writeInstanceVariables === "function") {
|
|
727
|
+
await nomad.writeInstanceVariables(instanceId);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch (e) {
|
|
731
|
+
throw new Error(`Failed to store instance secrets in Nomad Variables: ${e?.message ?? e}`);
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
/**
|
|
735
|
+
* OpenClaw auto-installs IM channel plugins (feishu, weixin, etc.) when
|
|
736
|
+
* they're enabled in `openclaw.json`. That logic currently lives in
|
|
737
|
+
* `instance-manager.saveConfig()`; wiring it through this hook is a
|
|
738
|
+
* no-op during MVP because saveConfig still runs inline. The hook
|
|
739
|
+
* exists so the physical-migration follow-up PR can move the code into
|
|
740
|
+
* this file without touching framework code.
|
|
741
|
+
*/
|
|
742
|
+
onConfigSaved: async (_args) => {
|
|
743
|
+
/* reserved — channel plugin install hook, filled by follow-up PR */
|
|
744
|
+
},
|
|
745
|
+
/**
|
|
746
|
+
* Surface an orphan-directory warning when the legacy `openclaw_home`
|
|
747
|
+
* field points outside the instance dir. The framework's deleteInstance
|
|
748
|
+
* only rm's the instance dir itself, so custom-home layouts leave a
|
|
749
|
+
* tree that the operator must clean up manually.
|
|
750
|
+
*/
|
|
751
|
+
onDelete: async ({ instanceId, meta }) => {
|
|
752
|
+
const home = typeof meta?.openclaw_home === "string" ? meta.openclaw_home : null;
|
|
753
|
+
if (!home)
|
|
754
|
+
return;
|
|
755
|
+
const instDir = framework_instanceDir(instanceId);
|
|
756
|
+
if (home.startsWith(instDir))
|
|
757
|
+
return; // inside the instance dir — rm already caught it
|
|
758
|
+
if (!existsSync(home))
|
|
759
|
+
return;
|
|
760
|
+
return {
|
|
761
|
+
warnings: [
|
|
762
|
+
`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`,
|
|
763
|
+
],
|
|
764
|
+
};
|
|
765
|
+
},
|
|
766
|
+
/**
|
|
767
|
+
* Rewrite `openclaw.json` x-jishushell.proxy.upstream when the panel
|
|
768
|
+
* default provider changes. Delegates to instance-manager's existing
|
|
769
|
+
* helper so the transition stays stepwise.
|
|
770
|
+
*/
|
|
771
|
+
onUpstreamProviderChange: async ({ instanceId, upstream }) => {
|
|
772
|
+
try {
|
|
773
|
+
const im = await lazyIm();
|
|
774
|
+
if (typeof im.pushUpstreamToInstance === "function") {
|
|
775
|
+
await im.pushUpstreamToInstance(instanceId, upstream);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
console.warn(`[openclaw] onUpstreamProviderChange failed for ${instanceId}: ${e.message}`);
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
/**
|
|
784
|
+
* Full OpenClaw instance bootstrap, physically migrated from the legacy
|
|
785
|
+
* `instance-manager.createInstance()` (~270 lines). Framework code now
|
|
786
|
+
* calls this uniformly via `getAdapter(agentType).createInstance(args)` —
|
|
787
|
+
* instance-manager no longer owns any OpenClaw business logic.
|
|
788
|
+
*
|
|
789
|
+
* Responsibilities:
|
|
790
|
+
* 1. openclaw_home path validation (traversal + symlink safety)
|
|
791
|
+
* 2. Gateway port allocation + runtime spec generation
|
|
792
|
+
* 3. clone_from handling (extensions/workspace/memory/sessions copy)
|
|
793
|
+
* 4. starter config seeding + default-provider API key injection
|
|
794
|
+
* 5. AppSpec overlays (config_defaults / skills)
|
|
795
|
+
* 6. Proxy bootstrap token generation via LLM proxy
|
|
796
|
+
* 7. chown to service user when running as root
|
|
797
|
+
*/
|
|
798
|
+
async createInstance(args) {
|
|
799
|
+
const { instanceId, name, description = "", cloneFrom, agentHome: openclawHomeArg, appSpec, cloneOptions, } = args;
|
|
800
|
+
// Guard: prevent creating an OpenClaw instance when the runtime isn't
|
|
801
|
+
// installed. Without this, the instance.json gets written but the first
|
|
802
|
+
// `service/start` fails with an opaque `npm package not found` or
|
|
803
|
+
// `docker image missing` error. The Setup wizard treats "any runtime
|
|
804
|
+
// ready" as overall ready, so the Hermes-only case reaches here.
|
|
805
|
+
// getInstallStatus() is best-effort: if config exports are unavailable
|
|
806
|
+
// (e.g. partially mocked in test environments) the check is skipped so
|
|
807
|
+
// tests that do not mock the install state continue to work unchanged.
|
|
808
|
+
// The e2e-real suite spawns a real CLI on hosts that intentionally do
|
|
809
|
+
// not have OpenClaw installed; NODE_ENV=test (set by the e2e helper)
|
|
810
|
+
// is the explicit opt-out so the gate doesn't break instance-lifecycle
|
|
811
|
+
// tests.
|
|
812
|
+
try {
|
|
813
|
+
const hasAppManagedRuntime = Boolean(appSpec?.tasks?.some((task) => (task.role ?? "service") === "service" && (task.command || task.image)));
|
|
814
|
+
if (!hasAppManagedRuntime && process.env.NODE_ENV !== "test") {
|
|
815
|
+
const installStatus = this.getInstallStatus();
|
|
816
|
+
if (!installStatus.installed) {
|
|
817
|
+
throw new Error("OpenClaw runtime is not installed. Install it from the Apps page " +
|
|
818
|
+
"or run `jishushell install openclaw`, then retry.");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
catch (err) {
|
|
823
|
+
// Re-throw only our own install-gate error; absorb config-unavailable
|
|
824
|
+
// errors that arise in partially mocked test environments.
|
|
825
|
+
if (err?.message?.startsWith("OpenClaw runtime is not installed"))
|
|
826
|
+
throw err;
|
|
827
|
+
}
|
|
828
|
+
const d = framework_instanceDir(instanceId);
|
|
829
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
830
|
+
if (existsSync(metaPath))
|
|
831
|
+
throw new Error(`Instance '${instanceId}' already exists`);
|
|
832
|
+
if (existsSync(d) && !isPrecreatedManagedAppDir(d)) {
|
|
833
|
+
throw new Error(`Instance '${instanceId}' already exists`);
|
|
834
|
+
}
|
|
835
|
+
const home = openclawHomeArg
|
|
836
|
+
? normalizePath(openclawHomeArg)
|
|
837
|
+
: defaultOpenclawHome(instanceId);
|
|
838
|
+
// Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent
|
|
839
|
+
// path traversal. Use realpathSync after mkdir to resolve symlinks.
|
|
840
|
+
if (openclawHomeArg) {
|
|
841
|
+
const resolved = pathResolve(home);
|
|
842
|
+
if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
|
|
843
|
+
throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
|
|
844
|
+
}
|
|
845
|
+
const parentDir = dirname(resolved);
|
|
846
|
+
if (existsSync(parentDir)) {
|
|
847
|
+
const realParent = realpathSync(parentDir);
|
|
848
|
+
if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
|
|
849
|
+
throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) ===
|
|
853
|
+
normalizePath(home));
|
|
854
|
+
if (shared.length) {
|
|
855
|
+
throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared
|
|
856
|
+
.map((i) => i.id)
|
|
857
|
+
.join(", ")}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Orphaned openclaw_home (e.g. instance.json deleted but data remains).
|
|
861
|
+
if (existsSync(home)) {
|
|
862
|
+
try {
|
|
863
|
+
const entries = readdirSync(home);
|
|
864
|
+
if (entries.length > 0) {
|
|
865
|
+
throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. ` +
|
|
866
|
+
`Remove it manually or choose a different path.`);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch (e) {
|
|
870
|
+
if (e.message.includes("not empty"))
|
|
871
|
+
throw e;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
ensureDirContainer(d);
|
|
875
|
+
try {
|
|
876
|
+
const parentGid = statSync(dirname(d)).gid;
|
|
877
|
+
chownSync(d, -1, parentGid);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* non-root without CAP_CHOWN */
|
|
881
|
+
}
|
|
882
|
+
ensureDirContainer(home);
|
|
883
|
+
ensureDirContainer(join(home, OPENCLAW_STATE_DIRNAME));
|
|
884
|
+
const portAlloc = await allocateGatewayPort(instanceId, OPENCLAW_DEFAULT_GATEWAY_PORT);
|
|
885
|
+
const baseRuntime = buildDefaultRuntime(instanceId, portAlloc.port, home);
|
|
886
|
+
let runtime = baseRuntime;
|
|
887
|
+
if (appSpec) {
|
|
888
|
+
const serviceTask = appSpec.tasks.find((t) => t.role === "service");
|
|
889
|
+
if (serviceTask) {
|
|
890
|
+
const compiled = compileTaskRuntime(serviceTask, instanceId);
|
|
891
|
+
runtime = { ...baseRuntime, ...compiled };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const allocatedPort = extractGatewayPort(runtime);
|
|
895
|
+
try {
|
|
896
|
+
const meta = {
|
|
897
|
+
id: instanceId,
|
|
898
|
+
name,
|
|
899
|
+
description,
|
|
900
|
+
agentType: "openclaw",
|
|
901
|
+
openclaw_home: home,
|
|
902
|
+
runtime,
|
|
903
|
+
created_at: new Date().toISOString(),
|
|
904
|
+
// Prefer appSpec.app_id (installed unique id set by routes/apps.ts:88-96)
|
|
905
|
+
// over appSpec.id (which materializeInstalledSpec preserves as the base id).
|
|
906
|
+
// Multi-instance / copied apps need the installed id so uninstall /
|
|
907
|
+
// capability rebuild / skills metadata find the right installed entry.
|
|
908
|
+
...(appSpec ? { app_id: appSpec.app_id ?? appSpec.id } : {}),
|
|
909
|
+
};
|
|
910
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
911
|
+
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
912
|
+
for (const ef of envFiles) {
|
|
913
|
+
if (!existsSync(ef))
|
|
914
|
+
writeConfigFile(ef, "");
|
|
915
|
+
}
|
|
916
|
+
// After writing env files, ensure the runtime user can read them
|
|
917
|
+
try {
|
|
918
|
+
const runtimeUser = runtime.user;
|
|
919
|
+
if (runtimeUser && runtimeUser !== userInfo().username) {
|
|
920
|
+
for (const ef of envFiles) {
|
|
921
|
+
execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
/* ignore - same user or no permission to chown */
|
|
927
|
+
}
|
|
928
|
+
const configPath = openclawConfigPath(instanceId, home);
|
|
929
|
+
ensureDirContainer(dirname(configPath));
|
|
930
|
+
if (cloneFrom && !existsSync(configPath)) {
|
|
931
|
+
const srcConfig = resolveExistingConfigPath(cloneFrom);
|
|
932
|
+
if (existsSync(srcConfig)) {
|
|
933
|
+
try {
|
|
934
|
+
const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
|
|
935
|
+
// Remove proxy provider (will be regenerated with new token)
|
|
936
|
+
const providers = cloned?.models?.providers;
|
|
937
|
+
if (providers) {
|
|
938
|
+
for (const [pid, prov] of Object.entries(providers)) {
|
|
939
|
+
if (typeof prov?.baseUrl === "string" &&
|
|
940
|
+
prov.baseUrl.includes("/proxy/")) {
|
|
941
|
+
delete providers[pid];
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// Remove proxy model reference from agent defaults
|
|
946
|
+
const defaultModel = cloned?.agents?.defaults?.model;
|
|
947
|
+
if (typeof defaultModel === "string" &&
|
|
948
|
+
(defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
|
|
949
|
+
delete cloned.agents.defaults.model;
|
|
950
|
+
}
|
|
951
|
+
// Strip IM channel configs + matching plugin entries
|
|
952
|
+
stripImBindings(cloned);
|
|
953
|
+
// Copy extensions, workspace, (optionally) memory + sessions
|
|
954
|
+
const subdirs = ["extensions", "workspace"];
|
|
955
|
+
if (cloneOptions?.include_memory !== false) {
|
|
956
|
+
const memDir = join(dirname(srcConfig), "memory");
|
|
957
|
+
if (existsSync(memDir))
|
|
958
|
+
subdirs.push("memory");
|
|
959
|
+
}
|
|
960
|
+
if (cloneOptions?.include_sessions) {
|
|
961
|
+
const sessDir = join(dirname(srcConfig), "agents");
|
|
962
|
+
if (existsSync(sessDir))
|
|
963
|
+
subdirs.push("agents");
|
|
964
|
+
}
|
|
965
|
+
for (const subdir of subdirs) {
|
|
966
|
+
const srcDir = join(dirname(srcConfig), subdir);
|
|
967
|
+
const dstDir = join(dirname(configPath), subdir);
|
|
968
|
+
if (existsSync(srcDir) && !existsSync(dstDir)) {
|
|
969
|
+
try {
|
|
970
|
+
cpSync(srcDir, dstDir, { recursive: true });
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
/* best effort */
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
writeConfigFile(configPath, JSON.stringify(cloned, null, 2));
|
|
978
|
+
// Copy x-jishushell upstream metadata from source instance.json
|
|
979
|
+
const srcMetaPath = join(framework_instanceDir(cloneFrom), "instance.json");
|
|
980
|
+
if (existsSync(srcMetaPath)) {
|
|
981
|
+
try {
|
|
982
|
+
const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
|
|
983
|
+
const srcXj = srcMeta?.["x-jishushell"];
|
|
984
|
+
if (srcXj?.proxy?.upstream) {
|
|
985
|
+
const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
|
|
986
|
+
delete dstXj.proxy.upstream.apiKey;
|
|
987
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
988
|
+
if (existsSync(metaPath)) {
|
|
989
|
+
const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
990
|
+
dstMeta["x-jishushell"] = dstXj;
|
|
991
|
+
writeConfigFile(metaPath, JSON.stringify(dstMeta, null, 2));
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
/* ignore metadata copy errors */
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
copyFileSync(srcConfig, configPath);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (!existsSync(configPath)) {
|
|
1006
|
+
writeConfigFile(configPath, JSON.stringify(starterConfig(), null, 2));
|
|
1007
|
+
// Inject default provider API key from setup into both env files
|
|
1008
|
+
const dp = getPanelConfig().default_provider;
|
|
1009
|
+
if (dp?.apiKey && dp?.providerId && envFiles.length) {
|
|
1010
|
+
const envKey = inferProviderApiKeyEnvName(dp.providerId);
|
|
1011
|
+
updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
|
|
1012
|
+
const providerEnv = join(dirname(envFiles[0]), "provider.env");
|
|
1013
|
+
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// Merge AppSpec runtime overlays into openclaw.json (shallow, app wins).
|
|
1017
|
+
// Prefers the plan §17 shape `runtime.defaults`; falls back to the
|
|
1018
|
+
// legacy `openclaw.config_defaults` namespace for pre-§17 specs.
|
|
1019
|
+
const runtimeOverlay = appSpec?.runtime ??
|
|
1020
|
+
appSpec?.openclaw;
|
|
1021
|
+
const overlayDefaults = runtimeOverlay?.defaults
|
|
1022
|
+
?? runtimeOverlay?.config_defaults;
|
|
1023
|
+
if (overlayDefaults && existsSync(configPath)) {
|
|
1024
|
+
try {
|
|
1025
|
+
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1026
|
+
const defaults = overlayDefaults;
|
|
1027
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
1028
|
+
if (typeof value === "object" &&
|
|
1029
|
+
value !== null &&
|
|
1030
|
+
!Array.isArray(value) &&
|
|
1031
|
+
typeof existing[key] === "object" &&
|
|
1032
|
+
existing[key] !== null) {
|
|
1033
|
+
existing[key] = { ...existing[key], ...value };
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
existing[key] = value;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
writeConfigFile(configPath, JSON.stringify(existing, null, 2));
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
/* ignore merge errors */
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Record App-level skills for later installation. Prefers the new
|
|
1046
|
+
// `runtime.skills` namespace; falls back to `openclaw.skills` for
|
|
1047
|
+
// pre-§17 specs.
|
|
1048
|
+
const overlaySkills = Array.isArray(runtimeOverlay?.skills)
|
|
1049
|
+
? runtimeOverlay.skills
|
|
1050
|
+
: null;
|
|
1051
|
+
if (overlaySkills) {
|
|
1052
|
+
try {
|
|
1053
|
+
const skillsDir = join(dirname(configPath), "skills");
|
|
1054
|
+
ensureDirContainer(skillsDir);
|
|
1055
|
+
const skillMeta = join(skillsDir, ".app-skills.json");
|
|
1056
|
+
safeWriteJson(skillMeta, {
|
|
1057
|
+
app_id: appSpec.app_id ?? appSpec.id,
|
|
1058
|
+
skills: overlaySkills,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
/* ignore */
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
// Copy cloned provider.env BEFORE proxy bootstrap
|
|
1066
|
+
if (cloneFrom && envFiles.length) {
|
|
1067
|
+
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
1068
|
+
const srcEnvFile = srcEnvFiles[0];
|
|
1069
|
+
const dstEnvFile = envFiles[0];
|
|
1070
|
+
if (srcEnvFile) {
|
|
1071
|
+
const srcProvider = join(dirname(srcEnvFile), "provider.env");
|
|
1072
|
+
const dstProvider = join(dirname(dstEnvFile), "provider.env");
|
|
1073
|
+
if (existsSync(srcProvider) && !existsSync(dstProvider)) {
|
|
1074
|
+
copyFileSync(srcProvider, dstProvider);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Bootstrap proxy: generate proxy token and write model.env
|
|
1079
|
+
try {
|
|
1080
|
+
await bootstrapInstanceProxy(instanceId);
|
|
1081
|
+
}
|
|
1082
|
+
catch (e) {
|
|
1083
|
+
console.warn(`[openclaw] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
|
|
1084
|
+
}
|
|
1085
|
+
// If running as root, hand ownership of all created files to service user
|
|
1086
|
+
const svcUser = resolveServiceUser();
|
|
1087
|
+
if (svcUser) {
|
|
1088
|
+
try {
|
|
1089
|
+
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], {
|
|
1090
|
+
timeout: 10_000,
|
|
1091
|
+
});
|
|
1092
|
+
if (!home.startsWith(d + "/") && existsSync(home)) {
|
|
1093
|
+
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], {
|
|
1094
|
+
timeout: 10_000,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
catch (e) {
|
|
1099
|
+
console.warn(`[openclaw] chown for ${instanceId} failed:`, e.message);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (portAlloc.skipped.length > 0) {
|
|
1103
|
+
meta.port_allocation = {
|
|
1104
|
+
assigned: portAlloc.port,
|
|
1105
|
+
requested: OPENCLAW_DEFAULT_GATEWAY_PORT,
|
|
1106
|
+
reason: "default_busy",
|
|
1107
|
+
skipped: portAlloc.skipped,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
return meta;
|
|
1111
|
+
}
|
|
1112
|
+
finally {
|
|
1113
|
+
if (allocatedPort)
|
|
1114
|
+
releasePendingPort(allocatedPort);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
async createInitialLayout(ctx) {
|
|
1118
|
+
// Framework never calls this on OpenClaw directly — `createInstance()`
|
|
1119
|
+
// above does the full layout. Kept to satisfy the interface for path
|
|
1120
|
+
// preview / UI callers.
|
|
1121
|
+
const instDir = framework_instanceDir(ctx.instanceId);
|
|
1122
|
+
const agentHome = defaultOpenclawHome(ctx.instanceId);
|
|
1123
|
+
return {
|
|
1124
|
+
instanceDir: instDir,
|
|
1125
|
+
agentHome,
|
|
1126
|
+
primaryConfig: join(agentHome, OPENCLAW_STATE_DIRNAME, OPENCLAW_CONFIG_FILENAME),
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
async buildRuntime(instanceId) {
|
|
1130
|
+
// OpenClaw persists its runtime in legacy snake_case shape
|
|
1131
|
+
// ({env_files, resources, ...}). Translate at read time into the
|
|
1132
|
+
// engine-neutral RuntimeSpec contract.
|
|
1133
|
+
const im = await lazyIm();
|
|
1134
|
+
const raw = im.getInstanceRuntime(instanceId);
|
|
1135
|
+
const home = im.getOpenclawHome(instanceId);
|
|
1136
|
+
return {
|
|
1137
|
+
image: raw?.image ?? getOpenclawDockerImage(),
|
|
1138
|
+
command: String(raw?.command || DEFAULT_COMMAND),
|
|
1139
|
+
args: Array.isArray(raw?.args) ? raw.args.map(String) : [...DEFAULT_ARGS],
|
|
1140
|
+
cwd: String(raw?.cwd || home || DEFAULT_CWD),
|
|
1141
|
+
user: String(raw?.user || DEFAULT_USER),
|
|
1142
|
+
env: { ...DEFAULT_ENV, ...(raw?.env || {}) },
|
|
1143
|
+
envFiles: Array.isArray(raw?.env_files)
|
|
1144
|
+
? raw.env_files
|
|
1145
|
+
: Array.isArray(raw?.envFiles)
|
|
1146
|
+
? raw.envFiles
|
|
1147
|
+
: [],
|
|
1148
|
+
resources: {
|
|
1149
|
+
CPU: Number(raw?.resources?.CPU ?? DEFAULT_RESOURCES.CPU),
|
|
1150
|
+
MemoryMB: Number(raw?.resources?.MemoryMB ?? DEFAULT_RESOURCES.MemoryMB),
|
|
1151
|
+
MemoryMaxMB: raw?.resources?.MemoryMaxMB != null ? Number(raw.resources.MemoryMaxMB) : undefined,
|
|
1152
|
+
},
|
|
1153
|
+
ports: [
|
|
1154
|
+
{
|
|
1155
|
+
name: "gateway",
|
|
1156
|
+
containerPort: im.getGatewayPort(instanceId),
|
|
1157
|
+
hostPort: im.getGatewayPort(instanceId),
|
|
1158
|
+
visibility: "external",
|
|
1159
|
+
},
|
|
1160
|
+
],
|
|
1161
|
+
volumes: [{ hostPath: home, containerPath: home, mode: "rw" }],
|
|
1162
|
+
health: null,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
async buildNomadTask(instanceId) {
|
|
1166
|
+
// Physically moved from nomad-manager.buildTaskDocker. The adapter owns
|
|
1167
|
+
// OpenClaw's Nomad task layout end-to-end so `nomad-manager.buildJob()`
|
|
1168
|
+
// becomes a pure dispatcher.
|
|
1169
|
+
const im = await lazyIm();
|
|
1170
|
+
const rawRuntime = im.getInstanceRuntime(instanceId);
|
|
1171
|
+
const openclawHome = im.getOpenclawHome(instanceId);
|
|
1172
|
+
if (rawRuntime.user && !VALID_USER_RE.test(rawRuntime.user)) {
|
|
1173
|
+
throw new Error(`Invalid runtime user: ${rawRuntime.user}`);
|
|
1174
|
+
}
|
|
1175
|
+
const image = rawRuntime.image || getOpenclawDockerImage();
|
|
1176
|
+
const command = String(rawRuntime.command || DEFAULT_COMMAND);
|
|
1177
|
+
const args = Array.isArray(rawRuntime.args)
|
|
1178
|
+
? rawRuntime.args.map(String)
|
|
1179
|
+
: [...DEFAULT_ARGS];
|
|
1180
|
+
const env = { ...DEFAULT_ENV };
|
|
1181
|
+
Object.assign(env, im.getRuntimeEnv(instanceId));
|
|
1182
|
+
delete env.JSPROXY_API_KEY; // supplied via Nomad Template from Variables
|
|
1183
|
+
env.OPENCLAW_HOME = openclawHome;
|
|
1184
|
+
env.OPENCLAW_INSTANCE_ID = instanceId;
|
|
1185
|
+
// Resource clamping
|
|
1186
|
+
const rawResources = { ...DEFAULT_RESOURCES };
|
|
1187
|
+
for (const [k, v] of Object.entries(rawRuntime.resources || {})) {
|
|
1188
|
+
if (v != null)
|
|
1189
|
+
rawResources[k] = Number(v);
|
|
1190
|
+
}
|
|
1191
|
+
rawResources.CPU = Math.max(1, Math.min(rawResources.CPU, MAX_CPU_MHZ));
|
|
1192
|
+
rawResources.MemoryMB = Math.max(1, Math.min(rawResources.MemoryMB, MAX_MEMORY_MB));
|
|
1193
|
+
// Container env — OpenClaw-specific HOME / NODE_PATH / PATH / npm cfg
|
|
1194
|
+
const containerEnv = { ...env };
|
|
1195
|
+
containerEnv.HOME = openclawHome;
|
|
1196
|
+
if (!containerEnv.OPENCLAW_STATE_DIR) {
|
|
1197
|
+
containerEnv.OPENCLAW_STATE_DIR = `${openclawHome}/.openclaw`;
|
|
1198
|
+
}
|
|
1199
|
+
containerEnv.npm_config_prefix = `${openclawHome}/.npm-global`;
|
|
1200
|
+
containerEnv.PIP_USER = "1";
|
|
1201
|
+
containerEnv.PYTHONUSERBASE = `${openclawHome}/.local`;
|
|
1202
|
+
containerEnv.NODE_ENV = "production";
|
|
1203
|
+
containerEnv.NODE_PATH = [
|
|
1204
|
+
`${openclawHome}/.npm-global/lib/node_modules`,
|
|
1205
|
+
"/app/node_modules",
|
|
1206
|
+
].join(":");
|
|
1207
|
+
containerEnv.PATH = [
|
|
1208
|
+
`${openclawHome}/.npm-global/bin`,
|
|
1209
|
+
`${openclawHome}/.local/bin`,
|
|
1210
|
+
`${openclawHome}/go/bin`,
|
|
1211
|
+
`${openclawHome}/.cargo/bin`,
|
|
1212
|
+
"/usr/local/sbin",
|
|
1213
|
+
"/usr/local/bin",
|
|
1214
|
+
"/usr/sbin",
|
|
1215
|
+
"/usr/bin",
|
|
1216
|
+
"/sbin",
|
|
1217
|
+
"/bin",
|
|
1218
|
+
].join(":");
|
|
1219
|
+
const gatewayPort = im.getGatewayPort(instanceId);
|
|
1220
|
+
const safeJobId = `${this.nomadJobPrefix}${instanceId}`;
|
|
1221
|
+
assertSafeTemplateId(safeJobId);
|
|
1222
|
+
const normalizedResources = normalizeDockerResources(instanceId, rawResources);
|
|
1223
|
+
return {
|
|
1224
|
+
Name: "gateway",
|
|
1225
|
+
Driver: "docker",
|
|
1226
|
+
User: resolveUidGid(String(rawRuntime.user || DEFAULT_USER)),
|
|
1227
|
+
Config: {
|
|
1228
|
+
image,
|
|
1229
|
+
force_pull: false,
|
|
1230
|
+
args,
|
|
1231
|
+
work_dir: openclawHome,
|
|
1232
|
+
volumes: [`${openclawHome}:${openclawHome}:rw`],
|
|
1233
|
+
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
1234
|
+
cap_drop: ["ALL"],
|
|
1235
|
+
security_opt: ["no-new-privileges"],
|
|
1236
|
+
pids_limit: DEFAULT_PIDS_LIMIT,
|
|
1237
|
+
readonly_rootfs: true,
|
|
1238
|
+
mounts: [
|
|
1239
|
+
{ type: "tmpfs", target: "/tmp", tmpfs_options: { size: 536870912 } },
|
|
1240
|
+
{ type: "tmpfs", target: "/var/tmp", tmpfs_options: { size: 67108864 } },
|
|
1241
|
+
{ type: "tmpfs", target: "/run", tmpfs_options: { size: 52428800 } },
|
|
1242
|
+
],
|
|
1243
|
+
},
|
|
1244
|
+
Env: containerEnv,
|
|
1245
|
+
Resources: {
|
|
1246
|
+
...normalizedResources,
|
|
1247
|
+
Networks: [{ ReservedPorts: [{ Label: "gateway", Value: gatewayPort }] }],
|
|
1248
|
+
},
|
|
1249
|
+
LogConfig: { MaxFiles: 3, MaxFileSizeMB: 10 },
|
|
1250
|
+
Templates: [
|
|
1251
|
+
{
|
|
1252
|
+
DestPath: "secrets/instance.env",
|
|
1253
|
+
Envvars: true,
|
|
1254
|
+
EmbeddedTmpl: [
|
|
1255
|
+
`{{ if nomadVarExists "nomad/jobs/${safeJobId}/openclaw/gateway" }}`,
|
|
1256
|
+
`JSPROXY_API_KEY={{ with nomadVar "nomad/jobs/${safeJobId}/openclaw/gateway" }}{{ .JSPROXY_API_KEY }}{{ end }}`,
|
|
1257
|
+
`{{ end }}`,
|
|
1258
|
+
].join("\n"),
|
|
1259
|
+
ChangeMode: "restart",
|
|
1260
|
+
},
|
|
1261
|
+
],
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
async getRuntimeVersion(_instanceId) {
|
|
1265
|
+
// Parse the image reference (digest > tag) for the baseline image.
|
|
1266
|
+
const image = getOpenclawDockerImage() || "";
|
|
1267
|
+
let ref;
|
|
1268
|
+
let digest;
|
|
1269
|
+
if (image) {
|
|
1270
|
+
if (image.includes("@")) {
|
|
1271
|
+
digest = image.split("@", 2)[1];
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
const lastColon = image.lastIndexOf(":");
|
|
1275
|
+
const lastSlash = image.lastIndexOf("/");
|
|
1276
|
+
if (lastColon > lastSlash)
|
|
1277
|
+
ref = image.slice(lastColon + 1);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return { agentType: "openclaw", ref, digest, mode: "baseline" };
|
|
1281
|
+
}
|
|
1282
|
+
async getConfigMeta(instanceId) {
|
|
1283
|
+
return {
|
|
1284
|
+
agentType: "openclaw",
|
|
1285
|
+
format: "json",
|
|
1286
|
+
schemaId: "openclaw/v1",
|
|
1287
|
+
capabilities: DEFAULT_CAPABILITIES,
|
|
1288
|
+
secretFields: ["content.x-jishushell.proxy.upstream.apiKey"],
|
|
1289
|
+
runtimeVersion: await this.getRuntimeVersion(instanceId),
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
async readConfig(instanceId) {
|
|
1293
|
+
const im = await lazyIm();
|
|
1294
|
+
const content = im.getStoredConfig
|
|
1295
|
+
? im.getStoredConfig(instanceId)
|
|
1296
|
+
: null;
|
|
1297
|
+
return {
|
|
1298
|
+
format: "json",
|
|
1299
|
+
content: (content && typeof content === "object") ? content : {},
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
async writeConfig(instanceId, doc) {
|
|
1303
|
+
if (doc.format !== "json") {
|
|
1304
|
+
throw new Error(`OpenClaw config requires format="json", got "${doc.format}"`);
|
|
1305
|
+
}
|
|
1306
|
+
// Route through llmProxy.saveInstanceConfig so x-jishushell.proxy
|
|
1307
|
+
// metadata is stripped, the upstream apiKey is AES-encrypted to
|
|
1308
|
+
// provider.env, and models.providers is rewritten to the local
|
|
1309
|
+
// proxy — same contract HermesAdapter.writeConfig uses. That
|
|
1310
|
+
// function internally calls instanceManager.saveConfig, which
|
|
1311
|
+
// dispatches back to this adapter's saveNativeConfig() for the
|
|
1312
|
+
// raw disk write (distinct method, no recursion).
|
|
1313
|
+
const { saveInstanceConfig } = await import("../../llm-proxy/index.js");
|
|
1314
|
+
await saveInstanceConfig(instanceId, doc.content);
|
|
1315
|
+
return this.readConfig(instanceId);
|
|
1316
|
+
}
|
|
1317
|
+
async buildPairingListCommand(_instanceId) {
|
|
1318
|
+
return ["openclaw", "pairing", "list"];
|
|
1319
|
+
}
|
|
1320
|
+
async buildPairingApproveCommand(_instanceId, input) {
|
|
1321
|
+
return ["openclaw", "pairing", "approve", input.channel, input.code];
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Framework delete hook — returns warnings for the caller to surface to
|
|
1325
|
+
* the user. OpenClaw supports a custom `openclaw_home` pointing outside
|
|
1326
|
+
* the instance directory; those directories are preserved on delete.
|
|
1327
|
+
*/
|
|
1328
|
+
async onDelete(args) {
|
|
1329
|
+
const warnings = [];
|
|
1330
|
+
const home = args.meta?.openclaw_home;
|
|
1331
|
+
if (home) {
|
|
1332
|
+
warnings.push(`Custom openclaw_home "${home}" was NOT removed; delete it manually if unused.`);
|
|
1333
|
+
}
|
|
1334
|
+
return warnings;
|
|
1335
|
+
}
|
|
1336
|
+
// ── Native config I/O (physically migrated from instance-manager) ───
|
|
1337
|
+
channelPluginMap = CHANNEL_PLUGIN_MAP;
|
|
1338
|
+
/**
|
|
1339
|
+
* Get the stored raw OpenClaw config (from `.openclaw/openclaw.json`
|
|
1340
|
+
* or the legacy `openclaw.json`), merged with the x-jishushell
|
|
1341
|
+
* metadata pulled from `instance.json`. Returns null when the instance
|
|
1342
|
+
* has no persisted config yet.
|
|
1343
|
+
*/
|
|
1344
|
+
getNativeConfig(instanceId) {
|
|
1345
|
+
const config = loadEffectiveConfig(instanceId);
|
|
1346
|
+
if (!config)
|
|
1347
|
+
return null;
|
|
1348
|
+
// Always merge the latest upstream proxy config from instance.json
|
|
1349
|
+
const meta = getInstance(instanceId);
|
|
1350
|
+
if (meta?.["x-jishushell"]) {
|
|
1351
|
+
config["x-jishushell"] = meta["x-jishushell"];
|
|
1352
|
+
}
|
|
1353
|
+
// Inject upstream provider apiKey from env file so callers see it
|
|
1354
|
+
return injectProviderApiKeys(instanceId, config);
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Get the stored raw config WITHOUT provider key injection. Used by
|
|
1358
|
+
* callers that round-trip the config back to disk (e.g. credential
|
|
1359
|
+
* writers) — injecting the provider key would re-persist it to the
|
|
1360
|
+
* config file, which is exactly what `saveConfig` takes pains to avoid.
|
|
1361
|
+
*/
|
|
1362
|
+
getStoredNativeConfig(instanceId) {
|
|
1363
|
+
const config = loadEffectiveConfig(instanceId);
|
|
1364
|
+
if (!config)
|
|
1365
|
+
return null;
|
|
1366
|
+
const meta = getInstance(instanceId);
|
|
1367
|
+
if (meta?.["x-jishushell"]) {
|
|
1368
|
+
config["x-jishushell"] = meta["x-jishushell"];
|
|
1369
|
+
}
|
|
1370
|
+
return config;
|
|
1371
|
+
}
|
|
1372
|
+
isChannelPluginInstalled(instanceId, channelId) {
|
|
1373
|
+
return isChannelPluginInstalled(instanceId, channelId);
|
|
1374
|
+
}
|
|
1375
|
+
async installChannelPlugin(instanceId, channelId) {
|
|
1376
|
+
return installChannelPlugin(instanceId, channelId);
|
|
1377
|
+
}
|
|
1378
|
+
saveNativeConfig(instanceId, config) {
|
|
1379
|
+
return saveNativeConfigImpl(instanceId, config);
|
|
1380
|
+
}
|
|
1381
|
+
// ── Path resolvers (physically migrated) ───────────────────────────
|
|
1382
|
+
resolveBin() {
|
|
1383
|
+
return resolveOpenclawBin();
|
|
1384
|
+
}
|
|
1385
|
+
resolveAgentHome(instanceId) {
|
|
1386
|
+
const meta = getInstance(instanceId);
|
|
1387
|
+
return meta?.openclaw_home || defaultOpenclawHome(instanceId);
|
|
1388
|
+
}
|
|
1389
|
+
/** Env vars OpenClaw's CLI needs when backup-manager runs it as a subprocess. */
|
|
1390
|
+
buildCliEnv(instanceId) {
|
|
1391
|
+
return { OPENCLAW_HOME: this.resolveAgentHome(instanceId) };
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Legacy gateway-port fallback for instances created before the
|
|
1395
|
+
* RuntimeSpec.ports[] migration. OpenClaw persists its port in both
|
|
1396
|
+
* `runtime.env.OPENCLAW_GATEWAY_PORT` and `runtime.args ["--port", N]`.
|
|
1397
|
+
*/
|
|
1398
|
+
readLegacyGatewayPort(runtime) {
|
|
1399
|
+
if (!runtime)
|
|
1400
|
+
return null;
|
|
1401
|
+
const envPort = runtime.env?.OPENCLAW_GATEWAY_PORT;
|
|
1402
|
+
if (envPort) {
|
|
1403
|
+
const p = parseInt(envPort, 10);
|
|
1404
|
+
if (!isNaN(p))
|
|
1405
|
+
return p;
|
|
1406
|
+
}
|
|
1407
|
+
const args = runtime.args || [];
|
|
1408
|
+
for (let i = 0; i < args.length; i++) {
|
|
1409
|
+
const arg = String(args[i]);
|
|
1410
|
+
if (arg === "--port" && i + 1 < args.length) {
|
|
1411
|
+
const p = parseInt(args[i + 1], 10);
|
|
1412
|
+
return isNaN(p) ? null : p;
|
|
1413
|
+
}
|
|
1414
|
+
if (arg.startsWith("--port=")) {
|
|
1415
|
+
const p = parseInt(arg.split("=")[1], 10);
|
|
1416
|
+
return isNaN(p) ? null : p;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Rewrite a persisted OpenClaw runtime spec to use a new host port.
|
|
1423
|
+
* Updates `args[--port]` + `env.OPENCLAW_GATEWAY_PORT` atomically so
|
|
1424
|
+
* both places stay in sync.
|
|
1425
|
+
*/
|
|
1426
|
+
reallocateRuntimePort(runtime, newPort) {
|
|
1427
|
+
const args = Array.isArray(runtime.args) ? [...runtime.args] : [];
|
|
1428
|
+
for (let i = 0; i < args.length; i++) {
|
|
1429
|
+
if (args[i] === "--port" && i + 1 < args.length) {
|
|
1430
|
+
args[i + 1] = String(newPort);
|
|
1431
|
+
}
|
|
1432
|
+
else if (typeof args[i] === "string" && args[i].startsWith("--port=")) {
|
|
1433
|
+
args[i] = `--port=${newPort}`;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
runtime.args = args;
|
|
1437
|
+
const env = (runtime.env ?? {});
|
|
1438
|
+
env.OPENCLAW_GATEWAY_PORT = String(newPort);
|
|
1439
|
+
runtime.env = env;
|
|
1440
|
+
}
|
|
1441
|
+
nomadJobPrefix = "openclaw-";
|
|
1442
|
+
// OpenClaw is the default/core runtime — runFullSetup must fail hard if
|
|
1443
|
+
// its image install fails, because the panel is unusable without it.
|
|
1444
|
+
// Third-party adapters should leave `required` unset (= optional).
|
|
1445
|
+
required = true;
|
|
1446
|
+
// Must match the path referenced by this adapter's buildNomadTask Templates
|
|
1447
|
+
// (see `nomadVar "nomad/jobs/<jid>/openclaw/gateway"` in the template body).
|
|
1448
|
+
nomadVariablePath = "openclaw/gateway";
|
|
1449
|
+
resolveConfigPath(instanceId) {
|
|
1450
|
+
return openclawConfigPath(instanceId, this.resolveAgentHome(instanceId));
|
|
1451
|
+
}
|
|
1452
|
+
resolveLegacyConfigPath(instanceId) {
|
|
1453
|
+
return legacyOpenclawConfigPath(instanceId, this.resolveAgentHome(instanceId));
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Return other OpenClaw instance IDs that share this one's
|
|
1457
|
+
* `openclaw_home` path. Used by the start-time conflict check.
|
|
1458
|
+
*/
|
|
1459
|
+
findInstancesSharingHome(instanceId) {
|
|
1460
|
+
const target = normalizePath(this.resolveAgentHome(instanceId));
|
|
1461
|
+
return listInstances()
|
|
1462
|
+
.filter((inst) => inst.id !== instanceId)
|
|
1463
|
+
.filter((inst) => {
|
|
1464
|
+
const meta = inst;
|
|
1465
|
+
const otherHome = meta.openclaw_home || defaultOpenclawHome(inst.id);
|
|
1466
|
+
return normalizePath(otherHome) === target;
|
|
1467
|
+
})
|
|
1468
|
+
.map((inst) => inst.id);
|
|
1469
|
+
}
|
|
1470
|
+
// ── Feishu / WeChat credential writers (physically migrated) ───────
|
|
1471
|
+
saveFeishuCredentials(instanceId, creds) {
|
|
1472
|
+
if (!FEISHU_APP_ID_RE.test(creds.appId)) {
|
|
1473
|
+
throw new Error(`Invalid Feishu appId format: expected cli_<alnum> (got "${creds.appId}")`);
|
|
1474
|
+
}
|
|
1475
|
+
if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
|
|
1476
|
+
throw new Error("Invalid Feishu appSecret: must be a non-empty string");
|
|
1477
|
+
}
|
|
1478
|
+
const configPath = this.resolveConfigPath(instanceId);
|
|
1479
|
+
let config = {};
|
|
1480
|
+
try {
|
|
1481
|
+
if (existsSync(configPath)) {
|
|
1482
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
/* best effort */
|
|
1487
|
+
}
|
|
1488
|
+
config.plugins ??= {};
|
|
1489
|
+
config.plugins.entries ??= {};
|
|
1490
|
+
config.plugins.entries.feishu = { enabled: false };
|
|
1491
|
+
config.plugins.entries["openclaw-lark"] = { enabled: true };
|
|
1492
|
+
config.channels ??= {};
|
|
1493
|
+
config.channels.feishu = {
|
|
1494
|
+
...config.channels.feishu,
|
|
1495
|
+
enabled: true,
|
|
1496
|
+
appId: creds.appId,
|
|
1497
|
+
appSecret: creds.appSecret,
|
|
1498
|
+
domain: creds.domain,
|
|
1499
|
+
dmPolicy: "open",
|
|
1500
|
+
allowFrom: ["*"],
|
|
1501
|
+
};
|
|
1502
|
+
safeWriteJson(configPath, config);
|
|
1503
|
+
chownToServiceUser(configPath);
|
|
1504
|
+
console.log(`[openclaw] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
|
|
1505
|
+
}
|
|
1506
|
+
saveWeixinCredentials(instanceId, creds) {
|
|
1507
|
+
if (!creds.accountId || !SAFE_ACCOUNT_ID_RE.test(creds.accountId) || creds.accountId.includes("..")) {
|
|
1508
|
+
throw new Error(`Invalid accountId: must be 1-128 chars of [a-zA-Z0-9@._-] without '..'`);
|
|
1509
|
+
}
|
|
1510
|
+
const home = this.resolveAgentHome(instanceId);
|
|
1511
|
+
const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
|
|
1512
|
+
const accountsDir = join(stateDir, "accounts");
|
|
1513
|
+
ensureDirContainer(accountsDir);
|
|
1514
|
+
const credObj = {
|
|
1515
|
+
token: creds.token,
|
|
1516
|
+
baseUrl: creds.baseUrl,
|
|
1517
|
+
userId: creds.userId,
|
|
1518
|
+
savedAt: new Date().toISOString(),
|
|
1519
|
+
};
|
|
1520
|
+
safeWriteJson(join(accountsDir, `${creds.accountId}.json`), credObj);
|
|
1521
|
+
safeWriteJson(join(accountsDir, "default.json"), credObj);
|
|
1522
|
+
chownToServiceUser(join(accountsDir, `${creds.accountId}.json`), join(accountsDir, "default.json"));
|
|
1523
|
+
// Update accounts.json index
|
|
1524
|
+
const indexPath = join(stateDir, "accounts.json");
|
|
1525
|
+
let index = [];
|
|
1526
|
+
try {
|
|
1527
|
+
const raw = readFileSync(indexPath, "utf-8");
|
|
1528
|
+
index = JSON.parse(raw);
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
/* start fresh */
|
|
1532
|
+
}
|
|
1533
|
+
if (!Array.isArray(index))
|
|
1534
|
+
index = [];
|
|
1535
|
+
if (!index.includes(creds.accountId))
|
|
1536
|
+
index.push(creds.accountId);
|
|
1537
|
+
safeWriteJson(indexPath, index);
|
|
1538
|
+
const configPath = this.resolveConfigPath(instanceId);
|
|
1539
|
+
let config = {};
|
|
1540
|
+
try {
|
|
1541
|
+
if (existsSync(configPath))
|
|
1542
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1543
|
+
}
|
|
1544
|
+
catch {
|
|
1545
|
+
/* best effort */
|
|
1546
|
+
}
|
|
1547
|
+
config.plugins ??= {};
|
|
1548
|
+
config.plugins.entries ??= {};
|
|
1549
|
+
config.plugins.entries["openclaw-weixin"] ??= {};
|
|
1550
|
+
config.plugins.entries["openclaw-weixin"].enabled = true;
|
|
1551
|
+
config.channels ??= {};
|
|
1552
|
+
config.channels["openclaw-weixin"] ??= {};
|
|
1553
|
+
config.channels["openclaw-weixin"].enabled = true;
|
|
1554
|
+
const normalizedId = creds.accountId.replace(/[@.]/g, "-");
|
|
1555
|
+
const accounts = config.channels["openclaw-weixin"].accounts ??= {};
|
|
1556
|
+
accounts[creds.accountId] = { enabled: true };
|
|
1557
|
+
if (normalizedId !== creds.accountId)
|
|
1558
|
+
accounts[normalizedId] = { enabled: true };
|
|
1559
|
+
accounts["default"] = { enabled: true };
|
|
1560
|
+
if (!config.channels["openclaw-weixin"].defaultAccount) {
|
|
1561
|
+
config.channels["openclaw-weixin"].defaultAccount = "default";
|
|
1562
|
+
}
|
|
1563
|
+
safeWriteJson(configPath, config);
|
|
1564
|
+
chownToServiceUser(configPath);
|
|
1565
|
+
console.log(`[openclaw] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
|
|
1566
|
+
}
|
|
1567
|
+
getWeixinAccounts(instanceId) {
|
|
1568
|
+
const home = this.resolveAgentHome(instanceId);
|
|
1569
|
+
const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
|
|
1570
|
+
const accountsDir = join(stateDir, "accounts");
|
|
1571
|
+
if (!existsSync(accountsDir))
|
|
1572
|
+
return [];
|
|
1573
|
+
let indexedIds = [];
|
|
1574
|
+
try {
|
|
1575
|
+
indexedIds = JSON.parse(readFileSync(join(stateDir, "accounts.json"), "utf-8"));
|
|
1576
|
+
}
|
|
1577
|
+
catch {
|
|
1578
|
+
/* fallback to scanning */
|
|
1579
|
+
}
|
|
1580
|
+
const results = [];
|
|
1581
|
+
for (const f of readdirSync(accountsDir)) {
|
|
1582
|
+
if (!f.endsWith(".json"))
|
|
1583
|
+
continue;
|
|
1584
|
+
const id = f.replace(/\.json$/, "");
|
|
1585
|
+
if (indexedIds.length > 0 && !indexedIds.includes(id))
|
|
1586
|
+
continue;
|
|
1587
|
+
if (id === "default")
|
|
1588
|
+
continue;
|
|
1589
|
+
try {
|
|
1590
|
+
const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
|
|
1591
|
+
results.push({
|
|
1592
|
+
accountId: id,
|
|
1593
|
+
userId: data.userId,
|
|
1594
|
+
savedAt: data.savedAt,
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
catch {
|
|
1598
|
+
/* skip */
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return results;
|
|
1602
|
+
}
|
|
1603
|
+
// ── §32.2.4 runtime install (physically migrated from setup-manager) ──
|
|
1604
|
+
//
|
|
1605
|
+
// Installs the OpenClaw npm package via `npm install -g --prefix` so that
|
|
1606
|
+
// postinstall scripts run naturally. Emits SSE progress through the task
|
|
1607
|
+
// machinery exported by setup-manager.
|
|
1608
|
+
async installRuntime(opts) {
|
|
1609
|
+
const version = opts?.version ?? "latest";
|
|
1610
|
+
try {
|
|
1611
|
+
const openclawPkgDir = join(OPENCLAW_MODULES, "openclaw");
|
|
1612
|
+
if (existsSync(openclawPkgDir)) {
|
|
1613
|
+
const ver = readInstalledOpenclawVersion() || "unknown";
|
|
1614
|
+
return { ok: true, message: `OpenClaw already installed: ${ver}` };
|
|
1615
|
+
}
|
|
1616
|
+
const task = createTask("openclaw");
|
|
1617
|
+
ensureDirHost(OPENCLAW_PKG_DIR);
|
|
1618
|
+
emitTask(task, { type: "progress", message: "开始安装 OpenClaw...", progress: 0 });
|
|
1619
|
+
const sizeTracker = setInterval(() => {
|
|
1620
|
+
const sizeMB = getDirSizeMB(OPENCLAW_PKG_DIR);
|
|
1621
|
+
const pct = Math.min(95, Math.round((sizeMB / OPENCLAW_EXPECTED_SIZE_MB) * 95));
|
|
1622
|
+
if (pct > 0) {
|
|
1623
|
+
emitTask(task, {
|
|
1624
|
+
type: "progress",
|
|
1625
|
+
message: `下载安装中... ${sizeMB}MB / ~${OPENCLAW_EXPECTED_SIZE_MB}MB`,
|
|
1626
|
+
progress: pct,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}, 3000);
|
|
1630
|
+
const result = await spawnWithTask(task, "npm", ["install", "-g", "--prefix", OPENCLAW_PKG_DIR, `openclaw@${version}`], { timeout: 600000, progressParser: npmProgressParser });
|
|
1631
|
+
clearInterval(sizeTracker);
|
|
1632
|
+
if (!result.ok) {
|
|
1633
|
+
emitTask(task, { type: "error", message: "OpenClaw 安装失败" });
|
|
1634
|
+
task.status = "error";
|
|
1635
|
+
return {
|
|
1636
|
+
ok: false,
|
|
1637
|
+
message: "OpenClaw installation failed",
|
|
1638
|
+
error: result.output,
|
|
1639
|
+
taskId: task.id,
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
const ver = readInstalledOpenclawVersion() || "installed";
|
|
1643
|
+
emitTask(task, { type: "done", message: `OpenClaw 安装完成: ${ver}`, progress: 100 });
|
|
1644
|
+
task.status = "done";
|
|
1645
|
+
return { ok: true, message: `OpenClaw installed: ${ver}`, taskId: task.id };
|
|
1646
|
+
}
|
|
1647
|
+
catch (e) {
|
|
1648
|
+
return { ok: false, message: "OpenClaw installation failed", error: e.message };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Build / pull the OpenClaw Docker image. Blocking — useful for CLI
|
|
1653
|
+
* installer and `runFullSetup()`. Non-blocking variant below is used by
|
|
1654
|
+
* the REST API and the adapter's own `onBeforeStart` self-heal path.
|
|
1655
|
+
*/
|
|
1656
|
+
async buildRuntimeImage(opts) {
|
|
1657
|
+
const task = createTask("openclaw-docker-pull");
|
|
1658
|
+
return pullOrBuildOpenclawImageWithTask(task, opts?.tag);
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Non-blocking variant of `buildRuntimeImage` — returns a task id
|
|
1662
|
+
* immediately so the caller can poll SSE progress.
|
|
1663
|
+
*/
|
|
1664
|
+
startBuildRuntimeImage(opts) {
|
|
1665
|
+
const task = createTask("openclaw-docker-pull");
|
|
1666
|
+
void pullOrBuildOpenclawImageWithTask(task, opts?.tag).catch((err) => {
|
|
1667
|
+
emitTask(task, {
|
|
1668
|
+
type: "error",
|
|
1669
|
+
message: `镜像获取失败: ${err?.message || err}`,
|
|
1670
|
+
});
|
|
1671
|
+
task.status = "error";
|
|
1672
|
+
});
|
|
1673
|
+
return { ok: true, message: "Docker image pull started", taskId: task.id };
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* §32.2.5 — register OpenClaw-specific HTTP endpoints. Called from
|
|
1677
|
+
* `routes/instances.ts` at server startup via the generic adapter
|
|
1678
|
+
* dispatch loop.
|
|
1679
|
+
*/
|
|
1680
|
+
registerRoutes(app) {
|
|
1681
|
+
registerOpenclawRoutes(app);
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Return OpenClaw's readiness for spawning instances. Docker image
|
|
1685
|
+
* presence is the gating factor in the default docker-mode deployment;
|
|
1686
|
+
* the legacy `npm install` path is still reported as `installed` but is
|
|
1687
|
+
* not required for the docker-driver stack.
|
|
1688
|
+
*/
|
|
1689
|
+
getInstallStatus() {
|
|
1690
|
+
// OPENCLAW_MODULES may be undefined when config is partially mocked in tests.
|
|
1691
|
+
const pkgInstalled = OPENCLAW_MODULES ? existsSync(join(OPENCLAW_MODULES, "openclaw")) : false;
|
|
1692
|
+
const version = pkgInstalled ? readInstalledOpenclawVersion() : undefined;
|
|
1693
|
+
const imageTag = getOpenclawDockerImage?.() ?? "";
|
|
1694
|
+
let imageReady = false;
|
|
1695
|
+
try {
|
|
1696
|
+
const invocation = resolveDockerInvocation();
|
|
1697
|
+
if (invocation?.cmd) {
|
|
1698
|
+
execFileSync(invocation.cmd, [...invocation.argsPrefix, "image", "inspect", imageTag], {
|
|
1699
|
+
timeout: 5000,
|
|
1700
|
+
stdio: "ignore",
|
|
1701
|
+
});
|
|
1702
|
+
imageReady = true;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
catch { /* image absent */ }
|
|
1706
|
+
return {
|
|
1707
|
+
installed: imageReady || pkgInstalled,
|
|
1708
|
+
imageReady,
|
|
1709
|
+
version: version || undefined,
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
// ── Install-time helpers (used by installRuntime) ─────────────────────
|
|
1714
|
+
const OPENCLAW_EXPECTED_SIZE_MB = 700;
|
|
1715
|
+
function readInstalledOpenclawVersion() {
|
|
1716
|
+
try {
|
|
1717
|
+
const pkg = join(OPENCLAW_MODULES, "openclaw", "package.json");
|
|
1718
|
+
if (existsSync(pkg))
|
|
1719
|
+
return JSON.parse(readFileSync(pkg, "utf-8")).version || "";
|
|
1720
|
+
}
|
|
1721
|
+
catch {
|
|
1722
|
+
/* best effort */
|
|
1723
|
+
}
|
|
1724
|
+
return "";
|
|
1725
|
+
}
|
|
1726
|
+
// Feishu app IDs issued by the open platform follow `cli_<hex/alnum>`.
|
|
1727
|
+
const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
|
|
1728
|
+
const SAFE_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
|
|
1729
|
+
// ── Channel plugin constants (physically migrated) ───────────────────
|
|
1730
|
+
const CHANNEL_EXT_DIR_ALIAS = {
|
|
1731
|
+
feishu: "openclaw-lark",
|
|
1732
|
+
lark: "openclaw-lark",
|
|
1733
|
+
};
|
|
1734
|
+
const CHANNEL_PLUGIN_MAP = {
|
|
1735
|
+
feishu: "@larksuite/openclaw-lark",
|
|
1736
|
+
lark: "@larksuite/openclaw-lark",
|
|
1737
|
+
telegram: "@openclaw/telegram",
|
|
1738
|
+
discord: "@openclaw/discord",
|
|
1739
|
+
slack: "@openclaw/slack",
|
|
1740
|
+
whatsapp: "@openclaw/whatsapp",
|
|
1741
|
+
signal: "@openclaw/signal",
|
|
1742
|
+
line: "@openclaw/line",
|
|
1743
|
+
msteams: "@openclaw/msteams",
|
|
1744
|
+
"openclaw-weixin": "@tencent-weixin/openclaw-weixin",
|
|
1745
|
+
};
|
|
1746
|
+
const IM_PLUGIN_ENTRY_IDS = new Set([
|
|
1747
|
+
...Object.keys(CHANNEL_PLUGIN_MAP),
|
|
1748
|
+
...Object.values(CHANNEL_EXT_DIR_ALIAS),
|
|
1749
|
+
]);
|
|
1750
|
+
// ── Config I/O helpers (physically migrated) ─────────────────────────
|
|
1751
|
+
function hasConfiguredValue(value) {
|
|
1752
|
+
if (typeof value !== "string")
|
|
1753
|
+
return !!value;
|
|
1754
|
+
return value.trim().length > 0;
|
|
1755
|
+
}
|
|
1756
|
+
function loadJsonSafe(path) {
|
|
1757
|
+
try {
|
|
1758
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
1759
|
+
}
|
|
1760
|
+
catch (e) {
|
|
1761
|
+
console.warn(`[openclaw] Failed to parse ${path}: ${e.message}`);
|
|
1762
|
+
return null;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
function deepMergeConfig(base, overlay) {
|
|
1766
|
+
if (typeof base !== "object" || base === null ||
|
|
1767
|
+
typeof overlay !== "object" || overlay === null ||
|
|
1768
|
+
Array.isArray(base) || Array.isArray(overlay)) {
|
|
1769
|
+
return structuredClone(overlay);
|
|
1770
|
+
}
|
|
1771
|
+
const merged = structuredClone(base);
|
|
1772
|
+
for (const key of Object.keys(overlay)) {
|
|
1773
|
+
merged[key] = key in merged ? deepMergeConfig(merged[key], overlay[key]) : structuredClone(overlay[key]);
|
|
1774
|
+
}
|
|
1775
|
+
return merged;
|
|
1776
|
+
}
|
|
1777
|
+
function loadEffectiveConfig(instanceId) {
|
|
1778
|
+
const runtimePath = openclawConfigPath(instanceId);
|
|
1779
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1780
|
+
const rExists = existsSync(runtimePath);
|
|
1781
|
+
const lExists = existsSync(legacyPath);
|
|
1782
|
+
if (rExists && lExists) {
|
|
1783
|
+
const legacy = loadJsonSafe(legacyPath);
|
|
1784
|
+
const runtime = loadJsonSafe(runtimePath);
|
|
1785
|
+
if (legacy && runtime)
|
|
1786
|
+
return deepMergeConfig(legacy, runtime);
|
|
1787
|
+
return runtime || legacy || null;
|
|
1788
|
+
}
|
|
1789
|
+
if (rExists)
|
|
1790
|
+
return loadJsonSafe(runtimePath);
|
|
1791
|
+
if (lExists)
|
|
1792
|
+
return loadJsonSafe(legacyPath);
|
|
1793
|
+
return null;
|
|
1794
|
+
}
|
|
1795
|
+
function injectProviderApiKeys(instanceId, config) {
|
|
1796
|
+
const merged = structuredClone(config);
|
|
1797
|
+
const runtimeEnv = getRuntimeEnv(instanceId);
|
|
1798
|
+
const providers = merged.models?.providers || {};
|
|
1799
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
1800
|
+
if (typeof provider !== "object" || provider === null)
|
|
1801
|
+
continue;
|
|
1802
|
+
const p = provider;
|
|
1803
|
+
const api = p.api;
|
|
1804
|
+
if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
|
|
1805
|
+
p.api = LEGACY_PROVIDER_API_ALIASES[api];
|
|
1806
|
+
}
|
|
1807
|
+
const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
|
|
1808
|
+
if (apiKey)
|
|
1809
|
+
p.apiKey = apiKey;
|
|
1810
|
+
}
|
|
1811
|
+
return merged;
|
|
1812
|
+
}
|
|
1813
|
+
function applyFeishuDebugAccessDefaults(channel) {
|
|
1814
|
+
if (channel.enabled === false)
|
|
1815
|
+
return;
|
|
1816
|
+
if (!hasConfiguredValue(channel.appId))
|
|
1817
|
+
return;
|
|
1818
|
+
if (!hasConfiguredValue(channel.appSecret))
|
|
1819
|
+
return;
|
|
1820
|
+
let dmPolicy = channel.dmPolicy;
|
|
1821
|
+
if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
|
|
1822
|
+
channel.dmPolicy = "open";
|
|
1823
|
+
dmPolicy = "open";
|
|
1824
|
+
}
|
|
1825
|
+
if (dmPolicy !== "open")
|
|
1826
|
+
return;
|
|
1827
|
+
if (!("resolveSenderNames" in channel))
|
|
1828
|
+
channel.resolveSenderNames = false;
|
|
1829
|
+
let accounts = channel.accounts;
|
|
1830
|
+
if (typeof accounts !== "object" || accounts === null) {
|
|
1831
|
+
accounts = {};
|
|
1832
|
+
channel.accounts = accounts;
|
|
1833
|
+
}
|
|
1834
|
+
let defaultAccount = accounts.default;
|
|
1835
|
+
if (typeof defaultAccount !== "object" || defaultAccount === null) {
|
|
1836
|
+
defaultAccount = {};
|
|
1837
|
+
accounts.default = defaultAccount;
|
|
1838
|
+
}
|
|
1839
|
+
if (!("resolveSenderNames" in defaultAccount))
|
|
1840
|
+
defaultAccount.resolveSenderNames = false;
|
|
1841
|
+
const allowFrom = channel.allowFrom;
|
|
1842
|
+
if (Array.isArray(allowFrom)) {
|
|
1843
|
+
const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
|
|
1844
|
+
if (!normalized.includes("*"))
|
|
1845
|
+
normalized.push("*");
|
|
1846
|
+
channel.allowFrom = normalized;
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
channel.allowFrom = ["*"];
|
|
1850
|
+
}
|
|
1851
|
+
function prepareConfigForSave(instanceId, config) {
|
|
1852
|
+
const configToWrite = structuredClone(config);
|
|
1853
|
+
delete configToWrite["x-jishushell"];
|
|
1854
|
+
const envUpdates = {};
|
|
1855
|
+
const providers = configToWrite.models?.providers || {};
|
|
1856
|
+
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
1857
|
+
const channels = configToWrite.channels || {};
|
|
1858
|
+
const plugins = configToWrite.plugins ??= {};
|
|
1859
|
+
const pluginEntries = plugins.entries ??= {};
|
|
1860
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
1861
|
+
if (typeof provider !== "object" || provider === null)
|
|
1862
|
+
continue;
|
|
1863
|
+
const p = provider;
|
|
1864
|
+
if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
|
|
1865
|
+
p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
|
|
1866
|
+
}
|
|
1867
|
+
if (!("apiKey" in p))
|
|
1868
|
+
continue;
|
|
1869
|
+
if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
|
|
1870
|
+
continue;
|
|
1871
|
+
const apiKey = p.apiKey;
|
|
1872
|
+
delete p.apiKey;
|
|
1873
|
+
if (envFiles.length) {
|
|
1874
|
+
envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
|
|
1875
|
+
}
|
|
1876
|
+
else {
|
|
1877
|
+
p.apiKey = apiKey;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
for (const [channelId, channel] of Object.entries(channels)) {
|
|
1881
|
+
if (typeof channel !== "object" || channel === null)
|
|
1882
|
+
continue;
|
|
1883
|
+
const ch = channel;
|
|
1884
|
+
if (channelId === "feishu" || channelId === "lark")
|
|
1885
|
+
applyFeishuDebugAccessDefaults(ch);
|
|
1886
|
+
let pluginEntry = pluginEntries[channelId];
|
|
1887
|
+
if (pluginEntry == null) {
|
|
1888
|
+
pluginEntry = {};
|
|
1889
|
+
pluginEntries[channelId] = pluginEntry;
|
|
1890
|
+
}
|
|
1891
|
+
if (typeof pluginEntry === "object") {
|
|
1892
|
+
pluginEntry.enabled = ch.enabled !== false;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return [configToWrite, envUpdates];
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Dissociate a cloned/imported config from its source instance's IM bindings.
|
|
1899
|
+
* Physically migrated from `instance-manager.stripImBindings` so framework
|
|
1900
|
+
* code no longer references OpenClaw channel concepts.
|
|
1901
|
+
*
|
|
1902
|
+
* Exported so `OpenClawAdapter.createInstance`'s clone path + `backup-manager`
|
|
1903
|
+
* import paths can use it without depending on instance-manager.
|
|
1904
|
+
*/
|
|
1905
|
+
export function stripImBindings(config) {
|
|
1906
|
+
if (config?.channels)
|
|
1907
|
+
delete config.channels;
|
|
1908
|
+
const entries = config?.plugins?.entries;
|
|
1909
|
+
if (entries && typeof entries === "object") {
|
|
1910
|
+
for (const key of Object.keys(entries)) {
|
|
1911
|
+
if (IM_PLUGIN_ENTRY_IDS.has(key))
|
|
1912
|
+
delete entries[key];
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
function getChannelExtensionsDir(instanceId) {
|
|
1917
|
+
const home = getInstance(instanceId)?.openclaw_home ||
|
|
1918
|
+
defaultOpenclawHome(instanceId);
|
|
1919
|
+
return join(home, OPENCLAW_STATE_DIRNAME, "extensions");
|
|
1920
|
+
}
|
|
1921
|
+
function getStockExtensionsDir() {
|
|
1922
|
+
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
1923
|
+
}
|
|
1924
|
+
function isChannelPluginInstalled(instanceId, channelId) {
|
|
1925
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
1926
|
+
const stockExtDir = getStockExtensionsDir();
|
|
1927
|
+
return (existsSync(join(getChannelExtensionsDir(instanceId), extDirName)) ||
|
|
1928
|
+
existsSync(join(stockExtDir, extDirName)) ||
|
|
1929
|
+
(extDirName !== channelId && existsSync(join(stockExtDir, channelId))));
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Install a single channel plugin. Docker mode → `docker exec` inside the
|
|
1933
|
+
* running container. Host mode → spawn the host openclaw binary directly.
|
|
1934
|
+
* Physically migrated from `instance-manager.installChannelPlugin`.
|
|
1935
|
+
*/
|
|
1936
|
+
async function installChannelPlugin(instanceId, channelId) {
|
|
1937
|
+
const pkg = CHANNEL_PLUGIN_MAP[channelId];
|
|
1938
|
+
if (!pkg)
|
|
1939
|
+
throw new Error(`Unknown channel: ${channelId}`);
|
|
1940
|
+
if (isChannelPluginInstalled(instanceId, channelId))
|
|
1941
|
+
return;
|
|
1942
|
+
const home = getInstance(instanceId)?.openclaw_home ||
|
|
1943
|
+
defaultOpenclawHome(instanceId);
|
|
1944
|
+
const extensionsDir = getChannelExtensionsDir(instanceId);
|
|
1945
|
+
if (getNomadDriver() === "docker") {
|
|
1946
|
+
await installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const openclawBin = resolveOpenclawBin();
|
|
1950
|
+
const nodeBinDir = dirname(process.execPath);
|
|
1951
|
+
const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
|
|
1952
|
+
const proxyEnvKeys = [
|
|
1953
|
+
"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
|
|
1954
|
+
"no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
|
|
1955
|
+
];
|
|
1956
|
+
const proxyEnv = {};
|
|
1957
|
+
for (const key of proxyEnvKeys) {
|
|
1958
|
+
if (process.env[key])
|
|
1959
|
+
proxyEnv[key] = process.env[key];
|
|
1960
|
+
}
|
|
1961
|
+
const childEnv = {
|
|
1962
|
+
PATH: childPath,
|
|
1963
|
+
HOME: process.env.HOME,
|
|
1964
|
+
LANG: process.env.LANG,
|
|
1965
|
+
OPENCLAW_HOME: home,
|
|
1966
|
+
...proxyEnv,
|
|
1967
|
+
};
|
|
1968
|
+
const MAX_ATTEMPTS = 3;
|
|
1969
|
+
const RETRY_DELAY_MS = 5_000;
|
|
1970
|
+
const attemptInstall = () => new Promise((resolve, reject) => {
|
|
1971
|
+
execFile(openclawBin, ["plugins", "install", pkg], { cwd: home, env: childEnv, timeout: 300_000 }, (err, stdout, stderr) => {
|
|
1972
|
+
if (err && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
1973
|
+
const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
|
|
1974
|
+
console.error(`[plugins] ${pkg} exit code ${err.code ?? "?"}, stderr: ${stderr?.trim() || "(empty)"}, stdout: ${stdout?.trim() || "(empty)"}`);
|
|
1975
|
+
try {
|
|
1976
|
+
if (existsSync(extensionsDir)) {
|
|
1977
|
+
for (const entry of readdirSync(extensionsDir)) {
|
|
1978
|
+
if (entry.startsWith(".openclaw-install-stage-")) {
|
|
1979
|
+
rmSync(join(extensionsDir, entry), { recursive: true, force: true });
|
|
1980
|
+
console.log(`[plugins] Cleaned up stage dir: ${entry}`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
catch {
|
|
1986
|
+
/* ignore */
|
|
1987
|
+
}
|
|
1988
|
+
reject(new Error(msg));
|
|
1989
|
+
}
|
|
1990
|
+
else {
|
|
1991
|
+
if (err)
|
|
1992
|
+
console.log(`[plugins] ${pkg} installed (ignored non-zero exit)`);
|
|
1993
|
+
else
|
|
1994
|
+
console.log(`[plugins] ${pkg} installed`);
|
|
1995
|
+
resolve();
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
});
|
|
1999
|
+
console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
|
|
2000
|
+
let lastErr;
|
|
2001
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
2002
|
+
try {
|
|
2003
|
+
await attemptInstall();
|
|
2004
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
2005
|
+
const installedExtDir = join(extensionsDir, extDirName);
|
|
2006
|
+
if (existsSync(installedExtDir)) {
|
|
2007
|
+
ensureDirContainer(installedExtDir);
|
|
2008
|
+
try {
|
|
2009
|
+
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
2010
|
+
if (entry.isDirectory()) {
|
|
2011
|
+
ensureDirContainer(join(installedExtDir, entry.name));
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
catch {
|
|
2016
|
+
/* best effort */
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
ensureDirContainer(extensionsDir);
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
catch (err) {
|
|
2023
|
+
lastErr = err;
|
|
2024
|
+
const isFetchError = /fetch failed/i.test(err.message ?? "");
|
|
2025
|
+
if (isFetchError && attempt < MAX_ATTEMPTS) {
|
|
2026
|
+
console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
2027
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
console.error(`[plugins] Failed to install ${pkg}:`, err.message);
|
|
2031
|
+
break;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
throw lastErr;
|
|
2035
|
+
}
|
|
2036
|
+
async function installChannelPluginViaDocker(instanceId, channelId, pkg, extensionsDir) {
|
|
2037
|
+
const { exec } = await import("../../nomad-manager.js");
|
|
2038
|
+
const MAX_ATTEMPTS = 3;
|
|
2039
|
+
const RETRY_DELAY_MS = 5_000;
|
|
2040
|
+
console.log(`[plugins] Installing ${pkg} for ${channelId} via docker exec (instance: ${instanceId})...`);
|
|
2041
|
+
let lastErr;
|
|
2042
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
2043
|
+
try {
|
|
2044
|
+
const result = await exec(instanceId, ["openclaw", "plugins", "install", pkg], 300_000);
|
|
2045
|
+
if (result.exitCode !== 0 && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
2046
|
+
const msg = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join(" | ");
|
|
2047
|
+
console.error(`[plugins] ${pkg} docker exec exit code ${result.exitCode}, output: ${msg}`);
|
|
2048
|
+
throw new Error(msg || `openclaw plugins install exited with code ${result.exitCode}`);
|
|
2049
|
+
}
|
|
2050
|
+
if (result.exitCode !== 0) {
|
|
2051
|
+
console.log(`[plugins] ${pkg} installed via docker (ignored non-zero exit)`);
|
|
2052
|
+
}
|
|
2053
|
+
else {
|
|
2054
|
+
console.log(`[plugins] ${pkg} installed via docker`);
|
|
2055
|
+
}
|
|
2056
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
2057
|
+
const installedExtDir = join(extensionsDir, extDirName);
|
|
2058
|
+
if (existsSync(installedExtDir)) {
|
|
2059
|
+
ensureDirContainer(installedExtDir);
|
|
2060
|
+
try {
|
|
2061
|
+
for (const entry of readdirSync(installedExtDir, { withFileTypes: true })) {
|
|
2062
|
+
if (entry.isDirectory()) {
|
|
2063
|
+
ensureDirContainer(join(installedExtDir, entry.name));
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
catch {
|
|
2068
|
+
/* best effort */
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
ensureDirContainer(extensionsDir);
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
catch (err) {
|
|
2075
|
+
lastErr = err;
|
|
2076
|
+
if (/not running/i.test(err.message ?? "")) {
|
|
2077
|
+
throw new Error("请先启动实例后再安装插件(Docker 模式下插件需在容器内安装)");
|
|
2078
|
+
}
|
|
2079
|
+
const isTransient = /fetch failed|ECONNREFUSED/i.test(err.message ?? "");
|
|
2080
|
+
if (isTransient && attempt < MAX_ATTEMPTS) {
|
|
2081
|
+
console.warn(`[plugins] ${pkg} docker install attempt ${attempt}/${MAX_ATTEMPTS} failed, retrying...`);
|
|
2082
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
console.error(`[plugins] Failed to install ${pkg} via docker:`, err.message);
|
|
2086
|
+
break;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
throw lastErr;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Full saveConfig implementation. Writes `.openclaw/openclaw.json`,
|
|
2093
|
+
* mirrors into legacy `openclaw.json` path, updates env files with
|
|
2094
|
+
* provider API keys, preserves backend-managed fields, and fires
|
|
2095
|
+
* config-change listeners.
|
|
2096
|
+
*
|
|
2097
|
+
* Physically migrated from `instance-manager.saveConfig`.
|
|
2098
|
+
*/
|
|
2099
|
+
function saveNativeConfigImpl(instanceId, config) {
|
|
2100
|
+
const configPath = openclawConfigPath(instanceId);
|
|
2101
|
+
if (!existsSync(framework_instanceDir(instanceId)))
|
|
2102
|
+
return false;
|
|
2103
|
+
if (!existsSync(configPath)) {
|
|
2104
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
2105
|
+
ensureDirContainer(dirname(configPath));
|
|
2106
|
+
if (existsSync(legacyPath))
|
|
2107
|
+
copyFileSync(legacyPath, configPath);
|
|
2108
|
+
}
|
|
2109
|
+
// Save x-jishushell metadata to instance.json (not openclaw.json)
|
|
2110
|
+
if (config["x-jishushell"]) {
|
|
2111
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
2112
|
+
if (existsSync(metaPath)) {
|
|
2113
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
2114
|
+
meta["x-jishushell"] = config["x-jishushell"];
|
|
2115
|
+
safeWriteJson(metaPath, meta);
|
|
2116
|
+
chownToServiceUser(metaPath);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
const [configToWrite, envUpdates] = prepareConfigForSave(instanceId, config);
|
|
2120
|
+
// If openclaw-lark is enabled, resolve which feishu plugin to use
|
|
2121
|
+
if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
|
|
2122
|
+
const stockExtDir = getStockExtensionsDir();
|
|
2123
|
+
const stockFeishu = join(stockExtDir, "feishu");
|
|
2124
|
+
const stockOcl = join(stockExtDir, "openclaw-lark");
|
|
2125
|
+
const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
|
|
2126
|
+
if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
|
|
2127
|
+
configToWrite.plugins.entries.feishu = { enabled: true };
|
|
2128
|
+
delete configToWrite.plugins.entries["openclaw-lark"];
|
|
2129
|
+
}
|
|
2130
|
+
else if (existsSync(stockFeishu)) {
|
|
2131
|
+
configToWrite.plugins ??= {};
|
|
2132
|
+
configToWrite.plugins.entries ??= {};
|
|
2133
|
+
configToWrite.plugins.entries.feishu = { enabled: false };
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
// Preserve backend-managed fields
|
|
2137
|
+
if (existsSync(configPath)) {
|
|
2138
|
+
try {
|
|
2139
|
+
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
2140
|
+
if (existing.plugins?.installs) {
|
|
2141
|
+
configToWrite.plugins ??= {};
|
|
2142
|
+
configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
|
|
2143
|
+
}
|
|
2144
|
+
if (existing.plugins?.entries && configToWrite.plugins?.entries) {
|
|
2145
|
+
for (const [key, val] of Object.entries(configToWrite.plugins.entries)) {
|
|
2146
|
+
const old = existing.plugins.entries[key];
|
|
2147
|
+
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
2148
|
+
configToWrite.plugins.entries[key] = { ...old, ...val };
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
if (existing.channels && configToWrite.channels) {
|
|
2153
|
+
for (const [key, val] of Object.entries(configToWrite.channels)) {
|
|
2154
|
+
const old = existing.channels[key];
|
|
2155
|
+
if (val && typeof val === "object" && !Array.isArray(val) && old && typeof old === "object") {
|
|
2156
|
+
configToWrite.channels[key] = { ...old, ...val };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
catch {
|
|
2162
|
+
/* best effort */
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
// Backup + atomic write
|
|
2166
|
+
if (existsSync(configPath)) {
|
|
2167
|
+
copyFileSync(configPath, configPath + ".bak");
|
|
2168
|
+
}
|
|
2169
|
+
const configJson = JSON.stringify(configToWrite, null, 2);
|
|
2170
|
+
ensureDirContainer(dirname(configPath));
|
|
2171
|
+
writeConfigFile(configPath + ".tmp", configJson);
|
|
2172
|
+
JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
|
|
2173
|
+
renameSync(configPath + ".tmp", configPath);
|
|
2174
|
+
chownToServiceUser(configPath);
|
|
2175
|
+
// Mirror into legacy path
|
|
2176
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
2177
|
+
if (existsSync(legacyPath)) {
|
|
2178
|
+
copyFileSync(legacyPath, legacyPath + ".bak");
|
|
2179
|
+
}
|
|
2180
|
+
writeConfigFile(legacyPath + ".tmp", configJson);
|
|
2181
|
+
JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
|
|
2182
|
+
renameSync(legacyPath + ".tmp", legacyPath);
|
|
2183
|
+
chownToServiceUser(legacyPath);
|
|
2184
|
+
if (Object.keys(envUpdates).length) {
|
|
2185
|
+
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
2186
|
+
if (envFiles.length)
|
|
2187
|
+
updateEnvFile(envFiles[0], envUpdates);
|
|
2188
|
+
}
|
|
2189
|
+
// Notify listeners (LLM proxy cache invalidation etc.)
|
|
2190
|
+
notifyConfigChange(instanceId);
|
|
2191
|
+
return true;
|
|
2192
|
+
}
|
|
2193
|
+
export const openclawAdapter = new OpenClawAdapter();
|
|
2194
|
+
registerAdapter(openclawAdapter);
|
|
2195
|
+
//# sourceMappingURL=openclaw.js.map
|