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