From a7921aece1151b9046598ba497f00d1efe6d58aa Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 05:03:10 -0700 Subject: [PATCH 1/2] Keep local browser routing on the deployment SSH host after conversion --- .github/workflows/e2e.yml | 7 +- config/scripts/ci-e2e-job-selection.mjs | 7 +- config/scripts/ci-e2e-job-selection.test.mjs | 8 + config/scripts/pr-e2e-source-routing.mjs | 9 + .../local-ssh-browser-route-target.test.ts | 29 +++ src/main/browser/local-ssh-browser-route.ts | 18 ++ src/main/ipc/browser.ts | 20 +- src/preload/api/browser-api.ts | 1 + ...er-bridge-page-interaction-and-sessions.ts | 1 + .../ssh-routed-browser-page-gate.test.tsx | 14 +- .../use-ssh-workspace-browser-route.ts | 36 ++- ...orkspace-browser-route-eligibility.test.ts | 51 ++++ ...ssh-workspace-browser-route-eligibility.ts | 18 +- .../worktree-host-connection-phase.test.ts | 18 +- .../src/lib/worktree-host-connection-phase.ts | 23 +- tests/e2e/ssh-orcad-browser-routing.spec.ts | 217 ++++++++++++++++++ 16 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 src/main/browser/local-ssh-browser-route-target.test.ts create mode 100644 src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts create mode 100644 tests/e2e/ssh-orcad-browser-routing.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 24c0c0859aaf..4fe4eeb7cba8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -352,7 +352,8 @@ jobs: . != "tests/e2e/windows-missing-appdata-startup.spec.ts" and . != "tests/e2e/ssh-orcad-idle-exit.spec.ts" and . != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and - . != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" + . != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" and + . != "tests/e2e/ssh-orcad-browser-routing.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -586,7 +587,7 @@ jobs: orcad-auto-convert-docker: name: ssh host auto-converts to managed orcad (Docker) needs: [build, prepare-native-cache] - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -622,7 +623,7 @@ jobs: ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay run: | export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template" - xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts --project=electron-headless --workers=1 + xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts --project=electron-headless --workers=1 - uses: actions/upload-artifact@v7 if: failure() with: diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index e799a1d5e8e0..d1e7b7f87406 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -55,6 +55,7 @@ export const ORCAD_BROWSER_CAPABILITIES_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts' export const ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-service-status.spec.ts' +export const ORCAD_BROWSER_ROUTING_E2E_SPEC = 'tests/e2e/ssh-orcad-browser-routing.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, @@ -66,7 +67,8 @@ export const DEDICATED_E2E_SPECS = [ WINDOWS_MISSING_APPDATA_E2E_SPEC, ORCAD_IDLE_EXIT_E2E_SPEC, ORCAD_BROWSER_CAPABILITIES_E2E_SPEC, - ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC + ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC, + ORCAD_BROWSER_ROUTING_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -110,7 +112,8 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { spec === ORCAD_AUTO_CONVERT_E2E_SPEC || spec === ORCAD_IDLE_EXIT_E2E_SPEC || spec === ORCAD_BROWSER_CAPABILITIES_E2E_SPEC || - spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC + spec === ORCAD_BROWSER_SERVICE_STATUS_E2E_SPEC || + spec === ORCAD_BROWSER_ROUTING_E2E_SPEC ) } } diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 701c7461fc2c..9ec59f3ebe51 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -32,6 +32,14 @@ it.each(['errors', 'status', 'lifecycle', 'restart-attempt'])( } ) +it.each([ + 'src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts', + 'src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts', + 'src/main/browser/local-ssh-browser-route.ts' +])('routes the managed browser routing oracle from %s', (path) => { + expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-routing.spec.ts') +}) + const workflow = parse(readFileSync('.github/workflows/e2e.yml', 'utf8')) const prWorkflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) const classify = (specs, ssh = 'false') => classifyE2eJobs(JSON.stringify(specs), ssh) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index ae55adce8234..32a4b9f2b407 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -64,6 +64,15 @@ export const PR_E2E_SOURCE_ROUTES = [ file ) }, + { + id: 'ssh.orcad-browser-routing', + specs: ['tests/e2e/ssh-orcad-browser-routing.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/(?:main\/browser\/local-ssh-browser|main\/ipc\/browser\.ts$|renderer\/src\/(?:lib\/(?:ssh-workspace-browser-route-eligibility|worktree-host-connection-phase)|components\/browser-pane\/use-ssh-workspace-browser-route))/.test( + file + ) + }, { id: 'ssh.orcad-idle-exit', specs: ['tests/e2e/ssh-orcad-idle-exit.spec.ts'], diff --git a/src/main/browser/local-ssh-browser-route-target.test.ts b/src/main/browser/local-ssh-browser-route-target.test.ts new file mode 100644 index 000000000000..3cd63edd9a63 --- /dev/null +++ b/src/main/browser/local-ssh-browser-route-target.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { requireLocalSshBrowserRouteTarget } from './local-ssh-browser-route' + +const targets = [{ id: 'target-a', generation: 4 }] + +describe('local SSH browser target registration', () => { + it('accepts the current managed deployment registration', () => { + expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a', 4)).not.toThrow() + }) + + it('preserves direct SSH callers that do not supply a deployment fence', () => { + expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a')).not.toThrow() + }) + + it.each([3, 5, '4', null])( + 'refuses a stale or invalid deployment registration: %j', + (generation) => { + expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-a', generation)).toThrow( + 'browser_local_route_target_stale' + ) + } + ) + + it('refuses a removed target instead of borrowing another route', () => { + expect(() => requireLocalSshBrowserRouteTarget(targets, 'target-b', 4)).toThrow( + 'browser_local_route_target_invalid' + ) + }) +}) diff --git a/src/main/browser/local-ssh-browser-route.ts b/src/main/browser/local-ssh-browser-route.ts index 5171edf17288..1b8177e9bd2a 100644 --- a/src/main/browser/local-ssh-browser-route.ts +++ b/src/main/browser/local-ssh-browser-route.ts @@ -1,9 +1,27 @@ import type { Duplex } from 'node:stream' import type { BrowserNetworkExecutionRouteResolver } from './browser-network-execution-route' import type { BrowserNetworkTunnelOpen } from '../../shared/browser-network-tunnel-protocol' +import type { SshTargetSummary } from '../../shared/ssh-types' import { openExecutionRouteSocketAsDuplex } from './execution-route-socket-duplex' import { RemoteBrowserSocksServer } from './remote-browser-socks-server' +export function requireLocalSshBrowserRouteTarget( + targets: readonly Pick[] | undefined, + targetId: string, + expectedGeneration?: unknown +): void { + const target = targets?.find((entry) => entry.id === targetId) + if (!target) { + throw new Error('browser_local_route_target_invalid') + } + if ( + expectedGeneration !== undefined && + (typeof expectedGeneration !== 'number' || target.generation !== expectedGeneration) + ) { + throw new Error('browser_local_route_target_stale') + } +} + export type LocalSshBrowserRouteDependencies = { resolveExecutionRoute: BrowserNetworkExecutionRouteResolver getAuthority: (targetId: string) => { providerEpoch: string; connectionGeneration: number } diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 76e502d969e7..5c8d71bb8b84 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -103,7 +103,12 @@ export function registerBrowserHandlers(): void { 'browser:prepareSshWorkspacePartition', async ( event, - args: { targetId?: unknown; browserProfileId?: unknown; skipProbe?: unknown } + args: { + targetId?: unknown + browserProfileId?: unknown + skipProbe?: unknown + expectedSshTargetGeneration?: unknown + } ) => { // Why (review P1-2): preparing mints bindings whose LRU eviction destroys // cookie jars; only the trusted renderer naming a REGISTERED target may. @@ -114,12 +119,13 @@ export function registerBrowserHandlers(): void { throw new Error('browser_local_route_target_invalid') } const { getSshConnectionStore } = await import('./ssh') - const registered = getSshConnectionStore() - ?.listTargets() - .some((target) => target.id === args.targetId) - if (!registered) { - throw new Error('browser_local_route_target_invalid') - } + const { requireLocalSshBrowserRouteTarget } = + await import('../browser/local-ssh-browser-route') + requireLocalSshBrowserRouteTarget( + getSshConnectionStore()?.listTargets(), + args.targetId, + args.expectedSshTargetGeneration + ) const { prepareLocalSshBrowserPartition } = await import('../browser/local-ssh-browser-partitions') return prepareLocalSshBrowserPartition({ diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index bb29fe115233..0d38e1ecee7f 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -145,6 +145,7 @@ export type BrowserApi = { /** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */ prepareSshWorkspacePartition: (args: { targetId: string + expectedSshTargetGeneration?: number browserProfileId?: string skipProbe?: boolean }) => Promise<{ partition: string }> diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 822c0f54025a..e67567017409 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -130,6 +130,7 @@ export const browserPageInteractionAndSessionsApi = { sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'), prepareSshWorkspacePartition: (args: { targetId: string + expectedSshTargetGeneration?: number browserProfileId?: string skipProbe?: boolean }): Promise<{ partition: string }> => diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx index 67e56e4b3121..f3ba3f61d7ac 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/ssh-routed-browser-page-gate.test.tsx @@ -20,6 +20,7 @@ vi.mock('../host-guest/webview-registry', () => ({ import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate' const PAGE_IDS = ['page-1', 'page-2'] as const +const initialHostState = useAppStore.getState() const settle = () => act(async () => { @@ -28,6 +29,14 @@ const settle = () => describe('SshRoutedBrowserPageGate', () => { beforeEach(() => { + useAppStore.setState({ + sshConnectionStates: new Map([ + [ + 'target-a', + { targetId: 'target-a', status: 'connected', error: null, reconnectAttempt: 0 } + ] + ]) + }) mocks.prepare.mockReset().mockResolvedValue({ partition: 'persist:orca-browser-v1-routed' }) mocks.destroyPersistentWebview.mockReset() Object.defineProperty(window, 'api', { @@ -35,7 +44,10 @@ describe('SshRoutedBrowserPageGate', () => { value: { browser: { prepareSshWorkspacePartition: mocks.prepare } } }) }) - afterEach(() => cleanup()) + afterEach(() => { + cleanup() + useAppStore.setState({ sshConnectionStates: initialHostState.sshConnectionStates }) + }) it('renders children with no override for non-SSH workspaces without any prepare call', async () => { mocks.executionHostId = 'local' diff --git a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts index 6af3275c14c5..4d1d6feaa28b 100644 --- a/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts +++ b/src/renderer/src/components/browser-pane/use-ssh-workspace-browser-route.ts @@ -9,7 +9,7 @@ export type SshWorkspaceBrowserRouteErrorKind = 'forwarding-blocked' | 'ssh-unav export type SshWorkspaceBrowserRouteState = | { kind: 'unrouted' } | { kind: 'preparing' } - | { kind: 'ready'; partition: string; targetId: string } + | { kind: 'ready'; partition: string; targetId: string; expectedSshTargetGeneration?: number } | { kind: 'error'; errorKind: SshWorkspaceBrowserRouteErrorKind; message: string } export function classifySshWorkspaceBrowserRouteError( @@ -47,16 +47,19 @@ export function useSshWorkspaceBrowserRoute( } { const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId)) const browserRoutingSettings = useAppStore((s) => s.settings) + const environments = useAppStore((s) => s.runtimeEnvironments) const probeSkippedTargetIds = useAppStore( (s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds ) const updateSettings = useAppStore((s) => s.updateSettings) const routeEligibility = resolveSshWorkspaceBrowserRouteEligibility( executionHostId, - browserRoutingSettings + browserRoutingSettings, + environments ) const sshTargetId = routeEligibility?.targetId ?? null const targetId = routeEligibility?.eligible === true ? routeEligibility.targetId : null + const expectedSshTargetGeneration = routeEligibility?.expectedSshTargetGeneration const browserProfileId = sessionProfileId ?? 'default' const [attempt, setAttempt] = useState<{ count: number; skipProbe: boolean }>({ count: 0, @@ -65,13 +68,16 @@ export function useSshWorkspaceBrowserRoute( const [state, setState] = useState( targetId ? { kind: 'preparing' } : { kind: 'unrouted' } ) - const hostConnection = useWorktreeHostConnection(worktreeId) + const hostConnection = useWorktreeHostConnection(worktreeId, targetId) const routeHost = targetId !== null && hostConnection.targetId === targetId ? hostConnection : null // Why only an unsettled route waits: a dial is a transient, so it must not unmount a ready // page or swap a failed route's card for "preparing". A connect (below) re-derives a failed one. // Unrouted and another target's page are unsettled too: neither answers for this target. - const routeReady = state.kind === 'ready' && state.targetId === targetId + const routeReady = + state.kind === 'ready' && + state.targetId === targetId && + state.expectedSshTargetGeneration === expectedSshTargetGeneration const awaitingHost = routeHost?.phase === 'connecting' && state.kind !== 'error' && !routeReady const connectedHostEpoch = routeHost?.connectedEpoch ?? null const [seenConnectedHostEpoch, setSeenConnectedHostEpoch] = useState(connectedHostEpoch) @@ -104,11 +110,17 @@ export function useSshWorkspaceBrowserRoute( .prepareSshWorkspacePartition({ targetId, browserProfileId, + ...(expectedSshTargetGeneration !== undefined ? { expectedSshTargetGeneration } : {}), ...(skipProbe ? { skipProbe: true } : {}) }) .then((result) => { if (!cancelled) { - setState({ kind: 'ready', partition: result.partition, targetId }) + setState({ + kind: 'ready', + partition: result.partition, + targetId, + ...(expectedSshTargetGeneration !== undefined ? { expectedSshTargetGeneration } : {}) + }) } }) .catch((error: unknown) => { @@ -124,7 +136,7 @@ export function useSshWorkspaceBrowserRoute( return () => { cancelled = true } - }, [targetId, browserProfileId, attempt, skipProbe, awaitingHost]) + }, [targetId, expectedSshTargetGeneration, browserProfileId, attempt, skipProbe, awaitingHost]) // Why (review P1-1): `state` lags one commit behind a targetId transition on // an already-mounted instance; returning stale 'unrouted' (or a stale @@ -133,9 +145,7 @@ export function useSshWorkspaceBrowserRoute( // routed/unrouted decision must be derived from targetId in-render. const effectiveState: SshWorkspaceBrowserRouteState = !targetId ? { kind: 'unrouted' } - : awaitingHost || - state.kind === 'unrouted' || - (state.kind === 'ready' && state.targetId !== targetId) + : awaitingHost || state.kind === 'unrouted' || (state.kind === 'ready' && !routeReady) ? { kind: 'preparing' } : state const rederive = (nextSkipProbe: boolean): void => { @@ -183,13 +193,17 @@ export function useSshWorkspaceBrowserRoute( export function useSshWorkspaceProbeSkipRecheck(worktreeId: string): (() => void) | null { const executionHostId = useAppStore((s) => getExecutionHostIdForWorktree(s, worktreeId)) const browserRoutingSettings = useAppStore((s) => s.settings) + const environments = useAppStore((s) => s.runtimeEnvironments) const probeSkippedTargetIds = useAppStore( (s) => s.settings?.browserSshWorkspaceRoutingProbeSkippedTargetIds ) const updateSettings = useAppStore((s) => s.updateSettings) const targetId = - resolveSshWorkspaceBrowserRouteEligibility(executionHostId, browserRoutingSettings)?.targetId ?? - null + resolveSshWorkspaceBrowserRouteEligibility( + executionHostId, + browserRoutingSettings, + environments + )?.targetId ?? null if (!targetId || probeSkippedTargetIds?.includes(targetId) !== true) { return null } 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 000000000000..fbf1d7c09bfe --- /dev/null +++ b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { resolveSshWorkspaceBrowserRouteEligibility } from './ssh-workspace-browser-route-eligibility' + +const deployment = { + sshTargetId: 'target-a', + sshTargetGeneration: 1, + localPort: 41000, + remotePort: 6768 +} +const managed = [{ id: 'env-a', orcadDeployment: deployment }] + +describe('SSH workspace browser route eligibility', () => { + it('keeps the same target when an SSH workspace becomes managed', () => { + const before = resolveSshWorkspaceBrowserRouteEligibility('ssh:target-a', {}) + expect(resolveSshWorkspaceBrowserRouteEligibility('runtime:env-a', {}, managed)).toEqual({ + ...before, + expectedSshTargetGeneration: 1 + }) + expect(before).toEqual({ targetId: 'target-a', eligible: true }) + }) + + it.each([ + { browserSshWorkspaceRoutingEnabled: false }, + { browserSshWorkspaceRoutingDisabledTargetIds: ['target-a'] } + ])('honors the existing routing opt-out after conversion: %j', (settings) => { + expect(resolveSshWorkspaceBrowserRouteEligibility('runtime:env-a', settings, managed)).toEqual({ + targetId: 'target-a', + eligible: false, + expectedSshTargetGeneration: 1 + }) + }) + + it('does not mistake an SSH access tunnel for the execution host', () => { + const accessOnly = [{ id: 'env-a', sshAccess: deployment }] + expect(resolveSshWorkspaceBrowserRouteEligibility('runtime:env-a', {}, accessOnly)).toBeNull() + }) + + it.each(['local', 'wsl:Ubuntu', 'runtime:other', 'ssh:runtime-ssh-owned'])( + 'does not borrow a managed route for %s', + (host) => { + expect(resolveSshWorkspaceBrowserRouteEligibility(host, {}, managed)).toBeNull() + } + ) + + it('keeps a paired runtime-owned deployment on its owning runtime', () => { + const nested = [ + { id: 'env-a', orcadDeployment: { ...deployment, sshTargetId: 'runtime-ssh-a' } } + ] + expect(resolveSshWorkspaceBrowserRouteEligibility('runtime:env-a', {}, nested)).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 2ecd6007e092..d48c621e22c7 100644 --- a/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts +++ b/src/renderer/src/lib/ssh-workspace-browser-route-eligibility.ts @@ -1,5 +1,6 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../shared/execution-host' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' type SshBrowserRoutingSettings = Pick< GlobalSettings, @@ -9,21 +10,30 @@ type SshBrowserRoutingSettings = Pick< export type SshWorkspaceBrowserRouteEligibility = { targetId: string eligible: boolean + expectedSshTargetGeneration?: number } export function resolveSshWorkspaceBrowserRouteEligibility( executionHostId: string | null | undefined, - settings: SshBrowserRoutingSettings | null | undefined + settings: SshBrowserRoutingSettings | null | undefined, + environments?: readonly Pick[] ): SshWorkspaceBrowserRouteEligibility | null { const parsed = parseExecutionHostId(executionHostId) + const deployment = + parsed?.kind === 'runtime' + ? environments?.find((environment) => environment.id === parsed.environmentId) + ?.orcadDeployment + : undefined + const targetId = parsed?.kind === 'ssh' ? parsed.targetId : deployment?.sshTargetId // Why: runtime-owned ephemeral SSH targets belong to the paired runtime's browser route. - if (parsed?.kind !== 'ssh' || isRuntimeOwnedSshTargetId(parsed.targetId)) { + if (!targetId || isRuntimeOwnedSshTargetId(targetId)) { return null } return { - targetId: parsed.targetId, + targetId, + ...(deployment ? { expectedSshTargetGeneration: deployment.sshTargetGeneration } : {}), eligible: settings?.browserSshWorkspaceRoutingEnabled !== false && - !settings?.browserSshWorkspaceRoutingDisabledTargetIds?.includes(parsed.targetId) + !settings?.browserSshWorkspaceRoutingDisabledTargetIds?.includes(targetId) } } diff --git a/src/renderer/src/lib/worktree-host-connection-phase.test.ts b/src/renderer/src/lib/worktree-host-connection-phase.test.ts index ab7b3cdeeb7d..0a6478dd5262 100644 --- a/src/renderer/src/lib/worktree-host-connection-phase.test.ts +++ b/src/renderer/src/lib/worktree-host-connection-phase.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import type { AppState } from '@/store/types' import type { SshConnectionStatus } from '../../../shared/ssh-types' -import { selectWorktreeHostConnectionPhase } from './worktree-host-connection-phase' +import { + resolveWorktreeHostConnection, + selectWorktreeHostConnectionPhase +} from './worktree-host-connection-phase' function makeState(overrides: Record): AppState { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the phase selector reads only the repo, worktree, runtime, and SSH fields set here. @@ -51,6 +54,19 @@ function makeSshState( } describe('selectWorktreeHostConnectionPhase', () => { + it('reads the local deployment tunnel for a managed browser route', () => { + const state = makeSshState('connected', { + repos: [{ id: 'repo-ssh', executionHostId: 'runtime:env-a' }] + }) + expect(resolveWorktreeHostConnection(state, 'wt-ssh', 'ssh-a').phase).toBe('unverifiable') + expect(resolveWorktreeHostConnection(state, 'wt-ssh', 'ssh-a', null)).toEqual({ + phase: 'connected', + targetId: 'ssh-a', + environmentId: null, + publishedStatus: 'connected', + connectedEpoch: 'ssh-a:7' + }) + }) it('reports a local workspace, and no worktree, as local', () => { const state = makeState({ repos: [{ id: 'repo-local' }], diff --git a/src/renderer/src/lib/worktree-host-connection-phase.ts b/src/renderer/src/lib/worktree-host-connection-phase.ts index 8a0d39d4b1cf..59b28114e91a 100644 --- a/src/renderer/src/lib/worktree-host-connection-phase.ts +++ b/src/renderer/src/lib/worktree-host-connection-phase.ts @@ -78,13 +78,17 @@ function derivePhase( /** The shared signal for a caller that already resolved the worktree's connection id. */ export function resolveWorktreeHostConnection( state: AppState, - worktreeId: string, - targetId: string | null | undefined + worktreeId: string | null, + targetId: string | null | undefined, + environmentIdOverride?: string | null ): WorktreeHostConnection { if (!targetId || isRuntimeOwnedSshTargetId(targetId)) { return LOCAL_HOST_CONNECTION } - const environmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + const environmentId = + environmentIdOverride === undefined + ? getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + : environmentIdOverride const publishedStatus = selectRuntimeAwareSshStatus(state, environmentId, targetId) const generation = selectRuntimeAwareSshConnectionGeneration(state, environmentId, targetId) return { @@ -114,6 +118,15 @@ export function selectWorktreeHostConnectionPhase( ) } -export function useWorktreeHostConnection(worktreeId: string | null): WorktreeHostConnection { - return useAppStore(useShallow((state) => selectWorktreeHostConnectionPhase(state, worktreeId))) +export function useWorktreeHostConnection( + worktreeId: string | null, + localSshTargetId?: string | null +): WorktreeHostConnection { + return useAppStore( + useShallow((state) => + localSshTargetId === undefined + ? selectWorktreeHostConnectionPhase(state, worktreeId) + : resolveWorktreeHostConnection(state, worktreeId, localSshTargetId, null) + ) + ) } diff --git a/tests/e2e/ssh-orcad-browser-routing.spec.ts b/tests/e2e/ssh-orcad-browser-routing.spec.ts new file mode 100644 index 000000000000..3d53191b02a3 --- /dev/null +++ b/tests/e2e/ssh-orcad-browser-routing.spec.ts @@ -0,0 +1,217 @@ +import { cpSync, rmSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { waitForSessionReady } from './helpers/store' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + startSshRemoteOnlyBrowserFixture, + readSshRemoteOnlyRequests, + SSH_REMOTE_ONLY_ORIGIN, + SSH_REMOTE_ONLY_COOKIE_NAME, + SSH_REMOTE_ONLY_COOKIE_VALUE +} from './helpers/ssh-remote-only-browser-fixture' +import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle' +import { managedServer, reconnect } from './helpers/orcad-convert-flow' +import { navigateGuest } from './helpers/browser-split-guest-probes' + +const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +test.skip(!TEMPLATE || process.env.ORCA_E2E_SSH_DOCKER !== '1', 'Needs Docker and server template') + +async function guestMarker(page: Page, tabId: string): Promise { + return page.evaluate(async (id) => { + const guest = document.querySelector( + `[data-browser-overlay-tab-id="${id}"] webview` + ) + try { + return await guest?.executeJavaScript('document.querySelector("#marker")?.textContent') + } catch { + return null + } + }, tabId) +} + +test('new browser tabs keep SSH routing and login cookies after managed conversion', async (// oxlint-disable-next-line no-empty-pattern -- Owns app launch. +{}, testInfo) => { + test.setTimeout(5 * 60_000) + const target = startDockerSshRelayTarget(testInfo) + const scratch = createRetentionFixtureDirectory() + const template = path.join(scratch, 'template') + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: template }) + let app: ElectronApplication | null = null + try { + startSshRemoteOnlyBrowserFixture(target) + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + const remote = await connectDockerSshRelayTarget(page, target, { seedInitialTab: false }) + expect(await managedServer(page, remote.targetId)).toMatchObject({ kind: 'relay' }) + const tabId = await page.evaluate( + ({ worktreeId, url }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Missing store') + } + const tab = state.createBrowserTab(worktreeId, url, { + title: 'Retained browser', + activate: true + }) + for (const terminal of state.tabsByWorktree[worktreeId] ?? []) { + state.closeTab(terminal.id) + } + return tab.id + }, + { worktreeId: remote.worktreeId, url: `${SSH_REMOTE_ONLY_ORIGIN}/login` } + ) + await expect.poll(() => guestMarker(page, tabId), { timeout: 60_000 }).toBe('login-marker') + await navigateGuest(page, tabId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/before`) + const cookieMarker = `cookie:${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}` + await expect.poll(() => guestMarker(page, tabId), { timeout: 30_000 }).toBe(cookieMarker) + await page.screenshot({ path: testInfo.outputPath('browser-before-conversion.png') }) + const partitionBefore = await page + .locator(`[data-browser-overlay-tab-id="${tabId}"] webview`) + .getAttribute('partition') + expect(partitionBefore).toMatch(/^persist:/) + await expect + .poll( + () => + page.evaluate((id) => window.api.pty.listSessions({ connectionId: id }), remote.targetId), + { timeout: 30_000 } + ) + .toEqual([]) + cpSync(TEMPLATE!, template, { recursive: true }) + const server = await reconnect(page, remote.targetId) + console.log('[browser-conversion-connect]', server) + expect(JSON.parse(server)).toMatchObject({ kind: 'managed' }) + const environments = await page.evaluate(() => window.api.runtimeEnvironments.list()) + const environment = environments.find( + (entry) => entry.orcadDeployment?.sshTargetId === remote.targetId + ) + if (!environment) { + throw new Error('Missing managed environment') + } + await expect + .poll( + () => + page.evaluate((worktreeId) => { + const state = window.__store?.getState() + return ( + state?.repos.find((repo) => + (state.worktreesByRepo[repo.id] ?? []).some( + (worktree) => worktree.id === worktreeId + ) + )?.executionHostId ?? null + ) + }, remote.worktreeId), + { timeout: 60_000 } + ) + .toMatch(/^runtime:/) + const retained = await page.evaluate((worktreeId) => { + const state = window.__store?.getState() + const tab = state?.browserTabsByWorktree[worktreeId]?.[0] + const repo = state?.repos.find((repo) => + (state.worktreesByRepo[repo.id] ?? []).some((worktree) => worktree.id === worktreeId) + ) + if (repo?.executionHostId) { + state?.setActiveWorktree(worktreeId, repo.executionHostId) + } + if (tab?.activePageId) { + state?.focusBrowserTabInWorktree(worktreeId, tab.activePageId, { surfacePane: true }) + } + return tab?.id ?? null + }, remote.worktreeId) + expect(retained).toBe(tabId) + await page.evaluate( + async ({ worktreeId, url }) => { + const state = window.__store?.getState() + const groupId = state?.activeGroupIdByWorktree[worktreeId] + if (!state || !groupId) { + throw new Error('No active browser group') + } + state.setBrowserDefaultUrl(url) + await state.openNewBrowserTabInActiveWorkspace(groupId) + }, + { worktreeId: remote.worktreeId, url: `${SSH_REMOTE_ONLY_ORIGIN}/echo/control` } + ) + await expect + .poll( + () => readSshRemoteOnlyRequests(target).some((request) => request.path === '/echo/control'), + { timeout: 60_000 } + ) + .toBe(true) + await page.screenshot({ path: testInfo.outputPath('browser-new-tab-control.png') }) + const createdId = await page.evaluate( + ({ worktreeId, retainedId }) => { + const created = window.__store + ?.getState() + .browserTabsByWorktree[worktreeId]?.find((tab) => tab.id !== retainedId) + if (!created) { + throw new Error('Missing new browser tab') + } + return created.id + }, + { worktreeId: remote.worktreeId, retainedId: tabId } + ) + await expect.poll(() => guestMarker(page, createdId), { timeout: 30_000 }).toBe(cookieMarker) + const partitionAfter = await page + .locator(`[data-browser-overlay-tab-id="${createdId}"] webview`) + .getAttribute('partition') + expect(partitionAfter).toBe(partitionBefore) + expect(readSshRemoteOnlyRequests(target)).toContainEqual({ + path: '/echo/control', + cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}` + }) + const generation = environment.orcadDeployment?.sshTargetGeneration + if (generation === undefined) { + throw new Error('Missing deployment registration') + } + const stale = await page.evaluate( + async (args) => { + try { + await window.api.browser.prepareSshWorkspacePartition(args) + return 'accepted' + } catch (error) { + return String(error) + } + }, + { targetId: remote.targetId, expectedSshTargetGeneration: generation + 1 } + ) + expect(stale).toContain('browser_local_route_target_stale') + await reconnect(page, remote.targetId) + await navigateGuest(page, createdId, `${SSH_REMOTE_ONLY_ORIGIN}/echo/reconnect`) + await expect.poll(() => guestMarker(page, createdId), { timeout: 30_000 }).toBe(cookieMarker) + expect(readSshRemoteOnlyRequests(target)).toContainEqual({ + path: '/echo/reconnect', + cookie: `${SSH_REMOTE_ONLY_COOKIE_NAME}=${SSH_REMOTE_ONLY_COOKIE_VALUE}` + }) + console.log( + '[managed-browser-route]', + JSON.stringify({ + partitionBefore, + partitionAfter, + requests: readSshRemoteOnlyRequests(target) + }) + ) + await page.keyboard.press('Escape') + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + }) + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('browser-new-tab-after.png') }) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + cleanupDockerSshRelayTarget(target) + rmSync(scratch, { recursive: true, force: true }) + } +}) From ae8c3b540f9fb61974bea70f7855bdd40bc24408 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 05:24:38 -0700 Subject: [PATCH 2/2] Seed published SSH connection state in browser lifecycle fixture --- .../browser-deferred-lifecycle.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx index 7bb75c50c1e7..102aaf342e24 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-deferred-lifecycle.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand' import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import type { SshConnectionState } from '../../../../../shared/ssh-types' type MockState = { browserTabsByWorktree: Record @@ -12,6 +13,7 @@ type MockState = { groupsByWorktree: Record activeGroupIdByWorktree: Record remoteBrowserPageHandlesByPageId: Record + sshConnectionStates: Map focusGroup: () => void updateBrowserPageState: () => void setBrowserPageUrl: () => void @@ -104,6 +106,18 @@ function createState(): MockState { }, activeGroupIdByWorktree: { 'wt-1': 'group-1' }, remoteBrowserPageHandlesByPageId: {}, + sshConnectionStates: new Map([ + [ + 'target-a', + { + targetId: 'target-a', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + } + ] + ]), focusGroup: () => {}, updateBrowserPageState: () => {}, setBrowserPageUrl: () => {},