mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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.
This commit is contained in:
+4
-11
@@ -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 <Globe className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
const routed = routingEnabled && !disabledTargetIds?.includes(targetId)
|
||||
const { targetId, eligible: routed } = routeEligibility
|
||||
const hostLabel =
|
||||
getHostSettingOverride(settings, toSshExecutionHostId(targetId), 'displayLabel') ??
|
||||
sshTargetLabels.get(targetId) ??
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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> = {}): HostedReviewInfo {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+21
-9
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
@@ -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<string, WorkspacePortScanResult>
|
||||
}
|
||||
|
||||
type RuntimeHttpLinkBrowserRequest = {
|
||||
type WorkspaceHttpLinkBrowserRequest = {
|
||||
workspaceId: string
|
||||
url: string
|
||||
intent: { kind: 'url' }
|
||||
expectedRuntimeEnvironmentId: string
|
||||
expectedRuntimeEnvironmentId?: string
|
||||
expectedSshConnectionId?: string
|
||||
}
|
||||
|
||||
type RuntimeHttpLinkBrowserOpener = (request: RuntimeHttpLinkBrowserRequest) => Promise<void>
|
||||
type WorkspaceHttpLinkBrowserOpener = (request: WorkspaceHttpLinkBrowserRequest) => Promise<void>
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<typeof resolveWorktreeOperationRoute>,
|
||||
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') {
|
||||
|
||||
@@ -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<AppState>()(
|
||||
)
|
||||
|
||||
registerHttpLinkStoreAccessor(() => useAppStore.getState())
|
||||
registerRuntimeHttpLinkBrowserOpener(async (request) => {
|
||||
registerWorkspaceHttpLinkBrowserOpener(async (request) => {
|
||||
const { openWorkspaceBrowserTab } = await import('@/lib/workspace-browser-tab-open')
|
||||
await openWorkspaceBrowserTab(request)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user