From 59278570dbdd7c28ea282ace151ef3a77efcd324 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 30 Aug 2026 02:37:40 -0400
Subject: [PATCH] STA-5496 offer Orca Browser for SSH terminal links (#16503)
Reuse direct SSH browser-route eligibility and route owner-pinned terminal links through the workspace createBrowserTab path. Keep the popover and modifier hints aligned with the eligible destination.
Preserve printed 0.0.0.0 and localhost URLs; the existing SSH SOCKS boundary normalizes wildcard listeners to remote loopback immediately before connect.
---
.../browser-egress-indicator.tsx | 15 ++---
.../use-ssh-workspace-browser-route.ts | 26 ++++-----
...Control.hosted-review-header-link.test.tsx | 2 +-
.../terminal-link-action-routing.test.ts | 28 +++++++++-
.../terminal-link-open-hints.test.ts | 48 +++++++++++-----
.../terminal-pane/terminal-link-open-hints.ts | 21 +++++--
...terminal-runtime-host-link-routing.test.ts | 30 +++++++---
.../terminal-url-link-hit-testing.ts | 10 ++--
.../use-terminal-pane-lifecycle.ts | 24 +++++---
.../lib/http-link-modifier-routing.test.ts | 8 +--
.../src/lib/http-link-routing.test.ts | 25 ++++++---
src/renderer/src/lib/http-link-routing.ts | 36 +++++++-----
...ssh-workspace-browser-route-eligibility.ts | 29 ++++++++++
.../lib/workspace-browser-tab-open.test.ts | 35 +++++++++++-
.../src/lib/workspace-browser-tab-open.ts | 56 +++++++++++++++++++
src/renderer/src/store/index.ts | 4 +-
16 files changed, 298 insertions(+), 99 deletions(-)
create mode 100644 src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts
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)
})