diff --git a/src/main/managed-server/install/orcad-remote-context.ts b/src/main/managed-server/install/orcad-remote-context.ts index ab1f332622f..2e9f945a5a7 100644 --- a/src/main/managed-server/install/orcad-remote-context.ts +++ b/src/main/managed-server/install/orcad-remote-context.ts @@ -52,7 +52,9 @@ export async function resolveOrcadRemoteContext> { const host = detectedHost ?? (await detectRemoteHostPlatform(connection, { signal })) if (!host) { @@ -65,7 +67,8 @@ export async function resolveOrcadRemoteContext { const { host } = remote if (!keyByMachine || isWindowsRemoteHost(host)) { const loginHome = normalizeRemoteHome( - await execOrcadRemote(remote, readRemoteHomeCommand(host)), + knownLoginHome ?? (await execOrcadRemote(remote, readRemoteHomeCommand(host))), host ) if (!validateRemoteHome(loginHome, host)) { diff --git a/src/main/managed-server/install/orcad-remote-primitives.test.ts b/src/main/managed-server/install/orcad-remote-primitives.test.ts index 071534b41f3..70ebf43a094 100644 --- a/src/main/managed-server/install/orcad-remote-primitives.test.ts +++ b/src/main/managed-server/install/orcad-remote-primitives.test.ts @@ -282,4 +282,20 @@ describe('orcad remote context', () => { }) expect(order).toEqual(['prepare', 'record']) }) + + it('uses a WSL caller-known platform and login home instead of probing them', async () => { + vi.mocked(detectRemoteHostPlatform).mockClear() + const commands: string[] = [] + mockExec.mockImplementation(async (_conn, command: string) => { + commands.push(command) + return command.includes('ldd') ? 'ldd (GNU libc) 2.39\n' : '__ORCAD_RECORD_ABSENT__\n' + }) + const wslTarget = { kind: 'wsl', id: 'wsl:u', distro: 'U', label: 'U' } as const + + const context = await resolveOrcadRemoteContext(wslTarget, conn, undefined, linux, '/home/u\n') + + expect(context).toMatchObject({ remoteHome: '/home/u', userDataDir: '/home/u/.orca' }) + expect(detectRemoteHostPlatform).not.toHaveBeenCalled() + expect(commands.some((command) => command.includes('$HOME'))).toBe(false) + }) }) diff --git a/src/main/ssh/ssh-remote-platform-detection.test.ts b/src/main/ssh/ssh-remote-platform-detection.test.ts index 43286af56a1..67740e2ca77 100644 --- a/src/main/ssh/ssh-remote-platform-detection.test.ts +++ b/src/main/ssh/ssh-remote-platform-detection.test.ts @@ -10,7 +10,8 @@ vi.mock('./remote-exec/remote-exec-command', async (importOriginal) => ({ execCommand: execCommandMock })) -const { detectRemoteHostPlatform } = await import('./ssh-remote-platform-detection') +const { detectRemoteHostPlatform, remoteHostPlatformFromUnameProbe } = + await import('./ssh-remote-platform-detection') const conn = {} as SshConnection @@ -264,3 +265,15 @@ describe('detectRemoteHostPlatform failure reporting', () => { }) }) }) + +describe('remoteHostPlatformFromUnameProbe', () => { + it('reads only marker-backed platform lines, past other output folded into the same exec', () => { + expect( + remoteHostPlatformFromUnameProbe( + '5.15.167.4-microsoft-standard-WSL2\n/home/u\n\n__ORCA_REMOTE_PLATFORM__ Linux aarch64\n' + ) + ).toMatchObject({ os: 'linux', arch: 'arm64' }) + expect(remoteHostPlatformFromUnameProbe('Linux x86_64\n')).toBeNull() + expect(remoteHostPlatformFromUnameProbe('\n__ORCA_REMOTE_PLATFORM__ Linux sparc\n')).toBeNull() + }) +}) diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index 047a1809483..1270588c35c 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -14,6 +14,8 @@ import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-pla import { powerShellCommand } from './ssh-remote-powershell' const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' +/** The probe's marker-backed line starts with this. */ +export const UNAME_PLATFORM_PROBE_MARKER = `${PLATFORM_PROBE_MARKER} ` const MAX_UNAME_FIELD_CHARS = 64 const MAX_THROWN_OUTPUT_CHARS = 200 const MAX_LOGGED_OUTPUT_CHARS = 1000 @@ -24,6 +26,15 @@ type PlatformProbeOutcome = | { kind: 'unparsed'; output: string } | { kind: 'failed'; error: unknown } +/** The POSIX platform probe, for callers that fold it into a command of their own. */ +export const UNAME_PLATFORM_PROBE_COMMAND = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + +/** The platform a probe's output proves; null for unsupported, unparsed or startup-noise output. */ +export function remoteHostPlatformFromUnameProbe(output: string): RemoteHostPlatform | null { + const outcome = parseRemotePlatformOutput(output) + return outcome.kind === 'detected' ? getRemoteHostPlatform(outcome.platform) : null +} + export async function detectRemoteHostPlatform( conn: RemoteInstallTransport, options?: { signal?: AbortSignal } @@ -127,10 +138,9 @@ async function detectUnamePlatform( ): Promise { try { // Separate marker from startup output that may omit its trailing newline. - const command = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` const output = signal - ? await execCommand(conn, command, { signal }) - : await execCommand(conn, command) + ? await execCommand(conn, UNAME_PLATFORM_PROBE_COMMAND, { signal }) + : await execCommand(conn, UNAME_PLATFORM_PROBE_COMMAND) return parseRemotePlatformOutput(output) } catch (error) { signal?.throwIfAborted() diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts index c5004e674e7..165b8953b25 100644 --- a/src/main/wsl/wsl-orcad-deploy.test.ts +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -108,9 +108,9 @@ function transport(target: WslOrcadHostTarget): OrcadHostTransport { function launch(kernel = '5.15.167.4-microsoft-standard-WSL2', options = {}) { mocks.exec.mockImplementation(async (_conn: unknown, command: string) => { - if (command === 'uname -r') { + if (command.includes('uname -r')) { mocks.steps.push('uname') - return `${kernel}\n` + return `__ORCA_WSL_KERNEL__ ${kernel}\n__ORCA_WSL_HOME__ /home/u\n\n__ORCA_REMOTE_PLATFORM__ Linux x86_64\n` } return '4242\n' }) diff --git a/src/main/wsl/wsl-orcad-held-context.test.ts b/src/main/wsl/wsl-orcad-held-context.test.ts index 814393f121a..552c1e146ae 100644 --- a/src/main/wsl/wsl-orcad-held-context.test.ts +++ b/src/main/wsl/wsl-orcad-held-context.test.ts @@ -4,10 +4,18 @@ import type { WslDistroHolder } from './wsl-distro-holder' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' const mocks = vi.hoisted(() => { - const state: { spawned: string[]; refused: string[]; probeStep: (() => void) | null } = { + const state: { + spawned: string[] + refused: string[] + probeStep: (() => void) | null + facts: string + contextArgs: unknown[] + } = { spawned: [], refused: [], - probeStep: null + probeStep: null, + facts: '', + contextArgs: [] } return state }) @@ -16,25 +24,32 @@ const mocks = vi.hoisted(() => { vi.mock('../ssh/remote-exec/remote-exec-command', () => ({ execCommand: vi.fn( async (_conn: unknown, command: string, options?: { signal?: AbortSignal }) => { + const label = + command === 'uname -r' ? command : command.includes('uname -r') ? 'host-facts' : command if (options?.signal?.aborted) { - mocks.refused.push(command) + mocks.refused.push(label) throw options.signal.reason } - mocks.spawned.push(command) - return command === 'uname -r' ? '5.15.167.4-microsoft-standard-WSL2\n' : '' + mocks.spawned.push(label) + if (label === 'uname -r') { + return `${mocks.facts.includes('-Microsoft') ? '4.4.0-19041-Microsoft' : '5.15.167.4-microsoft-standard-WSL2'}\n` + } + return label === 'host-facts' ? mocks.facts : '' } ) })) -// Like the real context resolver: several probes in series, each passed the caller's signal. +// Like the real context resolver given a known platform and home: its remaining probes in series. vi.mock('../managed-server/install/orcad-remote-context', async () => { const { execCommand } = await import('../ssh/remote-exec/remote-exec-command') return { resolveOrcadRemoteContext: async ( _target: unknown, conn: OrcadHostTransport, - signal?: AbortSignal + signal?: AbortSignal, + ...known: unknown[] ) => { - for (const command of ['uname -sm', 'echo $HOME', 'ldd --version']) { + mocks.contextArgs = known + for (const command of ['ldd --version', 'cat orcad-active.json']) { await new Promise((resolve) => { mocks.probeStep = resolve }) @@ -45,7 +60,13 @@ vi.mock('../managed-server/install/orcad-remote-context', async () => { } }) -import { holdWslDistroWithContext, isWsl1KernelRelease } from './wsl-orcad-held-context' +import { runProcess } from '@orca/process-host' +import { + holdWslDistroWithContext, + isWsl1KernelRelease, + readWslHostFacts, + WSL_HOST_FACTS_COMMAND +} from './wsl-orcad-held-context' const target: WslOrcadHostTarget = { kind: 'wsl', id: 'wsl:u', distro: 'U', label: 'U' } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module. @@ -64,10 +85,17 @@ function holderRejectingOn(gate: Promise): WslDistroHolder { } } +/** What WSL_HOST_FACTS_COMMAND prints. */ +function facts(kernel: string, home: string, uname: string): string { + return `__ORCA_WSL_KERNEL__ ${kernel}\n__ORCA_WSL_HOME__ ${home}\n\n__ORCA_REMOTE_PLATFORM__ ${uname}\n` +} + beforeEach(() => { mocks.spawned.length = 0 mocks.refused.length = 0 mocks.probeStep = null + mocks.facts = facts('5.15.167.4-microsoft-standard-WSL2', '/home/u', 'Linux x86_64') + mocks.contextArgs = [] }) describe('holdWslDistroWithContext', () => { @@ -90,14 +118,14 @@ describe('holdWslDistroWithContext', () => { const held = holdWslDistroWithContext('U', target, conn, hold, undefined).finally(() => { settled = true }) - for (let step = 0; step < 3; step++) { + for (let step = 0; step < 2; step++) { await vi.waitFor(() => expect(mocks.probeStep).not.toBeNull()) const next = mocks.probeStep mocks.probeStep = null next?.() } await vi.waitFor(() => - expect(mocks.spawned).toEqual(['uname -r', 'uname -sm', 'echo $HOME', 'ldd --version']) + expect(mocks.spawned).toEqual(['host-facts', 'ldd --version', 'cat orcad-active.json']) ) // The deploy awaits this before recovery, which may launch orcad. expect(settled).toBe(false) @@ -124,14 +152,101 @@ describe('holdWslDistroWithContext', () => { ) await vi.waitFor(() => expect(mocks.probeStep).not.toBeNull()) mocks.probeStep?.() - await vi.waitFor(() => expect(mocks.spawned).toEqual(['uname -r', 'uname -sm'])) + await vi.waitFor(() => expect(mocks.spawned).toEqual(['host-facts', 'ldd --version'])) failHold() await expect(held).rejects.toThrow('holder exited') mocks.probeStep?.() - await vi.waitFor(() => expect(mocks.refused).toEqual(['echo $HOME'])) + await vi.waitFor(() => expect(mocks.refused).toEqual(['cat orcad-active.json'])) - expect(mocks.spawned).toEqual(['uname -r', 'uname -sm']) + expect(mocks.spawned).toEqual(['host-facts', 'ldd --version']) + }) +}) + +describe('the folded host facts probe', () => { + function heldNow(): WslDistroHolder { + return { + ...holderRejectingOn(Promise.resolve()), + ensureHeld: vi.fn(async (distro: string) => ({ + outcome: 'adopted' as const, + record: { version: 1 as const, distro, pid: 9, creationTimeMs: 1, startedAtMs: 1 } + })) + } + } + + async function resolveWith(facts: string) { + mocks.facts = facts + const held = holdWslDistroWithContext('U', target, conn, heldNow(), undefined) + for (let step = 0; step < 2; step++) { + await vi.waitFor(() => expect(mocks.probeStep).not.toBeNull()) + const next = mocks.probeStep + mocks.probeStep = null + next?.() + } + return held + } + + it('hands the context the platform and login home it read in the same exec', async () => { + await resolveWith(mocks.facts) + + expect(mocks.spawned.filter((label) => label === 'host-facts')).toHaveLength(1) + expect(mocks.contextArgs).toEqual([ + expect.objectContaining({ os: 'linux', arch: 'x64' }), + '/home/u' + ]) + }) + + it('leaves the context to probe what the facts did not prove', async () => { + await resolveWith(facts('5.15.167.4-microsoft-standard-WSL2', '', 'Linux sparc')) + + expect(mocks.contextArgs).toEqual([undefined, undefined]) + expect(mocks.spawned[0]).toBe('host-facts') + expect(mocks.spawned).not.toContain('uname -r') + }) + + it('falls back to the separate probes for a HOME with a newline, never a truncated path', async () => { + await resolveWith(facts('5.15.167.4-microsoft-standard-WSL2', '/home/a\nb', 'Linux x86_64')) + + expect(mocks.contextArgs).toEqual([undefined, undefined]) + expect(mocks.spawned.slice(0, 2)).toEqual(['host-facts', 'uname -r']) + }) + + it('falls back without throwing when stdout noise precedes the facts', async () => { + await resolveWith( + `Welcome to Ubuntu\n${facts('5.15.167.4-microsoft-standard-WSL2', '/home/u', 'Linux x86_64')}` + ) + + expect(mocks.contextArgs).toEqual([undefined, undefined]) + expect(mocks.spawned.slice(0, 2)).toEqual(['host-facts', 'uname -r']) + }) + + it('refuses WSL 1 from the kernel line before holding or probing', async () => { + mocks.facts = facts('4.4.0-19041-Microsoft', '/home/u', 'Linux x86_64') + const holder = heldNow() + + await expect( + holdWslDistroWithContext('U', target, conn, holder, undefined) + ).rejects.toMatchObject({ + name: 'OrcadHostUnsupportedError' + }) + expect(holder.ensureHeld).not.toHaveBeenCalled() + expect(mocks.spawned).toEqual(['host-facts']) + }) +}) + +describe.skipIf(process.platform === 'win32')('WSL_HOST_FACTS_COMMAND under a real sh', () => { + it('prints exactly the shape readWslHostFacts accepts', async () => { + const result = await runProcess({ + program: 'sh', + args: ['-c', WSL_HOST_FACTS_COMMAND], + env: { ...process.env, HOME: '/home/orca user' }, + timeoutMs: 10_000 + }) + + expect(readWslHostFacts(result.stdout)).toMatchObject({ + kernel: expect.stringMatching(/\S/), + loginHome: '/home/orca user' + }) }) }) diff --git a/src/main/wsl/wsl-orcad-held-context.ts b/src/main/wsl/wsl-orcad-held-context.ts index 513810edc18..ea52c0a7f1d 100644 --- a/src/main/wsl/wsl-orcad-held-context.ts +++ b/src/main/wsl/wsl-orcad-held-context.ts @@ -1,10 +1,50 @@ import { resolveOrcadRemoteContext } from '../managed-server/install/orcad-remote-context' import type { OrcadHostTransport } from '../ssh/orcad-host-transport' import { execCommand } from '../ssh/remote-exec/remote-exec-command' +import { + remoteHostPlatformFromUnameProbe, + UNAME_PLATFORM_PROBE_COMMAND, + UNAME_PLATFORM_PROBE_MARKER +} from '../ssh/ssh-remote-platform-detection' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { WslDistroHolder } from './wsl-distro-holder' import { refuseMissingWslDistro, WslDistroVersion1Error } from './wsl-distro-unsupported' import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +const KERNEL_MARKER = '__ORCA_WSL_KERNEL__ ' +const HOME_MARKER = '__ORCA_WSL_HOME__ ' +export const WSL_HOST_FACTS_COMMAND = `printf '${KERNEL_MARKER}%s\\n${HOME_MARKER}%s\\n' "$(uname -r)" "$HOME"; ${UNAME_PLATFORM_PROBE_COMMAND}` + +type WslHostFacts = { kernel: string; loginHome?: string; platform?: RemoteHostPlatform } + +/** + * Accepts only the exact shape the facts command prints; anything else (startup noise, a HOME + * with a newline) returns null so the caller probes each fact on its own. + */ +export function readWslHostFacts(output: string): WslHostFacts | null { + const lines = output.split('\n') + const [kernelLine = '', homeLine = '', gap, platformLine = '', ...rest] = lines + if ( + !kernelLine.startsWith(KERNEL_MARKER) || + !homeLine.startsWith(HOME_MARKER) || + gap !== '' || + !platformLine.startsWith(UNAME_PLATFORM_PROBE_MARKER) || + rest.some((line) => line.trim() !== '') + ) { + return null + } + const loginHome = homeLine.slice(HOME_MARKER.length) + return { + kernel: kernelLine.slice(KERNEL_MARKER.length).trim(), + ...(loginHome.trim() ? { loginHome } : {}), + ...withPlatform(remoteHostPlatformFromUnameProbe(`\n${platformLine}`)) + } +} + +function withPlatform(platform: RemoteHostPlatform | null): { platform?: RemoteHostPlatform } { + return platform ? { platform } : {} +} + /** WSL 1 runs no Linux kernel, so the pinned Node and its pty cannot run there. */ export function isWsl1KernelRelease(release: string): boolean { return /^\d+\.\d+\.\d+-\d+-Microsoft$/.test(release.trim()) @@ -22,8 +62,13 @@ export async function holdWslDistroWithContext( holder: WslDistroHolder, signal: AbortSignal | undefined ) { - const kernel = await execCommand(conn, 'uname -r', { signal }).catch(refuseMissingWslDistro(name)) - if (isWsl1KernelRelease(kernel)) { + // One exec for the kernel, the login home and the platform the context would probe one by one. + const facts = readWslHostFacts( + await execCommand(conn, WSL_HOST_FACTS_COMMAND, { signal }).catch(refuseMissingWslDistro(name)) + ) ?? + // Unrecognized output proves nothing: read the kernel alone, as before, and let the context probe. + { kernel: await execCommand(conn, 'uname -r', { signal }).catch(refuseMissingWslDistro(name)) } + if (isWsl1KernelRelease(facts.kernel)) { throw new WslDistroVersion1Error(name) } // Once the hold fails, stop the probes: after dispose they fall back to fresh wsl.exe execs, @@ -40,7 +85,7 @@ export async function holdWslDistroWithContext( probes.abort(error) throw error }), - resolveOrcadRemoteContext(target, conn, probes.signal) + resolveOrcadRemoteContext(target, conn, probes.signal, facts.platform, facts.loginHome) ]) return { held, context } } catch (error) {