diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 51801c58940..d0bbf29477f 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -21,6 +21,7 @@ "../src/main/agent-hooks/installer-utils.ts", "../src/main/agent-hooks/installer-utils-remote.ts", "../src/main/agent-hooks/local-agent-cli-presence.ts", + "../src/main/ipc/wsl-guest-windows-drive-path.ts", "../src/main/agent-hooks/managed-toml-ownership.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", diff --git a/src/main/agent-hooks/local-agent-cli-presence.test.ts b/src/main/agent-hooks/local-agent-cli-presence.test.ts index d8bb85bc405..adc9f71b523 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.test.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.test.ts @@ -50,6 +50,23 @@ describe('detectLocalManagedAgentCliPresence', () => { ]) }) + it('skips Windows drive dirs on a WSL guest, so a /mnt/c npm shim is not the Linux CLI', async () => { + const probe = vi.fn(async (filePath: string) => filePath.startsWith('/mnt/c/')) + const result = await detectLocalManagedAgentCliPresence( + [codexTarget], + { agentCmdOverrides: {} }, + { + pathEnv: '/home/u/.local/bin:/mnt/c/Users/u/AppData/Roaming/npm', + pathDelimiter: ':', + fileProbe: { isExecutableFile: probe }, + platform: 'linux', + guestWindowsDriveMounts: ['/mnt/c'] + } + ) + expect(result.codex).toEqual({ state: 'missing' }) + expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual(['/home/u/.local/bin/codex']) + }) + it('uses executable override paths as positive evidence', async () => { const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex') const result = await detectLocalManagedAgentCliPresence( diff --git a/src/main/agent-hooks/local-agent-cli-presence.ts b/src/main/agent-hooks/local-agent-cli-presence.ts index c688508a512..b0fb6835487 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.ts @@ -11,6 +11,10 @@ import { import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets' import type { GlobalSettings } from '../../shared/global-settings-types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { + isUnderWindowsDriveMount, + readGuestWindowsDriveMounts +} from '../ipc/wsl-guest-windows-drive-path' export type LocalCliPresenceState = 'found' | 'missing' | 'unknown' export type LocalCliPresenceByAgent = Partial< @@ -37,6 +41,8 @@ type DetectOptions = { hydratePath?: () => Promise shouldHydrateShellPath?: boolean homeDir?: string + /** Windows drive mounts to skip in a WSL guest; defaults to this process's own. */ + guestWindowsDriveMounts?: readonly string[] } type CommandOverrideSettings = Partial> | null | undefined @@ -163,7 +169,10 @@ export async function detectLocalManagedAgentCliPresence( await maybeHydrateShellPath(options) const platform = options.platform ?? process.platform const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter - const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter) + const guestMounts = options.guestWindowsDriveMounts ?? readGuestWindowsDriveMounts() + const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter).filter( + (dir) => !isUnderWindowsDriveMount(dir, guestMounts) + ) const homeDir = options.homeDir ?? homedir() const fileProbe = options.fileProbe ?? { isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform) diff --git a/src/main/ipc/preflight-local-env.ts b/src/main/ipc/preflight-local-env.ts index f61f1021918..cbd3353208b 100644 --- a/src/main/ipc/preflight-local-env.ts +++ b/src/main/ipc/preflight-local-env.ts @@ -1,4 +1,8 @@ import { mergePersistedWindowsPath } from '../pty/windows-environment-path' +import { + readGuestWindowsDriveMounts, + withoutWindowsDriveMountDirs +} from './wsl-guest-windows-drive-path' function stringOnlyProcessEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {} @@ -10,9 +14,17 @@ function stringOnlyProcessEnv(env: NodeJS.ProcessEnv): Record { return result } -export function buildLocalPreflightEnv(): Record | undefined { +export function buildLocalPreflightEnv( + guestWindowsDriveMounts: readonly string[] = readGuestWindowsDriveMounts() +): Record | undefined { if (process.platform !== 'win32') { - return undefined + if (guestWindowsDriveMounts.length === 0) { + return undefined + } + // In a WSL guest, detection must find the Linux CLI, not a Windows shim on /mnt/. + const env = stringOnlyProcessEnv(process.env) + env.PATH = withoutWindowsDriveMountDirs(env.PATH ?? '', guestWindowsDriveMounts) + return env } const env = stringOnlyProcessEnv(process.env) // Why: newly installed CLIs update persisted Windows Path, but the running diff --git a/src/main/ipc/wsl-guest-windows-drive-path.test.ts b/src/main/ipc/wsl-guest-windows-drive-path.test.ts new file mode 100644 index 00000000000..75bae5a5809 --- /dev/null +++ b/src/main/ipc/wsl-guest-windows-drive-path.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + isWslGuestProcess, + isWslHostOrcadProcess, + ORCAD_WSL_HOST_ENV, + parseWindowsDriveMountPoints, + withoutWindowsDriveMountDirs +} from './wsl-guest-windows-drive-path' +import { buildLocalPreflightEnv } from './preflight-local-env' +import { getPreflightWslTarget } from './preflight-runtime-target' + +// Captured from Ubuntu 24.04 on WSL 2.7.10 (trimmed). +const WSL2_PROC_MOUNTS = [ + 'none /usr/lib/wsl/lib overlay rw,relatime 0 0', + '/dev/sdc / ext4 rw,relatime,discard 0 0', + 'drivers /usr/lib/wsl/drivers 9p ro,nosuid,nodev,noatime,aname=drivers;fmask=222 0 0', + 'C:\\134 /mnt/c 9p rw,noatime,dirsync,aname=drvfs;path=C:\\;uid=1000 0 0', + 'D:\\134 /data/d\\040drive 9p rw,noatime,aname=drvfs;path=D:\\ 0 0', + 'tmpfs /run tmpfs rw,nosuid,nodev,mode=755 0 0' +].join('\n') + +describe('Windows drive dirs on a WSL guest PATH', () => { + it('reads drive mounts by type, wherever the automount root is', () => { + expect(parseWindowsDriveMountPoints(WSL2_PROC_MOUNTS)).toEqual([ + '/usr/lib/wsl/drivers', + '/mnt/c', + '/data/d drive' + ]) + }) + + it('never treats a root mount as a drive, which would empty PATH', () => { + expect(parseWindowsDriveMountPoints('rootfs / virtiofs rw 0 0')).toEqual([]) + }) + + it('drops /mnt/ entries so a Windows shim cannot answer for a Linux CLI', () => { + const path = [ + '/home/u/.local/bin', + '/usr/bin', + '/mnt/c/Users/u/AppData/Roaming/npm', + '/mnt/c/WINDOWS/system32', + '/mnt/cache/bin', + '/usr/lib/wsl/lib' + ].join(':') + expect(withoutWindowsDriveMountDirs(path, ['/mnt/c'])).toBe( + '/home/u/.local/bin:/usr/bin:/mnt/cache/bin:/usr/lib/wsl/lib' + ) + }) + + it('leaves PATH untouched outside a WSL guest', () => { + expect(withoutWindowsDriveMountDirs('/mnt/c/x:/usr/bin', [])).toBe('/mnt/c/x:/usr/bin') + expect(isWslGuestProcess({}, 'linux', () => false)).toBe(false) + expect(isWslGuestProcess({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin', () => true)).toBe(false) + }) + + it('recognises the guest under `wsl.exe --exec` (WSL_DISTRO_NAME) or by its interop binfmt', () => { + expect(isWslGuestProcess({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux', () => false)).toBe(true) + expect(isWslGuestProcess({}, 'linux', () => true)).toBe(true) + }) + + // Regression: SSH-managed orcad in a distro and Orca under WSLg must keep /mnt/ CLIs. + it('filters only for an orcad Orca launched as a WSL host', () => { + const guest = { WSL_DISTRO_NAME: 'Ubuntu' } + expect(isWslHostOrcadProcess(guest, 'linux', () => true)).toBe(false) + expect(isWslHostOrcadProcess({ ...guest, [ORCAD_WSL_HOST_ENV]: '1' }, 'linux')).toBe(true) + expect(isWslHostOrcadProcess({ [ORCAD_WSL_HOST_ENV]: '1' }, 'linux', () => false)).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'gives local preflight a PATH without the drive dirs on a guest only', + () => { + const original = process.env.PATH + process.env.PATH = '/home/u/.local/bin:/mnt/c/Users/u/AppData/Roaming/npm:/usr/bin' + try { + expect(buildLocalPreflightEnv([])).toBeUndefined() + expect(buildLocalPreflightEnv(['/mnt/c'])?.PATH).toBe('/home/u/.local/bin:/usr/bin') + } finally { + process.env.PATH = original + } + } + ) + + it('never treats orcad inside the guest as a WSL preflight context', () => { + expect(getPreflightWslTarget({ wslDistro: 'Ubuntu' }, 'linux')).toBeNull() + expect(getPreflightWslTarget({ wslDefault: true }, 'linux')).toBeNull() + }) +}) diff --git a/src/main/ipc/wsl-guest-windows-drive-path.ts b/src/main/ipc/wsl-guest-windows-drive-path.ts new file mode 100644 index 00000000000..88144a4d4f1 --- /dev/null +++ b/src/main/ipc/wsl-guest-windows-drive-path.ts @@ -0,0 +1,90 @@ +/** + * Inside a WSL distro (orcad on a WSL host), WSL appends the Windows PATH to the guest's, so a bare + * PATH walk can pick a Windows shim (`/mnt/c/.../codex`) over the Linux CLI or report a CLI the + * guest cannot really run. Same mount rule as `buildPosixCommandPathLookupScript`'s + * `skipWindowsMountDirs`: matched by mount type, since the automount root is configurable. + */ +import { existsSync, readFileSync } from 'node:fs' + +const WINDOWS_DRIVE_MOUNT_TYPES = new Set(['drvfs', '9p', 'virtiofs']) + +/** Set only by the WSL-host orcad launch, so SSH remotes and Orca under WSLg keep their PATH. */ +export const ORCAD_WSL_HOST_ENV = 'ORCA_WSL_ORCAD_HOST' + +let cachedGuestMounts: readonly string[] | null = null + +export function isWslGuestProcess( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + interopRegistered: () => boolean = () => existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') +): boolean { + if (platform !== 'linux') { + return false + } + return Boolean(env.WSL_DISTRO_NAME?.trim() || env.WSL_INTEROP?.trim()) || interopRegistered() +} + +/** A WSL guest process that Orca launched as a WSL host's orcad. */ +export function isWslHostOrcadProcess( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + interopRegistered?: () => boolean +): boolean { + return env[ORCAD_WSL_HOST_ENV] === '1' && isWslGuestProcess(env, platform, interopRegistered) +} + +/** `/proc/mounts` escapes whitespace and backslashes in a mount point as octal (`\040`). */ +function decodeMountField(field: string): string { + return field.replace(/\\([0-7]{3})/g, (_match, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)) + ) +} + +export function parseWindowsDriveMountPoints(procMounts: string): string[] { + const mounts: string[] = [] + for (const line of procMounts.split('\n')) { + const [, mountPoint, type] = line.trim().split(/\s+/) + const decoded = mountPoint ? decodeMountField(mountPoint).replace(/\/+$/, '') : '' + // A root-level match would drop every PATH entry; no Windows drive mounts at `/`. + if (decoded && type && WINDOWS_DRIVE_MOUNT_TYPES.has(type)) { + mounts.push(decoded) + } + } + return mounts +} + +export function isUnderWindowsDriveMount(dir: string, mounts: readonly string[]): boolean { + return mounts.some((mount) => dir === mount || dir.startsWith(`${mount}/`)) +} + +export function withoutWindowsDriveMountDirs(pathValue: string, mounts: readonly string[]): string { + if (mounts.length === 0) { + return pathValue + } + return pathValue + .split(':') + .filter((dir) => !isUnderWindowsDriveMount(dir.replace(/\/+$/, ''), mounts)) + .join(':') +} + +/** This process's Windows drive mounts when it is a WSL host's orcad; empty everywhere else. */ +export function readGuestWindowsDriveMounts(): readonly string[] { + if (cachedGuestMounts) { + return cachedGuestMounts + } + let mounts: string[] = [] + if (isWslHostOrcadProcess()) { + try { + mounts = parseWindowsDriveMountPoints(readFileSync('/proc/mounts', 'utf8')) + } catch { + // Unreadable: keep PATH as is rather than guess which entries are Windows drives. + } + } + cachedGuestMounts = mounts + return mounts +} + +/** @internal - tests need a fresh mount read. */ +export function _resetGuestWindowsDriveMountsForTests(): void { + cachedGuestMounts = null +} diff --git a/src/main/orcad/orcad-wsl-guest-shell-path.test.ts b/src/main/orcad/orcad-wsl-guest-shell-path.test.ts new file mode 100644 index 00000000000..860eba35a30 --- /dev/null +++ b/src/main/orcad/orcad-wsl-guest-shell-path.test.ts @@ -0,0 +1,36 @@ +/** + * orcad on a WSL host is launched by `wsl.exe --exec`, whose PATH misses Linux CLIs installed by + * the login shell's rc files (claude, opencode, gemini) and carries Windows shims. Its agent + * detection must hydrate PATH from `$SHELL -ilc` like any packaged build. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const hydration = vi.hoisted(() => ({ + hydrateShellPath: vi.fn(async () => ({ ok: true as const, segments: ['/home/u/.local/bin'] })), + mergePathSegments: vi.fn(() => []) +})) +vi.mock('../startup/hydrate-shell-path', () => hydration) + +import { getAppEnvironment } from '../../shared/app-environment' +import { hydrateShellPathForAgentDetection } from '../ipc/agent-detection-shell-path' +import { installOrcadHostAdapters } from './orcad-entry' + +describe('orcad PATH hydration on a WSL guest', () => { + const platform = process.platform + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }) + vi.clearAllMocks() + }) + + it('reports itself packaged, which is what gates the login-shell probe', () => { + installOrcadHostAdapters() + expect(getAppEnvironment().isPackaged()).toBe(true) + }) + + it('hydrates for detection even when a client names a WSL distro: inside it, Linux is local', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + await hydrateShellPathForAgentDetection({ wslDistro: 'Ubuntu' }) + expect(hydration.hydrateShellPath).toHaveBeenCalledTimes(1) + expect(hydration.mergePathSegments).toHaveBeenCalledWith(['/home/u/.local/bin']) + }) +}) diff --git a/src/main/preflight/agent-command-detection.test.ts b/src/main/preflight/agent-command-detection.test.ts new file mode 100644 index 00000000000..550f78aa9cb --- /dev/null +++ b/src/main/preflight/agent-command-detection.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DrivePath from '../ipc/wsl-guest-windows-drive-path' + +const { detectInstallDirs, guestMounts } = vi.hoisted(() => { + const value: readonly string[] = [] + return { + detectInstallDirs: vi.fn( + (_commands: readonly string[], _pathEnv?: string) => new Set() + ), + guestMounts: { value } + } +}) + +vi.mock('../ipc/local-agent-install-dir-detection', () => ({ + detectCommandsInInstallDirs: detectInstallDirs +})) +vi.mock('../ipc/preflight-command-exec', () => ({ + isCommandOnPath: vi.fn(async () => false) +})) +vi.mock('../ipc/wsl-guest-windows-drive-path', async (importOriginal) => ({ + ...(await importOriginal()), + readGuestWindowsDriveMounts: () => guestMounts.value +})) + +import { detectAgentCommandsOnHost } from './agent-command-detection' + +describe('detectAgentCommandsOnHost install-dir fallback', () => { + beforeEach(() => { + detectInstallDirs.mockClear() + guestMounts.value = [] + }) + + // Regression: Windows must keep searching the process PATH, not the persisted-registry merge. + it('searches the process PATH everywhere except a WSL host orcad', async () => { + await detectAgentCommandsOnHost(['codex']) + expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], undefined) + }) + + it.skipIf(process.platform === 'win32')( + 'drops /mnt/ dirs from the fallback PATH on a WSL host orcad', + async () => { + guestMounts.value = ['/mnt/c'] + const original = process.env.PATH + process.env.PATH = '/usr/bin:/mnt/c/Users/u/AppData/Roaming/npm' + try { + await detectAgentCommandsOnHost(['codex']) + } finally { + process.env.PATH = original + } + expect(detectInstallDirs).toHaveBeenCalledWith(['codex'], '/usr/bin') + } + ) +}) diff --git a/src/main/preflight/agent-command-detection.ts b/src/main/preflight/agent-command-detection.ts index bf0c40f2494..bb1b7de45e9 100644 --- a/src/main/preflight/agent-command-detection.ts +++ b/src/main/preflight/agent-command-detection.ts @@ -6,6 +6,8 @@ import { type PreflightRuntimeContext } from '../ipc/preflight-runtime-target' import { isCommandOnPath } from '../ipc/preflight-command-exec' +import { buildLocalPreflightEnv } from '../ipc/preflight-local-env' +import { readGuestWindowsDriveMounts } from '../ipc/wsl-guest-windows-drive-path' export async function detectAgentCommandsOnHost( commands: readonly string[], @@ -28,7 +30,13 @@ export async function detectAgentCommandsOnHost( const missedCommands = pathChecks.filter((check) => !check.installedOnPath).map(({ cmd }) => cmd) // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution // computes user install dirs once instead of blocking once per missed CLI. - const installDirCommands = detectCommandsInInstallDirs(missedCommands) + // Same PATH as the walk above: a WSL host's orcad must not re-admit /mnt/ shims. + // Elsewhere (Windows included) keep the process PATH the fallback always searched. + const guestMounts = readGuestWindowsDriveMounts() + const installDirCommands = detectCommandsInInstallDirs( + missedCommands, + guestMounts.length > 0 ? buildLocalPreflightEnv(guestMounts)?.PATH : undefined + ) const foundCommands = new Set( pathChecks .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) diff --git a/src/main/ssh/orcad-host-transport.ts b/src/main/ssh/orcad-host-transport.ts index d508addc801..df32e25fa8f 100644 --- a/src/main/ssh/orcad-host-transport.ts +++ b/src/main/ssh/orcad-host-transport.ts @@ -1,6 +1,7 @@ import type { SshTarget } from '../../shared/ssh-types' import type { WslOrcadHostTarget } from '../wsl/wsl-orcad-host-target' import type { RemoteInstallTransport } from './remote-install-transport' +import type { OrcadLaunchDetach } from './orcad-remote-launch' /** The host an orcad transport reaches; only `id` and `label` mean the same for every kind. */ export type OrcadHostTarget = SshTarget | WslOrcadHostTarget @@ -13,4 +14,6 @@ export function isWslOrcadHostTarget(target: OrcadHostTarget): target is WslOrca export type OrcadHostTransport = RemoteInstallTransport & { /** Keys per-host state (remote context, wake dedupe) to the target it reaches. */ getTarget(): OrcadHostTarget + /** How a launch leaves the exec's session; absent (SSH) means `nohup`. */ + readonly orcadLaunchDetach?: OrcadLaunchDetach } diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts index daa54968fe1..3076ad2c75a 100644 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ b/src/main/ssh/orcad-managed-runtime-context.ts @@ -7,6 +7,10 @@ import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ss import type { ServeReadiness } from '../server/serve-readiness' import { computeLocalOrcadBuildHash } from './orcad-local-build-hash' import { sshOrcadHostTransport } from './orcad-ssh-host-transport' +import type { OrcadHostTarget } from './orcad-host-transport' +import type { OrcadActivationRecord } from './orcad-activation-record' +import { refuseOrcadHostDowngrade } from './orcad-host-version-admission' +import type { OrcadTerminalCensus } from './orcad-update-plan' import { probeActiveOrcadReadiness } from './orcad-active-readiness' import { resolveOrcadRemoteContext, type OrcadRemoteContext } from './orcad-remote-context' import { ORCAD_INSTALL_MODEL } from './remote-install-model' @@ -67,7 +71,11 @@ export async function resolveLinkedOrcadContext( } /** The slot options every remote lifecycle step takes for this managed server. */ -export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) { +export function managedOrcadSlot( + context: OrcadRemoteContext, + port: number, + signal?: AbortSignal +) { return { conn: context.connection, host: context.host, @@ -80,7 +88,10 @@ export function managedOrcadSlot(context: OrcadRemoteContext, port: number, sign } } -export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string { +export function managedOrcadInstallDir( + context: OrcadRemoteContext, + version: string +): string { return computeRemoteInstallDir( ORCAD_INSTALL_MODEL, context.remoteHome, @@ -91,7 +102,7 @@ export function managedOrcadInstallDir(context: OrcadRemoteContext, version: str /** The active slot's readiness, accepted only if it is the build this client holds. */ export function probeManagedOrcadReadiness( - context: OrcadRemoteContext, + context: OrcadRemoteContext, localOrcadDir: string, fullVersion: string, signal?: AbortSignal @@ -107,6 +118,34 @@ export function probeManagedOrcadReadiness( ) } +/** The census and version admission every first deploy onto a host applies, SSH or WSL. */ +export function managedOrcadDeployPolicy( + context: Pick, 'activationRecord'>, + appVersion: string, + force?: boolean +): { + census: OrcadTerminalCensus + censusRecord: OrcadActivationRecord + admitRecord: (record: OrcadActivationRecord, candidateVersion: string) => string | null +} { + return { + // Why unknown when a version is active: only the daemon can count its sessions, and + // a deploy that guessed zero would restart over live terminals. + census: context.activationRecord.active + ? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } + : { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, + censusRecord: context.activationRecord, + // Why: a host another desktop stopped may hold state its newer build migrated, and this + // path has no linked record to check first, so only the fenced read can refuse it. + // An explicit force runs this version anyway, as `environment update --force` does. + admitRecord: (record, candidateVersion) => + !force && refuseOrcadHostDowngrade(record, candidateVersion, appVersion) + ? `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` + + `Orca (${appVersion}). Update Orca to start it.` + : null + } +} + // Why only this code: deploy never has a session count to force past, so forcing an unknown // census lands on the daemon-protocol deferral, which force cannot clear. export function isForceableOrcadDeferral(code: string): boolean { diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts index 503888c611d..1c795447140 100644 --- a/src/main/ssh/orcad-remote-context.ts +++ b/src/main/ssh/orcad-remote-context.ts @@ -1,4 +1,4 @@ -/** Everything a managed-orcad operation needs to know about one SSH host before it acts. */ +/** Everything a managed-orcad operation needs to know about one host (SSH or WSL) before it acts. */ import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import type { SshTarget } from '../../shared/ssh-types' import type { OrcadActivationRecord } from './orcad-activation-record' @@ -6,7 +6,11 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store' import { resolveOrcadRuntimeTarget } from './orcad-runtime-target' import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation' import { execOrcadRemote } from './orcad-remote-runtime-control' -import type { OrcadHostTransport } from './orcad-host-transport' +import { + isWslOrcadHostTarget, + type OrcadHostTarget, + type OrcadHostTransport +} from './orcad-host-transport' import { readRemoteHomeCommand } from './ssh-remote-commands' import { isWindowsRemoteHost, @@ -19,23 +23,24 @@ import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' import { rememberSshHostPlatform } from './ssh-host-platform-memo' -export type OrcadRemoteContext = { +/** Defaults to SSH: most lifecycle code still reads SSH-only target fields. */ +export type OrcadRemoteContext = { activationRecord: OrcadActivationRecord /** A compat runtime on hosts below the default runtime's glibc floor (design D6 rung B). */ serverTarget: NodeRuntimeTarget connection: OrcadHostTransport host: RemoteHostPlatform remoteHome: string - target: SshTarget + target: Target userDataDir: string } -export async function resolveOrcadRemoteContext( - target: SshTarget, +export async function resolveOrcadRemoteContext( + target: Target, connection: OrcadHostTransport, signal?: AbortSignal, detectedHost?: RemoteHostPlatform -): Promise { +): Promise> { const host = detectedHost ?? (await detectRemoteHostPlatform(connection, { signal })) if (!host) { throw new OrcadHostUnsupportedError('This SSH host platform is not supported by managed orcad.') @@ -49,7 +54,9 @@ export async function resolveOrcadRemoteContext( throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) } const serverTarget = await resolveOrcadRuntimeTarget({ conn: connection, host, signal }) - rememberSshHostPlatform(target.id, host, serverTarget) + if (!isWslOrcadHostTarget(target)) { + rememberSshHostPlatform(target.id, host, serverTarget) + } if (isWindowsRemoteHost(host)) { // Every Windows host op, the activation record read included, runs on the pinned node.exe. await prepareWindowsOrcadHost({ conn: connection, host, remoteHome, serverTarget, signal }) diff --git a/src/main/ssh/orcad-remote-launch-setsid.test.ts b/src/main/ssh/orcad-remote-launch-setsid.test.ts new file mode 100644 index 00000000000..6f4b6bf5cd3 --- /dev/null +++ b/src/main/ssh/orcad-remote-launch-setsid.test.ts @@ -0,0 +1,103 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + ORCAD_PID_FILENAME, + ORCAD_READINESS_FILENAME, + orcadLaunchCommand, + type OrcadLaunchSpec +} from './orcad-remote-launch' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { ORCAD_WSL_HOST_ENV } from '../ipc/wsl-guest-windows-drive-path' + +const host = getRemoteHostPlatform('linux-x64') + +function spec(remoteInstallDir: string, nodePath = '/usr/bin/node'): OrcadLaunchSpec { + return { + remoteInstallDir, + nodePath, + fullVersion: '0.2.0+bb01', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 7777, + activationRoot: '/home/u/.orca-remote/.orcad-activation-transaction' + } +} + +describe('the WSL launch (setsid)', () => { + const dirs: string[] = [] + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + // Regression: `nohup … &` is killed when its `wsl.exe --exec` exits on a distro without systemd. + it('never backgrounds under nohup, and starts the runtime in a session of its own', () => { + const command = orcadLaunchCommand( + host, + spec('/home/u/.orca-remote/orcad-0.2.0+bb01'), + 'setsid' + ) + expect(command).not.toContain('nohup') + expect(command).toContain('setsid -f "$@"') + // busybox has no `-f`; a background child of a non-interactive shell can still setsid. + expect(command).toContain('else setsid "$@" & fi') + expect(command).toContain(`${ORCAD_WSL_HOST_ENV}=1 sh -c`) + }) + + it('has the child record its own PID, since the setsid -f parent exits at once', () => { + const command = orcadLaunchCommand(host, spec('/d/orcad-1'), 'setsid') + const removeStale = command.indexOf(`rm -f '/d/orcad-1/${ORCAD_PID_FILENAME}'`) + const recordOwn = command.indexOf(`echo $$ > "$1" && shift && exec "$@"`) + expect(removeStale).toBeGreaterThan(-1) + expect(recordOwn).toBeGreaterThan(removeStale) + expect(command.trimEnd().endsWith(`cat '/d/orcad-1/${ORCAD_PID_FILENAME}'`)).toBe(true) + }) + + it('keeps the SSH launch byte-for-byte when no detach is named', () => { + const ssh = orcadLaunchCommand(host, spec('/d/orcad-1')) + expect(ssh).toBe(orcadLaunchCommand(host, spec('/d/orcad-1'), 'nohup')) + expect(ssh).toContain('exec nohup "$orcad_runtime"') + expect(ssh).not.toContain('setsid') + expect(ssh).not.toContain(ORCAD_WSL_HOST_ENV) + }) + + it.skipIf(process.platform !== 'linux')( + 'really detaches: the recorded PID is the runtime, in a new session, and writes readiness', + () => { + const dir = mkdtempSync(join(tmpdir(), 'orcad-setsid-')) + dirs.push(dir) + const runtime = join(dir, 'fake-node') + writeFileSync(runtime, `#!/bin/sh\necho '{"type":"orca_server_ready"}'\nsleep 30\n`) + chmodSync(runtime, 0o755) + const out = execFileSync('sh', ['-c', orcadLaunchCommand(host, spec(dir, runtime), 'setsid')]) + const pid = Number(out.toString().trim()) + try { + expect(Number.isSafeInteger(pid) && pid > 0).toBe(true) + expect(readFileSync(join(dir, ORCAD_PID_FILENAME), 'utf8').trim()).toBe(String(pid)) + const ps = (field: string, target: number): string => + execFileSync('ps', ['-o', `${field}=`, '-p', String(target)]) + .toString() + .trim() + expect(ps('args', pid)).toContain(join(dir, 'orcad.js')) + expect(ps('sid', pid)).toBe(String(pid)) + expect(ps('sid', pid)).not.toBe(ps('sid', process.pid)) + for (let i = 0; i < 50 && !readFileSync(join(dir, ORCAD_READINESS_FILENAME), 'utf8'); i++) { + execFileSync('sleep', ['0.05']) + } + expect(readFileSync(join(dir, ORCAD_READINESS_FILENAME), 'utf8')).toContain( + 'orca_server_ready' + ) + } finally { + try { + process.kill(pid) + } catch { + // Already gone. + } + } + } + ) +}) diff --git a/src/main/ssh/orcad-remote-launch.ts b/src/main/ssh/orcad-remote-launch.ts index 7785db0acaf..b46fd409f45 100644 --- a/src/main/ssh/orcad-remote-launch.ts +++ b/src/main/ssh/orcad-remote-launch.ts @@ -22,6 +22,7 @@ import { type RemoteHostPlatform } from './ssh-remote-platform' import { RELAY_REMOTE_DIR } from './relay-protocol' +import { ORCAD_WSL_HOST_ENV } from '../ipc/wsl-guest-windows-drive-path' import { assertPosixOrcadHost as assertPosixHost, ORCAD_LOG_FILENAME, @@ -75,6 +76,14 @@ export function orcadManagedLaunchEnv( ] } +/** + * How the candidate leaves the exec's session. `nohup … &` dies with its `wsl.exe --exec` session + * on a distro without systemd, so a WSL slot starts in a session of its own. + */ +export type OrcadLaunchDetach = 'nohup' | 'setsid' + +const ORCAD_SETSID_PID_WAIT_TRIES = 100 + /** * Launch the candidate detached and echo its PID. * @@ -82,7 +91,11 @@ export function orcadManagedLaunchEnv( * thing a future version can change. The deploy states the posture it intends rather than * inheriting whatever the installed build happens to default to. */ -export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string { +export function orcadLaunchCommand( + host: RemoteHostPlatform, + spec: OrcadLaunchSpec, + detach: OrcadLaunchDetach = 'nohup' +): string { assertPosixHost(host) const dir = shellEscape(spec.remoteInstallDir) const readiness = shellEscape( @@ -113,18 +126,50 @@ export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSp `{ chmod 600 ${pidFile} ${log} 2>/dev/null || :; } &&`, // A stop request the previous process never consumed must not stop this one. `rm -f ${shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_STOP_REQUEST_FILENAME))} &&`, + ...(detach === 'setsid' ? [`rm -f ${pidFile} &&`] : []), `ORCA_VERSION=${shellEscape(spec.fullVersion)}`, `ORCA_USER_DATA=${shellEscape(spec.userDataDir)}`, ...orcadManagedLaunchEnv(spec).map(([name, value]) => `${name}=${shellEscape(value)}`), - // Keep $! equal to the runtime PID rather than a waiting shell's PID. - // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. - `exec nohup "$orcad_runtime" ${entry}`, - `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, - `> ${readiness} 2>> ${log} < /dev/null &`, - `echo $! > ${pidFile} && cat ${pidFile}` + ...(detach === 'setsid' + ? setsidLaunchTail(entry, spec, readiness, log, pidFile) + : [ + // Keep $! equal to the runtime PID rather than a waiting shell's PID. + // Older clients identify the live PID by orcad.js; the pinned launcher loads the body in-place. + `exec nohup "$orcad_runtime" ${entry}`, + `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `> ${readiness} 2>> ${log} < /dev/null &`, + `echo $! > ${pidFile} && cat ${pidFile}` + ]) ].join(' ') } +/** + * The forked child records its own PID (`$$` survives its exec into the runtime) because the + * `setsid -f` parent exits at once. busybox `setsid` has no `-f`; backgrounding it is equivalent + * there because a background child of a non-interactive shell never leads a process group. + */ +function setsidLaunchTail( + entry: string, + spec: OrcadLaunchSpec, + readiness: string, + log: string, + pidFile: string +): string[] { + return [ + // Only a setsid (WSL host) launch marks itself; SSH keeps its byte-for-byte command. + `${ORCAD_WSL_HOST_ENV}=1`, + 'sh -c \'orcad_detach() { if setsid -f true >/dev/null 2>&1; then setsid -f "$@"; ' + + 'else setsid "$@" & fi; }; orcad_detach "$@"\' orcad-detach', + `sh -c 'echo $$ > "$1" && shift && exec "$@"' orcad-launch ${pidFile}`, + `"$orcad_runtime" ${entry}`, + `--json --bind ${shellEscape(spec.bindHost)} --port ${String(spec.port)}`, + `> ${readiness} 2>> ${log} < /dev/null &&`, + `orcad_wait=0; while [ ! -s ${pidFile} ] && [ "$orcad_wait" -lt ${ORCAD_SETSID_PID_WAIT_TRIES} ];`, + 'do sleep 0.05; orcad_wait=$((orcad_wait + 1)); done;', + `cat ${pidFile}` + ] +} + export function readOrcadReadinessCommand( host: RemoteHostPlatform, remoteInstallDir: string diff --git a/src/main/ssh/orcad-remote-runtime-control.ts b/src/main/ssh/orcad-remote-runtime-control.ts index ace8c8cec8e..2f4644d8590 100644 --- a/src/main/ssh/orcad-remote-runtime-control.ts +++ b/src/main/ssh/orcad-remote-runtime-control.ts @@ -117,7 +117,10 @@ export async function launchOrcadAndAwaitReadiness( ) ) } else { - await execOrcadRemote(target, orcadLaunchCommand(target.host, spec)) + await execOrcadRemote( + target, + orcadLaunchCommand(target.host, spec, target.conn.orcadLaunchDetach ?? 'nohup') + ) } const deadline = Date.now() + (target.readinessTimeoutMs ?? ORCAD_STARTUP_READINESS_TIMEOUT_MS) const sleep = target.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))) diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index 3f0e78621d0..706f57262b0 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -26,7 +26,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store' import { resolveOrcadRemoteContext } from './orcad-remote-context' import { deployOrcad } from './orcad-remote-deploy' import { sshOrcadHostTransport } from './orcad-ssh-host-transport' -import { refuseOrcadHostDowngrade } from './orcad-host-version-admission' import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from './orcad-installed-activation' import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' @@ -35,6 +34,7 @@ import type { SshTarget } from '../../shared/ssh-types' import { deployedOrcadTunnelChecks } from './orcad-managed-tunnel-identity' import { isForceableOrcadDeferral, + managedOrcadDeployPolicy, managedOrcadSlot, probeManagedOrcadReadiness, requireManagedOrcadInfrastructure @@ -123,22 +123,9 @@ export async function createManagedOrcadEnvironment( conn: connection, localOrcadDir, target: context.serverTarget, - // Why unknown when a version is active: only the daemon can count its sessions, and - // a deploy that guessed zero would restart over live terminals. - census: context.activationRecord.active - ? { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } - : { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, - censusRecord: context.activationRecord, + ...managedOrcadDeployPolicy(context, appVersion, args.force), force: args.force, - appVersion, - // Why: a host another desktop stopped may hold state its newer build migrated, and this - // path has no linked record to check first, so only the fenced read can refuse it. - // An explicit force runs this version anyway, as `environment update --force` does. - admitRecord: (record, candidateVersion) => - !args.force && refuseOrcadHostDowngrade(record, candidateVersion, appVersion) - ? `Orca ${record.activeAppVersion} last ran this host's server, newer than this ` + - `Orca (${appVersion}). Update Orca to start it.` - : null + appVersion }) if (deployResult.outcome === 'installed-not-activated') { return { diff --git a/src/main/wsl/wsl-distro-holder.test.ts b/src/main/wsl/wsl-distro-holder.test.ts index 88f4350094f..d76204fbd3a 100644 --- a/src/main/wsl/wsl-distro-holder.test.ts +++ b/src/main/wsl/wsl-distro-holder.test.ts @@ -152,7 +152,7 @@ describe('createWslDistroHolder', () => { host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 5_000 }) await writeRecord(record()) const holder = makeHolder() - expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'live' }]) + expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'live' }]) expect((await holder.ensureHeld('Alpine')).outcome).toBe('adopted') expect(host.spawned).toEqual([]) }) @@ -161,7 +161,7 @@ describe('createWslDistroHolder', () => { host.processes.set(77, { commandLine: HOLDER_COMMAND('Alpine'), startedAtMs: 9_999 }) await writeRecord(record()) const holder = makeHolder() - expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }]) + expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'exited' }]) expect(await recordFiles()).toEqual([]) expect(host.killed).toEqual([]) }) @@ -169,7 +169,7 @@ describe('createWslDistroHolder', () => { it('rejects a pid whose command line is not our holder for that distro', async () => { host.processes.set(77, { commandLine: HOLDER_COMMAND('Ubuntu'), startedAtMs: 5_000 }) await writeRecord(record()) - expect(await makeHolder().adoptHolders()).toEqual([{ distro: 'alpine', state: 'exited' }]) + expect(await makeHolder().adoptHolders()).toEqual([{ distro: 'Alpine', state: 'exited' }]) expect(await recordFiles()).toEqual([]) }) @@ -181,7 +181,7 @@ describe('createWslDistroHolder', () => { const adopted = await holder.adoptHolders() expect(adopted.sort((a, b) => a.distro.localeCompare(b.distro))).toEqual([ { distro: 'broken', state: 'exited' }, - { distro: 'gone', state: 'exited' } + { distro: 'Gone', state: 'exited' } ]) expect(await recordFiles()).toEqual([]) }) @@ -191,7 +191,7 @@ describe('createWslDistroHolder', () => { await writeRecord(record()) host.tableFails = true const holder = makeHolder() - expect(await holder.adoptHolders()).toEqual([{ distro: 'alpine', state: 'unverifiable' }]) + expect(await holder.adoptHolders()).toEqual([{ distro: 'Alpine', state: 'unverifiable' }]) expect(await holder.holderState('Alpine')).toBe('unverifiable') expect((await holder.ensureHeld('Alpine')).outcome).toBe('unverifiable') expect(await holder.release('Alpine')).toBe('unverifiable') diff --git a/src/main/wsl/wsl-distro-holder.ts b/src/main/wsl/wsl-distro-holder.ts index 379955dd0a7..3f47bda211e 100644 --- a/src/main/wsl/wsl-distro-holder.ts +++ b/src/main/wsl/wsl-distro-holder.ts @@ -272,10 +272,11 @@ export function createWslDistroHolder(dependencies: WslDistroHolderDependencies) const distros = await listWslDistroHolderRecordDistros(userDataPath) return Promise.all( distros.map((distro) => - serialize(distro, async () => ({ - distro, - state: (await recordedHolderState(distro)).state - })) + serialize(distro, async () => { + const { state, record } = await recordedHolderState(distro) + // The file name is the lowercase key; the record keeps the distro's own casing. + return { distro: record?.distro ?? distro, state } + }) ) ) }, diff --git a/src/main/wsl/wsl-distro-registry-id.test.ts b/src/main/wsl/wsl-distro-registry-id.test.ts new file mode 100644 index 00000000000..1d710fb7be2 --- /dev/null +++ b/src/main/wsl/wsl-distro-registry-id.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { parseWslDistroRegistrations, readWslDistroRegistryId } from './wsl-distro-registry-id' + +const LXSS = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' +const REG_OUTPUT = [ + '', + `${LXSS}\\{0283592D-BE56-40D4-B935-3DC18C3AA007}`, + ' DistributionName REG_SZ Ubuntu-24.04', + '', + `${LXSS}\\{1b2c3d4e-0000-4000-8000-000000000001}`, + ' DistributionName REG_SZ docker-desktop', + '', + // Localized footer: matched by shape, never by text. + 'Fin de la recherche : 2 correspondance(s) trouvée(s).', + '' +].join('\r\n') + +describe('the distro registration GUID', () => { + it('pairs each Lxss subkey with its DistributionName, lowercased', () => { + expect(parseWslDistroRegistrations(REG_OUTPUT)).toEqual([ + { id: '{0283592d-be56-40d4-b935-3dc18c3aa007}', distro: 'Ubuntu-24.04' }, + { id: '{1b2c3d4e-0000-4000-8000-000000000001}', distro: 'docker-desktop' } + ]) + }) + + it('reads it through one reg.exe query, matching the name case-insensitively', async () => { + const query = vi.fn(async () => ({ code: 0, stdout: REG_OUTPUT })) + await expect(readWslDistroRegistryId('ubuntu-24.04', query)).resolves.toBe( + '{0283592d-be56-40d4-b935-3dc18c3aa007}' + ) + expect(query).toHaveBeenCalledWith([ + 'query', + 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss', + '/s', + '/v', + 'DistributionName' + ]) + }) + + it('answers null for an unknown distro, a failed query or a thrown spawn', async () => { + await expect( + readWslDistroRegistryId('Debian', async () => ({ code: 0, stdout: REG_OUTPUT })) + ).resolves.toBeNull() + await expect( + readWslDistroRegistryId('Ubuntu-24.04', async () => ({ code: 1, stdout: '' })) + ).resolves.toBeNull() + await expect( + readWslDistroRegistryId('Ubuntu-24.04', async () => { + throw new Error('ENOENT') + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/wsl/wsl-distro-registry-id.ts b/src/main/wsl/wsl-distro-registry-id.ts new file mode 100644 index 00000000000..888ec8aed6e --- /dev/null +++ b/src/main/wsl/wsl-distro-registry-id.ts @@ -0,0 +1,67 @@ +/** + * A distro's registration GUID (`HKCU\...\Lxss\{guid}`): stable across a rename and changed by an + * unregister + re-import, so it tells "the same distro" from "a new one with the old name". + */ +import { runProcess } from '@orca/process-host' +import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary' + +const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss' +const REG_QUERY_TIMEOUT_MS = 5_000 +const GUID_SUBKEY = /\\(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\})\s*$/i +const DISTRIBUTION_NAME_VALUE = /^\s+DistributionName\s+REG_\w+\s+(.+?)\s*$/ + +export type WslDistroRegistration = { id: string; distro: string } + +/** Parses `reg query /s /v DistributionName`; matched by shape, since the footer is localized. */ +export function parseWslDistroRegistrations(output: string): WslDistroRegistration[] { + const registrations: WslDistroRegistration[] = [] + let currentId: string | null = null + for (const line of output.split(/\r?\n/)) { + const key = GUID_SUBKEY.exec(line) + if (key?.[1] && /^HKEY_/i.test(line)) { + currentId = key[1].toLowerCase() + continue + } + const name = DISTRIBUTION_NAME_VALUE.exec(line) + if (name?.[1] && currentId) { + registrations.push({ id: currentId, distro: name[1] }) + currentId = null + } + } + return registrations +} + +export type RegQuery = (args: string[]) => Promise<{ code: number | null; stdout: string }> + +const defaultRegQuery: RegQuery = (args) => + // One reg.exe per lookup, as `openssh-default-shell` reads the registry; never powershell.exe. + runProcess({ + program: windowsSystem32Binary('reg.exe'), + args, + timeoutMs: REG_QUERY_TIMEOUT_MS + }) + +/** The distro's registration GUID (lowercase, braced), or null when it is not registered or unreadable. */ +export async function readWslDistroRegistryId( + distro: string, + query: RegQuery = defaultRegQuery +): Promise { + if (process.platform !== 'win32' && query === defaultRegQuery) { + return null + } + let result: { code: number | null; stdout: string } + try { + result = await query(['query', LXSS_KEY, '/s', '/v', 'DistributionName']) + } catch { + return null + } + if (result.code !== 0) { + return null + } + // WSL treats distro names case-insensitively. + const wanted = distro.trim().toLowerCase() + const matches = parseWslDistroRegistrations(result.stdout).filter( + (registration) => registration.distro.toLowerCase() === wanted + ) + return matches.length === 1 ? matches[0].id : null +} diff --git a/src/main/wsl/wsl-orcad-deploy.test.ts b/src/main/wsl/wsl-orcad-deploy.test.ts new file mode 100644 index 00000000000..d4be4d91852 --- /dev/null +++ b/src/main/wsl/wsl-orcad-deploy.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import type * as ManagedRuntimeContext from '../ssh/orcad-managed-runtime-context' +import { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { WslDistroHolder } from './wsl-distro-holder' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' + +const mocks = vi.hoisted(() => ({ + steps: new Array(), + exec: vi.fn(), + resolveContext: vi.fn(), + recover: vi.fn(), + deploy: vi.fn(), + probe: vi.fn(), + prune: vi.fn(), + wake: vi.fn(), + adopt: vi.fn() +})) +vi.mock('./wsl-orcad-instance-adoption', () => ({ adoptUnrecordedWslOrcad: mocks.adopt })) + +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getVersion: () => '1.5.0', getPath: () => '/user-data' }) +})) +vi.mock('../ssh/ssh-relay-exec-command', () => ({ execCommand: mocks.exec })) +vi.mock('../ssh/orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext })) +vi.mock('../ssh/orcad-activation-recovery', () => ({ + recoverInterruptedOrcadActivation: mocks.recover +})) +vi.mock('../orcad/orcad-artifact-materializer', () => ({ + materializeOrcadArtifact: async () => '/local/orcad' +})) +vi.mock('../ssh/orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy })) +vi.mock('../ssh/orcad-managed-version-gc', () => ({ pruneManagedOrcadVersions: mocks.prune })) +vi.mock('../ssh/orcad-managed-wake', () => ({ wakeStoppedManagedOrcad: mocks.wake })) +vi.mock('../ssh/orcad-managed-runtime-context', async (importOriginal) => ({ + ...(await importOriginal()), + probeManagedOrcadReadiness: mocks.probe +})) + +import { deployAndLaunchWslOrcad, isWsl1KernelRelease } from './wsl-orcad-deploy' + +const host = getRemoteHostPlatform('linux-x64') +const READINESS = { + runtimeId: 'r1', + boundEndpoint: 'ws://127.0.0.1:6790', + advertisedEndpoint: null, + managedWslCliReconciliation: 'settled' as const, + pairing: { available: false as const, reason: 'disabled_by_operator', guidance: '' } +} + +function holder(): WslDistroHolder { + return { + ensureHeld: vi.fn(async (distro: string) => { + mocks.steps.push(`hold:${distro}`) + return { + outcome: 'spawned' as const, + record: { version: 1 as const, distro, pid: 9, creationTimeMs: 1, startedAtMs: 1 } + } + }), + adoptHolders: vi.fn(), + release: vi.fn(), + holderState: vi.fn() + } +} + +let seenTarget: WslOrcadHostTarget | null = null +function transport(target: WslOrcadHostTarget): OrcadHostTransport { + seenTarget = target + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module. + return { getTarget: () => target } as unknown as OrcadHostTransport +} + +function launch(kernel = '5.15.167.4-microsoft-standard-WSL2', options = {}) { + mocks.exec.mockImplementation(async (_conn: unknown, command: string) => { + if (command === 'uname -r') { + mocks.steps.push('uname') + return `${kernel}\n` + } + return '4242\n' + }) + return deployAndLaunchWslOrcad('Ubuntu-24.04', { + holder: holder(), + createTransport: transport, + readRegistryId: async () => '{0283592d-be56-40d4-b935-3dc18c3aa007}', + ...options + }) +} + +describe('deployAndLaunchWslOrcad', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.steps.length = 0 + seenTarget = null + mocks.resolveContext.mockImplementation(async (target: WslOrcadHostTarget, connection) => { + mocks.steps.push('context') + return { + activationRecord: emptyOrcadActivationRecord(), + serverTarget: 'linux-x64-glibc', + connection, + host, + remoteHome: '/home/u', + target, + userDataDir: '/home/u/.orca' + } + }) + mocks.recover.mockResolvedValue({ outcome: 'none' }) + mocks.adopt.mockImplementation(async () => { + mocks.steps.push('adopt-check') + return { outcome: 'none' } + }) + mocks.deploy.mockImplementation(async () => { + mocks.steps.push('deploy+launch') + return { outcome: 'installed-and-activated', fullVersion: '1.5.0+ab', verdict: {} } + }) + mocks.probe.mockResolvedValue(READINESS) + }) + + it('holds the distro before it deploys and launches, and reports what C5 dials', async () => { + await expect(launch()).resolves.toEqual({ + outcome: 'ready', + distro: 'Ubuntu-24.04', + distroId: '{0283592d-be56-40d4-b935-3dc18c3aa007}', + port: 6790, + fullVersion: '1.5.0+ab', + pid: 4242, + readiness: READINESS, + holder: 'spawned' + }) + expect(mocks.steps).toEqual([ + 'uname', + 'hold:Ubuntu-24.04', + 'context', + 'adopt-check', + 'deploy+launch' + ]) + expect(seenTarget).toEqual({ + kind: 'wsl', + id: 'wsl:ubuntu-24.04', + distro: 'Ubuntu-24.04', + label: 'Ubuntu-24.04' + }) + expect(mocks.exec).toHaveBeenLastCalledWith( + expect.anything(), + "cat '/home/u/.orca-remote/orcad-1.5.0+ab/.orcad-pid' 2>/dev/null || true", + expect.anything() + ) + }) + + it('applies the SSH census and downgrade admission to the first deploy', async () => { + await launch() + const options = mocks.deploy.mock.calls[0][0] + expect(options).toMatchObject({ + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, + appVersion: '1.5.0', + localOrcadDir: '/local/orcad', + target: 'linux-x64-glibc' + }) + const newer = { ...emptyOrcadActivationRecord(), active: '2.0.0+cd', activeAppVersion: '2.0.0' } + expect(options.admitRecord(newer, '1.5.0+ab')).toBe( + "Orca 2.0.0 last ran this host's server, newer than this Orca (1.5.0). Update Orca to start it." + ) + }) + + it('refuses a WSL 1 distro before holding it or touching its files', async () => { + await expect(launch('4.4.0-19041-Microsoft')).rejects.toMatchObject({ + name: 'OrcadHostUnsupportedError' + }) + expect(mocks.steps).toEqual(['uname']) + }) + + it('surfaces an unsupported libc as the SSH host refusal does', async () => { + const refusal = Object.assign( + new Error('No Orca runtime supports linux-x64-glibc with glibc 2.17.'), + { + name: 'OrcadHostUnsupportedError' + } + ) + mocks.resolveContext.mockRejectedValueOnce(refusal) + await expect(launch()).rejects.toBe(refusal) + expect(mocks.deploy).not.toHaveBeenCalled() + }) + + it('starts an already-active slot the distro stop ended, instead of reporting it ready', async () => { + mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'started', readiness: READINESS }) + await expect(launch()).resolves.toMatchObject({ outcome: 'ready', port: 6790 }) + expect(mocks.wake).toHaveBeenCalledTimes(1) + }) + + // Real host (awin): a Remove that failed partway left orcad serving; the record named nothing. + it('deploys over an adopted unrecorded orcad under the SSH terminal gate, never a fresh launch', async () => { + const adoptedRecord = { + ...emptyOrcadActivationRecord(), + active: '0.1.0+aa', + activatedAt: '2026-10-12T01:00:00.000Z' + } + mocks.adopt.mockResolvedValueOnce({ + outcome: 'adopted', + version: '0.1.0+aa', + pid: 500, + record: adoptedRecord + }) + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-not-activated', + fullVersion: '1.5.0+ab', + code: 'orcad_update_terminal_census_unavailable', + reason: 'unknown census' + }) + await expect(launch()).resolves.toMatchObject({ outcome: 'deferred', forceable: false }) + expect(mocks.deploy.mock.calls[0][0]).toMatchObject({ + censusRecord: adoptedRecord, + census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: null } + }) + }) + + it('reports the adopted instance once the same version is serving', async () => { + mocks.adopt.mockResolvedValueOnce({ + outcome: 'adopted', + version: '1.5.0+ab', + pid: 500, + record: { ...emptyOrcadActivationRecord(), active: '1.5.0+ab' } + }) + mocks.deploy.mockResolvedValueOnce({ outcome: 'already-active', fullVersion: '1.5.0+ab' }) + mocks.wake.mockResolvedValueOnce({ outcome: 'serving' }) + await expect(launch()).resolves.toMatchObject({ + outcome: 'ready', + adoptedInstance: { version: '1.5.0+ab', pid: 500 } + }) + }) + + it('refuses to deploy or launch when an unrecorded orcad cannot be proven', async () => { + mocks.adopt.mockRejectedValueOnce( + Object.assign(new Error('cannot be identified'), { name: 'WslOrcadUnrecordedInstanceError' }) + ) + await expect(launch()).rejects.toMatchObject({ name: 'WslOrcadUnrecordedInstanceError' }) + expect(mocks.deploy).not.toHaveBeenCalled() + expect(mocks.wake).not.toHaveBeenCalled() + }) + + it('returns a deferral rather than launching over live terminals', async () => { + mocks.deploy.mockResolvedValueOnce({ + outcome: 'installed-not-activated', + fullVersion: '1.6.0+ef', + code: 'orcad_update_terminals_running', + reason: 'terminals are running' + }) + await expect(launch()).resolves.toEqual({ + outcome: 'deferred', + distro: 'Ubuntu-24.04', + candidateVersion: '1.6.0+ef', + code: 'orcad_update_terminals_running', + reason: 'terminals are running', + forceable: true + }) + expect(mocks.probe).not.toHaveBeenCalled() + }) +}) + +describe('isWsl1KernelRelease', () => { + it('tells the WSL 1 syscall layer from a WSL 2 kernel, stock or custom', () => { + expect(isWsl1KernelRelease('4.4.0-19041-Microsoft')).toBe(true) + expect(isWsl1KernelRelease('4.4.0-22621-Microsoft\n')).toBe(true) + expect(isWsl1KernelRelease('5.15.167.4-microsoft-standard-WSL2')).toBe(false) + expect(isWsl1KernelRelease('6.6.36.6-microsoft-standard-WSL2+')).toBe(false) + expect(isWsl1KernelRelease('6.10.0-custom')).toBe(false) + }) +}) diff --git a/src/main/wsl/wsl-orcad-deploy.ts b/src/main/wsl/wsl-orcad-deploy.ts new file mode 100644 index 00000000000..474c5a60fb6 --- /dev/null +++ b/src/main/wsl/wsl-orcad-deploy.ts @@ -0,0 +1,193 @@ +/** + * Deploys and starts orcad inside one WSL distro (design D1, D2b, D8), reusing the SSH host's + * install, activation fence/journal and version admission over the `wsl.exe` transport. + * No pairing here: the stdio bridge (C5) connects to the loopback port this reports. + */ +import { getAppEnvironment } from '../../shared/app-environment' +import { ORCAD_MANAGED_REMOTE_PORT } from '../../shared/orcad-managed-runtime' +import type { ServeReadiness } from '../server/serve-readiness' +import { recoverInterruptedOrcadActivation } from '../ssh/orcad-activation-recovery' +import { readOrcadActivationRecord } from '../ssh/orcad-activation-record-store' +import { materializeOrcadArtifact } from '../orcad/orcad-artifact-materializer' +import { OrcadHostUnsupportedError } from '../orcad/orcad-host-unavailable' +import type { OrcadHostTransport } from '../ssh/orcad-host-transport' +import { ORCAD_ACTIVATION_POLICY_REFUSED_CODE } from '../ssh/orcad-installed-activation' +import { orcadBoundPort } from '../ssh/orcad-managed-bound-port' +import { + isForceableOrcadDeferral, + managedOrcadDeployPolicy, + managedOrcadInstallDir, + managedOrcadSlot, + probeManagedOrcadReadiness +} from '../ssh/orcad-managed-runtime-context' +import { pruneManagedOrcadVersions } from '../ssh/orcad-managed-version-gc' +import { wakeStoppedManagedOrcad } from '../ssh/orcad-managed-wake' +import { resolveOrcadRemoteContext } from '../ssh/orcad-remote-context' +import { deployOrcad } from '../ssh/orcad-remote-deploy' +import { ORCAD_PID_FILENAME } from '../ssh/orcad-remote-launch' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { execCommand } from '../ssh/ssh-relay-exec-command' +import { joinRemotePath } from '../ssh/ssh-remote-platform' +import { createWslDistroHolder, type WslDistroHolder } from './wsl-distro-holder' +import { wslDistroHolderKey } from './wsl-distro-holder-record' +import { readWslDistroRegistryId } from './wsl-distro-registry-id' +import type { WslOrcadHostTarget } from './wsl-orcad-host-target' +import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' +import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption' + +export type WslOrcadLaunch = + | { + outcome: 'ready' + distro: string + /** The Lxss registration GUID for the deployment record's `distroId`; null if unreadable. */ + distroId: string | null + /** The loopback port orcad bound inside the distro (another one when the preferred was taken). */ + port: number + fullVersion: string + pid: number | null + readiness: ServeReadiness + holder: 'adopted' | 'spawned' | 'unverifiable' + /** An orcad the record did not name (e.g. left by a failed Remove), now recorded. */ + adoptedInstance?: { version: string; pid: number } + } + | { + outcome: 'deferred' + distro: string + candidateVersion: string + code: string + reason: string + forceable: boolean + } + +export type WslOrcadDeployOptions = { + /** The WSL environment's id; keys host-scoped state (wake dedupe). Defaults per distro. */ + environmentId?: string + label?: string + force?: boolean + signal?: AbortSignal + /** Defaults to one created over `userData`; the app should pass its shared instance. */ + holder?: WslDistroHolder + createTransport?: (target: WslOrcadHostTarget) => OrcadHostTransport + readRegistryId?: (distro: string) => Promise +} + +/** 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()) +} + +export async function deployAndLaunchWslOrcad( + distro: string, + options: WslOrcadDeployOptions = {} +): Promise { + const name = distro.trim() + if (!name) { + throw new Error('A WSL distro name is required') + } + const target: WslOrcadHostTarget = { + kind: 'wsl', + id: options.environmentId ?? `wsl:${wslDistroHolderKey(name)}`, + distro: name, + label: options.label ?? name + } + const conn = (options.createTransport ?? defaultTransport)(target) + const { signal } = options + const kernel = await execCommand(conn, 'uname -r', { signal }) + if (isWsl1KernelRelease(kernel)) { + throw new OrcadHostUnsupportedError( + `The WSL 1 distro ${name} is not supported by managed orcad. ` + + `Convert it with: wsl --set-version ${name} 2` + ) + } + // Before the launch: without a holder WSL stops an idle distro and orcad with it. + const holder = options.holder ?? createWslDistroHolder({ userDataPath: userDataPath() }) + const held = await holder.ensureHeld(name) + // Read alongside the deploy; a failed read only leaves the record without a GUID. + const distroId = (options.readRegistryId ?? readWslDistroRegistryId)(name).catch(() => null) + let context = await resolveOrcadRemoteContext(target, conn, signal) + const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, signal) + const recovery = await recoverInterruptedOrcadActivation(slot) + if (recovery.outcome === 'pending' || recovery.outcome === 'refused') { + throw new Error(recovery.reason) + } + if (recovery.outcome === 'recovered') { + context = { ...context, activationRecord: await readOrcadActivationRecord(slot) } + } + // Before deploy or wake: an orcad the record does not name would keep the instance lock. + const adoption = await adoptUnrecordedWslOrcad(slot, context.activationRecord) + if (adoption.outcome === 'adopted') { + context = { ...context, activationRecord: adoption.record } + } + const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { signal }) + const appVersion = getAppEnvironment().getVersion() + const deployed = await deployOrcad({ + ...slot, + conn, + localOrcadDir, + target: context.serverTarget, + ...managedOrcadDeployPolicy(context, appVersion, options.force), + force: options.force, + appVersion + }) + if (deployed.outcome === 'installed-not-activated') { + return { + outcome: 'deferred', + distro: name, + candidateVersion: deployed.fullVersion, + code: deployed.code, + reason: deployed.reason, + forceable: + isForceableOrcadDeferral(deployed.code) || + deployed.code === ORCAD_ACTIVATION_POLICY_REFUSED_CODE + } + } + if (deployed.outcome === 'already-active') { + // A stopped distro ended the active slot with it; start it again only on proven exit. + const wake = await wakeStoppedManagedOrcad(slot) + if (wake.outcome === 'recovery-refused') { + throw new Error(wake.reason) + } + } + const readiness = await probeManagedOrcadReadiness( + context, + localOrcadDir, + deployed.fullVersion, + signal + ) + await pruneManagedOrcadVersions({ + slot, + serverTarget: context.serverTarget, + activeVersion: deployed.fullVersion, + readiness + }) + const pidFile = joinRemotePath( + context.host, + managedOrcadInstallDir(context, deployed.fullVersion), + ORCAD_PID_FILENAME + ) + const pid = Number.parseInt( + await execCommand(conn, `cat ${shellEscape(pidFile)} 2>/dev/null || true`, { signal }), + 10 + ) + return { + outcome: 'ready', + distro: name, + distroId: await distroId, + port: orcadBoundPort(readiness) ?? ORCAD_MANAGED_REMOTE_PORT, + fullVersion: deployed.fullVersion, + pid: Number.isSafeInteger(pid) && pid > 0 ? pid : null, + readiness, + holder: held.outcome, + ...(adoption.outcome === 'adopted' + ? { adoptedInstance: { version: adoption.version, pid: adoption.pid } } + : {}) + } +} + +function defaultTransport(target: WslOrcadHostTarget): OrcadHostTransport { + return createWslOrcadHostTransport({ target }) +} + +function userDataPath(): string { + return getAppEnvironment().getPath('userData') +} diff --git a/src/main/wsl/wsl-orcad-host-transport.test.ts b/src/main/wsl/wsl-orcad-host-transport.test.ts index 2a3ff6eef89..c07dfe3b13b 100644 --- a/src/main/wsl/wsl-orcad-host-transport.test.ts +++ b/src/main/wsl/wsl-orcad-host-transport.test.ts @@ -172,4 +172,9 @@ describe('createWslOrcadHostTransport', () => { expect(transport.usesSystemSshTransport()).toBe(false) expect(transport.getConnectGeneration()).toBe(transport.getConnectGeneration()) }) + + // Regression: `nohup … &` dies with its `wsl.exe --exec` on a distro without systemd. + it('asks orcad launches to start in their own session', () => { + expect(transportWith([]).transport.orcadLaunchDetach).toBe('setsid') + }) }) diff --git a/src/main/wsl/wsl-orcad-host-transport.ts b/src/main/wsl/wsl-orcad-host-transport.ts index 7f8e59c6ede..0d24b820ec2 100644 --- a/src/main/wsl/wsl-orcad-host-transport.ts +++ b/src/main/wsl/wsl-orcad-host-transport.ts @@ -21,6 +21,11 @@ type WslOrcadHostTransportOptions = { target: WslOrcadHostTarget /** Test seam; production spawns through process-host. */ spawn?: PipedProcessSpawner + /** + * Tar over stdin by default: on win-lowspec it beat the guest's `/mnt` copy (0.97 s vs 1.6 s for + * 20 MB). `guest-mount` copies from `/mnt/` when the guest can see the source. + */ + uploadSource?: 'tar' | 'guest-mount' } /** @@ -63,6 +68,8 @@ export function createWslOrcadHostTransport( } return channel }, + // MEASURED: `nohup … &` dies when its `wsl.exe --exec` exits on a distro without systemd. + orcadLaunchDetach: 'setsid', usesSystemSshTransport: () => false, // One transport, one wsl.exe lane: nothing underneath is ever replaced. getConnectGeneration: () => 0, @@ -73,7 +80,7 @@ export function createWslOrcadHostTransport( uploadDirectoryIntoWsl( transport, localDir, - wslGuestSourcePath(localDir, target.distro), + options.uploadSource === 'guest-mount' ? wslGuestSourcePath(localDir, target.distro) : null, remoteDir, { signal: fileOptions?.signal } ) diff --git a/src/main/wsl/wsl-orcad-instance-adoption.test.ts b/src/main/wsl/wsl-orcad-instance-adoption.test.ts new file mode 100644 index 00000000000..253b20e0a06 --- /dev/null +++ b/src/main/wsl/wsl-orcad-instance-adoption.test.ts @@ -0,0 +1,260 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + emptyOrcadActivationRecord, + type OrcadActivationRecord +} from '../ssh/orcad-activation-record' +import type { OrcadSlotOptions } from '../ssh/orcad-recovery-slot' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +type FakeHost = { + lock: string | null + alive: Set + pidFiles: Map + record: unknown + transaction: unknown + stopTarget: unknown + readinessBuildHash: string + slotBuildHash: string + commands: string[] + writes: unknown[] + fenced: boolean +} + +const host = vi.hoisted((): FakeHost => ({ + lock: null, + alive: new Set(), + pidFiles: new Map(), + record: null, + transaction: null, + stopTarget: null, + readinessBuildHash: 'abcdefabcdefabcd', + slotBuildHash: 'abcdefabcdefabcd', + commands: [], + writes: [], + fenced: false +})) + +vi.mock('../ssh/orcad-remote-record-file', () => ({ + readBoundedOrcadRemoteRecord: async () => + host.lock === null ? { state: 'absent' } : { state: 'present', raw: host.lock } +})) +vi.mock('../ssh/orcad-remote-runtime-control', () => ({ + execOrcadRemote: async (_slot: unknown, command: string) => { + host.commands.push(command) + const kill = /kill -0 (\d+)/.exec(command) + if (kill) { + return host.alive.has(Number(kill[1])) ? 'LIVE\n' : 'DEAD\n' + } + const cat = /^cat '([^']+)'/.exec(command) + if (cat) { + return `${host.pidFiles.get(cat[1]) ?? ''}\n` + } + if (command.startsWith('head -c')) { + return `${JSON.stringify({ + type: 'orca_server_ready', + runtimeId: 'r1', + health: { buildHash: host.readinessBuildHash, buildVersion: '0.1.0' } + })}\n` + } + return '' + } +})) +vi.mock('../ssh/orcad-managed-remote-stop', () => ({ + readRemoteOrcadManagedStopTarget: async () => host.stopTarget +})) +vi.mock('../ssh/orcad-recovery-slot', () => ({ + orcadSlotDir: (_slot: unknown, version: string) => `/home/u/.orca-remote/orcad-${version}`, + resolveOrcadSlotIdentity: async (_slot: unknown, version: string) => ({ + version, + remoteDir: `/home/u/.orca-remote/orcad-${version}`, + buildHash: host.slotBuildHash + }) +})) +vi.mock('../ssh/orcad-activation-gate', () => ({ + evaluateOrcadActivation: ( + readiness: { health?: { buildHash?: string } } | null, + expected: { buildHash: string } + ) => + readiness?.health?.buildHash === expected.buildHash + ? { decision: 'accept' } + : { decision: 'reject', reason: 'its build does not match the installed slot.' } +})) +vi.mock('../ssh/orcad-activation-record-store', () => ({ + readOrcadActivationRecord: async () => host.record, + writeOrcadActivationRecord: async (_slot: unknown, record: unknown) => { + host.writes.push(record) + } +})) +vi.mock('../ssh/orcad-activation-transaction-store', () => ({ + readOrcadActivationTransaction: async () => host.transaction +})) +vi.mock('../ssh/orcad-activation-lock', () => ({ + withOrcadActivationLock: async ( + _slot: unknown, + run: () => Promise, + held: () => unknown + ) => { + if (host.fenced) { + return held() + } + return run() + } +})) + +import { adoptUnrecordedWslOrcad } from './wsl-orcad-instance-adoption' + +const OLD = '0.1.0+aaaaaaaaaaaa' +const NEW = '0.2.0+bbbbbbbbbbbb' +const slot: OrcadSlotOptions = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every host call is mocked at its module. + conn: {} as OrcadSlotOptions['conn'], + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/u', + nodePath: '', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 6768 +} +const NOW = new Date('2026-10-12T01:00:00.000Z') + +function lockFor(pid: number, version: string): string { + return JSON.stringify({ + pid, + startedAtMs: 1, + identity: '1000', + version, + acquiredAt: '2026-10-11T00:00:00.000Z', + nonce: 'n1', + role: 'orcad' + }) +} + +function stopTargetFor(pid: number, version: string) { + return { + state: 'ready', + context: { + version, + runtimeId: 'r1', + instance: { pid, startedAtMs: 1, nonce: 'n1', lockPath: '/home/u/.orca/orcad.lock' } + } + } +} + +function record(overrides: Partial = {}): OrcadActivationRecord { + return { ...emptyOrcadActivationRecord(), ...overrides } +} + +function adopt(current: OrcadActivationRecord) { + host.record = current + return adoptUnrecordedWslOrcad(slot, current, () => NOW) +} + +describe('adopting an orcad the activation record does not name', () => { + beforeEach(() => { + host.lock = null + host.alive.clear() + host.pidFiles.clear() + host.transaction = null + host.stopTarget = null + host.readinessBuildHash = 'abcdefabcdefabcd' + host.slotBuildHash = 'abcdefabcdefabcd' + host.commands.length = 0 + host.writes.length = 0 + host.fenced = false + }) + + it('adopts a stale orcad a removed registration left running, never stopping it', async () => { + host.lock = lockFor(500, OLD) + host.alive.add(500) + host.stopTarget = stopTargetFor(500, OLD) + // The failed Remove left nothing active in the record. + const result = await adopt(record({ previous: OLD })) + expect(result).toMatchObject({ outcome: 'adopted', version: OLD, pid: 500 }) + expect(host.writes).toEqual([ + record({ active: OLD, previous: OLD, activatedAt: NOW.toISOString(), snapshot: null }) + ]) + expect(host.commands).toContain( + `(umask 077 && printf '%s\\n' 500 > '/home/u/.orca-remote/orcad-${OLD}/.orcad-pid')` + ) + expect(host.commands.some((command) => /\bkill\b(?! -0)/.test(command))).toBe(false) + }) + + it('records an orcad from a different version as active, keeping the recorded one as previous', async () => { + host.lock = lockFor(600, OLD) + host.alive.add(600) + host.stopTarget = stopTargetFor(600, OLD) + const result = await adopt( + record({ active: NEW, activeAppVersion: '1.5.0', activatedAt: '2026-10-10T00:00:00.000Z' }) + ) + expect(result).toMatchObject({ outcome: 'adopted', version: OLD, pid: 600 }) + expect(host.writes).toEqual([ + record({ + active: OLD, + previous: NEW, + previousAppVersion: '1.5.0', + activatedAt: NOW.toISOString(), + snapshot: null + }) + ]) + }) + + it('repoints only the pid file when the recorded slot is serving under another pid', async () => { + host.lock = lockFor(700, OLD) + host.alive.add(700) + host.pidFiles.set(`/home/u/.orca-remote/orcad-${OLD}/.orcad-pid`, 699) + host.stopTarget = stopTargetFor(700, OLD) + const current = record({ active: OLD }) + await expect(adopt(current)).resolves.toMatchObject({ outcome: 'adopted', record: current }) + expect(host.writes).toEqual([]) + expect(host.commands.some((command) => command.includes("printf '%s\\n' 700"))).toBe(true) + }) + + it('leaves the recorded instance, a dead lock holder and a desktop lock alone', async () => { + host.lock = lockFor(800, OLD) + host.alive.add(800) + host.pidFiles.set(`/home/u/.orca-remote/orcad-${OLD}/.orcad-pid`, 800) + await expect(adopt(record({ active: OLD }))).resolves.toEqual({ outcome: 'none' }) + host.alive.clear() + await expect(adopt(record())).resolves.toEqual({ outcome: 'none' }) + host.lock = JSON.stringify({ ...JSON.parse(lockFor(800, OLD)), role: 'desktop' }) + host.alive.add(800) + await expect(adopt(record())).resolves.toEqual({ outcome: 'none' }) + expect(host.writes).toEqual([]) + }) + + it('refuses, writing nothing, when the running orcad cannot be proven to be its slot', async () => { + host.lock = lockFor(900, OLD) + host.alive.add(900) + host.stopTarget = { + state: 'refused', + verdict: 'unverifiable', + code: 'orcad_managed_stop_instance_unverifiable', + reason: 'The instance lock does not name the orcad that published readiness.' + } + await expect(adopt(record())).rejects.toMatchObject({ + name: 'WslOrcadUnrecordedInstanceError', + message: expect.stringContaining('cannot be identified') + }) + host.stopTarget = stopTargetFor(900, OLD) + host.readinessBuildHash = '0000000000000000' + await expect(adopt(record())).rejects.toThrow(/does not match the installed slot/) + expect(host.writes).toEqual([]) + }) + + it('refuses under a busy fence, a pending journal or a record that changed', async () => { + host.lock = lockFor(910, OLD) + host.alive.add(910) + host.stopTarget = stopTargetFor(910, OLD) + host.fenced = true + await expect(adopt(record())).rejects.toThrow(/Another Orca is changing/) + host.fenced = false + host.transaction = { transactionId: 't1' } + await expect(adopt(record())).rejects.toThrow(/interrupted activation is pending/) + host.transaction = null + host.record = record({ active: NEW }) + await expect(adoptUnrecordedWslOrcad(slot, record(), () => NOW)).rejects.toThrow( + /record changed/ + ) + expect(host.writes).toEqual([]) + }) +}) diff --git a/src/main/wsl/wsl-orcad-instance-adoption.ts b/src/main/wsl/wsl-orcad-instance-adoption.ts new file mode 100644 index 00000000000..4f21d345a6e --- /dev/null +++ b/src/main/wsl/wsl-orcad-instance-adoption.ts @@ -0,0 +1,195 @@ +/** + * An orcad still serving in a distro that its activation record does not name (a Remove that + * failed partway, say) is adopted, never killed: only it holds the data root's instance lock, so + * launching the recorded slot would fail, and stopping it would need a terminal census C4 has no + * channel to take. Adoption is proven first: the instance lock, the slot's readiness and the slot's + * installed build must all name the same process. Then the record and pid file are rewritten under + * the activation fence. A different version is then handled by the usual update gate. + */ +import { ORCAD_LOCK_FILE_NAME, parseOrcadInstanceLockRecord } from '../orcad/orcad-instance-lock' +import type { OrcadActivationRecord } from '../ssh/orcad-activation-record' +import { + readOrcadActivationRecord, + writeOrcadActivationRecord +} from '../ssh/orcad-activation-record-store' +import { evaluateOrcadActivation } from '../ssh/orcad-activation-gate' +import { withOrcadActivationLock } from '../ssh/orcad-activation-lock' +import { sameOrcadActivationRecord } from '../ssh/orcad-activation-transaction' +import { readOrcadActivationTransaction } from '../ssh/orcad-activation-transaction-store' +import { readRemoteOrcadManagedStopTarget } from '../ssh/orcad-managed-remote-stop' +import { + orcadSlotDir, + resolveOrcadSlotIdentity, + type OrcadSlotOptions +} from '../ssh/orcad-recovery-slot' +import { ORCAD_PID_FILENAME } from '../ssh/orcad-remote-launch' +import { + parseOrcadReadinessWaitOutput, + readOrcadReadinessNowCommand +} from '../ssh/orcad-remote-readiness-wait' +import { readBoundedOrcadRemoteRecord } from '../ssh/orcad-remote-record-file' +import { execOrcadRemote } from '../ssh/orcad-remote-runtime-control' +import { + ORCAD_INSTALL_MODEL, + remoteInstallDirName, + remoteInstallVersionDirRegex +} from '../ssh/remote-install-model' +import { shellEscape } from '../ssh/ssh-connection-utils' +import { joinRemotePath } from '../ssh/ssh-remote-platform' + +const LOCK_RECORD_MAX_BYTES = 64 * 1024 +const UNRECORDED_ORCAD_CODE = 'wsl_orcad_unrecorded_instance_unverifiable' + +export type WslOrcadAdoption = + | { outcome: 'none' } + | { outcome: 'adopted'; version: string; pid: number; record: OrcadActivationRecord } + +export class WslOrcadUnrecordedInstanceError extends Error { + readonly code = UNRECORDED_ORCAD_CODE + constructor(message: string) { + super(message) + this.name = 'WslOrcadUnrecordedInstanceError' + } +} + +type LockHolder = { pid: number; version: string } + +async function readLiveLockHolder(slot: OrcadSlotOptions): Promise { + const lockPath = joinRemotePath(slot.host, slot.userDataDir, ORCAD_LOCK_FILE_NAME) + const read = await readBoundedOrcadRemoteRecord(slot, lockPath, LOCK_RECORD_MAX_BYTES) + const lock = read.state === 'present' ? parseOrcadInstanceLockRecord(read.raw) : null + // A desktop holder is not an orcad; an absent or garbled lock names nobody to adopt. + if (!lock || lock.role === 'desktop') { + return null + } + const alive = await execOrcadRemote( + slot, + `if kill -0 ${String(lock.pid)} 2>/dev/null; then echo LIVE; else echo DEAD; fi` + ) + return alive.trim().split('\n').pop() === 'LIVE' ? { pid: lock.pid, version: lock.version } : null +} + +async function readSlotPid(slot: OrcadSlotOptions, version: string): Promise { + const pidFile = joinRemotePath(slot.host, orcadSlotDir(slot, version), ORCAD_PID_FILENAME) + const raw = await execOrcadRemote(slot, `cat ${shellEscape(pidFile)} 2>/dev/null || true`) + const pid = Number.parseInt(raw.trim(), 10) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + +/** The holder's slot must publish readiness for this pid, from the build installed there. */ +async function proveHolderServesItsSlot(slot: OrcadSlotOptions, holder: LockHolder): Promise { + const label = `An orcad (pid ${holder.pid}, ${holder.version}) is running in this distro but is not the recorded one` + const dirName = remoteInstallDirName(ORCAD_INSTALL_MODEL, holder.version) + if (!remoteInstallVersionDirRegex(ORCAD_INSTALL_MODEL).test(dirName)) { + throw new WslOrcadUnrecordedInstanceError(`${label}, and its version is not a slot name.`) + } + const target = await readRemoteOrcadManagedStopTarget(slot, holder.version) + if (target.state !== 'ready' || target.context.instance.pid !== holder.pid) { + throw new WslOrcadUnrecordedInstanceError( + `${label}, and it cannot be identified: ${target.state === 'refused' ? target.reason : 'its readiness names another process.'}` + ) + } + const identity = await resolveOrcadSlotIdentity(slot, holder.version) + const readiness = parseOrcadReadinessWaitOutput( + slot.host, + await execOrcadRemote(slot, readOrcadReadinessNowCommand(slot.host, identity.remoteDir)) + ) + const verdict = evaluateOrcadActivation( + readiness.state === 'ready' ? readiness.readiness : null, + { + buildHash: identity.buildHash, + fullVersion: identity.version + } + ) + if (verdict.decision === 'reject') { + throw new WslOrcadUnrecordedInstanceError(`${label}, and ${verdict.reason}`) + } +} + +function adoptedRecord( + record: OrcadActivationRecord, + version: string, + now: Date +): OrcadActivationRecord { + if (record.active === version) { + return record + } + const { activeAppVersion, previousAppVersion: _previousAppVersion, ...rest } = record + return { + ...rest, + active: version, + previous: record.active ?? record.previous, + ...(record.active && activeAppVersion ? { previousAppVersion: activeAppVersion } : {}), + activatedAt: now.toISOString(), + // Its prelaunch snapshot belonged to another activation; rollback must not restore it here. + snapshot: null + } +} + +/** + * Adopts the orcad holding the instance lock when the record or its slot's pid file does not + * name it. Anything unprovable refuses: a launch would only lose the instance lock to it. + */ +export async function adoptUnrecordedWslOrcad( + slot: OrcadSlotOptions, + record: OrcadActivationRecord, + now: () => Date = () => new Date() +): Promise { + const holder = await readLiveLockHolder(slot) + if (!holder) { + return { outcome: 'none' } + } + if ( + record.active === holder.version && + (await readSlotPid(slot, holder.version)) === holder.pid + ) { + return { outcome: 'none' } + } + await proveHolderServesItsSlot(slot, holder) + return withOrcadActivationLock( + slot, + async () => { + const current = await readOrcadActivationRecord(slot) + if (!sameOrcadActivationRecord(current, record)) { + throw new WslOrcadUnrecordedInstanceError( + 'The activation record changed while an unrecorded orcad was being adopted. Retry.' + ) + } + if (await readOrcadActivationTransaction(slot)) { + throw new WslOrcadUnrecordedInstanceError( + 'An interrupted activation is pending, so an unrecorded orcad cannot be adopted yet.' + ) + } + const again = await readLiveLockHolder(slot) + if (again?.pid !== holder.pid || again.version !== holder.version) { + throw new WslOrcadUnrecordedInstanceError( + 'The unrecorded orcad changed while it was being adopted. Retry.' + ) + } + const pidFile = joinRemotePath( + slot.host, + orcadSlotDir(slot, holder.version), + ORCAD_PID_FILENAME + ) + await execOrcadRemote( + slot, + `(umask 077 && printf '%s\\n' ${String(holder.pid)} > ${shellEscape(pidFile)})` + ) + const next = adoptedRecord(current, holder.version, now()) + if (next !== current) { + await writeOrcadActivationRecord(slot, next) + } + return { + outcome: 'adopted' as const, + version: holder.version, + pid: holder.pid, + record: next + } + }, + () => { + throw new WslOrcadUnrecordedInstanceError( + 'Another Orca is changing this distro’s server, so an unrecorded orcad cannot be adopted now.' + ) + } + ) +} diff --git a/src/shared/agent-cli-install-dir-fallback.test.ts b/src/shared/agent-cli-install-dir-fallback.test.ts index 793c0861453..b741b052b33 100644 --- a/src/shared/agent-cli-install-dir-fallback.test.ts +++ b/src/shared/agent-cli-install-dir-fallback.test.ts @@ -235,6 +235,19 @@ describe('agent CLI install-dir fallback', () => { } ) + // Real host (WSL 2.7.10): the guest's ambient PATH reached a /mnt/c npm shim after the + // filtered walk had already rejected it, reporting a Windows codex as installed. + it.skipIf(process.platform === 'win32')( + 'searches the PATH detection searched, not the ambient one', + () => { + const shimDir = '/mnt/c/Users/u/AppData/Roaming/npm' + stage(join(shimDir, 'codex')) + vi.stubEnv('PATH', [GUI_LAUNCH_PATH, shimDir].join(delimiter)) + expect(detectCommandsInInstallDirs(['codex'])).toEqual(new Set(['codex'])) + expect(detectCommandsInInstallDirs(['codex'], GUI_LAUNCH_PATH)).toEqual(new Set()) + } + ) + it('carries the system install dirs into the POSIX guest fallback prelude', () => { const prelude = buildPosixFallbackPathPrelude() const systemDirs = [ diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts index 9b29a142f9e..dd7f15d5321 100644 --- a/src/shared/local-agent-install-dir-detection.ts +++ b/src/shared/local-agent-install-dir-detection.ts @@ -2,12 +2,19 @@ import path from 'node:path' import { resolveCliCommands } from './node-cli-command-resolution' // Why: detection may precede shell-PATH hydration, but the fallback stays bounded. -export function detectCommandsInInstallDirs(commands: readonly string[]): Set { +export function detectCommandsInInstallDirs( + commands: readonly string[], + /** The PATH detection searched; defaults to this process's own. */ + pathEnv?: string +): Set { if (commands.length === 0) { return new Set() } try { - const resolvedCommands = resolveCliCommands(commands) + const resolvedCommands = + pathEnv === undefined + ? resolveCliCommands(commands) + : resolveCliCommands(commands, { pathEnv }) return new Set( commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) )