fix: route every host-browser open through open_url_external

The Rust command existed but nothing called it. All four frontend call
sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the
environment fix was inert and the three dialogs carried the same Linux bug
as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in
link and UpdateDialog's release link would all have reported success while
launching nothing.

`openUrlExternal` in tauri-commands.ts is now the single sink. There is no
platform branch: Linux gets the sanitized spawn, macOS and Windows reach
the same plugin as before but from Rust, and every platform picks up the
Rust-side re-validation, which matters because these URLs originate in an
untrusted container.

Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink
they guard are updated to match, and the two test files that mocked
`@tauri-apps/plugin-opener` now mock the command instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:09:57 -07:00
co-authored by Claude Opus 5
parent 9297020688
commit 5a09254538
9 changed files with 46 additions and 33 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { openUrlExternal } from "../lib/tauri-commands";
import { useDocker } from "../hooks/useDocker";
import Modal from "./ui/Modal";
import Button from "./ui/Button";
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
await openUrlExternal(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
hasClaudeToken: vi.fn(),
clearClaudeToken: vi.fn(),
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
}));
const cancelClaudeToken = vi.fn(() => Promise.resolve());
const openUrl = vi.fn();
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: (...args: unknown[]) => openUrl(...args),
}));
const openUrlExternal = vi.fn();
/** Captured event handlers, keyed by event name, so tests can emit. */
const handlers = new Map<string, (event: { payload: unknown }) => void>();
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: url });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
});
it("ignores output belonging to a different project", async () => {
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
const link = await screen.findByRole("link", { name: FULL_URL });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
});
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
@@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => {
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(openUrl).not.toHaveBeenCalled();
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("ignores a hyperlink belonging to a different project", async () => {
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { cancelClaudeToken } from "../../lib/tauri-commands";
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
return;
}
try {
await openUrl(target);
await openUrlExternal(target);
} catch (e) {
setLinkError(
authErrorMessage(
+2 -2
View File
@@ -1,5 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
import { openUrlExternal } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { formatBytes } from "../../lib/formatBytes";
@@ -19,7 +19,7 @@ export default function UpdateDialog({
}: Props) {
const handleDownload = async (url: string) => {
try {
await openUrl(url);
await openUrlExternal(url);
} catch (e) {
console.error("Failed to open URL:", e);
}
@@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
uploadHostFileToTerminal,
openUrlExternal,
} from "../../lib/tauri-commands";
import {
chooseSignInTarget,
resetBrowserSupportCache,
@@ -65,6 +67,7 @@ vi.mock("../../lib/tauri-commands", () => ({
uploadHostFileToTerminal: vi.fn(async () => ""),
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
openUrlExternal: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/event", () => ({
@@ -74,10 +77,6 @@ vi.mock("@tauri-apps/api/event", () => ({
},
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(async () => {}),
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
@@ -148,8 +147,8 @@ beforeEach(() => {
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
dragDrop.handler = null;
ptyOutput.listeners.clear();
vi.mocked(openUrl).mockReset();
vi.mocked(openUrl).mockResolvedValue(undefined);
vi.mocked(openUrlExternal).mockReset();
vi.mocked(openUrlExternal).mockResolvedValue(undefined);
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
containerEnv.detection = null;
// The Playwright probe is memoized across mounts (it is a container exec), so
@@ -760,7 +759,7 @@ describe("TerminalView — a host open that fails says so", () => {
}
it("pushes a toast instead of a console line nobody reads", async () => {
vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener"));
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
@@ -775,7 +774,7 @@ describe("TerminalView — a host open that fails says so", () => {
it("keeps the prompt on screen, so the other route is still one click away", async () => {
// Dismissing first is what this replaced: the toast vanished, nothing
// opened, and the URL only existed in the container's transcript.
vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener"));
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await mountWithPrompt();
await act(async () => {
fireEvent.click(openButton());
@@ -790,7 +789,7 @@ describe("TerminalView — a host open that fails says so", () => {
fireEvent.click(openButton());
await Promise.resolve();
});
expect(openUrl).toHaveBeenCalledWith(URL);
expect(openUrlExternal).toHaveBeenCalledWith(URL);
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
});
});
+4 -4
View File
@@ -3,7 +3,6 @@ import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
@@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
import {
awsSsoRefresh,
openPageInContainerBrowser,
openUrlExternal,
uploadHostFileToTerminal,
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
@@ -413,7 +413,7 @@ export default function TerminalView({ sessionId, active }: Props) {
// Same failure reporting as the toast's Open button — see the long note
// on `handleOpenUrl`, including what this catch does *not* catch on
// Linux. A click that appears to do nothing is the complaint either way.
openUrl(safe).catch((e) =>
openUrlExternal(safe).catch((e) =>
useAppState.getState().pushToast({
kind: "error",
message: "Could not open that link in your browser",
@@ -822,14 +822,14 @@ export default function TerminalView({ sessionId, active }: Props) {
if (!urlPrompt) return;
// Validated again at the sink. `promptUrl` is the only writer and already
// sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks.
// precisely when it matters that the last thing before the opener checks.
const safe = sanitizeRelayUrl(urlPrompt.url);
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
dismissUrlPrompt();
return;
}
openUrl(safe)
openUrlExternal(safe)
.then(() => dismissUrlPrompt())
.catch((e) =>
useAppState.getState().pushToast({
+15
View File
@@ -398,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
/** Open a URL in the user's own browser.
*
* Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On
* Linux the app ships as an AppImage whose environment leaks into everything
* it spawns, which kills a *cold-launched* browser before it paints while
* `xdg-open` still exits 0 — so the plugin path reported success and did
* nothing (triple-c#34). The Rust side hands the child a repaired environment
* and re-validates the URL, which matters because these URLs originate in an
* untrusted container. macOS and Windows still reach the plugin, just from
* Rust, so there is no platform branch here.
*
* Rejects with a string already phrased for a toast. */
export const openUrlExternal = (url: string) =>
invoke<void>("open_url_external", { url });
+3 -2
View File
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
* A direct port of `usable_sign_in_link` in
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
* only thing standing between any of this and `openUrl`, and duplicating its
* only thing standing between any of this and `openUrlExternal`, and
* duplicating its
* rules here would be a second place for them to go stale.
*
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
@@ -293,7 +294,7 @@ export class UrlDetector {
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
// swallowed into the middle of a match becomes a URL that renders as one
// thing in the toast and resolves as another. Everything emitted here is
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
// stopping the match early only means the legitimate prefix survives
// instead of the whole candidate being thrown away.
// eslint-disable-next-line no-control-regex
+2 -1
View File
@@ -1,6 +1,7 @@
/**
* URL relay — host side of `container/triple-c-open` — and the single URL
* validator every `openUrl` call site in the app is required to go through.
* validator every `openUrlExternal` call site in the app is required to go
* through.
*
* A CLI inside the container has no browser. When it wants to open a URL
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring