jishushell 0.0.1 → 0.4.2-beta2
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/INSTALL-NOTICE +41 -0
- 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 +78 -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 +36 -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 +87 -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 +124 -0
- package/install/post-uninstall.sh +46 -0
- package/package.json +57 -8
- package/public/assets/Dashboard-Dxsq690N.js +1 -0
- package/public/assets/InitPassword-CslWYy8G.js +1 -0
- package/public/assets/InstanceDetail-DmEkMj-t.js +14 -0
- package/public/assets/Login-d45wtgVA.js +1 -0
- package/public/assets/NewInstance-Czp5-AJe.js +1 -0
- package/public/assets/Settings-BKMGck05.js +1 -0
- package/public/assets/Setup-D3rfLWjZ.js +1 -0
- package/public/assets/index-77Ug7feY.css +1 -0
- package/public/assets/index-DkDnIohs.js +16 -0
- package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
- package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
- package/public/assets/providers-lBSOjUWy.js +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,1303 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { existsSync, realpathSync } from "fs";
|
|
3
|
+
import { readFile, stat } from "fs/promises";
|
|
4
|
+
import { request as httpRequest } from "http";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { getServiceManagerType } from "../config.js";
|
|
7
|
+
import { PROXY_IDENTITY_HEADERS } from "../constants.js";
|
|
8
|
+
import * as instanceManager from "../services/instance-manager.js";
|
|
9
|
+
import * as llmProxy from "../services/llm-proxy/index.js";
|
|
10
|
+
import * as pluginInstaller from "../services/plugin-installer.js";
|
|
11
|
+
import { TtlMap } from "../utils/ttl-cache.js";
|
|
12
|
+
// Hop-by-hop headers that must not be forwarded by a proxy (RFC 2616 §13.5.1)
|
|
13
|
+
const HOP_BY_HOP = new Set([
|
|
14
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
15
|
+
"te", "trailer", "transfer-encoding", "upgrade",
|
|
16
|
+
]);
|
|
17
|
+
function readHeaderValue(value) {
|
|
18
|
+
const header = Array.isArray(value) ? value[0] : value;
|
|
19
|
+
return typeof header === "string" && header.trim() ? header.trim() : null;
|
|
20
|
+
}
|
|
21
|
+
function parseHttpOrigin(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(value);
|
|
26
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function inferRequestOrigin(request) {
|
|
33
|
+
// Only trust browser-sent Origin/Referer for auto-allowlisting. Host and
|
|
34
|
+
// X-Forwarded-* are proxy metadata and should not become persisted origin
|
|
35
|
+
// policy by themselves.
|
|
36
|
+
return (parseHttpOrigin(readHeaderValue(request?.headers?.origin))
|
|
37
|
+
?? parseHttpOrigin(readHeaderValue(request?.headers?.referer)));
|
|
38
|
+
}
|
|
39
|
+
function ensureControlUiAllowedOrigin(instanceId, origin) {
|
|
40
|
+
const normalizedOrigin = origin.trim();
|
|
41
|
+
if (!normalizedOrigin)
|
|
42
|
+
return false;
|
|
43
|
+
const config = instanceManager.getStoredConfig(instanceId);
|
|
44
|
+
if (!config)
|
|
45
|
+
return false;
|
|
46
|
+
const gateway = config.gateway ??= {};
|
|
47
|
+
const controlUi = gateway.controlUi ??= {};
|
|
48
|
+
const existing = Array.isArray(controlUi.allowedOrigins)
|
|
49
|
+
? controlUi.allowedOrigins.map((value) => String(value))
|
|
50
|
+
: [];
|
|
51
|
+
const normalized = new Set(existing.map((value) => value.trim().toLowerCase()).filter(Boolean));
|
|
52
|
+
if (normalized.has("*") || normalized.has(normalizedOrigin.toLowerCase()))
|
|
53
|
+
return false;
|
|
54
|
+
controlUi.allowedOrigins = [...existing.filter((value) => value.trim()), normalizedOrigin];
|
|
55
|
+
instanceManager.saveConfig(instanceId, config);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// Resolve service manager once at route registration, re-resolve on config change
|
|
59
|
+
let _svc = null;
|
|
60
|
+
let _svcType = "";
|
|
61
|
+
async function getSvc() {
|
|
62
|
+
const currentType = getServiceManagerType();
|
|
63
|
+
if (_svc && _svcType === currentType)
|
|
64
|
+
return _svc;
|
|
65
|
+
_svc = currentType === "nomad"
|
|
66
|
+
? await import("../services/nomad-manager.js")
|
|
67
|
+
: await import("../services/process-manager.js");
|
|
68
|
+
_svcType = currentType;
|
|
69
|
+
return _svc;
|
|
70
|
+
}
|
|
71
|
+
function validateId(id) {
|
|
72
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(id)) {
|
|
73
|
+
return "ID must be lowercase alphanumeric with hyphens, 1-63 chars";
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// TTL 15s: outlives the frontend 10s polling interval so most requests hit cache
|
|
78
|
+
const statusCache = new TtlMap(15_000);
|
|
79
|
+
const controlUiRestartInFlight = new Map();
|
|
80
|
+
function getCachedStatus(svc, instanceId) {
|
|
81
|
+
const cached = statusCache.get(instanceId);
|
|
82
|
+
if (cached !== undefined)
|
|
83
|
+
return Promise.resolve(cached);
|
|
84
|
+
return Promise.resolve(svc.getStatus(instanceId)).then((data) => {
|
|
85
|
+
statusCache.set(instanceId, data);
|
|
86
|
+
return data;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function restartRunningInstanceForControlUiOrigin(instanceId, origin) {
|
|
90
|
+
const inFlight = controlUiRestartInFlight.get(instanceId);
|
|
91
|
+
if (inFlight)
|
|
92
|
+
return inFlight;
|
|
93
|
+
const task = (async () => {
|
|
94
|
+
const svc = await getSvc();
|
|
95
|
+
const status = await svc.getStatus(instanceId);
|
|
96
|
+
if (status.status !== "running")
|
|
97
|
+
return;
|
|
98
|
+
const result = await svc.restartInstance(instanceId);
|
|
99
|
+
statusCache.delete(instanceId);
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
console.warn(`[gateway-launch] failed to auto-restart ${instanceId} after allowing origin ${origin}:`, result.error || "unknown error");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
console.log(`[gateway-launch] auto-restarted ${instanceId} after allowing origin ${origin}`);
|
|
105
|
+
})().finally(() => {
|
|
106
|
+
controlUiRestartInFlight.delete(instanceId);
|
|
107
|
+
});
|
|
108
|
+
controlUiRestartInFlight.set(instanceId, task);
|
|
109
|
+
return task;
|
|
110
|
+
}
|
|
111
|
+
export async function instanceRoutes(app) {
|
|
112
|
+
// List
|
|
113
|
+
app.get("/api/instances", async () => {
|
|
114
|
+
const svc = await getSvc();
|
|
115
|
+
const instances = instanceManager.listInstances();
|
|
116
|
+
const statuses = await Promise.all(instances.map(inst => getCachedStatus(svc, inst.id).catch(() => ({ status: "unknown" }))));
|
|
117
|
+
return instances.map((inst, i) => ({ ...inst, service: statuses[i] }));
|
|
118
|
+
});
|
|
119
|
+
// Create
|
|
120
|
+
app.post("/api/instances", async (req, reply) => {
|
|
121
|
+
const err = validateId(req.body.id);
|
|
122
|
+
if (err)
|
|
123
|
+
return reply.status(400).send({ detail: err });
|
|
124
|
+
// Validate name/description length
|
|
125
|
+
if (typeof req.body.name !== "string" || req.body.name.length === 0 || req.body.name.length > 256) {
|
|
126
|
+
return reply.status(400).send({ detail: "Name must be 1-256 characters" });
|
|
127
|
+
}
|
|
128
|
+
if (req.body.description && req.body.description.length > 2048) {
|
|
129
|
+
return reply.status(400).send({ detail: "Description must be at most 2048 characters" });
|
|
130
|
+
}
|
|
131
|
+
// Validate clone_from if provided
|
|
132
|
+
if (req.body.clone_from) {
|
|
133
|
+
const cloneErr = validateId(req.body.clone_from);
|
|
134
|
+
if (cloneErr)
|
|
135
|
+
return reply.status(400).send({ detail: `Invalid clone_from: ${cloneErr}` });
|
|
136
|
+
const sourceInstance = instanceManager.getInstance(req.body.clone_from);
|
|
137
|
+
if (!sourceInstance)
|
|
138
|
+
return reply.status(400).send({ detail: `Source instance '${req.body.clone_from}' not found` });
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const meta = await instanceManager.createInstance(req.body.id, req.body.name, req.body.description || "", req.body.clone_from, req.body.openclaw_home);
|
|
142
|
+
// Auto-start if default provider is configured (model ready to use)
|
|
143
|
+
const { getPanelConfig } = await import("../config.js");
|
|
144
|
+
const dp = getPanelConfig().default_provider;
|
|
145
|
+
if (dp && dp.providerId && !dp.skipped) {
|
|
146
|
+
const svc = await getSvc();
|
|
147
|
+
svc.startInstance(req.body.id).catch((e) => {
|
|
148
|
+
console.warn(`[instances] Auto-start ${req.body.id} failed: ${e.message}`);
|
|
149
|
+
});
|
|
150
|
+
meta.autoStarted = true;
|
|
151
|
+
}
|
|
152
|
+
return meta;
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
return reply.status(409).send({ detail: e.message });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Get
|
|
159
|
+
app.get("/api/instances/:id", async (req, reply) => {
|
|
160
|
+
const idErr = validateId(req.params.id);
|
|
161
|
+
if (idErr)
|
|
162
|
+
return reply.status(400).send({ detail: idErr });
|
|
163
|
+
const svc = await getSvc();
|
|
164
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
165
|
+
if (!inst)
|
|
166
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
167
|
+
const status = await svc.getStatus(req.params.id);
|
|
168
|
+
return { ...inst, service: status };
|
|
169
|
+
});
|
|
170
|
+
// Update
|
|
171
|
+
app.put("/api/instances/:id", async (req, reply) => {
|
|
172
|
+
const idErr = validateId(req.params.id);
|
|
173
|
+
if (idErr)
|
|
174
|
+
return reply.status(400).send({ detail: idErr });
|
|
175
|
+
if (req.body.name !== undefined && (typeof req.body.name !== "string" || req.body.name.length === 0 || req.body.name.length > 256)) {
|
|
176
|
+
return reply.status(400).send({ detail: "Name must be 1-256 characters" });
|
|
177
|
+
}
|
|
178
|
+
if (req.body.description !== undefined && req.body.description.length > 2048) {
|
|
179
|
+
return reply.status(400).send({ detail: "Description must be at most 2048 characters" });
|
|
180
|
+
}
|
|
181
|
+
const inst = instanceManager.updateInstance(req.params.id, req.body.name, req.body.description);
|
|
182
|
+
if (!inst)
|
|
183
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
184
|
+
return inst;
|
|
185
|
+
});
|
|
186
|
+
// Delete
|
|
187
|
+
app.delete("/api/instances/:id", async (req, reply) => {
|
|
188
|
+
const idErr = validateId(req.params.id);
|
|
189
|
+
if (idErr)
|
|
190
|
+
return reply.status(400).send({ detail: idErr });
|
|
191
|
+
const svc = await getSvc();
|
|
192
|
+
let stopFailed = false;
|
|
193
|
+
try {
|
|
194
|
+
await svc.stopInstance(req.params.id, true);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
try {
|
|
198
|
+
await svc.stopInstance(req.params.id);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
stopFailed = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Also stop any legacy process-manager process that might be lingering
|
|
205
|
+
try {
|
|
206
|
+
const { getLegacyStatus, stopInstance: stopLegacy } = await import("../services/process-manager.js");
|
|
207
|
+
if ((await getLegacyStatus(req.params.id)).status === "running") {
|
|
208
|
+
await stopLegacy(req.params.id);
|
|
209
|
+
stopFailed = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch { /* ignore */ }
|
|
213
|
+
statusCache.delete(req.params.id);
|
|
214
|
+
llmProxy.cleanupInstance(req.params.id);
|
|
215
|
+
const result = instanceManager.deleteInstance(req.params.id);
|
|
216
|
+
if (!result.ok && result.warnings?.some(w => w.includes("not found"))) {
|
|
217
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
218
|
+
}
|
|
219
|
+
const warnings = result.warnings ? [...result.warnings] : [];
|
|
220
|
+
if (stopFailed) {
|
|
221
|
+
console.warn(`[instances] Delete ${req.params.id}: service stop failed, orphaned processes may remain`);
|
|
222
|
+
warnings.push("Service stop did not complete; verify no orphaned processes remain.");
|
|
223
|
+
}
|
|
224
|
+
return { ok: result.ok, warnings: warnings.length ? warnings : undefined };
|
|
225
|
+
});
|
|
226
|
+
// Config
|
|
227
|
+
app.get("/api/instances/:id/config", async (req, reply) => {
|
|
228
|
+
const idErr = validateId(req.params.id);
|
|
229
|
+
if (idErr)
|
|
230
|
+
return reply.status(400).send({ detail: idErr });
|
|
231
|
+
const config = llmProxy.getInstanceConfig(req.params.id);
|
|
232
|
+
if (!config)
|
|
233
|
+
return reply.status(404).send({ detail: "Instance or config not found" });
|
|
234
|
+
return config;
|
|
235
|
+
});
|
|
236
|
+
app.put("/api/instances/:id/config", async (req, reply) => {
|
|
237
|
+
const idErr = validateId(req.params.id);
|
|
238
|
+
if (idErr)
|
|
239
|
+
return reply.status(400).send({ detail: idErr });
|
|
240
|
+
// Basic payload validation
|
|
241
|
+
const body = req.body;
|
|
242
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
243
|
+
return reply.status(400).send({ detail: "Config must be a JSON object" });
|
|
244
|
+
}
|
|
245
|
+
const bodyStr = JSON.stringify(body);
|
|
246
|
+
if (bodyStr.length > 512 * 1024) {
|
|
247
|
+
return reply.status(400).send({ detail: "Config too large (max 512KB)" });
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const saved = await llmProxy.saveInstanceConfig(req.params.id, body);
|
|
251
|
+
return { ok: true, config: saved };
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
const msg = e.message || "";
|
|
255
|
+
if (msg.includes("not found"))
|
|
256
|
+
return reply.status(404).send({ detail: msg });
|
|
257
|
+
return reply.status(400).send({ detail: msg });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Service control
|
|
261
|
+
app.get("/api/instances/:id/service/status", async (req, reply) => {
|
|
262
|
+
const idErr = validateId(req.params.id);
|
|
263
|
+
if (idErr)
|
|
264
|
+
return reply.status(400).send({ detail: idErr });
|
|
265
|
+
const svc = await getSvc();
|
|
266
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
267
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
268
|
+
}
|
|
269
|
+
return svc.getStatus(req.params.id);
|
|
270
|
+
});
|
|
271
|
+
app.post("/api/instances/:id/service/start", async (req, reply) => {
|
|
272
|
+
const idErr = validateId(req.params.id);
|
|
273
|
+
if (idErr)
|
|
274
|
+
return reply.status(400).send({ detail: idErr });
|
|
275
|
+
const svc = await getSvc();
|
|
276
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
277
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
278
|
+
}
|
|
279
|
+
const result = await svc.startInstance(req.params.id);
|
|
280
|
+
statusCache.delete(req.params.id);
|
|
281
|
+
if (!result.ok)
|
|
282
|
+
return reply.status(400).send({ detail: result.error });
|
|
283
|
+
return result;
|
|
284
|
+
});
|
|
285
|
+
app.post("/api/instances/:id/service/stop", async (req, reply) => {
|
|
286
|
+
const idErr = validateId(req.params.id);
|
|
287
|
+
if (idErr)
|
|
288
|
+
return reply.status(400).send({ detail: idErr });
|
|
289
|
+
const svc = await getSvc();
|
|
290
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
291
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
292
|
+
}
|
|
293
|
+
const result = await svc.stopInstance(req.params.id);
|
|
294
|
+
statusCache.delete(req.params.id);
|
|
295
|
+
if (!result.ok)
|
|
296
|
+
return reply.status(400).send({ detail: result.error });
|
|
297
|
+
return result;
|
|
298
|
+
});
|
|
299
|
+
app.post("/api/instances/:id/service/restart", async (req, reply) => {
|
|
300
|
+
const idErr = validateId(req.params.id);
|
|
301
|
+
if (idErr)
|
|
302
|
+
return reply.status(400).send({ detail: idErr });
|
|
303
|
+
const svc = await getSvc();
|
|
304
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
305
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
306
|
+
}
|
|
307
|
+
const result = await svc.restartInstance(req.params.id);
|
|
308
|
+
statusCache.delete(req.params.id);
|
|
309
|
+
if (!result.ok)
|
|
310
|
+
return reply.status(400).send({ detail: result.error || "Unknown error" });
|
|
311
|
+
return result;
|
|
312
|
+
});
|
|
313
|
+
// Plugin check & install — host-side, no container dependency
|
|
314
|
+
app.get("/api/instances/:id/plugins/check/:channelId", async (req, reply) => {
|
|
315
|
+
const idErr = validateId(req.params.id);
|
|
316
|
+
if (idErr)
|
|
317
|
+
return reply.status(400).send({ detail: idErr });
|
|
318
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
319
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
channelId: req.params.channelId,
|
|
323
|
+
installed: instanceManager.isChannelPluginInstalled(req.params.id, req.params.channelId),
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
// Plugin status for all tracked IM plugins (feishu, openclaw-weixin)
|
|
327
|
+
app.get("/api/instances/:id/plugins/status", async (req, reply) => {
|
|
328
|
+
const idErr = validateId(req.params.id);
|
|
329
|
+
if (idErr)
|
|
330
|
+
return reply.status(400).send({ detail: idErr });
|
|
331
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
332
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
333
|
+
}
|
|
334
|
+
return { plugins: pluginInstaller.getAllPluginStatuses(req.params.id) };
|
|
335
|
+
});
|
|
336
|
+
// Quick status: IM binding + skill install state for the quick-skill panel
|
|
337
|
+
app.get("/api/instances/:id/quick-status", async (req, reply) => {
|
|
338
|
+
const idErr = validateId(req.params.id);
|
|
339
|
+
if (idErr)
|
|
340
|
+
return reply.status(400).send({ detail: idErr });
|
|
341
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
342
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
343
|
+
}
|
|
344
|
+
const id = req.params.id;
|
|
345
|
+
const cfg = instanceManager.getConfig(id) ?? {};
|
|
346
|
+
const channels = cfg.channels ?? {};
|
|
347
|
+
// IM binding: channel must be enabled AND have a non-empty token / accounts
|
|
348
|
+
const feishuCh = channels["feishu"] ?? channels["lark"] ?? {};
|
|
349
|
+
const weixinCh = channels["openclaw-weixin"] ?? {};
|
|
350
|
+
const feishuBound = !!(feishuCh.enabled && (feishuCh.appId || feishuCh.token || feishuCh.deviceToken || feishuCh.accessToken));
|
|
351
|
+
const weixinBound = !!(weixinCh.enabled && weixinCh.accounts && Object.keys(weixinCh.accounts).length > 0);
|
|
352
|
+
// Skill install state: scan workspace/skills/ and return all directory names
|
|
353
|
+
const { readdirSync: fsReaddir, existsSync: fsExists, readFileSync: fsRead } = await import("fs");
|
|
354
|
+
const { join: fsJoin } = await import("path");
|
|
355
|
+
const workspaceDir = fsJoin(instanceManager.getOpenclawHome(id), ".openclaw", "workspace");
|
|
356
|
+
const stateDir = fsJoin(workspaceDir, "skills");
|
|
357
|
+
let installedSkillDirs = [];
|
|
358
|
+
try {
|
|
359
|
+
installedSkillDirs = fsReaddir(stateDir, { withFileTypes: true })
|
|
360
|
+
.filter(e => e.isDirectory())
|
|
361
|
+
.map(e => e.name);
|
|
362
|
+
}
|
|
363
|
+
catch { }
|
|
364
|
+
// MCPorter install state — mcporter is installed as a skill in workspace/skills/mcporter
|
|
365
|
+
const mcporterInstalled = installedSkillDirs.some(d => d.toLowerCase() === 'mcporter');
|
|
366
|
+
// MCPorter configured servers
|
|
367
|
+
let mcporterServers = {};
|
|
368
|
+
const mcporterCfgPath = fsJoin(workspaceDir, "config", "mcporter.json");
|
|
369
|
+
try {
|
|
370
|
+
const raw = JSON.parse(fsRead(mcporterCfgPath, "utf8"));
|
|
371
|
+
mcporterServers = raw.mcpServers ?? {};
|
|
372
|
+
}
|
|
373
|
+
catch { }
|
|
374
|
+
return {
|
|
375
|
+
im: {
|
|
376
|
+
feishu: feishuBound,
|
|
377
|
+
weixin: weixinBound,
|
|
378
|
+
},
|
|
379
|
+
installedSkillDirs,
|
|
380
|
+
mcporterInstalled,
|
|
381
|
+
mcporterServers,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
// Run `mcporter list --json` and return live server status
|
|
385
|
+
app.get("/api/instances/:id/mcporter/list", async (req, reply) => {
|
|
386
|
+
const idErr = validateId(req.params.id);
|
|
387
|
+
if (idErr)
|
|
388
|
+
return reply.status(400).send({ detail: idErr });
|
|
389
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
390
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
391
|
+
}
|
|
392
|
+
const id = req.params.id;
|
|
393
|
+
const openclawHome = instanceManager.getOpenclawHome(id);
|
|
394
|
+
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
395
|
+
const mcporterBinPath = join(workspaceDir, ".npm-global", "bin", "mcporter");
|
|
396
|
+
const mcporterCfg = join(workspaceDir, "config", "mcporter.json");
|
|
397
|
+
if (!existsSync(mcporterBinPath)) {
|
|
398
|
+
return { servers: [], installed: false };
|
|
399
|
+
}
|
|
400
|
+
const { execFile } = await import("child_process");
|
|
401
|
+
const { promisify } = await import("util");
|
|
402
|
+
const execFileAsync = promisify(execFile);
|
|
403
|
+
try {
|
|
404
|
+
const { stdout } = await execFileAsync(mcporterBinPath, ["list", "--json"], {
|
|
405
|
+
env: { ...process.env, HOME: openclawHome, MCPORTER_CONFIG: mcporterCfg },
|
|
406
|
+
timeout: 60_000,
|
|
407
|
+
});
|
|
408
|
+
const parsed = JSON.parse(stdout);
|
|
409
|
+
return { servers: parsed.servers ?? [], installed: true };
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
// execFile throws if exit code != 0; stdout may still have partial JSON
|
|
413
|
+
const raw = err?.stdout ?? "";
|
|
414
|
+
try {
|
|
415
|
+
const parsed = JSON.parse(raw);
|
|
416
|
+
return { servers: parsed.servers ?? [], installed: true };
|
|
417
|
+
}
|
|
418
|
+
catch { }
|
|
419
|
+
return { servers: [], installed: true, error: err?.message ?? "unknown" };
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
// Merge servers into mcporter.json
|
|
423
|
+
app.post("/api/instances/:id/mcporter/add", async (req, reply) => {
|
|
424
|
+
const idErr = validateId(req.params.id);
|
|
425
|
+
if (idErr)
|
|
426
|
+
return reply.status(400).send({ detail: idErr });
|
|
427
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
428
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
429
|
+
}
|
|
430
|
+
const { servers } = req.body;
|
|
431
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
432
|
+
return reply.status(400).send({ detail: "servers must be an object" });
|
|
433
|
+
}
|
|
434
|
+
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
435
|
+
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
436
|
+
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
437
|
+
const { readFileSync, writeFileSync, mkdirSync } = await import("fs");
|
|
438
|
+
let cfg = { mcpServers: {}, imports: [] };
|
|
439
|
+
try {
|
|
440
|
+
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
441
|
+
}
|
|
442
|
+
catch { }
|
|
443
|
+
if (!cfg.mcpServers)
|
|
444
|
+
cfg.mcpServers = {};
|
|
445
|
+
// Explicit key-by-key copy instead of Object.assign to prevent prototype pollution:
|
|
446
|
+
// a crafted body with "__proto__" or "constructor" keys could corrupt the object prototype.
|
|
447
|
+
const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
448
|
+
for (const [k, v] of Object.entries(servers)) {
|
|
449
|
+
if (!PROTO_KEYS.has(k))
|
|
450
|
+
cfg.mcpServers[k] = v;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
mkdirSync(join(workspaceDir, "config"), { recursive: true });
|
|
454
|
+
writeFileSync(mcporterCfgPath, JSON.stringify(cfg, null, 2), "utf8");
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
458
|
+
}
|
|
459
|
+
return { ok: true, mcpServers: cfg.mcpServers };
|
|
460
|
+
});
|
|
461
|
+
// Remove a server from mcporter.json
|
|
462
|
+
app.delete("/api/instances/:id/mcporter/:serverName", async (req, reply) => {
|
|
463
|
+
const idErr = validateId(req.params.id);
|
|
464
|
+
if (idErr)
|
|
465
|
+
return reply.status(400).send({ detail: idErr });
|
|
466
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
467
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
468
|
+
}
|
|
469
|
+
const { serverName } = req.params;
|
|
470
|
+
if (!serverName || typeof serverName !== "string") {
|
|
471
|
+
return reply.status(400).send({ detail: "serverName is required" });
|
|
472
|
+
}
|
|
473
|
+
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
474
|
+
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
475
|
+
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
476
|
+
const { readFileSync, writeFileSync } = await import("fs");
|
|
477
|
+
let cfg = { mcpServers: {}, imports: [] };
|
|
478
|
+
try {
|
|
479
|
+
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
480
|
+
}
|
|
481
|
+
catch { }
|
|
482
|
+
if (!cfg.mcpServers || !(serverName in cfg.mcpServers)) {
|
|
483
|
+
return reply.status(404).send({ detail: `Server '${serverName}' not found` });
|
|
484
|
+
}
|
|
485
|
+
delete cfg.mcpServers[serverName];
|
|
486
|
+
try {
|
|
487
|
+
writeFileSync(mcporterCfgPath, JSON.stringify(cfg, null, 2), "utf8");
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
491
|
+
}
|
|
492
|
+
return { ok: true, mcpServers: cfg.mcpServers };
|
|
493
|
+
});
|
|
494
|
+
// Delete a skill directory from workspace/skills/
|
|
495
|
+
app.delete("/api/instances/:id/skills/:skillDir", async (req, reply) => {
|
|
496
|
+
const idErr = validateId(req.params.id);
|
|
497
|
+
if (idErr)
|
|
498
|
+
return reply.status(400).send({ detail: idErr });
|
|
499
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
500
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
501
|
+
}
|
|
502
|
+
const { skillDir } = req.params;
|
|
503
|
+
// Prevent path traversal
|
|
504
|
+
if (!skillDir || skillDir.includes("/") || skillDir.includes("..") || skillDir.startsWith(".")) {
|
|
505
|
+
return reply.status(400).send({ detail: "Invalid skill directory name" });
|
|
506
|
+
}
|
|
507
|
+
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
508
|
+
const skillPath = join(openclawHome, ".openclaw", "workspace", "skills", skillDir);
|
|
509
|
+
const { existsSync: fsEx, rmSync } = await import("fs");
|
|
510
|
+
if (!fsEx(skillPath)) {
|
|
511
|
+
return reply.status(404).send({ detail: `Skill '${skillDir}' not found` });
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
rmSync(skillPath, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
return reply.status(500).send({ detail: `Delete failed: ${err.message}` });
|
|
518
|
+
}
|
|
519
|
+
return { ok: true };
|
|
520
|
+
});
|
|
521
|
+
app.post("/api/instances/:id/plugins/install", async (req, reply) => {
|
|
522
|
+
const idErr = validateId(req.params.id);
|
|
523
|
+
if (idErr)
|
|
524
|
+
return reply.status(400).send({ detail: idErr });
|
|
525
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
526
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
527
|
+
}
|
|
528
|
+
const { channelId } = req.body;
|
|
529
|
+
if (!channelId || typeof channelId !== "string") {
|
|
530
|
+
return reply.status(400).send({ detail: "channelId is required" });
|
|
531
|
+
}
|
|
532
|
+
const pkg = instanceManager.CHANNEL_PLUGIN_MAP[channelId];
|
|
533
|
+
if (!pkg) {
|
|
534
|
+
return reply.status(400).send({ detail: `Unknown channel: ${channelId}` });
|
|
535
|
+
}
|
|
536
|
+
const pStatus = pluginInstaller.getPluginStatus(req.params.id, channelId);
|
|
537
|
+
if (pStatus.status === "installed")
|
|
538
|
+
return { ok: true, status: "already_installed" };
|
|
539
|
+
if (pStatus.status === "installing")
|
|
540
|
+
return { ok: true, status: "installing" };
|
|
541
|
+
pluginInstaller.enqueueInstall(req.params.id, channelId);
|
|
542
|
+
return { ok: true, status: "queued" };
|
|
543
|
+
});
|
|
544
|
+
// ── Helper: ensure a channel plugin is installed (check-only) ──
|
|
545
|
+
async function ensurePluginInstalled(instanceId, channelId) {
|
|
546
|
+
if (!instanceManager.CHANNEL_PLUGIN_MAP[channelId])
|
|
547
|
+
return;
|
|
548
|
+
if (!instanceManager.isChannelPluginInstalled(instanceId, channelId)) {
|
|
549
|
+
throw new Error(`Plugin ${channelId} is not installed. Please install it from the config page.`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// ── Feishu/Lark OAuth Device Code Login ──
|
|
553
|
+
const FEISHU_AUTH_URL = "https://accounts.feishu.cn";
|
|
554
|
+
const MAX_LOGIN_SESSIONS = 100;
|
|
555
|
+
const feishuLogins = new Map();
|
|
556
|
+
app.post("/api/instances/:id/feishu/login", async (req, reply) => {
|
|
557
|
+
const channelKey = req.body?.channelKey || "feishu";
|
|
558
|
+
const idErr = validateId(req.params.id);
|
|
559
|
+
if (idErr)
|
|
560
|
+
return reply.status(400).send({ detail: idErr });
|
|
561
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
562
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
563
|
+
}
|
|
564
|
+
// Require instance to be running
|
|
565
|
+
const svc = await getSvc();
|
|
566
|
+
const svcStatus = await svc.getStatus(req.params.id);
|
|
567
|
+
if (svcStatus.status !== "running") {
|
|
568
|
+
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
569
|
+
}
|
|
570
|
+
// Auto-install feishu plugin if not present
|
|
571
|
+
await ensurePluginInstalled(req.params.id, channelKey);
|
|
572
|
+
try {
|
|
573
|
+
// Step 1: init
|
|
574
|
+
await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
575
|
+
method: "POST",
|
|
576
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
577
|
+
body: "action=init",
|
|
578
|
+
signal: AbortSignal.timeout(30_000),
|
|
579
|
+
});
|
|
580
|
+
// Step 2: begin — get QR code URL and device code
|
|
581
|
+
const beginResp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
582
|
+
method: "POST",
|
|
583
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
584
|
+
body: "action=begin&archetype=PersonalAgent&auth_method=client_secret&request_user_info=open_id",
|
|
585
|
+
signal: AbortSignal.timeout(30_000),
|
|
586
|
+
});
|
|
587
|
+
if (!beginResp.ok)
|
|
588
|
+
throw new Error(`Feishu API error: ${beginResp.status}`);
|
|
589
|
+
const beginData = await beginResp.json();
|
|
590
|
+
const sessionKey = `${req.params.id}-${channelKey}-${Date.now()}`;
|
|
591
|
+
feishuLogins.set(sessionKey, {
|
|
592
|
+
instanceId: req.params.id,
|
|
593
|
+
deviceCode: beginData.device_code,
|
|
594
|
+
startedAt: Date.now(),
|
|
595
|
+
interval: beginData.interval || 5,
|
|
596
|
+
expireIn: beginData.expire_in || 600,
|
|
597
|
+
channelKey,
|
|
598
|
+
});
|
|
599
|
+
// Purge expired + enforce cap
|
|
600
|
+
for (const [k, v] of feishuLogins) {
|
|
601
|
+
if (Date.now() - v.startedAt > v.expireIn * 1000)
|
|
602
|
+
feishuLogins.delete(k);
|
|
603
|
+
}
|
|
604
|
+
while (feishuLogins.size > MAX_LOGIN_SESSIONS) {
|
|
605
|
+
feishuLogins.delete(feishuLogins.keys().next().value);
|
|
606
|
+
}
|
|
607
|
+
return { qrcodeUrl: beginData.verification_uri_complete, sessionKey };
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
return reply.status(502).send({ detail: e.message || "Failed to start Feishu login" });
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
app.get("/api/instances/:id/feishu/login/:sessionKey", async (req, reply) => {
|
|
614
|
+
const idErr = validateId(req.params.id);
|
|
615
|
+
if (idErr)
|
|
616
|
+
return reply.status(400).send({ detail: idErr });
|
|
617
|
+
const login = feishuLogins.get(req.params.sessionKey);
|
|
618
|
+
if (!login)
|
|
619
|
+
return reply.status(404).send({ detail: "Login session not found or expired" });
|
|
620
|
+
if (login.instanceId !== req.params.id)
|
|
621
|
+
return reply.status(403).send({ detail: "Session belongs to a different instance" });
|
|
622
|
+
if (Date.now() - login.startedAt > login.expireIn * 1000) {
|
|
623
|
+
feishuLogins.delete(req.params.sessionKey);
|
|
624
|
+
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const resp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
628
|
+
method: "POST",
|
|
629
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
630
|
+
body: `action=poll&device_code=${encodeURIComponent(login.deviceCode)}`,
|
|
631
|
+
signal: AbortSignal.timeout(10_000),
|
|
632
|
+
});
|
|
633
|
+
const data = await resp.json();
|
|
634
|
+
if (data.client_id && data.client_secret) {
|
|
635
|
+
const storedChannelKey = login.channelKey || "feishu";
|
|
636
|
+
feishuLogins.delete(req.params.sessionKey);
|
|
637
|
+
const domain = data.user_info?.tenant_brand === "lark" ? "lark" : "feishu";
|
|
638
|
+
instanceManager.saveFeishuCredentials(req.params.id, {
|
|
639
|
+
appId: data.client_id,
|
|
640
|
+
appSecret: data.client_secret,
|
|
641
|
+
domain,
|
|
642
|
+
channelKey: storedChannelKey,
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
status: "confirmed", connected: true, domain,
|
|
646
|
+
message: domain === "lark" ? "Lark bot configured!" : "Feishu bot configured!",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
if (data.error === "authorization_pending") {
|
|
650
|
+
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
651
|
+
}
|
|
652
|
+
if (data.error === "expired_token") {
|
|
653
|
+
feishuLogins.delete(req.params.sessionKey);
|
|
654
|
+
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
655
|
+
}
|
|
656
|
+
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
return reply.status(502).send({ detail: e.message || "Poll failed" });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
// ── Pairing ──────────────────────────────────────────────────────────────
|
|
663
|
+
// Pairing codes are uppercase alphanumeric, 4-16 chars (e.g. LVU7PNYK).
|
|
664
|
+
const PAIRING_CODE_RE = /^[A-Z0-9]{4,16}$/;
|
|
665
|
+
// Channel identifiers follow the same kebab-case rules as plugin IDs.
|
|
666
|
+
const PAIRING_CHANNEL_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
667
|
+
app.get("/api/instances/:id/pairing/list", async (req, reply) => {
|
|
668
|
+
const idErr = validateId(req.params.id);
|
|
669
|
+
if (idErr)
|
|
670
|
+
return reply.status(400).send({ detail: idErr });
|
|
671
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
672
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
673
|
+
}
|
|
674
|
+
const svc = await getSvc();
|
|
675
|
+
const result = await svc.exec(req.params.id, ["openclaw", "pairing", "list"], 15_000);
|
|
676
|
+
return { output: result.stdout + result.stderr, exitCode: result.exitCode };
|
|
677
|
+
});
|
|
678
|
+
app.post("/api/instances/:id/pairing/approve", async (req, reply) => {
|
|
679
|
+
const idErr = validateId(req.params.id);
|
|
680
|
+
if (idErr)
|
|
681
|
+
return reply.status(400).send({ detail: idErr });
|
|
682
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
683
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
684
|
+
}
|
|
685
|
+
const { channel, code, notify } = req.body ?? {};
|
|
686
|
+
if (!channel || !PAIRING_CHANNEL_RE.test(channel)) {
|
|
687
|
+
return reply.status(400).send({ detail: "Invalid channel: must be lowercase alphanumeric/hyphen/underscore" });
|
|
688
|
+
}
|
|
689
|
+
if (!code || !PAIRING_CODE_RE.test(code)) {
|
|
690
|
+
return reply.status(400).send({ detail: "Invalid pairing code: must be 4-16 uppercase alphanumeric characters" });
|
|
691
|
+
}
|
|
692
|
+
const cmd = ["openclaw", "pairing", "approve", channel, code];
|
|
693
|
+
if (notify)
|
|
694
|
+
cmd.push("--notify");
|
|
695
|
+
const svc = await getSvc();
|
|
696
|
+
const result = await svc.exec(req.params.id, cmd, 15_000);
|
|
697
|
+
if (result.exitCode !== 0) {
|
|
698
|
+
return reply.status(400).send({ detail: (result.stderr || result.stdout || "Approval failed").trim() });
|
|
699
|
+
}
|
|
700
|
+
return { ok: true, output: (result.stdout + result.stderr).trim() };
|
|
701
|
+
});
|
|
702
|
+
// WeChat accounts query
|
|
703
|
+
app.get("/api/instances/:id/weixin/accounts", async (req, reply) => {
|
|
704
|
+
const idErr = validateId(req.params.id);
|
|
705
|
+
if (idErr)
|
|
706
|
+
return reply.status(400).send({ detail: idErr });
|
|
707
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
708
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
709
|
+
}
|
|
710
|
+
return { accounts: instanceManager.getWeixinAccounts(req.params.id) };
|
|
711
|
+
});
|
|
712
|
+
// ── WeChat QR Login ──
|
|
713
|
+
const WEIXIN_API_BASE = "https://ilinkai.weixin.qq.com";
|
|
714
|
+
const WEIXIN_BOT_TYPE = "3";
|
|
715
|
+
// In-memory active login sessions (short-lived, 5 min TTL)
|
|
716
|
+
const weixinLogins = new Map();
|
|
717
|
+
app.post("/api/instances/:id/weixin/login", async (req, reply) => {
|
|
718
|
+
const idErr = validateId(req.params.id);
|
|
719
|
+
if (idErr)
|
|
720
|
+
return reply.status(400).send({ detail: idErr });
|
|
721
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
722
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
723
|
+
}
|
|
724
|
+
// Require instance to be running
|
|
725
|
+
const svc = await getSvc();
|
|
726
|
+
const svcStatus = await svc.getStatus(req.params.id);
|
|
727
|
+
if (svcStatus.status !== "running") {
|
|
728
|
+
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
729
|
+
}
|
|
730
|
+
// Auto-install weixin plugin if not present
|
|
731
|
+
await ensurePluginInstalled(req.params.id, "openclaw-weixin");
|
|
732
|
+
try {
|
|
733
|
+
const resp = await fetch(`${WEIXIN_API_BASE}/ilink/bot/get_bot_qrcode?bot_type=${WEIXIN_BOT_TYPE}`, { signal: AbortSignal.timeout(30_000) });
|
|
734
|
+
if (!resp.ok)
|
|
735
|
+
throw new Error(`WeChat API error: ${resp.status}`);
|
|
736
|
+
const data = await resp.json();
|
|
737
|
+
const sessionKey = `${req.params.id}-${Date.now()}`;
|
|
738
|
+
weixinLogins.set(sessionKey, {
|
|
739
|
+
instanceId: req.params.id,
|
|
740
|
+
qrcode: data.qrcode,
|
|
741
|
+
qrcodeUrl: data.qrcode_img_content,
|
|
742
|
+
startedAt: Date.now(),
|
|
743
|
+
});
|
|
744
|
+
// Purge expired sessions + enforce cap
|
|
745
|
+
for (const [k, v] of weixinLogins) {
|
|
746
|
+
if (Date.now() - v.startedAt > 5 * 60_000)
|
|
747
|
+
weixinLogins.delete(k);
|
|
748
|
+
}
|
|
749
|
+
while (weixinLogins.size > MAX_LOGIN_SESSIONS) {
|
|
750
|
+
weixinLogins.delete(weixinLogins.keys().next().value);
|
|
751
|
+
}
|
|
752
|
+
return { qrcodeUrl: data.qrcode_img_content, sessionKey };
|
|
753
|
+
}
|
|
754
|
+
catch (e) {
|
|
755
|
+
return reply.status(502).send({ detail: e.message || "Failed to get QR code" });
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
app.get("/api/instances/:id/weixin/login/:sessionKey", async (req, reply) => {
|
|
759
|
+
const idErr = validateId(req.params.id);
|
|
760
|
+
if (idErr)
|
|
761
|
+
return reply.status(400).send({ detail: idErr });
|
|
762
|
+
const login = weixinLogins.get(req.params.sessionKey);
|
|
763
|
+
if (!login)
|
|
764
|
+
return reply.status(404).send({ detail: "Login session not found or expired" });
|
|
765
|
+
if (login.instanceId !== req.params.id)
|
|
766
|
+
return reply.status(403).send({ detail: "Session belongs to a different instance" });
|
|
767
|
+
if (Date.now() - login.startedAt > 5 * 60_000) {
|
|
768
|
+
weixinLogins.delete(req.params.sessionKey);
|
|
769
|
+
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
const resp = await fetch(`${WEIXIN_API_BASE}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(login.qrcode)}`, { headers: { "iLink-App-ClientVersion": "1" }, signal: AbortSignal.timeout(35_000) });
|
|
773
|
+
if (!resp.ok)
|
|
774
|
+
throw new Error(`Status poll failed: ${resp.status}`);
|
|
775
|
+
const data = await resp.json();
|
|
776
|
+
if (data.status === "confirmed" && data.ilink_bot_id) {
|
|
777
|
+
weixinLogins.delete(req.params.sessionKey);
|
|
778
|
+
// Save credentials to instance
|
|
779
|
+
try {
|
|
780
|
+
instanceManager.saveWeixinCredentials(req.params.id, {
|
|
781
|
+
accountId: data.ilink_bot_id,
|
|
782
|
+
token: data.bot_token || "",
|
|
783
|
+
baseUrl: data.baseurl || WEIXIN_API_BASE,
|
|
784
|
+
userId: data.ilink_user_id || "",
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
console.error(`[weixin-login] Failed to save credentials: ${e.message}`);
|
|
789
|
+
return reply.status(500).send({
|
|
790
|
+
status: "confirmed", connected: false,
|
|
791
|
+
detail: "WeChat authenticated but failed to save credentials: " + e.message,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
status: "confirmed", connected: true,
|
|
796
|
+
accountId: data.ilink_bot_id,
|
|
797
|
+
message: "WeChat connected!",
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (data.status === "expired") {
|
|
801
|
+
// Auto-refresh QR
|
|
802
|
+
try {
|
|
803
|
+
const refreshResp = await fetch(`${WEIXIN_API_BASE}/ilink/bot/get_bot_qrcode?bot_type=${WEIXIN_BOT_TYPE}`, { signal: AbortSignal.timeout(30_000) });
|
|
804
|
+
if (refreshResp.ok) {
|
|
805
|
+
const refreshData = await refreshResp.json();
|
|
806
|
+
login.qrcode = refreshData.qrcode;
|
|
807
|
+
login.qrcodeUrl = refreshData.qrcode_img_content;
|
|
808
|
+
login.startedAt = Date.now();
|
|
809
|
+
return {
|
|
810
|
+
status: "refreshed", connected: false,
|
|
811
|
+
qrcodeUrl: refreshData.qrcode_img_content,
|
|
812
|
+
message: "QR code refreshed, please scan again.",
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
catch { /* fall through */ }
|
|
817
|
+
weixinLogins.delete(req.params.sessionKey);
|
|
818
|
+
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
status: data.status, connected: false,
|
|
822
|
+
message: data.status === "scaned" ? "Scanned, please confirm on WeChat..." : "Waiting for scan...",
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
catch (e) {
|
|
826
|
+
return reply.status(502).send({ detail: e.message || "Status poll failed" });
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
// Usage
|
|
830
|
+
app.get("/api/instances/:id/usage", async (req, reply) => {
|
|
831
|
+
const idErr = validateId(req.params.id);
|
|
832
|
+
if (idErr)
|
|
833
|
+
return reply.status(400).send({ detail: idErr });
|
|
834
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
835
|
+
if (!inst)
|
|
836
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
837
|
+
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
838
|
+
const sessionsIndex = join(openclawHome, ".openclaw", "agents", "main", "sessions", "sessions.json");
|
|
839
|
+
const emptyTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
840
|
+
if (!existsSync(sessionsIndex))
|
|
841
|
+
return { sessions: [], totals: emptyTotals };
|
|
842
|
+
// Check file size before reading
|
|
843
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
844
|
+
const indexStat = await stat(sessionsIndex);
|
|
845
|
+
if (indexStat.size > MAX_FILE_SIZE) {
|
|
846
|
+
return reply.status(400).send({ detail: "sessions.json exceeds 10MB size limit" });
|
|
847
|
+
}
|
|
848
|
+
let sessionsMap;
|
|
849
|
+
try {
|
|
850
|
+
const raw = await readFile(sessionsIndex, "utf-8");
|
|
851
|
+
sessionsMap = JSON.parse(raw);
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return reply.status(500).send({ detail: "Failed to parse sessions.json" });
|
|
855
|
+
}
|
|
856
|
+
const sessions = [];
|
|
857
|
+
const totals = { ...emptyTotals };
|
|
858
|
+
for (const [sessionKey, sessionMeta] of Object.entries(sessionsMap)) {
|
|
859
|
+
const sessionFile = sessionMeta?.sessionFile;
|
|
860
|
+
if (!sessionFile || !existsSync(sessionFile))
|
|
861
|
+
continue;
|
|
862
|
+
// Prevent path traversal: sessionFile must be under the instance's openclaw home.
|
|
863
|
+
// Use realpathSync to resolve symlinks and prevent symlink-based bypasses.
|
|
864
|
+
try {
|
|
865
|
+
const resolvedSession = realpathSync(sessionFile);
|
|
866
|
+
const resolvedHome = realpathSync(openclawHome);
|
|
867
|
+
if (!resolvedSession.startsWith(resolvedHome + "/") && resolvedSession !== resolvedHome)
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
// Check session file size before reading
|
|
874
|
+
let sessionStat;
|
|
875
|
+
try {
|
|
876
|
+
sessionStat = await stat(sessionFile);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (sessionStat.size > MAX_FILE_SIZE)
|
|
882
|
+
continue;
|
|
883
|
+
const sessionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
884
|
+
let model = "";
|
|
885
|
+
let firstTs = "";
|
|
886
|
+
let lastTs = "";
|
|
887
|
+
const originLabel = sessionMeta?.origin?.label || "";
|
|
888
|
+
const channel = sessionMeta?.origin?.provider || "";
|
|
889
|
+
let sessionContent;
|
|
890
|
+
try {
|
|
891
|
+
sessionContent = await readFile(sessionFile, "utf-8");
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
for (const line of sessionContent.split("\n")) {
|
|
897
|
+
let entry;
|
|
898
|
+
try {
|
|
899
|
+
entry = JSON.parse(line.trim());
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (entry.type !== "message")
|
|
905
|
+
continue;
|
|
906
|
+
const msg = entry.message || {};
|
|
907
|
+
const ts = entry.timestamp || "";
|
|
908
|
+
if (ts && !firstTs)
|
|
909
|
+
firstTs = ts;
|
|
910
|
+
if (ts)
|
|
911
|
+
lastTs = ts;
|
|
912
|
+
if (msg.role === "assistant") {
|
|
913
|
+
if (!model && msg.model)
|
|
914
|
+
model = msg.model;
|
|
915
|
+
const usage = msg.usage;
|
|
916
|
+
if (usage) {
|
|
917
|
+
sessionUsage.input += usage.input || 0;
|
|
918
|
+
sessionUsage.output += usage.output || 0;
|
|
919
|
+
sessionUsage.cacheRead += usage.cacheRead || 0;
|
|
920
|
+
sessionUsage.cacheWrite += usage.cacheWrite || 0;
|
|
921
|
+
sessionUsage.totalTokens += usage.totalTokens || 0;
|
|
922
|
+
if (typeof usage.cost === "object")
|
|
923
|
+
sessionUsage.costTotal += usage.cost.total || 0;
|
|
924
|
+
}
|
|
925
|
+
sessionUsage.messages++;
|
|
926
|
+
}
|
|
927
|
+
else if (msg.role === "user") {
|
|
928
|
+
sessionUsage.messages++;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
sessions.push({ key: sessionKey, model, channel, origin: originLabel, firstMessage: firstTs, lastMessage: lastTs, usage: sessionUsage });
|
|
932
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "messages"]) {
|
|
933
|
+
totals[k] += sessionUsage[k];
|
|
934
|
+
}
|
|
935
|
+
totals.costTotal += sessionUsage.costTotal;
|
|
936
|
+
}
|
|
937
|
+
sessions.sort((a, b) => (b.lastMessage || "").localeCompare(a.lastMessage || ""));
|
|
938
|
+
return { sessions, totals };
|
|
939
|
+
});
|
|
940
|
+
// Logs
|
|
941
|
+
app.get("/api/instances/:id/logs", async (req, reply) => {
|
|
942
|
+
const idErr = validateId(req.params.id);
|
|
943
|
+
if (idErr)
|
|
944
|
+
return reply.status(400).send({ detail: idErr });
|
|
945
|
+
const svc = await getSvc();
|
|
946
|
+
if (!instanceManager.getInstance(req.params.id)) {
|
|
947
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
948
|
+
}
|
|
949
|
+
const logType = req.query.log_type || "stderr";
|
|
950
|
+
if (logType !== "stdout" && logType !== "stderr") {
|
|
951
|
+
return reply.status(400).send({ detail: "log_type must be stdout or stderr" });
|
|
952
|
+
}
|
|
953
|
+
const MAX_LOG_LINES = 5000;
|
|
954
|
+
const lines = Math.min(parseInt(req.query.lines || "100", 10) || 100, MAX_LOG_LINES);
|
|
955
|
+
const logLines = await svc.getLogs(req.params.id, lines, logType);
|
|
956
|
+
return { lines: logLines };
|
|
957
|
+
});
|
|
958
|
+
// Admin: re-encrypt all instance secrets with current AES key
|
|
959
|
+
app.post("/api/admin/migrate-secrets", async (_req, reply) => {
|
|
960
|
+
const { getAesKey, getJwtSecret } = await import("../config.js");
|
|
961
|
+
const { scryptSync, createDecipheriv, createCipheriv, randomBytes } = await import("crypto");
|
|
962
|
+
const { readFileSync, writeFileSync, existsSync } = await import("fs");
|
|
963
|
+
const instances = instanceManager.listInstances();
|
|
964
|
+
const results = [];
|
|
965
|
+
const currentKey = getAesKey();
|
|
966
|
+
const legacyKey = scryptSync(getJwtSecret(), "jishushell-apikey-v1", 32);
|
|
967
|
+
for (const inst of instances) {
|
|
968
|
+
const envFiles = instanceManager.getRuntimeEnvFiles(inst.id);
|
|
969
|
+
const providerEnvFile = envFiles[0]?.replace(/model\.env$/, "provider.env");
|
|
970
|
+
if (!providerEnvFile || !existsSync(providerEnvFile)) {
|
|
971
|
+
results.push({ id: inst.id, status: "skipped", error: "no provider.env" });
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
const envContent = readFileSync(providerEnvFile, "utf-8");
|
|
975
|
+
const match = envContent.match(/UPSTREAM_API_KEY=(.+)/);
|
|
976
|
+
if (!match || !match[1]?.startsWith("enc:")) {
|
|
977
|
+
results.push({ id: inst.id, status: "skipped", error: "no encrypted key" });
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const encrypted = match[1];
|
|
981
|
+
const raw = Buffer.from(encrypted.slice(4), "base64");
|
|
982
|
+
if (raw.length < 29) {
|
|
983
|
+
results.push({ id: inst.id, status: "error", error: "encrypted data too short" });
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const iv = raw.subarray(0, 12);
|
|
987
|
+
const tag = raw.subarray(12, 28);
|
|
988
|
+
const ciphertext = raw.subarray(28);
|
|
989
|
+
// Try decrypt with current key first
|
|
990
|
+
let plaintext = null;
|
|
991
|
+
let needsReEncrypt = false;
|
|
992
|
+
try {
|
|
993
|
+
const d = createDecipheriv("aes-256-gcm", currentKey, iv);
|
|
994
|
+
d.setAuthTag(tag);
|
|
995
|
+
plaintext = d.update(ciphertext, undefined, "utf-8") + d.final("utf-8");
|
|
996
|
+
// Already encrypted with current key — no migration needed
|
|
997
|
+
results.push({ id: inst.id, status: "ok" });
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// Current key failed — try legacy
|
|
1002
|
+
needsReEncrypt = true;
|
|
1003
|
+
}
|
|
1004
|
+
if (needsReEncrypt) {
|
|
1005
|
+
try {
|
|
1006
|
+
const d = createDecipheriv("aes-256-gcm", legacyKey, iv);
|
|
1007
|
+
d.setAuthTag(tag);
|
|
1008
|
+
plaintext = d.update(ciphertext, undefined, "utf-8") + d.final("utf-8");
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
results.push({ id: inst.id, status: "error", error: "decrypt failed with both keys" });
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
// Re-encrypt with current key
|
|
1015
|
+
const newIv = randomBytes(12);
|
|
1016
|
+
const c = createCipheriv("aes-256-gcm", currentKey, newIv);
|
|
1017
|
+
const enc = Buffer.concat([c.update(plaintext, "utf-8"), c.final()]);
|
|
1018
|
+
const newTag = c.getAuthTag();
|
|
1019
|
+
const newEncrypted = "enc:" + Buffer.concat([newIv, newTag, enc]).toString("base64");
|
|
1020
|
+
// Write back
|
|
1021
|
+
const newContent = envContent.replace(/UPSTREAM_API_KEY=.+/, `UPSTREAM_API_KEY=${newEncrypted}`);
|
|
1022
|
+
writeFileSync(providerEnvFile, newContent, { mode: 0o600 });
|
|
1023
|
+
results.push({ id: inst.id, status: "migrated" });
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Invalidate proxy config cache for migrated instances
|
|
1027
|
+
for (const r of results) {
|
|
1028
|
+
if (r.status === "migrated")
|
|
1029
|
+
llmProxy.invalidateConfigCache(r.id);
|
|
1030
|
+
}
|
|
1031
|
+
return { ok: true, results };
|
|
1032
|
+
});
|
|
1033
|
+
app.get("/api/instances/:id/gateway-launch", async (req, reply) => {
|
|
1034
|
+
const idErr = validateId(req.params.id);
|
|
1035
|
+
if (idErr)
|
|
1036
|
+
return reply.status(400).send({ detail: idErr });
|
|
1037
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
1038
|
+
if (!inst)
|
|
1039
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
1040
|
+
const panelOrigin = inferRequestOrigin(req);
|
|
1041
|
+
if (panelOrigin) {
|
|
1042
|
+
let addedAllowedOrigin = false;
|
|
1043
|
+
try {
|
|
1044
|
+
addedAllowedOrigin = ensureControlUiAllowedOrigin(req.params.id, panelOrigin);
|
|
1045
|
+
}
|
|
1046
|
+
catch (err) {
|
|
1047
|
+
console.warn(`[gateway-launch] failed to add allowed origin for ${req.params.id}:`, err.message || err);
|
|
1048
|
+
}
|
|
1049
|
+
if (addedAllowedOrigin) {
|
|
1050
|
+
await restartRunningInstanceForControlUiOrigin(req.params.id, panelOrigin);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
const baseUrl = `/api/instances/${req.params.id}/gateway/`;
|
|
1054
|
+
const cfg = instanceManager.getStoredConfig(req.params.id);
|
|
1055
|
+
const token = cfg?.gateway?.auth?.token;
|
|
1056
|
+
if (typeof token !== "string" || !token.trim()) {
|
|
1057
|
+
return { url: baseUrl };
|
|
1058
|
+
}
|
|
1059
|
+
return { url: `${baseUrl}#token=${encodeURIComponent(token.trim())}` };
|
|
1060
|
+
});
|
|
1061
|
+
// Reverse-proxy to OpenClaw gateway web UI (gateway binds 127.0.0.1 only)
|
|
1062
|
+
// NOTE: WebSocket upgrades are handled via server.on('upgrade') in server.ts
|
|
1063
|
+
// to avoid Fastify's HTTP lifecycle timeouts killing long-lived connections.
|
|
1064
|
+
const gatewayProxy = async (request, reply) => {
|
|
1065
|
+
const { id } = request.params;
|
|
1066
|
+
const idErr = validateId(id);
|
|
1067
|
+
if (idErr)
|
|
1068
|
+
return reply.status(400).send({ detail: idErr });
|
|
1069
|
+
const inst = instanceManager.getInstance(id);
|
|
1070
|
+
if (!inst)
|
|
1071
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
1072
|
+
const port = instanceManager.getGatewayPort(id);
|
|
1073
|
+
const gwHost = await instanceManager.getGatewayHost(id);
|
|
1074
|
+
const suffix = request.params["*"] || "";
|
|
1075
|
+
const qs = request.url.includes("?") ? request.url.slice(request.url.indexOf("?")) : "";
|
|
1076
|
+
// Raw HTTP proxy — stream the request body and preserve headers
|
|
1077
|
+
const targetUrl = `http://${gwHost}:${port}/${suffix}${qs}`;
|
|
1078
|
+
try {
|
|
1079
|
+
// Build upstream headers: keep everything except hop-by-hop, rewrite host/origin
|
|
1080
|
+
const fwdHeaders = {};
|
|
1081
|
+
const rawHeaders = request.raw.headers;
|
|
1082
|
+
for (const [k, v] of Object.entries(rawHeaders)) {
|
|
1083
|
+
if (v === undefined)
|
|
1084
|
+
continue;
|
|
1085
|
+
const lk = k.toLowerCase();
|
|
1086
|
+
if (HOP_BY_HOP.has(lk) ||
|
|
1087
|
+
PROXY_IDENTITY_HEADERS.has(lk) ||
|
|
1088
|
+
lk === "host" ||
|
|
1089
|
+
// Strip panel auth credentials — do not leak to downstream OpenClaw gateway
|
|
1090
|
+
lk === "cookie" ||
|
|
1091
|
+
lk === "authorization")
|
|
1092
|
+
continue;
|
|
1093
|
+
fwdHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
1094
|
+
}
|
|
1095
|
+
fwdHeaders["host"] = `${gwHost}:${port}`;
|
|
1096
|
+
// Request uncompressed responses — avoids having to decompress before HTML injection,
|
|
1097
|
+
// and lets us stream non-HTML bodies without content-encoding mismatch.
|
|
1098
|
+
fwdHeaders["accept-encoding"] = "identity";
|
|
1099
|
+
// Use http.request for raw body streaming (avoids Fastify's parsed body)
|
|
1100
|
+
const upstreamRes = await new Promise((resolve, reject) => {
|
|
1101
|
+
const proxyReq = httpRequest(targetUrl, {
|
|
1102
|
+
method: request.method,
|
|
1103
|
+
headers: fwdHeaders,
|
|
1104
|
+
}, resolve);
|
|
1105
|
+
proxyReq.on("error", reject);
|
|
1106
|
+
// Pipe the raw incoming body directly to the upstream
|
|
1107
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1108
|
+
request.raw.pipe(proxyReq);
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
proxyReq.end();
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
reply.status(upstreamRes.statusCode || 502);
|
|
1115
|
+
// Forward response headers — preserve most, rewrite security headers for iframe
|
|
1116
|
+
for (const [k, v] of Object.entries(upstreamRes.headers)) {
|
|
1117
|
+
if (v === undefined)
|
|
1118
|
+
continue;
|
|
1119
|
+
const lk = k.toLowerCase();
|
|
1120
|
+
if (HOP_BY_HOP.has(lk))
|
|
1121
|
+
continue;
|
|
1122
|
+
if (lk === "x-frame-options") {
|
|
1123
|
+
reply.header("x-frame-options", "SAMEORIGIN");
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (lk === "content-security-policy") {
|
|
1127
|
+
// Replace frame-ancestors directive, preserve the rest
|
|
1128
|
+
const csp = Array.isArray(v) ? v.join(", ") : v;
|
|
1129
|
+
const rewritten = csp.replace(/frame-ancestors\s+[^;]*/i, "frame-ancestors 'self'");
|
|
1130
|
+
reply.header("content-security-policy", rewritten === csp ? `${csp}; frame-ancestors 'self'` : rewritten);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
reply.header(k, Array.isArray(v) ? v.join(", ") : v);
|
|
1134
|
+
}
|
|
1135
|
+
// Rewrite control-ui-config.json to set basePath for proxied gateway
|
|
1136
|
+
const respCt = upstreamRes.headers["content-type"] || "";
|
|
1137
|
+
if (suffix === "__openclaw/control-ui-config.json" && respCt.includes("application/json")) {
|
|
1138
|
+
const chunks = [];
|
|
1139
|
+
for await (const chunk of upstreamRes)
|
|
1140
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1141
|
+
try {
|
|
1142
|
+
const config = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1143
|
+
config.basePath = `/api/instances/${id}/gateway`;
|
|
1144
|
+
const buf = Buffer.from(JSON.stringify(config));
|
|
1145
|
+
reply.header("content-length", buf.length);
|
|
1146
|
+
reply.removeHeader("content-encoding");
|
|
1147
|
+
return reply.send(buf);
|
|
1148
|
+
}
|
|
1149
|
+
catch { /* fall through to stream */ }
|
|
1150
|
+
}
|
|
1151
|
+
// For HTML responses: buffer to inject crypto shim + basePath
|
|
1152
|
+
if (respCt.includes("text/html")) {
|
|
1153
|
+
const chunks = [];
|
|
1154
|
+
for await (const chunk of upstreamRes)
|
|
1155
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1156
|
+
let html = Buffer.concat(chunks).toString("utf-8");
|
|
1157
|
+
const basePath = `/api/instances/${id}/gateway`;
|
|
1158
|
+
const injectScript = [
|
|
1159
|
+
`window.__OPENCLAW_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};`,
|
|
1160
|
+
`(()=>{`,
|
|
1161
|
+
` try {`,
|
|
1162
|
+
` const settingsKey='openclaw.control.settings.v1';`,
|
|
1163
|
+
` const tokenStoragePrefix='openclaw.control.token.v1:';`,
|
|
1164
|
+
` const normalizeGatewayScope=(gatewayUrl)=>{`,
|
|
1165
|
+
` const raw=(gatewayUrl||'').trim();`,
|
|
1166
|
+
` if(!raw) return 'default';`,
|
|
1167
|
+
` try {`,
|
|
1168
|
+
` const base=\`\${window.location.protocol}//\${window.location.host}\${window.location.pathname||'/'}\`;`,
|
|
1169
|
+
` const parsed=new URL(raw, base);`,
|
|
1170
|
+
` const pathname=parsed.pathname==='/'?'':(parsed.pathname.replace(/\\/+$/,'')||parsed.pathname);`,
|
|
1171
|
+
` return \`\${parsed.protocol}//\${parsed.host}\${pathname}\`;`,
|
|
1172
|
+
` } catch {`,
|
|
1173
|
+
` return raw;`,
|
|
1174
|
+
` }`,
|
|
1175
|
+
` };`,
|
|
1176
|
+
` const proto=window.location.protocol==='https:'?'wss':'ws';`,
|
|
1177
|
+
` const gatewayUrl=\`\${proto}://\${window.location.host}${basePath}\`;`,
|
|
1178
|
+
` const tokenSessionKey=\`\${tokenStoragePrefix}\${normalizeGatewayScope(gatewayUrl)}\`;`,
|
|
1179
|
+
` const raw=window.localStorage.getItem(settingsKey);`,
|
|
1180
|
+
` let next={};`,
|
|
1181
|
+
` try { next=raw ? JSON.parse(raw) : {}; } catch { next={}; }`,
|
|
1182
|
+
` next.gatewayUrl=gatewayUrl;`,
|
|
1183
|
+
` if('token' in next) delete next.token;`,
|
|
1184
|
+
` const hashParams=new URLSearchParams(window.location.hash.startsWith('#')?window.location.hash.slice(1):window.location.hash);`,
|
|
1185
|
+
` const searchParams=new URLSearchParams(window.location.search);`,
|
|
1186
|
+
` const launchToken=(hashParams.get('token')||searchParams.get('token')||'').trim();`,
|
|
1187
|
+
` if(launchToken){`,
|
|
1188
|
+
` window.sessionStorage.setItem(tokenSessionKey, launchToken);`,
|
|
1189
|
+
` }`,
|
|
1190
|
+
` window.localStorage.setItem(settingsKey, JSON.stringify(next));`,
|
|
1191
|
+
` const autoConnect=()=>{`,
|
|
1192
|
+
` const attempt=()=>{`,
|
|
1193
|
+
` const app=document.querySelector('openclaw-app');`,
|
|
1194
|
+
` if(!app||typeof app.connect!=='function'||typeof app.applySettings!=='function'||!app.settings||typeof app.settings!=='object') return false;`,
|
|
1195
|
+
` const sessionToken=(window.sessionStorage.getItem(tokenSessionKey)||'').trim();`,
|
|
1196
|
+
` const token=(sessionToken||launchToken||app.settings.token||'').trim();`,
|
|
1197
|
+
` if(!token) return false;`,
|
|
1198
|
+
` const nextSettings={...app.settings, gatewayUrl, token};`,
|
|
1199
|
+
` if(nextSettings.gatewayUrl!==app.settings.gatewayUrl||nextSettings.token!==app.settings.token){`,
|
|
1200
|
+
` app.applySettings(nextSettings);`,
|
|
1201
|
+
` }`,
|
|
1202
|
+
` if(app.connected) return true;`,
|
|
1203
|
+
` const wsState=app.client&&app.client.ws?app.client.ws.readyState:null;`,
|
|
1204
|
+
` const connecting=wsState===0||wsState===1;`,
|
|
1205
|
+
` if(!connecting){`,
|
|
1206
|
+
` window.setTimeout(()=>{`,
|
|
1207
|
+
` try { if(!app.connected) app.connect(); } catch {}`,
|
|
1208
|
+
` }, 0);`,
|
|
1209
|
+
` }`,
|
|
1210
|
+
` return false;`,
|
|
1211
|
+
` };`,
|
|
1212
|
+
` const start=()=>{`,
|
|
1213
|
+
` let tries=0;`,
|
|
1214
|
+
` let timer=0;`,
|
|
1215
|
+
` const tick=()=>{`,
|
|
1216
|
+
` tries+=1;`,
|
|
1217
|
+
` if(attempt()||tries>=120){`,
|
|
1218
|
+
` window.clearInterval(timer);`,
|
|
1219
|
+
` }`,
|
|
1220
|
+
` };`,
|
|
1221
|
+
` tick();`,
|
|
1222
|
+
` timer=window.setInterval(()=>{`,
|
|
1223
|
+
` tick();`,
|
|
1224
|
+
` },500);`,
|
|
1225
|
+
` };`,
|
|
1226
|
+
` if(window.customElements&&typeof window.customElements.whenDefined==='function'){`,
|
|
1227
|
+
` window.customElements.whenDefined('openclaw-app').then(start).catch(()=>{});`,
|
|
1228
|
+
` }else{`,
|
|
1229
|
+
` start();`,
|
|
1230
|
+
` }`,
|
|
1231
|
+
` };`,
|
|
1232
|
+
` autoConnect();`,
|
|
1233
|
+
` } catch {}`,
|
|
1234
|
+
`})();`,
|
|
1235
|
+
].join("");
|
|
1236
|
+
const inject = `<script>${injectScript}</script>`;
|
|
1237
|
+
// Append jishu-inject listener as a separate script tag (keeps CSP hash separate)
|
|
1238
|
+
const injectCmdScript = [
|
|
1239
|
+
`(function(){`,
|
|
1240
|
+
` var _jishuInject=function(cmd,send){`,
|
|
1241
|
+
` // Primary path: use openclaw-app Lit component API directly.`,
|
|
1242
|
+
` // app.chatMessage is the reactive property backing the textarea draft.`,
|
|
1243
|
+
` // app.handleSendChat(cmd) invokes the component's own send handler.`,
|
|
1244
|
+
` var app=document.querySelector('openclaw-app');`,
|
|
1245
|
+
` if(!app)return false;`,
|
|
1246
|
+
` if(send){`,
|
|
1247
|
+
` // Only send when gateway WebSocket is connected`,
|
|
1248
|
+
` if(!app.connected)return false;`,
|
|
1249
|
+
` try{app.handleSendChat(cmd);return true;}catch(e){}`,
|
|
1250
|
+
` return false;`,
|
|
1251
|
+
` }else{`,
|
|
1252
|
+
` // Draft-only: set reactive property so Lit re-renders the textarea`,
|
|
1253
|
+
` try{app.chatMessage=cmd;return true;}catch(e){}`,
|
|
1254
|
+
` return false;`,
|
|
1255
|
+
` }`,
|
|
1256
|
+
` };`,
|
|
1257
|
+
` window.addEventListener('message',function(e){`,
|
|
1258
|
+
` if(!e.data||e.data.type!=='jishu:inject-cmd')return;`,
|
|
1259
|
+
` var cmd=e.data.cmd,send=!!e.data.send,tries=0;`,
|
|
1260
|
+
` var poll=function(){if(_jishuInject(cmd,send)||++tries>=50)return;setTimeout(poll,200);};`,
|
|
1261
|
+
` poll();`,
|
|
1262
|
+
` },false);`,
|
|
1263
|
+
`})();`,
|
|
1264
|
+
].join("");
|
|
1265
|
+
const injectCmdScriptHash = createHash("sha256").update(injectCmdScript, "utf8").digest("base64");
|
|
1266
|
+
const fullHtmlInject = `${inject}<script>${injectCmdScript}</script>`;
|
|
1267
|
+
html = html.replace(/<head\b[^>]*>/i, (match) => `${match}${fullHtmlInject}`);
|
|
1268
|
+
const inlineScriptHash = createHash("sha256").update(injectScript, "utf8").digest("base64");
|
|
1269
|
+
const cspHeader = reply.getHeader("content-security-policy");
|
|
1270
|
+
if (typeof cspHeader === "string" && cspHeader) {
|
|
1271
|
+
const hashToken = `'sha256-${inlineScriptHash}'`;
|
|
1272
|
+
const hashToken2 = `'sha256-${injectCmdScriptHash}'`;
|
|
1273
|
+
const addHashes = (src) => {
|
|
1274
|
+
let s = src;
|
|
1275
|
+
if (!s.includes(hashToken))
|
|
1276
|
+
s = s + ` ${hashToken}`;
|
|
1277
|
+
if (!s.includes(hashToken2))
|
|
1278
|
+
s = s + ` ${hashToken2}`;
|
|
1279
|
+
return s;
|
|
1280
|
+
};
|
|
1281
|
+
const nextCsp = /\bscript-src\b/i.test(cspHeader)
|
|
1282
|
+
? cspHeader.replace(/\bscript-src\b([^;]*)/i, (_m, value) => `script-src${addHashes(value)}`)
|
|
1283
|
+
: `${cspHeader}; script-src 'self' ${hashToken} ${hashToken2}`;
|
|
1284
|
+
reply.header("content-security-policy", nextCsp);
|
|
1285
|
+
}
|
|
1286
|
+
const buf = Buffer.from(html, "utf-8");
|
|
1287
|
+
reply.header("cache-control", "no-store");
|
|
1288
|
+
reply.header("content-length", buf.length);
|
|
1289
|
+
reply.removeHeader("content-encoding");
|
|
1290
|
+
return reply.send(buf);
|
|
1291
|
+
}
|
|
1292
|
+
// Non-HTML: stream response directly
|
|
1293
|
+
return reply.send(upstreamRes);
|
|
1294
|
+
}
|
|
1295
|
+
catch (err) {
|
|
1296
|
+
console.error(`[gateway-proxy] ${id}:`, err.message || err);
|
|
1297
|
+
return reply.status(502).send({ detail: "Cannot reach OpenClaw gateway" });
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
app.all("/api/instances/:id/gateway/*", gatewayProxy);
|
|
1301
|
+
app.all("/api/instances/:id/gateway", gatewayProxy);
|
|
1302
|
+
}
|
|
1303
|
+
//# sourceMappingURL=instances.js.map
|