diff --git a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts index 4ef2a3013aa..c70bd4b08f6 100644 --- a/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts +++ b/src/main/managed-server/lifecycle/orcad-managed-tunnel-identity-restart.test.ts @@ -14,6 +14,13 @@ import { OrcaRuntimeService } from '../../runtime/orca-runtime' import { OrcaRuntimeRpcServer } from '../../runtime/runtime-rpc' import { verifyRuntimePairingIdentity } from '../../runtime/runtime-environment-identity-verification' import { verifyManagedOrcadTunnelIdentity } from './orcad-managed-tunnel-identity' +import { getPreferredPairingOffer } from '../../../shared/runtime-environments' +import { + addWslHostEnvironment, + refreshWslHostPairing +} from '../../../shared/runtime-environment-wsl-host-store' +import { buildServePairingReadiness } from '../../server/serve-pairing-readiness' +import { WslOrcadConnectionManager } from '../../wsl/wsl-orcad-connection' vi.mock('../../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([]), @@ -84,6 +91,61 @@ function reachedThrough(pairing: PairingOffer, server: OrcaRuntimeRpcServer): Pa } describe('reopening a managed tunnel after orcad restarted elsewhere', () => { + it('keeps the WSL caller across host and desktop restarts, until explicit re-pairing', async () => { + const hostProfile = mkdtempSync(join(tmpdir(), 'orcad-wsl-host-')) + const desktopProfile = mkdtempSync(join(tmpdir(), 'orcad-wsl-client-')) + let server = await startOrcad(hostProfile) + const connect = async () => { + const manager = new WslOrcadConnectionManager({ + getPairingOffer: () => { + const [saved] = listEnvironments(desktopProfile) + return saved ? getPreferredPairingOffer(saved) : null + }, + deployAndLaunch: async () => ({ + outcome: 'ready', distro: 'Ubuntu', distroId: null, fullVersion: '1', pid: null, + holder: 'adopted', port: Number(new URL(server.getWebSocketEndpoint()!).port), + readiness: { runtimeId: server['runtime'].getRuntimeId(), + managedWslCliReconciliation: 'settled', + ...await buildServePairingReadiness({ pairingAddress: '127.0.0.1' }, server) } + }), + listen: async (_transport, { remotePort }) => ({ + entry: { id: 'bridge', connectionId: 'wsl:ubuntu', localPort: remotePort, + remoteHost: '127.0.0.1', remotePort }, + close: async () => {}, dispose: () => {} + }) + }) + const dispatch = vi.spyOn(server['dispatcher'], 'dispatchStreaming') + try { + const connection = await manager.ensure('Ubuntu') + return { connection, caller: dispatch.mock.calls.at(-1)?.[2]?.authenticatedCallerFingerprint } + } finally { + dispatch.mockRestore() + await manager.dispose() + } + } + const first = await connect() + expect(first.caller).toBeTruthy() + addWslHostEnvironment(desktopProfile, { + id: 'wsl:ubuntu', name: 'Ubuntu', pairingCode: first.connection.pairingOffer, + record: { kind: 'wsl', distro: 'Ubuntu', + localPort: first.connection.localPort, remotePort: first.connection.remotePort } + }) + await server.stop() + server = await startOrcad(hostProfile) + const restarted = await connect() + expect(restarted.caller).toBe(first.caller) + const paired = parsePairingCode(first.connection.pairingOffer)! + server.getDeviceRegistry()!.removeDevice(paired.pairedDeviceId!) + await expect(connect()).rejects.toThrow('orcad_identity_mismatch') + refreshWslHostPairing(desktopProfile, 'wsl:ubuntu', { + pairingCode: encodePairingOffer(pairingOffer(server)), + localPort: restarted.connection.localPort, remotePort: restarted.connection.remotePort + }) + const rePaired = await connect() + expect(rePaired.caller).toBeTruthy() + expect(rePaired.caller).not.toBe(first.caller) + }) + it('accepts the same host under a new runtime id, and its status recovers', async () => { const hostProfile = mkdtempSync(join(tmpdir(), 'orcad-host-')) const first = await startOrcad(hostProfile) diff --git a/src/main/ssh/orcad-tunneled-pairing.ts b/src/main/ssh/orcad-tunneled-pairing.ts index 53432181884..a536011f690 100644 --- a/src/main/ssh/orcad-tunneled-pairing.ts +++ b/src/main/ssh/orcad-tunneled-pairing.ts @@ -1,14 +1,20 @@ -import { encodePairingOffer, parsePairingCode } from '../../shared/pairing' +import { encodePairingOffer, parsePairingCode, type PairingOffer } from '../../shared/pairing' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' import type { ServeReadiness } from '../server/serve-readiness' -export function tunneledOrcadPairingCode(readiness: ServeReadiness, localPort: number): string { - if (!readiness.pairing.available) { +export function tunneledOrcadPairingCode( + readiness: ServeReadiness, + localPort: number, + pairedOffer?: PairingOffer | null +): string { + if (!pairedOffer && !readiness.pairing.available) { throw new Error( `The managed Orca server did not publish a pairing offer: ${readiness.pairing.guidance}` ) } - const offer = parsePairingCode(readiness.pairing.url) + // An enrolled client keeps its grant; startup offers are for new clients. + const offer = + pairedOffer ?? (readiness.pairing.available ? parsePairingCode(readiness.pairing.url) : null) if (!offer) { throw new Error('The managed Orca server published an invalid pairing offer.') } diff --git a/src/main/wsl/wsl-host-connection-record.ts b/src/main/wsl/wsl-host-connection-record.ts index 6c8d65bffca..9be1d5bf7be 100644 --- a/src/main/wsl/wsl-host-connection-record.ts +++ b/src/main/wsl/wsl-host-connection-record.ts @@ -5,7 +5,7 @@ import type { WslHostStatus } from '../../shared/wsl-host-types' import type { WslOrcadConnection } from './wsl-orcad-connection' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' -/** Saves a relaunch's new device token or a moved bridge; true when the pairing changed. */ +/** Saves an explicit re-pair or a moved bridge; true when the pairing changed. */ export function persistWslHostConnection( userDataPath: string, record: WslHostDeploymentRecord, diff --git a/src/main/wsl/wsl-host-connector.ts b/src/main/wsl/wsl-host-connector.ts index bd869b33c89..556dd82cfbc 100644 --- a/src/main/wsl/wsl-host-connector.ts +++ b/src/main/wsl/wsl-host-connector.ts @@ -1,5 +1,7 @@ /** The production WSL side of `WslHostService`: one holder, connection manager and supervisor. */ import { isWslDistroRunning } from '../wsl-background-distro-gate' +import { resolveEnvironmentPairingOffer } from '../../shared/runtime-environment-store' +import { findWslHostDeployment } from '../../shared/runtime-environment-wsl-host-store' import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' import { listWslHostDistros } from './wsl-host-distro-list' import type { WslHostConnector } from './wsl-host-service' @@ -54,6 +56,10 @@ export function createWslHostConnector(options: { } const manager = new WslOrcadConnectionManager({ holder, + getPairingOffer: (environmentId) => + findWslHostDeployment(options.userDataPath, environmentId) + ? resolveEnvironmentPairingOffer(options.userDataPath, environmentId) + : null, onChange: options.onChange, deployAndLaunch: (distro, deployOptions) => options.isKnownDistro(distro) diff --git a/src/main/wsl/wsl-orcad-connection.test.ts b/src/main/wsl/wsl-orcad-connection.test.ts index 2ce335e41fa..f4f9a1ea6b5 100644 --- a/src/main/wsl/wsl-orcad-connection.test.ts +++ b/src/main/wsl/wsl-orcad-connection.test.ts @@ -180,14 +180,14 @@ describe('WslOrcadConnectionManager', () => { expect(onChange).toHaveBeenCalledTimes(2) }) - it('reports a changed pairing offer without rebuilding the bridge', async () => { + it('keeps its client grant when a relaunch advertises a new pairing offer', async () => { const { manager, launches, onChange } = setup() const connection = await manager.ensure('Ubuntu') launches.push(ready(first.port, 'rotated-token')) const refreshed = await manager.ensure('Ubuntu') expect(refreshed.localPort).toBe(connection.localPort) - expect(parsePairingCode(refreshed.pairingOffer)?.deviceToken).toBe('rotated-token') - expect(onChange).toHaveBeenLastCalledWith('Ubuntu', refreshed, { state: 'ready', census: null }) + expect(refreshed.pairingOffer).toBe(connection.pairingOffer) + expect(onChange).toHaveBeenCalledTimes(2) }) it('moves the bridge to the new port after a relaunch, on the same local port', async () => { diff --git a/src/main/wsl/wsl-orcad-connection.ts b/src/main/wsl/wsl-orcad-connection.ts index 7c6a895ac5a..6162edb1a1e 100644 --- a/src/main/wsl/wsl-orcad-connection.ts +++ b/src/main/wsl/wsl-orcad-connection.ts @@ -5,13 +5,10 @@ * deployment record (C7) persists what `onChange` reports. */ import { errorMessage } from '../../shared/error-message' +import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import type { ServeReadiness } from '../server/serve-readiness' import type { OrcadHostTransport } from '../ssh/orcad-host-transport' -import { - OrcadManagedIdentityError, - type OrcadTunnelIdentity -} from '../managed-server/lifecycle/orcad-managed-tunnel-identity' -import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' +import type { OrcadTunnelIdentity } from '../managed-server/lifecycle/orcad-managed-tunnel-identity' import type { StartedPortForward } from '../ssh/port-forwarding/ssh-port-forward-provider' import type { WslDistroHolder } from './wsl-distro-holder' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' @@ -25,7 +22,7 @@ import { } from './wsl-orcad-deploy' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' -import { verifyWslOrcadIdentity } from './wsl-orcad-pairing-identity' +import { resolveWslOrcadPairing } from './wsl-orcad-pairing-identity' import { trackWslOrcadHostEnvironment } from './wsl-orcad-host-environments' import { listenWslOrcadStdioBridge, @@ -84,6 +81,7 @@ const READY: WslOrcadConnectionStatus = { state: 'ready', census: null } export type WslOrcadConnectionDependencies = { holder?: WslDistroHolder + getPairingOffer?: (environmentId: string) => PairingOffer | null deployAndLaunch?: (distro: string, options: WslOrcadDeployOptions) => Promise createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport listen?: ( @@ -225,12 +223,23 @@ export class WslOrcadConnectionManager { throw new WslOrcadConnectionSupersededError(distro) } const current = this.active.get(key) + const paired = + this.dependencies.getPairingOffer?.(environmentId) ?? + (current?.connection.environmentId === environmentId + ? parsePairingCode(current.connection.pairingOffer) + : null) if ( current && current.connection.remotePort === launch.port && current.connection.environmentId === environmentId ) { - return this.refreshOffer(key, current, launch, options.status ? status.ready : null) + const pairingOffer = await resolveWslOrcadPairing( + launch, current.connection.localPort, paired, this.dependencies.verifyIdentity + ) + if (this.generation(key) !== generation) { + throw new WslOrcadConnectionSupersededError(distro) + } + return this.refreshOffer(key, current, launch, pairingOffer, options.status ? status.ready : null) } // orcad moved ports (or this is the first connect): the old bridge dials a dead port. const preferredLocalPort = current?.connection.localPort ?? options.localPort ?? 0 @@ -250,18 +259,9 @@ export class WslOrcadConnectionManager { if (this.generation(key) !== generation) { throw new WslOrcadConnectionSupersededError(distro) } - const pairingOffer = tunneledOrcadPairingCode(launch.readiness, forward.entry.localPort) - const identity = await (this.dependencies.verifyIdentity ?? verifyWslOrcadIdentity)( - pairingOffer, - launch.readiness + const pairingOffer = await resolveWslOrcadPairing( + launch, forward.entry.localPort, paired, this.dependencies.verifyIdentity ) - if (identity.verdict === 'foreign') { - throw new OrcadManagedIdentityError(launch.port, identity.detail) - } - if (identity.verdict === 'unreachable') { - // Why not fail: a server that is still starting is not a wrong server. - console.warn(`[wsl] Orca server in ${distro} did not answer its bridge: ${identity.detail}`) - } if (this.generation(key) !== generation) { throw new WslOrcadConnectionSupersededError(distro) } @@ -293,10 +293,10 @@ export class WslOrcadConnectionManager { key: string, entry: ActiveWslOrcadConnection, launch: Extract, + pairingOffer: string, /** Reported even when nothing changed; null stays quiet then. */ announce: WslOrcadConnectionStatus | null ): WslOrcadConnection { - const pairingOffer = tunneledOrcadPairingCode(launch.readiness, entry.connection.localPort) const deferredUpdate = launch.deferredUpdate ?? null const changed = pairingOffer !== entry.connection.pairingOffer || diff --git a/src/main/wsl/wsl-orcad-pairing-identity.ts b/src/main/wsl/wsl-orcad-pairing-identity.ts index 4ecf5d392e0..f29595f0cb0 100644 --- a/src/main/wsl/wsl-orcad-pairing-identity.ts +++ b/src/main/wsl/wsl-orcad-pairing-identity.ts @@ -1,10 +1,31 @@ import { pairedRuntimeConnectionFromOffer } from '../../shared/paired-runtime-connection' -import { parsePairingCode } from '../../shared/pairing' +import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import type { ServeReadiness } from '../server/serve-readiness' import { + OrcadManagedIdentityError, verifyOrcadPairingIdentity, type OrcadTunnelIdentity } from '../managed-server/lifecycle/orcad-managed-tunnel-identity' +import { tunneledOrcadPairingCode } from '../ssh/orcad-tunneled-pairing' +import type { WslOrcadLaunch } from './wsl-orcad-deploy' + +/** Reconnect with the enrolled grant and host key; startup offers only enroll new clients. */ +export async function resolveWslOrcadPairing( + launch: Extract, + localPort: number, + paired: PairingOffer | null, + verifyIdentity = verifyWslOrcadIdentity +): Promise { + const pairingOffer = tunneledOrcadPairingCode(launch.readiness, localPort, paired) + const identity = await verifyIdentity(pairingOffer, launch.readiness) + if (identity.verdict === 'foreign') { + throw new OrcadManagedIdentityError(launch.port, identity.detail) + } + if (identity.verdict === 'unreachable') { + console.warn(`[wsl] Orca server in ${launch.distro} did not answer its bridge: ${identity.detail}`) + } + return pairingOffer +} /** Whether the server behind a WSL bridge is the orcad that published `readiness`. */ export async function verifyWslOrcadIdentity( diff --git a/src/shared/runtime-environment-wsl-host-store.ts b/src/shared/runtime-environment-wsl-host-store.ts index 1ba9896dc66..ee71f6052cf 100644 --- a/src/shared/runtime-environment-wsl-host-store.ts +++ b/src/shared/runtime-environment-wsl-host-store.ts @@ -106,8 +106,8 @@ export function addWslHostEnvironment( } /** - * Keeps a WSL host's pairing and ports current after orcad relaunched (a new device token) or - * the bridge moved. Returns whether the pairing changed, so callers retire stale transports. + * Saves an explicit re-pair or moved bridge. Server restarts retain the paired client grant. + * Returns whether the pairing changed, so callers retire stale transports. */ export function refreshWslHostPairing( userDataPath: string,