jishushell 0.4.24-beta.2 → 0.4.30

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 (167) hide show
  1. package/INSTALL-NOTICE +11 -0
  2. package/apps/browserless-chromium-container.yaml +78 -0
  3. package/apps/hermes-container.yaml +36 -2
  4. package/apps/ollama-binary.yaml +45 -8
  5. package/apps/ollama-cpu-container.yaml +8 -1
  6. package/apps/ollama-with-hollama-binary.yaml +45 -8
  7. package/apps/openclaw-binary.yaml +30 -1
  8. package/apps/openclaw-container.yaml +37 -2
  9. package/apps/openclaw-with-ollama-container.yaml +11 -2
  10. package/apps/openclaw-with-searxng-container.yaml +22 -2
  11. package/apps/openwebui-container.yaml +45 -1
  12. package/apps/playwright-container.yaml +7 -1
  13. package/apps/searxng-container.yaml +54 -4
  14. package/dist/cli/app.js +12 -2
  15. package/dist/cli/app.js.map +1 -1
  16. package/dist/cli/doctor.d.ts +12 -12
  17. package/dist/cli/doctor.js +242 -55
  18. package/dist/cli/doctor.js.map +1 -1
  19. package/dist/cli/llm.d.ts +4 -3
  20. package/dist/cli/llm.js +4 -3
  21. package/dist/cli/llm.js.map +1 -1
  22. package/dist/cli/panel.d.ts +6 -5
  23. package/dist/cli/panel.js +10 -9
  24. package/dist/cli/panel.js.map +1 -1
  25. package/dist/control.d.ts +7 -6
  26. package/dist/control.js +7 -6
  27. package/dist/control.js.map +1 -1
  28. package/dist/routes/agent-apps.d.ts +1 -1
  29. package/dist/routes/agent-apps.js +1 -1
  30. package/dist/routes/apps.js +44 -11
  31. package/dist/routes/apps.js.map +1 -1
  32. package/dist/routes/auth.js +3 -0
  33. package/dist/routes/auth.js.map +1 -1
  34. package/dist/routes/instances.js +787 -16
  35. package/dist/routes/instances.js.map +1 -1
  36. package/dist/routes/llm.js +24 -35
  37. package/dist/routes/llm.js.map +1 -1
  38. package/dist/routes/setup.js +1 -1
  39. package/dist/routes/setup.js.map +1 -1
  40. package/dist/server.d.ts +9 -0
  41. package/dist/server.js +410 -17
  42. package/dist/server.js.map +1 -1
  43. package/dist/services/agent-apps/catalog.js +4 -3
  44. package/dist/services/agent-apps/catalog.js.map +1 -1
  45. package/dist/services/agent-apps/index.d.ts +1 -1
  46. package/dist/services/agent-apps/index.js +1 -1
  47. package/dist/services/agent-apps/installers/adapter.d.ts +1 -1
  48. package/dist/services/agent-apps/installers/adapter.js +1 -1
  49. package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
  50. package/dist/services/agent-apps/installers/shell-script.js +3 -3
  51. package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
  52. package/dist/services/agent-apps/types.d.ts +2 -2
  53. package/dist/services/agent-apps/types.js +1 -1
  54. package/dist/services/app/app-manager.d.ts +24 -1
  55. package/dist/services/app/app-manager.js +490 -102
  56. package/dist/services/app/app-manager.js.map +1 -1
  57. package/dist/services/app/hermes-agent-manager.js +6 -4
  58. package/dist/services/app/hermes-agent-manager.js.map +1 -1
  59. package/dist/services/app/provide-resolver.d.ts +29 -0
  60. package/dist/services/app/provide-resolver.js +112 -0
  61. package/dist/services/app/provide-resolver.js.map +1 -0
  62. package/dist/services/capability-endpoint-validator.d.ts +41 -0
  63. package/dist/services/capability-endpoint-validator.js +104 -0
  64. package/dist/services/capability-endpoint-validator.js.map +1 -0
  65. package/dist/services/capability-health.d.ts +16 -0
  66. package/dist/services/capability-health.js +121 -0
  67. package/dist/services/capability-health.js.map +1 -0
  68. package/dist/services/capability-registry.d.ts +106 -0
  69. package/dist/services/capability-registry.js +313 -0
  70. package/dist/services/capability-registry.js.map +1 -0
  71. package/dist/services/connection-apply.d.ts +89 -0
  72. package/dist/services/connection-apply.js +421 -0
  73. package/dist/services/connection-apply.js.map +1 -0
  74. package/dist/services/connection-resolver.d.ts +65 -0
  75. package/dist/services/connection-resolver.js +281 -0
  76. package/dist/services/connection-resolver.js.map +1 -0
  77. package/dist/services/connection-transactor.d.ts +37 -0
  78. package/dist/services/connection-transactor.js +341 -0
  79. package/dist/services/connection-transactor.js.map +1 -0
  80. package/dist/services/instance-manager.d.ts +13 -0
  81. package/dist/services/instance-manager.js +137 -23
  82. package/dist/services/instance-manager.js.map +1 -1
  83. package/dist/services/llm-proxy/index.d.ts +16 -2
  84. package/dist/services/llm-proxy/index.js +48 -44
  85. package/dist/services/llm-proxy/index.js.map +1 -1
  86. package/dist/services/llm-proxy/probe.d.ts +6 -0
  87. package/dist/services/llm-proxy/probe.js +85 -0
  88. package/dist/services/llm-proxy/probe.js.map +1 -0
  89. package/dist/services/llm-proxy/ssrf.d.ts +1 -0
  90. package/dist/services/llm-proxy/ssrf.js +18 -7
  91. package/dist/services/llm-proxy/ssrf.js.map +1 -1
  92. package/dist/services/nomad-manager.js +375 -16
  93. package/dist/services/nomad-manager.js.map +1 -1
  94. package/dist/services/process-manager.js +1 -1
  95. package/dist/services/process-manager.js.map +1 -1
  96. package/dist/services/runtime/adapters/hermes.d.ts +30 -1
  97. package/dist/services/runtime/adapters/hermes.js +218 -5
  98. package/dist/services/runtime/adapters/hermes.js.map +1 -1
  99. package/dist/services/runtime/adapters/openclaw-mcporter.d.ts +45 -0
  100. package/dist/services/runtime/adapters/openclaw-mcporter.js +108 -0
  101. package/dist/services/runtime/adapters/openclaw-mcporter.js.map +1 -0
  102. package/dist/services/runtime/adapters/openclaw.d.ts +87 -0
  103. package/dist/services/runtime/adapters/openclaw.js +250 -2
  104. package/dist/services/runtime/adapters/openclaw.js.map +1 -1
  105. package/dist/services/runtime/mcp-shims/firewall.d.ts +26 -0
  106. package/dist/services/runtime/mcp-shims/firewall.js +129 -0
  107. package/dist/services/runtime/mcp-shims/firewall.js.map +1 -0
  108. package/dist/services/runtime/mcp-shims/searxng-shim.d.ts +27 -0
  109. package/dist/services/runtime/mcp-shims/searxng-shim.js +125 -0
  110. package/dist/services/runtime/mcp-shims/searxng-shim.js.map +1 -0
  111. package/dist/services/runtime/mcp-shims/write-mcp-entry.d.ts +83 -0
  112. package/dist/services/runtime/mcp-shims/write-mcp-entry.js +127 -0
  113. package/dist/services/runtime/mcp-shims/write-mcp-entry.js.map +1 -0
  114. package/dist/services/runtime/migrations.d.ts +8 -0
  115. package/dist/services/runtime/migrations.js +100 -0
  116. package/dist/services/runtime/migrations.js.map +1 -1
  117. package/dist/services/runtime/types.d.ts +15 -0
  118. package/dist/services/setup-manager.js +6 -6
  119. package/dist/services/setup-manager.js.map +1 -1
  120. package/dist/services/suggestions.d.ts +27 -0
  121. package/dist/services/suggestions.js +133 -0
  122. package/dist/services/suggestions.js.map +1 -0
  123. package/dist/services/task-registry.js +4 -2
  124. package/dist/services/task-registry.js.map +1 -1
  125. package/dist/services/telemetry/device-fingerprint.d.ts +1 -1
  126. package/dist/services/telemetry/device-fingerprint.js +1 -1
  127. package/dist/services/types-shim.d.ts +16 -0
  128. package/dist/services/types-shim.js +2 -0
  129. package/dist/services/types-shim.js.map +1 -0
  130. package/dist/types.d.ts +169 -1
  131. package/dist/utils/instance-lock.d.ts +22 -0
  132. package/dist/utils/instance-lock.js +48 -0
  133. package/dist/utils/instance-lock.js.map +1 -0
  134. package/dist/utils/safe-json.js +55 -22
  135. package/dist/utils/safe-json.js.map +1 -1
  136. package/install/jishu-install.sh +323 -26
  137. package/install/jishu-uninstall.sh +353 -20
  138. package/package.json +3 -1
  139. package/public/assets/Dashboard-rkWp-CXd.js +1 -0
  140. package/public/assets/{HermesChatPanel-D6JI6lLY.js → HermesChatPanel-_GHoklgo.js} +1 -1
  141. package/public/assets/HermesConfigForm-anDnwUp_.js +4 -0
  142. package/public/assets/{InitPassword-CFTKsED4.js → InitPassword-ZU9_-hDr.js} +1 -1
  143. package/public/assets/InstanceDetail-CN0FH1aw.js +92 -0
  144. package/public/assets/{Login-KB9qrtM0.js → Login-BItXqYAJ.js} +1 -1
  145. package/public/assets/NewInstance-BousE6kY.js +1 -0
  146. package/public/assets/ProviderRecommendations-DFYj7Fb6.js +1 -0
  147. package/public/assets/Settings-Bttc6QmM.js +1 -0
  148. package/public/assets/Setup-Bsxx1zgj.js +1 -0
  149. package/public/assets/{WeixinLoginPanel-gca0QTic.js → WeixinLoginPanel-DPZpAKgO.js} +2 -2
  150. package/public/assets/index-8xZy1z5k.css +1 -0
  151. package/public/assets/index-Dw3HhUYE.js +19 -0
  152. package/public/assets/providers-DtNXh9JD.js +1 -0
  153. package/public/assets/registry-5s2UB6is.js +2 -0
  154. package/public/index.html +2 -2
  155. package/scripts/check-app-spec.mjs +443 -0
  156. package/scripts/check-i18n.mjs +154 -0
  157. package/scripts/run.sh +4 -4
  158. package/public/assets/Dashboard-rh9qpYRR.js +0 -1
  159. package/public/assets/HermesConfigForm-DcbSemaj.js +0 -4
  160. package/public/assets/InstanceDetail-BhNIKA6Z.js +0 -91
  161. package/public/assets/NewInstance-CxkO8Hlq.js +0 -1
  162. package/public/assets/Settings-BVWJvOkU.js +0 -1
  163. package/public/assets/Setup-X-lzuaUT.js +0 -1
  164. package/public/assets/index-C8B0cFJM.js +0 -19
  165. package/public/assets/index-CPhVFEsx.css +0 -1
  166. package/public/assets/providers-V-vwrExZ.js +0 -1
  167. package/public/assets/registry-fVUSujib.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,293 @@ 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
