diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts index 6e587537b82..79417577ab7 100644 --- a/src/main/daemon/daemon-create-or-attach-result.ts +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -8,17 +8,24 @@ export type DaemonCreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent - wslDistro?: string + /** Undefined only when talking to a daemon predating WSL session context. */ + wslDistro?: string | null } export function getDaemonSessionResultMetadata(session: { launchAgent: TuiAgent | null historySeeded: boolean | undefined wslDistro: string | null -}): Pick { +}): { + launchAgent?: TuiAgent + historySeeded?: boolean + wslDistro: string | null +} { return { ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), - ...(session.wslDistro ? { wslDistro: session.wslDistro } : {}) + // Why: null authoritatively identifies a native session; omission is + // reserved for older daemons that predate this wire field. + wslDistro: session.wslDistro } } diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index a9f25d5865a..14243aa15eb 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -149,6 +149,38 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.id).toContain('wt-1') }) + it('keeps a reattached native UNC session native despite a conflicting WSL preference', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const sessionId = 'native-conflicting-wsl-attach' + const created = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId, + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe' + }) + const attached = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId, + cwd: 'C:\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(created.wslDistro).toBeNull() + expect(attached.wslDistro).toBeNull() + expect(attached.isReattach).toBe(true) + expect(lastSpawnOpts?.cwd).toBe('\\\\server\\share\\repo') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + itOnPosix('keeps plain Codex startup on the short daemon shell-ready timeout', async () => { await adapter.spawn({ cols: 80, diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 5d44eeb71d9..202718eab7f 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -301,10 +301,13 @@ export class DaemonPtyAdapter implements IPtyProvider { let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) - wslDistro = result.wslDistro ?? wslDistro + let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + // Why: explicit null from a current daemon overrides the caller's WSL + // preference; undefined preserves compatibility with older daemons. + wslDistro = providerWslDistro ?? undefined if (wslDistro) { this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (result.isNew) { + } else if (providerWslDistro === null || result.isNew) { this.wslDistrosBySessionId.delete(sessionId) } const launchIdentity = (): { launchAgent?: NonNullable } => @@ -337,7 +340,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore: cachedRestore, - ...(wslDistro ? { wslDistro } : {}), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(!result.isNew ? { isReattach: true } : {}) } } @@ -361,10 +364,11 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) - wslDistro = result.wslDistro ?? wslDistro + providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + wslDistro = providerWslDistro ?? undefined if (wslDistro) { this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (result.isNew) { + } else if (providerWslDistro === null || result.isNew) { this.wslDistrosBySessionId.delete(sessionId) } pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null @@ -409,7 +413,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore, - ...(wslDistro ? { wslDistro } : {}), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(!result.isNew ? { isReattach: true } : {}) } @@ -418,7 +422,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), - ...(wslDistro ? { wslDistro } : {}), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) } } @@ -458,7 +462,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), - ...(wslDistro ? { wslDistro } : {}), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) } @@ -478,7 +482,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), - ...(wslDistro ? { wslDistro } : {}), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 2ee3aeba35b..c5d6c04dfee 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -467,7 +467,7 @@ export class DaemonServer { pid: result.pid, shellState: result.shellState, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), - ...(result.wslDistro ? { wslDistro: result.wslDistro } : {}), + wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index 5c4ad2d9a69..10cd5733cd3 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -29,6 +29,6 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent - wslDistro?: string + wslDistro: string | null attachToken: symbol } diff --git a/src/main/daemon/terminal-host-wsl-context.test.ts b/src/main/daemon/terminal-host-wsl-context.test.ts index e9fccb7b737..123726beae2 100644 --- a/src/main/daemon/terminal-host-wsl-context.test.ts +++ b/src/main/daemon/terminal-host-wsl-context.test.ts @@ -60,6 +60,39 @@ describe('TerminalHost WSL context', () => { } }) + it('returns authoritative null when a native session is attached with a WSL preference', async () => { + const spawnSubprocess = vi.fn(() => createSubprocess()) + host = new TerminalHost({ spawnSubprocess }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const created = await host.createOrAttach({ + sessionId: 'session-native', + cols: 80, + rows: 24, + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const attached = await host.createOrAttach({ + sessionId: 'session-native', + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(created.wslDistro).toBeNull() + expect(attached.wslDistro).toBeNull() + expect(spawnSubprocess).toHaveBeenCalledOnce() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('uses a remembered distro only when the selected shell is WSL', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index a8dd387dffe..984bd045431 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1414,14 +1414,18 @@ describe('registerPtyHandlers', () => { // OpenCode plugin dir, Pi managed extension env, Codex home, and dev-mode CLI // overrides were silently missing for daemon users (the common case). - function setupDaemonAdapter(supportsGitCredentialGuardHost = true) { + function setupDaemonAdapter( + supportsGitCredentialGuardHost = true, + reportedWslDistro?: string | null + ) { const daemonSpawn = vi.fn( async (options: { env: Record sessionId?: string isNewSession?: boolean }) => ({ - id: options.sessionId ?? 'daemon-pty' + id: options.sessionId ?? 'daemon-pty', + ...(reportedWslDistro !== undefined ? { wslDistro: reportedWslDistro } : {}) }) ) setLocalPtyProvider({ @@ -1934,6 +1938,67 @@ describe('registerPtyHandlers', () => { }) }) + it('distinguishes an attached native context from an older daemon fallback', async () => { + await withWin32Platform(async () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const settings = { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + terminalWindowsPowerShellImplementation: 'auto' + } + const cases: { + reportedWslDistro: string | null | undefined + expectedWslDistro: string | null + sessionId: string + }[] = [ + { + reportedWslDistro: null, + expectedWslDistro: null, + sessionId: 'native-session' + }, + { + reportedWslDistro: undefined, + expectedWslDistro: 'Ubuntu', + sessionId: 'older-daemon-session' + } + ] + + for (const testCase of cases) { + setupDaemonAdapter(true, testCase.reportedWslDistro) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + (() => settings) as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: testCase.sessionId, + cwd: '\\\\server\\share\\repo' + }) + + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + testCase.sessionId, + testCase.expectedWslDistro + ) + } + }) + }) + it('blocks runtime-created daemon PTYs when project WSL runtime requires repair', async () => { await withWin32Platform(async () => { _setWslCachesForTests({ available: true, distros: ['Debian'] }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 0c1803d4555..8c6b89d0d48 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3235,7 +3235,11 @@ export function registerPtyHandlers( } runtime?.preparePtyExecutionContext?.( result.id, - args.connectionId ? null : (result.wslDistro ?? expectedWslDistro) + args.connectionId + ? null + : result.wslDistro === undefined + ? expectedWslDistro + : result.wslDistro ) } catch (err) { if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { @@ -4228,7 +4232,11 @@ export function registerPtyHandlers( } runtime?.preparePtyExecutionContext?.( result.id, - args.connectionId ? null : (result.wslDistro ?? expectedWslDistro) + args.connectionId + ? null + : result.wslDistro === undefined + ? expectedWslDistro + : result.wslDistro ) spawnTiming.mark('provider_spawn') } catch (err) { diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 4e2b189deaa..fcd6235b101 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -228,11 +228,63 @@ describe('LocalPtyProvider', () => { const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id }) - expect(second).toEqual({ id: 'serve-session-1', pid: 12345, isReattach: true }) + expect(second).toEqual({ + id: 'serve-session-1', + pid: 12345, + wslDistro: null, + isReattach: true + }) expect(mockProc.resize).toHaveBeenCalledWith(120, 40) expect(spawnMock).not.toHaveBeenCalled() }) + it('keeps a native UNC session native on a conflicting WSL reattach', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const first = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'native-session', + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe' + }) + spawnMock.mockClear() + + const second = await provider.spawn({ + cols: 120, + rows: 40, + sessionId: first.id, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(first.wslDistro).toBeNull() + expect(second.wslDistro).toBeNull() + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('keeps the first WSL distro on a conflicting distro reattach', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const first = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'wsl-session', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + spawnMock.mockClear() + + const second = await provider.spawn({ + cols: 120, + rows: 40, + sessionId: first.id, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + expect(first.wslDistro).toBe('Ubuntu') + expect(second.wslDistro).toBe('Ubuntu') + expect(spawnMock).not.toHaveBeenCalled() + }) + it('does not reattach numeric caller session ids that can collide after restart', async () => { const first = await provider.spawn({ cols: 80, rows: 24 }) spawnMock.mockClear() diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index fef99e7d4f8..28c5586152c 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -103,6 +103,9 @@ const ptyAgentForegroundContextPaths = new Map() const ptyLastRecognizedForeground = new Map() const ptyTerminalHandle = new Map() const ptyInitialCwd = new Map() +// Why: reattach requests carry current settings, not the live process's launch +// context. Keep the first creator's WSL/native identity for the PTY incarnation. +const ptyWslDistroById = new Map() // Why: node-pty callbacks must be disposed before environment teardown, but // onExit separately owns physical process-exit proof during termination. const ptyDisposables = new Map void }[]>() @@ -239,6 +242,7 @@ function clearPtyState(id: string): void { ptyLastRecognizedForeground.delete(id) ptyTerminalHandle.delete(id) ptyInitialCwd.delete(id) + ptyWslDistroById.delete(id) ptyLoadGeneration.delete(id) ptyTerminationMode.delete(id) ptyPhysicalExits.delete(id) @@ -518,12 +522,18 @@ export class LocalPtyProvider implements IPtyProvider { } const existing = ptyProcesses.get(reattachId) if (existing) { + const existingWslDistro = ptyWslDistroById.get(reattachId) try { existing.resize(args.cols, args.rows) } catch { /* Existing PTY may reject resize during teardown; still return the live handle. */ } - return { id: reattachId, pid: existing.pid, isReattach: true } + return { + id: reattachId, + pid: existing.pid, + ...(ptyWslDistroById.has(reattachId) ? { wslDistro: existingWslDistro ?? null } : {}), + isReattach: true + } } } const id = allocatePtyId(reattachId ?? undefined) @@ -871,9 +881,15 @@ export class LocalPtyProvider implements IPtyProvider { } const proc = spawnResult.process + const spawnedShellIsWsl = + process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const spawnedWslDistro = spawnedShellIsWsl ? (launchWslDistro ?? undefined) : null createPtyPhysicalExit(id) ptyProcesses.set(id, proc) ptyInitialCwd.set(id, cwd) + if (spawnedWslDistro !== undefined) { + ptyWslDistroById.set(id, spawnedWslDistro) + } // Why both signals: launchAgent is the caller's explicit intent and // survives command rewriting (e.g. auth env prefixes); recognition covers // callers that pass a bare agent command line without the flag. @@ -1031,7 +1047,11 @@ export class LocalPtyProvider implements IPtyProvider { // briefly 0/undefined if node-pty hasn't observed the forked child yet. const rawPid = proc.pid const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null - return { id, pid } + return { + id, + pid, + ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) + } } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 8b04b0b096a..36f58318c1d 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -111,8 +111,10 @@ export type PtySpawnResult = { pid?: number | null /** Minimal allowlisted launch ownership returned by daemon reattach. */ launchAgent?: TuiAgent - /** Immutable local WSL execution context returned by a daemon session. */ - wslDistro?: string + /** Immutable local WSL execution context returned by a daemon session. + * Null authoritatively identifies a native session; undefined means the + * provider cannot report the context (including older daemons). */ + wslDistro?: string | null /** ANSI snapshot of the terminal screen, present when reattaching to an * existing daemon session. Write this to xterm.js to restore visual state. */ snapshot?: string