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.
- package/INSTALL-NOTICE +11 -0
- package/apps/browserless-chromium-container.yaml +78 -0
- package/apps/hermes-container.yaml +36 -2
- package/apps/ollama-binary.yaml +45 -8
- package/apps/ollama-cpu-container.yaml +8 -1
- package/apps/ollama-with-hollama-binary.yaml +45 -8
- package/apps/openclaw-binary.yaml +30 -1
- package/apps/openclaw-container.yaml +37 -2
- package/apps/openclaw-with-ollama-container.yaml +11 -2
- package/apps/openclaw-with-searxng-container.yaml +22 -2
- package/apps/openwebui-container.yaml +45 -1
- package/apps/playwright-container.yaml +7 -1
- package/apps/searxng-container.yaml +54 -4
- package/dist/cli/app.js +12 -2
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/doctor.d.ts +12 -12
- package/dist/cli/doctor.js +242 -55
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/llm.d.ts +4 -3
- package/dist/cli/llm.js +4 -3
- package/dist/cli/llm.js.map +1 -1
- package/dist/cli/panel.d.ts +6 -5
- package/dist/cli/panel.js +10 -9
- package/dist/cli/panel.js.map +1 -1
- package/dist/control.d.ts +7 -6
- package/dist/control.js +7 -6
- package/dist/control.js.map +1 -1
- package/dist/routes/agent-apps.d.ts +1 -1
- package/dist/routes/agent-apps.js +1 -1
- package/dist/routes/apps.js +44 -11
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/auth.js +3 -0
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/instances.js +787 -16
- package/dist/routes/instances.js.map +1 -1
- package/dist/routes/llm.js +24 -35
- package/dist/routes/llm.js.map +1 -1
- package/dist/routes/setup.js +1 -1
- package/dist/routes/setup.js.map +1 -1
- package/dist/server.d.ts +9 -0
- package/dist/server.js +410 -17
- package/dist/server.js.map +1 -1
- package/dist/services/agent-apps/catalog.js +4 -3
- package/dist/services/agent-apps/catalog.js.map +1 -1
- package/dist/services/agent-apps/index.d.ts +1 -1
- package/dist/services/agent-apps/index.js +1 -1
- package/dist/services/agent-apps/installers/adapter.d.ts +1 -1
- package/dist/services/agent-apps/installers/adapter.js +1 -1
- package/dist/services/agent-apps/installers/shell-script.d.ts +1 -1
- package/dist/services/agent-apps/installers/shell-script.js +3 -3
- package/dist/services/agent-apps/installers/shell-script.js.map +1 -1
- package/dist/services/agent-apps/types.d.ts +2 -2
- package/dist/services/agent-apps/types.js +1 -1
- package/dist/services/app/app-manager.d.ts +24 -1
- package/dist/services/app/app-manager.js +490 -102
- package/dist/services/app/app-manager.js.map +1 -1
- package/dist/services/app/hermes-agent-manager.js +6 -4
- package/dist/services/app/hermes-agent-manager.js.map +1 -1
- package/dist/services/app/provide-resolver.d.ts +29 -0
- package/dist/services/app/provide-resolver.js +112 -0
- package/dist/services/app/provide-resolver.js.map +1 -0
- package/dist/services/capability-endpoint-validator.d.ts +41 -0
- package/dist/services/capability-endpoint-validator.js +104 -0
- package/dist/services/capability-endpoint-validator.js.map +1 -0
- package/dist/services/capability-health.d.ts +16 -0
- package/dist/services/capability-health.js +121 -0
- package/dist/services/capability-health.js.map +1 -0
- package/dist/services/capability-registry.d.ts +106 -0
- package/dist/services/capability-registry.js +313 -0
- package/dist/services/capability-registry.js.map +1 -0
- package/dist/services/connection-apply.d.ts +89 -0
- package/dist/services/connection-apply.js +421 -0
- package/dist/services/connection-apply.js.map +1 -0
- package/dist/services/connection-resolver.d.ts +65 -0
- package/dist/services/connection-resolver.js +281 -0
- package/dist/services/connection-resolver.js.map +1 -0
- package/dist/services/connection-transactor.d.ts +37 -0
- package/dist/services/connection-transactor.js +341 -0
- package/dist/services/connection-transactor.js.map +1 -0
- package/dist/services/instance-manager.d.ts +13 -0
- package/dist/services/instance-manager.js +137 -23
- package/dist/services/instance-manager.js.map +1 -1
- package/dist/services/llm-proxy/index.d.ts +16 -2
- package/dist/services/llm-proxy/index.js +48 -44
- package/dist/services/llm-proxy/index.js.map +1 -1
- package/dist/services/llm-proxy/probe.d.ts +6 -0
- package/dist/services/llm-proxy/probe.js +85 -0
- package/dist/services/llm-proxy/probe.js.map +1 -0
- package/dist/services/llm-proxy/ssrf.d.ts +1 -0
- package/dist/services/llm-proxy/ssrf.js +18 -7
- package/dist/services/llm-proxy/ssrf.js.map +1 -1
- package/dist/services/nomad-manager.js +375 -16
- package/dist/services/nomad-manager.js.map +1 -1
- package/dist/services/process-manager.js +1 -1
- package/dist/services/process-manager.js.map +1 -1
- package/dist/services/runtime/adapters/hermes.d.ts +30 -1
- package/dist/services/runtime/adapters/hermes.js +218 -5
- package/dist/services/runtime/adapters/hermes.js.map +1 -1
- package/dist/services/runtime/adapters/openclaw-mcporter.d.ts +45 -0
- package/dist/services/runtime/adapters/openclaw-mcporter.js +108 -0
- package/dist/services/runtime/adapters/openclaw-mcporter.js.map +1 -0
- package/dist/services/runtime/adapters/openclaw.d.ts +87 -0
- package/dist/services/runtime/adapters/openclaw.js +250 -2
- package/dist/services/runtime/adapters/openclaw.js.map +1 -1
- package/dist/services/runtime/mcp-shims/firewall.d.ts +26 -0
- package/dist/services/runtime/mcp-shims/firewall.js +129 -0
- package/dist/services/runtime/mcp-shims/firewall.js.map +1 -0
- package/dist/services/runtime/mcp-shims/searxng-shim.d.ts +27 -0
- package/dist/services/runtime/mcp-shims/searxng-shim.js +125 -0
- package/dist/services/runtime/mcp-shims/searxng-shim.js.map +1 -0
- package/dist/services/runtime/mcp-shims/write-mcp-entry.d.ts +83 -0
- package/dist/services/runtime/mcp-shims/write-mcp-entry.js +127 -0
- package/dist/services/runtime/mcp-shims/write-mcp-entry.js.map +1 -0
- package/dist/services/runtime/migrations.d.ts +8 -0
- package/dist/services/runtime/migrations.js +100 -0
- package/dist/services/runtime/migrations.js.map +1 -1
- package/dist/services/runtime/types.d.ts +15 -0
- package/dist/services/setup-manager.js +6 -6
- package/dist/services/setup-manager.js.map +1 -1
- package/dist/services/suggestions.d.ts +27 -0
- package/dist/services/suggestions.js +133 -0
- package/dist/services/suggestions.js.map +1 -0
- package/dist/services/task-registry.js +4 -2
- package/dist/services/task-registry.js.map +1 -1
- package/dist/services/telemetry/device-fingerprint.d.ts +1 -1
- package/dist/services/telemetry/device-fingerprint.js +1 -1
- package/dist/services/types-shim.d.ts +16 -0
- package/dist/services/types-shim.js +2 -0
- package/dist/services/types-shim.js.map +1 -0
- package/dist/types.d.ts +169 -1
- package/dist/utils/instance-lock.d.ts +22 -0
- package/dist/utils/instance-lock.js +48 -0
- package/dist/utils/instance-lock.js.map +1 -0
- package/dist/utils/safe-json.js +55 -22
- package/dist/utils/safe-json.js.map +1 -1
- package/install/jishu-install.sh +323 -26
- package/install/jishu-uninstall.sh +353 -20
- package/package.json +3 -1
- package/public/assets/Dashboard-rkWp-CXd.js +1 -0
- package/public/assets/{HermesChatPanel-D6JI6lLY.js → HermesChatPanel-_GHoklgo.js} +1 -1
- package/public/assets/HermesConfigForm-anDnwUp_.js +4 -0
- package/public/assets/{InitPassword-CFTKsED4.js → InitPassword-ZU9_-hDr.js} +1 -1
- package/public/assets/InstanceDetail-CN0FH1aw.js +92 -0
- package/public/assets/{Login-KB9qrtM0.js → Login-BItXqYAJ.js} +1 -1
- package/public/assets/NewInstance-BousE6kY.js +1 -0
- package/public/assets/ProviderRecommendations-DFYj7Fb6.js +1 -0
- package/public/assets/Settings-Bttc6QmM.js +1 -0
- package/public/assets/Setup-Bsxx1zgj.js +1 -0
- package/public/assets/{WeixinLoginPanel-gca0QTic.js → WeixinLoginPanel-DPZpAKgO.js} +2 -2
- package/public/assets/index-8xZy1z5k.css +1 -0
- package/public/assets/index-Dw3HhUYE.js +19 -0
- package/public/assets/providers-DtNXh9JD.js +1 -0
- package/public/assets/registry-5s2UB6is.js +2 -0
- package/public/index.html +2 -2
- package/scripts/check-app-spec.mjs +443 -0
- package/scripts/check-i18n.mjs +154 -0
- package/scripts/run.sh +4 -4
- package/public/assets/Dashboard-rh9qpYRR.js +0 -1
- package/public/assets/HermesConfigForm-DcbSemaj.js +0 -4
- package/public/assets/InstanceDetail-BhNIKA6Z.js +0 -91
- package/public/assets/NewInstance-CxkO8Hlq.js +0 -1
- package/public/assets/Settings-BVWJvOkU.js +0 -1
- package/public/assets/Setup-X-lzuaUT.js +0 -1
- package/public/assets/index-C8B0cFJM.js +0 -19
- package/public/assets/index-CPhVFEsx.css +0 -1
- package/public/assets/providers-V-vwrExZ.js +0 -1
- package/public/assets/registry-fVUSujib.js +0 -2
package/dist/routes/instances.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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")
|
|
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.
|
|
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
|
|
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:
|
|
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,
|
|
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,
|
|
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 (
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
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
|
-
|
|
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/
|