diff --git a/src/main/browser/routing/local-host-partitions/local-ssh-browser-route-target.test.ts b/src/main/browser/routing/local-host-partitions/local-ssh-browser-route-target.test.ts index ff9ea1a00aa..8ddaec3b3eb 100644 --- a/src/main/browser/routing/local-host-partitions/local-ssh-browser-route-target.test.ts +++ b/src/main/browser/routing/local-host-partitions/local-ssh-browser-route-target.test.ts @@ -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' ) }) diff --git a/src/main/browser/routing/local-host-partitions/local-ssh-browser-route.ts b/src/main/browser/routing/local-host-partitions/local-ssh-browser-route.ts index e73d00f5c51..49cc3633ed3 100644 --- a/src/main/browser/routing/local-host-partitions/local-ssh-browser-route.ts +++ b/src/main/browser/routing/local-host-partitions/local-ssh-browser-route.ts @@ -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[] | undefined, - targetId: string, + target: Pick | undefined, expectedGeneration?: unknown ): void { - const target = targets?.find((entry) => entry.id === targetId) if (!target) { throw new Error('browser_local_route_target_invalid') } diff --git a/src/main/ipc/browser/browser.ts b/src/main/ipc/browser/browser.ts index 19090787bd1..de5ad39ab89 100644 --- a/src/main/ipc/browser/browser.ts +++ b/src/main/ipc/browser/browser.ts @@ -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 } = diff --git a/src/main/ipc/browser/desktop-browser-bridge-candidates.test.ts b/src/main/ipc/browser/desktop-browser-bridge-candidates.test.ts new file mode 100644 index 00000000000..2310f27e4df --- /dev/null +++ b/src/main/ipc/browser/desktop-browser-bridge-candidates.test.ts @@ -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() } +}) + +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([]) + }) +}) diff --git a/src/main/ipc/browser/desktop-browser-bridge-wiring.ts b/src/main/ipc/browser/desktop-browser-bridge-wiring.ts index 9ee3ec845ab..fece12663ae 100644 --- a/src/main/ipc/browser/desktop-browser-bridge-wiring.ts +++ b/src/main/ipc/browser/desktop-browser-bridge-wiring.ts @@ -54,7 +54,9 @@ export function listDesktopBrowserBridgeCandidates( connections: Pick | 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 } diff --git a/src/main/ipc/ssh/ssh-renderer-broadcast.test.ts b/src/main/ipc/ssh/ssh-renderer-broadcast.test.ts new file mode 100644 index 00000000000..d8053ff757f --- /dev/null +++ b/src/main/ipc/ssh/ssh-renderer-broadcast.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/ssh/ssh-renderer-broadcast.ts b/src/main/ipc/ssh/ssh-renderer-broadcast.ts index f99df3573a7..e5028c01de3 100644 --- a/src/main/ipc/ssh/ssh-renderer-broadcast.ts +++ b/src/main/ipc/ssh/ssh-renderer-broadcast.ts @@ -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 { diff --git a/src/renderer/src/lib/browser-route-host-connection.test.ts b/src/renderer/src/lib/browser-route-host-connection.test.ts new file mode 100644 index 00000000000..6bb2988b0db --- /dev/null +++ b/src/renderer/src/lib/browser-route-host-connection.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts new file mode 100644 index 00000000000..57f65ec2465 --- /dev/null +++ b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts index 8a34f98a59b..f49acd49892 100644 --- a/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts +++ b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts @@ -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 { diff --git a/src/renderer/src/lib/worktree-host-connection-phase.ts b/src/renderer/src/lib/worktree-host-connection-phase.ts index 18ead563622..e87a6deee20 100644 --- a/src/renderer/src/lib/worktree-host-connection-phase.ts +++ b/src/renderer/src/lib/worktree-host-connection-phase.ts @@ -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) ) ) }