fix(browser): attach the desktop browser bridge to environment recipe SSH hosts (#38722)

* fix(browser): attach the desktop browser bridge to environment recipe SSH hosts

Recipe workspaces connect runtime-owned SSH targets that serve a managed
Orca server, but the bridge picked candidates from the SSH settings list,
which hides runtime-owned targets, and browser routing refused them too.
Pick bridge candidates from the managed-orcad claims, validate route
partitions by registration, and route deployment hosts whoever owns them.

Fixes #38603

Reported-by: Mehul K (@mek97)

* fix(browser): follow recipe host connections for the bridge and page routes

Owned SSH targets returned before the bridge rechecked, so a recipe host
connecting never attached it, and recipe browser panes had no published
SSH state to wait on. Recheck the bridge on every SSH state change, and
read a recipe target's connection from its server's state.

Fixes #38603

* fix(browser): settle a recipe browser route when its server is unreachable

Only a live dial waits; lost or never-made contact now lets the route
card offer Retry and browsing from this device.

Fixes #38603

* test: annotate hoisted fixtures instead of casting

Fixes #38603
This commit is contained in:
Neil
2026-11-05 15:55:01 -08:00
committed by GitHub
parent 5bc786c1a7
commit 8f490f2fcd
11 changed files with 276 additions and 16 deletions
@@ -1,24 +1,24 @@
import { describe, expect, it } from 'vitest'
import { requireLocalSshBrowserRouteTarget } from './local-ssh-browser-route'
const targets = [{ id: 'target-a', generation: 4 }]
const target = { generation: 4 }
describe('local SSH browser target registration', () => {
it('preserves direct SSH callers that do not supply a deployment fence', () => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a')).not.toThrow()
expect(() => requireLocalSshBrowserRouteTarget(target)).not.toThrow()
})
it.each([3, 5, '4', null])(
'refuses a stale or invalid deployment registration: %j',
(generation) => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a', generation)).toThrow(
expect(() => requireLocalSshBrowserRouteTarget(target, generation)).toThrow(
'browser_local_route_target_stale'
)
}
)
it('refuses a removed target instead of borrowing another route', () => {
expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-b', 4)).toThrow(
expect(() => requireLocalSshBrowserRouteTarget(undefined, 4)).toThrow(
'browser_local_route_target_invalid'
)
})
@@ -5,12 +5,11 @@ import type { SshTargetSummary } from '../../../../shared/ssh-types'
import { openExecutionRouteSocketAsDuplex } from '../../network/tunnels/execution-route-socket-duplex'
import { RemoteBrowserSocksServer } from '../../network/tunnels/remote-browser-socks-server'
/** Any registered target may carry a route, including the runtime-owned ones recipes connect. */
export function requireLocalSshBrowserRouteTarget(
targets: readonly Pick<SshTargetSummary, 'id' | 'generation'>[] | undefined,
targetId: string,
target: Pick<SshTargetSummary, 'generation'> | undefined,
expectedGeneration?: unknown
): void {
const target = targets?.find((entry) => entry.id === targetId)
if (!target) {
throw new Error('browser_local_route_target_invalid')
}
+1 -2
View File
@@ -132,8 +132,7 @@ export function registerBrowserHandlers(store: BrowserPasswordsSettingsSource):
const { requireLocalSshBrowserRouteTarget } =
await import('../../browser/routing/local-host-partitions/local-ssh-browser-route')
requireLocalSshBrowserRouteTarget(
getSshConnectionStore()?.listTargets(),
args.targetId,
getSshConnectionStore()?.getTarget(args.targetId),
args.expectedSshTargetGeneration
)
const { prepareLocalSshBrowserPartition } =
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshConnectionStore } from '../../ssh/connection/ssh-connection-store'
import { createMockStore } from '../../ssh/connection/ssh-connection-store-test-fixture'
import { emptyDependentStateStore } from '../../ssh/targets/ssh-target-orcad-dependents-fixture'
const { registry, environments } = vi.hoisted(() => {
const registry: { store: unknown } = { store: null }
return { registry, environments: new Map<string, unknown>() }
})
vi.mock('electron', () => ({ webContents: { fromId: vi.fn() } }))
vi.mock('../../browser/browser-manager', () => ({ browserManager: {} }))
vi.mock('../../runtime/rpc/dispatcher', () => ({ RpcDispatcher: vi.fn() }))
vi.mock('../../runtime/rpc/methods/index', () => ({ ALL_RPC_METHODS: [] }))
vi.mock('../../managed-server/lifecycle/orcad-managed-tunnel', () => ({
ensureOrcadManagedTunnel: vi.fn()
}))
vi.mock('../runtime-environments/runtime-environment-status-probe', () => ({
getRuntimeEnvironmentStatus: vi.fn()
}))
vi.mock('../ssh/ssh-ipc-context', () => ({ persistedStore: { getSettings: () => ({}) } }))
vi.mock('../../ssh/targets/ssh-target-registry', () => ({
getSshTargetRegistryStore: () => registry.store
}))
vi.mock('../../../shared/remote-runtime/host-store/runtime-environment-store', () => ({
resolveEnvironment: (_userDataPath: string, id: string) => {
const environment = environments.get(id)
if (!environment) {
throw new Error(`unknown environment ${id}`)
}
return environment
}
}))
import { listDesktopBrowserBridgeCandidates } from './desktop-browser-bridge-wiring'
const base = { host: 'vm.example.com', port: 22, username: 'dev' }
function servedEnvironment(environmentId: string, sshTargetId: string, generation: number) {
environments.set(environmentId, {
id: environmentId,
orcadDeployment: { sshTargetId, sshTargetGeneration: generation }
})
}
describe('desktop browser bridge candidates', () => {
let sshStore: SshConnectionStore
beforeEach(() => {
environments.clear()
const store = Object.assign(createMockStore(), emptyDependentStateStore())
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements the store methods SshConnectionStore calls.
sshStore = new SshConnectionStore(store as never, { config: async () => null })
registry.store = sshStore
})
it('attaches to an environment recipe host the same way as a listed managed host', () => {
const listed = sshStore.addTarget({ ...base, label: 'listed' })
sshStore.getOrcadRuntimeClaims().claim(listed.id, 'env-listed', { ownerRecorded: false })
servedEnvironment('env-listed', listed.id, listed.generation!)
const recipe = sshStore.upsertRuntimeOwnedTarget('vm-1', { ...base, label: 'recipe' })
sshStore.getOrcadRuntimeClaims().claim(recipe.id, 'env-recipe', { ownerRecorded: false })
servedEnvironment('env-recipe', recipe.id, recipe.generation!)
const connected = { getState: () => ({ status: 'connected' }) }
expect(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: candidates read only status.
listDesktopBrowserBridgeCandidates('/user-data', connected as never)
).toEqual([
{ environmentId: 'env-listed', targetId: listed.id, hostLabel: 'listed' },
{ environmentId: 'env-recipe', targetId: recipe.id, hostLabel: 'recipe' }
])
})
it('still skips a recipe host that serves no managed Orca server', () => {
sshStore.upsertRuntimeOwnedTarget('vm-2', { ...base, label: 'unserved' })
const connected = { getState: () => ({ status: 'connected' }) }
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: candidates read only status.
expect(listDesktopBrowserBridgeCandidates('/user-data', connected as never)).toEqual([])
})
})
@@ -54,7 +54,9 @@ export function listDesktopBrowserBridgeCandidates(
connections: Pick<SshConnectionManager, 'getState'> | null
): DesktopBrowserBridgeCandidate[] {
const candidates: DesktopBrowserBridgeCandidate[] = []
for (const target of getSshTargetRegistryStore()?.listTargets() ?? []) {
// Why: a managed host's eligibility follows its orcad claim, not whether it is listed in SSH
// settings; environment recipes connect runtime-owned targets that are claimed the same way.
for (const target of getSshTargetRegistryStore()?.getOrcadRuntimeClaims().listTargets() ?? []) {
if (!isRemoteCliControlAllowed(target)) {
continue
}
@@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { reconcile, send, target } = vi.hoisted(() => {
const target: { current: unknown } = { current: undefined }
return { reconcile: vi.fn(), send: vi.fn(), target }
})
vi.mock('../browser/desktop-browser-bridge-reconcile', () => ({
requestDesktopBrowserBridgeReconcile: reconcile
}))
vi.mock('../../ssh/targets/ssh-target-registry', () => ({
getSshTargetRegistryStore: () => ({ getTarget: () => target.current })
}))
vi.mock('./ssh-ipc-context', () => ({
connectionManager: null,
currentRuntime: null,
getCurrentMainWindow: () => null,
persistedStore: null,
portForwardManager: null
}))
vi.mock('../../ssh/ssh-provider-authority', () => ({
getSshProviderAuthority: () => ({ providerEpoch: 'epoch', connectionGeneration: 1 })
}))
vi.mock('../../ssh/ssh-host-server-status', () => ({
clearSshHostServerStatus: vi.fn(),
getSshHostServerStatus: () => undefined
}))
import { broadcastSshState } from './ssh-renderer-broadcast'
const window = { isDestroyed: () => false, webContents: { send } }
const connected = { targetId: '', status: 'connected', error: null, reconnectAttempt: 0 } as const
describe('SSH state broadcast', () => {
beforeEach(() => {
reconcile.mockClear()
send.mockClear()
})
it('rechecks the desktop browser bridge when an environment recipe host connects', () => {
target.current = {
id: 'runtime-ssh-vm-1',
owner: { type: 'on-demand-runtime', runtimeId: 'vm-1' }
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: broadcast reads only these window members.
broadcastSshState(() => window as never, 'runtime-ssh-vm-1', connected)
expect(send).not.toHaveBeenCalled()
expect(reconcile).toHaveBeenCalledTimes(1)
})
})
+2 -1
View File
@@ -30,6 +30,8 @@ export function broadcastSshState(
targetId: string,
state: SshConnectionState
): void {
// The bridge serves claimed hosts, including owned ones whose state stays internal below.
requestDesktopBrowserBridgeReconcile()
const target = getSshTargetRegistryStore()?.getTarget(targetId)
// Internal targets must not leak transport state into clients' persisted reconnect hints.
if (isOwnedSshTargetId(targetId) || (target && isRuntimeOwnedSshTarget(target))) {
@@ -43,7 +45,6 @@ export function broadcastSshState(
}
// Paired clients lack local SSH IPC; publish runtime events to clear stale reconnect overlays.
currentRuntime?.notifySshStateChanged?.(targetId, enrichedState)
requestDesktopBrowserBridgeReconcile()
}
function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState {
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import type { RuntimeEnvironmentStatus } from '../../../shared/remote-runtime/host-status/runtime-host-status'
import { resolveBrowserRouteHostConnection } from './worktree-host-connection-phase'
const RECIPE_TARGET = 'runtime-ssh-vm-1'
function stateWith(entry: RuntimeEnvironmentStatus | undefined): AppState {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only these fields.
return {
runtimeEnvironments: [
{
id: 'env-recipe',
orcadDeployment: { sshTargetId: RECIPE_TARGET, sshTargetGeneration: 1 }
}
],
runtimeStatusByEnvironmentId: new Map(entry ? [['env-recipe', entry]] : []),
sshConnectionStates: new Map()
} as unknown as AppState
}
describe('browser route host connection for a recipe VM', () => {
it('waits only while the recipe server is being dialed', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an awaiting handshake needs no other fields here.
const dialing = {
status: null,
remoteControl: { state: 'awaiting_ready' },
checkedAt: 1
} as RuntimeEnvironmentStatus
expect(resolveBrowserRouteHostConnection(stateWith(dialing), 'wt', RECIPE_TARGET)).toEqual(
expect.objectContaining({
phase: 'connecting',
targetId: RECIPE_TARGET,
connectedEpoch: null
})
)
})
it('settles when the recipe server was never reached, so the route offers recovery', () => {
expect(resolveBrowserRouteHostConnection(stateWith(undefined), 'wt', RECIPE_TARGET).phase).toBe(
'unavailable'
)
})
it('names a new connected epoch when the recipe server is reached again', () => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a ready status needs no other fields here.
const status = { graphStatus: 'ready' } as RuntimeEnvironmentStatus['status']
const first = resolveBrowserRouteHostConnection(
stateWith({ status, checkedAt: 1, connectionGeneration: 3, hostContactEpoch: 1 }),
'wt',
RECIPE_TARGET
)
const second = resolveBrowserRouteHostConnection(
stateWith({ status, checkedAt: 2, connectionGeneration: 3, hostContactEpoch: 2 }),
'wt',
RECIPE_TARGET
)
expect(first.phase).toBe('connected')
expect(first.connectedEpoch).not.toBeNull()
expect(second.connectedEpoch).not.toBe(first.connectedEpoch)
})
})
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility'
const recipeEnvironment = {
id: 'env-recipe',
orcadDeployment: { sshTargetId: 'runtime-ssh-vm-1', sshTargetGeneration: 7 }
}
describe('SSH workspace browser route eligibility', () => {
it('routes an environment recipe workspace through its runtime-owned SSH host', () => {
expect(
resolveSshWorkspaceBrowserRouteEligibility(toRuntimeExecutionHostId('env-recipe'), {}, [
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: eligibility reads only the deployment link.
recipeEnvironment as never
])
).toEqual({ targetId: 'runtime-ssh-vm-1', expectedSshTargetGeneration: 7, eligible: true })
})
it('never treats a recipe VM target as a direct SSH workspace host', () => {
expect(resolveSshWorkspaceBrowserRouteEligibility('ssh:runtime-ssh-vm-1', {})).toBeNull()
})
})
@@ -29,8 +29,8 @@ export function resolveSshWorkspaceBrowserRouteEligibility(
// WSL pages use the paired-runtime tunnel over wsl.exe, never SSH/localhost-forwarded routing.
const deployment = environment && getOrcadSshDeployment(environment)
const targetId = parsed?.kind === 'ssh' ? parsed.targetId : deployment?.sshTargetId
// Why: runtime-owned ephemeral SSH targets belong to the paired runtime's browser route.
if (!targetId || isRuntimeOwnedSshTargetId(targetId)) {
// Why: a recipe VM's target is never a direct workspace host, but routes pages as its deployment's.
if (!targetId || (parsed?.kind === 'ssh' && isRuntimeOwnedSshTargetId(targetId))) {
return null
}
return {
@@ -10,6 +10,9 @@ import {
import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host'
import { getOrcadSshDeployment } from '../../../shared/remote-runtime/host-catalog/runtime-environments'
import { isConnectedRuntimeHostState } from '../../../shared/remote-runtime/host-status/runtime-host-connection-state'
import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state'
/**
* `local`: no SSH target this client dials for the worktree — a local workspace, a
@@ -117,15 +120,52 @@ export function selectWorktreeHostConnectionPhase(
)
}
/**
* The SSH connection this client's browser route dials. A recipe VM's target publishes no SSH
* state, but its server's tunnel runs over that same connection, so the server's state stands in.
*/
export function resolveBrowserRouteHostConnection(
state: AppState,
worktreeId: string | null,
targetId: string | null
): WorktreeHostConnection {
if (!targetId || !isRuntimeOwnedSshTargetId(targetId)) {
return resolveWorktreeHostConnection(state, worktreeId, targetId, null)
}
const environmentId = state.runtimeEnvironments?.find(
(environment) => getOrcadSshDeployment(environment)?.sshTargetId === targetId
)?.id
if (!environmentId) {
return LOCAL_HOST_CONNECTION
}
const entry = state.runtimeStatusByEnvironmentId?.get(environmentId)
const hostState = runtimeHostConnectionStateForEntry(entry)
const connected = isConnectedRuntimeHostState(hostState)
// Only a live dial waits; lost or never-attempted contact settles so the route card offers recovery.
return {
phase: connected
? 'connected'
: entry && hostState === 'checking'
? 'connecting'
: 'unavailable',
targetId,
environmentId: null,
publishedStatus: null,
connectedEpoch: connected
? `${targetId}:${entry?.connectionGeneration ?? ''}:${entry?.hostContactEpoch ?? ''}`
: null
}
}
export function useWorktreeHostConnection(
worktreeId: string | null,
localSshTargetId?: string | null
browserRouteTargetId?: string | null
): WorktreeHostConnection {
return useAppStore(
useShallow((state) =>
localSshTargetId === undefined
browserRouteTargetId === undefined
? selectWorktreeHostConnectionPhase(state, worktreeId)
: resolveWorktreeHostConnection(state, worktreeId, localSshTargetId, null)
: resolveBrowserRouteHostConnection(state, worktreeId, browserRouteTargetId)
)
)
}