jishushell 0.4.10 → 0.4.24-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile.hermes-slim +193 -0
- package/INSTALL-NOTICE +10 -12
- package/apps/hermes-container.yaml +35 -0
- package/apps/ollama-binary.yaml +164 -0
- package/apps/ollama-cpu-container.yaml +37 -0
- package/apps/ollama-with-hollama-binary.yaml +159 -0
- package/apps/openclaw-binary.yaml +69 -0
- package/apps/openclaw-container.yaml +37 -0
- package/apps/openclaw-with-ollama-container.yaml +42 -0
- package/apps/openclaw-with-searxng-container.yaml +136 -0
- package/apps/openwebui-container.yaml +53 -0
- package/apps/playwright-container.yaml +120 -0
- package/apps/searxng-container.yaml +115 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +15 -14
- package/dist/auth.js.map +1 -1
- package/dist/cli/app.d.ts +4 -0
- package/dist/cli/app.js +874 -0
- package/dist/cli/app.js.map +1 -0
- 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/{doctor.d.ts → cli/doctor.d.ts} +7 -1
- package/dist/{doctor.js → cli/doctor.js} +377 -22
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/helpers.d.ts +4 -0
- package/dist/cli/helpers.js +32 -0
- package/dist/cli/helpers.js.map +1 -0
- package/dist/cli/job.d.ts +4 -0
- package/dist/cli/job.js +198 -0
- package/dist/cli/job.js.map +1 -0
- package/dist/cli/llm.d.ts +25 -0
- package/dist/cli/llm.js +599 -0
- package/dist/cli/llm.js.map +1 -0
- 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 +26 -0
- package/dist/cli/panel.js +804 -0
- package/dist/cli/panel.js.map +1 -0
- 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 +48 -776
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +69 -0
- package/dist/config.js +268 -7
- package/dist/config.js.map +1 -1
- package/dist/control.d.ts +17 -41
- package/dist/control.js +61 -1323
- 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.d.ts +3 -0
- package/dist/routes/apps.js +278 -0
- package/dist/routes/apps.js.map +1 -0
- 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 +863 -874
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.d.ts +15 -0
- package/dist/routes/llm.js +247 -0
- package/dist/routes/llm.js.map +1 -0
- 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 +131 -9
- package/dist/routes/setup.js.map +1 -1
- package/dist/routes/system.js +56 -9
- package/dist/routes/system.js.map +1 -1
- package/dist/server.js +107 -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/app-compiler.d.ts +15 -0
- package/dist/services/app/app-compiler.js +172 -0
- package/dist/services/app/app-compiler.js.map +1 -0
- package/dist/services/app/app-manager.d.ts +142 -0
- package/dist/services/app/app-manager.js +2148 -0
- package/dist/services/app/app-manager.js.map +1 -0
- package/dist/services/app/custom-manager.d.ts +27 -0
- package/dist/services/app/custom-manager.js +285 -0
- package/dist/services/app/custom-manager.js.map +1 -0
- package/dist/services/app/hermes-agent-manager.d.ts +20 -0
- package/dist/services/app/hermes-agent-manager.js +289 -0
- package/dist/services/app/hermes-agent-manager.js.map +1 -0
- package/dist/services/app/id-normalizer.d.ts +27 -0
- package/dist/services/app/id-normalizer.js +77 -0
- package/dist/services/app/id-normalizer.js.map +1 -0
- package/dist/services/app/ollama-manager.d.ts +18 -0
- package/dist/services/app/ollama-manager.js +207 -0
- package/dist/services/app/ollama-manager.js.map +1 -0
- package/dist/services/app/openclaw-manager.d.ts +63 -0
- package/dist/services/app/openclaw-manager.js +1178 -0
- package/dist/services/app/openclaw-manager.js.map +1 -0
- package/dist/services/app/paths.d.ts +47 -0
- package/dist/services/app/paths.js +68 -0
- package/dist/services/app/paths.js.map +1 -0
- package/dist/services/app/registry.d.ts +17 -0
- package/dist/services/app/registry.js +31 -0
- package/dist/services/app/registry.js.map +1 -0
- package/dist/services/app/remote-spec.d.ts +14 -0
- package/dist/services/app/remote-spec.js +58 -0
- package/dist/services/app/remote-spec.js.map +1 -0
- package/dist/services/app/terminal-session-manager.d.ts +27 -0
- package/dist/services/app/terminal-session-manager.js +157 -0
- package/dist/services/app/terminal-session-manager.js.map +1 -0
- package/dist/services/app/types.d.ts +72 -0
- package/dist/services/app/types.js +16 -0
- package/dist/services/app/types.js.map +1 -0
- package/dist/services/backup-manager.js +60 -22
- package/dist/services/backup-manager.js.map +1 -1
- package/dist/services/instance-manager.d.ts +125 -34
- package/dist/services/instance-manager.js +679 -1043
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/adapters.js +5 -1
- package/dist/services/llm-proxy/adapters.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 +43 -0
- package/dist/services/llm-proxy/index.js +120 -5
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/ssrf.js +1 -1
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.d.ts +260 -3
- package/dist/services/nomad-manager.js +2921 -341
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/panel-manager.d.ts +50 -0
- package/dist/services/panel-manager.js +443 -0
- package/dist/services/panel-manager.js.map +1 -0
- package/dist/services/plugin-installer.js +28 -2
- package/dist/services/plugin-installer.js.map +1 -1
- package/dist/services/process-manager.js +42 -7
- 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 +591 -625
- 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/services/update-manager.d.ts +47 -0
- package/dist/services/update-manager.js +305 -0
- package/dist/services/update-manager.js.map +1 -0
- package/dist/types.d.ts +224 -0
- 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 +303 -38
- package/install/post-install.sh +64 -5
- package/package.json +19 -5
- package/public/assets/Dashboard-rh9qpYRR.js +1 -0
- package/public/assets/HermesChatPanel-D6JI6lLY.js +1 -0
- package/public/assets/HermesConfigForm-DcbSemaj.js +4 -0
- package/public/assets/InitPassword-CFTKsED4.js +1 -0
- package/public/assets/InstanceDetail-BhNIKA6Z.js +91 -0
- package/public/assets/{Login-CUoEZOWR.js → Login-KB9qrtM0.js} +1 -1
- package/public/assets/NewInstance-CxkO8Hlq.js +1 -0
- package/public/assets/Settings-BVWJvOkU.js +1 -0
- package/public/assets/Setup-X-lzuaUT.js +1 -0
- package/public/assets/WeixinLoginPanel-gca0QTic.js +9 -0
- package/public/assets/index-C8B0cFJM.js +19 -0
- package/public/assets/index-CPhVFEsx.css +1 -0
- package/public/assets/input-paste-CrNVAyOy.js +1 -0
- package/public/assets/{providers-lBSOjUWy.js → providers-V-vwrExZ.js} +1 -1
- package/public/assets/registry-fVUSujib.js +2 -0
- package/public/assets/{usePolling-CK0DfI4h.js → usePolling-Do5Erqm_.js} +1 -1
- package/public/assets/vendor-i18n-ucpM0OR0.js +9 -0
- package/public/assets/{vendor-react-B1-3Yrt-.js → vendor-react-Bk1hRGiY.js} +1 -1
- package/public/favicon.png +0 -0
- package/public/index.html +9 -4
- package/public/logos/hermes.png +0 -0
- package/public/logos/ollama.png +0 -0
- package/public/logos/openclaw.svg +60 -0
- package/scripts/build-hermes-image.sh +21 -0
- package/scripts/build-local.sh +54 -0
- package/scripts/check-adapter-isolation.ts +293 -0
- package/scripts/fixtures/instances/hermes-sample/instance.json +37 -0
- package/scripts/fixtures/instances/legacy-openclaw-sample/instance.json +7 -0
- package/scripts/smoke/hermes-bootstrap.sh +195 -0
- package/templates/hermes-entrypoint.sh +154 -0
- package/dist/doctor.js.map +0 -1
- package/install/jishu-install-china.sh +0 -3092
- package/public/assets/Dashboard-DhsrzJ4F.js +0 -1
- package/public/assets/InitPassword-BjubiVdd.js +0 -1
- package/public/assets/InstanceDetail-DMcywsof.js +0 -17
- package/public/assets/NewInstance-Bk0G4EiJ.js +0 -1
- package/public/assets/Settings-D5tHL_h5.js +0 -1
- package/public/assets/Setup-4t6E3Rut.js +0 -1
- package/public/assets/index-BJ47MWpF.css +0 -1
- package/public/assets/index-DbX85irc.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,20 +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
|
-
|
|
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.
|
|
145
573
|
const { getPanelConfig } = await import("../config.js");
|
|
146
574
|
const dp = getPanelConfig().default_provider;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
})();
|
|
152
585
|
meta.autoStarted = true;
|
|
153
586
|
}
|
|
154
|
-
|
|
587
|
+
const decorated = await augmentInstanceMetadata(instanceId, meta);
|
|
588
|
+
if (meta.autoStarted)
|
|
589
|
+
decorated.autoStarted = true;
|
|
590
|
+
return decorated;
|
|
155
591
|
}
|
|
156
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
|
+
}
|
|
157
603
|
return reply.status(409).send({ detail: e.message });
|
|
158
604
|
}
|
|
159
605
|
});
|
|
@@ -166,8 +612,12 @@ export async function instanceRoutes(app) {
|
|
|
166
612
|
const inst = instanceManager.getInstance(req.params.id);
|
|
167
613
|
if (!inst)
|
|
168
614
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
169
|
-
const status = await svc
|
|
170
|
-
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
|
+
};
|
|
171
621
|
});
|
|
172
622
|
// Update
|
|
173
623
|
app.put("/api/instances/:id", async (req, reply) => {
|
|
@@ -202,6 +652,15 @@ export async function instanceRoutes(app) {
|
|
|
202
652
|
catch (e) {
|
|
203
653
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
204
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
|
+
}
|
|
205
664
|
const svc = await getSvc();
|
|
206
665
|
let stopFailed = false;
|
|
207
666
|
try {
|
|
@@ -227,7 +686,7 @@ export async function instanceRoutes(app) {
|
|
|
227
686
|
statusCache.delete(req.params.id);
|
|
228
687
|
llmProxy.cleanupInstance(req.params.id);
|
|
229
688
|
const purgeBackups = req.query.purge_backups === "true";
|
|
230
|
-
const result = instanceManager.deleteInstance(req.params.id, purgeBackups);
|
|
689
|
+
const result = await instanceManager.deleteInstance(req.params.id, purgeBackups);
|
|
231
690
|
if (!result.ok && result.warnings?.some(w => w.includes("not found"))) {
|
|
232
691
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
233
692
|
}
|
|
@@ -239,10 +698,39 @@ export async function instanceRoutes(app) {
|
|
|
239
698
|
return { ok: result.ok, warnings: warnings.length ? warnings : undefined };
|
|
240
699
|
});
|
|
241
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
|
+
});
|
|
242
710
|
app.get("/api/instances/:id/config", async (req, reply) => {
|
|
243
711
|
const idErr = validateId(req.params.id);
|
|
244
712
|
if (idErr)
|
|
245
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
|
+
}
|
|
246
734
|
const config = llmProxy.getInstanceConfig(req.params.id);
|
|
247
735
|
if (!config)
|
|
248
736
|
return reply.status(404).send({ detail: "Instance or config not found" });
|
|
@@ -252,17 +740,39 @@ export async function instanceRoutes(app) {
|
|
|
252
740
|
const idErr = validateId(req.params.id);
|
|
253
741
|
if (idErr)
|
|
254
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);
|
|
255
747
|
// Basic payload validation
|
|
256
748
|
const body = req.body;
|
|
257
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
258
|
-
return reply.status(400).send({ detail: "Config must be a JSON object" });
|
|
259
|
-
}
|
|
260
749
|
const bodyStr = JSON.stringify(body);
|
|
261
750
|
if (bodyStr.length > 512 * 1024) {
|
|
262
751
|
return reply.status(400).send({ detail: "Config too large (max 512KB)" });
|
|
263
752
|
}
|
|
264
753
|
try {
|
|
265
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
|
+
}
|
|
266
776
|
const saved = await llmProxy.saveInstanceConfig(req.params.id, body);
|
|
267
777
|
return { ok: true, config: saved };
|
|
268
778
|
}
|
|
@@ -279,9 +789,12 @@ export async function instanceRoutes(app) {
|
|
|
279
789
|
if (idErr)
|
|
280
790
|
return reply.status(400).send({ detail: idErr });
|
|
281
791
|
const svc = await getSvc();
|
|
282
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
792
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
283
793
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
284
794
|
}
|
|
795
|
+
if (getInstanceBackedInstalledApp(req.params.id)) {
|
|
796
|
+
return instanceManager.getAppStatus(req.params.id);
|
|
797
|
+
}
|
|
285
798
|
return svc.getStatus(req.params.id);
|
|
286
799
|
});
|
|
287
800
|
app.post("/api/instances/:id/service/start", async (req, reply) => {
|
|
@@ -295,13 +808,28 @@ export async function instanceRoutes(app) {
|
|
|
295
808
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
296
809
|
}
|
|
297
810
|
const svc = await getSvc();
|
|
298
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
811
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
299
812
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
300
813
|
}
|
|
301
|
-
const result =
|
|
814
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
815
|
+
? await instanceManager.startApp(req.params.id)
|
|
816
|
+
: await svc.startInstance(req.params.id);
|
|
302
817
|
statusCache.delete(req.params.id);
|
|
303
|
-
if (!result.ok)
|
|
304
|
-
|
|
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
|
+
}
|
|
305
833
|
return result;
|
|
306
834
|
});
|
|
307
835
|
app.post("/api/instances/:id/service/stop", async (req, reply) => {
|
|
@@ -315,10 +843,12 @@ export async function instanceRoutes(app) {
|
|
|
315
843
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
316
844
|
}
|
|
317
845
|
const svc = await getSvc();
|
|
318
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
846
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
319
847
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
320
848
|
}
|
|
321
|
-
const result =
|
|
849
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
850
|
+
? await instanceManager.stopApp(req.params.id)
|
|
851
|
+
: await svc.stopInstance(req.params.id);
|
|
322
852
|
statusCache.delete(req.params.id);
|
|
323
853
|
if (!result.ok)
|
|
324
854
|
return reply.status(400).send({ detail: result.error });
|
|
@@ -335,370 +865,17 @@ export async function instanceRoutes(app) {
|
|
|
335
865
|
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
336
866
|
}
|
|
337
867
|
const svc = await getSvc();
|
|
338
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
868
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
339
869
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
340
870
|
}
|
|
341
|
-
const result =
|
|
871
|
+
const result = getInstanceBackedInstalledApp(req.params.id)
|
|
872
|
+
? await instanceManager.restartApp(req.params.id)
|
|
873
|
+
: await svc.restartInstance(req.params.id);
|
|
342
874
|
statusCache.delete(req.params.id);
|
|
343
875
|
if (!result.ok)
|
|
344
876
|
return reply.status(400).send({ detail: result.error || "Unknown error" });
|
|
345
877
|
return result;
|
|
346
878
|
});
|
|
347
|
-
// Plugin check & install — host-side, no container dependency
|
|
348
|
-
app.get("/api/instances/:id/plugins/check/:channelId", async (req, reply) => {
|
|
349
|
-
const idErr = validateId(req.params.id);
|
|
350
|
-
if (idErr)
|
|
351
|
-
return reply.status(400).send({ detail: idErr });
|
|
352
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
353
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
354
|
-
}
|
|
355
|
-
return {
|
|
356
|
-
channelId: req.params.channelId,
|
|
357
|
-
installed: instanceManager.isChannelPluginInstalled(req.params.id, req.params.channelId),
|
|
358
|
-
};
|
|
359
|
-
});
|
|
360
|
-
// Plugin status for all tracked IM plugins (feishu, openclaw-weixin)
|
|
361
|
-
app.get("/api/instances/:id/plugins/status", async (req, reply) => {
|
|
362
|
-
const idErr = validateId(req.params.id);
|
|
363
|
-
if (idErr)
|
|
364
|
-
return reply.status(400).send({ detail: idErr });
|
|
365
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
366
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
367
|
-
}
|
|
368
|
-
return { plugins: pluginInstaller.getAllPluginStatuses(req.params.id) };
|
|
369
|
-
});
|
|
370
|
-
// Quick status: IM binding + skill install state for the quick-skill panel
|
|
371
|
-
app.get("/api/instances/:id/quick-status", async (req, reply) => {
|
|
372
|
-
const idErr = validateId(req.params.id);
|
|
373
|
-
if (idErr)
|
|
374
|
-
return reply.status(400).send({ detail: idErr });
|
|
375
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
376
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
377
|
-
}
|
|
378
|
-
const id = req.params.id;
|
|
379
|
-
const cfg = instanceManager.getConfig(id) ?? {};
|
|
380
|
-
const channels = cfg.channels ?? {};
|
|
381
|
-
// IM binding: channel must be enabled AND have a non-empty token / accounts
|
|
382
|
-
const feishuCh = channels["feishu"] ?? channels["lark"] ?? {};
|
|
383
|
-
const weixinCh = channels["openclaw-weixin"] ?? {};
|
|
384
|
-
const feishuBound = !!(feishuCh.enabled && (feishuCh.appId || feishuCh.token || feishuCh.deviceToken || feishuCh.accessToken));
|
|
385
|
-
const weixinBound = !!(weixinCh.enabled && weixinCh.accounts && Object.keys(weixinCh.accounts).length > 0);
|
|
386
|
-
// Skill install state: scan workspace/skills/ and return all directory names
|
|
387
|
-
const { readdirSync: fsReaddir, existsSync: fsExists, readFileSync: fsRead } = await import("fs");
|
|
388
|
-
const { join: fsJoin } = await import("path");
|
|
389
|
-
const workspaceDir = fsJoin(instanceManager.getOpenclawHome(id), ".openclaw", "workspace");
|
|
390
|
-
const stateDir = fsJoin(workspaceDir, "skills");
|
|
391
|
-
let installedSkillDirs = [];
|
|
392
|
-
try {
|
|
393
|
-
installedSkillDirs = fsReaddir(stateDir, { withFileTypes: true })
|
|
394
|
-
.filter(e => e.isDirectory())
|
|
395
|
-
.map(e => e.name);
|
|
396
|
-
}
|
|
397
|
-
catch { }
|
|
398
|
-
// MCPorter install state — mcporter is installed as a skill in workspace/skills/mcporter
|
|
399
|
-
const mcporterInstalled = installedSkillDirs.some(d => d.toLowerCase() === 'mcporter');
|
|
400
|
-
// MCPorter configured servers
|
|
401
|
-
let mcporterServers = {};
|
|
402
|
-
const mcporterCfgPath = fsJoin(workspaceDir, "config", "mcporter.json");
|
|
403
|
-
try {
|
|
404
|
-
const raw = JSON.parse(fsRead(mcporterCfgPath, "utf8"));
|
|
405
|
-
mcporterServers = raw.mcpServers ?? {};
|
|
406
|
-
}
|
|
407
|
-
catch { }
|
|
408
|
-
return {
|
|
409
|
-
im: {
|
|
410
|
-
feishu: feishuBound,
|
|
411
|
-
weixin: weixinBound,
|
|
412
|
-
},
|
|
413
|
-
installedSkillDirs,
|
|
414
|
-
mcporterInstalled,
|
|
415
|
-
mcporterServers,
|
|
416
|
-
};
|
|
417
|
-
});
|
|
418
|
-
// Run `mcporter list --json` and return live server status
|
|
419
|
-
app.get("/api/instances/:id/mcporter/list", async (req, reply) => {
|
|
420
|
-
const idErr = validateId(req.params.id);
|
|
421
|
-
if (idErr)
|
|
422
|
-
return reply.status(400).send({ detail: idErr });
|
|
423
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
424
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
425
|
-
}
|
|
426
|
-
const id = req.params.id;
|
|
427
|
-
const openclawHome = instanceManager.getOpenclawHome(id);
|
|
428
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
429
|
-
const mcporterBinPath = join(workspaceDir, ".npm-global", "bin", "mcporter");
|
|
430
|
-
const mcporterCfg = join(workspaceDir, "config", "mcporter.json");
|
|
431
|
-
if (!existsSync(mcporterBinPath)) {
|
|
432
|
-
return { servers: [], installed: false };
|
|
433
|
-
}
|
|
434
|
-
const { execFile } = await import("child_process");
|
|
435
|
-
const { promisify } = await import("util");
|
|
436
|
-
const execFileAsync = promisify(execFile);
|
|
437
|
-
try {
|
|
438
|
-
const { stdout } = await execFileAsync(mcporterBinPath, ["list", "--json"], {
|
|
439
|
-
env: { ...process.env, HOME: openclawHome, MCPORTER_CONFIG: mcporterCfg },
|
|
440
|
-
timeout: 60_000,
|
|
441
|
-
});
|
|
442
|
-
const parsed = JSON.parse(stdout);
|
|
443
|
-
return { servers: parsed.servers ?? [], installed: true };
|
|
444
|
-
}
|
|
445
|
-
catch (err) {
|
|
446
|
-
// execFile throws if exit code != 0; stdout may still have partial JSON
|
|
447
|
-
const raw = err?.stdout ?? "";
|
|
448
|
-
try {
|
|
449
|
-
const parsed = JSON.parse(raw);
|
|
450
|
-
return { servers: parsed.servers ?? [], installed: true };
|
|
451
|
-
}
|
|
452
|
-
catch { }
|
|
453
|
-
return { servers: [], installed: true, error: err?.message ?? "unknown" };
|
|
454
|
-
}
|
|
455
|
-
});
|
|
456
|
-
// Merge servers into mcporter.json
|
|
457
|
-
app.post("/api/instances/:id/mcporter/add", async (req, reply) => {
|
|
458
|
-
const idErr = validateId(req.params.id);
|
|
459
|
-
if (idErr)
|
|
460
|
-
return reply.status(400).send({ detail: idErr });
|
|
461
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
462
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
463
|
-
}
|
|
464
|
-
const { servers } = req.body;
|
|
465
|
-
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
466
|
-
return reply.status(400).send({ detail: "servers must be an object" });
|
|
467
|
-
}
|
|
468
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
469
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
470
|
-
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
471
|
-
const { readFileSync } = await import("fs");
|
|
472
|
-
let cfg = { mcpServers: {}, imports: [] };
|
|
473
|
-
try {
|
|
474
|
-
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
475
|
-
}
|
|
476
|
-
catch { }
|
|
477
|
-
if (!cfg.mcpServers)
|
|
478
|
-
cfg.mcpServers = {};
|
|
479
|
-
// Explicit key-by-key copy instead of Object.assign to prevent prototype pollution:
|
|
480
|
-
// a crafted body with "__proto__" or "constructor" keys could corrupt the object prototype.
|
|
481
|
-
const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
482
|
-
for (const [k, v] of Object.entries(servers)) {
|
|
483
|
-
if (!PROTO_KEYS.has(k))
|
|
484
|
-
cfg.mcpServers[k] = v;
|
|
485
|
-
}
|
|
486
|
-
try {
|
|
487
|
-
ensureDirContainer(join(workspaceDir, "config"));
|
|
488
|
-
writeConfigFile(mcporterCfgPath, JSON.stringify(cfg, null, 2));
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
492
|
-
}
|
|
493
|
-
return { ok: true, mcpServers: cfg.mcpServers };
|
|
494
|
-
});
|
|
495
|
-
// Remove a server from mcporter.json
|
|
496
|
-
app.delete("/api/instances/:id/mcporter/:serverName", async (req, reply) => {
|
|
497
|
-
const idErr = validateId(req.params.id);
|
|
498
|
-
if (idErr)
|
|
499
|
-
return reply.status(400).send({ detail: idErr });
|
|
500
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
501
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
502
|
-
}
|
|
503
|
-
const { serverName } = req.params;
|
|
504
|
-
if (!serverName || typeof serverName !== "string") {
|
|
505
|
-
return reply.status(400).send({ detail: "serverName is required" });
|
|
506
|
-
}
|
|
507
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
508
|
-
const workspaceDir = join(openclawHome, ".openclaw", "workspace");
|
|
509
|
-
const mcporterCfgPath = join(workspaceDir, "config", "mcporter.json");
|
|
510
|
-
const { readFileSync } = await import("fs");
|
|
511
|
-
let cfg = { mcpServers: {}, imports: [] };
|
|
512
|
-
try {
|
|
513
|
-
cfg = JSON.parse(readFileSync(mcporterCfgPath, "utf8"));
|
|
514
|
-
}
|
|
515
|
-
catch { }
|
|
516
|
-
if (!cfg.mcpServers || !(serverName in cfg.mcpServers)) {
|
|
517
|
-
return reply.status(404).send({ detail: `Server '${serverName}' not found` });
|
|
518
|
-
}
|
|
519
|
-
delete cfg.mcpServers[serverName];
|
|
520
|
-
try {
|
|
521
|
-
writeConfigFile(mcporterCfgPath, JSON.stringify(cfg, null, 2));
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
return reply.status(500).send({ detail: `Write failed: ${err.message}` });
|
|
525
|
-
}
|
|
526
|
-
return { ok: true, mcpServers: cfg.mcpServers };
|
|
527
|
-
});
|
|
528
|
-
// Delete a skill directory from workspace/skills/
|
|
529
|
-
app.delete("/api/instances/:id/skills/:skillDir", async (req, reply) => {
|
|
530
|
-
const idErr = validateId(req.params.id);
|
|
531
|
-
if (idErr)
|
|
532
|
-
return reply.status(400).send({ detail: idErr });
|
|
533
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
534
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
535
|
-
}
|
|
536
|
-
const { skillDir } = req.params;
|
|
537
|
-
// Prevent path traversal
|
|
538
|
-
if (!skillDir || skillDir.includes("/") || skillDir.includes("..") || skillDir.startsWith(".")) {
|
|
539
|
-
return reply.status(400).send({ detail: "Invalid skill directory name" });
|
|
540
|
-
}
|
|
541
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
542
|
-
const skillPath = join(openclawHome, ".openclaw", "workspace", "skills", skillDir);
|
|
543
|
-
const { existsSync: fsEx, rmSync } = await import("fs");
|
|
544
|
-
if (!fsEx(skillPath)) {
|
|
545
|
-
return reply.status(404).send({ detail: `Skill '${skillDir}' not found` });
|
|
546
|
-
}
|
|
547
|
-
try {
|
|
548
|
-
rmSync(skillPath, { recursive: true, force: true });
|
|
549
|
-
}
|
|
550
|
-
catch (err) {
|
|
551
|
-
return reply.status(500).send({ detail: `Delete failed: ${err.message}` });
|
|
552
|
-
}
|
|
553
|
-
return { ok: true };
|
|
554
|
-
});
|
|
555
|
-
app.post("/api/instances/:id/plugins/install", async (req, reply) => {
|
|
556
|
-
const idErr = validateId(req.params.id);
|
|
557
|
-
if (idErr)
|
|
558
|
-
return reply.status(400).send({ detail: idErr });
|
|
559
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
560
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
561
|
-
}
|
|
562
|
-
try {
|
|
563
|
-
assertNotLocked(req.params.id);
|
|
564
|
-
}
|
|
565
|
-
catch (e) {
|
|
566
|
-
return reply.status(e.statusCode || 409).send({ detail: e.message });
|
|
567
|
-
}
|
|
568
|
-
const { channelId } = req.body;
|
|
569
|
-
if (!channelId || typeof channelId !== "string") {
|
|
570
|
-
return reply.status(400).send({ detail: "channelId is required" });
|
|
571
|
-
}
|
|
572
|
-
const pkg = instanceManager.CHANNEL_PLUGIN_MAP[channelId];
|
|
573
|
-
if (!pkg) {
|
|
574
|
-
return reply.status(400).send({ detail: `Unknown channel: ${channelId}` });
|
|
575
|
-
}
|
|
576
|
-
const pStatus = pluginInstaller.getPluginStatus(req.params.id, channelId);
|
|
577
|
-
if (pStatus.status === "installed")
|
|
578
|
-
return { ok: true, status: "already_installed" };
|
|
579
|
-
if (pStatus.status === "installing")
|
|
580
|
-
return { ok: true, status: "installing" };
|
|
581
|
-
pluginInstaller.enqueueInstall(req.params.id, channelId);
|
|
582
|
-
return { ok: true, status: "queued" };
|
|
583
|
-
});
|
|
584
|
-
// ── Helper: ensure a channel plugin is installed (check-only) ──
|
|
585
|
-
async function ensurePluginInstalled(instanceId, channelId) {
|
|
586
|
-
if (!instanceManager.CHANNEL_PLUGIN_MAP[channelId])
|
|
587
|
-
return;
|
|
588
|
-
if (!instanceManager.isChannelPluginInstalled(instanceId, channelId)) {
|
|
589
|
-
throw new Error(`Plugin ${channelId} is not installed. Please install it from the config page.`);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
// ── Feishu/Lark OAuth Device Code Login ──
|
|
593
|
-
const FEISHU_AUTH_URL = "https://accounts.feishu.cn";
|
|
594
|
-
const MAX_LOGIN_SESSIONS = 100;
|
|
595
|
-
const feishuLogins = new Map();
|
|
596
|
-
app.post("/api/instances/:id/feishu/login", async (req, reply) => {
|
|
597
|
-
const channelKey = req.body?.channelKey || "feishu";
|
|
598
|
-
const idErr = validateId(req.params.id);
|
|
599
|
-
if (idErr)
|
|
600
|
-
return reply.status(400).send({ detail: idErr });
|
|
601
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
602
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
603
|
-
}
|
|
604
|
-
// Require instance to be running
|
|
605
|
-
const svc = await getSvc();
|
|
606
|
-
const svcStatus = await svc.getStatus(req.params.id);
|
|
607
|
-
if (svcStatus.status !== "running") {
|
|
608
|
-
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
609
|
-
}
|
|
610
|
-
// Auto-install feishu plugin if not present
|
|
611
|
-
await ensurePluginInstalled(req.params.id, channelKey);
|
|
612
|
-
try {
|
|
613
|
-
// Step 1: init
|
|
614
|
-
await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
615
|
-
method: "POST",
|
|
616
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
617
|
-
body: "action=init",
|
|
618
|
-
signal: AbortSignal.timeout(30_000),
|
|
619
|
-
});
|
|
620
|
-
// Step 2: begin — get QR code URL and device code
|
|
621
|
-
const beginResp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
622
|
-
method: "POST",
|
|
623
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
624
|
-
body: "action=begin&archetype=PersonalAgent&auth_method=client_secret&request_user_info=open_id",
|
|
625
|
-
signal: AbortSignal.timeout(30_000),
|
|
626
|
-
});
|
|
627
|
-
if (!beginResp.ok)
|
|
628
|
-
throw new Error(`Feishu API error: ${beginResp.status}`);
|
|
629
|
-
const beginData = await beginResp.json();
|
|
630
|
-
const sessionKey = `${req.params.id}-${channelKey}-${Date.now()}`;
|
|
631
|
-
feishuLogins.set(sessionKey, {
|
|
632
|
-
instanceId: req.params.id,
|
|
633
|
-
deviceCode: beginData.device_code,
|
|
634
|
-
startedAt: Date.now(),
|
|
635
|
-
interval: beginData.interval || 5,
|
|
636
|
-
expireIn: beginData.expire_in || 600,
|
|
637
|
-
channelKey,
|
|
638
|
-
});
|
|
639
|
-
// Purge expired + enforce cap
|
|
640
|
-
for (const [k, v] of feishuLogins) {
|
|
641
|
-
if (Date.now() - v.startedAt > v.expireIn * 1000)
|
|
642
|
-
feishuLogins.delete(k);
|
|
643
|
-
}
|
|
644
|
-
while (feishuLogins.size > MAX_LOGIN_SESSIONS) {
|
|
645
|
-
feishuLogins.delete(feishuLogins.keys().next().value);
|
|
646
|
-
}
|
|
647
|
-
return { qrcodeUrl: beginData.verification_uri_complete, sessionKey };
|
|
648
|
-
}
|
|
649
|
-
catch (e) {
|
|
650
|
-
return reply.status(502).send({ detail: e.message || "Failed to start Feishu login" });
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
app.get("/api/instances/:id/feishu/login/:sessionKey", async (req, reply) => {
|
|
654
|
-
const idErr = validateId(req.params.id);
|
|
655
|
-
if (idErr)
|
|
656
|
-
return reply.status(400).send({ detail: idErr });
|
|
657
|
-
const login = feishuLogins.get(req.params.sessionKey);
|
|
658
|
-
if (!login)
|
|
659
|
-
return reply.status(404).send({ detail: "Login session not found or expired" });
|
|
660
|
-
if (login.instanceId !== req.params.id)
|
|
661
|
-
return reply.status(403).send({ detail: "Session belongs to a different instance" });
|
|
662
|
-
if (Date.now() - login.startedAt > login.expireIn * 1000) {
|
|
663
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
664
|
-
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
665
|
-
}
|
|
666
|
-
try {
|
|
667
|
-
const resp = await fetch(`${FEISHU_AUTH_URL}/oauth/v1/app/registration`, {
|
|
668
|
-
method: "POST",
|
|
669
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
670
|
-
body: `action=poll&device_code=${encodeURIComponent(login.deviceCode)}`,
|
|
671
|
-
signal: AbortSignal.timeout(10_000),
|
|
672
|
-
});
|
|
673
|
-
const data = await resp.json();
|
|
674
|
-
if (data.client_id && data.client_secret) {
|
|
675
|
-
const storedChannelKey = login.channelKey || "feishu";
|
|
676
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
677
|
-
const domain = data.user_info?.tenant_brand === "lark" ? "lark" : "feishu";
|
|
678
|
-
instanceManager.saveFeishuCredentials(req.params.id, {
|
|
679
|
-
appId: data.client_id,
|
|
680
|
-
appSecret: data.client_secret,
|
|
681
|
-
domain,
|
|
682
|
-
channelKey: storedChannelKey,
|
|
683
|
-
});
|
|
684
|
-
return {
|
|
685
|
-
status: "confirmed", connected: true, domain,
|
|
686
|
-
message: domain === "lark" ? "Lark bot configured!" : "Feishu bot configured!",
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
if (data.error === "authorization_pending") {
|
|
690
|
-
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
691
|
-
}
|
|
692
|
-
if (data.error === "expired_token") {
|
|
693
|
-
feishuLogins.delete(req.params.sessionKey);
|
|
694
|
-
return { status: "expired", connected: false, message: "QR code expired, please regenerate." };
|
|
695
|
-
}
|
|
696
|
-
return { status: "waiting", connected: false, message: "Waiting for scan..." };
|
|
697
|
-
}
|
|
698
|
-
catch (e) {
|
|
699
|
-
return reply.status(502).send({ detail: e.message || "Poll failed" });
|
|
700
|
-
}
|
|
701
|
-
});
|
|
702
879
|
// ── Pairing ──────────────────────────────────────────────────────────────
|
|
703
880
|
// Pairing codes are uppercase alphanumeric, 4-16 chars (e.g. LVU7PNYK).
|
|
704
881
|
const PAIRING_CODE_RE = /^[A-Z0-9]{4,16}$/;
|
|
@@ -708,20 +885,33 @@ export async function instanceRoutes(app) {
|
|
|
708
885
|
const idErr = validateId(req.params.id);
|
|
709
886
|
if (idErr)
|
|
710
887
|
return reply.status(400).send({ detail: idErr });
|
|
711
|
-
|
|
888
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
889
|
+
if (!inst) {
|
|
712
890
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
713
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);
|
|
714
897
|
const svc = await getSvc();
|
|
715
|
-
|
|
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);
|
|
716
901
|
return { output: result.stdout + result.stderr, exitCode: result.exitCode };
|
|
717
902
|
});
|
|
718
903
|
app.post("/api/instances/:id/pairing/approve", async (req, reply) => {
|
|
719
904
|
const idErr = validateId(req.params.id);
|
|
720
905
|
if (idErr)
|
|
721
906
|
return reply.status(400).send({ detail: idErr });
|
|
722
|
-
|
|
907
|
+
const inst = instanceManager.getInstance(req.params.id);
|
|
908
|
+
if (!inst) {
|
|
723
909
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
724
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
|
+
}
|
|
725
915
|
const { channel, code, notify } = req.body ?? {};
|
|
726
916
|
if (!channel || !PAIRING_CHANNEL_RE.test(channel)) {
|
|
727
917
|
return reply.status(400).send({ detail: "Invalid channel: must be lowercase alphanumeric/hyphen/underscore" });
|
|
@@ -729,9 +919,12 @@ export async function instanceRoutes(app) {
|
|
|
729
919
|
if (!code || !PAIRING_CODE_RE.test(code)) {
|
|
730
920
|
return reply.status(400).send({ detail: "Invalid pairing code: must be 4-16 uppercase alphanumeric characters" });
|
|
731
921
|
}
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
922
|
+
const agentType = resolveAgentType(inst);
|
|
923
|
+
const cmd = await getAdapter(agentType).buildPairingApproveCommand(req.params.id, {
|
|
924
|
+
channel,
|
|
925
|
+
code,
|
|
926
|
+
notify,
|
|
927
|
+
});
|
|
735
928
|
const svc = await getSvc();
|
|
736
929
|
const result = await svc.exec(req.params.id, cmd, 15_000);
|
|
737
930
|
if (result.exitCode !== 0) {
|
|
@@ -739,251 +932,259 @@ export async function instanceRoutes(app) {
|
|
|
739
932
|
}
|
|
740
933
|
return { ok: true, output: (result.stdout + result.stderr).trim() };
|
|
741
934
|
});
|
|
742
|
-
//
|
|
743
|
-
|
|
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) => {
|
|
744
957
|
const idErr = validateId(req.params.id);
|
|
745
958
|
if (idErr)
|
|
746
959
|
return reply.status(400).send({ detail: idErr });
|
|
747
|
-
|
|
960
|
+
const rawInst = instanceManager.getInstance(req.params.id);
|
|
961
|
+
if (!rawInst)
|
|
748
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 */ }
|
|
749
1080
|
}
|
|
750
|
-
return { accounts: instanceManager.getWeixinAccounts(req.params.id) };
|
|
751
1081
|
});
|
|
752
|
-
|
|
753
|
-
const WEIXIN_API_BASE = "https://ilinkai.weixin.qq.com";
|
|
754
|
-
const WEIXIN_BOT_TYPE = "3";
|
|
755
|
-
// In-memory active login sessions (short-lived, 5 min TTL)
|
|
756
|
-
const weixinLogins = new Map();
|
|
757
|
-
app.post("/api/instances/:id/weixin/login", async (req, reply) => {
|
|
1082
|
+
app.post("/api/instances/:id/provides/:capability/terminal/session", async (req, reply) => {
|
|
758
1083
|
const idErr = validateId(req.params.id);
|
|
759
1084
|
if (idErr)
|
|
760
1085
|
return reply.status(400).send({ detail: idErr });
|
|
761
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
1086
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
762
1087
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
763
1088
|
}
|
|
764
|
-
// Require instance to be running
|
|
765
|
-
const svc = await getSvc();
|
|
766
|
-
const svcStatus = await svc.getStatus(req.params.id);
|
|
767
|
-
if (svcStatus.status !== "running") {
|
|
768
|
-
return reply.status(400).send({ detail: "Instance must be running first" });
|
|
769
|
-
}
|
|
770
|
-
// Auto-install weixin plugin if not present
|
|
771
|
-
await ensurePluginInstalled(req.params.id, "openclaw-weixin");
|
|
772
1089
|
try {
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
const
|
|
778
|
-
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({
|
|
779
1095
|
instanceId: req.params.id,
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
1096
|
+
capability: req.params.capability,
|
|
1097
|
+
terminal,
|
|
1098
|
+
command,
|
|
783
1099
|
});
|
|
784
|
-
|
|
785
|
-
for (const [k, v] of weixinLogins) {
|
|
786
|
-
if (Date.now() - v.startedAt > 5 * 60_000)
|
|
787
|
-
weixinLogins.delete(k);
|
|
788
|
-
}
|
|
789
|
-
while (weixinLogins.size > MAX_LOGIN_SESSIONS) {
|
|
790
|
-
weixinLogins.delete(weixinLogins.keys().next().value);
|
|
791
|
-
}
|
|
792
|
-
return { qrcodeUrl: data.qrcode_img_content, sessionKey };
|
|
1100
|
+
return reply.send(session);
|
|
793
1101
|
}
|
|
794
|
-
catch (
|
|
795
|
-
return reply.status(
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
return reply.status(400).send({ detail: error?.message || "Failed to start terminal session" });
|
|
796
1104
|
}
|
|
797
1105
|
});
|
|
798
|
-
app.get("/api/instances/:id/
|
|
1106
|
+
app.get("/api/instances/:id/provides/:capability/terminal/session/:sessionId/stream", async (req, reply) => {
|
|
799
1107
|
const idErr = validateId(req.params.id);
|
|
800
1108
|
if (idErr)
|
|
801
1109
|
return reply.status(400).send({ detail: idErr });
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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);
|
|
810
1130
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
token: data.bot_token || "",
|
|
823
|
-
baseUrl: data.baseurl || WEIXIN_API_BASE,
|
|
824
|
-
userId: data.ilink_user_id || "",
|
|
825
|
-
});
|
|
826
|
-
}
|
|
827
|
-
catch (e) {
|
|
828
|
-
console.error(`[weixin-login] Failed to save credentials: ${e.message}`);
|
|
829
|
-
return reply.status(500).send({
|
|
830
|
-
status: "confirmed", connected: false,
|
|
831
|
-
detail: "WeChat authenticated but failed to save credentials: " + e.message,
|
|
832
|
-
});
|
|
833
|
-
}
|
|
834
|
-
return {
|
|
835
|
-
status: "confirmed", connected: true,
|
|
836
|
-
accountId: data.ilink_bot_id,
|
|
837
|
-
message: "WeChat connected!",
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
if (data.status === "expired") {
|
|
841
|
-
// Auto-refresh QR
|
|
842
|
-
try {
|
|
843
|
-
const refreshResp = await fetch(`${WEIXIN_API_BASE}/ilink/bot/get_bot_qrcode?bot_type=${WEIXIN_BOT_TYPE}`, { signal: AbortSignal.timeout(30_000) });
|
|
844
|
-
if (refreshResp.ok) {
|
|
845
|
-
const refreshData = await refreshResp.json();
|
|
846
|
-
login.qrcode = refreshData.qrcode;
|
|
847
|
-
login.qrcodeUrl = refreshData.qrcode_img_content;
|
|
848
|
-
login.startedAt = Date.now();
|
|
849
|
-
return {
|
|
850
|
-
status: "refreshed", connected: false,
|
|
851
|
-
qrcodeUrl: refreshData.qrcode_img_content,
|
|
852
|
-
message: "QR code refreshed, please scan again.",
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
catch { /* fall through */ }
|
|
857
|
-
weixinLogins.delete(req.params.sessionKey);
|
|
858
|
-
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();
|
|
859
1142
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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" });
|
|
864
1155
|
}
|
|
865
|
-
|
|
866
|
-
|
|
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" });
|
|
867
1162
|
}
|
|
868
1163
|
});
|
|
869
|
-
|
|
870
|
-
app.get("/api/instances/:id/usage", async (req, reply) => {
|
|
1164
|
+
app.post("/api/instances/:id/provides/:capability/terminal/session/:sessionId/stop", async (req, reply) => {
|
|
871
1165
|
const idErr = validateId(req.params.id);
|
|
872
1166
|
if (idErr)
|
|
873
1167
|
return reply.status(400).send({ detail: idErr });
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const openclawHome = instanceManager.getOpenclawHome(req.params.id);
|
|
878
|
-
const sessionsIndex = join(openclawHome, ".openclaw", "agents", "main", "sessions", "sessions.json");
|
|
879
|
-
const emptyTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
880
|
-
if (!existsSync(sessionsIndex))
|
|
881
|
-
return { sessions: [], totals: emptyTotals };
|
|
882
|
-
// Check file size before reading
|
|
883
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
884
|
-
const indexStat = await stat(sessionsIndex);
|
|
885
|
-
if (indexStat.size > MAX_FILE_SIZE) {
|
|
886
|
-
return reply.status(400).send({ detail: "sessions.json exceeds 10MB size limit" });
|
|
887
|
-
}
|
|
888
|
-
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
|
+
}
|
|
889
1171
|
try {
|
|
890
|
-
|
|
891
|
-
|
|
1172
|
+
stopTerminalSession(req.params.sessionId);
|
|
1173
|
+
return reply.send({ ok: true });
|
|
892
1174
|
}
|
|
893
|
-
catch {
|
|
894
|
-
return reply.status(
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
return reply.status(400).send({ detail: error?.message || "Failed to stop terminal session" });
|
|
895
1177
|
}
|
|
896
|
-
const sessions = [];
|
|
897
|
-
const totals = { ...emptyTotals };
|
|
898
|
-
for (const [sessionKey, sessionMeta] of Object.entries(sessionsMap)) {
|
|
899
|
-
const sessionFile = sessionMeta?.sessionFile;
|
|
900
|
-
if (!sessionFile || !existsSync(sessionFile))
|
|
901
|
-
continue;
|
|
902
|
-
// Prevent path traversal: sessionFile must be under the instance's openclaw home.
|
|
903
|
-
// Use realpathSync to resolve symlinks and prevent symlink-based bypasses.
|
|
904
|
-
try {
|
|
905
|
-
const resolvedSession = realpathSync(sessionFile);
|
|
906
|
-
const resolvedHome = realpathSync(openclawHome);
|
|
907
|
-
if (!resolvedSession.startsWith(resolvedHome + "/") && resolvedSession !== resolvedHome)
|
|
908
|
-
continue;
|
|
909
|
-
}
|
|
910
|
-
catch {
|
|
911
|
-
continue;
|
|
912
|
-
}
|
|
913
|
-
// Check session file size before reading
|
|
914
|
-
let sessionStat;
|
|
915
|
-
try {
|
|
916
|
-
sessionStat = await stat(sessionFile);
|
|
917
|
-
}
|
|
918
|
-
catch {
|
|
919
|
-
continue;
|
|
920
|
-
}
|
|
921
|
-
if (sessionStat.size > MAX_FILE_SIZE)
|
|
922
|
-
continue;
|
|
923
|
-
const sessionUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, costTotal: 0, messages: 0 };
|
|
924
|
-
let model = "";
|
|
925
|
-
let firstTs = "";
|
|
926
|
-
let lastTs = "";
|
|
927
|
-
const originLabel = sessionMeta?.origin?.label || "";
|
|
928
|
-
const channel = sessionMeta?.origin?.provider || "";
|
|
929
|
-
let sessionContent;
|
|
930
|
-
try {
|
|
931
|
-
sessionContent = await readFile(sessionFile, "utf-8");
|
|
932
|
-
}
|
|
933
|
-
catch {
|
|
934
|
-
continue;
|
|
935
|
-
}
|
|
936
|
-
for (const line of sessionContent.split("\n")) {
|
|
937
|
-
let entry;
|
|
938
|
-
try {
|
|
939
|
-
entry = JSON.parse(line.trim());
|
|
940
|
-
}
|
|
941
|
-
catch {
|
|
942
|
-
continue;
|
|
943
|
-
}
|
|
944
|
-
if (entry.type !== "message")
|
|
945
|
-
continue;
|
|
946
|
-
const msg = entry.message || {};
|
|
947
|
-
const ts = entry.timestamp || "";
|
|
948
|
-
if (ts && !firstTs)
|
|
949
|
-
firstTs = ts;
|
|
950
|
-
if (ts)
|
|
951
|
-
lastTs = ts;
|
|
952
|
-
if (msg.role === "assistant") {
|
|
953
|
-
if (!model && msg.model)
|
|
954
|
-
model = msg.model;
|
|
955
|
-
const usage = msg.usage;
|
|
956
|
-
if (usage) {
|
|
957
|
-
sessionUsage.input += usage.input || 0;
|
|
958
|
-
sessionUsage.output += usage.output || 0;
|
|
959
|
-
sessionUsage.cacheRead += usage.cacheRead || 0;
|
|
960
|
-
sessionUsage.cacheWrite += usage.cacheWrite || 0;
|
|
961
|
-
sessionUsage.totalTokens += usage.totalTokens || 0;
|
|
962
|
-
if (typeof usage.cost === "object")
|
|
963
|
-
sessionUsage.costTotal += usage.cost.total || 0;
|
|
964
|
-
}
|
|
965
|
-
sessionUsage.messages++;
|
|
966
|
-
}
|
|
967
|
-
else if (msg.role === "user") {
|
|
968
|
-
sessionUsage.messages++;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
sessions.push({ key: sessionKey, model, channel, origin: originLabel, firstMessage: firstTs, lastMessage: lastTs, usage: sessionUsage });
|
|
972
|
-
for (const k of ["input", "output", "cacheRead", "cacheWrite", "totalTokens", "messages"]) {
|
|
973
|
-
totals[k] += sessionUsage[k];
|
|
974
|
-
}
|
|
975
|
-
totals.costTotal += sessionUsage.costTotal;
|
|
976
|
-
}
|
|
977
|
-
sessions.sort((a, b) => (b.lastMessage || "").localeCompare(a.lastMessage || ""));
|
|
978
|
-
return { sessions, totals };
|
|
979
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));
|
|
980
1181
|
// Logs
|
|
981
1182
|
app.get("/api/instances/:id/logs", async (req, reply) => {
|
|
982
1183
|
const idErr = validateId(req.params.id);
|
|
983
1184
|
if (idErr)
|
|
984
1185
|
return reply.status(400).send({ detail: idErr });
|
|
985
1186
|
const svc = await getSvc();
|
|
986
|
-
if (!instanceManager.getInstance(req.params.id)) {
|
|
1187
|
+
if (!instanceManager.getInstance(req.params.id) && !getInstanceBackedInstalledApp(req.params.id)) {
|
|
987
1188
|
return reply.status(404).send({ detail: "Instance not found" });
|
|
988
1189
|
}
|
|
989
1190
|
const logType = req.query.log_type || "stderr";
|
|
@@ -992,7 +1193,9 @@ export async function instanceRoutes(app) {
|
|
|
992
1193
|
}
|
|
993
1194
|
const MAX_LOG_LINES = 5000;
|
|
994
1195
|
const lines = Math.min(parseInt(req.query.lines || "100", 10) || 100, MAX_LOG_LINES);
|
|
995
|
-
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);
|
|
996
1199
|
return { lines: logLines };
|
|
997
1200
|
});
|
|
998
1201
|
// Admin: re-encrypt all instance secrets with current AES key
|
|
@@ -1070,274 +1273,60 @@ export async function instanceRoutes(app) {
|
|
|
1070
1273
|
}
|
|
1071
1274
|
return { ok: true, results };
|
|
1072
1275
|
});
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
}
|
|
1086
|
-
catch (err) {
|
|
1087
|
-
console.warn(`[gateway-launch] failed to add allowed origin for ${req.params.id}:`, err.message || err);
|
|
1088
|
-
}
|
|
1089
|
-
if (addedAllowedOrigin) {
|
|
1090
|
-
await restartRunningInstanceForControlUiOrigin(req.params.id, panelOrigin);
|
|
1091
|
-
}
|
|
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" });
|
|
1092
1288
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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 };
|
|
1098
1296
|
}
|
|
1099
|
-
return { url: `${baseUrl}#token=${encodeURIComponent(token.trim())}` };
|
|
1100
1297
|
});
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const inst = instanceManager.getInstance(id);
|
|
1110
|
-
if (!inst)
|
|
1111
|
-
return reply.status(404).send({ detail: "Instance not found" });
|
|
1112
|
-
const port = instanceManager.getGatewayPort(id);
|
|
1113
|
-
const gwHost = await instanceManager.getGatewayHost(id);
|
|
1114
|
-
const suffix = request.params["*"] || "";
|
|
1115
|
-
const qs = request.url.includes("?") ? request.url.slice(request.url.indexOf("?")) : "";
|
|
1116
|
-
// Raw HTTP proxy — stream the request body and preserve headers
|
|
1117
|
-
const targetUrl = `http://${gwHost}:${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
|
+
}
|
|
1118
1306
|
try {
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
fwdHeaders["accept-encoding"] = "identity";
|
|
1139
|
-
// Use http.request for raw body streaming (avoids Fastify's parsed body)
|
|
1140
|
-
const upstreamRes = await new Promise((resolve, reject) => {
|
|
1141
|
-
const proxyReq = httpRequest(targetUrl, {
|
|
1142
|
-
method: request.method,
|
|
1143
|
-
headers: fwdHeaders,
|
|
1144
|
-
}, resolve);
|
|
1145
|
-
proxyReq.on("error", reject);
|
|
1146
|
-
// Pipe the raw incoming body directly to the upstream
|
|
1147
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
1148
|
-
request.raw.pipe(proxyReq);
|
|
1149
|
-
}
|
|
1150
|
-
else {
|
|
1151
|
-
proxyReq.end();
|
|
1152
|
-
}
|
|
1153
|
-
});
|
|
1154
|
-
reply.status(upstreamRes.statusCode || 502);
|
|
1155
|
-
// Forward response headers — preserve most, rewrite security headers for iframe
|
|
1156
|
-
for (const [k, v] of Object.entries(upstreamRes.headers)) {
|
|
1157
|
-
if (v === undefined)
|
|
1158
|
-
continue;
|
|
1159
|
-
const lk = k.toLowerCase();
|
|
1160
|
-
if (HOP_BY_HOP.has(lk))
|
|
1161
|
-
continue;
|
|
1162
|
-
if (lk === "x-frame-options") {
|
|
1163
|
-
reply.header("x-frame-options", "SAMEORIGIN");
|
|
1164
|
-
continue;
|
|
1165
|
-
}
|
|
1166
|
-
if (lk === "content-security-policy") {
|
|
1167
|
-
// Replace frame-ancestors directive, preserve the rest
|
|
1168
|
-
const csp = Array.isArray(v) ? v.join(", ") : v;
|
|
1169
|
-
const rewritten = csp.replace(/frame-ancestors\s+[^;]*/i, "frame-ancestors 'self'");
|
|
1170
|
-
reply.header("content-security-policy", rewritten === csp ? `${csp}; frame-ancestors 'self'` : rewritten);
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
reply.header(k, Array.isArray(v) ? v.join(", ") : v);
|
|
1174
|
-
}
|
|
1175
|
-
// Rewrite control-ui-config.json to set basePath for proxied gateway
|
|
1176
|
-
const respCt = upstreamRes.headers["content-type"] || "";
|
|
1177
|
-
if (suffix === "__openclaw/control-ui-config.json" && respCt.includes("application/json")) {
|
|
1178
|
-
const chunks = [];
|
|
1179
|
-
for await (const chunk of upstreamRes)
|
|
1180
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1181
|
-
try {
|
|
1182
|
-
const config = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
1183
|
-
config.basePath = `/api/instances/${id}/gateway`;
|
|
1184
|
-
const buf = Buffer.from(JSON.stringify(config));
|
|
1185
|
-
reply.header("content-length", buf.length);
|
|
1186
|
-
reply.removeHeader("content-encoding");
|
|
1187
|
-
return reply.send(buf);
|
|
1188
|
-
}
|
|
1189
|
-
catch { /* fall through to stream */ }
|
|
1190
|
-
}
|
|
1191
|
-
// For HTML responses: buffer to inject crypto shim + basePath
|
|
1192
|
-
if (respCt.includes("text/html")) {
|
|
1193
|
-
const chunks = [];
|
|
1194
|
-
for await (const chunk of upstreamRes)
|
|
1195
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1196
|
-
let html = Buffer.concat(chunks).toString("utf-8");
|
|
1197
|
-
const basePath = `/api/instances/${id}/gateway`;
|
|
1198
|
-
const injectScript = [
|
|
1199
|
-
`window.__OPENCLAW_CONTROL_UI_BASE_PATH__=${JSON.stringify(basePath)};`,
|
|
1200
|
-
`(()=>{`,
|
|
1201
|
-
` try {`,
|
|
1202
|
-
` const settingsKey='openclaw.control.settings.v1';`,
|
|
1203
|
-
` const tokenStoragePrefix='openclaw.control.token.v1:';`,
|
|
1204
|
-
` const normalizeGatewayScope=(gatewayUrl)=>{`,
|
|
1205
|
-
` const raw=(gatewayUrl||'').trim();`,
|
|
1206
|
-
` if(!raw) return 'default';`,
|
|
1207
|
-
` try {`,
|
|
1208
|
-
` const base=\`\${window.location.protocol}//\${window.location.host}\${window.location.pathname||'/'}\`;`,
|
|
1209
|
-
` const parsed=new URL(raw, base);`,
|
|
1210
|
-
` const pathname=parsed.pathname==='/'?'':(parsed.pathname.replace(/\\/+$/,'')||parsed.pathname);`,
|
|
1211
|
-
` return \`\${parsed.protocol}//\${parsed.host}\${pathname}\`;`,
|
|
1212
|
-
` } catch {`,
|
|
1213
|
-
` return raw;`,
|
|
1214
|
-
` }`,
|
|
1215
|
-
` };`,
|
|
1216
|
-
` const proto=window.location.protocol==='https:'?'wss':'ws';`,
|
|
1217
|
-
` const gatewayUrl=\`\${proto}://\${window.location.host}${basePath}\`;`,
|
|
1218
|
-
` const tokenSessionKey=\`\${tokenStoragePrefix}\${normalizeGatewayScope(gatewayUrl)}\`;`,
|
|
1219
|
-
` const raw=window.localStorage.getItem(settingsKey);`,
|
|
1220
|
-
` let next={};`,
|
|
1221
|
-
` try { next=raw ? JSON.parse(raw) : {}; } catch { next={}; }`,
|
|
1222
|
-
` next.gatewayUrl=gatewayUrl;`,
|
|
1223
|
-
` if('token' in next) delete next.token;`,
|
|
1224
|
-
` const hashParams=new URLSearchParams(window.location.hash.startsWith('#')?window.location.hash.slice(1):window.location.hash);`,
|
|
1225
|
-
` const searchParams=new URLSearchParams(window.location.search);`,
|
|
1226
|
-
` const launchToken=(hashParams.get('token')||searchParams.get('token')||'').trim();`,
|
|
1227
|
-
` if(launchToken){`,
|
|
1228
|
-
` window.sessionStorage.setItem(tokenSessionKey, launchToken);`,
|
|
1229
|
-
` }`,
|
|
1230
|
-
` window.localStorage.setItem(settingsKey, JSON.stringify(next));`,
|
|
1231
|
-
` const autoConnect=()=>{`,
|
|
1232
|
-
` const attempt=()=>{`,
|
|
1233
|
-
` const app=document.querySelector('openclaw-app');`,
|
|
1234
|
-
` if(!app||typeof app.connect!=='function'||typeof app.applySettings!=='function'||!app.settings||typeof app.settings!=='object') return false;`,
|
|
1235
|
-
` const sessionToken=(window.sessionStorage.getItem(tokenSessionKey)||'').trim();`,
|
|
1236
|
-
` const token=(sessionToken||launchToken||app.settings.token||'').trim();`,
|
|
1237
|
-
` if(!token) return false;`,
|
|
1238
|
-
` const nextSettings={...app.settings, gatewayUrl, token};`,
|
|
1239
|
-
` if(nextSettings.gatewayUrl!==app.settings.gatewayUrl||nextSettings.token!==app.settings.token){`,
|
|
1240
|
-
` app.applySettings(nextSettings);`,
|
|
1241
|
-
` }`,
|
|
1242
|
-
` if(app.connected) return true;`,
|
|
1243
|
-
` const wsState=app.client&&app.client.ws?app.client.ws.readyState:null;`,
|
|
1244
|
-
` const connecting=wsState===0||wsState===1;`,
|
|
1245
|
-
` if(!connecting){`,
|
|
1246
|
-
` window.setTimeout(()=>{`,
|
|
1247
|
-
` try { if(!app.connected) app.connect(); } catch {}`,
|
|
1248
|
-
` }, 0);`,
|
|
1249
|
-
` }`,
|
|
1250
|
-
` return false;`,
|
|
1251
|
-
` };`,
|
|
1252
|
-
` const start=()=>{`,
|
|
1253
|
-
` let tries=0;`,
|
|
1254
|
-
` let timer=0;`,
|
|
1255
|
-
` const tick=()=>{`,
|
|
1256
|
-
` tries+=1;`,
|
|
1257
|
-
` if(attempt()||tries>=120){`,
|
|
1258
|
-
` window.clearInterval(timer);`,
|
|
1259
|
-
` }`,
|
|
1260
|
-
` };`,
|
|
1261
|
-
` tick();`,
|
|
1262
|
-
` timer=window.setInterval(()=>{`,
|
|
1263
|
-
` tick();`,
|
|
1264
|
-
` },500);`,
|
|
1265
|
-
` };`,
|
|
1266
|
-
` if(window.customElements&&typeof window.customElements.whenDefined==='function'){`,
|
|
1267
|
-
` window.customElements.whenDefined('openclaw-app').then(start).catch(()=>{});`,
|
|
1268
|
-
` }else{`,
|
|
1269
|
-
` start();`,
|
|
1270
|
-
` }`,
|
|
1271
|
-
` };`,
|
|
1272
|
-
` autoConnect();`,
|
|
1273
|
-
` } catch {}`,
|
|
1274
|
-
`})();`,
|
|
1275
|
-
].join("");
|
|
1276
|
-
const inject = `<script>${injectScript}</script>`;
|
|
1277
|
-
// Append jishu-inject listener as a separate script tag (keeps CSP hash separate)
|
|
1278
|
-
const injectCmdScript = [
|
|
1279
|
-
`(function(){`,
|
|
1280
|
-
` var _jishuInject=function(cmd,send){`,
|
|
1281
|
-
` // Primary path: use openclaw-app Lit component API directly.`,
|
|
1282
|
-
` // app.chatMessage is the reactive property backing the textarea draft.`,
|
|
1283
|
-
` // app.handleSendChat(cmd) invokes the component's own send handler.`,
|
|
1284
|
-
` var app=document.querySelector('openclaw-app');`,
|
|
1285
|
-
` if(!app)return false;`,
|
|
1286
|
-
` if(send){`,
|
|
1287
|
-
` // Only send when gateway WebSocket is connected`,
|
|
1288
|
-
` if(!app.connected)return false;`,
|
|
1289
|
-
` try{app.handleSendChat(cmd);return true;}catch(e){}`,
|
|
1290
|
-
` return false;`,
|
|
1291
|
-
` }else{`,
|
|
1292
|
-
` // Draft-only: set reactive property so Lit re-renders the textarea`,
|
|
1293
|
-
` try{app.chatMessage=cmd;return true;}catch(e){}`,
|
|
1294
|
-
` return false;`,
|
|
1295
|
-
` }`,
|
|
1296
|
-
` };`,
|
|
1297
|
-
` window.addEventListener('message',function(e){`,
|
|
1298
|
-
` if(!e.data||e.data.type!=='jishu:inject-cmd')return;`,
|
|
1299
|
-
` var cmd=e.data.cmd,send=!!e.data.send,tries=0;`,
|
|
1300
|
-
` var poll=function(){if(_jishuInject(cmd,send)||++tries>=50)return;setTimeout(poll,200);};`,
|
|
1301
|
-
` poll();`,
|
|
1302
|
-
` },false);`,
|
|
1303
|
-
`})();`,
|
|
1304
|
-
].join("");
|
|
1305
|
-
const injectCmdScriptHash = createHash("sha256").update(injectCmdScript, "utf8").digest("base64");
|
|
1306
|
-
const fullHtmlInject = `${inject}<script>${injectCmdScript}</script>`;
|
|
1307
|
-
html = html.replace(/<head\b[^>]*>/i, (match) => `${match}${fullHtmlInject}`);
|
|
1308
|
-
const inlineScriptHash = createHash("sha256").update(injectScript, "utf8").digest("base64");
|
|
1309
|
-
const cspHeader = reply.getHeader("content-security-policy");
|
|
1310
|
-
if (typeof cspHeader === "string" && cspHeader) {
|
|
1311
|
-
const hashToken = `'sha256-${inlineScriptHash}'`;
|
|
1312
|
-
const hashToken2 = `'sha256-${injectCmdScriptHash}'`;
|
|
1313
|
-
const addHashes = (src) => {
|
|
1314
|
-
let s = src;
|
|
1315
|
-
if (!s.includes(hashToken))
|
|
1316
|
-
s = s + ` ${hashToken}`;
|
|
1317
|
-
if (!s.includes(hashToken2))
|
|
1318
|
-
s = s + ` ${hashToken2}`;
|
|
1319
|
-
return s;
|
|
1320
|
-
};
|
|
1321
|
-
const nextCsp = /\bscript-src\b/i.test(cspHeader)
|
|
1322
|
-
? cspHeader.replace(/\bscript-src\b([^;]*)/i, (_m, value) => `script-src${addHashes(value)}`)
|
|
1323
|
-
: `${cspHeader}; script-src 'self' ${hashToken} ${hashToken2}`;
|
|
1324
|
-
reply.header("content-security-policy", nextCsp);
|
|
1325
|
-
}
|
|
1326
|
-
const buf = Buffer.from(html, "utf-8");
|
|
1327
|
-
reply.header("cache-control", "no-store");
|
|
1328
|
-
reply.header("content-length", buf.length);
|
|
1329
|
-
reply.removeHeader("content-encoding");
|
|
1330
|
-
return reply.send(buf);
|
|
1331
|
-
}
|
|
1332
|
-
// Non-HTML: stream response directly
|
|
1333
|
-
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);
|
|
1334
1326
|
}
|
|
1335
1327
|
catch (err) {
|
|
1336
|
-
console.error(`[
|
|
1337
|
-
return reply.status(502).send({ detail: "Cannot reach OpenClaw gateway" });
|
|
1328
|
+
console.error(`[instances] adapter ${adapter.agentType} registerRoutes failed:`, err);
|
|
1338
1329
|
}
|
|
1339
|
-
}
|
|
1340
|
-
app.all("/api/instances/:id/gateway/*", gatewayProxy);
|
|
1341
|
-
app.all("/api/instances/:id/gateway", gatewayProxy);
|
|
1330
|
+
}
|
|
1342
1331
|
}
|
|
1343
1332
|
//# sourceMappingURL=instances.js.map
|