jishushell 0.4.24 → 0.5.15

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 (281) hide show
  1. package/INSTALL-NOTICE +11 -0
  2. package/apps/anythingllm-container.yaml +287 -0
  3. package/apps/browserless-chromium-container.yaml +90 -0
  4. package/apps/filebrowser-container.yaml +163 -0
  5. package/apps/hermes-container.yaml +36 -2
  6. package/apps/ollama-binary.yaml +91 -90
  7. package/apps/ollama-cpu-container.yaml +8 -1
  8. package/apps/ollama-with-hollama-binary.yaml +91 -90
  9. package/apps/openclaw-binary.yaml +38 -1
  10. package/apps/openclaw-container.yaml +45 -2
  11. package/apps/openclaw-with-ollama-container.yaml +11 -2
  12. package/apps/openclaw-with-searxng-container.yaml +26 -2
  13. package/apps/openwebui-container.yaml +45 -1
  14. package/apps/playwright-container.yaml +7 -1
  15. package/apps/searxng-container.yaml +58 -7
  16. package/apps/weknora-container.yaml +471 -0
  17. package/dist/cli/app.js +79 -9
  18. package/dist/cli/app.js.map +1 -1
  19. package/dist/cli/doctor.d.ts +12 -12
  20. package/dist/cli/doctor.js +242 -55
  21. package/dist/cli/doctor.js.map +1 -1
  22. package/dist/cli/llm.d.ts +4 -3
  23. package/dist/cli/llm.js +4 -3
  24. package/dist/cli/llm.js.map +1 -1
  25. package/dist/cli/panel.d.ts +6 -5
  26. package/dist/cli/panel.js +10 -9
  27. package/dist/cli/panel.js.map +1 -1
  28. package/dist/config.d.ts +19 -0
  29. package/dist/config.js +99 -1
  30. package/dist/config.js.map +1 -1
  31. package/dist/control.d.ts +7 -6
  32. package/dist/control.js +7 -6
  33. package/dist/control.js.map +1 -1
  34. package/dist/install.js +3 -3
  35. package/dist/install.js.map +1 -1
  36. package/dist/routes/agent-apps.d.ts +1 -1
  37. package/dist/routes/agent-apps.js +1 -1
  38. package/dist/routes/apps.js +44 -11
  39. package/dist/routes/apps.js.map +1 -1
  40. package/dist/routes/auth.js +5 -2
  41. package/dist/routes/auth.js.map +1 -1
  42. package/dist/routes/backup.js +64 -11
  43. package/dist/routes/backup.js.map +1 -1
  44. package/dist/routes/external-mounts.d.ts +17 -0
  45. package/dist/routes/external-mounts.js +73 -0
  46. package/dist/routes/external-mounts.js.map +1 -0
  47. package/dist/routes/file-mounts.d.ts +13 -0
  48. package/dist/routes/file-mounts.js +90 -0
  49. package/dist/routes/file-mounts.js.map +1 -0
  50. package/dist/routes/files-organize.d.ts +28 -0
  51. package/dist/routes/files-organize.js +167 -0
  52. package/dist/routes/files-organize.js.map +1 -0
  53. package/dist/routes/files.d.ts +31 -0
  54. package/dist/routes/files.js +321 -0
  55. package/dist/routes/files.js.map +1 -0
  56. package/dist/routes/instances.js +826 -17
  57. package/dist/routes/instances.js.map +1 -1
  58. package/dist/routes/internal.d.ts +2 -0
  59. package/dist/routes/internal.js +59 -0
  60. package/dist/routes/internal.js.map +1 -0
  61. package/dist/routes/llm.js +24 -35
  62. package/dist/routes/llm.js.map +1 -1
  63. package/dist/routes/setup.js +10 -10
  64. package/dist/routes/setup.js.map +1 -1
  65. package/dist/routes/system.js +1 -1
  66. package/dist/routes/system.js.map +1 -1
  67. package/dist/routes/webdav.d.ts +17 -0
  68. package/dist/routes/webdav.js +114 -0
  69. package/dist/routes/webdav.js.map +1 -0
  70. package/dist/server.d.ts +9 -0
  71. package/dist/server.js +751 -20
  72. package/dist/server.js.map +1 -1
  73. package/dist/services/agent-apps/catalog.js +4 -3
  74. package/dist/services/agent-apps/catalog.js.map +1 -1
  75. package/dist/services/agent-apps/index.d.ts +1 -1
  76. package/dist/services/agent-apps/index.js +1 -1
  77. package/dist/services/agent-apps/installers/adapter.d.ts +1 -1
  78. package/dist/services/agent-apps/installers/adapter.js +1 -1
  79. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  80. package/dist/services/agent-apps/installers/shell-script.js +3 -3
  81. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  82. package/dist/services/agent-apps/types.d.ts +2 -2
  83. package/dist/services/agent-apps/types.js +1 -1
  84. package/dist/services/app/app-compiler.d.ts +1 -1
  85. package/dist/services/app/app-compiler.js +5 -5
  86. package/dist/services/app/app-compiler.js.map +1 -1
  87. package/dist/services/app/app-manager.d.ts +25 -1
  88. package/dist/services/app/app-manager.js +829 -150
  89. package/dist/services/app/app-manager.js.map +1 -1
  90. package/dist/services/app/custom-manager.js.map +1 -1
  91. package/dist/services/app/hermes-agent-manager.js +7 -4
  92. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  93. package/dist/services/app/ollama-manager.js +1 -1
  94. package/dist/services/app/ollama-manager.js.map +1 -1
  95. package/dist/services/app/openclaw-manager.js +20 -3
  96. package/dist/services/app/openclaw-manager.js.map +1 -1
  97. package/dist/services/app/platform-transform.d.ts +32 -0
  98. package/dist/services/app/platform-transform.js +65 -0
  99. package/dist/services/app/platform-transform.js.map +1 -0
  100. package/dist/services/app/provide-resolver.d.ts +29 -0
  101. package/dist/services/app/provide-resolver.js +112 -0
  102. package/dist/services/app/provide-resolver.js.map +1 -0
  103. package/dist/services/app-passwords.d.ts +61 -0
  104. package/dist/services/app-passwords.js +173 -0
  105. package/dist/services/app-passwords.js.map +1 -0
  106. package/dist/services/backup-manager.d.ts +11 -0
  107. package/dist/services/backup-manager.js +177 -4
  108. package/dist/services/backup-manager.js.map +1 -1
  109. package/dist/services/capability-endpoint-validator.d.ts +41 -0
  110. package/dist/services/capability-endpoint-validator.js +104 -0
  111. package/dist/services/capability-endpoint-validator.js.map +1 -0
  112. package/dist/services/capability-health.d.ts +16 -0
  113. package/dist/services/capability-health.js +121 -0
  114. package/dist/services/capability-health.js.map +1 -0
  115. package/dist/services/capability-registry.d.ts +106 -0
  116. package/dist/services/capability-registry.js +313 -0
  117. package/dist/services/capability-registry.js.map +1 -0
  118. package/dist/services/connection-apply.d.ts +91 -0
  119. package/dist/services/connection-apply.js +475 -0
  120. package/dist/services/connection-apply.js.map +1 -0
  121. package/dist/services/connection-resolver.d.ts +65 -0
  122. package/dist/services/connection-resolver.js +281 -0
  123. package/dist/services/connection-resolver.js.map +1 -0
  124. package/dist/services/connection-transactor.d.ts +39 -0
  125. package/dist/services/connection-transactor.js +351 -0
  126. package/dist/services/connection-transactor.js.map +1 -0
  127. package/dist/services/external-mounts.d.ts +40 -0
  128. package/dist/services/external-mounts.js +187 -0
  129. package/dist/services/external-mounts.js.map +1 -0
  130. package/dist/services/files-manager.d.ts +252 -0
  131. package/dist/services/files-manager.js +1075 -0
  132. package/dist/services/files-manager.js.map +1 -0
  133. package/dist/services/files-mounts.d.ts +42 -0
  134. package/dist/services/files-mounts.js +207 -0
  135. package/dist/services/files-mounts.js.map +1 -0
  136. package/dist/services/instance-manager.d.ts +13 -0
  137. package/dist/services/instance-manager.js +138 -46
  138. package/dist/services/instance-manager.js.map +1 -1
  139. package/dist/services/llm-proxy/index.d.ts +16 -2
  140. package/dist/services/llm-proxy/index.js +48 -44
  141. package/dist/services/llm-proxy/index.js.map +1 -1
  142. package/dist/services/llm-proxy/probe.d.ts +6 -0
  143. package/dist/services/llm-proxy/probe.js +85 -0
  144. package/dist/services/llm-proxy/probe.js.map +1 -0
  145. package/dist/services/llm-proxy/ssrf.d.ts +1 -0
  146. package/dist/services/llm-proxy/ssrf.js +24 -9
  147. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  148. package/dist/services/nomad-manager.d.ts +4 -0
  149. package/dist/services/nomad-manager.js +428 -35
  150. package/dist/services/nomad-manager.js.map +1 -1
  151. package/dist/services/organize/applier.d.ts +46 -0
  152. package/dist/services/organize/applier.js +218 -0
  153. package/dist/services/organize/applier.js.map +1 -0
  154. package/dist/services/organize/rules.d.ts +57 -0
  155. package/dist/services/organize/rules.js +286 -0
  156. package/dist/services/organize/rules.js.map +1 -0
  157. package/dist/services/organize/scanner.d.ts +50 -0
  158. package/dist/services/organize/scanner.js +366 -0
  159. package/dist/services/organize/scanner.js.map +1 -0
  160. package/dist/services/organize/store.d.ts +14 -0
  161. package/dist/services/organize/store.js +82 -0
  162. package/dist/services/organize/store.js.map +1 -0
  163. package/dist/services/panel-manager.js +20 -1
  164. package/dist/services/panel-manager.js.map +1 -1
  165. package/dist/services/process-manager.js +4 -3
  166. package/dist/services/process-manager.js.map +1 -1
  167. package/dist/services/runtime/adapters/hermes.d.ts +30 -1
  168. package/dist/services/runtime/adapters/hermes.js +219 -6
  169. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  170. package/dist/services/runtime/adapters/openclaw-mcporter.d.ts +45 -0
  171. package/dist/services/runtime/adapters/openclaw-mcporter.js +108 -0
  172. package/dist/services/runtime/adapters/openclaw-mcporter.js.map +1 -0
  173. package/dist/services/runtime/adapters/openclaw-routes.d.ts +8 -2
  174. package/dist/services/runtime/adapters/openclaw-routes.js +68 -0
  175. package/dist/services/runtime/adapters/openclaw-routes.js.map +1 -1
  176. package/dist/services/runtime/adapters/openclaw.d.ts +177 -0
  177. package/dist/services/runtime/adapters/openclaw.js +1171 -11
  178. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  179. package/dist/services/runtime/instance.d.ts +1 -1
  180. package/dist/services/runtime/instance.js +1 -1
  181. package/dist/services/runtime/instance.js.map +1 -1
  182. package/dist/services/runtime/mcp-shims/anythingllm-shim.d.ts +46 -0
  183. package/dist/services/runtime/mcp-shims/anythingllm-shim.js +281 -0
  184. package/dist/services/runtime/mcp-shims/anythingllm-shim.js.map +1 -0
  185. package/dist/services/runtime/mcp-shims/drive-shim.d.ts +54 -0
  186. package/dist/services/runtime/mcp-shims/drive-shim.js +489 -0
  187. package/dist/services/runtime/mcp-shims/drive-shim.js.map +1 -0
  188. package/dist/services/runtime/mcp-shims/firewall.d.ts +26 -0
  189. package/dist/services/runtime/mcp-shims/firewall.js +129 -0
  190. package/dist/services/runtime/mcp-shims/firewall.js.map +1 -0
  191. package/dist/services/runtime/mcp-shims/searxng-shim.d.ts +27 -0
  192. package/dist/services/runtime/mcp-shims/searxng-shim.js +125 -0
  193. package/dist/services/runtime/mcp-shims/searxng-shim.js.map +1 -0
  194. package/dist/services/runtime/mcp-shims/write-mcp-entry.d.ts +83 -0
  195. package/dist/services/runtime/mcp-shims/write-mcp-entry.js +127 -0
  196. package/dist/services/runtime/mcp-shims/write-mcp-entry.js.map +1 -0
  197. package/dist/services/runtime/migrations.d.ts +8 -0
  198. package/dist/services/runtime/migrations.js +100 -0
  199. package/dist/services/runtime/migrations.js.map +1 -1
  200. package/dist/services/runtime/types.d.ts +46 -0
  201. package/dist/services/setup-manager.js +99 -24
  202. package/dist/services/setup-manager.js.map +1 -1
  203. package/dist/services/suggestions.d.ts +27 -0
  204. package/dist/services/suggestions.js +133 -0
  205. package/dist/services/suggestions.js.map +1 -0
  206. package/dist/services/task-registry.js +4 -2
  207. package/dist/services/task-registry.js.map +1 -1
  208. package/dist/services/telemetry/device-fingerprint.d.ts +1 -1
  209. package/dist/services/telemetry/device-fingerprint.js +1 -1
  210. package/dist/services/types-shim.d.ts +16 -0
  211. package/dist/services/types-shim.js +2 -0
  212. package/dist/services/types-shim.js.map +1 -0
  213. package/dist/services/webdav/server.d.ts +24 -0
  214. package/dist/services/webdav/server.js +420 -0
  215. package/dist/services/webdav/server.js.map +1 -0
  216. package/dist/services/webdav/xml-builder.d.ts +73 -0
  217. package/dist/services/webdav/xml-builder.js +156 -0
  218. package/dist/services/webdav/xml-builder.js.map +1 -0
  219. package/dist/services/workspace-builder.d.ts +29 -0
  220. package/dist/services/workspace-builder.js +188 -0
  221. package/dist/services/workspace-builder.js.map +1 -0
  222. package/dist/types.d.ts +231 -1
  223. package/dist/utils/instance-lock.d.ts +22 -0
  224. package/dist/utils/instance-lock.js +48 -0
  225. package/dist/utils/instance-lock.js.map +1 -0
  226. package/dist/utils/path-locks.d.ts +30 -0
  227. package/dist/utils/path-locks.js +63 -0
  228. package/dist/utils/path-locks.js.map +1 -0
  229. package/dist/utils/path-safety.d.ts +41 -0
  230. package/dist/utils/path-safety.js +119 -0
  231. package/dist/utils/path-safety.js.map +1 -0
  232. package/dist/utils/safe-json.js +55 -22
  233. package/dist/utils/safe-json.js.map +1 -1
  234. package/dist/utils/safe-write.d.ts +24 -0
  235. package/dist/utils/safe-write.js +82 -0
  236. package/dist/utils/safe-write.js.map +1 -0
  237. package/install/jishu-install.sh +323 -27
  238. package/install/jishu-uninstall.sh +353 -20
  239. package/package.json +18 -1
  240. package/public/assets/Dashboard-BdWPtroF.js +1 -0
  241. package/public/assets/{HermesChatPanel-mFSureyc.js → HermesChatPanel-B_2HlVBQ.js} +1 -1
  242. package/public/assets/HermesConfigForm-DVlhg3WV.js +4 -0
  243. package/public/assets/{InitPassword-CVA8wQA6.js → InitPassword-D7glTExX.js} +1 -1
  244. package/public/assets/InstanceDetail-CxSy2cpe.js +92 -0
  245. package/public/assets/{Login-BWsZH2mu.js → Login-Cfr5c2sv.js} +1 -1
  246. package/public/assets/NewInstance-BIYDmJis.js +1 -0
  247. package/public/assets/ProviderRecommendations-BuRnvRcI.js +1 -0
  248. package/public/assets/Settings-Cc-tYBil.js +1 -0
  249. package/public/assets/Setup-lGZEk5jq.js +1 -0
  250. package/public/assets/{WeixinLoginPanel-CnjR8xMu.js → WeixinLoginPanel-CoGqzxeV.js} +2 -2
  251. package/public/assets/index-87IJXG-w.css +1 -0
  252. package/public/assets/index-BZc5zH7u.js +19 -0
  253. package/public/assets/providers-DtNXh9JD.js +1 -0
  254. package/public/assets/registry-BWnkJgZ1.js +2 -0
  255. package/public/assets/{usePolling-Do5Erqm_.js → usePolling-CwwT9KrC.js} +1 -1
  256. package/public/assets/{vendor-i18n-ucpM0OR0.js → vendor-i18n-y9V7Sfuu.js} +1 -1
  257. package/public/assets/{vendor-react-Bk1hRGiY.js → vendor-react-BWrEVJVb.js} +6 -6
  258. package/public/index.html +4 -4
  259. package/scripts/check-app-spec.mjs +457 -0
  260. package/scripts/check-i18n.mjs +154 -0
  261. package/scripts/check-new-file-tests.mjs +230 -0
  262. package/scripts/check-quarantine-expiry.mjs +105 -0
  263. package/scripts/perf/README.md +49 -0
  264. package/scripts/perf/auth.js +99 -0
  265. package/scripts/perf/config.js +63 -0
  266. package/scripts/perf/instances.js +143 -0
  267. package/scripts/perf/proxy.js +96 -0
  268. package/scripts/run.sh +4 -4
  269. package/scripts/smoke/files-w1.sh +142 -0
  270. package/scripts/smoke-backend.mjs +122 -0
  271. package/scripts/smoke-post-publish.mjs +346 -0
  272. package/public/assets/Dashboard-B-JoOjBQ.js +0 -1
  273. package/public/assets/HermesConfigForm-DvR05LK1.js +0 -4
  274. package/public/assets/InstanceDetail-DcZW2QGO.js +0 -91
  275. package/public/assets/NewInstance-BCIrAd86.js +0 -1
  276. package/public/assets/Settings-xkDcduFz.js +0 -1
  277. package/public/assets/Setup-Cfuwj4gV.js +0 -1
  278. package/public/assets/index-CPhVFEsx.css +0 -1
  279. package/public/assets/index-DQsM6Joa.js +0 -19
  280. package/public/assets/providers-V-vwrExZ.js +0 -1
  281. package/public/assets/registry-B4UFJdpA.js +0 -2
@@ -9,6 +9,7 @@ import { normalizeInstanceId } from "../services/app/id-normalizer.js";
9
9
  import { assertTerminalSessionOwner, getTerminalSession, getTerminalSessionEvents, sendTerminalSessionInput, startTerminalSession, stopTerminalSession, subscribeTerminalSession, } from "../services/app/terminal-session-manager.js";
10
10
  import { TtlMap } from "../utils/ttl-cache.js";
11
11
  import { writeSecretFile } from "../utils/fs.js";
12
+ import { Readable } from "node:stream";
12
13
  // Hop-by-hop headers that must not be forwarded by a proxy (RFC 2616 §13.5.1).
13
14
  // Exported for adapter-owned route modules that implement their own HTTP proxies.
14
15
  export const HOP_BY_HOP = new Set([
@@ -47,6 +48,17 @@ function joinProxyPath(basePath, suffix) {
47
48
  return normalizedBase;
48
49
  return `${normalizedBase}/${normalizedSuffix}`;
49
50
  }
51
+ function canonicalCapabilityProxyBase(basePath, capabilityPath) {
52
+ const normalizedCapabilityPath = typeof capabilityPath === "string" ? capabilityPath.trim() : "";
53
+ const needsTrailingSlash = !normalizedCapabilityPath || normalizedCapabilityPath.endsWith("/");
54
+ return needsTrailingSlash ? `${basePath}/` : basePath;
55
+ }
56
+ function rewriteCapabilityLocation(basePath, canonicalBasePath, pathname, search = "", hash = "") {
57
+ const rewrittenPath = pathname === "/"
58
+ ? canonicalBasePath
59
+ : joinProxyPath(basePath, pathname);
60
+ return `${rewrittenPath}${search}${hash}`;
61
+ }
50
62
  function joinUpstreamPath(basePath, suffix) {
51
63
  const normalizedBase = typeof basePath === "string" && basePath.trim()
52
64
  ? (basePath.startsWith("/") ? basePath : `/${basePath}`)
@@ -60,11 +72,307 @@ function shouldRewriteProxyResponse(contentType) {
60
72
  const value = (contentType ?? "").toLowerCase();
61
73
  return value.includes("text/html") || value.includes("text/css");
62
74
  }
63
- function rewriteProxyTextBody(body, contentType, proxyBasePath) {
75
+ function browserlessDebuggerBootstrap(instanceId) {
76
+ const apiProxyPath = `${capabilityProxyPath(instanceId, "browserless-api")}`;
77
+ const escapedPath = JSON.stringify(apiProxyPath + "/");
78
+ const escapedProxyBase = JSON.stringify(apiProxyPath);
79
+ const escapedWorkerPrelude = JSON.stringify([
80
+ "(function(){",
81
+ `var P=${escapedProxyBase};`,
82
+ "var _WS=self.WebSocket;",
83
+ "if(typeof _WS!=='function')return;",
84
+ "self.WebSocket=function(url,protocols){",
85
+ "try{var p=new URL(url,self.location.origin);",
86
+ "if(p.host===self.location.host&&!p.pathname.startsWith('/api/')){",
87
+ "var s=self.location.protocol==='https:'?'wss:':'ws:';",
88
+ "url=s+'//'+self.location.host+P+p.pathname+p.search;",
89
+ "}}catch(_e){}",
90
+ "return protocols!==undefined?new _WS(url,protocols):new _WS(url);",
91
+ "};",
92
+ "self.WebSocket.prototype=_WS.prototype;",
93
+ "self.WebSocket.CONNECTING=_WS.CONNECTING;",
94
+ "self.WebSocket.OPEN=_WS.OPEN;",
95
+ "self.WebSocket.CLOSING=_WS.CLOSING;",
96
+ "self.WebSocket.CLOSED=_WS.CLOSED;",
97
+ "})();",
98
+ ].join(""));
99
+ // 1) Set baseURL in localStorage so the debugger's HTTP API calls go through
100
+ // the capability proxy.
101
+ // 2) Monkey-patch WebSocket in the page and any same-origin workers so that
102
+ // Browserless connections targeting the panel origin (e.g.
103
+ // ws://panel:8090/?launch=...) are rewritten through the capability proxy.
104
+ return [
105
+ "<script>(function(){",
106
+ // --- localStorage apiSettings.baseURL ---
107
+ // Browserless reads `state.apiSettings.baseURL` (a NESTED object); writing
108
+ // a flat `state.baseURL` is silently ignored, and the SPA falls back to
109
+ // `window.location.origin` (no proxy prefix) — producing connect URLs like
110
+ // `ws://panel:8090/?launch=...` that bypass the capability proxy.
111
+ "try{var key='browserless-debugger:'+window.location.origin+window.location.pathname;",
112
+ "var raw=window.localStorage.getItem(key)||'{}';",
113
+ "var state={};try{state=JSON.parse(raw)||{}}catch(_e){}",
114
+ `var base=new URL(${escapedPath},window.location.origin);`,
115
+ "var token=new URL(window.location.href).searchParams.get('token');",
116
+ "if(token)base.searchParams.set('token',token);",
117
+ "var settings=(state&&typeof state.apiSettings==='object'&&state.apiSettings)?state.apiSettings:{};",
118
+ "settings.baseURL=base.href;",
119
+ "state.apiSettings=settings;",
120
+ "window.localStorage.setItem(key,JSON.stringify(state));",
121
+ "}catch(_e){}",
122
+ // --- WebSocket monkey-patch ---
123
+ "var _WS=window.WebSocket;",
124
+ `var _base=${escapedProxyBase};`,
125
+ "window.WebSocket=function(url,protocols){",
126
+ "try{var p=new URL(url,window.location.origin);",
127
+ "if(p.host===window.location.host&&!p.pathname.startsWith('/api/')){",
128
+ "var s=window.location.protocol==='https:'?'wss:':'ws:';",
129
+ "url=s+'//'+window.location.host+_base+p.pathname+p.search;",
130
+ "}}catch(_e){}",
131
+ "return protocols!==undefined?new _WS(url,protocols):new _WS(url);",
132
+ "};",
133
+ "window.WebSocket.prototype=_WS.prototype;",
134
+ "window.WebSocket.CONNECTING=_WS.CONNECTING;",
135
+ "window.WebSocket.OPEN=_WS.OPEN;",
136
+ "window.WebSocket.CLOSING=_WS.CLOSING;",
137
+ "window.WebSocket.CLOSED=_WS.CLOSED;",
138
+ // --- Worker monkey-patch ---
139
+ "var _Worker=window.Worker;",
140
+ `var _workerPrelude=${escapedWorkerPrelude};`,
141
+ "function shouldWrapWorker(url){",
142
+ "try{var p=new URL(String(url),window.location.href);",
143
+ "return p.protocol==='blob:'||p.protocol==='data:'||p.origin===window.location.origin;",
144
+ "}catch(_e){return false;}",
145
+ "}",
146
+ "function wrapWorker(url,options){",
147
+ "if(typeof _Worker!=='function'||!shouldWrapWorker(url))return url;",
148
+ "try{var resolved=new URL(String(url),window.location.href).href;",
149
+ "var isModule=!!(options&&options.type==='module');",
150
+ "var source=isModule?_workerPrelude+'\\nimport '+JSON.stringify(resolved)+';':_workerPrelude+'\\nimportScripts('+JSON.stringify(resolved)+');';",
151
+ "return URL.createObjectURL(new Blob([source],{type:'text/javascript'}));",
152
+ "}catch(_e){return url;}",
153
+ "}",
154
+ "if(typeof _Worker==='function'){",
155
+ "window.Worker=function(url,options){",
156
+ "var wrapped=wrapWorker(url,options);",
157
+ "return options!==undefined?new _Worker(wrapped,options):new _Worker(wrapped);",
158
+ "};",
159
+ "window.Worker.prototype=_Worker.prototype;",
160
+ "}",
161
+ "})();</script>",
162
+ ].join("");
163
+ }
164
+ /**
165
+ * Inject a tiny <script> that monkey-patches `fetch`, `XMLHttpRequest.open`,
166
+ * and `Element.prototype.{setAttribute,src,href}` so that same-origin absolute
167
+ * paths (e.g. `/api/v1/...`, `/static/...`) are transparently rewritten to go
168
+ * through the capability proxy prefix.
169
+ *
170
+ * The alternative — rewriting every JS bundle byte-for-byte — is fragile and
171
+ * expensive; a runtime shim at document load is the standard approach used by
172
+ * reverse-proxy front-ends (cf. Cloudflare Access, oauth2-proxy, etc.).
173
+ */
174
+ function capabilityProxyBootstrap(proxyBasePath) {
175
+ const prefix = JSON.stringify(proxyBasePath.replace(/\/+$/, ""));
176
+ const workerPrelude = JSON.stringify([
177
+ "(function(){",
178
+ `var P=${prefix};`,
179
+ "function px(path){",
180
+ "if(!path||path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf(P)===0||path.indexOf('/api/instances/')===0)return path;",
181
+ "return P+path;",
182
+ "}",
183
+ "function str(u){return typeof u==='string'?u:(u&&typeof u.href==='string'?u.href:null);}",
184
+ "function rwWs(u){",
185
+ "var s0=str(u);if(!s0)return u;",
186
+ "var H=typeof __capProxyHost==='string'?__capProxyHost:self.location.host;",
187
+ "var L=typeof __capProxyProtocol==='string'?__capProxyProtocol:self.location.protocol;",
188
+ "var B=(L==='https:'?'https:':'http:')+'//'+H+'/';",
189
+ "try{var p=new URL(s0,B);",
190
+ "if(p.host===H&&(p.protocol==='ws:'||p.protocol==='wss:'||p.protocol===L)){",
191
+ "var pp=px(p.pathname);",
192
+ "if(pp!==p.pathname){var s=L==='https:'?'wss:':'ws:';return s+'//'+H+pp+p.search+p.hash;}",
193
+ "}}catch(_e){}",
194
+ "return u;",
195
+ "}",
196
+ "var _WS=self.WebSocket;",
197
+ "if(typeof _WS==='function')self.WebSocket=function(url,protocols){url=rwWs(url);return protocols!==undefined?new _WS(url,protocols):new _WS(url);};",
198
+ "if(typeof _WS==='function'){self.WebSocket.prototype=_WS.prototype;self.WebSocket.CONNECTING=_WS.CONNECTING;self.WebSocket.OPEN=_WS.OPEN;self.WebSocket.CLOSING=_WS.CLOSING;self.WebSocket.CLOSED=_WS.CLOSED;}",
199
+ "})();",
200
+ ].join(""));
201
+ return [
202
+ "<script>(function(){",
203
+ `var P=${prefix};`,
204
+ "var O=window.location.origin;",
205
+ // --- Service Worker neutralization ---
206
+ // Embedded SPAs like OpenWebUI register a service worker that aggressively
207
+ // caches HTML and JS chunks at the proxy origin. After we update the proxy
208
+ // bootstrap, the SW would keep serving the old (unpatched) HTML, so socket.io
209
+ // never gets the WebSocket monkey-patch and the splash screen spins forever.
210
+ // Unregister any existing SW, purge its caches, and block future
211
+ // registrations for the lifetime of the iframe.
212
+ "try{if(navigator.serviceWorker){",
213
+ "if(navigator.serviceWorker.getRegistrations){",
214
+ "navigator.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){try{r.unregister()}catch(_e){}})}).catch(function(){});",
215
+ "}",
216
+ "navigator.serviceWorker.register=function(){return Promise.reject(new Error('service worker disabled in capability proxy'))};",
217
+ "}}catch(_e){}",
218
+ "try{if(window.caches&&caches.keys){caches.keys().then(function(ks){ks.forEach(function(k){try{caches.delete(k)}catch(_e){}})}).catch(function(){});}}catch(_e){}",
219
+ // Only rewrite paths that do NOT already start with the proxy prefix and
220
+ // that are simple absolute paths (start with `/` but not `//`).
221
+ "function px(path){",
222
+ "if(!path||path.charAt(0)!=='/'||path.charAt(1)==='/'||path.indexOf(P)===0||path.indexOf('/api/instances/')===0)return path;",
223
+ "return P+path;",
224
+ "}",
225
+ "function str(u){return typeof u==='string'?u:(u&&typeof u.href==='string'?u.href:null);}",
226
+ "function rw(u){",
227
+ "var s0=str(u);if(!s0)return u;",
228
+ "var direct=px(s0);",
229
+ "if(direct!==s0)return direct;",
230
+ "try{var p=new URL(s0,window.location.href);",
231
+ "if(p.origin===O){var pp=px(p.pathname);if(pp!==p.pathname)return pp+p.search+p.hash;}",
232
+ "}catch(_e){}",
233
+ "return u;",
234
+ "}",
235
+ "function rwWs(u){",
236
+ "var s0=str(u);if(!s0)return u;",
237
+ "try{var p=new URL(s0,window.location.href);",
238
+ "if(p.host===window.location.host&&(p.protocol==='ws:'||p.protocol==='wss:'||p.protocol===window.location.protocol)){",
239
+ "var pp=px(p.pathname);",
240
+ "if(pp!==p.pathname){var s=window.location.protocol==='https:'?'wss:':'ws:';return s+'//'+window.location.host+pp+p.search+p.hash;}",
241
+ "}}catch(_e){}",
242
+ "return u;",
243
+ "}",
244
+ // --- fetch() ---
245
+ "var _f=window.fetch;",
246
+ "window.fetch=function(r,o){",
247
+ "if(typeof r==='string'||(r&&typeof r.href==='string'))r=rw(r);",
248
+ "else if(r instanceof Request){",
249
+ "var nr=rw(r.url);if(nr!==r.url)r=new Request(nr,r);}",
250
+ "return _f.call(this,r,o);};",
251
+ // --- XMLHttpRequest.open() ---
252
+ "var _xo=XMLHttpRequest.prototype.open;",
253
+ "XMLHttpRequest.prototype.open=function(m,u){",
254
+ "arguments[1]=rw(u);",
255
+ "return _xo.apply(this,arguments);};",
256
+ // --- history.pushState / replaceState ---
257
+ // SPA routers (SvelteKit, React Router, etc.) navigate via pushState.
258
+ // Without this, pushing "/" lands on the panel's own SPA.
259
+ "var _ps=history.pushState;",
260
+ "var _rs=history.replaceState;",
261
+ "history.pushState=function(s,t,u){",
262
+ "if(typeof u==='string')u=rw(u);",
263
+ "return _ps.call(this,s,t,u);};",
264
+ "history.replaceState=function(s,t,u){",
265
+ "if(typeof u==='string')u=rw(u);",
266
+ "return _rs.call(this,s,t,u);};",
267
+ // --- location.assign / location.replace ---
268
+ "var _la=location.assign.bind(location);",
269
+ "var _lr=location.replace.bind(location);",
270
+ "location.assign=function(u){return _la(rw(u));};",
271
+ "location.replace=function(u){return _lr(rw(u));};",
272
+ // --- frame-busting defense ---
273
+ // Embedded SPAs (e.g. WeKnora) frequently do
274
+ // window.top.location.href = '/login'
275
+ // when they see a 401, intending to log the user out. Inside our
276
+ // capability proxy iframe `top` is the panel's main window — that
277
+ // tears the user away from the instance detail page entirely.
278
+ // Redirect `top`/`parent` to the iframe's own window so the
279
+ // navigation stays inside the embed. Safe because the iframe IS
280
+ // same-origin as the panel (our reverse proxy serves it from the
281
+ // panel's host); cross-origin access would throw and fail closed.
282
+ "try{",
283
+ "Object.defineProperty(window,'top',{configurable:true,get:function(){return window;}});",
284
+ "Object.defineProperty(window,'parent',{configurable:true,get:function(){return window;}});",
285
+ "}catch(_e){}",
286
+ // --- dynamic property assignment: img.src = '/static/...' ---
287
+ "function patchProp(tag,prop){",
288
+ "var d=Object.getOwnPropertyDescriptor(tag.prototype,prop);",
289
+ "if(!d||!d.set)return;",
290
+ "var orig=d.set;",
291
+ "Object.defineProperty(tag.prototype,prop,{",
292
+ "set:function(v){return orig.call(this,rw(v));},",
293
+ "get:d.get,configurable:true,enumerable:true});",
294
+ "}",
295
+ "patchProp(HTMLImageElement,'src');",
296
+ "patchProp(HTMLScriptElement,'src');",
297
+ "patchProp(HTMLLinkElement,'href');",
298
+ "patchProp(HTMLSourceElement,'src');",
299
+ // --- Worker monkey-patch ---
300
+ // Some SPA clients create socket.io/WebSocket connections from workers.
301
+ // Patch same-origin workers so the same proxy-prefixing rule applies there.
302
+ "var _Worker=window.Worker;",
303
+ `var _workerPrelude=${workerPrelude};`,
304
+ "function shouldWrapWorker(url){",
305
+ "try{var p=new URL(String(url),window.location.href);return p.protocol==='blob:'||p.protocol==='data:'||p.origin===O;}catch(_e){return false;}",
306
+ "}",
307
+ "function wrapWorker(url,options){",
308
+ "if(typeof _Worker!=='function'||!shouldWrapWorker(url))return url;",
309
+ "try{var resolved=new URL(String(url),window.location.href).href;",
310
+ "var isModule=!!(options&&options.type==='module');",
311
+ "var workerEnv='var __capProxyHost='+JSON.stringify(window.location.host)+';var __capProxyProtocol='+JSON.stringify(window.location.protocol)+';';",
312
+ "var source=isModule?workerEnv+_workerPrelude+'\\nimport '+JSON.stringify(resolved)+';':workerEnv+_workerPrelude+'\\nimportScripts('+JSON.stringify(resolved)+');';",
313
+ "return URL.createObjectURL(new Blob([source],{type:'text/javascript'}));",
314
+ "}catch(_e){return url;}",
315
+ "}",
316
+ "if(typeof _Worker==='function'){",
317
+ "window.Worker=function(url,options){var wrapped=wrapWorker(url,options);return options!==undefined?new _Worker(wrapped,options):new _Worker(wrapped);};",
318
+ "window.Worker.prototype=_Worker.prototype;",
319
+ "}",
320
+ // --- WebSocket ---
321
+ // SPAs like OpenWebUI use socket.io over WebSocket. The socket.io client
322
+ // builds ws:// URLs from window.location and the configured path; the URL
323
+ // ends up pointing at the panel root (e.g. ws://panel:8090/ws/socket.io)
324
+ // instead of the capability proxy. Without this patch the WS upgrade
325
+ // request either gets destroyed (no route) or hits the wrong backend.
326
+ "var _WS=window.WebSocket;",
327
+ "if(typeof _WS==='function'){",
328
+ "window.WebSocket=function(url,protocols){",
329
+ "url=rwWs(url);",
330
+ "return protocols!==undefined?new _WS(url,protocols):new _WS(url);",
331
+ "};",
332
+ "window.WebSocket.prototype=_WS.prototype;",
333
+ "window.WebSocket.CONNECTING=_WS.CONNECTING;",
334
+ "window.WebSocket.OPEN=_WS.OPEN;",
335
+ "window.WebSocket.CLOSING=_WS.CLOSING;",
336
+ "window.WebSocket.CLOSED=_WS.CLOSED;",
337
+ "}",
338
+ // --- EventSource ---
339
+ // Some frameworks use SSE (Server-Sent Events) for real-time updates.
340
+ "var _ES=window.EventSource;",
341
+ "if(typeof _ES==='function'){",
342
+ "window.EventSource=function(url,opts){",
343
+ "if(typeof url==='string')url=rw(url);",
344
+ "return new _ES(url,opts);",
345
+ "};",
346
+ "window.EventSource.prototype=_ES.prototype;",
347
+ "window.EventSource.CONNECTING=_ES.CONNECTING;",
348
+ "window.EventSource.OPEN=_ES.OPEN;",
349
+ "window.EventSource.CLOSED=_ES.CLOSED;",
350
+ "}",
351
+ "})();</script>",
352
+ ].join("");
353
+ }
354
+ function rewriteProxyTextBody(body, contentType, proxyBasePath, extraHeadHtml = "") {
64
355
  const value = (contentType ?? "").toLowerCase();
65
356
  const proxyBaseWithSlash = `${proxyBasePath.replace(/\/+$/, "")}/`;
66
357
  let rewritten = body;
67
358
  if (value.includes("text/html")) {
359
+ // Rewrite asset URLs FIRST, then optionally inject a <base> tag.
360
+ // Reversing the order would let the regex below match (and double-
361
+ // prefix) the leading slash of the just-inserted `<base href="/api/...">`,
362
+ // producing
363
+ // <base href="/api/instances/X/provides/Y/api/instances/X/provides/Y/">
364
+ // which then resolves every relative asset to a 404.
365
+ rewritten = rewritten.replace(/((?:href|src|action|poster)=['"])\/(?!\/)/gi, `$1${proxyBaseWithSlash}`);
366
+ // Rewrite dynamic import() paths inside inline <script> blocks so that
367
+ // SvelteKit (and similar frameworks) resolve JS modules through the proxy.
368
+ // Matches import("/_app/...") and import('/_app/...').
369
+ rewritten = rewritten.replace(/\bimport\(\s*(['"])\/(?!\/)/g, `import($1${proxyBaseWithSlash}`);
370
+ // Rewrite SvelteKit's client-side base path so that client-side routing
371
+ // and subsequent chunk fetches go through the capability proxy path.
372
+ // Older SvelteKit SSR output: __sveltekit_XXXXX = { base: "" };
373
+ rewritten = rewritten.replace(/(__sveltekit_\w+\s*=\s*\{\s*base\s*:\s*)(["'])["']/, `$1$2${proxyBasePath.replace(/\/+$/, "")}$2`);
374
+ // SvelteKit 2.x start() config: paths: { base: "", assets: "..." }
375
+ rewritten = rewritten.replace(/(paths\s*:\s*\{\s*base\s*:\s*)(["'])["'](\s*,\s*assets\s*:)/, `$1$2${proxyBasePath.replace(/\/+$/, "")}$2$3`);
68
376
  if (!/<base\b/i.test(rewritten)) {
69
377
  if (/<head[^>]*>/i.test(rewritten)) {
70
378
  rewritten = rewritten.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBaseWithSlash}">`);
@@ -73,11 +381,25 @@ function rewriteProxyTextBody(body, contentType, proxyBasePath) {
73
381
  rewritten = `<base href="${proxyBaseWithSlash}">${rewritten}`;
74
382
  }
75
383
  }
76
- rewritten = rewritten.replace(/((?:href|src|action|poster)=['"])\/(?!\/)/gi, `$1${proxyBaseWithSlash}`);
384
+ if (extraHeadHtml) {
385
+ if (/<base\b/i.test(rewritten)) {
386
+ rewritten = rewritten.replace(/<base\b[^>]*>/i, (match) => `${match}${extraHeadHtml}`);
387
+ }
388
+ else if (/<head[^>]*>/i.test(rewritten)) {
389
+ rewritten = rewritten.replace(/<head([^>]*)>/i, `<head$1>${extraHeadHtml}`);
390
+ }
391
+ else {
392
+ rewritten = `${extraHeadHtml}${rewritten}`;
393
+ }
394
+ }
77
395
  }
78
- if (value.includes("text/css") || value.includes("text/html")) {
396
+ if (value.includes("text/css")) {
79
397
  rewritten = rewritten.replace(/url\((['"]?)\/(?!\/)/gi, `url($1${proxyBaseWithSlash}`);
80
398
  }
399
+ else if (value.includes("text/html")) {
400
+ // HTML can contain inline scripts like new URL("/..."); only rewrite lowercase CSS url(...).
401
+ rewritten = rewritten.replace(/url\((['"]?)\/(?!\/)/g, `url($1${proxyBaseWithSlash}`);
402
+ }
81
403
  return rewritten;
82
404
  }
83
405
  function buildProxyRequestBody(req) {
@@ -202,13 +524,19 @@ async function proxyProvidedCapability(req, reply) {
202
524
  if (typeof capability.port !== "number" || capability.port < 1) {
203
525
  return reply.status(500).send({ detail: `Capability '${req.params.capability}' has no resolved port` });
204
526
  }
205
- const upstreamHost = instanceManager.getListeningHostForPort(capability.port);
527
+ const upstreamHost = await instanceManager.getHostForAppPort(req.params.id, capability.port);
206
528
  const upstreamOrigin = `${capability.protocol}://${instanceManager.urlHost(upstreamHost)}:${capability.port}`;
207
529
  const wildcardSuffix = typeof req.params["*"] === "string" ? req.params["*"] : "";
208
- const upstreamPath = joinUpstreamPath(capability.path, wildcardSuffix);
530
+ const proxyBasePath = capabilityProxyPath(req.params.id, req.params.capability);
209
531
  const querySuffix = req.raw.url?.includes("?") ? req.raw.url.slice(req.raw.url.indexOf("?")) : "";
532
+ const requestPath = req.raw.url?.split("?")[0] ?? "";
533
+ const canonicalProxyBase = canonicalCapabilityProxyBase(proxyBasePath, capability.path);
534
+ if (!wildcardSuffix && canonicalProxyBase !== proxyBasePath && !requestPath.endsWith("/")) {
535
+ reply.code(308).header("location", `${canonicalProxyBase}${querySuffix}`);
536
+ return reply.send();
537
+ }
538
+ const upstreamPath = joinUpstreamPath(capability.path, wildcardSuffix);
210
539
  const targetUrl = `${upstreamOrigin}${upstreamPath}${querySuffix}`;
211
- const proxyBasePath = capabilityProxyPath(req.params.id, req.params.capability);
212
540
  const headers = new Headers();
213
541
  for (const [key, value] of Object.entries(req.headers)) {
214
542
  if (value == null)
@@ -226,6 +554,8 @@ async function proxyProvidedCapability(req, reply) {
226
554
  }
227
555
  }
228
556
  headers.set("accept-encoding", "identity");
557
+ if (headers.has("origin"))
558
+ headers.set("origin", upstreamOrigin);
229
559
  // `x-forwarded-prefix` is not a standard reverse-proxy header and some
230
560
  // upstream frameworks (notably SvelteKit apps like Hollama) treat it as a
231
561
  // deployment base path, which breaks `/_app/*` asset resolution under this
@@ -233,30 +563,85 @@ async function proxyProvidedCapability(req, reply) {
233
563
  if (req.headers.host)
234
564
  headers.set("x-forwarded-host", String(req.headers.host));
235
565
  headers.set("x-forwarded-proto", req.protocol);
566
+ // Intercept service worker scripts BEFORE talking to upstream. SPAs like
567
+ // OpenWebUI register a SvelteKit service worker that aggressively caches
568
+ // HTML/JS at the proxy origin; once installed, the SW serves stale bodies
569
+ // and the page never receives our latest bootstrap (WebSocket / fetch
570
+ // monkey-patch). We replace the SW body with a self-unregistration stub so
571
+ // the next browser update cycle removes the offending worker and restores
572
+ // network-backed loading.
573
+ if (/(?:^|\/)(?:service-worker|sw)\.js$/i.test(requestPath)) {
574
+ reply
575
+ .code(200)
576
+ .header("content-type", "application/javascript; charset=utf-8")
577
+ .header("cache-control", "no-store, no-cache, must-revalidate, max-age=0")
578
+ .header("pragma", "no-cache")
579
+ .header("service-worker-allowed", "/");
580
+ return reply.send("// Capability proxy: service worker intentionally disabled.\n" +
581
+ "self.addEventListener('install',function(e){self.skipWaiting()});\n" +
582
+ "self.addEventListener('activate',function(e){\n" +
583
+ " e.waitUntil((async function(){\n" +
584
+ " try{var cs=await caches.keys();for(var i=0;i<cs.length;i++){try{await caches.delete(cs[i])}catch(_e){}}}catch(_e){}\n" +
585
+ " try{var clients=await self.clients.matchAll({includeUncontrolled:true});clients.forEach(function(c){try{c.navigate(c.url)}catch(_e){}})}catch(_e){}\n" +
586
+ " try{await self.registration.unregister()}catch(_e){}\n" +
587
+ " })());\n" +
588
+ "});\n");
589
+ }
590
+ // Single AbortController so we can cancel the upstream when the client
591
+ // disconnects. AbortSignal.timeout() only limits connection establishment;
592
+ // long-poll/SSE bodies (e.g. socket.io) would otherwise pin the fetch
593
+ // promise indefinitely and starve the event loop.
594
+ const upstreamAbort = new AbortController();
595
+ const connectTimer = setTimeout(() => upstreamAbort.abort(new Error("upstream connect timeout")), 30_000);
596
+ const onClientClose = () => upstreamAbort.abort();
597
+ req.raw.once("close", onClientClose);
236
598
  try {
237
599
  const upstream = await fetch(targetUrl, {
238
600
  method: req.method,
239
601
  headers,
240
602
  body: buildProxyRequestBody(req),
241
603
  redirect: "manual",
242
- signal: AbortSignal.timeout(60_000),
243
- });
604
+ signal: upstreamAbort.signal,
605
+ }).finally(() => clearTimeout(connectTimer));
606
+ const upstreamContentType = upstream.headers.get("content-type");
607
+ const willRewriteBody = shouldRewriteProxyResponse(upstreamContentType);
608
+ const willInjectHtml = (upstreamContentType ?? "").toLowerCase().includes("text/html");
244
609
  reply.code(upstream.status);
245
610
  upstream.headers.forEach((value, key) => {
246
611
  const normalizedKey = key.toLowerCase();
247
612
  if (HOP_BY_HOP.has(normalizedKey) || normalizedKey === "content-length" || normalizedKey === "content-encoding") {
248
613
  return;
249
614
  }
615
+ // When we rewrite the response body (HTML/CSS/JS), the upstream ETag /
616
+ // Cache-Control values describe the *original* upstream bytes — but the
617
+ // body the browser receives is post-rewrite (proxy-prefixed paths, JS
618
+ // hard-coded redirect targets, etc.). Honoring the upstream cache hints
619
+ // lets the browser pin a stale rewrite indefinitely: e.g. an early
620
+ // visit that pre-dated the JS rewrite gets cached and survives across
621
+ // panel restarts, breaking the auth redirect logic until a hard refresh.
622
+ // Strip cache validators and force revalidation on every load.
623
+ if (willRewriteBody && (normalizedKey === "cache-control" ||
624
+ normalizedKey === "etag" ||
625
+ normalizedKey === "last-modified" ||
626
+ normalizedKey === "expires" ||
627
+ normalizedKey === "pragma")) {
628
+ return;
629
+ }
630
+ if (willInjectHtml && (normalizedKey === "content-security-policy" ||
631
+ normalizedKey === "content-security-policy-report-only" ||
632
+ normalizedKey === "x-frame-options")) {
633
+ return;
634
+ }
250
635
  if (normalizedKey === "location") {
251
636
  if (value.startsWith("/")) {
252
- reply.header(key, joinProxyPath(proxyBasePath, value));
637
+ reply.header(key, rewriteCapabilityLocation(proxyBasePath, canonicalProxyBase, value));
253
638
  return;
254
639
  }
255
640
  try {
256
641
  const parsed = new URL(value);
257
642
  const upstreamBase = new URL(upstreamOrigin);
258
643
  if (parsed.origin === upstreamBase.origin) {
259
- reply.header(key, `${joinProxyPath(proxyBasePath, parsed.pathname)}${parsed.search}${parsed.hash}`);
644
+ reply.header(key, rewriteCapabilityLocation(proxyBasePath, canonicalProxyBase, parsed.pathname, parsed.search, parsed.hash));
260
645
  return;
261
646
  }
262
647
  }
@@ -267,16 +652,58 @@ async function proxyProvidedCapability(req, reply) {
267
652
  reply.header(key, value);
268
653
  });
269
654
  if (req.method === "HEAD") {
655
+ req.raw.off("close", onClientClose);
270
656
  return reply.send();
271
657
  }
272
- if (shouldRewriteProxyResponse(upstream.headers.get("content-type"))) {
273
- const rewritten = rewriteProxyTextBody(await upstream.text(), upstream.headers.get("content-type"), proxyBasePath);
658
+ if (willRewriteBody) {
659
+ // Pair with the cache-validator strip above.
660
+ reply.header("cache-control", "no-cache, no-store, must-revalidate");
661
+ reply.header("pragma", "no-cache");
662
+ reply.header("expires", "0");
663
+ let extraHeadHtml = "";
664
+ if (req.params.capability === "browserless-debugger") {
665
+ extraHeadHtml = browserlessDebuggerBootstrap(req.params.id);
666
+ }
667
+ // Inject a generic fetch/XHR monkey-patch for all capability-proxied
668
+ // HTML pages. SPA frameworks like SvelteKit compile absolute API paths
669
+ // (e.g. `/api/v1/...`, `/ollama/...`) into JS bundles at build time.
670
+ // When the page is served under the proxy path those requests bypass
671
+ // the proxy and hit the panel's own `/api/` routes instead. The patch
672
+ // intercepts fetch() and XMLHttpRequest.open() and rewrites same-origin
673
+ // absolute paths that do NOT already start with the proxy prefix.
674
+ extraHeadHtml += capabilityProxyBootstrap(proxyBasePath);
675
+ // First-visit cleanup: if the browser still has a stale ServiceWorker
676
+ // registered from an earlier panel build (which would intercept this
677
+ // navigation and serve cached HTML *without* the bootstrap patches),
678
+ // emit Clear-Site-Data so the browser drops the SW + its cache and
679
+ // reloads through the proxy. We mark the success with a long-lived
680
+ // cookie scoped to the proxy path to avoid a reload loop.
681
+ // Gate to HTML only — JS/CSS sub-resources also flow through this branch
682
+ // now that we rewrite JS bundles, and emitting Clear-Site-Data on a JS
683
+ // response would clear storage mid-page-load.
684
+ if (willInjectHtml) {
685
+ const cookieHeader = (req.headers.cookie || "").toString();
686
+ const swCleaned = /(?:^|;\s*)cap_proxy_sw_clean=1(?:;|$)/.test(cookieHeader);
687
+ if (!swCleaned) {
688
+ reply.header("Clear-Site-Data", '"cache", "storage"');
689
+ reply.header("Set-Cookie", `cap_proxy_sw_clean=1; Path=${proxyBasePath}; Max-Age=2592000; SameSite=Lax`);
690
+ }
691
+ }
692
+ const rawBody = await upstream.text();
693
+ req.raw.off("close", onClientClose);
694
+ const rewritten = rewriteProxyTextBody(rawBody, upstreamContentType, proxyBasePath, extraHeadHtml);
274
695
  return reply.send(rewritten);
275
696
  }
276
- const buffer = Buffer.from(await upstream.arrayBuffer());
277
- return reply.send(buffer);
697
+ if (!upstream.body) {
698
+ req.raw.off("close", onClientClose);
699
+ return reply.send();
700
+ }
701
+ const readable = Readable.fromWeb(upstream.body);
702
+ readable.once("close", () => req.raw.off("close", onClientClose));
703
+ return reply.send(readable);
278
704
  }
279
705
  catch (error) {
706
+ req.raw.off("close", onClientClose);
280
707
  return reply.status(502).send({ detail: error?.message || `Failed to proxy capability '${req.params.capability}'` });
281
708
  }
282
709
  }
@@ -327,9 +754,38 @@ function isLegacyInstanceAppType(value) {
327
754
  function getInstanceBackedInstalledApp(instanceId) {
328
755
  return instanceManager.getApp(instanceId);
329
756
  }
757
+ /**
758
+ * Resolve the spec used to drive the Connections feature.
759
+ *
760
+ * For V2 app-installed instances (`meta.app_id` set), pull the persisted
761
+ * `app-spec.yaml` via app-manager. For legacy hermes/openclaw instances
762
+ * created via the old "新建实例" flow (no `app_id`, no spec on disk),
763
+ * synthesize an in-memory capability-only spec from the bundled
764
+ * `apps/<agentType>-container.yaml` template.
765
+ *
766
+ * Returning `legacy: true` lets the PUT handler skip the adapter-driven
767
+ * env injection that V2 expects — for legacy instances the apply hooks
768
+ * fall back to writing `instance.json["connections-env"]`, which the
769
+ * legacy start path is responsible for merging into runtime.env.
770
+ */
771
+ async function loadConnectionsSpec(meta) {
772
+ const { app_id } = meta;
773
+ if (app_id) {
774
+ const appData = instanceManager.getApp(app_id);
775
+ if (!appData)
776
+ return null;
777
+ return { spec: appData.spec, legacy: false, appId: app_id };
778
+ }
779
+ const { loadCapabilitySpecForLegacyInstance } = await import("../services/runtime/migrations.js");
780
+ const synthetic = loadCapabilitySpecForLegacyInstance(meta);
781
+ if (!synthetic)
782
+ return null;
783
+ return { spec: synthetic, legacy: true, appId: meta.id };
784
+ }
330
785
  const DEFAULT_INSTANCE_TEMPLATE_BY_KIND = {
331
786
  ollama: "ollama-with-hollama-binary.yaml",
332
787
  };
788
+ const HIDDEN_CONNECTION_STATUS_CAPABILITIES = new Set(["mcp"]);
333
789
  function loadBuiltinAppSpecYaml(fileName) {
334
790
  const template = instanceManager.listBuiltinAppSpecs().find((entry) => entry.fileName === fileName);
335
791
  if (!template?.yaml) {
@@ -483,7 +939,7 @@ export async function instanceRoutes(app) {
483
939
  (typeof req.body.app_type === "string" && req.body.app_type.trim().length > 0);
484
940
  // Skip normalization for legacy app types (ollama / custom old marker):
485
941
  // these follow the V1 legacy short-circuit path (see
486
- // docs/app-dir-v2-plan.md §2.1 "Legacy Ollama 口径"), so we keep their
942
+ // docs/app-dir-v2-plan.md §2.1 "Legacy Ollama semantics"), so we keep their
487
943
  // ids verbatim rather than retroactively prefixing them.
488
944
  const shouldNormalize = (explicitRuntime || appSpecYaml.length > 0) && !legacyAppType;
489
945
  const kind = requestedKind === "hermes" ? "hermes"
@@ -828,7 +1284,15 @@ export async function instanceRoutes(app) {
828
1284
  payload.building = true;
829
1285
  if (resultRecord.taskId)
830
1286
  payload.taskId = resultRecord.taskId;
831
- return reply.status(400).send(payload);
1287
+ if (resultRecord.code)
1288
+ payload.code = resultRecord.code;
1289
+ // Honor the structured ConnectionError statusCode (412 / 409 / 400)
1290
+ // when present so the UI can distinguish missing-required from
1291
+ // ambiguous-prefix from invalid-binding without parsing the message.
1292
+ const statusCode = typeof resultRecord.statusCode === "number" && resultRecord.statusCode >= 400
1293
+ ? resultRecord.statusCode
1294
+ : 400;
1295
+ return reply.status(statusCode).send(payload);
832
1296
  }
833
1297
  return result;
834
1298
  });
@@ -1199,7 +1663,7 @@ export async function instanceRoutes(app) {
1199
1663
  return { lines: logLines };
1200
1664
  });
1201
1665
  // Admin: re-encrypt all instance secrets with current AES key
1202
- app.post("/api/admin/migrate-secrets", async (_req, reply) => {
1666
+ app.post("/api/admin/migrate-secrets", async (_req, _reply) => {
1203
1667
  const { getAesKey, getJwtSecret } = await import("../config.js");
1204
1668
  const { scryptSync, createDecipheriv, createCipheriv, randomBytes } = await import("crypto");
1205
1669
  const { readFileSync, existsSync: fsExistsSync } = await import("fs");
@@ -1314,6 +1778,351 @@ export async function instanceRoutes(app) {
1314
1778
  return reply.status(500).send({ detail: `Failed to pull image: ${e.message}` });
1315
1779
  }
1316
1780
  });
1781
+ // ── Connections REST API (PR 4 of app-interconnect-design) ──────────────
1782
+ /** GET /api/instances/:id/connections — view spec.requires + bindings. */
1783
+ app.get("/api/instances/:id/connections", async (req, reply) => {
1784
+ const idErr = validateId(req.params.id);
1785
+ if (idErr)
1786
+ return reply.status(400).send({ detail: idErr });
1787
+ const meta = instanceManager.getInstance(req.params.id);
1788
+ if (!meta)
1789
+ return reply.status(404).send({ detail: "Instance not found" });
1790
+ const specInfo = await loadConnectionsSpec(meta);
1791
+ if (!specInfo) {
1792
+ // Truly unknown agent type or missing spec — empty Connections view.
1793
+ return { requires: [], connections: {}, pending: [] };
1794
+ }
1795
+ const { resolveConnections } = await import("../services/connection-resolver.js");
1796
+ const capabilityRegistry = await import("../services/capability-registry.js");
1797
+ const persistedConnections = (meta.connections ?? {});
1798
+ const { resolved, pending } = resolveConnections(specInfo.spec, { connections: persistedConnections }, "preCreate");
1799
+ // For UI display: each require slot, candidates pulled from registry.
1800
+ // Two filters keep nonsensical bindings out of the dropdown:
1801
+ // 1. Self-binding — an instance must never appear in its own slot's
1802
+ // candidates. Without this, e.g. hermes-h shows up as a candidate
1803
+ // for hermes-h's own `llm` slot, which would create an infinite
1804
+ // self-call loop at apply time.
1805
+ // 2. Agent-on-agent — agent runtimes (hermes / openclaw) consume
1806
+ // `llm` for their internal reasoning. Letting them bind another
1807
+ // agent's `llm-agent` capability would chain agent → agent →
1808
+ // llm provider, which is structurally wrong (the inner agent is
1809
+ // not a model). Only true LLM model providers (capability
1810
+ // `llm-<vendor>` like `llm-ollama`, `llm-openai`) are valid.
1811
+ // The `llm-agent` capability stays available to non-agent
1812
+ // consumers (OpenWebUI etc.) where agent-as-LLM makes sense.
1813
+ const consumerAgentType = String(meta?.agentType ?? "");
1814
+ const consumerIsAgent = consumerAgentType === "hermes" || consumerAgentType === "openclaw";
1815
+ const requires = (specInfo.spec.requires ?? []).map((r) => {
1816
+ const isCategoryToken = ["llm", "search", "browser", "mcp", "files", "knowledge"].includes(r.capability);
1817
+ const candidates = isCategoryToken
1818
+ ? Object.entries(capabilityRegistry.snapshot().providersByCapability ?? {})
1819
+ .filter(([cap]) => cap.startsWith(r.capability + "-") || cap === r.capability)
1820
+ .flatMap(([_, list]) => list)
1821
+ : capabilityRegistry.listProviders(r.capability);
1822
+ const filtered = candidates.filter((c) => {
1823
+ if (c.instanceId === req.params.id)
1824
+ return false;
1825
+ if (consumerIsAgent && r.capability === "llm" && c.capability === "llm-agent")
1826
+ return false;
1827
+ return true;
1828
+ });
1829
+ return {
1830
+ capability: r.capability,
1831
+ inject_as: r.inject_as,
1832
+ required: r.required !== false,
1833
+ cardinality: r.cardinality ?? "one",
1834
+ apply: r.apply,
1835
+ category: isCategoryToken ? r.capability : "default",
1836
+ candidates: filtered.map((c) => ({
1837
+ providerId: c.instanceId,
1838
+ capability: c.capability,
1839
+ name: c.name,
1840
+ protocol: c.protocol,
1841
+ status: c.status,
1842
+ hostPort: c.hostPort,
1843
+ path: c.path,
1844
+ })),
1845
+ };
1846
+ });
1847
+ return {
1848
+ requires,
1849
+ connections: persistedConnections,
1850
+ pending: pending.map((p) => ({
1851
+ slot: p.slot,
1852
+ capability: p.capability,
1853
+ reason: p.reason,
1854
+ required: p.required,
1855
+ })),
1856
+ // resolved is internal — surface as debug to help UI understand state.
1857
+ _resolved: resolved.map((r) => ({
1858
+ slot: r.slot,
1859
+ capability: r.capability,
1860
+ category: r.category,
1861
+ source: r.source,
1862
+ providerCount: r.entries.length,
1863
+ })),
1864
+ };
1865
+ });
1866
+ /** PUT /api/instances/:id/connections — save bindings via transactor. */
1867
+ app.put("/api/instances/:id/connections", async (req, reply) => {
1868
+ const idErr = validateId(req.params.id);
1869
+ if (idErr)
1870
+ return reply.status(400).send({ detail: idErr });
1871
+ const meta = instanceManager.getInstance(req.params.id);
1872
+ if (!meta)
1873
+ return reply.status(404).send({ detail: "Instance not found" });
1874
+ const specInfo = await loadConnectionsSpec(meta);
1875
+ if (!specInfo) {
1876
+ return reply.status(400).send({
1877
+ detail: "Connections only available for app-installed instances",
1878
+ code: "NOT_APP_INSTANCE",
1879
+ });
1880
+ }
1881
+ const newConnections = (req.body?.connections ?? {});
1882
+ const { applyConnections } = await import("../services/connection-transactor.js");
1883
+ const safeJson = await import("../utils/safe-json.js");
1884
+ const fs = await import("fs");
1885
+ const path = await import("path");
1886
+ const instancePath = instanceManager.instanceMetaPath(req.params.id);
1887
+ const readInstanceJson = async (_id) => {
1888
+ const cur = safeJson.safeReadJson(instancePath, `instance:${req.params.id}`);
1889
+ return (cur ?? {});
1890
+ };
1891
+ const saveInstanceJson = async (_id, mutator) => {
1892
+ const cur = safeJson.safeReadJson(instancePath, `instance:${req.params.id}`) ?? {};
1893
+ const updated = mutator(cur);
1894
+ // Ensure parent dir exists (instance dir is always there for app instances).
1895
+ try {
1896
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
1897
+ }
1898
+ catch {
1899
+ /* noop */
1900
+ }
1901
+ safeJson.safeWriteJson(instancePath, updated);
1902
+ };
1903
+ // Adapter resolution — adapter-managed consumers route writeConnectionEnv
1904
+ // through adapter.applyConnectionEnv; generic apps go through
1905
+ // instance.json["connections-env"].
1906
+ let adapter = null;
1907
+ try {
1908
+ const agentType = resolveAgentType(meta);
1909
+ if (hasAdapter(agentType)) {
1910
+ adapter = getAdapter(agentType);
1911
+ // Only expose the optional methods if the adapter implements them.
1912
+ if (typeof adapter.applyConnectionEnv !== "function")
1913
+ adapter = null;
1914
+ }
1915
+ }
1916
+ catch {
1917
+ adapter = null;
1918
+ }
1919
+ try {
1920
+ const result = await applyConnections({
1921
+ instance: meta,
1922
+ spec: specInfo.spec,
1923
+ newConnections,
1924
+ saveInstanceJson,
1925
+ readInstanceJson,
1926
+ adapter,
1927
+ });
1928
+ return {
1929
+ ok: true,
1930
+ resolved: result.resolved.map((r) => ({
1931
+ slot: r.slot,
1932
+ capability: r.capability,
1933
+ category: r.category,
1934
+ })),
1935
+ };
1936
+ }
1937
+ catch (e) {
1938
+ const status = e?.statusCode ?? 500;
1939
+ return reply.status(status).send({
1940
+ detail: e?.message ?? "Connection apply failed",
1941
+ code: e?.code,
1942
+ ...(e?.details ? { details: e.details } : {}),
1943
+ });
1944
+ }
1945
+ });
1946
+ /** GET /api/instances/:id/connection-status — UI badge state. */
1947
+ app.get("/api/instances/:id/connection-status", async (req, reply) => {
1948
+ const idErr = validateId(req.params.id);
1949
+ if (idErr)
1950
+ return reply.status(400).send({ detail: idErr });
1951
+ const meta = instanceManager.getInstance(req.params.id);
1952
+ if (!meta)
1953
+ return reply.status(404).send({ detail: "Instance not found" });
1954
+ const specInfo = await loadConnectionsSpec(meta);
1955
+ if (!specInfo)
1956
+ return { state: "empty", unboundRequired: [], unboundOptional: [] };
1957
+ // An instance with no `requires` slots (pure provider — e.g. SearXNG,
1958
+ // Ollama, Browserless) has nothing to bind. Returning `ok` would
1959
+ // surface a misleading "Connected" badge in the UI; treat it as
1960
+ // empty so the badge / Connect-Apps menu entry hide entirely
1961
+ // (ConnectionsBadge already returns null on `empty`).
1962
+ if (!Array.isArray(specInfo.spec.requires) || specInfo.spec.requires.length === 0) {
1963
+ return { state: "empty", unboundRequired: [], unboundOptional: [] };
1964
+ }
1965
+ const { resolveConnections } = await import("../services/connection-resolver.js");
1966
+ try {
1967
+ const { pending, resolved } = resolveConnections(specInfo.spec, { connections: (meta.connections ?? {}) }, "preCreate");
1968
+ const hiddenSlots = new Set((specInfo.spec.requires ?? [])
1969
+ .filter((req) => HIDDEN_CONNECTION_STATUS_CAPABILITIES.has(req.capability))
1970
+ .map((req) => req.inject_as));
1971
+ const visibleRequires = (specInfo.spec.requires ?? []).filter((req) => !hiddenSlots.has(req.inject_as));
1972
+ if (visibleRequires.length === 0) {
1973
+ return { state: "empty", unboundRequired: [], unboundOptional: [], bindable: 0 };
1974
+ }
1975
+ // Stale bindings — slot is bound (in `resolved`) but at least one
1976
+ // bound provider entry is no longer running. preCreate mode skips
1977
+ // the runtime status check so these otherwise stay invisible to
1978
+ // the badge; surface them as synthetic pending so the badge drops
1979
+ // out of `ok`.
1980
+ const stalePending = resolved
1981
+ .filter((r) => r.entries.some((e) => e.status !== "running"))
1982
+ .map((r) => ({ slot: r.slot, capability: r.capability, required: r.required }));
1983
+ const staleSlots = new Set(stalePending.map((p) => p.slot));
1984
+ const allPending = [...pending, ...stalePending];
1985
+ const visiblePending = allPending.filter((p) => !hiddenSlots.has(p.slot));
1986
+ const unboundRequired = visiblePending.filter((p) => p.required).map((p) => p.slot);
1987
+ const unboundOptional = visiblePending.filter((p) => !p.required).map((p) => p.slot);
1988
+ // Count slots that the user can act on right now — i.e. unbound
1989
+ // visible slots whose capability has at least one running candidate
1990
+ // in the registry. Hidden categories like MCP should neither show
1991
+ // cards in the UI nor inflate the badge count. Stale slots are
1992
+ // always actionable (re-bind or start the bound provider) so they
1993
+ // count even when no other candidate is running.
1994
+ const capabilityRegistry = await import("../services/capability-registry.js");
1995
+ const consumerAgentType = String(meta?.agentType ?? "");
1996
+ const consumerIsAgent = consumerAgentType === "hermes" || consumerAgentType === "openclaw";
1997
+ const isCategoryToken = (cap) => ["llm", "search", "browser", "mcp"].includes(cap);
1998
+ const enumerateRunning = (cap) => {
1999
+ const list = isCategoryToken(cap)
2000
+ ? Object.entries(capabilityRegistry.snapshot().providersByCapability ?? {})
2001
+ .filter(([k]) => k.startsWith(cap + "-") || k === cap)
2002
+ .flatMap(([_, v]) => v)
2003
+ : capabilityRegistry.listProviders(cap);
2004
+ return list.filter((c) => {
2005
+ if (c.status !== "running")
2006
+ return false;
2007
+ if (c.instanceId === req.params.id)
2008
+ return false;
2009
+ if (consumerIsAgent && cap === "llm" && c.capability === "llm-agent")
2010
+ return false;
2011
+ return true;
2012
+ });
2013
+ };
2014
+ const bindable = visiblePending.filter((p) => staleSlots.has(p.slot) || enumerateRunning(p.capability).length > 0).length;
2015
+ let state = "ok";
2016
+ if (unboundRequired.length > 0)
2017
+ state = "error";
2018
+ else if (bindable > 0)
2019
+ state = "warn";
2020
+ return { state, unboundRequired, unboundOptional, bindable };
2021
+ }
2022
+ catch (e) {
2023
+ return { state: "error", unboundRequired: [], unboundOptional: [], bindable: 0, error: e.message };
2024
+ }
2025
+ });
2026
+ // ── Suggestions API (PR 6) ─────────────────────────────────────────────
2027
+ app.get("/api/suggestions", async () => {
2028
+ const { computeSuggestions } = await import("../services/suggestions.js");
2029
+ return { suggestions: computeSuggestions() };
2030
+ });
2031
+ app.post("/api/suggestions/:id/apply", async (req, reply) => {
2032
+ const { computeSuggestions } = await import("../services/suggestions.js");
2033
+ const all = computeSuggestions();
2034
+ const target = all.find((s) => s.id === req.params.id);
2035
+ if (!target) {
2036
+ return reply.status(404).send({ detail: "Suggestion no longer applies" });
2037
+ }
2038
+ // Apply by issuing the equivalent PUT /connections — read current
2039
+ // bindings, splice in the new one for `slot`, persist via the
2040
+ // transactor.
2041
+ const meta = instanceManager.getInstance(target.consumerInstanceId);
2042
+ if (!meta)
2043
+ return reply.status(404).send({ detail: "Consumer instance not found" });
2044
+ // Resolve consumer spec — app-installed instances have it in the app
2045
+ // registry; legacy instances (no app_id) fall back to the
2046
+ // yaml-template synthesizer used everywhere else for legacy
2047
+ // capability work.
2048
+ let consumerSpec = null;
2049
+ if (target.appId) {
2050
+ const appData = instanceManager.getApp(target.appId);
2051
+ if (!appData)
2052
+ return reply.status(404).send({ detail: "App spec not found" });
2053
+ consumerSpec = appData.spec;
2054
+ }
2055
+ else {
2056
+ const { loadCapabilitySpecForLegacyInstance } = await import("../services/runtime/migrations.js");
2057
+ consumerSpec = loadCapabilitySpecForLegacyInstance(meta);
2058
+ if (!consumerSpec) {
2059
+ return reply.status(404).send({ detail: "No capability spec for legacy instance" });
2060
+ }
2061
+ }
2062
+ const newConnections = {
2063
+ ...(meta.connections ?? {}),
2064
+ [target.slot]: {
2065
+ kind: "single",
2066
+ providerId: target.candidate.providerId,
2067
+ capability: target.candidate.capability,
2068
+ },
2069
+ };
2070
+ const { applyConnections } = await import("../services/connection-transactor.js");
2071
+ const safeJson = await import("../utils/safe-json.js");
2072
+ const fs = await import("fs");
2073
+ const path = await import("path");
2074
+ const instancePath = instanceManager.instanceMetaPath(target.consumerInstanceId);
2075
+ const readInstanceJson = async () => (safeJson.safeReadJson(instancePath, `instance:${target.consumerInstanceId}`) ?? {});
2076
+ const saveInstanceJson = async (_id, mutator) => {
2077
+ const cur = (safeJson.safeReadJson(instancePath, `instance:${target.consumerInstanceId}`) ?? {});
2078
+ try {
2079
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
2080
+ }
2081
+ catch {
2082
+ /* noop */
2083
+ }
2084
+ safeJson.safeWriteJson(instancePath, mutator(cur));
2085
+ };
2086
+ let adapter = null;
2087
+ try {
2088
+ const agentType = resolveAgentType(meta);
2089
+ if (hasAdapter(agentType)) {
2090
+ adapter = getAdapter(agentType);
2091
+ if (typeof adapter.applyConnectionEnv !== "function")
2092
+ adapter = null;
2093
+ }
2094
+ }
2095
+ catch {
2096
+ adapter = null;
2097
+ }
2098
+ try {
2099
+ const result = await applyConnections({
2100
+ instance: meta,
2101
+ spec: consumerSpec,
2102
+ newConnections,
2103
+ saveInstanceJson,
2104
+ readInstanceJson,
2105
+ adapter,
2106
+ });
2107
+ return { ok: true, applied: target.id, resolved: result.resolved.length };
2108
+ }
2109
+ catch (e) {
2110
+ return reply.status(e?.statusCode ?? 500).send({
2111
+ detail: e?.message ?? "Suggestion apply failed",
2112
+ code: e?.code,
2113
+ });
2114
+ }
2115
+ });
2116
+ app.post("/api/suggestions/:id/dismiss", async (req, reply) => {
2117
+ const { dismissSuggestion } = await import("../services/suggestions.js");
2118
+ try {
2119
+ await dismissSuggestion(req.params.id);
2120
+ return { ok: true };
2121
+ }
2122
+ catch (e) {
2123
+ return reply.status(400).send({ detail: e?.message ?? "Invalid suggestion id" });
2124
+ }
2125
+ });
1317
2126
  // ── Adapter-owned routes (§32.2.5) ─────────────────────────────────────
1318
2127
  // Each registered runtime adapter may contribute its own HTTP endpoints.
1319
2128
  // OpenClaw owns plugins/mcporter/skills/feishu/weixin/usage/gateway-launch/