jishushell 0.0.1 → 0.4.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/LICENSE +202 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES +387 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +88 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +290 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +226 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +15 -0
- package/dist/constants.js.map +1 -0
- package/dist/control.d.ts +44 -0
- package/dist/control.js +1359 -0
- package/dist/control.js.map +1 -0
- package/dist/crypto-shim.d.ts +1 -0
- package/dist/crypto-shim.js +2 -0
- package/dist/crypto-shim.js.map +1 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +937 -0
- package/dist/doctor.js.map +1 -0
- package/dist/install.d.ts +27 -0
- package/dist/install.js +570 -0
- package/dist/install.js.map +1 -0
- package/dist/routes/auth.d.ts +4 -0
- package/dist/routes/auth.js +151 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/instances.d.ts +2 -0
- package/dist/routes/instances.js +1303 -0
- package/dist/routes/instances.js.map +1 -0
- package/dist/routes/setup.d.ts +2 -0
- package/dist/routes/setup.js +139 -0
- package/dist/routes/setup.js.map +1 -0
- package/dist/routes/system.d.ts +2 -0
- package/dist/routes/system.js +102 -0
- package/dist/routes/system.js.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +392 -0
- package/dist/server.js.map +1 -0
- package/dist/services/instance-manager.d.ts +67 -0
- package/dist/services/instance-manager.js +1319 -0
- package/dist/services/instance-manager.js.map +1 -0
- package/dist/services/llm-proxy/adapters.d.ts +3 -0
- package/dist/services/llm-proxy/adapters.js +309 -0
- package/dist/services/llm-proxy/adapters.js.map +1 -0
- package/dist/services/llm-proxy/circuit-breaker.d.ts +9 -0
- package/dist/services/llm-proxy/circuit-breaker.js +73 -0
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -0
- package/dist/services/llm-proxy/encryption.d.ts +6 -0
- package/dist/services/llm-proxy/encryption.js +61 -0
- package/dist/services/llm-proxy/encryption.js.map +1 -0
- package/dist/services/llm-proxy/index.d.ts +24 -0
- package/dist/services/llm-proxy/index.js +708 -0
- package/dist/services/llm-proxy/index.js.map +1 -0
- package/dist/services/llm-proxy/rate-limiter.d.ts +1 -0
- package/dist/services/llm-proxy/rate-limiter.js +39 -0
- package/dist/services/llm-proxy/rate-limiter.js.map +1 -0
- package/dist/services/llm-proxy/sse.d.ts +10 -0
- package/dist/services/llm-proxy/sse.js +378 -0
- package/dist/services/llm-proxy/sse.js.map +1 -0
- package/dist/services/llm-proxy/ssrf.d.ts +16 -0
- package/dist/services/llm-proxy/ssrf.js +185 -0
- package/dist/services/llm-proxy/ssrf.js.map +1 -0
- package/dist/services/llm-proxy/types.d.ts +52 -0
- package/dist/services/llm-proxy/types.js +2 -0
- package/dist/services/llm-proxy/types.js.map +1 -0
- package/dist/services/llm-proxy/usage.d.ts +12 -0
- package/dist/services/llm-proxy/usage.js +108 -0
- package/dist/services/llm-proxy/usage.js.map +1 -0
- package/dist/services/nomad-manager.d.ts +22 -0
- package/dist/services/nomad-manager.js +828 -0
- package/dist/services/nomad-manager.js.map +1 -0
- package/dist/services/plugin-installer.d.ts +22 -0
- package/dist/services/plugin-installer.js +102 -0
- package/dist/services/plugin-installer.js.map +1 -0
- package/dist/services/process-manager.d.ts +25 -0
- package/dist/services/process-manager.js +531 -0
- package/dist/services/process-manager.js.map +1 -0
- package/dist/services/setup-manager.d.ts +93 -0
- package/dist/services/setup-manager.js +1922 -0
- package/dist/services/setup-manager.js.map +1 -0
- package/dist/services/system-monitor.d.ts +1 -0
- package/dist/services/system-monitor.js +79 -0
- package/dist/services/system-monitor.js.map +1 -0
- package/dist/services/telemetry/activation.d.ts +12 -0
- package/dist/services/telemetry/activation.js +75 -0
- package/dist/services/telemetry/activation.js.map +1 -0
- package/dist/services/telemetry/client.d.ts +21 -0
- package/dist/services/telemetry/client.js +47 -0
- package/dist/services/telemetry/client.js.map +1 -0
- package/dist/services/telemetry/device-fingerprint.d.ts +18 -0
- package/dist/services/telemetry/device-fingerprint.js +123 -0
- package/dist/services/telemetry/device-fingerprint.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +13 -0
- package/dist/services/telemetry/heartbeat.js +81 -0
- package/dist/services/telemetry/heartbeat.js.map +1 -0
- package/dist/services/telemetry/index.d.ts +3 -0
- package/dist/services/telemetry/index.js +4 -0
- package/dist/services/telemetry/index.js.map +1 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/safe-json.d.ts +2 -0
- package/dist/utils/safe-json.js +80 -0
- package/dist/utils/safe-json.js.map +1 -0
- package/dist/utils/ttl-cache.d.ts +29 -0
- package/dist/utils/ttl-cache.js +77 -0
- package/dist/utils/ttl-cache.js.map +1 -0
- package/install/jishu-install.sh +2920 -0
- package/install/jishu-uninstall.sh +811 -0
- package/install/post-install.sh +110 -0
- package/install/post-uninstall.sh +46 -0
- package/package.json +57 -8
- package/public/assets/Dashboard-CAOQDYDR.js +1 -0
- package/public/assets/InitPassword-CkehIkJG.js +1 -0
- package/public/assets/InstanceDetail-CzW2S95J.js +14 -0
- package/public/assets/Login-RkjzTNWg.js +1 -0
- package/public/assets/NewInstance-DdbErdjA.js +1 -0
- package/public/assets/Settings-BUD7zwv9.js +1 -0
- package/public/assets/Setup-RRTIERGG.js +1 -0
- package/public/assets/index-77Ug7feY.css +1 -0
- package/public/assets/index-DfRnVUQR.js +16 -0
- package/public/assets/providers-lBSOjUWy.js +1 -0
- package/public/assets/usePolling-CqQ8hrNc.js +1 -0
- package/public/assets/vendor-i18n-Bvxxh8Di.js +9 -0
- package/public/assets/vendor-react-DONn7uBV.js +59 -0
- package/public/index.html +15 -0
- package/scripts/build-image.sh +55 -0
- package/scripts/run.sh +310 -0
- package/scripts/setup-pi.sh +80 -0
- package/scripts/start-feishu1.js +46 -0
- package/index.js +0 -0
- package/jishushell-0.0.1.tgz +0 -0
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "child_process";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { chmodSync, chownSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "fs";
|
|
4
|
+
import { createServer as netCreateServer } from "net";
|
|
5
|
+
import { userInfo } from "os";
|
|
6
|
+
import { dirname, join, resolve } from "path";
|
|
7
|
+
import { INSTANCES_DIR, JISHUSHELL_HOME, getPanelConfig } from "../config.js";
|
|
8
|
+
import { LEGACY_PROVIDER_API_ALIASES } from "../constants.js";
|
|
9
|
+
import { safeReadJson, safeWriteJson } from "../utils/safe-json.js";
|
|
10
|
+
const _configChangeListeners = [];
|
|
11
|
+
export function onConfigChange(listener) {
|
|
12
|
+
_configChangeListeners.push(listener);
|
|
13
|
+
return () => {
|
|
14
|
+
const idx = _configChangeListeners.indexOf(listener);
|
|
15
|
+
if (idx >= 0)
|
|
16
|
+
_configChangeListeners.splice(idx, 1);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
|
+
const DEFAULT_GATEWAY_PORT = 18789;
|
|
21
|
+
const INSTANCE_OPENCLAW_HOME_DIRNAME = "openclaw-home";
|
|
22
|
+
const INSTANCE_MODEL_ENV_FILENAME = "model.env";
|
|
23
|
+
const OPENCLAW_STATE_DIRNAME = ".openclaw";
|
|
24
|
+
const OPENCLAW_CONFIG_FILENAME = "openclaw.json";
|
|
25
|
+
// ── Path helpers ──
|
|
26
|
+
function instanceDir(instanceId) {
|
|
27
|
+
return join(INSTANCES_DIR, instanceId);
|
|
28
|
+
}
|
|
29
|
+
function instanceMetaPath(instanceId) {
|
|
30
|
+
return join(instanceDir(instanceId), "instance.json");
|
|
31
|
+
}
|
|
32
|
+
function defaultOpenclawHome(instanceId) {
|
|
33
|
+
return join(instanceDir(instanceId), INSTANCE_OPENCLAW_HOME_DIRNAME);
|
|
34
|
+
}
|
|
35
|
+
function defaultModelEnvFile(instanceId) {
|
|
36
|
+
return join(instanceDir(instanceId), INSTANCE_MODEL_ENV_FILENAME);
|
|
37
|
+
}
|
|
38
|
+
function getOpenclawHomeInternal(instanceId) {
|
|
39
|
+
const meta = getInstance(instanceId);
|
|
40
|
+
if (meta?.openclaw_home)
|
|
41
|
+
return meta.openclaw_home;
|
|
42
|
+
return defaultOpenclawHome(instanceId);
|
|
43
|
+
}
|
|
44
|
+
function openclawStateDir(instanceId) {
|
|
45
|
+
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME);
|
|
46
|
+
}
|
|
47
|
+
function legacyOpenclawConfigPath(instanceId) {
|
|
48
|
+
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_CONFIG_FILENAME);
|
|
49
|
+
}
|
|
50
|
+
function openclawConfigPathInternal(instanceId) {
|
|
51
|
+
return join(openclawStateDir(instanceId), OPENCLAW_CONFIG_FILENAME);
|
|
52
|
+
}
|
|
53
|
+
function normalizePath(p) {
|
|
54
|
+
return resolve(p.replace(/^~/, userInfo().homedir));
|
|
55
|
+
}
|
|
56
|
+
// ── JSON / deep merge ──
|
|
57
|
+
function loadJson(path) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.warn(`[instance] Failed to parse ${path}: ${e.message}`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function deepMerge(base, overlay) {
|
|
67
|
+
if (typeof base !== "object" || base === null ||
|
|
68
|
+
typeof overlay !== "object" || overlay === null ||
|
|
69
|
+
Array.isArray(base) || Array.isArray(overlay)) {
|
|
70
|
+
return structuredClone(overlay);
|
|
71
|
+
}
|
|
72
|
+
const merged = structuredClone(base);
|
|
73
|
+
for (const key of Object.keys(overlay)) {
|
|
74
|
+
merged[key] = key in merged ? deepMerge(merged[key], overlay[key]) : structuredClone(overlay[key]);
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
// ── Gateway port helpers ──
|
|
79
|
+
// Track in-flight port allocations to prevent race conditions
|
|
80
|
+
// between concurrent createInstance() calls.
|
|
81
|
+
const _pendingPorts = new Set();
|
|
82
|
+
function extractGatewayPort(runtime) {
|
|
83
|
+
if (!runtime)
|
|
84
|
+
return null;
|
|
85
|
+
const envPort = runtime.env?.OPENCLAW_GATEWAY_PORT;
|
|
86
|
+
if (envPort) {
|
|
87
|
+
const p = parseInt(envPort, 10);
|
|
88
|
+
if (!isNaN(p))
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
const args = runtime.args || [];
|
|
92
|
+
for (let i = 0; i < args.length; i++) {
|
|
93
|
+
const arg = String(args[i]);
|
|
94
|
+
if (arg === "--port" && i + 1 < args.length) {
|
|
95
|
+
const p = parseInt(args[i + 1], 10);
|
|
96
|
+
return isNaN(p) ? null : p;
|
|
97
|
+
}
|
|
98
|
+
if (arg.startsWith("--port=")) {
|
|
99
|
+
const p = parseInt(arg.split("=")[1], 10);
|
|
100
|
+
return isNaN(p) ? null : p;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function usedGatewayPorts(excludeId) {
|
|
106
|
+
const ports = new Set();
|
|
107
|
+
for (const inst of listInstances()) {
|
|
108
|
+
if (inst.id === excludeId)
|
|
109
|
+
continue;
|
|
110
|
+
const port = extractGatewayPort(inst.runtime);
|
|
111
|
+
if (port)
|
|
112
|
+
ports.add(port);
|
|
113
|
+
}
|
|
114
|
+
return ports;
|
|
115
|
+
}
|
|
116
|
+
function safePort(port) {
|
|
117
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
118
|
+
throw new Error(`Invalid port: ${port}`);
|
|
119
|
+
return String(port);
|
|
120
|
+
}
|
|
121
|
+
function isPortInUse(port) {
|
|
122
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
123
|
+
return Promise.resolve(false);
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const server = netCreateServer();
|
|
126
|
+
server.once("error", () => resolve(true));
|
|
127
|
+
server.once("listening", () => {
|
|
128
|
+
server.close(() => resolve(false));
|
|
129
|
+
});
|
|
130
|
+
server.listen(port, "0.0.0.0");
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function defaultGatewayPort(instanceId) {
|
|
134
|
+
const used = usedGatewayPorts(instanceId);
|
|
135
|
+
let port = DEFAULT_GATEWAY_PORT;
|
|
136
|
+
while (true) {
|
|
137
|
+
if (port > 65535)
|
|
138
|
+
throw new Error("No available gateway port found (all ports 18789-65535 in use)");
|
|
139
|
+
if (used.has(port) || _pendingPorts.has(port)) {
|
|
140
|
+
port++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// Reserve BEFORE the async isPortInUse check to prevent concurrent callers
|
|
144
|
+
// from selecting the same port during the await gap.
|
|
145
|
+
_pendingPorts.add(port);
|
|
146
|
+
try {
|
|
147
|
+
if (await isPortInUse(port)) {
|
|
148
|
+
_pendingPorts.delete(port);
|
|
149
|
+
port++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
return port;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
_pendingPorts.delete(port);
|
|
156
|
+
// Skip this port on a transient OS error rather than failing the entire
|
|
157
|
+
// allocation — a single bad port check should not prevent instance creation.
|
|
158
|
+
console.warn(`[instance] Port ${port} availability check failed, trying next port`);
|
|
159
|
+
port++;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// ── Runtime / config builders ──
|
|
165
|
+
function resolveOpenclawBin() {
|
|
166
|
+
const candidates = [
|
|
167
|
+
join(JISHUSHELL_HOME, "packages", "openclaw", "bin", "openclaw"),
|
|
168
|
+
"/usr/local/bin/openclaw",
|
|
169
|
+
"/usr/bin/openclaw",
|
|
170
|
+
];
|
|
171
|
+
for (const p of candidates) {
|
|
172
|
+
if (existsSync(p)) {
|
|
173
|
+
// Ensure executable permission (npm install may strip +x on some platforms)
|
|
174
|
+
try {
|
|
175
|
+
chmodSync(p, 0o755);
|
|
176
|
+
}
|
|
177
|
+
catch { /* best effort — may be a symlink */ }
|
|
178
|
+
// If symlink, also chmod the target
|
|
179
|
+
try {
|
|
180
|
+
const real = realpathSync(p);
|
|
181
|
+
if (real !== p)
|
|
182
|
+
chmodSync(real, 0o755);
|
|
183
|
+
}
|
|
184
|
+
catch { /* best effort */ }
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return candidates[0]; // fallback, will fail with clear error at spawn
|
|
189
|
+
}
|
|
190
|
+
export function getResolvedOpenclawBin() {
|
|
191
|
+
return resolveOpenclawBin();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* When jishushell runs as root (e.g. systemd service), returns the actual
|
|
195
|
+
* non-root user that should own instance files and run openclaw processes.
|
|
196
|
+
* Returns null when not running as root or when no suitable non-root user is found.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveServiceUser() {
|
|
199
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0)
|
|
200
|
+
return null;
|
|
201
|
+
// SUDO_USER is set when launched via `sudo`
|
|
202
|
+
const sudoUser = process.env.SUDO_USER;
|
|
203
|
+
if (sudoUser && sudoUser !== "root") {
|
|
204
|
+
try {
|
|
205
|
+
const uid = parseInt(execFileSync("id", ["-u", sudoUser], { encoding: "utf-8" }).trim(), 10);
|
|
206
|
+
const gid = parseInt(execFileSync("id", ["-g", sudoUser], { encoding: "utf-8" }).trim(), 10);
|
|
207
|
+
if (!isNaN(uid) && !isNaN(gid))
|
|
208
|
+
return { username: sudoUser, uid, gid };
|
|
209
|
+
}
|
|
210
|
+
catch { /* fall through */ }
|
|
211
|
+
}
|
|
212
|
+
// Fall back to owner of INSTANCES_DIR or JISHUSHELL_HOME
|
|
213
|
+
try {
|
|
214
|
+
const target = existsSync(INSTANCES_DIR) ? INSTANCES_DIR : JISHUSHELL_HOME;
|
|
215
|
+
const uid = statSync(target).uid;
|
|
216
|
+
if (uid !== 0) {
|
|
217
|
+
const username = execFileSync("id", ["-un", String(uid)], { encoding: "utf-8" }).trim();
|
|
218
|
+
const gid = parseInt(execFileSync("id", ["-g", username], { encoding: "utf-8" }).trim(), 10);
|
|
219
|
+
if (!isNaN(gid))
|
|
220
|
+
return { username, uid, gid };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch { /* fall through */ }
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* When running as root, chown a file (and its .bak) to the service user so the
|
|
228
|
+
* openclaw process (running as that user) can read/write its own data files.
|
|
229
|
+
* No-op when not running as root.
|
|
230
|
+
*/
|
|
231
|
+
function chownToServiceUser(...paths) {
|
|
232
|
+
const svc = resolveServiceUser();
|
|
233
|
+
if (!svc)
|
|
234
|
+
return;
|
|
235
|
+
for (const p of paths) {
|
|
236
|
+
for (const f of [p, p + ".bak"]) {
|
|
237
|
+
try {
|
|
238
|
+
if (existsSync(f))
|
|
239
|
+
chownSync(f, svc.uid, svc.gid);
|
|
240
|
+
}
|
|
241
|
+
catch { /* best effort */ }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async function defaultRuntime(instanceId, openclawHome) {
|
|
246
|
+
const port = await defaultGatewayPort(instanceId);
|
|
247
|
+
const home = openclawHome || defaultOpenclawHome(instanceId);
|
|
248
|
+
return {
|
|
249
|
+
command: resolveOpenclawBin(),
|
|
250
|
+
args: ["gateway", "run", "--port", String(port), "--allow-unconfigured"],
|
|
251
|
+
cwd: home,
|
|
252
|
+
user: resolveServiceUser()?.username ?? userInfo().username,
|
|
253
|
+
env_files: [defaultModelEnvFile(instanceId)],
|
|
254
|
+
env: {
|
|
255
|
+
OPENCLAW_GATEWAY_PORT: String(port),
|
|
256
|
+
NODE_OPTIONS: "--max-old-space-size=2048",
|
|
257
|
+
},
|
|
258
|
+
resources: { CPU: 1000, MemoryMB: 2048 },
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function starterConfig() {
|
|
262
|
+
const dp = getPanelConfig().default_provider;
|
|
263
|
+
let providerName = "minimax";
|
|
264
|
+
let providerConfig = {
|
|
265
|
+
baseUrl: "https://api.minimaxi.com/v1",
|
|
266
|
+
api: "openai-completions",
|
|
267
|
+
models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 204800 }],
|
|
268
|
+
};
|
|
269
|
+
let defaultModel = "minimax/MiniMax-M2.7";
|
|
270
|
+
if (dp?.providerId) {
|
|
271
|
+
providerName = dp.providerId;
|
|
272
|
+
providerConfig = {
|
|
273
|
+
baseUrl: dp.baseUrl,
|
|
274
|
+
api: dp.api,
|
|
275
|
+
...(dp.authHeader ? { authHeader: true } : {}),
|
|
276
|
+
models: dp.models || [],
|
|
277
|
+
};
|
|
278
|
+
const modelId = dp.selectedModelId || dp.models?.[0]?.id || "";
|
|
279
|
+
defaultModel = `${providerName}/${modelId}`;
|
|
280
|
+
}
|
|
281
|
+
const config = {
|
|
282
|
+
models: { providers: { [providerName]: providerConfig } },
|
|
283
|
+
agents: { defaults: { model: defaultModel, models: { [defaultModel]: {} } } },
|
|
284
|
+
channels: {},
|
|
285
|
+
gateway: {
|
|
286
|
+
mode: "local",
|
|
287
|
+
auth: { mode: "token", token: randomBytes(24).toString("hex") },
|
|
288
|
+
controlUi: { dangerouslyDisableDeviceAuth: true },
|
|
289
|
+
},
|
|
290
|
+
plugins: { entries: { feishu: { enabled: false } } },
|
|
291
|
+
};
|
|
292
|
+
// Store upstream proxy config so LLM proxy knows where to forward
|
|
293
|
+
if (dp?.providerId) {
|
|
294
|
+
config["x-jishushell"] = {
|
|
295
|
+
proxy: {
|
|
296
|
+
upstream: {
|
|
297
|
+
providerId: dp.providerId,
|
|
298
|
+
baseUrl: dp.baseUrl,
|
|
299
|
+
api: dp.api,
|
|
300
|
+
authHeader: dp.authHeader || false,
|
|
301
|
+
models: dp.models || [],
|
|
302
|
+
selectedModelId: dp.selectedModelId || dp.models?.[0]?.id || "",
|
|
303
|
+
hasApiKey: !!dp.apiKey,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return config;
|
|
309
|
+
}
|
|
310
|
+
// ── Config loading ──
|
|
311
|
+
function loadEffectiveConfig(instanceId) {
|
|
312
|
+
const runtimePath = openclawConfigPathInternal(instanceId);
|
|
313
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
314
|
+
const rExists = existsSync(runtimePath);
|
|
315
|
+
const lExists = existsSync(legacyPath);
|
|
316
|
+
if (rExists && lExists) {
|
|
317
|
+
const legacy = loadJson(legacyPath);
|
|
318
|
+
const runtime = loadJson(runtimePath);
|
|
319
|
+
if (legacy && runtime)
|
|
320
|
+
return deepMerge(legacy, runtime);
|
|
321
|
+
return runtime || legacy || null;
|
|
322
|
+
}
|
|
323
|
+
if (rExists)
|
|
324
|
+
return loadJson(runtimePath);
|
|
325
|
+
if (lExists)
|
|
326
|
+
return loadJson(legacyPath);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
// ── Env file helpers ──
|
|
330
|
+
export function parseEnvFile(path) {
|
|
331
|
+
const env = {};
|
|
332
|
+
if (!existsSync(path))
|
|
333
|
+
return env;
|
|
334
|
+
for (let line of readFileSync(path, "utf-8").split("\n")) {
|
|
335
|
+
line = line.trim();
|
|
336
|
+
if (!line || line.startsWith("#"))
|
|
337
|
+
continue;
|
|
338
|
+
if (line.startsWith("export "))
|
|
339
|
+
line = line.slice(7).trimStart();
|
|
340
|
+
if (!line.includes("="))
|
|
341
|
+
continue;
|
|
342
|
+
const eqIdx = line.indexOf("=");
|
|
343
|
+
const key = line.slice(0, eqIdx).trim();
|
|
344
|
+
let value = line.slice(eqIdx + 1).trim();
|
|
345
|
+
if (!ENV_KEY_RE.test(key))
|
|
346
|
+
continue;
|
|
347
|
+
if (value.length >= 2 && value[0] === value[value.length - 1] && (value[0] === "'" || value[0] === '"')) {
|
|
348
|
+
value = value.slice(1, -1);
|
|
349
|
+
}
|
|
350
|
+
env[key] = value;
|
|
351
|
+
}
|
|
352
|
+
return env;
|
|
353
|
+
}
|
|
354
|
+
function quoteEnvValue(value) {
|
|
355
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value))
|
|
356
|
+
return value;
|
|
357
|
+
return JSON.stringify(value);
|
|
358
|
+
}
|
|
359
|
+
export function updateEnvFile(path, updates) {
|
|
360
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
361
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8").split("\n") : [];
|
|
362
|
+
const remaining = { ...updates };
|
|
363
|
+
const newLines = [];
|
|
364
|
+
for (const rawLine of existing) {
|
|
365
|
+
const stripped = rawLine.trim();
|
|
366
|
+
const candidate = stripped.startsWith("export ") ? stripped.slice(7).trimStart() : stripped;
|
|
367
|
+
if (!candidate || candidate.startsWith("#") || !candidate.includes("=")) {
|
|
368
|
+
newLines.push(rawLine);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const key = candidate.slice(0, candidate.indexOf("=")).trim();
|
|
372
|
+
if (!ENV_KEY_RE.test(key) || !(key in remaining)) {
|
|
373
|
+
newLines.push(rawLine);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const value = remaining[key];
|
|
377
|
+
delete remaining[key];
|
|
378
|
+
if (value === "")
|
|
379
|
+
continue;
|
|
380
|
+
const prefix = stripped.startsWith("export ") ? "export " : "";
|
|
381
|
+
newLines.push(`${prefix}${key}=${quoteEnvValue(value)}`);
|
|
382
|
+
}
|
|
383
|
+
for (const [key, value] of Object.entries(remaining)) {
|
|
384
|
+
if (!ENV_KEY_RE.test(key) || value === "")
|
|
385
|
+
continue;
|
|
386
|
+
newLines.push(`${key}=${quoteEnvValue(value)}`);
|
|
387
|
+
}
|
|
388
|
+
const output = newLines.join("\n").trimEnd();
|
|
389
|
+
const content = output ? output + "\n" : "";
|
|
390
|
+
// Atomic write: tmp then rename to protect against RPi power loss
|
|
391
|
+
const tmp = path + ".tmp";
|
|
392
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
393
|
+
renameSync(tmp, path);
|
|
394
|
+
}
|
|
395
|
+
// ── Provider key helpers ──
|
|
396
|
+
export function inferProviderApiKeyEnvName(providerId) {
|
|
397
|
+
let normalized = providerId.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "").toUpperCase();
|
|
398
|
+
if (!normalized)
|
|
399
|
+
normalized = "OPENCLAW_PROVIDER";
|
|
400
|
+
return `${normalized}_API_KEY`;
|
|
401
|
+
}
|
|
402
|
+
function hasConfiguredValue(value) {
|
|
403
|
+
if (typeof value === "string")
|
|
404
|
+
return value.trim().length > 0;
|
|
405
|
+
if (typeof value === "object" && value !== null)
|
|
406
|
+
return Object.keys(value).length > 0;
|
|
407
|
+
return value != null;
|
|
408
|
+
}
|
|
409
|
+
function injectProviderApiKeys(instanceId, config) {
|
|
410
|
+
const merged = structuredClone(config);
|
|
411
|
+
const runtimeEnv = getRuntimeEnv(instanceId);
|
|
412
|
+
const providers = merged.models?.providers || {};
|
|
413
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
414
|
+
if (typeof provider !== "object" || provider === null)
|
|
415
|
+
continue;
|
|
416
|
+
const p = provider;
|
|
417
|
+
const api = p.api;
|
|
418
|
+
if (typeof api === "string" && api in LEGACY_PROVIDER_API_ALIASES) {
|
|
419
|
+
p.api = LEGACY_PROVIDER_API_ALIASES[api];
|
|
420
|
+
}
|
|
421
|
+
const apiKey = runtimeEnv[inferProviderApiKeyEnvName(providerId)];
|
|
422
|
+
if (apiKey)
|
|
423
|
+
p.apiKey = apiKey;
|
|
424
|
+
}
|
|
425
|
+
return merged;
|
|
426
|
+
}
|
|
427
|
+
function applyFeishuDebugAccessDefaults(channel) {
|
|
428
|
+
if (channel.enabled === false)
|
|
429
|
+
return;
|
|
430
|
+
if (!hasConfiguredValue(channel.appId))
|
|
431
|
+
return;
|
|
432
|
+
if (!hasConfiguredValue(channel.appSecret))
|
|
433
|
+
return;
|
|
434
|
+
let dmPolicy = channel.dmPolicy;
|
|
435
|
+
if (typeof dmPolicy !== "string" || !dmPolicy.trim()) {
|
|
436
|
+
channel.dmPolicy = "open";
|
|
437
|
+
dmPolicy = "open";
|
|
438
|
+
}
|
|
439
|
+
if (dmPolicy !== "open")
|
|
440
|
+
return;
|
|
441
|
+
if (!("resolveSenderNames" in channel))
|
|
442
|
+
channel.resolveSenderNames = false;
|
|
443
|
+
let accounts = channel.accounts;
|
|
444
|
+
if (typeof accounts !== "object" || accounts === null) {
|
|
445
|
+
accounts = {};
|
|
446
|
+
channel.accounts = accounts;
|
|
447
|
+
}
|
|
448
|
+
let defaultAccount = accounts.default;
|
|
449
|
+
if (typeof defaultAccount !== "object" || defaultAccount === null) {
|
|
450
|
+
defaultAccount = {};
|
|
451
|
+
accounts.default = defaultAccount;
|
|
452
|
+
}
|
|
453
|
+
if (!("resolveSenderNames" in defaultAccount))
|
|
454
|
+
defaultAccount.resolveSenderNames = false;
|
|
455
|
+
const allowFrom = channel.allowFrom;
|
|
456
|
+
if (Array.isArray(allowFrom)) {
|
|
457
|
+
const normalized = allowFrom.map((e) => String(e).trim()).filter(Boolean);
|
|
458
|
+
if (!normalized.includes("*"))
|
|
459
|
+
normalized.push("*");
|
|
460
|
+
channel.allowFrom = normalized;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
channel.allowFrom = ["*"];
|
|
464
|
+
}
|
|
465
|
+
function prepareConfigForSave(instanceId, config) {
|
|
466
|
+
const configToWrite = structuredClone(config);
|
|
467
|
+
// Remove JishuShell metadata — OpenClaw rejects unrecognized keys
|
|
468
|
+
delete configToWrite["x-jishushell"];
|
|
469
|
+
const envUpdates = {};
|
|
470
|
+
const providers = configToWrite.models?.providers || {};
|
|
471
|
+
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
472
|
+
const channels = configToWrite.channels || {};
|
|
473
|
+
const plugins = configToWrite.plugins ??= {};
|
|
474
|
+
const pluginEntries = plugins.entries ??= {};
|
|
475
|
+
for (const [providerId, provider] of Object.entries(providers)) {
|
|
476
|
+
if (typeof provider !== "object" || provider === null)
|
|
477
|
+
continue;
|
|
478
|
+
const p = provider;
|
|
479
|
+
if (typeof p.api === "string" && p.api in LEGACY_PROVIDER_API_ALIASES) {
|
|
480
|
+
p.api = LEGACY_PROVIDER_API_ALIASES[p.api];
|
|
481
|
+
}
|
|
482
|
+
if (!("apiKey" in p))
|
|
483
|
+
continue;
|
|
484
|
+
// Keep proxy provider apiKey in config — OpenClaw reads it from config directly.
|
|
485
|
+
// Only real upstream provider keys get moved to env files for security.
|
|
486
|
+
// Detect proxy by baseUrl (provider ID now uses upstream name for display).
|
|
487
|
+
if (typeof p.baseUrl === "string" && p.baseUrl.includes("/proxy/"))
|
|
488
|
+
continue;
|
|
489
|
+
const apiKey = p.apiKey;
|
|
490
|
+
delete p.apiKey;
|
|
491
|
+
if (envFiles.length) {
|
|
492
|
+
envUpdates[inferProviderApiKeyEnvName(providerId)] = String(apiKey || "");
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
p.apiKey = apiKey;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const [channelId, channel] of Object.entries(channels)) {
|
|
499
|
+
if (typeof channel !== "object" || channel === null)
|
|
500
|
+
continue;
|
|
501
|
+
const ch = channel;
|
|
502
|
+
if (channelId === "feishu" || channelId === "lark")
|
|
503
|
+
applyFeishuDebugAccessDefaults(ch);
|
|
504
|
+
let pluginEntry = pluginEntries[channelId];
|
|
505
|
+
if (pluginEntry == null) {
|
|
506
|
+
pluginEntry = {};
|
|
507
|
+
pluginEntries[channelId] = pluginEntry;
|
|
508
|
+
}
|
|
509
|
+
if (typeof pluginEntry === "object") {
|
|
510
|
+
pluginEntry.enabled = ch.enabled !== false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return [configToWrite, envUpdates];
|
|
514
|
+
}
|
|
515
|
+
// ── Channel plugin helpers ──
|
|
516
|
+
// Channel → plugin package mapping for auto-install.
|
|
517
|
+
// Stock plugins (bundled with newer OpenClaw) are detected via extensions/{id} dir;
|
|
518
|
+
// if missing (older OpenClaw), they get installed as fallback.
|
|
519
|
+
// @larksuite/openclaw-lark installs as "openclaw-lark" dir but registers channel "feishu"
|
|
520
|
+
const CHANNEL_EXT_DIR_ALIAS = {
|
|
521
|
+
feishu: "openclaw-lark",
|
|
522
|
+
lark: "openclaw-lark",
|
|
523
|
+
};
|
|
524
|
+
export const CHANNEL_PLUGIN_MAP = {
|
|
525
|
+
// Official vendor plugins (ByteDance Feishu/Lark)
|
|
526
|
+
feishu: "@larksuite/openclaw-lark",
|
|
527
|
+
lark: "@larksuite/openclaw-lark",
|
|
528
|
+
// Built-in (stock) — fallback install for older OpenClaw versions
|
|
529
|
+
telegram: "@openclaw/telegram",
|
|
530
|
+
discord: "@openclaw/discord",
|
|
531
|
+
slack: "@openclaw/slack",
|
|
532
|
+
whatsapp: "@openclaw/whatsapp",
|
|
533
|
+
signal: "@openclaw/signal",
|
|
534
|
+
line: "@openclaw/line",
|
|
535
|
+
msteams: "@openclaw/msteams",
|
|
536
|
+
// Official vendor plugins — need install (not bundled)
|
|
537
|
+
"openclaw-weixin": "@tencent-weixin/openclaw-weixin",
|
|
538
|
+
};
|
|
539
|
+
/** Check if a channel plugin is installed for an instance. */
|
|
540
|
+
export function isChannelPluginInstalled(instanceId, channelId) {
|
|
541
|
+
const extDirName = CHANNEL_EXT_DIR_ALIAS[channelId] || channelId;
|
|
542
|
+
const stockExtDir = getStockExtensionsDir();
|
|
543
|
+
return existsSync(join(getChannelExtensionsDir(instanceId), extDirName))
|
|
544
|
+
|| existsSync(join(stockExtDir, extDirName))
|
|
545
|
+
// Also accept the built-in directory named after the raw channelId (e.g. "feishu/" in stock)
|
|
546
|
+
|| (extDirName !== channelId && existsSync(join(stockExtDir, channelId)));
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Install a single channel plugin on the host.
|
|
550
|
+
* This is the unified entry point for all plugin installation — never use docker exec.
|
|
551
|
+
*/
|
|
552
|
+
export async function installChannelPlugin(instanceId, channelId) {
|
|
553
|
+
const pkg = CHANNEL_PLUGIN_MAP[channelId];
|
|
554
|
+
if (!pkg)
|
|
555
|
+
throw new Error(`Unknown channel: ${channelId}`);
|
|
556
|
+
if (isChannelPluginInstalled(instanceId, channelId))
|
|
557
|
+
return;
|
|
558
|
+
const openclawHome = getOpenclawHomeInternal(instanceId);
|
|
559
|
+
const openclawBin = resolveOpenclawBin();
|
|
560
|
+
const extensionsDir = getChannelExtensionsDir(instanceId);
|
|
561
|
+
// Ensure the node binary that runs jishushell is on PATH for the child process.
|
|
562
|
+
// When running under systemd, PATH may not include the nvm node directory, which
|
|
563
|
+
// causes the `#!/usr/bin/env node` shebang in the openclaw CLI to fail.
|
|
564
|
+
const nodeBinDir = dirname(process.execPath);
|
|
565
|
+
const childPath = [nodeBinDir, process.env.PATH].filter(Boolean).join(":");
|
|
566
|
+
// Pass through proxy and TLS env vars so fetch works under systemd / restricted environments.
|
|
567
|
+
const proxyEnvKeys = [
|
|
568
|
+
"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY",
|
|
569
|
+
"no_proxy", "NO_PROXY", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED",
|
|
570
|
+
];
|
|
571
|
+
const proxyEnv = {};
|
|
572
|
+
for (const key of proxyEnvKeys) {
|
|
573
|
+
if (process.env[key])
|
|
574
|
+
proxyEnv[key] = process.env[key];
|
|
575
|
+
}
|
|
576
|
+
const childEnv = {
|
|
577
|
+
PATH: childPath,
|
|
578
|
+
HOME: process.env.HOME,
|
|
579
|
+
LANG: process.env.LANG,
|
|
580
|
+
OPENCLAW_HOME: openclawHome,
|
|
581
|
+
...proxyEnv,
|
|
582
|
+
};
|
|
583
|
+
const MAX_ATTEMPTS = 3;
|
|
584
|
+
const RETRY_DELAY_MS = 5_000;
|
|
585
|
+
const attemptInstall = () => new Promise((resolve, reject) => {
|
|
586
|
+
execFile(openclawBin, ["plugins", "install", pkg], {
|
|
587
|
+
cwd: openclawHome,
|
|
588
|
+
env: childEnv,
|
|
589
|
+
timeout: 300_000,
|
|
590
|
+
}, (err, stdout, stderr) => {
|
|
591
|
+
// openclaw plugins install may exit non-zero due to "plugins.allow is empty" warning
|
|
592
|
+
// even though the plugin was installed successfully. Check actual directory.
|
|
593
|
+
if (err && !isChannelPluginInstalled(instanceId, channelId)) {
|
|
594
|
+
const msg = [stderr?.trim(), stdout?.trim(), err.message].filter(Boolean).join(" | ");
|
|
595
|
+
console.error(`[plugins] ${pkg} exit code ${err.code ?? '?'}, stderr: ${stderr?.trim() || '(empty)'}, stdout: ${stdout?.trim() || '(empty)'}`);
|
|
596
|
+
// Clean up leftover stage directories
|
|
597
|
+
try {
|
|
598
|
+
if (existsSync(extensionsDir)) {
|
|
599
|
+
for (const entry of readdirSync(extensionsDir)) {
|
|
600
|
+
if (entry.startsWith(".openclaw-install-stage-")) {
|
|
601
|
+
rmSync(join(extensionsDir, entry), { recursive: true, force: true });
|
|
602
|
+
console.log(`[plugins] Cleaned up stage dir: ${entry}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch { /* best effort */ }
|
|
608
|
+
reject(new Error(msg));
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
if (err)
|
|
612
|
+
console.log(`[plugins] ${pkg} installed (ignored non-zero exit: warning only)`);
|
|
613
|
+
else
|
|
614
|
+
console.log(`[plugins] ${pkg} installed`);
|
|
615
|
+
resolve();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
console.log(`[plugins] Installing ${pkg} for ${channelId} (host)...`);
|
|
620
|
+
let lastErr;
|
|
621
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
622
|
+
try {
|
|
623
|
+
await attemptInstall();
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
lastErr = err;
|
|
628
|
+
const isFetchError = /fetch failed/i.test(err.message ?? "");
|
|
629
|
+
if (isFetchError && attempt < MAX_ATTEMPTS) {
|
|
630
|
+
console.warn(`[plugins] ${pkg} install attempt ${attempt}/${MAX_ATTEMPTS} failed with fetch error, retrying in ${RETRY_DELAY_MS / 1000}s...`);
|
|
631
|
+
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
console.error(`[plugins] Failed to install ${pkg}:`, err.message);
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
throw lastErr;
|
|
639
|
+
}
|
|
640
|
+
function getChannelExtensionsDir(instanceId) {
|
|
641
|
+
return join(getOpenclawHomeInternal(instanceId), OPENCLAW_STATE_DIRNAME, "extensions");
|
|
642
|
+
}
|
|
643
|
+
function getStockExtensionsDir() {
|
|
644
|
+
return join(JISHUSHELL_HOME, "packages", "openclaw", "lib", "node_modules", "openclaw", "extensions");
|
|
645
|
+
}
|
|
646
|
+
// ── Public API ──
|
|
647
|
+
export function listInstances() {
|
|
648
|
+
if (!existsSync(INSTANCES_DIR))
|
|
649
|
+
return [];
|
|
650
|
+
const entries = readdirSync(INSTANCES_DIR).sort();
|
|
651
|
+
const instances = [];
|
|
652
|
+
for (const name of entries) {
|
|
653
|
+
const metaPath = join(INSTANCES_DIR, name, "instance.json");
|
|
654
|
+
const dirPath = join(INSTANCES_DIR, name);
|
|
655
|
+
try {
|
|
656
|
+
if (statSync(dirPath).isDirectory() && existsSync(metaPath)) {
|
|
657
|
+
instances.push(JSON.parse(readFileSync(metaPath, "utf-8")));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (e) {
|
|
661
|
+
// Permission errors (EACCES) caused by root-owned files are a common deployment
|
|
662
|
+
// mistake (e.g. running a maintenance script as sudo). Log clearly instead of
|
|
663
|
+
// silently dropping the instance from the list.
|
|
664
|
+
console.error(`[instance-manager] cannot read instance '${name}': ${e.message}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return instances;
|
|
668
|
+
}
|
|
669
|
+
export function getInstance(instanceId) {
|
|
670
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
671
|
+
if (!existsSync(metaPath))
|
|
672
|
+
return null;
|
|
673
|
+
try {
|
|
674
|
+
return JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
// Surface permission errors clearly (EACCES: instance.json owned by root after a sudo script)
|
|
678
|
+
throw new Error(`Cannot read instance '${instanceId}' metadata: ${e.message}. Check file ownership with: ls -la ${metaPath}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
export async function createInstance(instanceId, name, description = "", cloneFrom, openclawHome) {
|
|
682
|
+
const d = instanceDir(instanceId);
|
|
683
|
+
if (existsSync(d))
|
|
684
|
+
throw new Error(`Instance '${instanceId}' already exists`);
|
|
685
|
+
const home = openclawHome ? normalizePath(openclawHome) : defaultOpenclawHome(instanceId);
|
|
686
|
+
// Restrict openclaw_home to be under JISHUSHELL_HOME or /home to prevent path traversal.
|
|
687
|
+
// Use realpathSync after mkdir to resolve symlinks, preventing symlink-based bypasses.
|
|
688
|
+
if (openclawHome) {
|
|
689
|
+
const resolved = resolve(home);
|
|
690
|
+
if (!resolved.startsWith(JISHUSHELL_HOME) && !resolved.startsWith("/home/")) {
|
|
691
|
+
throw new Error(`openclaw_home must be under ${JISHUSHELL_HOME} or /home/`);
|
|
692
|
+
}
|
|
693
|
+
// Resolve symlinks for the parent dir to catch symlink attacks
|
|
694
|
+
const parentDir = dirname(resolved);
|
|
695
|
+
if (existsSync(parentDir)) {
|
|
696
|
+
const realParent = realpathSync(parentDir);
|
|
697
|
+
if (!realParent.startsWith(JISHUSHELL_HOME) && !realParent.startsWith("/home/")) {
|
|
698
|
+
throw new Error(`openclaw_home parent resolves outside allowed paths (symlink detected)`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
const shared = listInstances().filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === normalizePath(home));
|
|
702
|
+
if (shared.length) {
|
|
703
|
+
throw new Error(`OpenClaw home '${home}' is already used by instance(s): ${shared.map((i) => i.id).join(", ")}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// Check for orphaned openclaw_home directory (e.g. instance.json deleted but data remains)
|
|
707
|
+
if (existsSync(home)) {
|
|
708
|
+
try {
|
|
709
|
+
const entries = readdirSync(home);
|
|
710
|
+
if (entries.length > 0) {
|
|
711
|
+
throw new Error(`OpenClaw home directory '${home}' already exists and is not empty. Remove it manually or choose a different path.`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
catch (e) {
|
|
715
|
+
if (e.message.includes("not empty"))
|
|
716
|
+
throw e;
|
|
717
|
+
// readdirSync failed — directory might not be readable, proceed cautiously
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
mkdirSync(d, { recursive: true, mode: 0o750 });
|
|
721
|
+
chmodSync(d, 0o750);
|
|
722
|
+
// Inherit group from INSTANCES_DIR so both root and the real user can access
|
|
723
|
+
try {
|
|
724
|
+
const parentGid = statSync(INSTANCES_DIR).gid;
|
|
725
|
+
chownSync(d, -1, parentGid);
|
|
726
|
+
}
|
|
727
|
+
catch { /* non-root without CAP_CHOWN — already correct owner */ }
|
|
728
|
+
mkdirSync(home, { recursive: true, mode: 0o750 });
|
|
729
|
+
chmodSync(home, 0o750);
|
|
730
|
+
mkdirSync(join(home, OPENCLAW_STATE_DIRNAME), { recursive: true, mode: 0o750 });
|
|
731
|
+
chmodSync(join(home, OPENCLAW_STATE_DIRNAME), 0o750);
|
|
732
|
+
const runtime = await defaultRuntime(instanceId, home);
|
|
733
|
+
const allocatedPort = extractGatewayPort(runtime);
|
|
734
|
+
// Port already reserved inside defaultGatewayPort; just track for cleanup
|
|
735
|
+
try {
|
|
736
|
+
const meta = {
|
|
737
|
+
id: instanceId,
|
|
738
|
+
name,
|
|
739
|
+
description,
|
|
740
|
+
openclaw_home: home,
|
|
741
|
+
runtime,
|
|
742
|
+
created_at: new Date().toISOString(),
|
|
743
|
+
};
|
|
744
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
745
|
+
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p));
|
|
746
|
+
for (const ef of envFiles) {
|
|
747
|
+
mkdirSync(dirname(ef), { recursive: true });
|
|
748
|
+
if (!existsSync(ef))
|
|
749
|
+
writeFileSync(ef, "", { mode: 0o600 });
|
|
750
|
+
}
|
|
751
|
+
// After writing env files, ensure the runtime user can read them
|
|
752
|
+
try {
|
|
753
|
+
const runtimeUser = runtime.user;
|
|
754
|
+
if (runtimeUser && runtimeUser !== userInfo().username) {
|
|
755
|
+
for (const ef of envFiles) {
|
|
756
|
+
execFileSync("chown", [runtimeUser, ef], { timeout: 5000 });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch { /* ignore - same user or no permission to chown */ }
|
|
761
|
+
const configPath = openclawConfigPathInternal(instanceId);
|
|
762
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
763
|
+
if (cloneFrom && !existsSync(configPath)) {
|
|
764
|
+
const srcConfig = resolveExistingConfigPath(cloneFrom);
|
|
765
|
+
if (existsSync(srcConfig)) {
|
|
766
|
+
// Domain-level clone: copy config but strip proxy identity (token, jsproxy provider)
|
|
767
|
+
// so the new instance gets its own proxy token via saveInstanceConfig later
|
|
768
|
+
try {
|
|
769
|
+
const cloned = JSON.parse(readFileSync(srcConfig, "utf-8"));
|
|
770
|
+
// Remove proxy provider (will be regenerated with new proxy token)
|
|
771
|
+
// Detect by baseUrl since provider ID now uses upstream name (e.g. "js-minimax")
|
|
772
|
+
const providers = cloned?.models?.providers;
|
|
773
|
+
if (providers) {
|
|
774
|
+
for (const [pid, prov] of Object.entries(providers)) {
|
|
775
|
+
if (typeof prov?.baseUrl === "string" && prov.baseUrl.includes("/proxy/")) {
|
|
776
|
+
delete providers[pid];
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// Remove proxy model reference from agent defaults (regenerated by bootstrap)
|
|
781
|
+
const defaultModel = cloned?.agents?.defaults?.model;
|
|
782
|
+
if (typeof defaultModel === "string" && (defaultModel.startsWith("jsproxy/") || defaultModel.startsWith("js-"))) {
|
|
783
|
+
delete cloned.agents.defaults.model;
|
|
784
|
+
}
|
|
785
|
+
// Strip IM channel configs — same channel cannot serve multiple instances
|
|
786
|
+
if (cloned?.channels)
|
|
787
|
+
delete cloned.channels;
|
|
788
|
+
// Copy extensions directory so plugin references in config remain valid
|
|
789
|
+
// Copy workspace directory to preserve agent personality (.md files)
|
|
790
|
+
for (const subdir of ["extensions", "workspace", "skills"]) {
|
|
791
|
+
const srcDir = join(dirname(srcConfig), subdir);
|
|
792
|
+
const dstDir = join(dirname(configPath), subdir);
|
|
793
|
+
if (existsSync(srcDir) && !existsSync(dstDir)) {
|
|
794
|
+
try {
|
|
795
|
+
cpSync(srcDir, dstDir, { recursive: true });
|
|
796
|
+
}
|
|
797
|
+
catch { /* best effort */ }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
writeFileSync(configPath, JSON.stringify(cloned, null, 2), { mode: 0o600 });
|
|
801
|
+
// Copy x-jishushell upstream metadata from source instance.json
|
|
802
|
+
// (saveConfig stores x-jishushell in instance.json, not openclaw.json)
|
|
803
|
+
const srcMetaPath = join(instanceDir(cloneFrom), "instance.json");
|
|
804
|
+
if (existsSync(srcMetaPath)) {
|
|
805
|
+
try {
|
|
806
|
+
const srcMeta = JSON.parse(readFileSync(srcMetaPath, "utf-8"));
|
|
807
|
+
const srcXj = srcMeta?.["x-jishushell"];
|
|
808
|
+
if (srcXj?.proxy?.upstream) {
|
|
809
|
+
const dstXj = { proxy: { upstream: srcXj.proxy.upstream } };
|
|
810
|
+
// Clear instance-specific fields
|
|
811
|
+
delete dstXj.proxy.upstream.apiKey;
|
|
812
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
813
|
+
if (existsSync(metaPath)) {
|
|
814
|
+
const dstMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
815
|
+
dstMeta["x-jishushell"] = dstXj;
|
|
816
|
+
writeFileSync(metaPath, JSON.stringify(dstMeta, null, 2));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
catch { /* ignore metadata copy errors */ }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
// Fallback: raw copy if parse fails
|
|
825
|
+
copyFileSync(srcConfig, configPath);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (!existsSync(configPath)) {
|
|
830
|
+
writeFileSync(configPath, JSON.stringify(starterConfig(), null, 2), { mode: 0o600 });
|
|
831
|
+
// Inject default provider API key from setup into both env files
|
|
832
|
+
const dp = getPanelConfig().default_provider;
|
|
833
|
+
if (dp?.apiKey && dp?.providerId && envFiles.length) {
|
|
834
|
+
const envKey = inferProviderApiKeyEnvName(dp.providerId);
|
|
835
|
+
updateEnvFile(envFiles[0], { [envKey]: dp.apiKey });
|
|
836
|
+
// Also write to provider.env as UPSTREAM_API_KEY (LLM proxy reads this first)
|
|
837
|
+
const providerEnv = join(dirname(envFiles[0]), "provider.env");
|
|
838
|
+
updateEnvFile(providerEnv, { UPSTREAM_API_KEY: dp.apiKey });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
// Copy cloned provider.env BEFORE proxy bootstrap so bootstrap can find the API key
|
|
842
|
+
if (cloneFrom && envFiles.length) {
|
|
843
|
+
const srcEnvFiles = getRuntimeEnvFiles(cloneFrom);
|
|
844
|
+
const srcEnvFile = srcEnvFiles[0];
|
|
845
|
+
const dstEnvFile = envFiles[0];
|
|
846
|
+
// Copy provider.env (upstream API key)
|
|
847
|
+
if (srcEnvFile) {
|
|
848
|
+
const srcProvider = join(dirname(srcEnvFile), "provider.env");
|
|
849
|
+
const dstProvider = join(dirname(dstEnvFile), "provider.env");
|
|
850
|
+
if (existsSync(srcProvider) && !existsSync(dstProvider)) {
|
|
851
|
+
copyFileSync(srcProvider, dstProvider);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
// Note: model.env is NOT copied (new instance needs its own proxy token)
|
|
855
|
+
}
|
|
856
|
+
// Bootstrap proxy: generate proxy token and write model.env so instance
|
|
857
|
+
// is ready to run immediately without requiring a manual "save config" first
|
|
858
|
+
try {
|
|
859
|
+
const { bootstrapInstanceProxy } = await import("../services/llm-proxy/index.js");
|
|
860
|
+
await bootstrapInstanceProxy(instanceId);
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
console.warn(`[instance] Proxy bootstrap for ${instanceId} deferred: ${e.message}`);
|
|
864
|
+
}
|
|
865
|
+
// If running as root, hand ownership of all created files to the service user
|
|
866
|
+
// so the openclaw process (running as that user) can read/write its own files.
|
|
867
|
+
const svcUser = resolveServiceUser();
|
|
868
|
+
if (svcUser) {
|
|
869
|
+
try {
|
|
870
|
+
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, d], { timeout: 10_000 });
|
|
871
|
+
if (!home.startsWith(d + "/") && existsSync(home)) {
|
|
872
|
+
execFileSync("chown", ["-R", `${svcUser.uid}:${svcUser.gid}`, home], { timeout: 10_000 });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
catch (e) {
|
|
876
|
+
console.warn(`[instance] chown for ${instanceId} failed:`, e.message);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return meta;
|
|
880
|
+
}
|
|
881
|
+
finally {
|
|
882
|
+
if (allocatedPort)
|
|
883
|
+
_pendingPorts.delete(allocatedPort);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
export function updateInstance(instanceId, name, description) {
|
|
887
|
+
const meta = getInstance(instanceId);
|
|
888
|
+
if (!meta)
|
|
889
|
+
return null;
|
|
890
|
+
if (name != null)
|
|
891
|
+
meta.name = name;
|
|
892
|
+
if (description != null)
|
|
893
|
+
meta.description = description;
|
|
894
|
+
safeWriteJson(instanceMetaPath(instanceId), meta);
|
|
895
|
+
chownToServiceUser(instanceMetaPath(instanceId));
|
|
896
|
+
return meta;
|
|
897
|
+
}
|
|
898
|
+
export function deleteInstance(instanceId) {
|
|
899
|
+
const d = instanceDir(instanceId);
|
|
900
|
+
if (!existsSync(d))
|
|
901
|
+
return { ok: false, warnings: ["Instance directory not found"] };
|
|
902
|
+
const warnings = [];
|
|
903
|
+
// Cache metadata BEFORE deletion so we can check custom openclaw_home after rm
|
|
904
|
+
const meta = getInstance(instanceId);
|
|
905
|
+
const home = meta?.openclaw_home;
|
|
906
|
+
// Clean up Nomad Variables (async, best-effort)
|
|
907
|
+
import("./nomad-manager.js").then((nm) => {
|
|
908
|
+
nm.purgeInstanceVariables(instanceId).catch((e) => {
|
|
909
|
+
console.warn(`[instance] Failed to purge Nomad variables for ${instanceId}:`, e.message);
|
|
910
|
+
});
|
|
911
|
+
}).catch((e) => {
|
|
912
|
+
console.warn(`[instance] Could not load nomad-manager for cleanup:`, e.message);
|
|
913
|
+
});
|
|
914
|
+
let dirDeleted = false;
|
|
915
|
+
try {
|
|
916
|
+
rmSync(d, { recursive: true, force: true });
|
|
917
|
+
dirDeleted = true;
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
try {
|
|
921
|
+
execFileSync("sudo", ["rm", "-rf", d], { timeout: 10000 });
|
|
922
|
+
dirDeleted = true;
|
|
923
|
+
}
|
|
924
|
+
catch (e) {
|
|
925
|
+
warnings.push(`Failed to delete instance directory: ${e.message}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Warn if custom openclaw_home exists outside the instance dir
|
|
929
|
+
if (home && !home.startsWith(d) && existsSync(home)) {
|
|
930
|
+
warnings.push(`Custom openclaw_home '${home}' was preserved. Delete manually if no longer needed.`);
|
|
931
|
+
}
|
|
932
|
+
return { ok: dirDeleted, warnings: warnings.length ? warnings : undefined };
|
|
933
|
+
}
|
|
934
|
+
export function getConfig(instanceId) {
|
|
935
|
+
const config = loadEffectiveConfig(instanceId);
|
|
936
|
+
if (!config)
|
|
937
|
+
return null;
|
|
938
|
+
// Merge x-jishushell metadata from instance.json
|
|
939
|
+
const meta = getInstance(instanceId);
|
|
940
|
+
if (meta?.["x-jishushell"]) {
|
|
941
|
+
config["x-jishushell"] = meta["x-jishushell"];
|
|
942
|
+
}
|
|
943
|
+
return injectProviderApiKeys(instanceId, config);
|
|
944
|
+
}
|
|
945
|
+
export function getStoredConfig(instanceId) {
|
|
946
|
+
const config = loadEffectiveConfig(instanceId);
|
|
947
|
+
if (!config)
|
|
948
|
+
return null;
|
|
949
|
+
const meta = getInstance(instanceId);
|
|
950
|
+
if (meta?.["x-jishushell"]) {
|
|
951
|
+
config["x-jishushell"] = meta["x-jishushell"];
|
|
952
|
+
}
|
|
953
|
+
return config;
|
|
954
|
+
}
|
|
955
|
+
export function saveConfig(instanceId, config) {
|
|
956
|
+
const configPath = openclawConfigPathInternal(instanceId);
|
|
957
|
+
if (!existsSync(instanceDir(instanceId)))
|
|
958
|
+
return false;
|
|
959
|
+
if (!existsSync(configPath)) {
|
|
960
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
961
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
962
|
+
// Ensure the state dir is group-writable so the Docker container (which may
|
|
963
|
+
// run as a different UID) can create sub-directories via shared group.
|
|
964
|
+
try {
|
|
965
|
+
chmodSync(dirname(configPath), 0o770);
|
|
966
|
+
}
|
|
967
|
+
catch { /* best effort */ }
|
|
968
|
+
if (existsSync(legacyPath))
|
|
969
|
+
copyFileSync(legacyPath, configPath);
|
|
970
|
+
}
|
|
971
|
+
// Save x-jishushell metadata to instance.json (not openclaw.json)
|
|
972
|
+
if (config["x-jishushell"]) {
|
|
973
|
+
const metaPath = instanceMetaPath(instanceId);
|
|
974
|
+
if (existsSync(metaPath)) {
|
|
975
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
976
|
+
meta["x-jishushell"] = config["x-jishushell"];
|
|
977
|
+
safeWriteJson(metaPath, meta);
|
|
978
|
+
chownToServiceUser(metaPath);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const [configToWrite, envUpdates] = prepareConfigForSave(instanceId, config);
|
|
982
|
+
// If openclaw-lark is configured as enabled, resolve which feishu plugin should actually be used:
|
|
983
|
+
// - If built-in feishu/ exists in stock AND openclaw-lark/ is not installed anywhere → switch to
|
|
984
|
+
// built-in feishu (removes the stale openclaw-lark reference that breaks container startup).
|
|
985
|
+
// - If both exist → keep openclaw-lark but disable built-in feishu to avoid conflict.
|
|
986
|
+
if (configToWrite.plugins?.entries?.["openclaw-lark"]?.enabled) {
|
|
987
|
+
const stockExtDir = getStockExtensionsDir();
|
|
988
|
+
const stockFeishu = join(stockExtDir, "feishu");
|
|
989
|
+
const stockOcl = join(stockExtDir, "openclaw-lark");
|
|
990
|
+
const instanceOcl = join(getChannelExtensionsDir(instanceId), "openclaw-lark");
|
|
991
|
+
if (existsSync(stockFeishu) && !existsSync(stockOcl) && !existsSync(instanceOcl)) {
|
|
992
|
+
// Built-in available, community package absent → switch to built-in
|
|
993
|
+
configToWrite.plugins.entries.feishu = { enabled: true };
|
|
994
|
+
delete configToWrite.plugins.entries["openclaw-lark"];
|
|
995
|
+
}
|
|
996
|
+
else if (existsSync(stockFeishu)) {
|
|
997
|
+
// Both present → disable built-in to avoid conflict with community package
|
|
998
|
+
configToWrite.plugins ??= {};
|
|
999
|
+
configToWrite.plugins.entries ??= {};
|
|
1000
|
+
configToWrite.plugins.entries.feishu = { enabled: false };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// Preserve plugins.installs from existing config on disk —
|
|
1004
|
+
// these are managed by `openclaw plugins install` and must not be overwritten
|
|
1005
|
+
// by the frontend config which doesn't track them.
|
|
1006
|
+
if (existsSync(configPath)) {
|
|
1007
|
+
try {
|
|
1008
|
+
const existing = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1009
|
+
if (existing.plugins?.installs) {
|
|
1010
|
+
configToWrite.plugins ??= {};
|
|
1011
|
+
configToWrite.plugins.installs = { ...existing.plugins.installs, ...configToWrite.plugins?.installs };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
catch { /* best effort */ }
|
|
1015
|
+
}
|
|
1016
|
+
// backup
|
|
1017
|
+
if (existsSync(configPath)) {
|
|
1018
|
+
copyFileSync(configPath, configPath + ".bak");
|
|
1019
|
+
}
|
|
1020
|
+
const configJson = JSON.stringify(configToWrite, null, 2);
|
|
1021
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
1022
|
+
writeFileSync(configPath + ".tmp", configJson, { mode: 0o600 });
|
|
1023
|
+
// Verify tmp file is valid JSON before replacing (guards against disk-full partial writes)
|
|
1024
|
+
JSON.parse(readFileSync(configPath + ".tmp", "utf-8"));
|
|
1025
|
+
renameSync(configPath + ".tmp", configPath);
|
|
1026
|
+
chownToServiceUser(configPath);
|
|
1027
|
+
// also write to legacy path (with restricted permissions)
|
|
1028
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1029
|
+
if (existsSync(legacyPath)) {
|
|
1030
|
+
copyFileSync(legacyPath, legacyPath + ".bak");
|
|
1031
|
+
}
|
|
1032
|
+
writeFileSync(legacyPath + ".tmp", configJson, { mode: 0o600 });
|
|
1033
|
+
JSON.parse(readFileSync(legacyPath + ".tmp", "utf-8"));
|
|
1034
|
+
renameSync(legacyPath + ".tmp", legacyPath);
|
|
1035
|
+
chownToServiceUser(legacyPath);
|
|
1036
|
+
if (Object.keys(envUpdates).length) {
|
|
1037
|
+
const envFiles = getRuntimeEnvFiles(instanceId);
|
|
1038
|
+
if (envFiles.length)
|
|
1039
|
+
updateEnvFile(envFiles[0], envUpdates);
|
|
1040
|
+
}
|
|
1041
|
+
// Plugins are installed inside the container — no host-side auto-install on config save.
|
|
1042
|
+
// Notify listeners (e.g. llm-proxy cache invalidation)
|
|
1043
|
+
for (const listener of _configChangeListeners) {
|
|
1044
|
+
try {
|
|
1045
|
+
listener(instanceId);
|
|
1046
|
+
}
|
|
1047
|
+
catch { /* ignore listener errors */ }
|
|
1048
|
+
}
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
export function getOpenclawHome(instanceId) {
|
|
1052
|
+
return getOpenclawHomeInternal(instanceId);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Save WeChat login credentials for an instance.
|
|
1056
|
+
* Save Feishu/Lark credentials from OAuth Device Code flow.
|
|
1057
|
+
*/
|
|
1058
|
+
// Feishu app IDs issued by the open platform follow the pattern cli_<hex/alnum>.
|
|
1059
|
+
// Validate appId to reject malformed values sourced from OAuth API responses.
|
|
1060
|
+
const FEISHU_APP_ID_RE = /^cli_[a-zA-Z0-9]{8,64}$/;
|
|
1061
|
+
export function saveFeishuCredentials(instanceId, creds) {
|
|
1062
|
+
if (!FEISHU_APP_ID_RE.test(creds.appId)) {
|
|
1063
|
+
throw new Error(`Invalid Feishu appId format: expected cli_<alnum> (got "${creds.appId}")`);
|
|
1064
|
+
}
|
|
1065
|
+
if (!creds.appSecret || typeof creds.appSecret !== "string" || creds.appSecret.length < 4) {
|
|
1066
|
+
throw new Error("Invalid Feishu appSecret: must be a non-empty string");
|
|
1067
|
+
}
|
|
1068
|
+
const configPath = openclawConfigPathInternal(instanceId);
|
|
1069
|
+
let config = safeReadJson(configPath, "feishu-creds") || {};
|
|
1070
|
+
// Enable @larksuite/openclaw-lark plugin (installed inside Docker container),
|
|
1071
|
+
// disable built-in @openclaw/feishu to avoid conflict.
|
|
1072
|
+
config.plugins ??= {};
|
|
1073
|
+
config.plugins.entries ??= {};
|
|
1074
|
+
config.plugins.entries.feishu = { enabled: false };
|
|
1075
|
+
config.plugins.entries["openclaw-lark"] = { enabled: true };
|
|
1076
|
+
// Set channel config — official plugin reads from channels.feishu
|
|
1077
|
+
config.channels ??= {};
|
|
1078
|
+
config.channels.feishu = {
|
|
1079
|
+
...config.channels.feishu,
|
|
1080
|
+
enabled: true,
|
|
1081
|
+
appId: creds.appId,
|
|
1082
|
+
appSecret: creds.appSecret,
|
|
1083
|
+
domain: creds.domain,
|
|
1084
|
+
dmPolicy: "pairing",
|
|
1085
|
+
};
|
|
1086
|
+
safeWriteJson(configPath, config);
|
|
1087
|
+
chownToServiceUser(configPath);
|
|
1088
|
+
console.log(`[instance-manager] Feishu credentials saved for ${instanceId}, domain=${creds.domain}`);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Writes account data + updates openclaw.json to enable the plugin and register the account.
|
|
1092
|
+
*/
|
|
1093
|
+
const SAFE_ACCOUNT_ID_RE = /^[a-zA-Z0-9@._-]{1,128}$/;
|
|
1094
|
+
export function saveWeixinCredentials(instanceId, creds) {
|
|
1095
|
+
// Prevent path traversal via accountId (used as filename)
|
|
1096
|
+
if (!creds.accountId || !SAFE_ACCOUNT_ID_RE.test(creds.accountId) || creds.accountId.includes('..')) {
|
|
1097
|
+
throw new Error(`Invalid accountId: must be 1-128 chars of [a-zA-Z0-9@._-] without '..'`);
|
|
1098
|
+
}
|
|
1099
|
+
const home = getOpenclawHomeInternal(instanceId);
|
|
1100
|
+
const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
|
|
1101
|
+
const accountsDir = join(stateDir, "accounts");
|
|
1102
|
+
mkdirSync(accountsDir, { recursive: true });
|
|
1103
|
+
// Save account credentials file (via safeWriteJson for atomic + .bak protection)
|
|
1104
|
+
const credObj = {
|
|
1105
|
+
token: creds.token,
|
|
1106
|
+
baseUrl: creds.baseUrl,
|
|
1107
|
+
userId: creds.userId,
|
|
1108
|
+
savedAt: new Date().toISOString(),
|
|
1109
|
+
};
|
|
1110
|
+
safeWriteJson(join(accountsDir, `${creds.accountId}.json`), credObj);
|
|
1111
|
+
// OpenClaw also needs a "default" account file with the same credentials
|
|
1112
|
+
safeWriteJson(join(accountsDir, "default.json"), credObj);
|
|
1113
|
+
chownToServiceUser(join(accountsDir, `${creds.accountId}.json`), join(accountsDir, "default.json"));
|
|
1114
|
+
// Update accounts.json index (required by the plugin to discover accounts)
|
|
1115
|
+
const indexPath = join(stateDir, "accounts.json");
|
|
1116
|
+
let index = [];
|
|
1117
|
+
try {
|
|
1118
|
+
const raw = readFileSync(indexPath, "utf-8");
|
|
1119
|
+
index = JSON.parse(raw);
|
|
1120
|
+
}
|
|
1121
|
+
catch { /* start fresh */ }
|
|
1122
|
+
if (!Array.isArray(index))
|
|
1123
|
+
index = [];
|
|
1124
|
+
if (!index.includes(creds.accountId))
|
|
1125
|
+
index.push(creds.accountId);
|
|
1126
|
+
safeWriteJson(indexPath, index);
|
|
1127
|
+
// Update openclaw.json: enable plugin + register account
|
|
1128
|
+
const configPath = openclawConfigPathInternal(instanceId);
|
|
1129
|
+
let config = safeReadJson(configPath, "weixin-creds") || {};
|
|
1130
|
+
// Enable plugin
|
|
1131
|
+
config.plugins ??= {};
|
|
1132
|
+
config.plugins.entries ??= {};
|
|
1133
|
+
config.plugins.entries["openclaw-weixin"] ??= {};
|
|
1134
|
+
config.plugins.entries["openclaw-weixin"].enabled = true;
|
|
1135
|
+
// Enable channel with account
|
|
1136
|
+
config.channels ??= {};
|
|
1137
|
+
config.channels["openclaw-weixin"] ??= {};
|
|
1138
|
+
config.channels["openclaw-weixin"].enabled = true;
|
|
1139
|
+
// Register account with both original and normalized IDs (OpenClaw normalizes @ and . to -)
|
|
1140
|
+
const normalizedId = creds.accountId.replace(/[@.]/g, "-");
|
|
1141
|
+
const accounts = config.channels["openclaw-weixin"].accounts ??= {};
|
|
1142
|
+
accounts[creds.accountId] = { enabled: true };
|
|
1143
|
+
if (normalizedId !== creds.accountId)
|
|
1144
|
+
accounts[normalizedId] = { enabled: true };
|
|
1145
|
+
accounts["default"] = { enabled: true };
|
|
1146
|
+
// Set defaultAccount (required by OpenClaw)
|
|
1147
|
+
if (!config.channels["openclaw-weixin"].defaultAccount) {
|
|
1148
|
+
config.channels["openclaw-weixin"].defaultAccount = "default";
|
|
1149
|
+
}
|
|
1150
|
+
safeWriteJson(configPath, config);
|
|
1151
|
+
chownToServiceUser(configPath);
|
|
1152
|
+
console.log(`[instance-manager] WeChat credentials saved for ${instanceId}, account=${creds.accountId}`);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Get connected WeChat accounts for an instance.
|
|
1156
|
+
*/
|
|
1157
|
+
export function getWeixinAccounts(instanceId) {
|
|
1158
|
+
const home = getOpenclawHomeInternal(instanceId);
|
|
1159
|
+
const stateDir = join(home, OPENCLAW_STATE_DIRNAME, "openclaw-weixin");
|
|
1160
|
+
const accountsDir = join(stateDir, "accounts");
|
|
1161
|
+
if (!existsSync(accountsDir))
|
|
1162
|
+
return [];
|
|
1163
|
+
// Only return accounts listed in the index (skip default.json and other auxiliary files)
|
|
1164
|
+
let indexedIds = [];
|
|
1165
|
+
try {
|
|
1166
|
+
indexedIds = JSON.parse(readFileSync(join(stateDir, "accounts.json"), "utf-8"));
|
|
1167
|
+
}
|
|
1168
|
+
catch { /* fallback to scanning */ }
|
|
1169
|
+
const results = [];
|
|
1170
|
+
for (const f of readdirSync(accountsDir)) {
|
|
1171
|
+
if (!f.endsWith(".json"))
|
|
1172
|
+
continue;
|
|
1173
|
+
const id = f.replace(/\.json$/, "");
|
|
1174
|
+
if (indexedIds.length > 0 && !indexedIds.includes(id))
|
|
1175
|
+
continue; // skip auxiliary files
|
|
1176
|
+
if (id === "default")
|
|
1177
|
+
continue; // always skip default alias
|
|
1178
|
+
try {
|
|
1179
|
+
const data = JSON.parse(readFileSync(join(accountsDir, f), "utf-8"));
|
|
1180
|
+
results.push({
|
|
1181
|
+
accountId: id,
|
|
1182
|
+
userId: data.userId,
|
|
1183
|
+
savedAt: data.savedAt,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
catch { /* skip */ }
|
|
1187
|
+
}
|
|
1188
|
+
return results;
|
|
1189
|
+
}
|
|
1190
|
+
export function getOpenclawConfigPath(instanceId) {
|
|
1191
|
+
return openclawConfigPathInternal(instanceId);
|
|
1192
|
+
}
|
|
1193
|
+
export function getLegacyOpenclawConfigPath(instanceId) {
|
|
1194
|
+
return legacyOpenclawConfigPath(instanceId);
|
|
1195
|
+
}
|
|
1196
|
+
export function getInstanceRuntime(instanceId) {
|
|
1197
|
+
const meta = getInstance(instanceId);
|
|
1198
|
+
if (!meta)
|
|
1199
|
+
return {};
|
|
1200
|
+
return structuredClone(meta.runtime || {});
|
|
1201
|
+
}
|
|
1202
|
+
export function getRuntimeEnvFiles(instanceId) {
|
|
1203
|
+
const runtime = getInstanceRuntime(instanceId);
|
|
1204
|
+
const envFiles = (runtime.env_files || []).map((p) => normalizePath(p)).filter(Boolean);
|
|
1205
|
+
return envFiles.length ? envFiles : [defaultModelEnvFile(instanceId)];
|
|
1206
|
+
}
|
|
1207
|
+
export function getGatewayPort(instanceId) {
|
|
1208
|
+
return extractGatewayPort(getInstanceRuntime(instanceId)) || DEFAULT_GATEWAY_PORT;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Detect the host address where the gateway port is actually listening.
|
|
1212
|
+
*
|
|
1213
|
+
* Strategy by driver:
|
|
1214
|
+
* - raw_exec (Linux/Pi): use `ss` to read the listening address directly.
|
|
1215
|
+
* - docker (macOS/bridge): Nomad publishes the port on the Nomad client's
|
|
1216
|
+
* interface IP (e.g. 10.x.x.x), NOT 127.0.0.1. Query the Nomad allocation
|
|
1217
|
+
* for `AllocatedResources.Shared.Ports[label=gateway].HostIP`.
|
|
1218
|
+
*
|
|
1219
|
+
* Result is cached for 30 s to avoid an API call on every proxy request.
|
|
1220
|
+
*/
|
|
1221
|
+
const _gwHostCache = new Map();
|
|
1222
|
+
const GW_HOST_CACHE_TTL = 30000;
|
|
1223
|
+
export async function getGatewayHost(instanceId) {
|
|
1224
|
+
const cached = _gwHostCache.get(instanceId);
|
|
1225
|
+
if (cached && Date.now() - cached.ts < GW_HOST_CACHE_TTL)
|
|
1226
|
+
return cached.host;
|
|
1227
|
+
const port = getGatewayPort(instanceId);
|
|
1228
|
+
let result = "127.0.0.1";
|
|
1229
|
+
try {
|
|
1230
|
+
const { getNomadDriver } = await import("../config.js");
|
|
1231
|
+
if (getNomadDriver() === "docker") {
|
|
1232
|
+
const { getNomadAddr, getNomadToken } = await import("../config.js");
|
|
1233
|
+
const jid = `openclaw-${instanceId}`;
|
|
1234
|
+
const headers = { "Content-Type": "application/json" };
|
|
1235
|
+
const token = getNomadToken();
|
|
1236
|
+
if (token)
|
|
1237
|
+
headers["X-Nomad-Token"] = token;
|
|
1238
|
+
const resp = await fetch(`${getNomadAddr()}/v1/job/${encodeURIComponent(jid)}/allocations`, {
|
|
1239
|
+
headers,
|
|
1240
|
+
signal: AbortSignal.timeout(5000),
|
|
1241
|
+
});
|
|
1242
|
+
if (resp.ok) {
|
|
1243
|
+
const allocs = await resp.json();
|
|
1244
|
+
const alloc = allocs.find((a) => a.ClientStatus === "running")
|
|
1245
|
+
?? allocs.find((a) => a.ClientStatus === "pending");
|
|
1246
|
+
if (alloc) {
|
|
1247
|
+
const detail = await fetch(`${getNomadAddr()}/v1/allocation/${encodeURIComponent(alloc.ID)}`, { headers, signal: AbortSignal.timeout(5000) });
|
|
1248
|
+
if (detail.ok) {
|
|
1249
|
+
const d = await detail.json();
|
|
1250
|
+
const ports = d?.AllocatedResources?.Shared?.Ports ?? [];
|
|
1251
|
+
const gwPort = ports.find((p) => p.Label === "gateway");
|
|
1252
|
+
if (gwPort?.HostIP && gwPort.HostIP !== "0.0.0.0") {
|
|
1253
|
+
result = gwPort.HostIP;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
// If Nomad API resolved a non-default host, cache and return it
|
|
1259
|
+
if (result !== "127.0.0.1") {
|
|
1260
|
+
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1261
|
+
return result;
|
|
1262
|
+
}
|
|
1263
|
+
// Otherwise fall through to ss-based detection
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
catch { /* fall through */ }
|
|
1267
|
+
try {
|
|
1268
|
+
const out = execFileSync("ss", ["-tlnH", "sport", "=", ":" + safePort(port)], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
|
|
1269
|
+
for (const line of out.split("\n")) {
|
|
1270
|
+
const match = line.match(/\s([\d.]+):(\d+)\s/);
|
|
1271
|
+
if (match && match[2] === String(port)) {
|
|
1272
|
+
const addr = match[1];
|
|
1273
|
+
result = addr === "0.0.0.0" ? "127.0.0.1" : addr;
|
|
1274
|
+
break;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
catch { /* fall through */ }
|
|
1279
|
+
_gwHostCache.set(instanceId, { host: result, ts: Date.now() });
|
|
1280
|
+
return result;
|
|
1281
|
+
}
|
|
1282
|
+
export function findInstancesSharingOpenclawHome(instanceId) {
|
|
1283
|
+
const targetHome = normalizePath(getOpenclawHome(instanceId));
|
|
1284
|
+
return listInstances()
|
|
1285
|
+
.filter((inst) => inst.id !== instanceId)
|
|
1286
|
+
.filter((inst) => normalizePath(inst.openclaw_home || defaultOpenclawHome(inst.id)) === targetHome)
|
|
1287
|
+
.map((inst) => inst.id);
|
|
1288
|
+
}
|
|
1289
|
+
export function findInstancesSharingGatewayPort(instanceId) {
|
|
1290
|
+
const targetPort = getGatewayPort(instanceId);
|
|
1291
|
+
return listInstances()
|
|
1292
|
+
.filter((inst) => inst.id !== instanceId)
|
|
1293
|
+
.filter((inst) => extractGatewayPort(inst.runtime) === targetPort)
|
|
1294
|
+
.map((inst) => inst.id);
|
|
1295
|
+
}
|
|
1296
|
+
export function getRuntimeEnv(instanceId) {
|
|
1297
|
+
const runtime = getInstanceRuntime(instanceId);
|
|
1298
|
+
const env = {};
|
|
1299
|
+
for (const envFile of getRuntimeEnvFiles(instanceId)) {
|
|
1300
|
+
Object.assign(env, parseEnvFile(envFile));
|
|
1301
|
+
}
|
|
1302
|
+
for (const [key, value] of Object.entries(runtime.env || {})) {
|
|
1303
|
+
if (value != null)
|
|
1304
|
+
env[key] = String(value);
|
|
1305
|
+
}
|
|
1306
|
+
return env;
|
|
1307
|
+
}
|
|
1308
|
+
// Re-export instanceDir for nomad-manager
|
|
1309
|
+
export { instanceDir as getInstanceDir };
|
|
1310
|
+
function resolveExistingConfigPath(instanceId) {
|
|
1311
|
+
const runtimePath = openclawConfigPathInternal(instanceId);
|
|
1312
|
+
if (existsSync(runtimePath))
|
|
1313
|
+
return runtimePath;
|
|
1314
|
+
const legacyPath = legacyOpenclawConfigPath(instanceId);
|
|
1315
|
+
if (existsSync(legacyPath))
|
|
1316
|
+
return legacyPath;
|
|
1317
|
+
return runtimePath;
|
|
1318
|
+
}
|
|
1319
|
+
//# sourceMappingURL=instance-manager.js.map
|