Make in-container OAuth logins actually complete #57
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user