mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
+4
-4
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user