jishushell 0.4.17 → 0.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile.hermes-slim +193 -0
- package/apps/hermes-container.yaml +35 -0
- package/apps/ollama-binary.yaml +200 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +195 -0
- package/apps/openclaw-binary.yaml +69 -0
- package/apps/openclaw-container.yaml +37 -0
- package/apps/openclaw-with-ollama-container.yaml +42 -0
- package/apps/openclaw-with-searxng-container.yaml +136 -0
- package/apps/openwebui-container.yaml +53 -0
- package/apps/playwright-container.yaml +120 -0
- package/apps/searxng-container.yaml +115 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +15 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +1 -0
- package/dist/cli/app.js +710 -52
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/backup.d.ts +3 -0
- package/dist/cli/backup.js +434 -0
- package/dist/cli/backup.js.map +1 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +61 -35
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/job.d.ts +1 -0
- package/dist/cli/job.js +37 -99
- package/dist/cli/job.js.map +1 -1
- package/dist/cli/llm.d.ts +1 -0
- package/dist/cli/llm.js +20 -14
- package/dist/cli/llm.js.map +1 -1
- package/dist/cli/managed-list.d.ts +30 -0
- package/dist/cli/managed-list.js +129 -0
- package/dist/cli/managed-list.js.map +1 -0
- package/dist/cli/panel.d.ts +4 -3
- package/dist/cli/panel.js +94 -24
- package/dist/cli/panel.js.map +1 -1
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +12 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli.js +47 -516
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +68 -0
- package/dist/config.js +266 -12
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +10 -6
- package/dist/control.js +87 -6
- package/dist/control.js.map +1 -1
- package/dist/install.d.ts +16 -0
- package/dist/install.js +75 -26
- package/dist/install.js.map +1 -1
- package/dist/routes/agent-apps.d.ts +15 -0
- package/dist/routes/agent-apps.js +78 -0
- package/dist/routes/agent-apps.js.map +1 -0
- package/dist/routes/apps.js +186 -7
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/backup.js +3 -3
- package/dist/routes/backup.js.map +1 -1
- package/dist/routes/instances.d.ts +6 -0
- package/dist/routes/instances.js +862 -879
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.js +9 -8
- package/dist/routes/llm.js.map +1 -1
- package/dist/routes/runtime.d.ts +15 -0
- package/dist/routes/runtime.js +69 -0
- package/dist/routes/runtime.js.map +1 -0
- package/dist/routes/setup.js +103 -8
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +25 -3
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +71 -7
- package/dist/server.js.map +1 -1
- package/dist/services/agent-apps/catalog.d.ts +30 -0
- package/dist/services/agent-apps/catalog.js +60 -0
- package/dist/services/agent-apps/catalog.js.map +1 -0
- package/dist/services/agent-apps/index.d.ts +36 -0
- package/dist/services/agent-apps/index.js +171 -0
- package/dist/services/agent-apps/index.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter-probes.d.ts +49 -0
- package/dist/services/agent-apps/installers/adapter-probes.js +223 -0
- package/dist/services/agent-apps/installers/adapter-probes.js.map +1 -0
- package/dist/services/agent-apps/installers/adapter.d.ts +30 -0
- package/dist/services/agent-apps/installers/adapter.js +171 -0
- package/dist/services/agent-apps/installers/adapter.js.map +1 -0
- package/dist/services/agent-apps/installers/registry-probe.d.ts +38 -0
- package/dist/services/agent-apps/installers/registry-probe.js +183 -0
- package/dist/services/agent-apps/installers/registry-probe.js.map +1 -0
- package/dist/services/agent-apps/installers/shell-script.d.ts +47 -0
- package/dist/services/agent-apps/installers/shell-script.js +471 -0
- package/dist/services/agent-apps/installers/shell-script.js.map +1 -0
- package/dist/services/agent-apps/types.d.ts +125 -0
- package/dist/services/agent-apps/types.js +17 -0
- package/dist/services/agent-apps/types.js.map +1 -0
- package/dist/services/{app-compiler.d.ts → app/app-compiler.d.ts} +3 -3
- package/dist/services/{app-compiler.js → app/app-compiler.js} +10 -7
- package/dist/services/app/app-compiler.js.map +1 -0
- package/dist/services/app/app-manager.d.ts +142 -0
- package/dist/services/app/app-manager.js +1988 -0
- package/dist/services/app/app-manager.js.map +1 -0
- package/dist/services/app/custom-manager.d.ts +27 -0
- package/dist/services/app/custom-manager.js +285 -0
- package/dist/services/app/custom-manager.js.map +1 -0
- package/dist/services/app/hermes-agent-manager.d.ts +20 -0
- package/dist/services/app/hermes-agent-manager.js +289 -0
- package/dist/services/app/hermes-agent-manager.js.map +1 -0
- package/dist/services/app/id-normalizer.d.ts +27 -0
- package/dist/services/app/id-normalizer.js +77 -0
- package/dist/services/app/id-normalizer.js.map +1 -0
- package/dist/services/app/ollama-manager.d.ts +18 -0
- package/dist/services/app/ollama-manager.js +207 -0
- package/dist/services/app/ollama-manager.js.map +1 -0
- package/dist/services/app/openclaw-manager.d.ts +63 -0
- package/dist/services/app/openclaw-manager.js +1178 -0
- package/dist/services/app/openclaw-manager.js.map +1 -0
- package/dist/services/app/paths.d.ts +47 -0
- package/dist/services/app/paths.js +68 -0
- package/dist/services/app/paths.js.map +1 -0
- package/dist/services/app/registry.d.ts +17 -0
- package/dist/services/app/registry.js +31 -0
- package/dist/services/app/registry.js.map +1 -0
- package/dist/services/app/remote-spec.d.ts +14 -0
- package/dist/services/app/remote-spec.js +58 -0
- package/dist/services/app/remote-spec.js.map +1 -0
- package/dist/services/app/terminal-session-manager.d.ts +27 -0
- package/dist/services/app/terminal-session-manager.js +157 -0
- package/dist/services/app/terminal-session-manager.js.map +1 -0
- package/dist/services/app/types.d.ts +72 -0
- package/dist/services/app/types.js +16 -0
- package/dist/services/app/types.js.map +1 -0
- package/dist/services/backup-manager.js +60 -22
- package/dist/services/backup-manager.js.map +1 -1
- package/dist/services/instance-manager.d.ts +82 -39
- package/dist/services/instance-manager.js +575 -1142
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/circuit-breaker.js +10 -2
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +14 -1
- package/dist/services/llm-proxy/index.js +51 -6
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/nomad-manager.d.ts +260 -3
- package/dist/services/nomad-manager.js +2866 -449
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +10 -0
- package/dist/services/panel-manager.js +97 -0
- package/dist/services/panel-manager.js.map +1 -1
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +22 -0
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/runtime/adapters/custom.d.ts +20 -0
- package/dist/services/runtime/adapters/custom.js +90 -0
- package/dist/services/runtime/adapters/custom.js.map +1 -0
- package/dist/services/runtime/adapters/hermes.d.ts +174 -0
- package/dist/services/runtime/adapters/hermes.js +1316 -0
- package/dist/services/runtime/adapters/hermes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw-routes.d.ts +17 -0
- package/dist/services/runtime/adapters/openclaw-routes.js +946 -0
- package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw.d.ts +188 -0
- package/dist/services/runtime/adapters/openclaw.js +2195 -0
- package/dist/services/runtime/adapters/openclaw.js.map +1 -0
- package/dist/services/runtime/errors.d.ts +28 -0
- package/dist/services/runtime/errors.js +31 -0
- package/dist/services/runtime/errors.js.map +1 -0
- package/dist/services/runtime/index.d.ts +34 -0
- package/dist/services/runtime/index.js +51 -0
- package/dist/services/runtime/index.js.map +1 -0
- package/dist/services/runtime/instance.d.ts +24 -0
- package/dist/services/runtime/instance.js +143 -0
- package/dist/services/runtime/instance.js.map +1 -0
- package/dist/services/runtime/migrations.d.ts +15 -0
- package/dist/services/runtime/migrations.js +25 -0
- package/dist/services/runtime/migrations.js.map +1 -0
- package/dist/services/runtime/registry.d.ts +13 -0
- package/dist/services/runtime/registry.js +32 -0
- package/dist/services/runtime/registry.js.map +1 -0
- package/dist/services/runtime/types.d.ts +545 -0
- package/dist/services/runtime/types.js +14 -0
- package/dist/services/runtime/types.js.map +1 -0
- package/dist/services/setup-manager.d.ts +70 -29
- package/dist/services/setup-manager.js +278 -597
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/task-registry.d.ts +44 -0
- package/dist/services/task-registry.js +74 -0
- package/dist/services/task-registry.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +6 -6
- package/dist/services/telemetry/heartbeat.js +29 -30
- package/dist/services/telemetry/heartbeat.js.map +1 -1
- package/dist/types.d.ts +162 -2
- package/dist/utils/docker-host.d.ts +15 -0
- package/dist/utils/docker-host.js +64 -0
- package/dist/utils/docker-host.js.map +1 -0
- package/install/jishu-install.sh +25 -1
- package/package.json +14 -4
- package/public/assets/Dashboard-B-JoOjBQ.js +1 -0
- package/public/assets/HermesChatPanel-mFSureyc.js +1 -0
- package/public/assets/HermesConfigForm-DvR05LK1.js +4 -0
- package/public/assets/InitPassword-CVA8wQA6.js +1 -0
- package/public/assets/InstanceDetail-DcZW2QGO.js +91 -0
- package/public/assets/{Login-D1Bt-Lyk.js → Login-BWsZH2mu.js} +1 -1
- package/public/assets/NewInstance-BCIrAd86.js +1 -0
- package/public/assets/Settings-xkDcduFz.js +1 -0
- package/public/assets/Setup-Cfuwj4gV.js +1 -0
- package/public/assets/WeixinLoginPanel-CnjR8xMu.js +9 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/index-DQsM6Joa.js +19 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/registry-B4UFJdpA.js +2 -0
- package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
- package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
- package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
- package/public/favicon.png +0 -0
- package/public/index.html +9 -4
- package/public/logos/hermes.png +0 -0
- package/public/logos/ollama.png +0 -0
- package/public/logos/openclaw.svg +60 -0
- package/scripts/build-hermes-image.sh +21 -0
- package/scripts/build-local.sh +54 -0
- package/scripts/check-adapter-isolation.ts +293 -0
- package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
- package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
- package/scripts/smoke/hermes-bootstrap.sh +195 -0
- package/templates/hermes-entrypoint.sh +154 -0
- package/dist/cli/openclaw.d.ts +0 -12
- package/dist/cli/openclaw.js +0 -156
- package/dist/cli/openclaw.js.map +0 -1
- package/dist/services/app-compiler.js.map +0 -1
- package/dist/services/app-manager.d.ts +0 -17
- package/dist/services/app-manager.js +0 -168
- package/dist/services/app-manager.js.map +0 -1
- package/dist/services/job-manager.d.ts +0 -22
- package/dist/services/job-manager.js +0 -102
- package/dist/services/job-manager.js.map +0 -1
- package/public/assets/Dashboard-CQsp1Mr9.js +0 -1
- package/public/assets/InitPassword-BEC8SE4A.js +0 -1
- package/public/assets/InstanceDetail-B5wTgNEg.js +0 -17
- package/public/assets/NewInstance-GQzm3K9D.js +0 -1
- package/public/assets/Settings-ByjGlqhP.js +0 -1
- package/public/assets/Setup-cMF21Y-8.js +0 -1
- package/public/assets/index-B6qQP4mH.css +0 -1
- package/public/assets/index-BuTQtuNy.js +0 -16
- package/public/assets/vendor-i18n-CfW0RvgE.js +0 -9
package/dist/routes/instances.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
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
1
|
import { getServiceManagerType } from "../config.js";
|
|
7
|
-
import { PROXY_IDENTITY_HEADERS } from "../constants.js";
|
|
8
2
|
import { assertNotLocked } from "../services/backup-manager.js";
|
|
9
|
-
import * as instanceManager from "../services/
|
|
3
|
+
import * as instanceManager from "../services/app/app-manager.js";
|
|
10
4
|
import * as llmProxy from "../services/llm-proxy/index.js";
|
|
11
|
-
import
|
|
5
|
+
import { loadRemoteAppSpecYaml } from "../services/app/remote-spec.js";
|
|
6
|
+
import { augmentInstanceMetadata, getInstanceCapabilities, getInstanceConfigMeta, resolveAgentType, } from "../services/runtime/instance.js";
|
|
7
|
+
import { getAdapter, hasAdapter, listRegisteredAdapters } from "../services/runtime/index.js";
|
|
8
|
+
import { normalizeInstanceId } from "../services/app/id-normalizer.js";
|
|
9
|
+
import { assertTerminalSessionOwner, getTerminalSession, getTerminalSessionEvents, sendTerminalSessionInput, startTerminalSession, stopTerminalSession, subscribeTerminalSession, } from "../services/app/terminal-session-manager.js";
|
|
12
10
|
import { TtlMap } from "../utils/ttl-cache.js";
|
|
13
|
-
import {
|
|
14
|
-
// Hop-by-hop headers that must not be forwarded by a proxy (RFC 2616 §13.5.1)
|
|
15
|
-
|
|
11
|
+
import { writeSecretFile } from "../utils/fs.js";
|
|
12
|
+
// Hop-by-hop headers that must not be forwarded by a proxy (RFC 2616 §13.5.1).
|
|
13
|
+
// Exported for adapter-owned route modules that implement their own HTTP proxies.
|
|
14
|
+
export const HOP_BY_HOP = new Set([
|
|
16
15
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
17
16
|
"te", "trailer", "transfer-encoding", "upgrade",
|
|
18
17
|
]);
|
|
@@ -31,14 +30,257 @@ function parseHttpOrigin(value) {
|
|
|
31
30
|
return null;
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
|
-
function inferRequestOrigin(request) {
|
|
33
|
+
export function inferRequestOrigin(request) {
|
|
35
34
|
// Only trust browser-sent Origin/Referer for auto-allowlisting. Host and
|
|
36
35
|
// X-Forwarded-* are proxy metadata and should not become persisted origin
|
|
37
36
|
// policy by themselves.
|
|
38
37
|
return (parseHttpOrigin(readHeaderValue(request?.headers?.origin))
|
|
39
38
|
?? parseHttpOrigin(readHeaderValue(request?.headers?.referer)));
|
|
40
39
|
}
|
|
41
|
-
function
|
|
40
|
+
function capabilityProxyPath(instanceId, capability) {
|
|
41
|
+
return `/api/instances/${encodeURIComponent(instanceId)}/provides/${encodeURIComponent(capability)}`;
|
|
42
|
+
}
|
|
43
|
+
function joinProxyPath(basePath, suffix) {
|
|
44
|
+
const normalizedBase = basePath.replace(/\/+$/, "");
|
|
45
|
+
const normalizedSuffix = suffix.replace(/^\/+/, "");
|
|
46
|
+
if (!normalizedSuffix)
|
|
47
|
+
return normalizedBase;
|
|
48
|
+
return `${normalizedBase}/${normalizedSuffix}`;
|
|
49
|
+
}
|
|
50
|
+
function joinUpstreamPath(basePath, suffix) {
|
|
51
|
+
const normalizedBase = typeof basePath === "string" && basePath.trim()
|
|
52
|
+
? (basePath.startsWith("/") ? basePath : `/${basePath}`)
|
|
53
|
+
: "/";
|
|
54
|
+
const normalizedSuffix = suffix.replace(/^\/+/, "");
|
|
55
|
+
if (!normalizedSuffix)
|
|
56
|
+
return normalizedBase;
|
|
57
|
+
return `${normalizedBase.replace(/\/+$/, "")}/${normalizedSuffix}`;
|
|
58
|
+
}
|
|
59
|
+
function shouldRewriteProxyResponse(contentType) {
|
|
60
|
+
const value = (contentType ?? "").toLowerCase();
|
|
61
|
+
return value.includes("text/html") || value.includes("text/css");
|
|
62
|
+
}
|
|
63
|
+
function rewriteProxyTextBody(body, contentType, proxyBasePath) {
|
|
64
|
+
const value = (contentType ?? "").toLowerCase();
|
|
65
|
+
const proxyBaseWithSlash = `${proxyBasePath.replace(/\/+$/, "")}/`;
|
|
66
|
+
let rewritten = body;
|
|
67
|
+
if (value.includes("text/html")) {
|
|
68
|
+
if (!/<base\b/i.test(rewritten)) {
|
|
69
|
+
if (/<head[^>]*>/i.test(rewritten)) {
|
|
70
|
+
rewritten = rewritten.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBaseWithSlash}">`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
rewritten = `<base href="${proxyBaseWithSlash}">${rewritten}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
rewritten = rewritten.replace(/((?:href|src|action|poster)=['"])\/(?!\/)/gi, `$1${proxyBaseWithSlash}`);
|
|
77
|
+
}
|
|
78
|
+
if (value.includes("text/css") || value.includes("text/html")) {
|
|
79
|
+
rewritten = rewritten.replace(/url\((['"]?)\/(?!\/)/gi, `url($1${proxyBaseWithSlash}`);
|
|
80
|
+
}
|
|
81
|
+
return rewritten;
|
|
82
|
+
}
|
|
83
|
+
function buildProxyRequestBody(req) {
|
|
84
|
+
if (req.method === "GET" || req.method === "HEAD")
|
|
85
|
+
return undefined;
|
|
86
|
+
const body = req.body;
|
|
87
|
+
if (body == null)
|
|
88
|
+
return undefined;
|
|
89
|
+
if (typeof body === "string" || body instanceof Uint8Array || Buffer.isBuffer(body)) {
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
|
|
93
|
+
if (contentType.includes("application/x-www-form-urlencoded") && typeof body === "object") {
|
|
94
|
+
return new URLSearchParams(body).toString();
|
|
95
|
+
}
|
|
96
|
+
if (contentType.includes("application/json")) {
|
|
97
|
+
return JSON.stringify(body);
|
|
98
|
+
}
|
|
99
|
+
if (typeof body === "object") {
|
|
100
|
+
return JSON.stringify(body);
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
function parseCommandLine(input) {
|
|
105
|
+
const trimmed = input.trim();
|
|
106
|
+
if (!trimmed)
|
|
107
|
+
return [];
|
|
108
|
+
const args = [];
|
|
109
|
+
let current = "";
|
|
110
|
+
let quote = null;
|
|
111
|
+
let escaping = false;
|
|
112
|
+
for (const char of trimmed) {
|
|
113
|
+
if (escaping) {
|
|
114
|
+
current += char;
|
|
115
|
+
escaping = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (char === "\\") {
|
|
119
|
+
escaping = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (quote) {
|
|
123
|
+
if (char === quote) {
|
|
124
|
+
quote = null;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
current += char;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (char === '"' || char === "'") {
|
|
132
|
+
quote = char;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (/\s/.test(char)) {
|
|
136
|
+
if (current) {
|
|
137
|
+
args.push(current);
|
|
138
|
+
current = "";
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
current += char;
|
|
143
|
+
}
|
|
144
|
+
if (escaping)
|
|
145
|
+
current += "\\";
|
|
146
|
+
if (quote)
|
|
147
|
+
throw new Error("Command contains an unterminated quote");
|
|
148
|
+
if (current)
|
|
149
|
+
args.push(current);
|
|
150
|
+
return args;
|
|
151
|
+
}
|
|
152
|
+
function isTerminalProvide(provide) {
|
|
153
|
+
return !!provide && String(provide.protocol).toLowerCase() === "terminal" && !!provide.terminal;
|
|
154
|
+
}
|
|
155
|
+
function resolveTerminalProvide(instanceId, capability) {
|
|
156
|
+
const provide = instanceManager
|
|
157
|
+
.getProvidedCapabilitiesForApp(instanceId)
|
|
158
|
+
.find((entry) => entry.capability === capability);
|
|
159
|
+
if (!provide)
|
|
160
|
+
throw new Error(`Capability '${capability}' not found`);
|
|
161
|
+
if (!isTerminalProvide(provide)) {
|
|
162
|
+
throw new Error(`Capability '${capability}' is not a terminal provide`);
|
|
163
|
+
}
|
|
164
|
+
return provide;
|
|
165
|
+
}
|
|
166
|
+
function buildTerminalCommand(baseCommand, input) {
|
|
167
|
+
if (!Array.isArray(baseCommand) || baseCommand.length === 0 || baseCommand.some((part) => typeof part !== "string" || !part.trim())) {
|
|
168
|
+
throw new Error("Terminal provide is missing a valid base command");
|
|
169
|
+
}
|
|
170
|
+
const parsed = parseCommandLine(input);
|
|
171
|
+
if (!parsed.length)
|
|
172
|
+
throw new Error("Command cannot be empty");
|
|
173
|
+
const baseName = baseCommand[0].split("/").pop() || baseCommand[0];
|
|
174
|
+
const matchesBase = parsed.length >= baseCommand.length && baseCommand.every((part, index) => parsed[index] === part);
|
|
175
|
+
const matchesBaseName = parsed[0] === baseName;
|
|
176
|
+
if (matchesBase)
|
|
177
|
+
return parsed;
|
|
178
|
+
if (matchesBaseName)
|
|
179
|
+
return [baseCommand[0], ...parsed.slice(1)];
|
|
180
|
+
return [...baseCommand, ...parsed];
|
|
181
|
+
}
|
|
182
|
+
async function proxyProvidedCapability(req, reply) {
|
|
183
|
+
const idErr = validateId(req.params.id);
|
|
184
|
+
if (idErr)
|
|
185
|
+
return reply.status(400).send({ detail: idErr });
|
|
186
|
+
const rawInst = instanceManager.getInstance(req.params.id);
|
|
187
|
+
if (!rawInst)
|
|
188
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
189
|
+
if (!instanceManager.getApp(req.params.id))
|
|
190
|
+
return reply.status(404).send({ detail: "App not found" });
|
|
191
|
+
const capabilities = instanceManager.getProvidedCapabilitiesForApp(req.params.id);
|
|
192
|
+
const capability = capabilities.find((entry) => entry.capability === req.params.capability);
|
|
193
|
+
if (!capability) {
|
|
194
|
+
return reply.status(404).send({ detail: `Capability '${req.params.capability}' not found` });
|
|
195
|
+
}
|
|
196
|
+
if (capability.visibility === "internal") {
|
|
197
|
+
return reply.status(403).send({ detail: `Capability '${req.params.capability}' is not externally accessible` });
|
|
198
|
+
}
|
|
199
|
+
if (capability.protocol !== "http" && capability.protocol !== "https") {
|
|
200
|
+
return reply.status(400).send({ detail: `Capability '${req.params.capability}' does not use HTTP(S)` });
|
|
201
|
+
}
|
|
202
|
+
if (typeof capability.port !== "number" || capability.port < 1) {
|
|
203
|
+
return reply.status(500).send({ detail: `Capability '${req.params.capability}' has no resolved port` });
|
|
204
|
+
}
|
|
205
|
+
const upstreamHost = instanceManager.getListeningHostForPort(capability.port);
|
|
206
|
+
const upstreamOrigin = `${capability.protocol}://${instanceManager.urlHost(upstreamHost)}:${capability.port}`;
|
|
207
|
+
const wildcardSuffix = typeof req.params["*"] === "string" ? req.params["*"] : "";
|
|
208
|
+
const upstreamPath = joinUpstreamPath(capability.path, wildcardSuffix);
|
|
209
|
+
const querySuffix = req.raw.url?.includes("?") ? req.raw.url.slice(req.raw.url.indexOf("?")) : "";
|
|
210
|
+
const targetUrl = `${upstreamOrigin}${upstreamPath}${querySuffix}`;
|
|
211
|
+
const proxyBasePath = capabilityProxyPath(req.params.id, req.params.capability);
|
|
212
|
+
const headers = new Headers();
|
|
213
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
214
|
+
if (value == null)
|
|
215
|
+
continue;
|
|
216
|
+
const normalizedKey = key.toLowerCase();
|
|
217
|
+
if (HOP_BY_HOP.has(normalizedKey) || normalizedKey === "host" || normalizedKey === "content-length" || normalizedKey === "accept-encoding") {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (Array.isArray(value)) {
|
|
221
|
+
for (const item of value)
|
|
222
|
+
headers.append(key, item);
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
headers.set(key, String(value));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
headers.set("accept-encoding", "identity");
|
|
229
|
+
// `x-forwarded-prefix` is not a standard reverse-proxy header and some
|
|
230
|
+
// upstream frameworks (notably SvelteKit apps like Hollama) treat it as a
|
|
231
|
+
// deployment base path, which breaks `/_app/*` asset resolution under this
|
|
232
|
+
// generic proxy. The HTML/base rewrite below already handles path prefixing.
|
|
233
|
+
if (req.headers.host)
|
|
234
|
+
headers.set("x-forwarded-host", String(req.headers.host));
|
|
235
|
+
headers.set("x-forwarded-proto", req.protocol);
|
|
236
|
+
try {
|
|
237
|
+
const upstream = await fetch(targetUrl, {
|
|
238
|
+
method: req.method,
|
|
239
|
+
headers,
|
|
240
|
+
body: buildProxyRequestBody(req),
|
|
241
|
+
redirect: "manual",
|
|
242
|
+
signal: AbortSignal.timeout(60_000),
|
|
243
|
+
});
|
|
244
|
+
reply.code(upstream.status);
|
|
245
|
+
upstream.headers.forEach((value, key) => {
|
|
246
|
+
const normalizedKey = key.toLowerCase();
|
|
247
|
+
if (HOP_BY_HOP.has(normalizedKey) || normalizedKey === "content-length" || normalizedKey === "content-encoding") {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (normalizedKey === "location") {
|
|
251
|
+
if (value.startsWith("/")) {
|
|
252
|
+
reply.header(key, joinProxyPath(proxyBasePath, value));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const parsed = new URL(value);
|
|
257
|
+
const upstreamBase = new URL(upstreamOrigin);
|
|
258
|
+
if (parsed.origin === upstreamBase.origin) {
|
|
259
|
+
reply.header(key, `${joinProxyPath(proxyBasePath, parsed.pathname)}${parsed.search}${parsed.hash}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// fall through to raw location header
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
reply.header(key, value);
|
|
268
|
+
});
|
|
269
|
+
if (req.method === "HEAD") {
|
|
270
|
+
return reply.send();
|
|
271
|
+
}
|
|
272
|
+
if (shouldRewriteProxyResponse(upstream.headers.get("content-type"))) {
|
|
273
|
+
const rewritten = rewriteProxyTextBody(await upstream.text(), upstream.headers.get("content-type"), proxyBasePath);
|
|
274
|
+
return reply.send(rewritten);
|
|
275
|
+
}
|
|
276
|
+
const buffer = Buffer.from(await upstream.arrayBuffer());
|
|
277
|
+
return reply.send(buffer);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
return reply.status(502).send({ detail: error?.message || `Failed to proxy capability '${req.params.capability}'` });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export async function ensureControlUiAllowedOrigin(instanceId, origin) {
|
|
42
284
|
const normalizedOrigin = origin.trim();
|
|
43
285
|
if (!normalizedOrigin)
|
|
44
286
|
return false;
|
|
@@ -54,13 +296,13 @@ function ensureControlUiAllowedOrigin(instanceId, origin) {
|
|
|
54
296
|
if (normalized.has("*") || normalized.has(normalizedOrigin.toLowerCase()))
|
|
55
297
|
return false;
|
|
56
298
|
controlUi.allowedOrigins = [...existing.filter((value) => value.trim()), normalizedOrigin];
|
|
57
|
-
instanceManager.saveConfig(instanceId, config);
|
|
299
|
+
await instanceManager.saveConfig(instanceId, config);
|
|
58
300
|
return true;
|
|
59
301
|
}
|
|
60
302
|
// Resolve service manager once at route registration, re-resolve on config change
|
|
61
303
|
let _svc = null;
|
|
62
304
|
let _svcType = "";
|
|
63
|
-
async function getSvc() {
|
|
305
|
+
export async function getSvc() {
|
|
64
306
|
const currentType = getServiceManagerType();
|
|
65
307
|
if (_svc && _svcType === currentType)
|
|
66
308
|
return _svc;
|
|
@@ -76,6 +318,34 @@ export function validateId(id) {
|
|
|
76
318
|
}
|
|
77
319
|
return null;
|
|
78
320
|
}
|
|
321
|
+
function normalizeInstanceName(name) {
|
|
322
|
+
return name.trim().toLowerCase();
|
|
323
|
+
}
|
|
324
|
+
function isLegacyInstanceAppType(value) {
|
|
325
|
+
return value === "custom" || value === "ollama";
|
|
326
|
+
}
|
|
327
|
+
function getInstanceBackedInstalledApp(instanceId) {
|
|
328
|
+
return instanceManager.getApp(instanceId);
|
|
329
|
+
}
|
|
330
|
+
const DEFAULT_INSTANCE_TEMPLATE_BY_KIND = {
|
|
331
|
+
ollama: "ollama-with-hollama-binary.yaml",
|
|
332
|
+
};
|
|
333
|
+
function loadBuiltinAppSpecYaml(fileName) {
|
|
334
|
+
const template = instanceManager.listBuiltinAppSpecs().find((entry) => entry.fileName === fileName);
|
|
335
|
+
if (!template?.yaml) {
|
|
336
|
+
throw new Error(`Builtin app spec '${fileName}' not found`);
|
|
337
|
+
}
|
|
338
|
+
return template.yaml;
|
|
339
|
+
}
|
|
340
|
+
async function loadRemoteAppSpecYamlForInstance(urlText) {
|
|
341
|
+
return loadRemoteAppSpecYaml(urlText, { fieldName: "app_spec_url" });
|
|
342
|
+
}
|
|
343
|
+
function isConfigDocument(value) {
|
|
344
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
345
|
+
return false;
|
|
346
|
+
const format = value.format;
|
|
347
|
+
return format === "json" || format === "yaml+env";
|
|
348
|
+
}
|
|
79
349
|
// TTL 15s: outlives the frontend 10s polling interval so most requests hit cache
|
|
80
350
|
const statusCache = new TtlMap(15_000);
|
|
81
351
|
const controlUiRestartInFlight = new Map();
|
|
@@ -83,12 +353,21 @@ function getCachedStatus(svc, instanceId) {
|
|
|
83
353
|
const cached = statusCache.get(instanceId);
|
|
84
354
|
if (cached !== undefined)
|
|
85
355
|
return Promise.resolve(cached);
|
|
86
|
-
|
|
356
|
+
const statusPromise = getInstanceBackedInstalledApp(instanceId)
|
|
357
|
+
? instanceManager.getAppStatus(instanceId).then((data) => ({
|
|
358
|
+
status: data.status,
|
|
359
|
+
pid: data.pid,
|
|
360
|
+
uptime: data.uptime,
|
|
361
|
+
memory_mb: data.memory_mb,
|
|
362
|
+
cpu_percent: data.cpu_percent,
|
|
363
|
+
}))
|
|
364
|
+
: Promise.resolve(svc.getStatus(instanceId));
|
|
365
|
+
return statusPromise.then((data) => {
|
|
87
366
|
statusCache.set(instanceId, data);
|
|
88
367
|
return data;
|
|
89
368
|
});
|
|
90
369
|
}
|
|
91
|
-
async function restartRunningInstanceForControlUiOrigin(instanceId, origin) {
|
|
370
|
+
export async function restartRunningInstanceForControlUiOrigin(instanceId, origin) {
|
|
92
371
|
const inFlight = controlUiRestartInFlight.get(instanceId);
|
|
93
372
|
if (inFlight)
|
|
94
373
|
return inFlight;
|
|
@@ -116,21 +395,130 @@ export async function instanceRoutes(app) {
|
|
|
116
395
|
const svc = await getSvc();
|
|
117
396
|
const instances = instanceManager.listInstances();
|
|
118
397
|
const statuses = await Promise.all(instances.map(inst => getCachedStatus(svc, inst.id).catch(() => ({ status: "unknown" }))));
|
|
119
|
-
return instances.map((inst, i) => ({
|
|
398
|
+
return Promise.all(instances.map(async (inst, i) => ({
|
|
399
|
+
...(await augmentInstanceMetadata(inst.id, inst)),
|
|
400
|
+
service: statuses[i],
|
|
401
|
+
})));
|
|
120
402
|
});
|
|
121
403
|
// Create
|
|
122
404
|
app.post("/api/instances", async (req, reply) => {
|
|
123
405
|
const err = validateId(req.body.id);
|
|
124
406
|
if (err)
|
|
125
407
|
return reply.status(400).send({ detail: err });
|
|
408
|
+
const requestedName = typeof req.body.name === "string" ? req.body.name.trim() : "";
|
|
126
409
|
// Validate name/description length
|
|
127
|
-
if (
|
|
410
|
+
if (!requestedName || requestedName.length > 256) {
|
|
128
411
|
return reply.status(400).send({ detail: "Name must be 1-256 characters" });
|
|
129
412
|
}
|
|
130
413
|
if (req.body.description && req.body.description.length > 2048) {
|
|
131
414
|
return reply.status(400).send({ detail: "Description must be at most 2048 characters" });
|
|
132
415
|
}
|
|
133
|
-
//
|
|
416
|
+
// §32.2 / §32.8: pure adapter dispatch. The route layer has no
|
|
417
|
+
// knowledge of specific runtimes beyond trusting what the caller sent;
|
|
418
|
+
// `getAdapter(agentType)` throws cleanly if the type is unregistered.
|
|
419
|
+
// `app_type` is accepted as a legacy alias that predates the adapter
|
|
420
|
+
// contract (pre-§32.2) — both resolve to the same dispatch key.
|
|
421
|
+
const requestedKind = typeof req.body.agentType === "string" && req.body.agentType.trim()
|
|
422
|
+
? req.body.agentType.trim()
|
|
423
|
+
: typeof req.body.app_type === "string" && req.body.app_type.trim()
|
|
424
|
+
? req.body.app_type.trim()
|
|
425
|
+
: "openclaw";
|
|
426
|
+
const { getAdapter, hasAdapter } = await import("../services/runtime/index.js");
|
|
427
|
+
const legacyAppType = isLegacyInstanceAppType(requestedKind) ? requestedKind : null;
|
|
428
|
+
if (!hasAdapter(requestedKind) && !legacyAppType) {
|
|
429
|
+
return reply.status(400).send({
|
|
430
|
+
detail: `Unsupported agentType="${requestedKind}". Install the matching runtime first.`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Parse app_spec_yaml if provided (for YAML-backed install flows).
|
|
434
|
+
let appSpec;
|
|
435
|
+
let appSpecYaml = typeof req.body.app_spec_yaml === "string" ? req.body.app_spec_yaml.trim() : "";
|
|
436
|
+
if (!appSpecYaml && typeof req.body.app_spec_url === "string" && req.body.app_spec_url.trim()) {
|
|
437
|
+
try {
|
|
438
|
+
appSpecYaml = await loadRemoteAppSpecYamlForInstance(req.body.app_spec_url.trim());
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
return reply.status(400).send({ detail: e.message });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// When a legacy dispatch key (e.g. app_type="ollama") is sent without
|
|
445
|
+
// an explicit AppSpec, auto-load the shipped default template so the
|
|
446
|
+
// caller doesn't have to know the YAML lives in apps/.
|
|
447
|
+
const defaultTemplateFile = DEFAULT_INSTANCE_TEMPLATE_BY_KIND[requestedKind];
|
|
448
|
+
if (!appSpecYaml && defaultTemplateFile) {
|
|
449
|
+
try {
|
|
450
|
+
appSpecYaml = loadBuiltinAppSpecYaml(defaultTemplateFile);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
return reply.status(500).send({ detail: e.message });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (appSpecYaml) {
|
|
457
|
+
try {
|
|
458
|
+
const { parse: parseYaml } = await import("yaml");
|
|
459
|
+
const parsed = parseYaml(appSpecYaml);
|
|
460
|
+
if (!parsed || !parsed.tasks || !Array.isArray(parsed.tasks)) {
|
|
461
|
+
return reply.status(400).send({ detail: "Invalid app-spec YAML: must contain a 'tasks' array" });
|
|
462
|
+
}
|
|
463
|
+
if (!parsed.id)
|
|
464
|
+
parsed.id = req.body.id;
|
|
465
|
+
appSpec = parsed;
|
|
466
|
+
}
|
|
467
|
+
catch (e) {
|
|
468
|
+
return reply.status(400).send({ detail: `Invalid YAML: ${e.message}` });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// Normalize instance id: Tier-1 agents get "<agentType>-" prefix;
|
|
472
|
+
// custom apps get "<app_spec_ref>-" prefix (or the spec id verbatim
|
|
473
|
+
// for singleInstance). Idempotent when the user already types the
|
|
474
|
+
// expected prefix. See docs/app-dir-v2-plan.md §2.4.
|
|
475
|
+
//
|
|
476
|
+
// Gate: only apply normalization when the caller explicitly opted in
|
|
477
|
+
// by sending `agentType` (or the legacy `app_type` alias), or when an
|
|
478
|
+
// AppSpec is being installed. Requests without an explicit runtime
|
|
479
|
+
// hint keep the V1 contract (id preserved verbatim) so older API
|
|
480
|
+
// consumers and existing test fixtures continue working. Panel UI
|
|
481
|
+
// sends `agentType` explicitly (`frontend/src/pages/NewInstance.tsx`).
|
|
482
|
+
const explicitRuntime = (typeof req.body.agentType === "string" && req.body.agentType.trim().length > 0) ||
|
|
483
|
+
(typeof req.body.app_type === "string" && req.body.app_type.trim().length > 0);
|
|
484
|
+
// Skip normalization for legacy app types (ollama / custom old marker):
|
|
485
|
+
// these follow the V1 legacy short-circuit path (see
|
|
486
|
+
// docs/app-dir-v2-plan.md §2.1 "Legacy Ollama 口径"), so we keep their
|
|
487
|
+
// ids verbatim rather than retroactively prefixing them.
|
|
488
|
+
const shouldNormalize = (explicitRuntime || appSpecYaml.length > 0) && !legacyAppType;
|
|
489
|
+
const kind = requestedKind === "hermes" ? "hermes"
|
|
490
|
+
: requestedKind === "custom" ? "custom"
|
|
491
|
+
: requestedKind === "openclaw" ? "openclaw"
|
|
492
|
+
: "openclaw";
|
|
493
|
+
let instanceId = req.body.id;
|
|
494
|
+
if (shouldNormalize) {
|
|
495
|
+
try {
|
|
496
|
+
instanceId = normalizeInstanceId(req.body.id, kind, appSpec ? { id: appSpec.id, singleInstance: appSpec.singleInstance } : undefined);
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
return reply.status(400).send({ detail: e.message });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const existingById = instanceManager.getInstance(instanceId);
|
|
503
|
+
if (existingById) {
|
|
504
|
+
return reply.status(409).send({ detail: `Application '${instanceId}' already exists` });
|
|
505
|
+
}
|
|
506
|
+
const requestedNormalizedName = normalizeInstanceName(requestedName);
|
|
507
|
+
const existingByName = instanceManager.listInstances().find((instance) => {
|
|
508
|
+
if (!instance || typeof instance.name !== "string")
|
|
509
|
+
return false;
|
|
510
|
+
return normalizeInstanceName(instance.name) === requestedNormalizedName;
|
|
511
|
+
});
|
|
512
|
+
if (existingByName) {
|
|
513
|
+
return reply.status(409).send({
|
|
514
|
+
detail: `Application name '${requestedName}' already exists`,
|
|
515
|
+
existingId: existingByName.id,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
// Validate clone_from if provided — adapter will reject it if the
|
|
519
|
+
// runtime does not support cloning, but we do the existence check
|
|
520
|
+
// here so the error message points at the wrong source, not at the
|
|
521
|
+
// wrong-runtime rejection.
|
|
134
522
|
if (req.body.clone_from) {
|
|
135
523
|
const cloneErr = validateId(req.body.clone_from);
|
|
136
524
|
if (cloneErr)
|
|
@@ -140,21 +528,78 @@ export async function instanceRoutes(app) {
|
|
|
140
528
|
return reply.status(400).send({ detail: `Source instance '${req.body.clone_from}' not found` });
|
|
141
529
|
}
|
|
142
530
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
531
|
+
let meta;
|
|
532
|
+
const createFromAppSpec = appSpecYaml.length > 0;
|
|
533
|
+
if (!createFromAppSpec) {
|
|
534
|
+
if (!hasAdapter(requestedKind)) {
|
|
535
|
+
return reply.status(400).send({ detail: `Creating ${requestedKind} requires an app spec` });
|
|
536
|
+
}
|
|
537
|
+
const adapter = getAdapter(requestedKind);
|
|
538
|
+
if (typeof adapter.createInstance !== "function") {
|
|
539
|
+
return reply.status(400).send({ detail: `Creating ${requestedKind} requires an app spec` });
|
|
540
|
+
}
|
|
541
|
+
meta = await adapter.createInstance({
|
|
542
|
+
instanceId,
|
|
543
|
+
name: requestedName,
|
|
544
|
+
description: req.body.description || "",
|
|
545
|
+
cloneFrom: req.body.clone_from,
|
|
546
|
+
agentHome: req.body.agent_home ?? req.body.openclaw_home,
|
|
547
|
+
cloneOptions: req.body.clone_options,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
await instanceManager.installApp(appSpecYaml, instanceId, {
|
|
552
|
+
bootstrap: {
|
|
553
|
+
name: requestedName,
|
|
554
|
+
description: req.body.description || "",
|
|
555
|
+
cloneFrom: req.body.clone_from,
|
|
556
|
+
agentHome: req.body.agent_home ?? req.body.openclaw_home,
|
|
557
|
+
cloneOptions: req.body.clone_options,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
meta = instanceManager.updateInstance(instanceId, requestedName, req.body.description || "") ?? instanceManager.getInstance(instanceId) ?? {
|
|
561
|
+
id: instanceId,
|
|
562
|
+
name: requestedName,
|
|
563
|
+
description: req.body.description || "",
|
|
564
|
+
created_at: new Date().toISOString(),
|
|
565
|
+
...(legacyAppType ? { app_type: legacyAppType } : {}),
|
|
566
|
+
...(appSpec ? { app_id: appSpec.app_id ?? instanceId } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
// Auto-start if default provider is configured (model ready to use).
|
|
570
|
+
// Non-openclaw runtimes always auto-start — Hermes/Ollama/custom manage
|
|
571
|
+
// their own provider plumbing (or don't need one), so skipping the
|
|
572
|
+
// auto-start would make the first run feel broken.
|
|
146
573
|
const { getPanelConfig } = await import("../config.js");
|
|
147
574
|
const dp = getPanelConfig().default_provider;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
575
|
+
const shouldAutoStart = requestedKind !== "openclaw" || (dp && dp.providerId && !dp.skipped);
|
|
576
|
+
if (shouldAutoStart) {
|
|
577
|
+
void (async () => {
|
|
578
|
+
const result = createFromAppSpec
|
|
579
|
+
? await instanceManager.startApp(instanceId)
|
|
580
|
+
: await (await getSvc()).startInstance(instanceId);
|
|
581
|
+
if (!result.ok) {
|
|
582
|
+
console.warn(`[instances] Auto-start ${instanceId} failed: ${result.error ?? "unknown error"}`);
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
153
585
|
meta.autoStarted = true;
|
|
154
586
|
}
|
|
155
|
-
|
|
587
|
+
const decorated = await augmentInstanceMetadata(instanceId, meta);
|
|
588
|
+
if (meta.autoStarted)
|
|
589
|
+
decorated.autoStarted = true;
|
|
590
|
+
return decorated;
|
|
156
591
|
}
|
|
157
592
|
catch (e) {
|
|
593
|
+
// Structured rejection from createHermesInstance — return 409 with code
|
|
594
|
+
if (e && e.name === "InstanceCreationRejected") {
|
|
595
|
+
return reply.status(409).send({
|
|
596
|
+
detail: e.hint,
|
|
597
|
+
code: e.code,
|
|
598
|
+
requestedKind: e.requestedKind,
|
|
599
|
+
currentServiceManager: e.currentServiceManager,
|
|
600
|
+
currentNomadDriver: e.currentNomadDriver,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
158
603
|
return reply.status(409).send({ detail: e.message });
|
|
159
604
|
}
|
|
160
605
|
});
|
|
@@ -167,8 +612,12 @@ export async function instanceRoutes(app) {
|
|
|
167
612
|
const inst = instanceManager.getInstance(req.params.id);
|
|
168
613
|
if (!inst)
|
|
169
614
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
170
|
-
const status = await svc
|
|
171
|
-
return {
|
|
615
|
+
const status = await getCachedStatus(svc, req.params.id);
|
|
616
|
+
return {
|
|
617
|
+
...(await augmentInstanceMetadata(req.params.id, inst)),
|
|
618
|
+
service: status,
|
|
619
|
+
llmError: llmProxy.getLastProxyError(req.params.id) ?? null,
|
|
620
|
+
};
|
|
172
621
|
});
|
|
173
622
|
// Update
|
|
174
623
|
app.put("/api/instances/:id", async (req, reply) => {
|
|
@@ -203,6 +652,15 @@ export async function instanceRoutes(app) {
|
|
|
203
652
|
catch (e) {
|
|
204
653
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
205
654
|
}
|
|
655
|
+
if (getInstanceBackedInstalledApp(req.params.id)) {
|
|
656
|
+
const sudoPassword = typeof req.body?.sudoPassword === "string" && req.body.sudoPassword.trim()
|
|
657
|
+
? req.body.sudoPassword
|
|
658
|
+
: undefined;
|
|
659
|
+
await instanceManager.uninstallApp(req.params.id, sudoPassword ? { exec: { sudoPassword } } : {});
|
|
660
|
+
statusCache.delete(req.params.id);
|
|
661
|
+
llmProxy.cleanupInstance(req.params.id);
|
|
662
|
+
return { ok: true };
|
|
663
|
+
}
|
|
206
664
|
const svc = await getSvc();
|
|
207
665
|
let stopFailed = false;
|
|
208
666
|
try {
|
|
@@ -240,10 +698,39 @@ export async function instanceRoutes(app) {
|
|
|
240
698
|
return { ok: result.ok, warnings: warnings.length ? warnings : undefined };
|
|
241
699
|
});
|
|
242
700
|
// Config
|
|
701
|
+
app.get("/api/instances/:id/config-meta", async (req, reply) => {
|
|
702
|
+
const idErr = validateId(req.params.id);
|
|
703
|
+
if (idErr)
|
|
704
|
+
return reply.status(400).send({ detail: idErr });
|
|
705
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
706
|
+
if (!inst)
|
|
707
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
708
|
+
return getInstanceConfigMeta(req.params.id, inst);
|
|
709
|
+
});
|
|
243
710
|
app.get("/api/instances/:id/config", async (req, reply) => {
|
|
244
711
|
const idErr = validateId(req.params.id);
|
|
245
712
|
if (idErr)
|
|
246
713
|
return reply.status(400).send({ detail: idErr });
|
|
714
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
715
|
+
if (!inst)
|
|
716
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
717
|
+
const agentType = resolveAgentType(inst);
|
|
718
|
+
if (hasAdapter(agentType)) {
|
|
719
|
+
try {
|
|
720
|
+
const doc = await getAdapter(agentType).readConfig(req.params.id);
|
|
721
|
+
// Back-compat: frontend ConfigForm treats JSON-format responses as
|
|
722
|
+
// the raw config object (no {format, content} wrapper). Unwrap here
|
|
723
|
+
// so the existing OpenClaw editor keeps working without touching
|
|
724
|
+
// every consumer.
|
|
725
|
+
return doc.format === "json" ? doc.content : doc;
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
if (/not found/i.test(e?.message || "")) {
|
|
729
|
+
return reply.status(404).send({ detail: e.message });
|
|
730
|
+
}
|
|
731
|
+
throw e;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
247
734
|
const config = llmProxy.getInstanceConfig(req.params.id);
|
|
248
735
|
if (!config)
|
|
249
736
|
return reply.status(404).send({ detail: "Instance or config not found" });
|
|
@@ -253,17 +740,39 @@ export async function instanceRoutes(app) {
|
|
|
253
740
|
const idErr = validateId(req.params.id);
|
|
254
741
|
if (idErr)
|
|
255
742
|
return reply.status(400).send({ detail: idErr });
|
|
743
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
744
|
+
if (!inst)
|
|
745
|
+
return reply.status(404).send({ detail: "Instance not found" });
|
|
746
|
+
const agentType = resolveAgentType(inst);
|
|
256
747
|
// Basic payload validation
|
|
257
748
|
const body = req.body;
|
|
258
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
259
|
-
return reply.status(400).send({ detail: "Config must be a JSON object" });
|
|
260
|
-
}
|
|
261
749
|
const bodyStr = JSON.stringify(body);
|
|
262
750
|
if (bodyStr.length > 512 * 1024) {
|
|
263
751
|
return reply.status(400).send({ detail: "Config too large (max 512KB)" });
|
|
264
752
|
}
|
|
265
753
|
try {
|
|
266
754
|
assertNotLocked(req.params.id);
|
|
755
|
+
if (hasAdapter(agentType)) {
|
|
756
|
+
// Accept either a typed ConfigDocument (`{format, content|yaml+env}`)
|
|
757
|
+
// or a raw JSON object (legacy OpenClaw shape) — the route wraps
|
|
758
|
+
// the raw shape so adapter.writeConfig always sees the typed
|
|
759
|
+
// contract. Response is unwrapped symmetrically with GET.
|
|
760
|
+
let doc;
|
|
761
|
+
if (isConfigDocument(body)) {
|
|
762
|
+
doc = body;
|
|
763
|
+
}
|
|
764
|
+
else if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
765
|
+
doc = { format: "json", content: body };
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
return reply.status(400).send({ detail: "Config must be a JSON object" });
|
|
769
|
+
}
|
|
770
|
+
const saved = await getAdapter(agentType).writeConfig(req.params.id, doc);
|
|
771
|
+
return { ok: true, config: saved.format === "json" ? saved.content : saved };
|
|
772
|
+
}
|
|
773
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
774
|
+
return reply.status(400).send({ detail: "Config must be a JSON object" });
|
|
775
|
+
}
|
|
267
776
|
const saved = await llmProxy.saveInstanceConfig(req.params.id, body);
|
|
268
777
|
return { ok: true, config: saved };
|
|
269
778
|
}
|
|
@@ -280,9 +789,12 @@ export async function instanceRoutes(app) {
|
|
|
280
789
|
if (idErr)
|
|
281
790
|
return reply.status(400).send({ detail: idErr });
|
|
282
791
|
const svc = await getSvc();
|
|
283
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
792
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
284
793
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
285
794
|
}
|
|
795
|
+
if (getInstanceBackedInstalledApp(req.params.id)) {
|
|
796
|
+
return instanceManager.getAppStatus(req.params.id);
|
|
797
|
+
}
|
|
286
798
|
return svc.getStatus(req.params.id);
|
|
287
799
|
});
|
|
288
800
|
app.post("/api/instances/:id/service/start", async (req, reply) => {
|
|
@@ -296,13 +808,28 @@ export async function instanceRoutes(app) {
|
|
|
296
808
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
297
809
|
}
|
|
298
810
|
const svc = await getSvc();
|
|
299
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
811
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
300
812
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
301
813
|
}
|
|
302
|
-
const result =
|
|
814
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
815
|
+
? await instanceManager.startApp(req.params.id)
|
|
816
|
+
: await svc.startInstance(req.params.id);
|
|
303
817
|
statusCache.delete(req.params.id);
|
|
304
|
-
if (!result.ok)
|
|
305
|
-
|
|
818
|
+
if (!result.ok) {
|
|
819
|
+
const resultRecord = result;
|
|
820
|
+
// Surface the phase tag so the UI can highlight where the start
|
|
821
|
+
// pipeline failed (running_check / home_conflict / port_alloc /
|
|
822
|
+
// pre_start_hook / submit). Legacy clients that only read `detail`
|
|
823
|
+
// keep working.
|
|
824
|
+
const payload = { detail: result.error };
|
|
825
|
+
if (resultRecord.phase)
|
|
826
|
+
payload.phase = resultRecord.phase;
|
|
827
|
+
if (resultRecord.building)
|
|
828
|
+
payload.building = true;
|
|
829
|
+
if (resultRecord.taskId)
|
|
830
|
+
payload.taskId = resultRecord.taskId;
|
|
831
|
+
return reply.status(400).send(payload);
|
|
832
|
+
}
|
|
306
833
|
return result;
|
|
307
834
|
});
|
|
308
835
|
app.post("/api/instances/:id/service/stop", async (req, reply) => {
|
|
@@ -316,10 +843,12 @@ export async function instanceRoutes(app) {
|
|
|
316
843
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
317
844
|
}
|
|
318
845
|
const svc = await getSvc();
|
|
319
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
846
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
320
847
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
321
848
|
}
|
|
322
|
-
const result =
|
|
849
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
850
|
+
? await instanceManager.stopApp(req.params.id)
|
|
851
|
+
: await svc.stopInstance(req.params.id);
|
|
323
852
|
statusCache.delete(req.params.id);
|
|
324
853
|
if (!result.ok)
|
|
325
854
|
return reply.status(400).send({ detail: result.error });
|
|
@@ -336,370 +865,17 @@ export async function instanceRoutes(app) {
|
|
|
336
865
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
337
866
|
}
|
|
338
867
|
const svc = await getSvc();
|
|
339
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
868
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
340
869
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
341
870
|
}
|
|
342
|
-
const result =
|
|
871
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
872
|
+
? await instanceManager.restartApp(req.params.id)
|
|
873
|
+
: await svc.restartInstance(req.params.id);
|
|
343
874
|
statusCache.delete(req.params.id);
|
|
344
875
|
if (!result.ok)
|
|
345
876
|
return reply.status(400).send({ detail: result.error || "Unknown error" });
|
|
346
877
|
return result;
|
|
347
878
|
});
|
|
348
|
-
// Plugin check & install — host-side, no container dependency
|
|
349
|
-
app.get("/api/instances/:id/plugins/check/:channelId", async (req, reply) => {
|
|
350
|
-
const idErr = validateId(req.params.id);
|
|
351
|
-
if (idErr)
|
|
352
|
-
return reply.status(400).send({ detail: idErr });
|
|
353
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
354
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
355
|
-
}
|
|
356
|
-
return {
|
|
357
|
-
channelId: req.params.channelId,
|
|
358
|
-
installed: instanceManager.isChannelPluginInstalled(req.params.id, req.params.channelId),
|
|
359
|
-
};
|
|
360
|
-
});
|
|
361
|
-
// Plugin status for all tracked IM plugins (feishu, openclaw-weixin)
|
|
362
|
-
app.get("/api/instances/:id/plugins/status", async (req, reply) => {
|
|
363
|
-
const idErr = validateId(req.params.id);
|
|
364
|
-
if (idErr)
|
|
365
|
-
return reply.status(400).send({ detail: idErr });
|
|
366
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
367
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
368
|
-
}
|
|
369
|
-
return { plugins: pluginInstaller.getAllPluginStatuses(req.params.id) };
|
|
370
|
-
});
|
|
371
|
-
// Quick status: IM binding + skill install state for the quick-skill panel
|
|
372
|
-
app.get("/api/instances/:id/quick-status", async (req, reply) => {
|
|
373
|
-
const idErr = validateId(req.params.id);
|
|
374
|
-
if (idErr)
|
|
375
|
-
return reply.status(400).send({ detail: idErr });
|
|
376
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
377
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
378
|
-
}
|
|
379
|
-
const id = req.params.id;
|
|
380
|
-
const cfg = instanceManager.getConfig(id) ?? {};
|
|
381
|
-
const channels = cfg.channels ?? {};
|
|
382
|
-
// IM binding: channel must be enabled AND have a non-empty token / accounts
|
|
383
|
-
const feishuCh = channels["feishu"] ?? channels["lark"] ?? {};
|
|
384
|
-
const weixinCh = channels["openclaw-weixin"] ?? {};
|
|
385
|
-
const feishuBound = !!(feishuCh.enabled && (feishuCh.appId || feishuCh.token || feishuCh.deviceToken || feishuCh.accessToken));
|
|
386
|
-
const weixinBound = !!(weixinCh.enabled && weixinCh.accounts && Object.keys(weixinCh.accounts).length > 0);
|
|
387
|
-
// Skill install state: scan workspace/skills/ and return all directory names
|
|
388
|
-
const { readdirSync: fsReaddir, existsSync: fsExists, readFileSync: fsRead } = await import("fs");
|
|
389
|
-
const { join: fsJoin } = await import("path");
|
|
390
|
-
const workspaceDir = fsJoin(instanceManager.getOpenclawHome(id), ".openclaw", "workspace");
|
|
391
|
-
const stateDir = fsJoin(workspaceDir, "skills");
|
|
392
|
-
let installedSkillDirs = [];
|
|
393
|
-
try {
|
|
394
|
-
installedSkillDirs = fsReaddir(stateDir, { withFileTypes: true })
|
|
395
|
-
.filter(e => e.isDirectory())
|
|
396
|
-
.map(e => e.name);
|
|
397
|
-
}
|
|
398
|
-
catch { }
|
|
399
|
-
// MCPorter install state — mcporter is installed as a skill in workspace/skills/mcporter
|
|
400
|
-
const mcporterInstalled = installedSkillDirs.some(d => d.toLowerCase() === 'mcporter');
|
|
401
|
-
// MCPorter configured servers
|
|
402
|
-
let mcporterServers = {};
|
|
403
|
-
const mcporterCfgPath = fsJoin(workspaceDir, "config", "mcporter.json");
|
|
404
|
-
try {
|
|
405
|
-
const raw = JSON.parse(fsRead(mcporterCfgPath, "utf8"));
|
|
406
|
-
mcporterServers = raw.mcpServers ?? {};
|
|
407
|
-
}
|
|
408
|
-
catch { }
|
|
409
|
-
return {
|
|
410
|
-
im: {
|
|
411
|
-
feishu: feishuBound,
|
|
412
|
-
weixin: weixinBound,
|
|
413
|
-
},
|
|
414
|
-
installedSkillDirs,
|
|
415
|
-
mcporterInstalled,
|
|
416
|
-
mcporterServers,
|
|
417
|
-
};
|
|
418
|
-
});
|
|
419
|
-
// Run `mcporter list --json` and return live server status
|
|
420
|
-
app.get("/api/instances/:id/mcporter/list", async (req, reply) => {
|
|
421
|
-
const idErr = validateId(req.params.id);
|
|
422
|
-
if (idErr)
|
|
423
|
-
return reply.status(400).send({ detail: idErr });
|
|
424
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
425
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
426
|
-
}
|
|
427
|
-
const id = req.params.id;
|
|
428
|
-
const openclawHome = instanceManager.getOpenclawHome(id);
|
|
429
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
430
|
-
const mcporterBinPath = join(workspaceDir, ".npm-global", "bin", "mcporter");
|
|
431
|
-
const mcporterCfg = join(workspaceDir, "config", "mcporter.json");
|
|
432
|
-
if (!existsSync(mcporterBinPath)) {
|
|
433
|
-
return { servers: [], installed: false };
|
|
434
|
-
}
|
|
435
|
-
const { execFile } = await import("child_process");
|
|
436
|
-
const { promisify } = await import("util");
|
|
437
|
-
const execFileAsync = promisify(execFile);
|
|
438
|
-
try {
|
|
439
|
-
const { stdout } = await execFileAsync(mcporterBinPath, ["list", "--json"], {
|
|
440
|
-
env: { ...process.env, HOME: openclawHome, MCPORTER_CONFIG: mcporterCfg },
|
|
441
|
-
timeout: 60_000,
|
|
442
|
-
});
|
|
443
|
-
const parsed = JSON.parse(stdout);
|
|
444
|
-
return { servers: parsed.servers ?? [], installed: true };
|
|
445
|
-
}
|
|
446
|
-
catch (err) {
|
|
447
|
-
// execFile throws if exit code != 0; stdout may still have partial JSON
|
|
448
|
-
const raw = err?.stdout ?? "";
|
|
449
|
-
try {
|
|
450
|
-
const parsed = JSON.parse(raw);
|
|
451
|
-
return { servers: parsed.servers ?? [], installed: true };
|
|
452
|
-
}
|
|
453
|
-
catch { }
|
|
454
|
-
return { servers: [], installed: true, error: err?.message ?? "unknown" };
|
|
455
|
-
}
|
|
456
|
-
});
|
|
457
|
-
// Merge servers into mcporter.json
|
|
458
|
-
app.post("/api/instances/:id/mcporter/add", async (req, reply) => {
|
|
459
|
-
const idErr = validateId(req.params.id);
|
|
460
|
-
if (idErr)
|
|
461
|
-
return reply.status(400).send({ detail: idErr });
|
|
462
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
463
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
464
|
-
}
|
|
465
|
-
const { servers } = req.body;
|
|
466
|
-
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
467
|
-
return reply.status(400).send({ detail: "servers must be an object" });
|
|
468
|
-
}
|
|
469
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
470
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
471
|
-
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
472
|
-
const { readFileSync } = await import("fs");
|
|
473
|
-
let cfg = { mcpServers: {}, imports: [] };
|
|
474
|
-
try {
|
|
475
|
-
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
476
|
-
}
|
|
477
|
-
catch { }
|
|
478
|
-
if (!cfg.mcpServers)
|
|
479
|
-
cfg.mcpServers = {};
|
|
480
|
-
// Explicit key-by-key copy instead of Object.assign to prevent prototype pollution:
|
|
481
|
-
// a crafted body with "__proto__" or "constructor" keys could corrupt the object prototype.
|
|
482
|
-
const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
483
|
-
for (const [k, v] of Object.entries(servers)) {
|
|
484
|
-
if (!PROTO_KEYS.has(k))
|
|
485
|
-
cfg.mcpServers[k] = v;
|
|
486
|
-
}
|
|
487
|
-
try {
|
|
488
|
-
ensureDirContainer(join(workspaceDir, "config"));
|
|
489
|
-
writeConfigFile(mcporterCfgPath, JSON.stringify(cfg, null, 2));
|
|
490
|
-
}
|
|
491
|
-
catch (err) {
|
|
492
|
-
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
493
|
-
}
|
|
494
|
-
return { ok: true, mcpServers: cfg.mcpServers };
|
|
495
|
-
});
|
|
496
|
-
// Remove a server from mcporter.json
|
|
497
|
-
app.delete("/api/instances/:id/mcporter/:serverName", async (req, reply) => {
|
|
498
|
-
const idErr = validateId(req.params.id);
|
|
499
|
-
if (idErr)
|
|
500
|
-
return reply.status(400).send({ detail: idErr });
|
|
501
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
502
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
503
|
-
}
|
|
504
|
-
const { serverName } = req.params;
|
|
505
|
-
if (!serverName || typeof serverName !== "string") {
|
|
506
|
-
return reply.status(400).send({ detail: "serverName is required" });
|
|
507
|
-
}
|
|
508
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
509
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
510
|
-
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
511
|
-
const { readFileSync } = await import("fs");
|
|
512
|
-
let cfg = { mcpServers: {}, imports: [] };
|
|
513
|
-
try {
|
|
514
|
-
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
515
|
-
}
|
|
516
|
-
catch { }
|
|
517
|
-
if (!cfg.mcpServers || !(serverName in cfg.mcpServers)) {
|
|
518
|
-
return reply.status(404).send({ detail: `Server '${serverName}' not found` });
|
|
519
|
-
}
|
|
520
|
-
delete cfg.mcpServers[serverName];
|
|
521
|
-
try {
|
|
522
|
-
writeConfigFile(mcporterCfgPath, JSON.stringify(cfg, null, 2));
|
|
523
|
-
}
|
|
524
|
-
catch (err) {
|
|
525
|
-
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
526
|
-
}
|
|
527
|
-
return { ok: true, mcpServers: cfg.mcpServers };
|
|
528
|
-
});
|
|
529
|
-
// Delete a skill directory from workspace/skills/
|
|
530
|
-
app.delete("/api/instances/:id/skills/:skillDir", async (req, reply) => {
|
|
531
|
-
const idErr = validateId(req.params.id);
|
|
532
|
-
if (idErr)
|
|
533
|
-
return reply.status(400).send({ detail: idErr });
|
|
534
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
535
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
536
|
-
}
|
|
537
|
-
const { skillDir } = req.params;
|
|
538
|
-
// Prevent path traversal
|
|
539
|
-
if (!skillDir || skillDir.includes("/") || skillDir.includes("..") || skillDir.startsWith(".")) {
|
|
540
|
-
return reply.status(400).send({ detail: "Invalid skill directory name" });
|
|
541
|
-
}
|
|
542
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
543
|
-
const skillPath = join(openclawHome, ".openclaw", "workspace", "skills", skillDir);
|
|
544
|
-
const { existsSync: fsEx, rmSync } = await import("fs");
|
|
545
|
-
if (!fsEx(skillPath)) {
|
|
546
|
-
return reply.status(404).send({ detail: `Skill '${skillDir}' not found` });
|
|
547
|
-
}
|
|
548
|
-
try {
|
|
549
|
-
rmSync(skillPath, { recursive: true, force: true });
|
|
550
|
-
}
|
|
551
|
-
catch (err) {
|
|
552
|
-
return reply.status(500).send({ detail: `Delete failed: ${err.message}` });
|
|
553
|
-
}
|
|
554
|
-
return { ok: true };
|
|
555
|
-
});
|
|
556
|
-
app.post("/api/instances/:id/plugins/install", async (req, reply) => {
|
|
557
|
-
const idErr = validateId(req.params.id);
|
|
558
|
-
if (idErr)
|
|
559
|
-
return reply.status(400).send({ detail: idErr });
|
|
560
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
561
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
562
|
-
}
|
|
563
|
-
try {
|
|
564
|
-
assertNotLocked(req.params.id);
|
|
565
|
-
}
|
|
566
|
-
catch (e) {
|
|
567
|
-
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
568
|
-
}
|
|
569
|
-
const { channelId } = req.body;
|
|
570
|
-
if (!channelId || typeof channelId !== "string") {
|
|
571
|
-
return reply.status(400).send({ detail: "channelId is required" });
|
|
572
|
-
}
|
|
573
|
-
const pkg = instanceManager.CHANNEL_PLUGIN_MAP[channelId];
|
|
574
|
-
if (!pkg) {
|
|
575
|
-
return reply.status(400).send({ detail: `Unknown channel: ${channelId}` });
|
|
576
|
-
}
|
|
577
|
-
const pStatus = pluginInstaller.getPluginStatus(req.params.id, channelId);
|
|
578
|
-
if (pStatus.status === "installed")
|
|
579
|
-
return { ok: true, status: "already_installed" };
|
|
580
|
-
if (pStatus.status === "installing")
|
|
581
|
-
return { ok: true, status: "installing" };
|
|
582
|
-
pluginInstaller.enqueueInstall(req.params.id, channelId);
|
|
583
|
-
return { ok: true, status: "queued" };
|
|
584
|
-
});
|
|
585
|
-
// ── Helper: ensure a channel plugin is installed (check-only) ──
|
|
586
|
-
async function ensurePluginInstalled(instanceId, channelId) {
|
|
587
|
-
if (!instanceManager.CHANNEL_PLUGIN_MAP[channelId])
|
|
588
|
-
return;
|
|
589
|
-
if (!instanceManager.isChannelPluginInstalled(instanceId, channelId)) {
|
|
590
|
-
throw new Error(`Plugin ${channelId} is not installed. Please install it from the config page.`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
// ── Feishu/Lark OAuth Device Code Login ──
|
|
594
|
-
const FEISHU_AUTH_URL = "https://accounts.feishu.cn";
|
|
595
|
-
const MAX_LOGIN_SESSIONS = 100;
|
|
596
|
-
const feishuLogins = new Map();
|
|
597
|
-
app.post("/api/instances/:id/feishu/login", async (req, reply) => {
|
|
598
|
-
const channelKey = req.body?.channelKey || "feishu";
|
|
599
|
-
const idErr = validateId(req.params.id);
|
|
600
|
-
if (idErr)
|
|
601
|
-
return reply.status(400).send({ detail: idErr });
|
|
602
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
603
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
604
|
-
}
|
|
605
|
-
// Require instance to be running
|
|
606
|
-
const svc = await getSvc();
|
|
607
|
-
const svcStatus = await svc.getStatus(req.params.id);
|
|
608
|
-
if (svcStatus.status !== "running") {
|
|
609
|
-
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
610
|
-
}
|
|
611
|
-
// Auto-install feishu plugin if not present
|
|
612
|
-
await ensurePluginInstalled(req.params.id, channelKey);
|
|
613
|
-
try {
|
|
614
|
-
// Step 1: init
|
|
615
|
-
await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
616
|
-
method: "POST",
|
|
617
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
618
|
-
body: "action=init",
|
|
619
|
-
signal: AbortSignal.timeout(30_000),
|
|
620
|
-
});
|
|
621
|
-
// Step 2: begin — get QR code URL and device code
|
|
622
|
-
const beginResp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
623
|
-
method: "POST",
|
|
624
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
625
|
-
body: "action=begin&archetype=PersonalAgent&auth_method=client_secret&request_user_info=open_id",
|
|
626
|
-
signal: AbortSignal.timeout(30_000),
|
|
627
|
-
});
|
|
628
|
-
if (!beginResp.ok)
|
|
629
|
-
throw new Error(`Feishu API error: ${beginResp.status}`);
|
|
630
|
-
const beginData = await beginResp.json();
|
|
631
|
-
const sessionKey = `${req.params.id}-${channelKey}-${Date.now()}`;
|
|
632
|
-
feishuLogins.set(sessionKey, {
|
|
633
|
-
instanceId: req.params.id,
|
|
634
|
-
deviceCode: beginData.device_code,
|
|
635
|
-
startedAt: Date.now(),
|
|
636
|
-
interval: beginData.interval || 5,
|
|
637
|
-
expireIn: beginData.expire_in || 600,
|
|
638
|
-
channelKey,
|
|
639
|
-
});
|
|
640
|
-
// Purge expired + enforce cap
|
|
641
|
-
for (const [k, v] of feishuLogins) {
|
|
642
|
-
if (Date.now() - v.startedAt > v.expireIn * 1000)
|
|
643
|
-
feishuLogins.delete(k);
|
|
644
|
-
}
|
|
645
|
-
while (feishuLogins.size > MAX_LOGIN_SESSIONS) {
|
|
646
|
-
feishuLogins.delete(feishuLogins.keys().next().value);
|
|
647
|
-
}
|
|
648
|
-
return { qrcodeUrl: beginData.verification_uri_complete, sessionKey };
|
|
649
|
-
}
|
|
650
|
-
catch (e) {
|
|
651
|
-
return reply.status(502).send({ detail: e.message || "Failed to start Feishu login" });
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
app.get("/api/instances/:id/feishu/login/:sessionKey", async (req, reply) => {
|
|
655
|
-
const idErr = validateId(req.params.id);
|
|
656
|
-
if (idErr)
|
|
657
|
-
return reply.status(400).send({ detail: idErr });
|
|
658
|
-
const login = feishuLogins.get(req.params.sessionKey);
|
|
659
|
-
if (!login)
|
|
660
|
-
return reply.status(404).send({ detail: "Login session not found or expired" });
|
|
661
|
-
if (login.instanceId !== req.params.id)
|
|
662
|
-
return reply.status(403).send({ detail: "Session belongs to a different instance" });
|
|
663
|
-
if (Date.now() - login.startedAt > login.expireIn * 1000) {
|
|
664
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
665
|
-
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
const resp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
669
|
-
method: "POST",
|
|
670
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
671
|
-
body: `action=poll&device_code=${encodeURIComponent(login.deviceCode)}`,
|
|
672
|
-
signal: AbortSignal.timeout(10_000),
|
|
673
|
-
});
|
|
674
|
-
const data = await resp.json();
|
|
675
|
-
if (data.client_id && data.client_secret) {
|
|
676
|
-
const storedChannelKey = login.channelKey || "feishu";
|
|
677
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
678
|
-
const domain = data.user_info?.tenant_brand === "lark" ? "lark" : "feishu";
|
|
679
|
-
instanceManager.saveFeishuCredentials(req.params.id, {
|
|
680
|
-
appId: data.client_id,
|
|
681
|
-
appSecret: data.client_secret,
|
|
682
|
-
domain,
|
|
683
|
-
channelKey: storedChannelKey,
|
|
684
|
-
});
|
|
685
|
-
return {
|
|
686
|
-
status: "confirmed", connected: true, domain,
|
|
687
|
-
message: domain === "lark" ? "Lark bot configured!" : "Feishu bot configured!",
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
if (data.error === "authorization_pending") {
|
|
691
|
-
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
692
|
-
}
|
|
693
|
-
if (data.error === "expired_token") {
|
|
694
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
695
|
-
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
696
|
-
}
|
|
697
|
-
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
698
|
-
}
|
|
699
|
-
catch (e) {
|
|
700
|
-
return reply.status(502).send({ detail: e.message || "Poll failed" });
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
879
|
// ── Pairing ──────────────────────────────────────────────────────────────
|
|
704
880
|
// Pairing codes are uppercase alphanumeric, 4-16 chars (e.g. LVU7PNYK).
|
|
705
881
|
const PAIRING_CODE_RE = /^[A-Z0-9]{4,16}$/;
|
|
@@ -709,20 +885,33 @@ export async function instanceRoutes(app) {
|
|
|
709
885
|
const idErr = validateId(req.params.id);
|
|
710
886
|
if (idErr)
|
|
711
887
|
return reply.status(400).send({ detail: idErr });
|
|
712
|
-
|
|
888
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
889
|
+
if (!inst) {
|
|
713
890
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
714
891
|
}
|
|
892
|
+
const capabilities = await getInstanceCapabilities(req.params.id, inst);
|
|
893
|
+
if (!capabilities.pairing.list) {
|
|
894
|
+
return reply.status(501).send({ detail: "Pairing list is not supported for this runtime" });
|
|
895
|
+
}
|
|
896
|
+
const agentType = resolveAgentType(inst);
|
|
715
897
|
const svc = await getSvc();
|
|
716
|
-
|
|
898
|
+
// Pure adapter dispatch — no hardcoded kind fallback.
|
|
899
|
+
const cmd = await getAdapter(agentType).buildPairingListCommand(req.params.id);
|
|
900
|
+
const result = await svc.exec(req.params.id, cmd, 15_000);
|
|
717
901
|
return { output: result.stdout + result.stderr, exitCode: result.exitCode };
|
|
718
902
|
});
|
|
719
903
|
app.post("/api/instances/:id/pairing/approve", async (req, reply) => {
|
|
720
904
|
const idErr = validateId(req.params.id);
|
|
721
905
|
if (idErr)
|
|
722
906
|
return reply.status(400).send({ detail: idErr });
|
|
723
|
-
|
|
907
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
908
|
+
if (!inst) {
|
|
724
909
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
725
910
|
}
|
|
911
|
+
const capabilities = await getInstanceCapabilities(req.params.id, inst);
|
|
912
|
+
if (!capabilities.pairing.approve) {
|
|
913
|
+
return reply.status(501).send({ detail: "Pairing approve is not supported for this runtime" });
|
|
914
|
+
}
|
|
726
915
|
const { channel, code, notify } = req.body ?? {};
|
|
727
916
|
if (!channel || !PAIRING_CHANNEL_RE.test(channel)) {
|
|
728
917
|
return reply.status(400).send({ detail: "Invalid channel: must be lowercase alphanumeric/hyphen/underscore" });
|
|
@@ -730,9 +919,12 @@ export async function instanceRoutes(app) {
|
|
|
730
919
|
if (!code || !PAIRING_CODE_RE.test(code)) {
|
|
731
920
|
return reply.status(400).send({ detail: "Invalid pairing code: must be 4-16 uppercase alphanumeric characters" });
|
|
732
921
|
}
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
922
|
+
const agentType = resolveAgentType(inst);
|
|
923
|
+
const cmd = await getAdapter(agentType).buildPairingApproveCommand(req.params.id, {
|
|
924
|
+
channel,
|
|
925
|
+
code,
|
|
926
|
+
notify,
|
|
927
|
+
});
|
|
736
928
|
const svc = await getSvc();
|
|
737
929
|
const result = await svc.exec(req.params.id, cmd, 15_000);
|
|
738
930
|
if (result.exitCode !== 0) {
|
|
@@ -740,251 +932,259 @@ export async function instanceRoutes(app) {
|
|
|
740
932
|
}
|
|
741
933
|
return { ok: true, output: (result.stdout + result.stderr).trim() };
|
|
742
934
|
});
|
|
743
|
-
//
|
|
744
|
-
|
|
935
|
+
// Agent chat (inline chat panel for runtimes that expose an OpenAI-compat
|
|
936
|
+
// HTTP chat completion endpoint and declare chatPanel="inline" in their
|
|
937
|
+
// capability profile — currently only Hermes).
|
|
938
|
+
//
|
|
939
|
+
// Flow: panel JWT auth → read per-instance API_SERVER_KEY from agent-home/.env →
|
|
940
|
+
// read allocated host port from runtime.ports → POST forward to
|
|
941
|
+
// http://127.0.0.1:<port>/v1/chat/completions.
|
|
942
|
+
//
|
|
943
|
+
// The response is framed as Server-Sent Events with periodic `: ping`
|
|
944
|
+
// heartbeats while we wait for the agent to finish. Long-running agent
|
|
945
|
+
// tasks (tool loops, thinking, cold starts) can legitimately run far
|
|
946
|
+
// longer than a browser's default fetch-idle tolerance; the heartbeat
|
|
947
|
+
// keeps the connection visibly alive so the client can tell "still
|
|
948
|
+
// working" apart from "network dead". Once the agent responds we emit
|
|
949
|
+
// a single `result` event carrying the upstream JSON, then `done`.
|
|
950
|
+
// Errors before headers are sent fall back to HTTP 5xx JSON; errors
|
|
951
|
+
// after hijack go out as an `event: error` SSE payload.
|
|
952
|
+
//
|
|
953
|
+
// This is a thin server-side forwarder, NOT a new LLM proxy. The actual
|
|
954
|
+
// LLM call still goes through Hermes → jsproxy → JishuShell /proxy/v1 →
|
|
955
|
+
// upstream provider.
|
|
956
|
+
app.post("/api/instances/:id/agent/chat", async (req, reply) => {
|
|
745
957
|
const idErr = validateId(req.params.id);
|
|
746
958
|
if (idErr)
|
|
747
959
|
return reply.status(400).send({ detail: idErr });
|
|
748
|
-
|
|
960
|
+
const rawInst = instanceManager.getInstance(req.params.id);
|
|
961
|
+
if (!rawInst)
|
|
749
962
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
963
|
+
// getInstance returns raw instance.json without `capabilities` (that's
|
|
964
|
+
// runtime-synthesized per §32.4). Use augmentInstanceMetadata so we
|
|
965
|
+
// read the adapter's live defaultCapabilities.
|
|
966
|
+
const inst = await augmentInstanceMetadata(req.params.id, rawInst);
|
|
967
|
+
// Only runtimes declaring chatPanel="inline" are supported here
|
|
968
|
+
const chatPanel = inst?.capabilities?.gateway?.chatPanel;
|
|
969
|
+
if (chatPanel !== "inline") {
|
|
970
|
+
return reply.status(400).send({
|
|
971
|
+
detail: `Runtime "${inst.agentType}" does not support inline chat (chatPanel=${chatPanel})`,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
// Adapter-owned dispatch: the route no longer hardcodes Hermes's env
|
|
975
|
+
// var name or endpoint path. Any adapter that declares chatPanel
|
|
976
|
+
// "inline" MUST also supply `inlineChatDescriptor` (api key env var +
|
|
977
|
+
// optional path/header/timeout) so this forwarder can reach its agent.
|
|
978
|
+
const agentType = resolveAgentType(inst);
|
|
979
|
+
const adapter = getAdapter(agentType);
|
|
980
|
+
const desc = adapter.inlineChatDescriptor;
|
|
981
|
+
if (!desc) {
|
|
982
|
+
return reply.status(500).send({
|
|
983
|
+
detail: `Runtime "${agentType}" declares chatPanel=inline but no inlineChatDescriptor`,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
// Resolve host port from the persisted RuntimeSpec.ports[] allocation.
|
|
987
|
+
const ports = Array.isArray(inst?.runtime?.ports) ? inst.runtime.ports : [];
|
|
988
|
+
const gw = ports.find((p) => p?.name === "gateway") || ports[0];
|
|
989
|
+
const hostPort = Number(gw?.hostPort) || 0;
|
|
990
|
+
if (!hostPort) {
|
|
991
|
+
return reply.status(500).send({ detail: "Gateway host port not allocated" });
|
|
992
|
+
}
|
|
993
|
+
// Agent API key lives in the adapter-managed secretEnv file. Adapter
|
|
994
|
+
// declares which env var holds it (Hermes → API_SERVER_KEY).
|
|
995
|
+
const secretEnv = inst?.paths?.secretEnv;
|
|
996
|
+
if (!secretEnv) {
|
|
997
|
+
return reply.status(500).send({ detail: "Instance has no secretEnv path" });
|
|
998
|
+
}
|
|
999
|
+
const envVars = instanceManager.parseEnvFile(secretEnv);
|
|
1000
|
+
const apiKey = envVars[desc.apiKeyEnvVar] || "";
|
|
1001
|
+
if (!apiKey) {
|
|
1002
|
+
return reply.status(500).send({
|
|
1003
|
+
detail: `${desc.apiKeyEnvVar} not set in instance env; agent may not be configured`,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
const endpointPath = desc.endpointPath ?? "/v1/chat/completions";
|
|
1007
|
+
const authHeader = desc.authHeader ?? "Authorization";
|
|
1008
|
+
const authScheme = desc.authScheme ?? "Bearer ";
|
|
1009
|
+
// Upstream budget: the Hermes call itself still gets a hard ceiling so
|
|
1010
|
+
// a wedged container can't hold the connection forever. Adapter can
|
|
1011
|
+
// extend this via inlineChatDescriptor.timeoutMs; default is 30 min
|
|
1012
|
+
// which comfortably covers multi-step tool loops.
|
|
1013
|
+
const upstreamTimeoutMs = desc.timeoutMs ?? 30 * 60_000;
|
|
1014
|
+
// Don't hardcode 127.0.0.1. Nomad's docker driver binds the published
|
|
1015
|
+
// port to whichever loopback address it enumerates first from `lo`, and
|
|
1016
|
+
// on modern Linux (Debian 12, Ubuntu 22.04+) that's frequently `::1`
|
|
1017
|
+
// rather than `127.0.0.1` — so a hardcoded IPv4 fetch() returns
|
|
1018
|
+
// ECONNREFUSED and the user sees "Failed to reach agent".
|
|
1019
|
+
const gwHost = await instanceManager.getGatewayHost(req.params.id);
|
|
1020
|
+
const target = `http://${instanceManager.urlHost(gwHost)}:${hostPort}${endpointPath}`;
|
|
1021
|
+
// Hijack the response so we own the raw socket — lets us flush SSE
|
|
1022
|
+
// headers + heartbeats before the upstream fetch resolves.
|
|
1023
|
+
reply.hijack();
|
|
1024
|
+
const raw = reply.raw;
|
|
1025
|
+
raw.writeHead(200, {
|
|
1026
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1027
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1028
|
+
"Connection": "keep-alive",
|
|
1029
|
+
"X-Accel-Buffering": "no",
|
|
1030
|
+
});
|
|
1031
|
+
const writeEvent = (event, data) => {
|
|
1032
|
+
const payload = typeof data === "string" ? data : JSON.stringify(data);
|
|
1033
|
+
raw.write(`event: ${event}\ndata: ${payload}\n\n`);
|
|
1034
|
+
};
|
|
1035
|
+
// Kick off one heartbeat immediately so buffering proxies flush.
|
|
1036
|
+
raw.write(": ping\n\n");
|
|
1037
|
+
const HEARTBEAT_MS = 10_000;
|
|
1038
|
+
const heartbeat = setInterval(() => {
|
|
1039
|
+
try {
|
|
1040
|
+
raw.write(": ping\n\n");
|
|
1041
|
+
}
|
|
1042
|
+
catch { /* socket closed */ }
|
|
1043
|
+
}, HEARTBEAT_MS);
|
|
1044
|
+
// Abort upstream if the client goes away.
|
|
1045
|
+
const abortController = new AbortController();
|
|
1046
|
+
const clientGone = () => abortController.abort();
|
|
1047
|
+
req.raw.on("close", clientGone);
|
|
1048
|
+
const upstreamTimer = setTimeout(() => abortController.abort(), upstreamTimeoutMs);
|
|
1049
|
+
try {
|
|
1050
|
+
const resp = await fetch(target, {
|
|
1051
|
+
method: "POST",
|
|
1052
|
+
headers: {
|
|
1053
|
+
"Content-Type": "application/json",
|
|
1054
|
+
[authHeader]: `${authScheme}${apiKey}`,
|
|
1055
|
+
},
|
|
1056
|
+
body: JSON.stringify(req.body || {}),
|
|
1057
|
+
signal: abortController.signal,
|
|
1058
|
+
});
|
|
1059
|
+
const text = await resp.text();
|
|
1060
|
+
clearInterval(heartbeat);
|
|
1061
|
+
clearTimeout(upstreamTimer);
|
|
1062
|
+
req.raw.off("close", clientGone);
|
|
1063
|
+
writeEvent("result", {
|
|
1064
|
+
status: resp.status,
|
|
1065
|
+
contentType: resp.headers.get("content-type") || "application/json",
|
|
1066
|
+
body: text,
|
|
1067
|
+
});
|
|
1068
|
+
writeEvent("done", {});
|
|
1069
|
+
raw.end();
|
|
1070
|
+
}
|
|
1071
|
+
catch (e) {
|
|
1072
|
+
clearInterval(heartbeat);
|
|
1073
|
+
clearTimeout(upstreamTimer);
|
|
1074
|
+
req.raw.off("close", clientGone);
|
|
1075
|
+
try {
|
|
1076
|
+
writeEvent("error", { detail: `Failed to reach agent: ${e?.message || String(e)}` });
|
|
1077
|
+
raw.end();
|
|
1078
|
+
}
|
|
1079
|
+
catch { /* socket already closed */ }
|
|
750
1080
|
}
|
|
751
|
-
return { accounts: instanceManager.getWeixinAccounts(req.params.id) };
|
|
752
1081
|
});
|
|
753
|
-
|
|
754
|
-
const WEIXIN_API_BASE = "https://ilinkai.weixin.qq.com";
|
|
755
|
-
const WEIXIN_BOT_TYPE = "3";
|
|
756
|
-
// In-memory active login sessions (short-lived, 5 min TTL)
|
|
757
|
-
const weixinLogins = new Map();
|
|
758
|
-
app.post("/api/instances/:id/weixin/login", async (req, reply) => {
|
|
1082
|
+
app.post("/api/instances/:id/provides/:capability/terminal/session", async (req, reply) => {
|
|
759
1083
|
const idErr = validateId(req.params.id);
|
|
760
1084
|
if (idErr)
|
|
761
1085
|
return reply.status(400).send({ detail: idErr });
|
|
762
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
1086
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
763
1087
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
764
1088
|
}
|
|
765
|
-
// Require instance to be running
|
|
766
|
-
const svc = await getSvc();
|
|
767
|
-
const svcStatus = await svc.getStatus(req.params.id);
|
|
768
|
-
if (svcStatus.status !== "running") {
|
|
769
|
-
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
770
|
-
}
|
|
771
|
-
// Auto-install weixin plugin if not present
|
|
772
|
-
await ensurePluginInstalled(req.params.id, "openclaw-weixin");
|
|
773
1089
|
try {
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
const
|
|
778
|
-
const
|
|
779
|
-
weixinLogins.set(sessionKey, {
|
|
1090
|
+
const provide = resolveTerminalProvide(req.params.id, req.params.capability);
|
|
1091
|
+
const terminal = provide.terminal;
|
|
1092
|
+
const input = typeof req.body?.input === "string" ? req.body.input : "";
|
|
1093
|
+
const command = buildTerminalCommand(terminal.command, input);
|
|
1094
|
+
const session = startTerminalSession({
|
|
780
1095
|
instanceId: req.params.id,
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1096
|
+
capability: req.params.capability,
|
|
1097
|
+
terminal,
|
|
1098
|
+
command,
|
|
784
1099
|
});
|
|
785
|
-
|
|
786
|
-
for (const [k, v] of weixinLogins) {
|
|
787
|
-
if (Date.now() - v.startedAt > 5 * 60_000)
|
|
788
|
-
weixinLogins.delete(k);
|
|
789
|
-
}
|
|
790
|
-
while (weixinLogins.size > MAX_LOGIN_SESSIONS) {
|
|
791
|
-
weixinLogins.delete(weixinLogins.keys().next().value);
|
|
792
|
-
}
|
|
793
|
-
return { qrcodeUrl: data.qrcode_img_content, sessionKey };
|
|
1100
|
+
return reply.send(session);
|
|
794
1101
|
}
|
|
795
|
-
catch (
|
|
796
|
-
return reply.status(
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
return reply.status(400).send({ detail: error?.message || "Failed to start terminal session" });
|
|
797
1104
|
}
|
|
798
1105
|
});
|
|
799
|
-
app.get("/api/instances/:id/
|
|
1106
|
+
app.get("/api/instances/:id/provides/:capability/terminal/session/:sessionId/stream", async (req, reply) => {
|
|
800
1107
|
const idErr = validateId(req.params.id);
|
|
801
1108
|
if (idErr)
|
|
802
1109
|
return reply.status(400).send({ detail: idErr });
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1110
|
+
if (!assertTerminalSessionOwner(req.params.sessionId, req.params.id, req.params.capability)) {
|
|
1111
|
+
return reply.status(404).send({ detail: "Terminal session not found" });
|
|
1112
|
+
}
|
|
1113
|
+
const session = getTerminalSession(req.params.sessionId);
|
|
1114
|
+
if (!session)
|
|
1115
|
+
return reply.status(404).send({ detail: "Terminal session not found" });
|
|
1116
|
+
const since = Math.max(parseInt(req.query.since || "0", 10) || 0, 0);
|
|
1117
|
+
reply.hijack();
|
|
1118
|
+
const raw = reply.raw;
|
|
1119
|
+
raw.writeHead(200, {
|
|
1120
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1121
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1122
|
+
"Connection": "keep-alive",
|
|
1123
|
+
"X-Accel-Buffering": "no",
|
|
1124
|
+
});
|
|
1125
|
+
const writeEvent = (event, data) => {
|
|
1126
|
+
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1127
|
+
};
|
|
1128
|
+
for (const event of getTerminalSessionEvents(req.params.sessionId, since)) {
|
|
1129
|
+
writeEvent(event.type, event);
|
|
811
1130
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
token: data.bot_token || "",
|
|
824
|
-
baseUrl: data.baseurl || WEIXIN_API_BASE,
|
|
825
|
-
userId: data.ilink_user_id || "",
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
catch (e) {
|
|
829
|
-
console.error(`[weixin-login] Failed to save credentials: ${e.message}`);
|
|
830
|
-
return reply.status(500).send({
|
|
831
|
-
status: "confirmed", connected: false,
|
|
832
|
-
detail: "WeChat authenticated but failed to save credentials: " + e.message,
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
return {
|
|
836
|
-
status: "confirmed", connected: true,
|
|
837
|
-
accountId: data.ilink_bot_id,
|
|
838
|
-
message: "WeChat connected!",
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
if (data.status === "expired") {
|
|
842
|
-
// Auto-refresh QR
|
|
843
|
-
try {
|
|
844
|
-
const refreshResp = await fetch(`${WEIXIN_API_BASE}/ilink/bot/get_bot_qrcode?bot_type=${WEIXIN_BOT_TYPE}`, { signal: AbortSignal.timeout(30_000) });
|
|
845
|
-
if (refreshResp.ok) {
|
|
846
|
-
const refreshData = await refreshResp.json();
|
|
847
|
-
login.qrcode = refreshData.qrcode;
|
|
848
|
-
login.qrcodeUrl = refreshData.qrcode_img_content;
|
|
849
|
-
login.startedAt = Date.now();
|
|
850
|
-
return {
|
|
851
|
-
status: "refreshed", connected: false,
|
|
852
|
-
qrcodeUrl: refreshData.qrcode_img_content,
|
|
853
|
-
message: "QR code refreshed, please scan again.",
|
|
854
|
-
};
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
catch { /* fall through */ }
|
|
858
|
-
weixinLogins.delete(req.params.sessionKey);
|
|
859
|
-
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
1131
|
+
if (!session.running) {
|
|
1132
|
+
writeEvent("done", { sessionId: req.params.sessionId });
|
|
1133
|
+
raw.end();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const unsubscribe = subscribeTerminalSession(req.params.sessionId, (event) => {
|
|
1137
|
+
writeEvent(event.type, event);
|
|
1138
|
+
if (event.type === "exit" || event.type === "error") {
|
|
1139
|
+
writeEvent("done", { sessionId: req.params.sessionId });
|
|
1140
|
+
unsubscribe?.();
|
|
1141
|
+
raw.end();
|
|
860
1142
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1143
|
+
});
|
|
1144
|
+
req.raw.on("close", () => {
|
|
1145
|
+
unsubscribe?.();
|
|
1146
|
+
});
|
|
1147
|
+
raw.write(": ping\n\n");
|
|
1148
|
+
});
|
|
1149
|
+
app.post("/api/instances/:id/provides/:capability/terminal/session/:sessionId/input", async (req, reply) => {
|
|
1150
|
+
const idErr = validateId(req.params.id);
|
|
1151
|
+
if (idErr)
|
|
1152
|
+
return reply.status(400).send({ detail: idErr });
|
|
1153
|
+
if (!assertTerminalSessionOwner(req.params.sessionId, req.params.id, req.params.capability)) {
|
|
1154
|
+
return reply.status(404).send({ detail: "Terminal session not found" });
|
|
865
1155
|
}
|
|
866
|
-
|
|
867
|
-
|
|
1156
|
+
try {
|
|
1157
|
+
sendTerminalSessionInput(req.params.sessionId, typeof req.body?.input === "string" ? req.body.input : "");
|
|
1158
|
+
return reply.send({ ok: true });
|
|
1159
|
+
}
|
|
1160
|
+
catch (error) {
|
|
1161
|
+
return reply.status(400).send({ detail: error?.message || "Failed to send terminal input" });
|
|
868
1162
|
}
|
|
869
1163
|
});
|
|
870
|
-
|
|
871
|
-
app.get("/api/instances/:id/usage", async (req, reply) => {
|
|
1164
|
+
app.post("/api/instances/:id/provides/:capability/terminal/session/:sessionId/stop", async (req, reply) => {
|
|
872
1165
|
const idErr = validateId(req.params.id);
|
|
873
1166
|
if (idErr)
|
|
874
1167
|
return reply.status(400).send({ detail: idErr });
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
879
|
-
const sessionsIndex = join(openclawHome, ".openclaw", "agents", "main", "sessions", "sessions.json");
|
|
880
|
-
const emptyTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
881
|
-
if (!existsSync(sessionsIndex))
|
|
882
|
-
return { sessions: [], totals: emptyTotals };
|
|
883
|
-
// Check file size before reading
|
|
884
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
885
|
-
const indexStat = await stat(sessionsIndex);
|
|
886
|
-
if (indexStat.size > MAX_FILE_SIZE) {
|
|
887
|
-
return reply.status(400).send({ detail: "sessions.json exceeds 10MB size limit" });
|
|
888
|
-
}
|
|
889
|
-
let sessionsMap;
|
|
1168
|
+
if (!assertTerminalSessionOwner(req.params.sessionId, req.params.id, req.params.capability)) {
|
|
1169
|
+
return reply.status(404).send({ detail: "Terminal session not found" });
|
|
1170
|
+
}
|
|
890
1171
|
try {
|
|
891
|
-
|
|
892
|
-
|
|
1172
|
+
stopTerminalSession(req.params.sessionId);
|
|
1173
|
+
return reply.send({ ok: true });
|
|
893
1174
|
}
|
|
894
|
-
catch {
|
|
895
|
-
return reply.status(
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
return reply.status(400).send({ detail: error?.message || "Failed to stop terminal session" });
|
|
896
1177
|
}
|
|
897
|
-
const sessions = [];
|
|
898
|
-
const totals = { ...emptyTotals };
|
|
899
|
-
for (const [sessionKey, sessionMeta] of Object.entries(sessionsMap)) {
|
|
900
|
-
const sessionFile = sessionMeta?.sessionFile;
|
|
901
|
-
if (!sessionFile || !existsSync(sessionFile))
|
|
902
|
-
continue;
|
|
903
|
-
// Prevent path traversal: sessionFile must be under the instance's openclaw home.
|
|
904
|
-
// Use realpathSync to resolve symlinks and prevent symlink-based bypasses.
|
|
905
|
-
try {
|
|
906
|
-
const resolvedSession = realpathSync(sessionFile);
|
|
907
|
-
const resolvedHome = realpathSync(openclawHome);
|
|
908
|
-
if (!resolvedSession.startsWith(resolvedHome + "/") && resolvedSession !== resolvedHome)
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
catch {
|
|
912
|
-
continue;
|
|
913
|
-
}
|
|
914
|
-
// Check session file size before reading
|
|
915
|
-
let sessionStat;
|
|
916
|
-
try {
|
|
917
|
-
sessionStat = await stat(sessionFile);
|
|
918
|
-
}
|
|
919
|
-
catch {
|
|
920
|
-
continue;
|
|
921
|
-
}
|
|
922
|
-
if (sessionStat.size > MAX_FILE_SIZE)
|
|
923
|
-
continue;
|
|
924
|
-
const sessionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
925
|
-
let model = "";
|
|
926
|
-
let firstTs = "";
|
|
927
|
-
let lastTs = "";
|
|
928
|
-
const originLabel = sessionMeta?.origin?.label || "";
|
|
929
|
-
const channel = sessionMeta?.origin?.provider || "";
|
|
930
|
-
let sessionContent;
|
|
931
|
-
try {
|
|
932
|
-
sessionContent = await readFile(sessionFile, "utf-8");
|
|
933
|
-
}
|
|
934
|
-
catch {
|
|
935
|
-
continue;
|
|
936
|
-
}
|
|
937
|
-
for (const line of sessionContent.split("\n")) {
|
|
938
|
-
let entry;
|
|
939
|
-
try {
|
|
940
|
-
entry = JSON.parse(line.trim());
|
|
941
|
-
}
|
|
942
|
-
catch {
|
|
943
|
-
continue;
|
|
944
|
-
}
|
|
945
|
-
if (entry.type !== "message")
|
|
946
|
-
continue;
|
|
947
|
-
const msg = entry.message || {};
|
|
948
|
-
const ts = entry.timestamp || "";
|
|
949
|
-
if (ts && !firstTs)
|
|
950
|
-
firstTs = ts;
|
|
951
|
-
if (ts)
|
|
952
|
-
lastTs = ts;
|
|
953
|
-
if (msg.role === "assistant") {
|
|
954
|
-
if (!model && msg.model)
|
|
955
|
-
model = msg.model;
|
|
956
|
-
const usage = msg.usage;
|
|
957
|
-
if (usage) {
|
|
958
|
-
sessionUsage.input += usage.input || 0;
|
|
959
|
-
sessionUsage.output += usage.output || 0;
|
|
960
|
-
sessionUsage.cacheRead += usage.cacheRead || 0;
|
|
961
|
-
sessionUsage.cacheWrite += usage.cacheWrite || 0;
|
|
962
|
-
sessionUsage.totalTokens += usage.totalTokens || 0;
|
|
963
|
-
if (typeof usage.cost === "object")
|
|
964
|
-
sessionUsage.costTotal += usage.cost.total || 0;
|
|
965
|
-
}
|
|
966
|
-
sessionUsage.messages++;
|
|
967
|
-
}
|
|
968
|
-
else if (msg.role === "user") {
|
|
969
|
-
sessionUsage.messages++;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
sessions.push({ key: sessionKey, model, channel, origin: originLabel, firstMessage: firstTs, lastMessage: lastTs, usage: sessionUsage });
|
|
973
|
-
for (const k of ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "messages"]) {
|
|
974
|
-
totals[k] += sessionUsage[k];
|
|
975
|
-
}
|
|
976
|
-
totals.costTotal += sessionUsage.costTotal;
|
|
977
|
-
}
|
|
978
|
-
sessions.sort((a, b) => (b.lastMessage || "").localeCompare(a.lastMessage || ""));
|
|
979
|
-
return { sessions, totals };
|
|
980
1178
|
});
|
|
1179
|
+
app.all("/api/instances/:id/provides/:capability", async (req, reply) => proxyProvidedCapability(req, reply));
|
|
1180
|
+
app.all("/api/instances/:id/provides/:capability/*", async (req, reply) => proxyProvidedCapability(req, reply));
|
|
981
1181
|
// Logs
|
|
982
1182
|
app.get("/api/instances/:id/logs", async (req, reply) => {
|
|
983
1183
|
const idErr = validateId(req.params.id);
|
|
984
1184
|
if (idErr)
|
|
985
1185
|
return reply.status(400).send({ detail: idErr });
|
|
986
1186
|
const svc = await getSvc();
|
|
987
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
1187
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
988
1188
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
989
1189
|
}
|
|
990
1190
|
const logType = req.query.log_type || "stderr";
|
|
@@ -993,7 +1193,9 @@ export async function instanceRoutes(app) {
|
|
|
993
1193
|
}
|
|
994
1194
|
const MAX_LOG_LINES = 5000;
|
|
995
1195
|
const lines = Math.min(parseInt(req.query.lines || "100", 10) || 100, MAX_LOG_LINES);
|
|
996
|
-
const logLines =
|
|
1196
|
+
const logLines = getInstanceBackedInstalledApp(req.params.id)
|
|
1197
|
+
? await instanceManager.getAppLogs(req.params.id, "", lines, logType)
|
|
1198
|
+
: await svc.getLogs(req.params.id, lines, logType);
|
|
997
1199
|
return { lines: logLines };
|
|
998
1200
|
});
|
|
999
1201
|
// Admin: re-encrypt all instance secrets with current AES key
|
|
@@ -1071,279 +1273,60 @@ export async function instanceRoutes(app) {
|
|
|
1071
1273
|
}
|
|
1072
1274
|
return { ok: true, results };
|
|
1073
1275
|
});
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
}
|
|
1087
|
-
catch (err) {
|
|
1088
|
-
console.warn(`[gateway-launch] failed to add allowed origin for ${req.params.id}:`, err.message || err);
|
|
1089
|
-
}
|
|
1090
|
-
if (addedAllowedOrigin) {
|
|
1091
|
-
await restartRunningInstanceForControlUiOrigin(req.params.id, panelOrigin);
|
|
1092
|
-
}
|
|
1276
|
+
// ── Docker image check & pull ───────────────────────────────────────────
|
|
1277
|
+
// Generic Docker operations used by NewInstance UI to verify / pull runtime
|
|
1278
|
+
// images before creating an instance. Framework-level (not adapter-scoped)
|
|
1279
|
+
// because every container runtime shares the same docker CLI here.
|
|
1280
|
+
app.get("/api/docker/image-check", async (req, reply) => {
|
|
1281
|
+
const image = req.query.image;
|
|
1282
|
+
if (!image || typeof image !== "string") {
|
|
1283
|
+
return reply.status(400).send({ detail: "Missing 'image' query parameter" });
|
|
1284
|
+
}
|
|
1285
|
+
// Validate image name to prevent command injection
|
|
1286
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/.test(image) || image.length > 256) {
|
|
1287
|
+
return reply.status(400).send({ detail: "Invalid Docker image name" });
|
|
1093
1288
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1289
|
+
try {
|
|
1290
|
+
const { execFileSync } = await import("child_process");
|
|
1291
|
+
execFileSync("docker", ["image", "inspect", image], { timeout: 10000, stdio: "ignore" });
|
|
1292
|
+
return { exists: true, image };
|
|
1293
|
+
}
|
|
1294
|
+
catch {
|
|
1295
|
+
return { exists: false, image };
|
|
1099
1296
|
}
|
|
1100
|
-
return { url: `${baseUrl}#token=${encodeURIComponent(token.trim())}` };
|
|
1101
1297
|
});
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
const inst = instanceManager.getInstance(id);
|
|
1111
|
-
if (!inst)
|
|
1112
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
1113
|
-
const port = instanceManager.getGatewayPort(id);
|
|
1114
|
-
const gwHost = await instanceManager.getGatewayHost(id);
|
|
1115
|
-
const suffix = request.params["*"] || "";
|
|
1116
|
-
const qs = request.url.includes("?") ? request.url.slice(request.url.indexOf("?")) : "";
|
|
1117
|
-
// Raw HTTP proxy — stream the request body and preserve headers.
|
|
1118
|
-
// Bracket IPv6 literals (`::1`) because `http://::1:18789/` is not a
|
|
1119
|
-
// valid URL; see instance-manager.urlHost. Nomad 1.6.5 on Linux with
|
|
1120
|
-
// `network_interface = "lo"` can allocate the task port to `::1`
|
|
1121
|
-
// whenever the kernel enumerates the lo interface's v6 address first.
|
|
1122
|
-
const urlGwHost = instanceManager.urlHost(gwHost);
|
|
1123
|
-
const targetUrl = `http://${urlGwHost}:${port}/${suffix}${qs}`;
|
|
1298
|
+
app.post("/api/docker/image-pull", async (req, reply) => {
|
|
1299
|
+
const image = req.body?.image;
|
|
1300
|
+
if (!image || typeof image !== "string") {
|
|
1301
|
+
return reply.status(400).send({ detail: "Missing 'image' in request body" });
|
|
1302
|
+
}
|
|
1303
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9\-_.:/@]*$/.test(image) || image.length > 256) {
|
|
1304
|
+
return reply.status(400).send({ detail: "Invalid Docker image name" });
|
|
1305
|
+
}
|
|
1124
1306
|
try {
|
|
1125
|
-
|
|
1126
|
-
const
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
fwdHeaders["accept-encoding"] = "identity";
|
|
1145
|
-
// Use http.request for raw body streaming (avoids Fastify's parsed body)
|
|
1146
|
-
const upstreamRes = await new Promise((resolve, reject) => {
|
|
1147
|
-
const proxyReq = httpRequest(targetUrl, {
|
|
1148
|
-
method: request.method,
|
|
1149
|
-
headers: fwdHeaders,
|
|
1150
|
-
}, resolve);
|
|
1151
|
-
proxyReq.on("error", reject);
|
|
1152
|
-
// Pipe the raw incoming body directly to the upstream
|
|
1153
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1154
|
-
request.raw.pipe(proxyReq);
|
|
1155
|
-
}
|
|
1156
|
-
else {
|
|
1157
|
-
proxyReq.end();
|
|
1158
|
-
}
|
|
1159
|
-
});
|
|
1160
|
-
reply.status(upstreamRes.statusCode || 502);
|
|
1161
|
-
// Forward response headers — preserve most, rewrite security headers for iframe
|
|
1162
|
-
for (const [k, v] of Object.entries(upstreamRes.headers)) {
|
|
1163
|
-
if (v === undefined)
|
|
1164
|
-
continue;
|
|
1165
|
-
const lk = k.toLowerCase();
|
|
1166
|
-
if (HOP_BY_HOP.has(lk))
|
|
1167
|
-
continue;
|
|
1168
|
-
if (lk === "x-frame-options") {
|
|
1169
|
-
reply.header("x-frame-options", "SAMEORIGIN");
|
|
1170
|
-
continue;
|
|
1171
|
-
}
|
|
1172
|
-
if (lk === "content-security-policy") {
|
|
1173
|
-
// Replace frame-ancestors directive, preserve the rest
|
|
1174
|
-
const csp = Array.isArray(v) ? v.join(", ") : v;
|
|
1175
|
-
const rewritten = csp.replace(/frame-ancestors\s+[^;]*/i, "frame-ancestors 'self'");
|
|
1176
|
-
reply.header("content-security-policy", rewritten === csp ? `${csp}; frame-ancestors 'self'` : rewritten);
|
|
1177
|
-
continue;
|
|
1178
|
-
}
|
|
1179
|
-
reply.header(k, Array.isArray(v) ? v.join(", ") : v);
|
|
1180
|
-
}
|
|
1181
|
-
// Rewrite control-ui-config.json to set basePath for proxied gateway
|
|
1182
|
-
const respCt = upstreamRes.headers["content-type"] || "";
|
|
1183
|
-
if (suffix === "__openclaw/control-ui-config.json" && respCt.includes("application/json")) {
|
|
1184
|
-
const chunks = [];
|
|
1185
|
-
for await (const chunk of upstreamRes)
|
|
1186
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1187
|
-
try {
|
|
1188
|
-
const config = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1189
|
-
config.basePath = `/api/instances/${id}/gateway`;
|
|
1190
|
-
const buf = Buffer.from(JSON.stringify(config));
|
|
1191
|
-
reply.header("content-length", buf.length);
|
|
1192
|
-
reply.removeHeader("content-encoding");
|
|
1193
|
-
return reply.send(buf);
|
|
1194
|
-
}
|
|
1195
|
-
catch { /* fall through to stream */ }
|
|
1196
|
-
}
|
|
1197
|
-
// For HTML responses: buffer to inject crypto shim + basePath
|
|
1198
|
-
if (respCt.includes("text/html")) {
|
|
1199
|
-
const chunks = [];
|
|
1200
|
-
for await (const chunk of upstreamRes)
|
|
1201
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1202
|
-
let html = Buffer.concat(chunks).toString("utf-8");
|
|
1203
|
-
const basePath = `/api/instances/${id}/gateway`;
|
|
1204
|
-
const injectScript = [
|
|
1205
|
-
`window.__OPENCLAW_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};`,
|
|
1206
|
-
`(()=>{`,
|
|
1207
|
-
` try {`,
|
|
1208
|
-
` const settingsKey='openclaw.control.settings.v1';`,
|
|
1209
|
-
` const tokenStoragePrefix='openclaw.control.token.v1:';`,
|
|
1210
|
-
` const normalizeGatewayScope=(gatewayUrl)=>{`,
|
|
1211
|
-
` const raw=(gatewayUrl||'').trim();`,
|
|
1212
|
-
` if(!raw) return 'default';`,
|
|
1213
|
-
` try {`,
|
|
1214
|
-
` const base=\`\${window.location.protocol}//\${window.location.host}\${window.location.pathname||'/'}\`;`,
|
|
1215
|
-
` const parsed=new URL(raw, base);`,
|
|
1216
|
-
` const pathname=parsed.pathname==='/'?'':(parsed.pathname.replace(/\\/+$/,'')||parsed.pathname);`,
|
|
1217
|
-
` return \`\${parsed.protocol}//\${parsed.host}\${pathname}\`;`,
|
|
1218
|
-
` } catch {`,
|
|
1219
|
-
` return raw;`,
|
|
1220
|
-
` }`,
|
|
1221
|
-
` };`,
|
|
1222
|
-
` const proto=window.location.protocol==='https:'?'wss':'ws';`,
|
|
1223
|
-
` const gatewayUrl=\`\${proto}://\${window.location.host}${basePath}\`;`,
|
|
1224
|
-
` const tokenSessionKey=\`\${tokenStoragePrefix}\${normalizeGatewayScope(gatewayUrl)}\`;`,
|
|
1225
|
-
` const raw=window.localStorage.getItem(settingsKey);`,
|
|
1226
|
-
` let next={};`,
|
|
1227
|
-
` try { next=raw ? JSON.parse(raw) : {}; } catch { next={}; }`,
|
|
1228
|
-
` next.gatewayUrl=gatewayUrl;`,
|
|
1229
|
-
` if('token' in next) delete next.token;`,
|
|
1230
|
-
` const hashParams=new URLSearchParams(window.location.hash.startsWith('#')?window.location.hash.slice(1):window.location.hash);`,
|
|
1231
|
-
` const searchParams=new URLSearchParams(window.location.search);`,
|
|
1232
|
-
` const launchToken=(hashParams.get('token')||searchParams.get('token')||'').trim();`,
|
|
1233
|
-
` if(launchToken){`,
|
|
1234
|
-
` window.sessionStorage.setItem(tokenSessionKey, launchToken);`,
|
|
1235
|
-
` }`,
|
|
1236
|
-
` window.localStorage.setItem(settingsKey, JSON.stringify(next));`,
|
|
1237
|
-
` const autoConnect=()=>{`,
|
|
1238
|
-
` const attempt=()=>{`,
|
|
1239
|
-
` const app=document.querySelector('openclaw-app');`,
|
|
1240
|
-
` if(!app||typeof app.connect!=='function'||typeof app.applySettings!=='function'||!app.settings||typeof app.settings!=='object') return false;`,
|
|
1241
|
-
` const sessionToken=(window.sessionStorage.getItem(tokenSessionKey)||'').trim();`,
|
|
1242
|
-
` const token=(sessionToken||launchToken||app.settings.token||'').trim();`,
|
|
1243
|
-
` if(!token) return false;`,
|
|
1244
|
-
` const nextSettings={...app.settings, gatewayUrl, token};`,
|
|
1245
|
-
` if(nextSettings.gatewayUrl!==app.settings.gatewayUrl||nextSettings.token!==app.settings.token){`,
|
|
1246
|
-
` app.applySettings(nextSettings);`,
|
|
1247
|
-
` }`,
|
|
1248
|
-
` if(app.connected) return true;`,
|
|
1249
|
-
` const wsState=app.client&&app.client.ws?app.client.ws.readyState:null;`,
|
|
1250
|
-
` const connecting=wsState===0||wsState===1;`,
|
|
1251
|
-
` if(!connecting){`,
|
|
1252
|
-
` window.setTimeout(()=>{`,
|
|
1253
|
-
` try { if(!app.connected) app.connect(); } catch {}`,
|
|
1254
|
-
` }, 0);`,
|
|
1255
|
-
` }`,
|
|
1256
|
-
` return false;`,
|
|
1257
|
-
` };`,
|
|
1258
|
-
` const start=()=>{`,
|
|
1259
|
-
` let tries=0;`,
|
|
1260
|
-
` let timer=0;`,
|
|
1261
|
-
` const tick=()=>{`,
|
|
1262
|
-
` tries+=1;`,
|
|
1263
|
-
` if(attempt()||tries>=120){`,
|
|
1264
|
-
` window.clearInterval(timer);`,
|
|
1265
|
-
` }`,
|
|
1266
|
-
` };`,
|
|
1267
|
-
` tick();`,
|
|
1268
|
-
` timer=window.setInterval(()=>{`,
|
|
1269
|
-
` tick();`,
|
|
1270
|
-
` },500);`,
|
|
1271
|
-
` };`,
|
|
1272
|
-
` if(window.customElements&&typeof window.customElements.whenDefined==='function'){`,
|
|
1273
|
-
` window.customElements.whenDefined('openclaw-app').then(start).catch(()=>{});`,
|
|
1274
|
-
` }else{`,
|
|
1275
|
-
` start();`,
|
|
1276
|
-
` }`,
|
|
1277
|
-
` };`,
|
|
1278
|
-
` autoConnect();`,
|
|
1279
|
-
` } catch {}`,
|
|
1280
|
-
`})();`,
|
|
1281
|
-
].join("");
|
|
1282
|
-
const inject = `<script>${injectScript}</script>`;
|
|
1283
|
-
// Append jishu-inject listener as a separate script tag (keeps CSP hash separate)
|
|
1284
|
-
const injectCmdScript = [
|
|
1285
|
-
`(function(){`,
|
|
1286
|
-
` var _jishuInject=function(cmd,send){`,
|
|
1287
|
-
` // Primary path: use openclaw-app Lit component API directly.`,
|
|
1288
|
-
` // app.chatMessage is the reactive property backing the textarea draft.`,
|
|
1289
|
-
` // app.handleSendChat(cmd) invokes the component's own send handler.`,
|
|
1290
|
-
` var app=document.querySelector('openclaw-app');`,
|
|
1291
|
-
` if(!app)return false;`,
|
|
1292
|
-
` if(send){`,
|
|
1293
|
-
` // Only send when gateway WebSocket is connected`,
|
|
1294
|
-
` if(!app.connected)return false;`,
|
|
1295
|
-
` try{app.handleSendChat(cmd);return true;}catch(e){}`,
|
|
1296
|
-
` return false;`,
|
|
1297
|
-
` }else{`,
|
|
1298
|
-
` // Draft-only: set reactive property so Lit re-renders the textarea`,
|
|
1299
|
-
` try{app.chatMessage=cmd;return true;}catch(e){}`,
|
|
1300
|
-
` return false;`,
|
|
1301
|
-
` }`,
|
|
1302
|
-
` };`,
|
|
1303
|
-
` window.addEventListener('message',function(e){`,
|
|
1304
|
-
` if(!e.data||e.data.type!=='jishu:inject-cmd')return;`,
|
|
1305
|
-
` var cmd=e.data.cmd,send=!!e.data.send,tries=0;`,
|
|
1306
|
-
` var poll=function(){if(_jishuInject(cmd,send)||++tries>=50)return;setTimeout(poll,200);};`,
|
|
1307
|
-
` poll();`,
|
|
1308
|
-
` },false);`,
|
|
1309
|
-
`})();`,
|
|
1310
|
-
].join("");
|
|
1311
|
-
const injectCmdScriptHash = createHash("sha256").update(injectCmdScript, "utf8").digest("base64");
|
|
1312
|
-
const fullHtmlInject = `${inject}<script>${injectCmdScript}</script>`;
|
|
1313
|
-
html = html.replace(/<head\b[^>]*>/i, (match) => `${match}${fullHtmlInject}`);
|
|
1314
|
-
const inlineScriptHash = createHash("sha256").update(injectScript, "utf8").digest("base64");
|
|
1315
|
-
const cspHeader = reply.getHeader("content-security-policy");
|
|
1316
|
-
if (typeof cspHeader === "string" && cspHeader) {
|
|
1317
|
-
const hashToken = `'sha256-${inlineScriptHash}'`;
|
|
1318
|
-
const hashToken2 = `'sha256-${injectCmdScriptHash}'`;
|
|
1319
|
-
const addHashes = (src) => {
|
|
1320
|
-
let s = src;
|
|
1321
|
-
if (!s.includes(hashToken))
|
|
1322
|
-
s = s + ` ${hashToken}`;
|
|
1323
|
-
if (!s.includes(hashToken2))
|
|
1324
|
-
s = s + ` ${hashToken2}`;
|
|
1325
|
-
return s;
|
|
1326
|
-
};
|
|
1327
|
-
const nextCsp = /\bscript-src\b/i.test(cspHeader)
|
|
1328
|
-
? cspHeader.replace(/\bscript-src\b([^;]*)/i, (_m, value) => `script-src${addHashes(value)}`)
|
|
1329
|
-
: `${cspHeader}; script-src 'self' ${hashToken} ${hashToken2}`;
|
|
1330
|
-
reply.header("content-security-policy", nextCsp);
|
|
1331
|
-
}
|
|
1332
|
-
const buf = Buffer.from(html, "utf-8");
|
|
1333
|
-
reply.header("cache-control", "no-store");
|
|
1334
|
-
reply.header("content-length", buf.length);
|
|
1335
|
-
reply.removeHeader("content-encoding");
|
|
1336
|
-
return reply.send(buf);
|
|
1337
|
-
}
|
|
1338
|
-
// Non-HTML: stream response directly
|
|
1339
|
-
return reply.send(upstreamRes);
|
|
1307
|
+
const { execFile } = await import("child_process");
|
|
1308
|
+
const { promisify } = await import("util");
|
|
1309
|
+
const execFileAsync = promisify(execFile);
|
|
1310
|
+
await execFileAsync("docker", ["pull", image], { timeout: 600_000 });
|
|
1311
|
+
return { ok: true, image };
|
|
1312
|
+
}
|
|
1313
|
+
catch (e) {
|
|
1314
|
+
return reply.status(500).send({ detail: `Failed to pull image: ${e.message}` });
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
// ── Adapter-owned routes (§32.2.5) ─────────────────────────────────────
|
|
1318
|
+
// Each registered runtime adapter may contribute its own HTTP endpoints.
|
|
1319
|
+
// OpenClaw owns plugins/mcporter/skills/feishu/weixin/usage/gateway-launch/
|
|
1320
|
+
// gateway proxy; Hermes currently owns none. Adding a new agent that
|
|
1321
|
+
// needs custom routes is a matter of implementing registerRoutes() in
|
|
1322
|
+
// the adapter — the framework layer never hard-codes kind-specific paths.
|
|
1323
|
+
for (const adapter of listRegisteredAdapters()) {
|
|
1324
|
+
try {
|
|
1325
|
+
await adapter.registerRoutes?.(app);
|
|
1340
1326
|
}
|
|
1341
1327
|
catch (err) {
|
|
1342
|
-
console.error(`[
|
|
1343
|
-
return reply.status(502).send({ detail: "Cannot reach OpenClaw gateway" });
|
|
1328
|
+
console.error(`[instances] adapter ${adapter.agentType} registerRoutes failed:`, err);
|
|
1344
1329
|
}
|
|
1345
|
-
}
|
|
1346
|
-
app.all("/api/instances/:id/gateway/*", gatewayProxy);
|
|
1347
|
-
app.all("/api/instances/:id/gateway", gatewayProxy);
|
|
1330
|
+
}
|
|
1348
1331
|
}
|
|
1349
1332
|
//# sourceMappingURL=instances.js.map
|