diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go index b30e4601c..9c8736efb 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -2,6 +2,7 @@ package server import ( "crypto/sha256" + _ "embed" "encoding/base64" "encoding/json" "io" @@ -18,6 +19,12 @@ import ( const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024 +// Keep the runtime as executable JavaScript so its DOM behavior can be tested +// directly. The very same bytes are used for HTML injection and CSP hashing. +// +//go:embed tunnel_runtime.js +var tunnelRuntimeScript string + type tunnelResponseRewriteKind int const ( @@ -220,20 +227,7 @@ func tunnelShimScriptBody(rw tunnelRewrite) string { if err != nil { return "" } - return `(function(config){` + - `if(window.__LIVEAGENT_TUNNEL__&&window.__LIVEAGENT_TUNNEL__.installed)return;` + - `var base=String(config.basePath||"").replace(/\/+$/,"");` + - `window.__LIVEAGENT_TUNNEL__={basePath:base,installed:true};` + - `function rw(input){if(input==null||!base)return input;var raw=input instanceof URL?input.href:String(input);var u;try{u=new URL(raw,location.href)}catch(_){return input}` + - `if(u.host!==location.host||!/^(http:|https:|ws:|wss:)$/i.test(u.protocol))return input;` + - `if(u.pathname===base||u.pathname.indexOf(base+"/")===0)return u.href;` + - `u.pathname=base+(u.pathname==="/"?"/":u.pathname);return u.href}` + - `function rwWs(input){var out=rw(input);try{var u=new URL(String(out),location.href);if(u.protocol==="http:")u.protocol="ws:";if(u.protocol==="https:")u.protocol="wss:";return u.href}catch(_){return out}}` + - `if(window.WebSocket){var NativeWebSocket=window.WebSocket;window.WebSocket=function(url,protocols){return new NativeWebSocket(rwWs(url),protocols)};window.WebSocket.prototype=NativeWebSocket.prototype;["CONNECTING","OPEN","CLOSING","CLOSED"].forEach(function(k){window.WebSocket[k]=NativeWebSocket[k]})}` + - `if(window.EventSource){var NativeEventSource=window.EventSource;window.EventSource=function(url,options){return new NativeEventSource(rw(url),options)};window.EventSource.prototype=NativeEventSource.prototype}` + - `if(window.fetch){var nativeFetch=window.fetch.bind(window);window.fetch=function(input,init){if(input instanceof Request)return nativeFetch(new Request(rw(input.url),input),init);return nativeFetch(rw(input),init)}}` + - `if(window.XMLHttpRequest){var open=window.XMLHttpRequest.prototype.open;window.XMLHttpRequest.prototype.open=function(method,url){arguments[1]=rw(url);return open.apply(this,arguments)}}` + - `})(` + string(config) + `);` + return strings.TrimSpace(tunnelRuntimeScript) + `(` + string(config) + `);` } func rewriteTunnelCSSURLToken(token string, rw tunnelRewrite) (string, bool) { diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go index c318f43d7..99bb60f3b 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -221,9 +221,9 @@ func TestRewriteTunnelHTMLBodyInjectsRuntimeShimBeforeFirstScript(t *testing.T) t.Fatalf("runtime shim was not injected before app script:\n%s", output) } assertContains(t, output, `"basePath":"/t/test-slug"`) - assertContains(t, output, `window.WebSocket=function`) - assertContains(t, output, `window.fetch=function`) - assertContains(t, output, `window.EventSource=function`) + assertContains(t, output, `window.WebSocket = function`) + assertContains(t, output, `window.fetch = function`) + assertContains(t, output, `window.EventSource = function`) assertContains(t, output, `XMLHttpRequest.prototype.open`) } diff --git a/crates/agent-gateway/internal/server/tunnel_runtime.js b/crates/agent-gateway/internal/server/tunnel_runtime.js new file mode 100644 index 000000000..6f30e5590 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_runtime.js @@ -0,0 +1,161 @@ +(function (config) { + if (window.__LIVEAGENT_TUNNEL__ && window.__LIVEAGENT_TUNNEL__.installed) return; + var base = String(config.basePath || "").replace(/\/+$/, ""); + window.__LIVEAGENT_TUNNEL__ = { basePath: base, installed: true }; + + function rw(input) { + if (input == null || !base) return input; + var raw = input instanceof URL ? input.href : String(input); + var u; + try { + u = new URL(raw, location.href); + } catch (_) { + return input; + } + if (u.host !== location.host || !/^(http:|https:|ws:|wss:)$/i.test(u.protocol)) return input; + if (u.pathname === base || u.pathname.indexOf(base + "/") === 0) return u.href; + u.pathname = base + (u.pathname === "/" ? "/" : u.pathname); + return u.href; + } + + function rwWs(input) { + var out = rw(input); + try { + var u = new URL(String(out), location.href); + if (u.protocol === "http:") u.protocol = "ws:"; + if (u.protocol === "https:") u.protocol = "wss:"; + return u.href; + } catch (_) { + return out; + } + } + + if (window.WebSocket) { + var NativeWebSocket = window.WebSocket; + window.WebSocket = function (url, protocols) { + return new NativeWebSocket(rwWs(url), protocols); + }; + window.WebSocket.prototype = NativeWebSocket.prototype; + ["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (k) { + window.WebSocket[k] = NativeWebSocket[k]; + }); + } + if (window.EventSource) { + var NativeEventSource = window.EventSource; + window.EventSource = function (url, options) { + return new NativeEventSource(rw(url), options); + }; + window.EventSource.prototype = NativeEventSource.prototype; + } + if (window.fetch) { + var nativeFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + if (input instanceof Request) return nativeFetch(new Request(rw(input.url), input), init); + return nativeFetch(rw(input), init); + }; + } + if (window.XMLHttpRequest) { + var open = window.XMLHttpRequest.prototype.open; + window.XMLHttpRequest.prototype.open = function (method, url) { + arguments[1] = rw(url); + return open.apply(this, arguments); + }; + } + + // Dynamic script/link requests are issued by the browser's resource loader, + // not window.fetch. Rewrite synchronously before invoking native setters; + // a MutationObserver runs too late to prevent a request to the gateway root. + function resourceURL(value) { + // Preserve relative URLs, external origins, non-HTTP schemes and typed + // values (e.g. TrustedScriptURL) instead of bypassing their native checks. + if (typeof value !== "string" || !base) return value; + var raw = value.trim(); + if (!/^(\/|https?:\/\/)/i.test(raw)) return value; + var u; + try { + u = new URL(raw, document.baseURI); + } catch (_) { + return value; + } + if (u.origin !== location.origin) return value; + if (u.pathname === base || u.pathname.indexOf(base + "/") === 0) return value; + return rw(u.href); + } + + function resourceAttribute(element) { + if (element.namespaceURI !== "http://www.w3.org/1999/xhtml") return ""; + if (element.localName === "script") return "src"; + if (element.localName === "link") return "href"; + return ""; + } + + function patchURLSetter(constructor, property) { + if (!constructor) return; + var prototype = constructor.prototype; + var descriptor = Object.getOwnPropertyDescriptor(prototype, property); + if (!descriptor || !descriptor.set || !descriptor.configurable) return; + var nativeSet = descriptor.set; + descriptor.set = function (value) { + return nativeSet.call(this, resourceURL(value)); + }; + Object.defineProperty(prototype, property, descriptor); + } + patchURLSetter(window.HTMLScriptElement, "src"); + patchURLSetter(window.HTMLLinkElement, "href"); + + var nativeSetAttribute = Element.prototype.setAttribute; + var nativeGetAttribute = Element.prototype.getAttribute; + Element.prototype.setAttribute = function (name, value) { + if (typeof name === "string" && name.toLowerCase() === resourceAttribute(this)) { + value = resourceURL(value); + } + return nativeSetAttribute.call(this, name, value); + }; + var nativeSetAttributeNS = Element.prototype.setAttributeNS; + Element.prototype.setAttributeNS = function (namespace, name, value) { + if ((namespace == null || namespace === "") && name === resourceAttribute(this)) { + value = resourceURL(value); + } + return nativeSetAttributeNS.call(this, namespace, name, value); + }; + + // Clones and nodes parsed in a detached fragment can bypass the setters. + // Normalize their resource attributes before insertion starts loading them. + function rewriteElement(element) { + var attribute = resourceAttribute(element); + if (!attribute) return; + var value = nativeGetAttribute.call(element, attribute); + var rewritten = resourceURL(value); + if (rewritten !== value) nativeSetAttribute.call(element, attribute, rewritten); + } + function rewriteTree(node) { + if (!node || typeof node !== "object") return; + if (node.nodeType === 1) rewriteElement(node); + if ((node.nodeType === 1 || node.nodeType === 11) && node.querySelectorAll) { + node.querySelectorAll("script[src],link[href]").forEach(rewriteElement); + } + } + function patchInsertion(prototype, name, allArguments) { + if (!prototype || typeof prototype[name] !== "function") return; + var native = prototype[name]; + prototype[name] = function () { + var count = allArguments ? arguments.length : Math.min(arguments.length, 1); + for (var i = 0; i < count; i++) rewriteTree(arguments[i]); + return native.apply(this, arguments); + }; + } + ["appendChild", "insertBefore", "replaceChild"].forEach(function (name) { + patchInsertion(Node.prototype, name, false); + }); + [Element.prototype, Document.prototype, DocumentFragment.prototype].forEach(function (prototype) { + ["append", "prepend", "replaceChildren"].forEach(function (name) { + patchInsertion(prototype, name, true); + }); + }); + [Element.prototype, CharacterData.prototype, DocumentType.prototype].forEach(function (prototype) { + ["before", "after", "replaceWith"].forEach(function (name) { + patchInsertion(prototype, name, true); + }); + }); + patchInsertion(Element.prototype, "insertAdjacentElement", true); +}) diff --git a/crates/agent-gateway/test/webui/tunnel-runtime.test.mjs b/crates/agent-gateway/test/webui/tunnel-runtime.test.mjs new file mode 100644 index 000000000..f0abfdd5b --- /dev/null +++ b/crates/agent-gateway/test/webui/tunnel-runtime.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(new URL("../../web/package.json", import.meta.url)); +const { JSDOM, VirtualConsole } = require("jsdom"); +const runtime = readFileSync(new URL("../../internal/server/tunnel_runtime.js", import.meta.url), "utf8"); +const prefix = "/t/runtime-test"; + +async function fixture(t) { + const requests = []; + const server = createServer((req, res) => { + requests.push(req.url); + if (!req.url.startsWith(`${prefix}/`)) { + res.writeHead(404).end("wrong root"); + } else if (req.url.includes(".js")) { + res.writeHead(200, { "Content-Type": "text/javascript" }); + res.end('window.executed = (window.executed || 0) + 1;'); + } else if (req.url.includes(".css")) { + res.writeHead(200, { "Content-Type": "text/css" }); + res.end("body { border-top-width: 7px; }"); + } else { + res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const origin = `http://127.0.0.1:${server.address().port}`; + const dom = new JSDOM("
", { + url: `${origin}${prefix}/`, + resources: "usable", + runScripts: "dangerously", + virtualConsole: new VirtualConsole(), + }); + t.after(() => dom.window.close()); + const { window } = dom; + const nativeSetAttribute = window.Element.prototype.setAttribute; + window.Request = Request; + window.fetch = fetch; + window.eval(`${runtime}(${JSON.stringify({ basePath: prefix })});`); + return { window, document: window.document, requests, origin, nativeSetAttribute }; +} + +function loaded(element) { + return new Promise((resolve, reject) => { + element.onload = resolve; + element.onerror = () => reject(new Error(`resource failed: ${element.src || element.href}`)); + }); +} + +test("dynamic scripts load and execute through the tunnel for native URL assignment paths", { timeout: 10000 }, async (t) => { + const { window, document, requests, origin } = await fixture(t); + const variants = [ + (script) => { script.src = "/property.js"; }, + (script) => { script.setAttribute("src", "/attribute.js?version=2"); }, + (script) => { script.setAttribute("SRC", "/uppercase.js"); }, + (script) => { script.setAttributeNS(null, "src", "/namespace.js"); }, + (script) => { script.src = `${origin}/absolute.js`; }, + (script) => { script.src = `//${new URL(origin).host}/protocol-relative.js`; }, + ]; + for (const connected of [false, true]) { + for (const assign of variants) { + const script = document.createElement("script"); + const done = loaded(script); + if (connected) document.head.append(script); + assign(script); + if (!connected) document.head.appendChild(script); + await done; + } + } + assert.equal(window.executed, variants.length * 2); + assert.equal(requests.length, variants.length * 2); + assert.ok(requests.every((url) => url.startsWith(`${prefix}/`)), requests.join("\n")); + assert.ok(requests.includes(`${prefix}/attribute.js?version=2`)); +}); + +test("dynamic stylesheets load via properties, attributes and detached HTML fragments", { timeout: 10000 }, async (t) => { + const { document, requests } = await fixture(t); + for (const useAttribute of [false, true]) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + const done = loaded(link); + document.head.append(link); + if (useAttribute) link.setAttribute("href", "/attribute.css"); + else link.href = "/property.css"; + await done; + assert.equal(link.sheet.cssRules.length, 1); + } + const template = document.createElement("template"); + template.innerHTML = ''; + const fragment = template.content.cloneNode(true); + const link = fragment.firstChild; + const done = loaded(link); + document.head.appendChild(fragment); + await done; + assert.equal(link.sheet.cssRules.length, 1); + assert.deepEqual(requests, [`${prefix}/property.css`, `${prefix}/attribute.css`, `${prefix}/fragment.css`]); +}); + +test("insertion APIs rewrite cloned resources before the first request", { timeout: 10000 }, async (t) => { + const { document, requests, nativeSetAttribute } = await fixture(t); + const insertions = [ + (node, anchor) => anchor.parentNode.appendChild(node), + (node, anchor) => anchor.parentNode.insertBefore(node, anchor), + (node, anchor) => anchor.parentNode.replaceChild(node, anchor), + (node, anchor) => anchor.parentNode.append(node), + (node, anchor) => anchor.parentNode.prepend(node), + (node, anchor) => anchor.parentNode.replaceChildren(node), + (node, anchor) => anchor.before(node), + (node, anchor) => anchor.after(node), + (node, anchor) => anchor.replaceWith(node), + (node, anchor) => anchor.insertAdjacentElement("afterend", node), + ]; + for (const [index, insert] of insertions.entries()) { + const container = document.createElement("div"); + const anchor = document.createElement("span"); + container.append(anchor); + document.body.append(container); + const source = document.createElement("script"); + nativeSetAttribute.call(source, "src", `/clone-${index}.js`); + const script = source.cloneNode(true); + const done = loaded(script); + insert(script, anchor); + await done; + } + assert.equal(requests.length, insertions.length); + assert.ok(requests.every((url) => url.startsWith(`${prefix}/clone-`)), requests.join("\n")); +}); + +test("resource rewriting preserves external, relative, typed and already-prefixed values", { timeout: 10000 }, async (t) => { + const { window, document, origin } = await fixture(t); + for (const value of [ + "./relative.js", "../relative.js", "https://cdn.example/lib.js", "//cdn.example/lib.js", + "data:text/javascript,void 0", "blob:https://example/id", `${prefix}/existing.js?v=1#fragment`, + `${origin}${prefix}/existing.js`, + ]) { + const script = document.createElement("script"); + script.src = value; + assert.equal(script.getAttribute("src"), value); + script.setAttribute("src", value); + assert.equal(script.getAttribute("src"), value); + } + const script = document.createElement("script"); + const typedValue = { toString: () => "/typed.js" }; + script.src = typedValue; + assert.equal(script.getAttribute("src"), "/typed.js"); + script.setAttribute("data-src", "/unrelated.js"); + assert.equal(script.getAttribute("data-src"), "/unrelated.js"); + const image = document.createElement("img"); + image.setAttribute("src", "/unchanged.png"); + assert.equal(image.getAttribute("src"), "/unchanged.png"); + const setter = Object.getOwnPropertyDescriptor(window.HTMLScriptElement.prototype, "src").set; + window.eval(`${runtime}(${JSON.stringify({ basePath: prefix })});`); + assert.equal(Object.getOwnPropertyDescriptor(window.HTMLScriptElement.prototype, "src").set, setter); +}); + +test("fetch Request and XHR still reach the target through the prefix", { timeout: 10000 }, async (t) => { + const { window, requests, origin } = await fixture(t); + const response = await window.fetch(new Request(`${origin}/api/health`)); + assert.deepEqual(await response.json(), { ok: true }); + await new Promise((resolve, reject) => { + const xhr = new window.XMLHttpRequest(); + xhr.onload = () => { assert.equal(xhr.status, 200); resolve(); }; + xhr.onerror = reject; + xhr.open("GET", "/api/xhr"); + xhr.send(); + }); + assert.deepEqual(requests, [`${prefix}/api/health`, `${prefix}/api/xhr`]); +}); diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index edaae4545..850dc01be 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -42,6 +42,7 @@ import { useMentionApps } from "@liveagent/ui/lib/chat/useMentionApps"; import { setPreferredMonacoNlsLocale } from "@liveagent/ui/lib/monacoNls"; import { releaseProjectToolFromDock } from "@liveagent/ui/lib/projectTools/releaseProjectToolFromDock"; import { useRightDockSettings } from "@liveagent/ui/lib/projectTools/useRightDockSettings"; +import { buildGatewayPublicBaseUrl } from "@liveagent/ui/lib/shared/gatewayPublicUrl"; import type { ConversationOpenOptions, ConversationOpenRequest, @@ -3679,7 +3680,10 @@ export function ChatPage(props: ChatPageProps) { gitWriteEnabled: true, tunnelEnabled, tunnelDisabledMessage, - tunnelPublicBaseUrl: settings.remote.gatewayUrl.trim(), + tunnelPublicBaseUrl: buildGatewayPublicBaseUrl( + settings.remote.gatewayUrl, + settings.remote.gatewayPort, + ), }, workspaceProjectRootClient: desktopWorkspaceProjectRootClient, workspaceRootRevision, @@ -3744,6 +3748,7 @@ export function ChatPage(props: ChatPageProps) { setSettings, setTerminalSessions, settings.customSettings, + settings.remote.gatewayPort, settings.remote.gatewayUrl, settings.ssh, tauriTunnelClient, @@ -4169,7 +4174,10 @@ export function ChatPage(props: ChatPageProps) { tunnelClient={isAgentMode ? tauriTunnelClient : null} tunnelEnabled={tunnelEnabled} tunnelDisabledMessage={tunnelDisabledMessage} - tunnelPublicBaseUrl={settings.remote.gatewayUrl.trim()} + tunnelPublicBaseUrl={buildGatewayPublicBaseUrl( + settings.remote.gatewayUrl, + settings.remote.gatewayPort, + )} workspaceActivityClient={tauriWorkspaceActivityClient} onWidthChange={handleRightDockWidthChange} onProjectStateChange={handleRightDockProjectStateChange} diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 667181343..c3da9ffb7 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -13,6 +13,7 @@ import { } from "@liveagent/ui/lib/chat/uploadedFiles"; import { appendManagedSkillSelections } from "@liveagent/ui/lib/chat/useComposerActions"; import type { ScrollFollowHandle } from "@liveagent/ui/lib/chat-scroll/useScrollFollow"; +import { buildGatewayPublicBaseUrl } from "@liveagent/ui/lib/shared/gatewayPublicUrl"; import type { SidebarStore } from "@liveagent/ui/lib/sidebar/store"; import { buildSkillsSystemPrompt, @@ -1723,7 +1724,10 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { ); }, remoteWebTunnelsEnabled: settings.remote.enableWebTunnels, - tunnelPublicBaseUrl: settings.remote.gatewayUrl.trim(), + tunnelPublicBaseUrl: buildGatewayPublicBaseUrl( + settings.remote.gatewayUrl, + settings.remote.gatewayPort, + ), sshHosts: settings.ssh.hosts, associatedSshHostIds: effectiveAssociatedSshHostIds, sshManagerRemoteAllowed: diff --git a/crates/agent-gui/test/settings/gateway-public-url.test.mjs b/crates/agent-gui/test/settings/gateway-public-url.test.mjs new file mode 100644 index 000000000..b5caf05a0 --- /dev/null +++ b/crates/agent-gui/test/settings/gateway-public-url.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { buildGatewayPublicBaseUrl } = loader.loadModule( + "@liveagent/ui/lib/shared/gatewayPublicUrl.ts", +); +const { composePublicUrl } = loader.loadModule("@liveagent/ui/lib/tunnels/constants.ts"); + +for (const [name, address, port, expected] of [ + ["Docker published port", "http://127.0.0.1", 3000, "http://127.0.0.1:3000"], + ["custom TLS port", "https://gateway.example", 8443, "https://gateway.example:8443"], + ["default HTTP port", "http://gateway.example", 80, "http://gateway.example"], + ["default HTTPS port", "https://gateway.example", 443, "https://gateway.example"], + ["separate port wins", "http://127.0.0.1:8080", 3000, "http://127.0.0.1:3000"], + ["unspecified port", "http://127.0.0.1:3000/", undefined, "http://127.0.0.1:3000"], + ["zero port matches native fallback", "http://127.0.0.1:3000/", 0, "http://127.0.0.1:3000"], + ["IPv6", "http://[::1]", 3000, "http://[::1]:3000"], + ["proxy path", " https://gateway.example/agent/?q=1#old ", 8443, "https://gateway.example:8443/agent"], + ["WebSocket scheme", "wss://gateway.example/agent/", 443, "https://gateway.example/agent"], +]) { + test(`public tunnel URL preserves ${name}`, () => { + const base = buildGatewayPublicBaseUrl(address, port); + assert.equal(base, expected); + assert.equal(composePublicUrl(base, "/t/abc/"), `${expected}/t/abc/`); + }); +} + +test("invalid configuration cannot produce a clickable tunnel URL", () => { + for (const [address, port] of [["", 3000], ["not a URL", 3000], ["file:///tmp", 3000], + ["http://localhost", -1], ["http://localhost", 65536], ["http://localhost", 1.5]]) { + assert.equal(composePublicUrl(buildGatewayPublicBaseUrl(address, port), "/t/abc/"), ""); + } +}); diff --git a/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs index 8fc7bcce6..28f96ab65 100644 --- a/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs +++ b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs @@ -77,6 +77,27 @@ test("TunnelManager is injected only when Remote Web Tunnels are enabled", async assert.equal(cronRegistry.hasTool("TunnelManager"), false); }); +test("TunnelManager public links use the configured gateway port, not the local target port", async () => { + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + invoke: async () => createSnapshot([createTunnel({ targetUrl: "http://127.0.0.1:3099" })]), + }, + }, + }); + const { buildGatewayPublicBaseUrl } = loader.loadModule("@liveagent/ui/lib/shared/gatewayPublicUrl.ts"); + const { createTunnelManagerTools } = loader.loadModule("src/lib/tools/tunnelManagerTools.ts"); + const bundle = createTunnelManagerTools({ + enabled: true, + runtimeScope: "chat", + publicBaseUrl: buildGatewayPublicBaseUrl("http://127.0.0.1", 3000), + }); + const result = await bundle.executeToolCall(createToolCall({ action: "list" })); + assert.equal(result.isError, false); + assert.match(result.content[0].text, /public: http:\/\/127\.0\.0\.1:3000\/t\/abc123\//); + assert.match(result.content[0].text, /target: http:\/\/127\.0\.0\.1:3099/); +}); + test("TunnelManager list/create/close/check call gateway tunnel commands", async () => { const invocations = []; const tunnels = [createTunnel()]; diff --git a/crates/agent-ui/src/lib/shared/gatewayPublicUrl.ts b/crates/agent-ui/src/lib/shared/gatewayPublicUrl.ts new file mode 100644 index 000000000..607faf9f5 --- /dev/null +++ b/crates/agent-ui/src/lib/shared/gatewayPublicUrl.ts @@ -0,0 +1,20 @@ +// Match Desktop's build_ws_url: the separately configured port wins, and a +// reverse proxy's path prefix is retained. Default ports are normalized by URL. +export function buildGatewayPublicBaseUrl(gatewayUrl: string, gatewayPort?: number): string { + if (!gatewayUrl.trim()) return ""; + try { + const url = new URL(gatewayUrl.trim()); + if (url.protocol === "ws:") url.protocol = "http:"; + if (url.protocol === "wss:") url.protocol = "https:"; + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + if (gatewayPort !== undefined && gatewayPort !== 0) { + if (!Number.isInteger(gatewayPort) || gatewayPort < 1 || gatewayPort > 65_535) return ""; + url.port = String(gatewayPort); + } + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); + } catch { + return ""; + } +} diff --git a/crates/agent-ui/src/pages/settings/RemoteSection.tsx b/crates/agent-ui/src/pages/settings/RemoteSection.tsx index 3b2ca61f8..35db984e7 100644 --- a/crates/agent-ui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-ui/src/pages/settings/RemoteSection.tsx @@ -26,6 +26,7 @@ import { } from "@liveagent/ui/components/IconSet"; import { Input } from "@liveagent/ui/components/ui/input"; import { useLocale } from "@liveagent/ui/i18n/index"; +import { buildGatewayPublicBaseUrl } from "@liveagent/ui/lib/shared/gatewayPublicUrl"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { normalizeIntegerDraftInput, @@ -205,20 +206,7 @@ function usePositiveIntegerDraft( } function buildGatewayEndpointPreview(settings: AppSettings["remote"]) { - const gatewayUrl = settings.gatewayUrl.trim(); - if (!gatewayUrl) return ""; - - try { - const url = new URL(gatewayUrl); - const port = String(settings.gatewayPort || 443); - url.port = port; - url.pathname = ""; - url.search = ""; - url.hash = ""; - return url.toString().replace(/\/$/, ""); - } catch { - return `${gatewayUrl}:${settings.gatewayPort || 443}`; - } + return buildGatewayPublicBaseUrl(settings.gatewayUrl, settings.gatewayPort); } function formatTimestamp(value?: number | null) { diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index 78898e90d..d1787e875 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -12,6 +12,36 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 | WebUI -> Gateway | WebSocket `/ws/v2`(Protobuf 帧) | 浏览器端发起 chat(command/subscribe)、直通 history/settings/skills/memory/cron 等请求,并订阅 `chat_event` 与同步广播。 | | WebUI -> Gateway | HTTP `/api/*` | 状态检查、文件上传、公网分享页、图片代理、静态资源。 | +## HTTP 隧道的公开地址与资源路径 + +HTTP 隧道通过 `/t/