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:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useInstallHelper } from "../hooks/useInstallHelper";
|
import { useInstallHelper } from "../hooks/useInstallHelper";
|
||||||
|
import { openUrlExternal } from "../lib/tauri-commands";
|
||||||
import { useDocker } from "../hooks/useDocker";
|
import { useDocker } from "../hooks/useDocker";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "./ui/Modal";
|
||||||
import Button from "./ui/Button";
|
import Button from "./ui/Button";
|
||||||
@@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) {
|
|||||||
const handleOpenDocs = async () => {
|
const handleOpenDocs = async () => {
|
||||||
if (!options) return;
|
if (!options) return;
|
||||||
try {
|
try {
|
||||||
await openUrl(options.docs_url);
|
await openUrlExternal(options.docs_url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open docs URL:", e);
|
console.error("Failed to open docs URL:", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({
|
|||||||
hasClaudeToken: vi.fn(),
|
hasClaudeToken: vi.fn(),
|
||||||
clearClaudeToken: vi.fn(),
|
clearClaudeToken: vi.fn(),
|
||||||
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
|
||||||
|
openUrlExternal: (...args: unknown[]) => openUrlExternal(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
const cancelClaudeToken = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
const openUrl = vi.fn();
|
const openUrlExternal = vi.fn();
|
||||||
vi.mock("@tauri-apps/plugin-opener", () => ({
|
|
||||||
openUrl: (...args: unknown[]) => openUrl(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** Captured event handlers, keyed by event name, so tests can emit. */
|
/** Captured event handlers, keyed by event name, so tests can emit. */
|
||||||
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
const handlers = new Map<string, (event: { payload: unknown }) => void>();
|
||||||
@@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => {
|
|||||||
|
|
||||||
const link = await screen.findByRole("link", { name: url });
|
const link = await screen.findByRole("link", { name: url });
|
||||||
fireEvent.click(link);
|
fireEvent.click(link);
|
||||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores output belonging to a different project", async () => {
|
it("ignores output belonging to a different project", async () => {
|
||||||
@@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => {
|
|||||||
|
|
||||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||||
fireEvent.click(link);
|
fireEvent.click(link);
|
||||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL));
|
||||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
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");
|
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||||
|
|
||||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||||
expect(openUrl).not.toHaveBeenCalled();
|
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a hyperlink belonging to a different project", async () => {
|
it("ignores a hyperlink belonging to a different project", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands";
|
||||||
import { cancelClaudeToken } from "../../lib/tauri-commands";
|
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
@@ -118,7 +117,7 @@ export default function ClaudeAuthModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await openUrl(target);
|
await openUrlExternal(target);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLinkError(
|
setLinkError(
|
||||||
authErrorMessage(
|
authErrorMessage(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { UpdateInfo } from "../../lib/types";
|
import type { UpdateInfo } from "../../lib/types";
|
||||||
|
import { openUrlExternal } from "../../lib/tauri-commands";
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
import { formatBytes } from "../../lib/formatBytes";
|
import { formatBytes } from "../../lib/formatBytes";
|
||||||
@@ -19,7 +19,7 @@ export default function UpdateDialog({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const handleDownload = async (url: string) => {
|
const handleDownload = async (url: string) => {
|
||||||
try {
|
try {
|
||||||
await openUrl(url);
|
await openUrlExternal(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to open URL:", 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 { render, fireEvent, cleanup, act } from "@testing-library/react";
|
||||||
import TerminalView, { supersedes } from "./TerminalView";
|
import TerminalView, { supersedes } from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
import {
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
uploadHostFileToTerminal,
|
||||||
|
openUrlExternal,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
import {
|
import {
|
||||||
chooseSignInTarget,
|
chooseSignInTarget,
|
||||||
resetBrowserSupportCache,
|
resetBrowserSupportCache,
|
||||||
@@ -65,6 +67,7 @@ vi.mock("../../lib/tauri-commands", () => ({
|
|||||||
uploadHostFileToTerminal: vi.fn(async () => ""),
|
uploadHostFileToTerminal: vi.fn(async () => ""),
|
||||||
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge),
|
||||||
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
checkBrowserViewSupport: vi.fn(async () => containerEnv.detection),
|
||||||
|
openUrlExternal: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
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", () => ({
|
vi.mock("@tauri-apps/api/webview", () => ({
|
||||||
getCurrentWebview: () => ({
|
getCurrentWebview: () => ({
|
||||||
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
|
||||||
@@ -148,8 +147,8 @@ beforeEach(() => {
|
|||||||
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
|
||||||
dragDrop.handler = null;
|
dragDrop.handler = null;
|
||||||
ptyOutput.listeners.clear();
|
ptyOutput.listeners.clear();
|
||||||
vi.mocked(openUrl).mockReset();
|
vi.mocked(openUrlExternal).mockReset();
|
||||||
vi.mocked(openUrl).mockResolvedValue(undefined);
|
vi.mocked(openUrlExternal).mockResolvedValue(undefined);
|
||||||
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
|
containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] };
|
||||||
containerEnv.detection = null;
|
containerEnv.detection = null;
|
||||||
// The Playwright probe is memoized across mounts (it is a container exec), so
|
// 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 () => {
|
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 mountWithPrompt();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(openButton());
|
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 () => {
|
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
|
// Dismissing first is what this replaced: the toast vanished, nothing
|
||||||
// opened, and the URL only existed in the container's transcript.
|
// 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 mountWithPrompt();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(openButton());
|
fireEvent.click(openButton());
|
||||||
@@ -790,7 +789,7 @@ describe("TerminalView — a host open that fails says so", () => {
|
|||||||
fireEvent.click(openButton());
|
fireEvent.click(openButton());
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(openUrl).toHaveBeenCalledWith(URL);
|
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||||
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
|
expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Terminal } from "@xterm/xterm";
|
|||||||
import { FitAddon } from "@xterm/addon-fit";
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
@@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
|||||||
import {
|
import {
|
||||||
awsSsoRefresh,
|
awsSsoRefresh,
|
||||||
openPageInContainerBrowser,
|
openPageInContainerBrowser,
|
||||||
|
openUrlExternal,
|
||||||
uploadHostFileToTerminal,
|
uploadHostFileToTerminal,
|
||||||
} from "../../lib/tauri-commands";
|
} from "../../lib/tauri-commands";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
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
|
// Same failure reporting as the toast's Open button — see the long note
|
||||||
// on `handleOpenUrl`, including what this catch does *not* catch on
|
// on `handleOpenUrl`, including what this catch does *not* catch on
|
||||||
// Linux. A click that appears to do nothing is the complaint either way.
|
// Linux. A click that appears to do nothing is the complaint either way.
|
||||||
openUrl(safe).catch((e) =>
|
openUrlExternal(safe).catch((e) =>
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "Could not open that link in your browser",
|
message: "Could not open that link in your browser",
|
||||||
@@ -822,14 +822,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
if (!urlPrompt) return;
|
if (!urlPrompt) return;
|
||||||
// Validated again at the sink. `promptUrl` is the only writer and already
|
// 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
|
// 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);
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a URL that failed validation");
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
dismissUrlPrompt();
|
dismissUrlPrompt();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openUrl(safe)
|
openUrlExternal(safe)
|
||||||
.then(() => dismissUrlPrompt())
|
.then(() => dismissUrlPrompt())
|
||||||
.catch((e) =>
|
.catch((e) =>
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
|
|||||||
@@ -398,3 +398,18 @@ export const rollbackMigration = (projectId: string) =>
|
|||||||
* app crash shows up here as phase "interrupted". */
|
* app crash shows up here as phase "interrupted". */
|
||||||
export const getMigrationState = (projectId: string) =>
|
export const getMigrationState = (projectId: string) =>
|
||||||
invoke<MigrationState | null>("get_migration_state", { projectId });
|
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 });
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void;
|
|||||||
* A direct port of `usable_sign_in_link` in
|
* A direct port of `usable_sign_in_link` in
|
||||||
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
||||||
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
|
* 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.
|
* 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
|
* 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
|
// 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
|
// 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
|
// 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
|
// stopping the match early only means the legitimate prefix survives
|
||||||
// instead of the whole candidate being thrown away.
|
// instead of the whole candidate being thrown away.
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* URL relay — host side of `container/triple-c-open` — and the single URL
|
* 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
|
* 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
|
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
|
||||||
|
|||||||
Reference in New Issue
Block a user