+ // --- dynamic property assignment: img.src = '/static/...' ---
273
+ "function patchProp(tag,prop){",
274
+ "var d=Object.getOwnPropertyDescriptor(tag.prototype,prop);",
275
+ "if(!d||!d.set)return;",
276
+ "var orig=d.set;",
277
+ "Object.defineProperty(tag.prototype,prop,{",
278
+ "set:function(v){return orig.call(this,rw(v));},",
279
+ "get:d.get,configurable:true,enumerable:true});",
280
+ "}",
281
+ "patchProp(HTMLImageElement,'src');",
282
+ "patchProp(HTMLScriptElement,'src');",
283
+ "patchProp(HTMLLinkElement,'href');",
284
+ "patchProp(HTMLSourceElement,'src');",
285
+ // --- Worker monkey-patch ---
286
+ // Some SPA clients create socket.io/WebSocket connections from workers.
287
+ // Patch same-origin workers so the same proxy-prefixing rule applies there.
288
+ "var _Worker=window.Worker;",
289
+ `var _workerPrelude=${workerPrelude};`,
290
+ "function shouldWrapWorker(url){",
291
+ "try{var p=new URL(String(url),window.location.href);return p.protocol==='blob:'||p.protocol==='data:'||p.origin===O;}catch(_e){return false;}",
292
+ "}",
293
+ "function wrapWorker(url,options){",
294
+ "if(typeof _Worker!=='function'||!shouldWrapWorker(url))return url;",
295
+ "try{var resolved=new URL(String(url),window.location.href).href;",
296
+ "var isModule=!!(options&&options.type==='module');",
297
+ "var workerEnv='var __capProxyHost='+JSON.stringify(window.location.host)+';var __capProxyProtocol='+JSON.stringify(window.location.protocol)+';';",
298
+ "var source=isModule?workerEnv+_workerPrelude+'\\nimport '+JSON.stringify(resolved)+';':workerEnv+_workerPrelude+'\\nimportScripts('+JSON.stringify(resolved)+');';",
299
+ "return URL.createObjectURL(new Blob([source],{type:'text/javascript'}));",
300
+ "}catch(_e){return url;}",
301
+ "}",
302
+ "if(typeof _Worker==='function'){",
303
+ "window.Worker=function(url,options){var wrapped=wrapWorker(url,options);return options!==undefined?new _Worker(wrapped,options):new _Worker(wrapped);};",
304
+ "window.Worker.prototype=_Worker.prototype;",
305
+ "}",
306
+ // --- WebSocket ---
307
+ // SPAs like OpenWebUI use socket.io over WebSocket. The socket.io client
308
+ // builds ws:// URLs from window.location and the configured path; the URL
309
+ // ends up pointing at the panel root (e.g. ws://panel:8090/ws/socket.io)
310
+ // instead of the capability proxy. Without this patch the WS upgrade
311
+ // request either gets destroyed (no route) or hits the wrong backend.
312
+ "var _WS=window.WebSocket;",
313
+ "if(typeof _WS==='function'){",
314
+ "window.WebSocket=function(url,protocols){",
315
+ "url=rwWs(url);",
316
+ "return protocols!==undefined?new _WS(url,protocols):new _WS(url);",
317
+ "};",
318
+ "window.WebSocket.prototype=_WS.prototype;",
319
+ "window.WebSocket.CONNECTING=_WS.CONNECTING;",
320
+ "window.WebSocket.OPEN=_WS.OPEN;",
321
+ "window.WebSocket.CLOSING=_WS.CLOSING;",
322
+ "window.WebSocket.CLOSED=_WS.CLOSED;",
323
+ "}",
324
+ // --- EventSource ---
325
+ // Some frameworks use SSE (Server-Sent Events) for real-time updates.
326
+ "var _ES=window.EventSource;",
327
+ "if(typeof _ES==='function'){",
328
+ "window.EventSource=function(url,opts){",
329
+ "if(typeof url==='string')url=rw(url);",
330
+ "return new _ES(url,opts);",
331
+ "};",
332
+ "window.EventSource.prototype=_ES.prototype;",
333
+ "window.EventSource.CONNECTING=_ES.CONNECTING;",
334
+ "window.EventSource.OPEN=_ES.OPEN;",
335
+ "window.EventSource.CLOSED=_ES.CLOSED;",
336
+ "}",
337
+ "})();</script>",
338
+ ].join("");
339
+ }
340
+ function rewriteProxyTextBody(body, contentType, proxyBasePath, extraHeadHtml = "") {
64
341
  const value = (contentType ?? "").toLowerCase();
65
342
  const proxyBaseWithSlash = `${proxyBasePath.replace(/\/+$/, "")}/`;
66
343
  let rewritten = body;
67
344
  if (value.includes("text/html")) {
345
+ // Rewrite asset URLs FIRST, then optionally inject a <base> tag.
346
+ // Reversing the order would let the regex below match (and double-
347
+ // prefix) the leading slash of the just-inserted `<base href="/api/...">`,
348
+ // producing
349
+ // <base href="/api/instances/X/provides/Y/api/instances/X/provides/Y/">
350
+ // which then resolves every relative asset to a 404.
351
+ rewritten = rewritten.replace(/((?:href|src|action|poster)=['"])\/(?!\/)/gi, `$1${proxyBaseWithSlash}`);
352
+ // Rewrite dynamic import() paths inside inline <script> blocks so that
353
+ // SvelteKit (and similar frameworks) resolve JS modules through the proxy.
354
+ // Matches import("/_app/...") and import('/_app/...').
355
+ rewritten = rewritten.replace(/\bimport\(\s*(['"])\/(?!\/)/g, `import($1${proxyBaseWithSlash}`);
356
+ // Rewrite SvelteKit's client-side base path so that client-side routing
357
+ // and subsequent chunk fetches go through the capability proxy path.
358
+ // Older SvelteKit SSR output: __sveltekit_XXXXX = { base: "" };
359
+ rewritten = rewritten.replace(/(__sveltekit_\w+\s*=\s*\{\s*base\s*:\s*)(["'])["']/, `$1$2${proxyBasePath.replace(/\/+$/, "")}$2`);
360
+ // SvelteKit 2.x start() config: paths: { base: "", assets: "..." }
361
+ rewritten = rewritten.replace(/(paths\s*:\s*\{\s*base\s*:\s*)(["'])["'](\s*,\s*assets\s*:)/, `$1$2${proxyBasePath.replace(/\/+$/, "")}$2$3`);
68
362
  if (!/<base\b/i.test(rewritten)) {
69
363
  if (/<head[^>]*>/i.test(rewritten)) {
70
364
  rewritten = rewritten.replace(/<head([^>]*)>/i, `<head$1><base href="${proxyBaseWithSlash}">`);
@@ -73,11 +367,25 @@ function rewriteProxyTextBody(body, contentType, proxyBasePath) {
73
367
  rewritten = `<base href="${proxyBaseWithSlash}">${rewritten}`;
74
368
  }
75
369
  }
76
- rewritten = rewritten.replace(/((?:href|src|action|poster)=['"])\/(?!\/)/gi, `$1${proxyBaseWithSlash}`);
370
+ if (extraHeadHtml) {
371
+ if (/<base\b/i.test(rewritten)) {
372
+ rewritten = rewritten.replace(/<base\b[^>]*>/i, (match) => `${match}${extraHeadHtml}`);
373
+ }
374
+ else if (/<head[^>]*>/i.test(rewritten)) {
375
+ rewritten = rewritten.replace(/<head([^>]*)>/i, `<head$1>${extraHeadHtml}`);
376
+ }
377
+ else {
378
+ rewritten = `${extraHeadHtml}${rewritten}`;
379
+ }
380
+ }
77
381
  }
78
- if (value.includes("text/css") || value.includes("text/html")) {
382
+ if (value.includes("text/css")) {
79
383
  rewritten = rewritten.replace(/url\((['"]?)\/(?!\/)/gi, `url($1${proxyBaseWithSlash}`);
80
384
  }
385
+ else if (value.includes("text/html")) {
386
+ // HTML can contain inline scripts like new URL("/..."); only rewrite lowercase CSS url(...).
387
+ rewritten = rewritten.replace(/url\((['"]?)\/(?!\/)/g, `url($1${proxyBaseWithSlash}`);
388
+ }
81
389
  return rewritten;
82
390
  }
83
391
  function buildProxyRequestBody(req) {
@@ -202,13 +510,19 @@ async function proxyProvidedCapability(req, reply) {
202
510
  if (typeof capability.port !== "number" || capability.port < 1) {
203
511
  return reply.status(500).send({ detail: `Capability '${req.params.capability}' has no resolved port` });
204
512
  }
205
- const upstreamHost = instanceManager.getListeningHostForPort(capability.port);
513
+ const upstreamHost = await instanceManager.getHostForAppPort(req.params.id, capability.port);
206
514
  const upstreamOrigin = `${capability.protocol}://${instanceManager.urlHost(upstreamHost)}:${capability.port}`;
207
515
  const wildcardSuffix = typeof req.params["*"] === "string" ? req.params["*"] : "";
208
- const upstreamPath = joinUpstreamPath(capability.path, wildcardSuffix);
516
+ const proxyBasePath = capabilityProxyPath(req.params.id, req.params.capability);
209
517
  const querySuffix = req.raw.url?.includes("?") ? req.raw.url.slice(req.raw.url.indexOf("?")) : "";
518
+ const requestPath = req.raw.url?.split("?")[0] ?? "";
519
+ const canonicalProxyBase = canonicalCapabilityProxyBase(proxyBasePath, capability.path);
520
+ if (!wildcardSuffix && canonicalProxyBase !== proxyBasePath && !requestPath.endsWith("/")) {
521
+ reply.code(308).header("location", `${canonicalProxyBase}${querySuffix}`);
522
+ return reply.send();
523
+ }
524
+ const upstreamPath = joinUpstreamPath(capability.path, wildcardSuffix);
210
525
  const targetUrl = `${upstreamOrigin}${upstreamPath}${querySuffix}`;
211
- const proxyBasePath = capabilityProxyPath(req.params.id, req.params.capability);
212
526
  const headers = new Headers();
213
527
  for (const [key, value] of Object.entries(req.headers)) {
214
528
  if (value == null)
@@ -226,6 +540,8 @@ async function proxyProvidedCapability(req, reply) {
226
540
  }
227
541
  }
228
542
  headers.set("accept-encoding", "identity");
543
+ if (headers.has("origin"))
544
+ headers.set("origin", upstreamOrigin);
229
545
  // `x-forwarded-prefix` is not a standard reverse-proxy header and some
230
546
  // upstream frameworks (notably SvelteKit apps like Hollama) treat it as a
231
547
  // deployment base path, which breaks `/_app/*` asset resolution under this
@@ -233,30 +549,70 @@ async function proxyProvidedCapability(req, reply) {
233
549
  if (req.headers.host)
234
550
  headers.set("x-forwarded-host", String(req.headers.host));
235
551
  headers.set("x-forwarded-proto", req.protocol);
552
+ // Intercept service worker scripts BEFORE talking to upstream. SPAs like
553
+ // OpenWebUI register a SvelteKit service worker that aggressively caches
554
+ // HTML/JS at the proxy origin; once installed, the SW serves stale bodies
555
+ // and the page never receives our latest bootstrap (WebSocket / fetch
556
+ // monkey-patch). We replace the SW body with a self-unregistration stub so
557
+ // the next browser update cycle removes the offending worker and restores
558
+ // network-backed loading.
559
+ if (/(?:^|\/)(?:service-worker|sw)\.js$/i.test(requestPath)) {
560
+ reply
561
+ .code(200)
562
+ .header("content-type", "application/javascript; charset=utf-8")
563
+ .header("cache-control", "no-store, no-cache, must-revalidate, max-age=0")
564
+ .header("pragma", "no-cache")
565
+ .header("service-worker-allowed", "/");
566
+ return reply.send("// Capability proxy: service worker intentionally disabled.\n" +
567
+ "self.addEventListener('install',function(e){self.skipWaiting()});\n" +
568
+ "self.addEventListener('activate',function(e){\n" +
569
+ " e.waitUntil((async function(){\n" +
570
+ " 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" +
571
+ " try{var clients=await self.clients.matchAll({includeUncontrolled:true});clients.forEach(function(c){try{c.navigate(c.url)}catch(_e){}})}catch(_e){}\n" +
572
+ " try{await self.registration.unregister()}catch(_e){}\n" +
573
+ " })());\n" +
574
+ "});\n");
575
+ }
576
+ // Single AbortController so we can cancel the upstream when the client
577
+ // disconnects. AbortSignal.timeout() only limits connection establishment;
578
+ // long-poll/SSE bodies (e.g. socket.io) would otherwise pin the fetch
579
+ // promise indefinitely and starve the event loop.
580
+ const upstreamAbort = new AbortController();
581
+ const connectTimer = setTimeout(() => upstreamAbort.abort(new Error("upstream connect timeout")), 30_000);
582
+ const onClientClose = () => upstreamAbort.abort();
583
+ req.raw.once("close", onClientClose);
236
584
  try {
237
585
  const upstream = await fetch(targetUrl, {
238
586
  method: req.method,
239
587
  headers,
240
588
  body: buildProxyRequestBody(req),
241
589
  redirect: "manual",
242
- signal: AbortSignal.timeout(60_000),
243
- });
590
+ signal: upstreamAbort.signal,
591
+ }).finally(() => clearTimeout(connectTimer));
592
+ const upstreamContentType = upstream.headers.get("content-type");
593
+ const willRewriteBody = shouldRewriteProxyResponse(upstreamContentType);
594
+ const willInjectHtml = (upstreamContentType ?? "").toLowerCase().includes("text/html");
244
595
  reply.code(upstream.status);
245
596
  upstream.headers.forEach((value, key) => {
246
597
  const normalizedKey = key.toLowerCase();
247
598
  if (HOP_BY_HOP.has(normalizedKey) || normalizedKey === "content-length" || normalizedKey === "content-encoding") {
248
599
  return;
249
600
  }
601
+ if (willInjectHtml && (normalizedKey === "content-security-policy" ||
602
+ normalizedKey === "content-security-policy-report-only" ||
603
+ normalizedKey === "x-frame-options")) {
604
+ return;
605
+ }
250
606
  if (normalizedKey === "location") {
251
607
  if (value.startsWith("/")) {
252
- reply.header(key, joinProxyPath(proxyBasePath, value));
608
+ reply.header(key, rewriteCapabilityLocation(proxyBasePath, canonicalProxyBase, value));
253
609
  return;
254
610
  }
255
611
  try {
256
612
  const parsed = new URL(value);
257
613
  const upstreamBase = new URL(upstreamOrigin);
258
614
  if (parsed.origin === upstreamBase.origin) {
259
- reply.header(key, `${joinProxyPath(proxyBasePath, parsed.pathname)}${parsed.search}${parsed.hash}`);
615
+ reply.header(key, rewriteCapabilityLocation(proxyBasePath, canonicalProxyBase, parsed.pathname, parsed.search, parsed.hash));
260
616
  return;
261
617
  }
262
618
  }
@@ -267,16 +623,49 @@ async function proxyProvidedCapability(req, reply) {
267
623
  reply.header(key, value);
268
624
  });
269
625
  if (req.method === "HEAD") {
626
+ req.raw.off("close", onClientClose);
270
627
  return reply.send();
271
628
  }
272
- if (shouldRewriteProxyResponse(upstream.headers.get("content-type"))) {
273
- const rewritten = rewriteProxyTextBody(await upstream.text(), upstream.headers.get("content-type"), proxyBasePath);
629
+ if (willRewriteBody) {
630
+ let extraHeadHtml = "";
631
+ if (req.params.capability === "browserless-debugger") {
632
+ extraHeadHtml = browserlessDebuggerBootstrap(req.params.id);
633
+ }
634
+ // Inject a generic fetch/XHR monkey-patch for all capability-proxied
635
+ // HTML pages. SPA frameworks like SvelteKit compile absolute API paths
636
+ // (e.g. `/api/v1/...`, `/ollama/...`) into JS bundles at build time.
637
+ // When the page is served under the proxy path those requests bypass
638
+ // the proxy and hit the panel's own `/api/` routes instead. The patch
639
+ // intercepts fetch() and XMLHttpRequest.open() and rewrites same-origin
640
+ // absolute paths that do NOT already start with the proxy prefix.
641
+ extraHeadHtml += capabilityProxyBootstrap(proxyBasePath);
642
+ // First-visit cleanup: if the browser still has a stale ServiceWorker
643
+ // registered from an earlier panel build (which would intercept this
644
+ // navigation and serve cached HTML *without* the bootstrap patches),
645
+ // emit Clear-Site-Data so the browser drops the SW + its cache and
646
+ // reloads through the proxy. We mark the success with a long-lived
647
+ // cookie scoped to the proxy path to avoid a reload loop.
648
+ const cookieHeader = (req.headers.cookie || "").toString();
649
+ const swCleaned = /(?:^|;\s*)cap_proxy_sw_clean=1(?:;|$)/.test(cookieHeader);
650
+ if (!swCleaned) {
651
+ reply.header("Clear-Site-Data", '"cache", "storage"');
652
+ reply.header("Set-Cookie", `cap_proxy_sw_clean=1; Path=${proxyBasePath}; Max-Age=2592000; SameSite=Lax`);
653
+ }
654
+ const rawBody = await upstream.text();
655
+ req.raw.off("close", onClientClose);
656
+ const rewritten = rewriteProxyTextBody(rawBody, upstreamContentType, proxyBasePath, extraHeadHtml);
274
657
  return reply.send(rewritten);
275
658
  }
276
- const buffer = Buffer.from(await upstream.arrayBuffer());
277
- return reply.send(buffer);
659
+ if (!upstream.body) {
660
+ req.raw.off("close", onClientClose);
661
+ return reply.send();
662
+ }
663
+ const readable = Readable.fromWeb(upstream.body);
664
+ readable.once("close", () => req.raw.off("close", onClientClose));
665
+ return reply.send(readable);
278
666
  }
279
667
  catch (error) {
668
+ req.raw.off("close", onClientClose);
280
669
  return reply.status(502).send({ detail: error?.message || `Failed to proxy capability '${req.params.capability}'` });
281
670
  }
282
671
  }
@@ -327,9 +716,38 @@ function isLegacyInstanceAppType(value) {
327
716
  function getInstanceBackedInstalledApp(instanceId) {
328
717
  return instanceManager.getApp(instanceId);
329
718
  }
719
+ /**
720
+ * Resolve the spec used to drive the Connections feature.
721
+ *
722
+ * For V2 app-installed instances (`meta.app_id` set), pull the persisted
723
+ * `app-spec.yaml` via app-manager. For legacy hermes/openclaw instances
724
+ * created via the old "新建实例" flow (no `app_id`, no spec on disk),
725
+ * synthesize an in-memory capability-only spec from the bundled
726
+ * `apps/<agentType>-container.yaml` template.
727
+ *
728
+ * Returning `legacy: true` lets the PUT handler skip the adapter-driven
729
+ * env injection that V2 expects — for legacy instances the apply hooks
730
+ * fall back to writing `instance.json["connections-env"]`, which the
731
+ * legacy start path is responsible for merging into runtime.env.
732
+ */
733
+ async function loadConnectionsSpec(meta) {
734
+ const { app_id } = meta;
735
+ if (app_id) {
736
+ const appData = instanceManager.getApp(app_id);
737
+ if (!appData)
738
+ return null;
739
+ return { spec: appData.spec, legacy: false, appId: app_id };
740
+ }
741
+ const { loadCapabilitySpecForLegacyInstance } = await import("../services/runtime/migrations.js");
742
+ const synthetic = loadCapabilitySpecForLegacyInstance(meta);
743
+ if (!synthetic)
744
+ return null;
745
+ return { spec: synthetic, legacy: true, appId: meta.id };
746
+ }
330
747
  const DEFAULT_INSTANCE_TEMPLATE_BY_KIND = {
331
748
  ollama: "ollama-with-hollama-binary.yaml",
332
749
  };
750
+ const HIDDEN_CONNECTION_STATUS_CAPABILITIES = new Set(["mcp"]);
333
751
  function loadBuiltinAppSpecYaml(fileName) {
334
752
  const template = instanceManager.listBuiltinAppSpecs().find((entry) => entry.fileName === fileName);
335
753
  if (!template?.yaml) {
@@ -483,7 +901,7 @@ export async function instanceRoutes(app) {
483
901
  (typeof req.body.app_type === "string" && req.body.app_type.trim().length > 0);
484
902
  // Skip normalization for legacy app types (ollama / custom old marker):
485
903
  // these follow the V1 legacy short-circuit path (see
486
- // docs/app-dir-v2-plan.md §2.1 "Legacy Ollama 口径"), so we keep their
904
+ // docs/app-dir-v2-plan.md §2.1 "Legacy Ollama semantics"), so we keep their
487
905
  // ids verbatim rather than retroactively prefixing them.
488
906
  const shouldNormalize = (explicitRuntime || appSpecYaml.length > 0) && !legacyAppType;
489
907
  const kind = requestedKind === "hermes" ? "hermes"
@@ -828,7 +1246,15 @@ export async function instanceRoutes(app) {
828
1246
  payload.building = true;
829
1247
  if (resultRecord.taskId)
830
1248
  payload.taskId = resultRecord.taskId;
831
- return reply.status(400).send(payload);
1249
+ if (resultRecord.code)
1250
+ payload.code = resultRecord.code;
1251
+ // Honor the structured ConnectionError statusCode (412 / 409 / 400)
1252
+ // when present so the UI can distinguish missing-required from
1253
+ // ambiguous-prefix from invalid-binding without parsing the message.
1254
+ const statusCode = typeof resultRecord.statusCode === "number" && resultRecord.statusCode >= 400
1255
+ ? resultRecord.statusCode
1256
+ : 400;
1257
+ return reply.status(statusCode).send(payload);
832
1258
  }
833
1259
  return result;
834
1260
  });
@@ -1314,6 +1740,351 @@ export async function instanceRoutes(app) {
1314
1740
  return reply.status(500).send({ detail: `Failed to pull image: ${e.message}` });
1315
1741
  }
1316
1742
  });
1743
+ // ── Connections REST API (PR 4 of app-interconnect-design) ──────────────
1744
+ /** GET /api/instances/:id/connections — view spec.requires + bindings. */
1745
+ app.get("/api/instances/:id/connections", async (req, reply) => {
1746
+ const idErr = validateId(req.params.id);
1747
+ if (idErr)
1748
+ return reply.status(400).send({ detail: idErr });
1749
+ const meta = instanceManager.getInstance(req.params.id);
1750
+ if (!meta)
1751
+ return reply.status(404).send({ detail: "Instance not found" });
1752
+ const specInfo = await loadConnectionsSpec(meta);
1753
+ if (!specInfo) {
1754
+ // Truly unknown agent type or missing spec — empty Connections view.
1755
+ return { requires: [], connections: {}, pending: [] };
1756
+ }
1757
+ const { resolveConnections } = await import("../services/connection-resolver.js");
1758
+ const capabilityRegistry = await import("../services/capability-registry.js");
1759
+ const persistedConnections = (meta.connections ?? {});
1760
+ const { resolved, pending } = resolveConnections(specInfo.spec, { connections: persistedConnections }, "preCreate");
1761
+ // For UI display: each require slot, candidates pulled from registry.
1762
+ // Two filters keep nonsensical bindings out of the dropdown:
1763
+ // 1. Self-binding — an instance must never appear in its own slot's
1764
+ // candidates. Without this, e.g. hermes-h shows up as a candidate
1765
+ // for hermes-h's own `llm` slot, which would create an infinite
1766
+ // self-call loop at apply time.
1767
+ // 2. Agent-on-agent — agent runtimes (hermes / openclaw) consume
1768
+ // `llm` for their internal reasoning. Letting them bind another
1769
+ // agent's `llm-agent` capability would chain agent → agent →
1770
+ // llm provider, which is structurally wrong (the inner agent is
1771
+ // not a model). Only true LLM model providers (capability
1772
+ // `llm-<vendor>` like `llm-ollama`, `llm-openai`) are valid.
1773
+ // The `llm-agent` capability stays available to non-agent
1774
+ // consumers (OpenWebUI etc.) where agent-as-LLM makes sense.
1775
+ const consumerAgentType = String(meta?.agentType ?? "");
1776
+ const consumerIsAgent = consumerAgentType === "hermes" || consumerAgentType === "openclaw";
1777
+ const requires = (specInfo.spec.requires ?? []).map((r) => {
1778
+ const isCategoryToken = ["llm", "search", "browser", "mcp"].includes(r.capability);
1779
+ const candidates = isCategoryToken
1780
+ ? Object.entries(capabilityRegistry.snapshot().providersByCapability ?? {})
1781
+ .filter(([cap]) => cap.startsWith(r.capability + "-") || cap === r.capability)
1782
+ .flatMap(([_, list]) => list)
1783
+ : capabilityRegistry.listProviders(r.capability);
1784
+ const filtered = candidates.filter((c) => {
1785
+ if (c.instanceId === req.params.id)
1786
+ return false;
1787
+ if (consumerIsAgent && r.capability === "llm" && c.capability === "llm-agent")
1788
+ return false;
1789
+ return true;
1790
+ });
1791
+ return {
1792
+ capability: r.capability,
1793
+ inject_as: r.inject_as,
1794
+ required: r.required !== false,
1795
+ cardinality: r.cardinality ?? "one",
1796
+ apply: r.apply,
1797
+ category: isCategoryToken ? r.capability : "default",
1798
+ candidates: filtered.map((c) => ({
1799
+ providerId: c.instanceId,
1800
+ capability: c.capability,
1801
+ name: c.name,
1802
+ protocol: c.protocol,
1803
+ status: c.status,
1804
+ hostPort: c.hostPort,
1805
+ path: c.path,
1806
+ })),
1807
+ };
1808
+ });
1809
+ return {
1810
+ requires,
1811
+ connections: persistedConnections,
1812
+ pending: pending.map((p) => ({
1813
+ slot: p.slot,
1814
+ capability: p.capability,
1815
+ reason: p.reason,
1816
+ required: p.required,
1817
+ })),
1818
+ // resolved is internal — surface as debug to help UI understand state.
1819
+ _resolved: resolved.map((r) => ({
1820
+ slot: r.slot,
1821
+ capability: r.capability,
1822
+ category: r.category,
1823
+ source: r.source,
1824
+ providerCount: r.entries.length,
1825
+ })),
1826
+ };
1827
+ });
1828
+ /** PUT /api/instances/:id/connections — save bindings via transactor. */
1829
+ app.put("/api/instances/:id/connections", async (req, reply) => {
1830
+ const idErr = validateId(req.params.id);
1831
+ if (idErr)
1832
+ return reply.status(400).send({ detail: idErr });
1833
+ const meta = instanceManager.getInstance(req.params.id);
1834
+ if (!meta)
1835
+ return reply.status(404).send({ detail: "Instance not found" });
1836
+ const specInfo = await loadConnectionsSpec(meta);
1837
+ if (!specInfo) {
1838
+ return reply.status(400).send({
1839
+ detail: "Connections only available for app-installed instances",
1840
+ code: "NOT_APP_INSTANCE",
1841
+ });
1842
+ }
1843
+ const newConnections = (req.body?.connections ?? {});
1844
+ const { applyConnections } = await import("../services/connection-transactor.js");
1845
+ const safeJson = await import("../utils/safe-json.js");
1846
+ const fs = await import("fs");
1847
+ const path = await import("path");
1848
+ const instancePath = instanceManager.instanceMetaPath(req.params.id);
1849
+ const readInstanceJson = async (_id) => {
1850
+ const cur = safeJson.safeReadJson(instancePath, `instance:${req.params.id}`);
1851
+ return (cur ?? {});
1852
+ };
1853
+ const saveInstanceJson = async (_id, mutator) => {
1854
+ const cur = safeJson.safeReadJson(instancePath, `instance:${req.params.id}`) ?? {};
1855
+ const updated = mutator(cur);
1856
+ // Ensure parent dir exists (instance dir is always there for app instances).
1857
+ try {
1858
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
1859
+ }
1860
+ catch {
1861
+ /* noop */
1862
+ }
1863
+ safeJson.safeWriteJson(instancePath, updated);
1864
+ };
1865
+ // Adapter resolution — adapter-managed consumers route writeConnectionEnv
1866
+ // through adapter.applyConnectionEnv; generic apps go through
1867
+ // instance.json["connections-env"].
1868
+ let adapter = null;
1869
+ try {
1870
+ const agentType = resolveAgentType(meta);
1871
+ if (hasAdapter(agentType)) {
1872
+ adapter = getAdapter(agentType);
1873
+ // Only expose the optional methods if the adapter implements them.
1874
+ if (typeof adapter.applyConnectionEnv !== "function")
1875
+ adapter = null;
1876
+ }
1877
+ }
1878
+ catch {
1879
+ adapter = null;
1880
+ }
1881
+ try {
1882
+ const result = await applyConnections({
1883
+ instance: meta,
1884
+ spec: specInfo.spec,
1885
+ newConnections,
1886
+ saveInstanceJson,
1887
+ readInstanceJson,
1888
+ adapter,
1889
+ });
1890
+ return {
1891
+ ok: true,
1892
+ resolved: result.resolved.map((r) => ({
1893
+ slot: r.slot,
1894
+ capability: r.capability,
1895
+ category: r.category,
1896
+ })),
1897
+ };
1898
+ }
1899
+ catch (e) {
1900
+ const status = e?.statusCode ?? 500;
1901
+ return reply.status(status).send({
1902
+ detail: e?.message ?? "Connection apply failed",
1903
+ code: e?.code,
1904
+ ...(e?.details ? { details: e.details } : {}),
1905
+ });
1906
+ }
1907
+ });
1908
+ /** GET /api/instances/:id/connection-status — UI badge state. */
1909
+ app.get("/api/instances/:id/connection-status", async (req, reply) => {
1910
+ const idErr = validateId(req.params.id);
1911
+ if (idErr)
1912
+ return reply.status(400).send({ detail: idErr });
1913
+ const meta = instanceManager.getInstance(req.params.id);
1914
+ if (!meta)
1915
+ return reply.status(404).send({ detail: "Instance not found" });
1916
+ const specInfo = await loadConnectionsSpec(meta);
1917
+ if (!specInfo)
1918
+ return { state: "empty", unboundRequired: [], unboundOptional: [] };
1919
+ // An instance with no `requires` slots (pure provider — e.g. SearXNG,
1920
+ // Ollama, Browserless) has nothing to bind. Returning `ok` would
1921
+ // surface a misleading "Connected" badge in the UI; treat it as
1922
+ // empty so the badge / Connect-Apps menu entry hide entirely
1923
+ // (ConnectionsBadge already returns null on `empty`).
1924
+ if (!Array.isArray(specInfo.spec.requires) || specInfo.spec.requires.length === 0) {
1925
+ return { state: "empty", unboundRequired: [], unboundOptional: [] };
1926
+ }
1927
+ const { resolveConnections } = await import("../services/connection-resolver.js");
1928
+ try {
1929
+ const { pending, resolved } = resolveConnections(specInfo.spec, { connections: (meta.connections ?? {}) }, "preCreate");
1930
+ const hiddenSlots = new Set((specInfo.spec.requires ?? [])
1931
+ .filter((req) => HIDDEN_CONNECTION_STATUS_CAPABILITIES.has(req.capability))
1932
+ .map((req) => req.inject_as));
1933
+ const visibleRequires = (specInfo.spec.requires ?? []).filter((req) => !hiddenSlots.has(req.inject_as));
1934
+ if (visibleRequires.length === 0) {
1935
+ return { state: "empty", unboundRequired: [], unboundOptional: [], bindable: 0 };
1936
+ }
1937
+ // Stale bindings — slot is bound (in `resolved`) but at least one
1938
+ // bound provider entry is no longer running. preCreate mode skips
1939
+ // the runtime status check so these otherwise stay invisible to
1940
+ // the badge; surface them as synthetic pending so the badge drops
1941
+ // out of `ok`.
1942
+ const stalePending = resolved
1943
+ .filter((r) => r.entries.some((e) => e.status !== "running"))
1944
+ .map((r) => ({ slot: r.slot, capability: r.capability, required: r.required }));
1945
+ const staleSlots = new Set(stalePending.map((p) => p.slot));
1946
+ const allPending = [...pending, ...stalePending];
1947
+ const visiblePending = allPending.filter((p) => !hiddenSlots.has(p.slot));
1948
+ const unboundRequired = visiblePending.filter((p) => p.required).map((p) => p.slot);
1949
+ const unboundOptional = visiblePending.filter((p) => !p.required).map((p) => p.slot);
1950
+ // Count slots that the user can act on right now — i.e. unbound
1951
+ // visible slots whose capability has at least one running candidate
1952
+ // in the registry. Hidden categories like MCP should neither show
1953
+ // cards in the UI nor inflate the badge count. Stale slots are
1954
+ // always actionable (re-bind or start the bound provider) so they
1955
+ // count even when no other candidate is running.
1956
+ const capabilityRegistry = await import("../services/capability-registry.js");
1957
+ const consumerAgentType = String(meta?.agentType ?? "");
1958
+ const consumerIsAgent = consumerAgentType === "hermes" || consumerAgentType === "openclaw";
1959
+ const isCategoryToken = (cap) => ["llm", "search", "browser", "mcp"].includes(cap);
1960
+ const enumerateRunning = (cap) => {
1961
+ const list = isCategoryToken(cap)
1962
+ ? Object.entries(capabilityRegistry.snapshot().providersByCapability ?? {})
1963
+ .filter(([k]) => k.startsWith(cap + "-") || k === cap)
1964
+ .flatMap(([_, v]) => v)
1965
+ : capabilityRegistry.listProviders(cap);
1966
+ return list.filter((c) => {
1967
+ if (c.status !== "running")
1968
+ return false;
1969
+ if (c.instanceId === req.params.id)
1970
+ return false;
1971
+ if (consumerIsAgent && cap === "llm" && c.capability === "llm-agent")
1972
+ return false;
1973
+ return true;
1974
+ });
1975
+ };
1976
+ const bindable = visiblePending.filter((p) => staleSlots.has(p.slot) || enumerateRunning(p.capability).length > 0).length;
1977
+ let state = "ok";
1978
+ if (unboundRequired.length > 0)
1979
+ state = "error";
1980
+ else if (bindable > 0)
1981
+ state = "warn";
1982
+ return { state, unboundRequired, unboundOptional, bindable };
1983
+ }
1984
+ catch (e) {
1985
+ return { state: "error", unboundRequired: [], unboundOptional: [], bindable: 0, error: e.message };
1986
+ }
1987
+ });
1988
+ // ── Suggestions API (PR 6) ─────────────────────────────────────────────
1989
+ app.get("/api/suggestions", async () => {
1990
+ const { computeSuggestions } = await import("../services/suggestions.js");
1991
+ return { suggestions: computeSuggestions() };
1992
+ });
1993
+ app.post("/api/suggestions/:id/apply", async (req, reply) => {
1994
+ const { computeSuggestions } = await import("../services/suggestions.js");
1995
+ const all = computeSuggestions();
1996
+ const target = all.find((s) => s.id === req.params.id);
1997
+ if (!target) {
1998
+ return reply.status(404).send({ detail: "Suggestion no longer applies" });
1999
+ }
2000
+ // Apply by issuing the equivalent PUT /connections — read current
2001
+ // bindings, splice in the new one for `slot`, persist via the
2002
+ // transactor.
2003
+ const meta = instanceManager.getInstance(target.consumerInstanceId);
2004
+ if (!meta)
2005
+ return reply.status(404).send({ detail: "Consumer instance not found" });
2006
+ // Resolve consumer spec — app-installed instances have it in the app
2007
+ // registry; legacy instances (no app_id) fall back to the
2008
+ // yaml-template synthesizer used everywhere else for legacy
2009
+ // capability work.
2010
+ let consumerSpec = null;
2011
+ if (target.appId) {
2012
+ const appData = instanceManager.getApp(target.appId);
2013
+ if (!appData)
2014
+ return reply.status(404).send({ detail: "App spec not found" });
2015
+ consumerSpec = appData.spec;
2016
+ }
2017
+ else {
2018
+ const { loadCapabilitySpecForLegacyInstance } = await import("../services/runtime/migrations.js");
2019
+ consumerSpec = loadCapabilitySpecForLegacyInstance(meta);
2020
+ if (!consumerSpec) {
2021
+ return reply.status(404).send({ detail: "No capability spec for legacy instance" });
2022
+ }
2023
+ }
2024
+ const newConnections = {
2025
+ ...(meta.connections ?? {}),
2026
+ [target.slot]: {
2027
+ kind: "single",
2028
+ providerId: target.candidate.providerId,
2029
+ capability: target.candidate.capability,
2030
+ },
2031
+ };
2032
+ const { applyConnections } = await import("../services/connection-transactor.js");
2033
+ const safeJson = await import("../utils/safe-json.js");
2034
+ const fs = await import("fs");
2035
+ const path = await import("path");
2036
+ const instancePath = instanceManager.instanceMetaPath(target.consumerInstanceId);
2037
+ const readInstanceJson = async () => (safeJson.safeReadJson(instancePath, `instance:${target.consumerInstanceId}`) ?? {});
2038
+ const saveInstanceJson = async (_id, mutator) => {
2039
+ const cur = (safeJson.safeReadJson(instancePath, `instance:${target.consumerInstanceId}`) ?? {});
2040
+ try {
2041
+ fs.mkdirSync(path.dirname(instancePath), { recursive: true });
2042
+ }
2043
+ catch {
2044
+ /* noop */
2045
+ }
2046
+ safeJson.safeWriteJson(instancePath, mutator(cur));
2047
+ };
2048
+ let adapter = null;
2049
+ try {
2050
+ const agentType = resolveAgentType(meta);
2051
+ if (hasAdapter(agentType)) {
2052
+ adapter = getAdapter(agentType);
2053
+ if (typeof adapter.applyConnectionEnv !== "function")
2054
+ adapter = null;
2055
+ }
2056
+ }
2057
+ catch {
2058
+ adapter = null;
2059
+ }
2060
+ try {
2061
+ const result = await applyConnections({
2062
+ instance: meta,
2063
+ spec: consumerSpec,
2064
+ newConnections,
2065
+ saveInstanceJson,
2066
+ readInstanceJson,
2067
+ adapter,
2068
+ });
2069
+ return { ok: true, applied: target.id, resolved: result.resolved.length };
2070
+ }
2071
+ catch (e) {
2072
+ return reply.status(e?.statusCode ?? 500).send({
2073
+ detail: e?.message ?? "Suggestion apply failed",
2074
+ code: e?.code,
2075
+ });
2076
+ }
2077
+ });
2078
+ app.post("/api/suggestions/:id/dismiss", async (req, reply) => {
2079
+ const { dismissSuggestion } = await import("../services/suggestions.js");
2080
+ try {
2081
+ await dismissSuggestion(req.params.id);
2082
+ return { ok: true };
2083
+ }
2084
+ catch (e) {
2085
+ return reply.status(400).send({ detail: e?.message ?? "Invalid suggestion id" });
2086
+ }
2087
+ });
1317
2088
  // ── Adapter-owned routes (§32.2.5) ─────────────────────────────────────
1318
2089
  // Each registered runtime adapter may contribute its own HTTP endpoints.
1319
2090
  // OpenClaw owns plugins/mcporter/skills/feishu/weixin/usage/gateway-launch/