diff --git a/app/components/ChatInput/ChatInput.tsx b/app/components/ChatInput/ChatInput.tsx index ed6f55bb8..ba4057f7e 100644 --- a/app/components/ChatInput/ChatInput.tsx +++ b/app/components/ChatInput/ChatInput.tsx @@ -47,6 +47,7 @@ import { } from "@/app/hooks/useOnlineStatus"; import { WifiOff } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { isFreeDesktopSandboxAvailable } from "@/lib/activation/free-desktop-sandbox"; interface ChatInputProps { onSubmit: (e: React.FormEvent) => void | boolean | Promise; @@ -257,6 +258,7 @@ export const ChatInput = ({ subscription, isCheckingProPlan, hasLocalSandbox, + localConnections, freeDesktopAgentOnlyActive, desktopBridgeStatus, defaultLocalSandboxPreference, @@ -598,14 +600,18 @@ export const ChatInput = ({ }, [draftId, restoreDraftAttachments, uploadedFiles]); // Free agent mode constraints: - // 1. Requires local sandbox — web users fall back to Ask if disconnected, - // while Desktop stays Agent-only and waits for its bridge to reconnect + // 1. Requires a connected local sandbox — Desktop may use either its + // built-in bridge or a separately connected local runner // 2. Force local sandbox preference (not e2b) // 3. Force auto model selection const isFreeAgent = !isCheckingProPlan && subscription === "free" && isAgentMode(chatMode); const freeAgentSandboxAvailable = freeDesktopAgentOnlyActive - ? desktopBridgeStatus === "connected" + ? isFreeDesktopSandboxAvailable({ + sandboxPreference, + desktopBridgeActive: desktopBridgeStatus === "connected", + localConnections, + }) : hasLocalSandbox; const prevFreeAgentSandboxAvailableRef = useRef(freeAgentSandboxAvailable); @@ -619,10 +625,18 @@ export const ChatInput = ({ if (!freeAgentSandboxAvailable) { if (freeDesktopAgentOnlyActive) { if (wasConnected) { - toast.info("Desktop sandbox disconnected.", { - description: "Reconnect the Desktop sandbox to keep using Agent.", - duration: 5000, - }); + const selectedDesktop = sandboxPreference === "desktop"; + toast.info( + selectedDesktop + ? "Desktop sandbox disconnected." + : "Local sandbox disconnected.", + { + description: selectedDesktop + ? "Reconnect the Desktop sandbox to keep using Agent." + : "Reconnect the selected local runner to keep using Agent.", + duration: 5000, + }, + ); } return; } @@ -639,6 +653,7 @@ export const ChatInput = ({ freeAgentSandboxAvailable, freeDesktopAgentOnlyActive, isFreeAgent, + sandboxPreference, setChatMode, ]); @@ -656,14 +671,18 @@ export const ChatInput = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFreeAgent]); - const desktopSandboxUnavailableReason = - freeDesktopAgentOnlyActive && desktopBridgeStatus !== "connected" - ? desktopBridgeStatus === "connecting" - ? "Desktop sandbox is connecting" - : "Reconnect the Desktop sandbox to use Agent" + const freeDesktopSandboxUnavailableReason = + freeDesktopAgentOnlyActive && !freeAgentSandboxAvailable + ? sandboxPreference === "desktop" + ? desktopBridgeStatus === "connecting" + ? "Desktop sandbox is reconnecting" + : "Reconnect the Desktop sandbox to use Agent" + : sandboxPreference === "e2b" + ? "Select a local sandbox to use Agent" + : "Reconnect the selected local sandbox to use Agent" : undefined; const effectiveSendDisabledReason = - sendDisabledReason ?? desktopSandboxUnavailableReason; + sendDisabledReason ?? freeDesktopSandboxUnavailableReason; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/app/components/SandboxSelector.tsx b/app/components/SandboxSelector.tsx index 3b910f222..6a83630d3 100644 --- a/app/components/SandboxSelector.tsx +++ b/app/components/SandboxSelector.tsx @@ -45,7 +45,11 @@ export function SandboxSelector({ const [open, setOpen] = useState(false); const [connectHovered, setConnectHovered] = useState(false); const { isTauri } = useTauri(); - const { subscription, localConnections: connections } = useGlobalState(); + const { + subscription, + localConnections: connections, + desktopBridgeStatus, + } = useGlobalState(); const isFreeUser = subscription === "free"; const detectedPlatform = useMemo(() => { @@ -59,13 +63,19 @@ export function SandboxSelector({ shortLabel: "Cloud", icon: Cloud, }; + const desktopLabel = + isTauri && desktopBridgeStatus !== "connected" + ? desktopBridgeStatus === "connecting" + ? "Local reconnecting" + : "Local unavailable" + : "Local"; const desktopOptions: ConnectionOption[] = connections ?.filter((conn) => conn.isDesktop) .map(() => ({ id: "desktop" as string, - label: "Local", - shortLabel: "Local", + label: desktopLabel, + shortLabel: desktopLabel, icon: Monitor, })) || []; const remoteOptions: ConnectionOption[] = @@ -113,7 +123,27 @@ export function SandboxSelector({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFreeUser, value, connections]); - const selectedOption = options.find((opt) => opt.id === value) || options[0]; + const unavailableLocalOption: ConnectionOption | null = + value !== "e2b" && !valueMatchesOption + ? { + id: value, + label: + value === "desktop" && desktopBridgeStatus === "connecting" + ? "Local reconnecting" + : "Local unavailable", + shortLabel: + value === "desktop" && desktopBridgeStatus === "connecting" + ? "Local reconnecting" + : value === "desktop" && desktopBridgeStatus === "connected" + ? "Local" + : "Local unavailable", + icon: value === "desktop" ? Monitor : Laptop, + } + : null; + const selectedOption = + options.find((option) => option.id === value) ?? + unavailableLocalOption ?? + cloudOption; const Icon = selectedOption?.icon || Cloud; const buttonClassName = diff --git a/app/components/__tests__/SandboxSelector.test.tsx b/app/components/__tests__/SandboxSelector.test.tsx new file mode 100644 index 000000000..57c651a1d --- /dev/null +++ b/app/components/__tests__/SandboxSelector.test.tsx @@ -0,0 +1,77 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const mockGlobalState = { + subscription: "free", + localConnections: [] as Array<{ + connectionId: string; + isDesktop: boolean; + name?: string; + osInfo?: { hostname?: string }; + }>, + desktopBridgeStatus: "connecting", +}; + +jest.mock("@/app/contexts/GlobalState", () => ({ + useGlobalState: () => mockGlobalState, +})); + +jest.mock("@/app/hooks/useTauri", () => ({ + useTauri: () => ({ isTauri: true }), +})); + +jest.mock("@/app/download/DownloadSection", () => ({ + detectPlatform: () => ({ platform: "linux", downloadUrl: "/download" }), +})); + +jest.mock("sonner", () => ({ + toast: { info: jest.fn() }, +})); + +const { SandboxSelector } = + require("../SandboxSelector") as typeof import("../SandboxSelector"); + +describe("SandboxSelector", () => { + beforeEach(() => { + mockGlobalState.subscription = "free"; + mockGlobalState.localConnections = []; + mockGlobalState.desktopBridgeStatus = "connecting"; + }); + + it("shows Local reconnecting instead of Cloud while Desktop reconnects", () => { + render(); + + expect( + screen.getByRole("button", { name: /Local reconnecting/i }), + ).toBeInTheDocument(); + }); + + it("shows Local unavailable instead of Cloud after Desktop recovery fails", () => { + mockGlobalState.desktopBridgeStatus = "failed"; + mockGlobalState.localConnections = [ + { connectionId: "stale-desktop", isDesktop: true }, + ]; + + render(); + + expect( + screen.getByRole("button", { name: /Local unavailable/i }), + ).toBeInTheDocument(); + }); + + it("shows the selected remote runner when it is connected", () => { + mockGlobalState.localConnections = [ + { + connectionId: "remote-kali", + isDesktop: false, + name: "Kali VM", + osInfo: { hostname: "4p3x" }, + }, + ]; + + render(); + + expect(screen.getByRole("button", { name: /4p3x/i })).toBeInTheDocument(); + }); +}); diff --git a/app/contexts/GlobalState.tsx b/app/contexts/GlobalState.tsx index 3068c032d..45d5d3cd1 100644 --- a/app/contexts/GlobalState.tsx +++ b/app/contexts/GlobalState.tsx @@ -59,6 +59,7 @@ import { getAgentFirstDefaultDecision, normalizeAgentFirstSandboxType, } from "@/lib/activation/agent-first-default"; +import { resolveFreeDesktopSandboxPreference } from "@/lib/activation/free-desktop-sandbox"; import { ComposerStateProvider, useComposerActions, @@ -675,6 +676,22 @@ const GlobalStateProviderInner: React.FC = ({ isTauriEnvironment(); const agentOnlyActive = paidAgentOnlyActive || freeDesktopAgentOnlyActive; const accessibleChatMode: ChatMode = agentOnlyActive ? "agent" : chatMode; + const freeDesktopSandboxPreference = useMemo( + () => + freeDesktopAgentOnlyActive + ? resolveFreeDesktopSandboxPreference({ + sandboxPreference, + desktopBridgeActive, + localConnections, + }) + : null, + [ + desktopBridgeActive, + freeDesktopAgentOnlyActive, + localConnections, + sandboxPreference, + ], + ); const setChatMode = useCallback( (mode: ChatMode) => { @@ -687,14 +704,18 @@ const GlobalStateProviderInner: React.FC = ({ useEffect(() => { if (!agentOnlyActive) return; - if (freeDesktopAgentOnlyActive && sandboxPreference !== "desktop") { - setSandboxPreference("desktop"); + if ( + freeDesktopSandboxPreference && + sandboxPreference !== freeDesktopSandboxPreference + ) { + setSandboxPreference(freeDesktopSandboxPreference); } if (freeDesktopAgentOnlyActive && selectedModel !== "auto") { setSelectedModelRaw("auto"); } }, [ agentOnlyActive, + freeDesktopSandboxPreference, freeDesktopAgentOnlyActive, sandboxPreference, selectedModel, diff --git a/app/hooks/__tests__/useSandboxPreference.test.tsx b/app/hooks/__tests__/useSandboxPreference.test.tsx index 8e08d5f91..40e6cacc3 100644 --- a/app/hooks/__tests__/useSandboxPreference.test.tsx +++ b/app/hooks/__tests__/useSandboxPreference.test.tsx @@ -16,6 +16,11 @@ jest.mock("@/app/services/desktop-sandbox-bridge", () => ({ DesktopSandboxBridge: jest.fn(), })); +const mockCaptureAuthenticatedEvent = jest.fn(); +jest.mock("@/lib/analytics/client", () => ({ + captureAuthenticatedEvent: mockCaptureAuthenticatedEvent, +})); + jest.mock("sonner", () => ({ toast: { error: jest.fn() }, })); @@ -55,6 +60,71 @@ describe("useSandboxPreference", () => { expect(DesktopSandboxBridge).not.toHaveBeenCalled(); }); + it("automatically retries a bridge that fails during startup readiness", async () => { + const bridgeInstances: Array<{ + start: jest.Mock; + stop: jest.Mock; + getConnectionId: jest.Mock; + }> = []; + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + (DesktopSandboxBridge as jest.Mock).mockImplementation(() => { + const index = bridgeInstances.length; + const instance = { + start: jest + .fn() + .mockImplementation(() => + index === 0 + ? Promise.reject(new Error("transport closed")) + : Promise.resolve("connection-2"), + ), + stop: jest.fn().mockResolvedValue(undefined), + getConnectionId: jest.fn().mockReturnValue("connection-2"), + }; + bridgeInstances.push(instance); + return instance; + }); + + jest.useFakeTimers(); + try { + const { result, rerender } = renderHook( + ({ isAuthenticated }) => useSandboxPreference(isAuthenticated), + { initialProps: { isAuthenticated: true } }, + ); + + await act(async () => { + await jest.advanceTimersByTimeAsync(0); + }); + expect(bridgeInstances).toHaveLength(1); + expect(result.current.desktopBridgeStatus).toBe("connecting"); + expect(mockCaptureAuthenticatedEvent).toHaveBeenCalledWith( + "desktop_bridge_recovery_scheduled", + { + clientSurface: "desktop_bridge", + reason: "startup_failed", + attempt: 1, + delayMs: 1_000, + }, + ); + + await act(async () => { + await jest.advanceTimersByTimeAsync(1_000); + }); + expect(bridgeInstances).toHaveLength(2); + expect(result.current.desktopBridgeStatus).toBe("connected"); + expect(result.current.desktopBridgeActive).toBe(true); + + rerender({ isAuthenticated: false }); + await act(async () => { + await jest.advanceTimersByTimeAsync(0); + }); + expect(result.current.desktopBridgeStatus).toBe("idle"); + } finally { + jest.useRealTimers(); + warnSpy.mockRestore(); + } + }); + it("invalidates on auth loss and automatically recovers a stale connection", async () => { let resolveFirstStart: ((connectionId: string) => void) | undefined; const firstStart = new Promise((resolve) => { diff --git a/app/hooks/useSandboxPreference.ts b/app/hooks/useSandboxPreference.ts index 651714aef..f09c8d472 100644 --- a/app/hooks/useSandboxPreference.ts +++ b/app/hooks/useSandboxPreference.ts @@ -7,6 +7,7 @@ import type { SandboxPreference } from "@/types/chat"; import { toast } from "sonner"; import type { DesktopSandboxBridge } from "@/app/services/desktop-sandbox-bridge"; import { isTauriEnvironment } from "@/app/hooks/useTauri"; +import { captureAuthenticatedEvent } from "@/lib/analytics/client"; export type DesktopBridgeStatus = "idle" | "connecting" | "connected" | "failed"; @@ -29,11 +30,24 @@ const PERSISTABLE_SANDBOX_PREFERENCES = new Set(["e2b", "desktop"]); const DESKTOP_BRIDGE_RECOVERY_DELAYS_MS = [1_000, 3_000, 8_000, 16_000]; const DESKTOP_BRIDGE_MAX_RECOVERY_ATTEMPTS = 6; const DESKTOP_BRIDGE_STABLE_RESET_MS = 60_000; -const RECOVERABLE_DESKTOP_TERMINATIONS = new Set([ - "connection_not_found", - "connection_inactive", - "transport_disconnected", -]); +type RecoverableDesktopTerminationReason = + "connection_not_found" | "connection_inactive" | "transport_disconnected"; +type DesktopBridgeRecoveryReason = + RecoverableDesktopTerminationReason | "startup_failed"; +const RECOVERABLE_DESKTOP_TERMINATIONS = + new Set([ + "connection_not_found", + "connection_inactive", + "transport_disconnected", + ]); + +function isRecoverableDesktopTermination( + reason: string, +): reason is RecoverableDesktopTerminationReason { + return RECOVERABLE_DESKTOP_TERMINATIONS.has( + reason as RecoverableDesktopTerminationReason, + ); +} let bridgeRecoveryAttempt = 0; let bridgeRecoveryTimer: ReturnType | null = null; let bridgeStableResetTimer: ReturnType | null = null; @@ -118,6 +132,59 @@ export function useSandboxPreference( updateBridgeState(active, status); }); }; + const scheduleBridgeRecovery = ( + reason: DesktopBridgeRecoveryReason, + generation: number, + ): boolean => { + if (generation !== bridgeGeneration) return false; + + clearBridgeRecovery(false); + if (bridgeRecoveryAttempt >= DESKTOP_BRIDGE_MAX_RECOVERY_ATTEMPTS) { + const attempts = bridgeRecoveryAttempt; + console.warn("[DesktopSandboxBridge] Automatic recovery exhausted", { + reason, + attempts, + }); + captureAuthenticatedEvent("desktop_bridge_recovery_exhausted", { + clientSurface: "desktop_bridge", + reason, + attempts, + }); + clearBridgeRecovery(true); + updateBridgeState(false, "failed"); + return true; + } + + const delayMs = + DESKTOP_BRIDGE_RECOVERY_DELAYS_MS[ + Math.min( + bridgeRecoveryAttempt, + DESKTOP_BRIDGE_RECOVERY_DELAYS_MS.length - 1, + ) + ]; + bridgeRecoveryAttempt += 1; + const attempt = bridgeRecoveryAttempt; + console.warn("[DesktopSandboxBridge] Automatic recovery scheduled", { + reason, + attempt, + delayMs, + }); + captureAuthenticatedEvent("desktop_bridge_recovery_scheduled", { + clientSurface: "desktop_bridge", + reason, + attempt, + delayMs, + }); + updateBridgeState(false, "connecting"); + bridgeRecoveryTimer = setTimeout(() => { + bridgeRecoveryTimer = null; + if (generation !== bridgeGeneration) return; + bridgeGeneration += 1; + bridgeStartPromise = null; + setDesktopBridgeRetryAttempt((currentAttempt) => currentAttempt + 1); + }, delayMs); + return true; + }; if (!isAuthenticated || !isTauriEnvironment()) { bridgeStateListener = null; @@ -148,11 +215,12 @@ export function useSandboxPreference( } async function startBridge() { + const startGeneration = bridgeGeneration; setDesktopBridgeActive(false); setDesktopBridgeStatus("connecting"); try { if (!bridgeStartPromise) { - const generation = bridgeGeneration; + const generation = startGeneration; let startPromise: Promise; startPromise = import("@/app/services/desktop-sandbox-bridge") .then( @@ -175,45 +243,12 @@ export function useSandboxPreference( onTerminated: (reason) => { if (generation !== bridgeGeneration) return; if (activeBridge === bridge) activeBridge = null; - if (!RECOVERABLE_DESKTOP_TERMINATIONS.has(reason)) { + if (!isRecoverableDesktopTermination(reason)) { clearBridgeRecovery(true); bridgeStateListener?.(false, "failed"); return; } - - clearBridgeRecovery(false); - if ( - bridgeRecoveryAttempt >= - DESKTOP_BRIDGE_MAX_RECOVERY_ATTEMPTS - ) { - console.warn( - "[DesktopSandboxBridge] Automatic recovery exhausted", - { - reason, - attempts: bridgeRecoveryAttempt, - }, - ); - clearBridgeRecovery(true); - bridgeStateListener?.(false, "failed"); - return; - } - - bridgeStateListener?.(false, "connecting"); - const delay = - DESKTOP_BRIDGE_RECOVERY_DELAYS_MS[ - Math.min( - bridgeRecoveryAttempt, - DESKTOP_BRIDGE_RECOVERY_DELAYS_MS.length - 1, - ) - ]; - bridgeRecoveryAttempt += 1; - bridgeRecoveryTimer = setTimeout(() => { - bridgeRecoveryTimer = null; - if (generation !== bridgeGeneration) return; - bridgeGeneration += 1; - bridgeStartPromise = null; - setDesktopBridgeRetryAttempt((attempt) => attempt + 1); - }, delay); + scheduleBridgeRecovery(reason, generation); }, }); @@ -254,6 +289,7 @@ export function useSandboxPreference( setDesktopBridgeStatus("connecting"); return; } + if (scheduleBridgeRecovery("startup_failed", startGeneration)) return; console.error("[DesktopSandboxBridge] Failed to start:", error); setDesktopBridgeActive(false); setDesktopBridgeStatus("failed"); diff --git a/lib/activation/__tests__/free-desktop-sandbox.test.ts b/lib/activation/__tests__/free-desktop-sandbox.test.ts new file mode 100644 index 000000000..43d81f836 --- /dev/null +++ b/lib/activation/__tests__/free-desktop-sandbox.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@jest/globals"; +import { + isFreeDesktopSandboxAvailable, + resolveFreeDesktopSandboxPreference, +} from "../free-desktop-sandbox"; + +const remoteConnection = { + connectionId: "remote-kali", + isDesktop: false, +}; + +describe("free desktop sandbox selection", () => { + it("keeps a healthy explicitly selected remote runner", () => { + expect( + resolveFreeDesktopSandboxPreference({ + sandboxPreference: "remote-kali", + desktopBridgeActive: true, + localConnections: [remoteConnection], + }), + ).toBe("remote-kali"); + }); + + it("selects a healthy remote runner when the desktop bridge is unavailable", () => { + expect( + resolveFreeDesktopSandboxPreference({ + sandboxPreference: "desktop", + desktopBridgeActive: false, + localConnections: [ + { connectionId: "stale-desktop", isDesktop: true }, + remoteConnection, + ], + }), + ).toBe("remote-kali"); + }); + + it("keeps the desktop sentinel while no local runner is available", () => { + expect( + resolveFreeDesktopSandboxPreference({ + sandboxPreference: "e2b", + desktopBridgeActive: false, + localConnections: [], + }), + ).toBe("desktop"); + }); + + it("waits for connection discovery before replacing a stored remote runner", () => { + expect( + resolveFreeDesktopSandboxPreference({ + sandboxPreference: "remote-kali", + desktopBridgeActive: false, + localConnections: undefined, + }), + ).toBe("remote-kali"); + }); + + it("reports either a connected desktop bridge or selected remote runner as available", () => { + expect( + isFreeDesktopSandboxAvailable({ + sandboxPreference: "desktop", + desktopBridgeActive: true, + localConnections: [], + }), + ).toBe(true); + expect( + isFreeDesktopSandboxAvailable({ + sandboxPreference: "remote-kali", + desktopBridgeActive: false, + localConnections: [remoteConnection], + }), + ).toBe(true); + expect( + isFreeDesktopSandboxAvailable({ + sandboxPreference: "desktop", + desktopBridgeActive: false, + localConnections: [remoteConnection], + }), + ).toBe(false); + }); +}); diff --git a/lib/activation/free-desktop-sandbox.ts b/lib/activation/free-desktop-sandbox.ts new file mode 100644 index 000000000..7e85d3aaf --- /dev/null +++ b/lib/activation/free-desktop-sandbox.ts @@ -0,0 +1,57 @@ +import type { SandboxPreference } from "@/types/chat"; + +interface LocalSandboxConnection { + connectionId: string; + isDesktop: boolean; +} + +interface FreeDesktopSandboxState { + sandboxPreference: SandboxPreference; + desktopBridgeActive: boolean; + localConnections: readonly LocalSandboxConnection[] | undefined; +} + +export function resolveFreeDesktopSandboxPreference({ + sandboxPreference, + desktopBridgeActive, + localConnections, +}: FreeDesktopSandboxState): SandboxPreference { + if (sandboxPreference === "desktop" && desktopBridgeActive) { + return sandboxPreference; + } + + if (sandboxPreference !== "desktop" && sandboxPreference !== "e2b") { + if (localConnections === undefined) return sandboxPreference; + if ( + localConnections.some( + (connection) => + !connection.isDesktop && + connection.connectionId === sandboxPreference, + ) + ) { + return sandboxPreference; + } + } + + if (desktopBridgeActive) return "desktop"; + + const remoteConnection = localConnections?.find( + (connection) => !connection.isDesktop, + ); + return remoteConnection?.connectionId ?? "desktop"; +} + +export function isFreeDesktopSandboxAvailable({ + sandboxPreference, + desktopBridgeActive, + localConnections, +}: FreeDesktopSandboxState): boolean { + if (sandboxPreference === "desktop") return desktopBridgeActive; + if (sandboxPreference === "e2b") return false; + return Boolean( + localConnections?.some( + (connection) => + !connection.isDesktop && connection.connectionId === sandboxPreference, + ), + ); +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 06c930e31..63f3e298f 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -1496,10 +1496,7 @@ async fn start_dev_auth_server(app_handle: tauri::AppHandle) { origin, encoded_token, encoded_state ); - log::info!( - "Dev auth: navigating to callback (token: {}...)", - &t[..8.min(t.len())] - ); + log::info!("Dev auth: navigating to callback"); if let Some(window) = handle.get_webview_window("main") { let _ = window.set_focus(); @@ -1582,6 +1579,16 @@ fn is_valid_token_format(token: &str) -> bool { token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) } +fn deep_link_log_label(url: &url::Url) -> String { + let mut label = format!("{}:", url.scheme()); + if let Some(host) = url.host_str() { + label.push_str("//"); + label.push_str(host); + } + label.push_str(url.path()); + label +} + fn validate_origin(origin: &str) -> bool { match url::Url::parse(origin) { Ok(parsed) => { @@ -1671,15 +1678,12 @@ fn handle_auth_deep_link(app: &tauri::AppHandle, url: &url::Url) { "{}/desktop-callback?token={}&desktop_state={}", origin, encoded_token, encoded_state ); - log::info!( - "Navigating to desktop callback (token: {}...)", - &token[..8.min(token.len())] - ); + log::info!("Navigating to desktop callback"); match callback_url.parse() { Ok(parsed_url) => { - if let Err(e) = window.navigate(parsed_url) { - log::error!("Failed to navigate to callback URL: {}", e); + if window.navigate(parsed_url).is_err() { + log::error!("Failed to navigate to callback URL"); // Try to navigate to error page let error_url = format!("{}/login?error=navigation_failed", origin); if let Ok(error_parsed) = error_url.parse() { @@ -1687,17 +1691,20 @@ fn handle_auth_deep_link(app: &tauri::AppHandle, url: &url::Url) { } } } - Err(e) => { - log::error!("Invalid callback URL format: {}", e); + Err(_) => { + log::error!("Invalid callback URL format"); } } } } None => { - if let Some((_, error)) = url.query_pairs().find(|(k, _)| k == "error") { - log::error!("Auth deep link received with error: {}", error); + if url.query_pairs().any(|(k, _)| k == "error") { + log::error!("Auth deep link received with an error"); } else { - log::warn!("Auth deep link received without token: {:?}", url); + log::warn!( + "Auth deep link received without token: {}", + deep_link_log_label(url) + ); } } } @@ -1859,6 +1866,23 @@ mod tests { )) } + #[test] + fn deep_link_log_label_omits_authentication_query_values() { + let token = "a".repeat(64); + let desktop_state = "b".repeat(64); + let url = url::Url::parse(&format!( + "hackerai://auth?token={token}&origin=https%3A%2F%2Fhackerai.co&desktop_state={desktop_state}" + )) + .expect("valid deep link"); + + let label = deep_link_log_label(&url); + + assert_eq!(label, "hackerai://auth"); + assert!(!label.contains(&token)); + assert!(!label.contains(&desktop_state)); + assert!(!label.contains("origin")); + } + #[tokio::test] async fn file_write_allows_nested_paths_inside_allowed_root() { let root = unique_test_dir("allowed-root"); @@ -2081,11 +2105,17 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // Handle deep links passed as CLI args (Linux/Windows) - log::info!("Single instance callback with args: {:?}", args); + log::info!( + "Single instance callback with {} argument(s)", + args.len() + ); for arg in args.iter().skip(1) { if let Ok(url) = url::Url::parse(arg) { if url.scheme() == "hackerai" { - log::info!("Processing deep link from CLI arg: {}", arg); + log::info!( + "Processing deep link from CLI arg: {}", + deep_link_log_label(&url) + ); handle_auth_deep_link(app, &url); } } @@ -2122,9 +2152,16 @@ pub fn run() { let handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { let urls = event.urls(); - log::info!("Deep link received: {:?}", urls); + log::info!( + "Deep link callback received with {} URL(s)", + urls.len() + ); for url in urls { + log::info!( + "Processing deep link: {}", + deep_link_log_label(&url) + ); handle_auth_deep_link(&handle, &url); } });