diff --git a/surfaces/gui/src-tauri/Cargo.lock b/surfaces/gui/src-tauri/Cargo.lock
index e1d348643b..23c7533d02 100644
--- a/surfaces/gui/src-tauri/Cargo.lock
+++ b/surfaces/gui/src-tauri/Cargo.lock
@@ -1936,6 +1936,25 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -2664,6 +2683,17 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "open"
+version = "5.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+]
+
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2681,6 +2711,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
+ "tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"uuid",
@@ -4075,6 +4106,28 @@ dependencies = [
"url",
]
+[[package]]
+name = "tauri-plugin-opener"
+version = "2.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
+dependencies = [
+ "dunce",
+ "glob",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "open",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "url",
+ "windows 0.61.3",
+ "zbus",
+]
+
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
diff --git a/surfaces/gui/src-tauri/Cargo.toml b/surfaces/gui/src-tauri/Cargo.toml
index e8e721abc9..c367107174 100644
--- a/surfaces/gui/src-tauri/Cargo.toml
+++ b/surfaces/gui/src-tauri/Cargo.toml
@@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
+tauri-plugin-opener = "2"
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
diff --git a/surfaces/gui/src-tauri/capabilities/default.json b/surfaces/gui/src-tauri/capabilities/default.json
index b2548af66d..042b33da47 100644
--- a/surfaces/gui/src-tauri/capabilities/default.json
+++ b/surfaces/gui/src-tauri/capabilities/default.json
@@ -10,6 +10,7 @@
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"dialog:default",
- "autostart:default"
+ "autostart:default",
+ "opener:default"
]
}
diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs
index d96c3c6f4c..5579a9f836 100644
--- a/surfaces/gui/src-tauri/src/lib.rs
+++ b/surfaces/gui/src-tauri/src/lib.rs
@@ -717,6 +717,7 @@ pub fn run() {
show_main(app);
}))
.plugin(tauri_plugin_dialog::init())
+ .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx
index e27a6627a3..a0587dea71 100644
--- a/surfaces/gui/src/components/Markdown.tsx
+++ b/surfaces/gui/src/components/Markdown.tsx
@@ -1,6 +1,7 @@
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { useTranslation } from "react-i18next";
import remarkGfm from "remark-gfm";
+import { openExternal } from "../tauri";
import { Icon } from "./Icon";
// §34 (UX-016): the agent ends a deliverable turn with plain markdown —
@@ -77,7 +78,18 @@ export function Markdown({ text }: { text: string }) {
return ;
}
return (
-
+ {
+ if (href) {
+ e.preventDefault();
+ openExternal(href);
+ }
+ }}
+ >
{children}
);
diff --git a/surfaces/gui/src/tauri-opener.test.ts b/surfaces/gui/src/tauri-opener.test.ts
new file mode 100644
index 0000000000..c212c81a97
--- /dev/null
+++ b/surfaces/gui/src/tauri-opener.test.ts
@@ -0,0 +1,25 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { openExternal } from "./tauri";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("does not bypass a rejected opener scope", async () => {
+ const invoke = vi.fn().mockResolvedValue(null);
+ vi.stubGlobal("__TAURI__", {
+ opener: { openUrl: vi.fn().mockRejectedValue(new Error("ForbiddenUrl")) },
+ core: { invoke },
+ });
+ openExternal("irc://example.invalid/channel");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(invoke).not.toHaveBeenCalled();
+});
+
+it("uses the scoped plugin when the desktop opener JS API is absent", async () => {
+ const invoke = vi.fn().mockRejectedValue(new Error("ForbiddenUrl"));
+ vi.stubGlobal("__TAURI__", { core: { invoke } });
+ openExternal("irc://example.invalid/channel");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(invoke.mock.calls).toEqual([
+ ["plugin:opener|open_url", { url: "irc://example.invalid/channel" }],
+ ]);
+});
diff --git a/surfaces/gui/src/tauri.ts b/surfaces/gui/src/tauri.ts
index 95f3ccdba0..2035ff60b0 100644
--- a/surfaces/gui/src/tauri.ts
+++ b/surfaces/gui/src/tauri.ts
@@ -125,13 +125,15 @@ export const clearPendingUpdate = () => invokeStrict("clear_pending_update
* Windows hands off to the installer). */
export const installUpdate = () => invokeStrict("install_update");
-/** Best-effort open a URL in the user's browser. Uses the Tauri opener plugin if present, else
- * `window.open`. The caller should also render the raw URL so it stays copyable if both no-op
- * (the desktop webview has no opener plugin wired yet). */
+/** Open browser links through the scoped desktop plugin or the browser. */
export function openExternal(url: string): void {
- const opener = (globalThis as any).__TAURI__?.opener;
- if (opener?.openUrl) {
- opener.openUrl(url).catch(() => window.open(url, "_blank", "noopener,noreferrer"));
+ if (isTauri()) {
+ const opener = (globalThis as any).__TAURI__?.opener;
+ // Both routes enforce the plugin's URL scope. A denied URL must stay denied.
+ const opened = opener?.openUrl
+ ? opener.openUrl(url)
+ : invokeStrict("plugin:opener|open_url", { url });
+ void opened.catch(() => {});
return;
}
window.open(url, "_blank", "noopener,noreferrer");