diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx index 2943081020d..4c8a0254661 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-egress-indicator.tsx @@ -10,13 +10,12 @@ import { BROWSER_SSH_WORKSPACE_ROUTING_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { - isRuntimeOwnedSshTargetId, - parseExecutionHostId, toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../../shared/execution-host' import { getHostSettingOverride } from '../../../../../shared/host-setting-overrides' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveSshWorkspaceBrowserRouteEligibility } from '@/lib/ssh-workspace-browser-route-eligibility' /** * The address bar's leading icon, egress-aware: pages that render locally and @@ -103,19 +102,13 @@ export function SshEgressIndicator({ worktreeId: string }): React.JSX.Element | null { const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId)) - const routingEnabled = useAppStore((s) => s.settings?.browserSshWorkspaceRoutingEnabled !== false) - const disabledTargetIds = useAppStore( - (s) => s.settings?.browserSshWorkspaceRoutingDisabledTargetIds - ) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) const settings = useAppStore((s) => s.settings) - const parsed = parseExecutionHostId(executionHostId) - const targetId = - parsed?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsed.targetId) ? parsed.targetId : null - if (!targetId) { + const routeEligibility = resolveSshWorkspaceBrowserRouteEligibility(executionHostId, settings) + if (!routeEligibility) { return } - const routed = routingEnabled && !disabledTargetIds?.includes(targetId) + const { targetId, eligible: routed } = routeEligibility const hostLabel = getHostSettingOverride(settings, toSshExecutionHostId(targetId), 'displayLabel') ?? sshTargetLabels.get(targetId) ?? diff --git a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts index e11bfc4aa8c..b7a1e432664 100644 --- a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts +++ b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useAppStore } from '@/store' -import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { resolveSshWorkspaceBrowserRouteEligibility } from '@/lib/ssh-workspace-browser-route-eligibility' export type SshWorkspaceBrowserRouteErrorKind = 'forwarding-blocked' | 'ssh-unavailable' | 'unknown' @@ -45,20 +45,17 @@ export function useSshWorkspaceBrowserRoute( browseFromThisDevice: () => void } { const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId)) - const routingEnabled = useAppStore((s) => s.settings?.browserSshWorkspaceRoutingEnabled !== false) - const disabledTargetIds = useAppStore( - (s) => s.settings?.browserSshWorkspaceRoutingDisabledTargetIds - ) + const browserRoutingSettings = useAppStore((s) => s.settings) const probeSkippedTargetIds = useAppStore( (s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds ) const updateSettings = useAppStore((s) => s.updateSettings) - const parsed = parseExecutionHostId(executionHostId) - // Why: runtime-owned ephemeral targets belong to a paired runtime's own machinery. - const sshTargetId = - parsed?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsed.targetId) ? parsed.targetId : null - const targetId = - routingEnabled && sshTargetId && !disabledTargetIds?.includes(sshTargetId) ? sshTargetId : null + const routeEligibility = resolveSshWorkspaceBrowserRouteEligibility( + executionHostId, + browserRoutingSettings + ) + const sshTargetId = routeEligibility?.targetId ?? null + const targetId = routeEligibility?.eligible === true ? routeEligibility.targetId : null const browserProfileId = sessionProfileId ?? 'default' const [attempt, setAttempt] = useState<{ count: number; skipProbe: boolean }>({ count: 0, @@ -135,7 +132,7 @@ export function useSshWorkspaceBrowserRoute( if (!sshTargetId) { return } - const disabled = disabledTargetIds ?? [] + const disabled = browserRoutingSettings?.browserSshWorkspaceRoutingDisabledTargetIds ?? [] if (!disabled.includes(sshTargetId)) { updateSettings({ browserSshWorkspaceRoutingDisabledTargetIds: [...disabled, sshTargetId] @@ -153,13 +150,14 @@ export function useSshWorkspaceBrowserRoute( */ export function useSshWorkspaceProbeSkipRecheck(worktreeId: string): (() => void) | null { const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId)) + const browserRoutingSettings = useAppStore((s) => s.settings) const probeSkippedTargetIds = useAppStore( (s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds ) const updateSettings = useAppStore((s) => s.updateSettings) - const parsed = parseExecutionHostId(executionHostId) const targetId = - parsed?.kind === 'ssh' && !isRuntimeOwnedSshTargetId(parsed.targetId) ? parsed.targetId : null + resolveSshWorkspaceBrowserRouteEligibility(executionHostId, browserRoutingSettings)?.targetId ?? + null if (!targetId || probeSkippedTargetIds?.includes(targetId) !== true) { return null } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx index 48296d44aef..cf407854ebd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx @@ -9,7 +9,7 @@ const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) vi.mock('@/lib/http-link-routing', () => ({ openHttpLink: openHttpLinkMock, registerHttpLinkStoreAccessor: vi.fn(), - registerRuntimeHttpLinkBrowserOpener: vi.fn() + registerWorkspaceHttpLinkBrowserOpener: vi.fn() })) function makeReview(overrides: Partial = {}): HostedReviewInfo { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index 8350ec17fec..e344ac82a0e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { registerHttpLinkStoreAccessor, - registerRuntimeHttpLinkBrowserOpener + registerWorkspaceHttpLinkBrowserOpener } from '@/lib/http-link-routing' import { closeTerminalLinkActionRequest, @@ -48,11 +48,11 @@ beforeEach(() => { setActiveWorktree, createBrowserTab })) - registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTab) + registerWorkspaceHttpLinkBrowserOpener(openRuntimeBrowserTab) }) afterEach(() => { - registerRuntimeHttpLinkBrowserOpener(null) + registerWorkspaceHttpLinkBrowserOpener(null) vi.clearAllMocks() vi.unstubAllGlobals() }) @@ -209,6 +209,28 @@ describe('terminal link action routing', () => { expect(createBrowserTab).not.toHaveBeenCalled() }) + it('routes an explicit Orca Browser action through the owning SSH workspace', () => { + const request = vi.fn() + const url = 'http://0.0.0.0:8000/' + + handleTerminalHttpLink(url, plainEvent(), { + worktreeId: 'wt-1', + sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' }, + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + + request.mock.calls[0][0].alternate.run() + expect(openRuntimeBrowserTab).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + url, + intent: { kind: 'url' }, + expectedSshConnectionId: 'ssh-1' + }) + expect(createBrowserTab).not.toHaveBeenCalled() + expect(openUrl).not.toHaveBeenCalled() + }) + it('uses Shift+modifier for the alternate local destination', () => { const event = { ...plainEvent(), metaKey: true, shiftKey: true } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts index 514f36aad6d..466f95b6a67 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -155,25 +155,47 @@ describe('terminalUrlOpenHintOptionsFor', () => { expect(options.modifierInverts).toBe(true) }) + + it('keeps inversion for an eligible SSH pane', () => { + const options = terminalUrlOpenHintOptionsFor( + { + openLinksInApp: false, + openLinksInAppModifierInverts: true + }, + { kind: 'ssh', connectionId: 'ssh-1' }, + true + ) + + expect(options.modifierInverts).toBe(true) + }) }) describe('terminalHttpLinkActionDestinationsFor', () => { - it('offers both destinations for a capable runtime and follows the preference', () => { - const owner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const - - expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: true }, owner, true)).toEqual({ - primary: 'orca', - alternate: 'system' - }) - expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: false }, owner, true)).toEqual({ - primary: 'system', - alternate: 'orca' - }) - }) + it.each([ + ['local', { kind: 'local' } as const, false], + ['capable runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const, true], + ['eligible SSH', { kind: 'ssh', connectionId: 'ssh-1' } as const, true] + ])( + 'offers both destinations for a %s owner and follows the preference', + (_label, owner, canOpen) => { + expect( + terminalHttpLinkActionDestinationsFor({ openLinksInApp: true }, owner, canOpen) + ).toEqual({ + primary: 'orca', + alternate: 'system' + }) + expect( + terminalHttpLinkActionDestinationsFor({ openLinksInApp: false }, owner, canOpen) + ).toEqual({ + primary: 'system', + alternate: 'orca' + }) + } + ) it.each([ ['incapable runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const], - ['SSH', { kind: 'ssh', connectionId: 'ssh-1' } as const], + ['ineligible SSH', { kind: 'ssh', connectionId: 'ssh-1' } as const], ['unknown owner', { kind: 'unknown' } as const] ])('offers only the system browser for an %s', (_label, owner) => { expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: true }, owner, false)).toEqual({ diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 7820f28341f..d0646a9db1d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -37,13 +37,22 @@ export type TerminalUrlOpenHintOptions = { showActions?: boolean } +function canSourceOwnerOpenInOrca( + sourceOwner: HttpLinkSourceOwner, + canOpenOwnedBrowser: boolean +): boolean { + return ( + sourceOwner.kind === 'local' || + ((sourceOwner.kind === 'runtime' || sourceOwner.kind === 'ssh') && canOpenOwnedBrowser) + ) +} + export function terminalHttpLinkActionDestinationsFor( settings: { openLinksInApp?: boolean } | null | undefined, sourceOwner: HttpLinkSourceOwner, - canOpenRuntimeBrowser: boolean + canOpenOwnedBrowser: boolean ): TerminalHttpLinkActionDestinations { - const canOpenInOrca = - sourceOwner.kind === 'local' || (sourceOwner.kind === 'runtime' && canOpenRuntimeBrowser) + const canOpenInOrca = canSourceOwnerOpenInOrca(sourceOwner, canOpenOwnedBrowser) if (!canOpenInOrca) { return { primary: 'system' } } @@ -52,7 +61,7 @@ export function terminalHttpLinkActionDestinationsFor( : { primary: 'system', alternate: 'orca' } } -// Why: only a capability-verified runtime can advertise the in-app destination. +// Why: remote owners advertise Orca only when their existing browser route is eligible. export function terminalUrlOpenHintOptionsFor( settings: | { @@ -63,10 +72,10 @@ export function terminalUrlOpenHintOptionsFor( | null | undefined, sourceOwner?: HttpLinkSourceOwner, - canOpenRuntimeBrowser = false + canOpenOwnedBrowser = false ): TerminalUrlOpenHintOptions { const sourceCanOpenInOrca = sourceOwner - ? sourceOwner.kind === 'local' || (sourceOwner.kind === 'runtime' && canOpenRuntimeBrowser) + ? canSourceOwnerOpenInOrca(sourceOwner, canOpenOwnedBrowser) : !settings?.activeRuntimeEnvironmentId?.trim() return { openLinksInApp: settings?.openLinksInApp === true, diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts index 01896238c05..9bd10474235 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -2,7 +2,7 @@ import type { IBufferLine, Terminal } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { registerHttpLinkStoreAccessor, - registerRuntimeHttpLinkBrowserOpener + registerWorkspaceHttpLinkBrowserOpener } from '@/lib/http-link-routing' import { handleOscLink } from './terminal-osc-link-routing' import { handleTerminalWebLinkClick } from './terminal-web-link-click' @@ -108,11 +108,11 @@ beforeEach(() => { setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock })) - registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) + registerWorkspaceHttpLinkBrowserOpener(openRuntimeBrowserTabMock) }) afterEach(() => { - registerRuntimeHttpLinkBrowserOpener(null) + registerWorkspaceHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) @@ -190,17 +190,23 @@ describe('terminal HTTP links on a runtime-hosted pane', () => { describe('terminal HTTP links on a direct SSH pane', () => { const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' } - it('sends an OSC 8 hyperlink to the system browser', () => { + it('opens an OSC 8 hyperlink through the owning SSH workspace', () => { expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: sshSourceOwner })).toBe( true ) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + url: URL, + intent: { kind: 'url' }, + expectedSshConnectionId: 'ssh-1' + }) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() expect(setActiveWorktreeMock).not.toHaveBeenCalled() }) - it('sends a WebLinksAddon click to the system browser', () => { + it('opens a WebLinksAddon click through the owning SSH workspace', () => { const { terminal } = makeTerminal() expect( @@ -211,11 +217,14 @@ describe('terminal HTTP links on a direct SSH pane', () => { }) ).toBe(true) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedSshConnectionId: 'ssh-1', url: URL }) + ) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() }) - it('sends a click-fallback activation to the system browser', () => { + it('opens a click-fallback activation through the owning SSH workspace', () => { const { terminal, registrations } = makeTerminal() const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1', @@ -226,7 +235,10 @@ describe('terminal HTTP links on a direct SSH pane', () => { ([name, _listener, options]) => name === 'mouseup' && options === undefined )?.[1](clickEvent()) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedSshConnectionId: 'ssh-1', url: URL }) + ) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index fad5301c804..df223a4bbd1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -277,7 +277,7 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi const sourceOwner = deps.sourceOwner ?? { kind: 'local' } if (deps.forceDestination) { openHttpLink(url, { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: deps.worktreeId, forceInApp: deps.forceDestination === 'orca', forceSystemBrowser: deps.forceDestination === 'system', @@ -289,7 +289,7 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi // Why: the modifier states a destination outright, so it also skips the // one-time routing prompt; openHttpLink resolves which destination it means. openHttpLink(url, { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: deps.worktreeId, modifierHeld: true, sourceOwner @@ -301,7 +301,7 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi const preferenceDecision = sourceOwner.kind === 'local' ? deps.requestOpenLinksInAppPreference?.(url) : null if (preferenceDecision === null || preferenceDecision === undefined) { - openHttpLink(url, { allowRuntimeInApp: true, worktreeId: deps.worktreeId, sourceOwner }) + openHttpLink(url, { allowRemoteInApp: true, worktreeId: deps.worktreeId, sourceOwner }) return } @@ -311,7 +311,7 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi void Promise.resolve(preferenceDecision) .then((openInOrca) => { openHttpLink(url, { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: deps.worktreeId, forceSystemBrowser: !openInOrca, sourceOwner @@ -319,7 +319,7 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi }) .catch(() => { openHttpLink(url, { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: deps.worktreeId, forceSystemBrowser: true, sourceOwner diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 473d8551346..bb6d515f221 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -57,7 +57,10 @@ import { type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' -import { canOpenWorkspaceBrowserTabOnRuntime } from '@/lib/workspace-browser-tab-open' +import { + canOpenWorkspaceBrowserTabOnRuntime, + canOpenWorkspaceBrowserTabOnSsh +} from '@/lib/workspace-browser-tab-open' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -828,15 +831,22 @@ export function useTerminalPaneLifecycle({ resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) const getHttpLinkSourceOwnerForPane = (paneId: number) => resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId)) - const canOpenRuntimeBrowserForPane = (paneId: number): boolean => { + const canOpenOwnedBrowserForPane = (paneId: number): boolean => { const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) - return ( - sourceOwner.kind === 'runtime' && - canOpenWorkspaceBrowserTabOnRuntime( + if (sourceOwner.kind === 'runtime') { + return canOpenWorkspaceBrowserTabOnRuntime( useAppStore.getState(), worktreeId, sourceOwner.runtimeEnvironmentId ) + } + return ( + sourceOwner.kind === 'ssh' && + canOpenWorkspaceBrowserTabOnSsh( + useAppStore.getState(), + worktreeId, + sourceOwner.connectionId + ) ) } const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => { @@ -844,7 +854,7 @@ export function useTerminalPaneLifecycle({ return terminalHttpLinkActionDestinationsFor( settingsRef.current, sourceOwner, - canOpenRuntimeBrowserForPane(paneId) + canOpenOwnedBrowserForPane(paneId) ) } const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => { @@ -1001,7 +1011,7 @@ export function useTerminalPaneLifecycle({ ...terminalUrlOpenHintOptionsFor( settingsRef.current, getHttpLinkSourceOwnerForPane(paneId), - canOpenRuntimeBrowserForPane(paneId) + canOpenOwnedBrowserForPane(paneId) ), showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false }) diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index fb3be988a4f..8d0f94d580d 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { openHttpLink, registerHttpLinkStoreAccessor, - registerRuntimeHttpLinkBrowserOpener, + registerWorkspaceHttpLinkBrowserOpener, resolveModifierRouting } from './http-link-routing' @@ -73,12 +73,12 @@ describe('modifier routing across link source owners', () => { beforeEach(() => { vi.clearAllMocks() registerHttpLinkStoreAccessor(() => storeState) - registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) + registerWorkspaceHttpLinkBrowserOpener(openRuntimeBrowserTabMock) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) }) afterEach(() => { - registerRuntimeHttpLinkBrowserOpener(null) + registerWorkspaceHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) @@ -104,7 +104,7 @@ describe('modifier routing across link source owners', () => { } openHttpLink('https://example.com/', { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: 'wt-1', modifierHeld: true, sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 1be44f90732..efd9ff7e4b5 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -4,7 +4,7 @@ import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' import { openHttpLink, registerHttpLinkStoreAccessor, - registerRuntimeHttpLinkBrowserOpener, + registerWorkspaceHttpLinkBrowserOpener, resolveLocalhostHttpLinkDisplayUrl } from './http-link-routing' @@ -48,7 +48,7 @@ beforeEach(() => { storeState.settings = undefined storeState.workspacePortScansByKey = {} registerHttpLinkStoreAccessor(() => storeState) - registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) + registerWorkspaceHttpLinkBrowserOpener(openRuntimeBrowserTabMock) vi.stubGlobal('window', { api: { shell: { @@ -62,7 +62,7 @@ beforeEach(() => { }) afterEach(() => { - registerRuntimeHttpLinkBrowserOpener(null) + registerWorkspaceHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) @@ -163,26 +163,33 @@ describe('openHttpLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) - it('routes runtime and SSH document owners to their distinct destinations', () => { + it('routes runtime and SSH document owners through their workspace browsers', () => { storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true } openHttpLink('http://localhost:5180/runtime', { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: 'wt-1', sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } }) openHttpLink('http://localhost:5180/ssh', { + allowRemoteInApp: true, worktreeId: 'wt-1', sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' } }) - expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({ + expect(openRuntimeBrowserTabMock).toHaveBeenNthCalledWith(1, { workspaceId: 'wt-1', url: 'http://localhost:5180/runtime', intent: { kind: 'url' }, expectedRuntimeEnvironmentId: 'env-1' }) - expect(openUrlMock).toHaveBeenCalledWith('http://localhost:5180/ssh') + expect(openRuntimeBrowserTabMock).toHaveBeenNthCalledWith(2, { + workspaceId: 'wt-1', + url: 'http://localhost:5180/ssh', + intent: { kind: 'url' }, + expectedSshConnectionId: 'ssh-1' + }) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) @@ -193,7 +200,7 @@ describe('openHttpLink', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: null } openHttpLink('https://example.com/', { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: 'wt-1', sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } }) @@ -211,7 +218,7 @@ describe('openHttpLink', () => { openRuntimeBrowserTabMock.mockRejectedValueOnce(new Error('runtime unavailable')) openHttpLink('https://example.com/', { - allowRuntimeInApp: true, + allowRemoteInApp: true, worktreeId: 'wt-1', sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } }) diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 0699a7f6300..aa89fb7071f 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -10,8 +10,8 @@ import { toast } from 'sonner' export type OpenHttpLinkOptions = { worktreeId?: string | null - /** Terminal-only opt-in for routing a runtime-owned source through its managed browser. */ - allowRuntimeInApp?: boolean + /** Terminal-only opt-in for routing a remote-owned source through its managed browser. */ + allowRemoteInApp?: boolean /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean /** Unconditional for local sources: open inside Orca regardless of settings. */ @@ -47,14 +47,15 @@ type StoreAccessor = () => { workspacePortScansByKey?: Record } -type RuntimeHttpLinkBrowserRequest = { +type WorkspaceHttpLinkBrowserRequest = { workspaceId: string url: string intent: { kind: 'url' } - expectedRuntimeEnvironmentId: string + expectedRuntimeEnvironmentId?: string + expectedSshConnectionId?: string } -type RuntimeHttpLinkBrowserOpener = (request: RuntimeHttpLinkBrowserRequest) => Promise +type WorkspaceHttpLinkBrowserOpener = (request: WorkspaceHttpLinkBrowserRequest) => Promise type LocalhostLinkRepo = { id: string @@ -74,16 +75,16 @@ type LocalhostLinkWorktree = { // the break, several renderer test files that load this module first see // `createEditorSlice` as undefined at store/index.ts initialization. let storeAccessor: StoreAccessor | null = null -let runtimeHttpLinkBrowserOpener: RuntimeHttpLinkBrowserOpener | null = null +let workspaceHttpLinkBrowserOpener: WorkspaceHttpLinkBrowserOpener | null = null export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { storeAccessor = fn } -export function registerRuntimeHttpLinkBrowserOpener( - fn: RuntimeHttpLinkBrowserOpener | null +export function registerWorkspaceHttpLinkBrowserOpener( + fn: WorkspaceHttpLinkBrowserOpener | null ): void { - runtimeHttpLinkBrowserOpener = fn + workspaceHttpLinkBrowserOpener = fn } // Scope: http(s) URLs only. file: URIs and in-worktree markdown targets are @@ -115,7 +116,7 @@ export function resolveModifierRouting( export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { const { worktreeId, - allowRuntimeInApp, + allowRemoteInApp, forceSystemBrowser, forceInApp, modifierHeld, @@ -139,13 +140,20 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void Boolean(worktreeId) && (forceInApp || openLinksInApp || modifier.wantsOrca) - if (wantsOrca && allowRuntimeInApp && worktreeId && sourceOwner?.kind === 'runtime') { - if (runtimeHttpLinkBrowserOpener) { - void runtimeHttpLinkBrowserOpener({ + if ( + wantsOrca && + allowRemoteInApp && + worktreeId && + (sourceOwner?.kind === 'runtime' || sourceOwner?.kind === 'ssh') + ) { + if (workspaceHttpLinkBrowserOpener) { + void workspaceHttpLinkBrowserOpener({ workspaceId: worktreeId, url, intent: { kind: 'url' }, - expectedRuntimeEnvironmentId: sourceOwner.runtimeEnvironmentId + ...(sourceOwner.kind === 'runtime' + ? { expectedRuntimeEnvironmentId: sourceOwner.runtimeEnvironmentId } + : { expectedSshConnectionId: sourceOwner.connectionId }) }).catch((error) => { toast.error( error instanceof Error diff --git a/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts new file mode 100644 index 00000000000..2ecd6007e09 --- /dev/null +++ b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts @@ -0,0 +1,29 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../shared/execution-host' + +type SshBrowserRoutingSettings = Pick< + GlobalSettings, + 'browserSshWorkspaceRoutingEnabled' | 'browserSshWorkspaceRoutingDisabledTargetIds' +> + +export type SshWorkspaceBrowserRouteEligibility = { + targetId: string + eligible: boolean +} + +export function resolveSshWorkspaceBrowserRouteEligibility( + executionHostId: string | null | undefined, + settings: SshBrowserRoutingSettings | null | undefined +): SshWorkspaceBrowserRouteEligibility | null { + const parsed = parseExecutionHostId(executionHostId) + // Why: runtime-owned ephemeral SSH targets belong to the paired runtime's browser route. + if (parsed?.kind !== 'ssh' || isRuntimeOwnedSshTargetId(parsed.targetId)) { + return null + } + return { + targetId: parsed.targetId, + eligible: + settings?.browserSshWorkspaceRoutingEnabled !== false && + !settings?.browserSshWorkspaceRoutingDisabledTargetIds?.includes(parsed.targetId) + } +} diff --git a/src/renderer/src/lib/workspace-browser-tab-open.test.ts b/src/renderer/src/lib/workspace-browser-tab-open.test.ts index 3e8b93a6726..307156f40f0 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.test.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.test.ts @@ -8,6 +8,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { canOpenWorkspaceBrowserTabOnRuntime, + canOpenWorkspaceBrowserTabOnSsh, openWorkspaceBrowserTab } from './workspace-browser-tab-open' @@ -76,11 +77,16 @@ describe('openWorkspaceBrowserTab', () => { defaultBrowserSessionProfileIdByHostId: { [sshHost]: 'ssh-profile' } } + expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-target')).toBe( + true + ) + await openWorkspaceBrowserTab({ workspaceId: WORKSPACE_ID, targetGroupId: 'group-1', url: 'https://www.google.com/search?q=private%20query', - intent: { kind: 'search', engine: 'google' } + intent: { kind: 'search', engine: 'google' }, + expectedSshConnectionId: 'ssh-target' }) expect(createBrowserTab).toHaveBeenCalledWith( @@ -98,6 +104,33 @@ describe('openWorkspaceBrowserTab', () => { expect(mocks.createRemote).not.toHaveBeenCalled() }) + it('fails closed when the asserted SSH browser route is opted out or belongs to another host', async () => { + const sshHost = toSshExecutionHostId('ssh-target') + mocks.state = { + ...ownerState(sshHost), + settings: { browserSshWorkspaceRoutingDisabledTargetIds: ['ssh-target'] }, + createBrowserTab: vi.fn(), + defaultBrowserSessionProfileId: 'focused-profile', + defaultBrowserSessionProfileIdByHostId: { [sshHost]: 'ssh-profile' } + } + + expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-target')).toBe( + false + ) + expect(canOpenWorkspaceBrowserTabOnSsh(mocks.state as never, WORKSPACE_ID, 'ssh-other')).toBe( + false + ) + await expect( + openWorkspaceBrowserTab({ + workspaceId: WORKSPACE_ID, + url: 'http://0.0.0.0:8000/', + intent: { kind: 'url' }, + expectedSshConnectionId: 'ssh-target' + }) + ).rejects.toThrow('Unable to open URL.') + expect(mocks.state.createBrowserTab).not.toHaveBeenCalled() + }) + it('surfaces the opening workspace and titles runtime-owned URL tabs by target', async () => { const createBrowserTab = vi.fn() mocks.state = { diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index 52416b75739..9fed023ac4d 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -15,6 +15,8 @@ import { type ClientCreationActionAvailability } from './client-creation-action-policy' import { resolveWorktreeOperationRoute } from './worktree-operation-route' +import { getExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility' export type WorkspaceBrowserTabIntent = { kind: 'url' } | { kind: 'search'; engine: SearchEngine } @@ -24,6 +26,7 @@ export type OpenWorkspaceBrowserTabRequest = { url: string intent: WorkspaceBrowserTabIntent expectedRuntimeEnvironmentId?: string + expectedSshConnectionId?: string } function isExpectedRuntimeBrowserRoute( @@ -69,6 +72,42 @@ export function canOpenWorkspaceBrowserTabOnRuntime( ) } +function isExpectedSshBrowserRoute( + state: AppState, + availability: ClientCreationActionAvailability, + route: ReturnType, + workspaceId: string, + expectedSshConnectionId: string +): boolean { + if (availability.state !== 'enabled' || workspaceId === FLOATING_TERMINAL_WORKTREE_ID || !route) { + return false + } + const expectedTargetId = expectedSshConnectionId.trim() + const eligibility = resolveSshWorkspaceBrowserRouteEligibility( + getExecutionHostIdForWorktree(state, workspaceId), + state.settings + ) + const host = parseExecutionHostId(route.executionHostId) + return ( + Boolean(expectedTargetId) && + route.runtimeEnvironmentId === null && + eligibility?.eligible === true && + eligibility.targetId === expectedTargetId && + host?.kind === 'ssh' && + host.targetId === expectedTargetId + ) +} + +export function canOpenWorkspaceBrowserTabOnSsh( + state: AppState, + workspaceId: string, + expectedSshConnectionId: string +): boolean { + const availability = getClientCreationActionPolicy(state, workspaceId)['managed-browser'] + const route = resolveWorktreeOperationRoute(state, workspaceId) + return isExpectedSshBrowserRoute(state, availability, route, workspaceId, expectedSshConnectionId) +} + // Why: concurrent URL tabs are indistinguishable under a shared "Open URL" // label until the page title loads; the query string stays out so a typed URL // does not park credentials or tokens in persisted tab state. @@ -183,6 +222,11 @@ export async function openWorkspaceBrowserTab( request.expectedRuntimeEnvironmentId === undefined ? null : request.expectedRuntimeEnvironmentId.trim() + const expectedSshConnectionId = + request.expectedSshConnectionId === undefined ? null : request.expectedSshConnectionId.trim() + if (expectedEnvironmentId !== null && expectedSshConnectionId !== null) { + throw openFailure(presentation.error, 'browser owner assertion is ambiguous') + } if ( expectedEnvironmentId !== null && !isExpectedRuntimeBrowserRoute( @@ -195,6 +239,18 @@ export async function openWorkspaceBrowserTab( ) { throw openFailure(presentation.error, 'asserted runtime cannot provide this managed browser') } + if ( + expectedSshConnectionId !== null && + !isExpectedSshBrowserRoute( + state, + availability, + route, + request.workspaceId, + expectedSshConnectionId + ) + ) { + throw openFailure(presentation.error, 'asserted SSH connection cannot provide this browser') + } const host = parseExecutionHostId(route.executionHostId) if (!environmentId) { if (!host || host.kind === 'runtime') { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 35bc1845736..48976018f81 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -49,7 +49,7 @@ import { e2eConfig } from '@/lib/e2e-config' import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session' import { registerHttpLinkStoreAccessor, - registerRuntimeHttpLinkBrowserOpener + registerWorkspaceHttpLinkBrowserOpener } from '@/lib/http-link-routing' import { installStoreListenerCensus } from './store-listener-census' import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' @@ -112,7 +112,7 @@ export const useAppStore = create()( ) registerHttpLinkStoreAccessor(() => useAppStore.getState()) -registerRuntimeHttpLinkBrowserOpener(async (request) => { +registerWorkspaceHttpLinkBrowserOpener(async (request) => { const { openWorkspaceBrowserTab } = await import('@/lib/workspace-browser-tab-open') await openWorkspaceBrowserTab(request) })