jishushell 0.4.17 → 0.4.24

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