diff --git a/docs/wsl-osc7-sleep-wake-cwd.md b/docs/wsl-osc7-sleep-wake-cwd.md new file mode 100644 index 00000000000..24d46b70660 --- /dev/null +++ b/docs/wsl-osc7-sleep-wake-cwd.md @@ -0,0 +1,153 @@ +# WSL OSC 7 Sleep/Wake CWD + +## Problem + +On native Windows, a WSL terminal can persist a fake UNC current working directory and then fail to wake. The deterministic trigger is a WSL shell emitting `file:///home/...` before sleep: + +- `osc7-file-uri.ts` treats every non-local authority as a UNC server when parsing Win32 paths. +- `Session` constructs its daemon `HeadlessEmulator` without the WSL execution context, so checkpoints can store `\\\home\...`. +- `OrcaRuntimeService` likewise gives every local Windows PTY Win32 OSC 7 semantics; `remotePosixAuthority` only protects POSIX SSH PTYs. +- `HistoryReader.restoreFromIncrementalLog` creates a third context-free emulator. A log containing the OSC 7 can therefore re-create the bad CWD even if the base checkpoint is valid. +- `DaemonPtyAdapter.doSpawn` prefers `restoreInfo.cwd` over the requested worktree CWD. `pty-subprocess.ts` later validates the fake UNC and rejects it before spawning WSL. + +Reproduction on Windows 11 build 26200, WSL 2.3.26.0, Ubuntu 24.04.1 LTS, and Orca 1.4.144-rc.4: + +1. Open a terminal in `\\wsl.localhost\Ubuntu\home\\...`. +2. Emit `OSC 7;file:///home//...`. +3. Sleep for 38-69 seconds, then wake. + +Both triggered attempts failed. The saved CWD became `\\\home\\...`; the original WSL UNC remained valid. A 60-second control sleep without the hostname OSC 7 passed. + +## Root cause and invariant + +OSC 7 authority semantics follow the PTY's execution environment, not Electron's host OS. A native Windows shell may legitimately mean `\\server\share` by `file://server/share`; a WSL shell means a POSIX pathname inside its already-known distro. Orca currently classifies local WSL PTYs as generic Win32 PTYs. + +The fix must establish one immutable `wslDistro: string | null` per PTY incarnation before any OSC 7 bytes or recovered history are parsed. The same value must drive daemon live parsing, incremental-history replay, runtime parsing, and legacy CWD recovery. URI authority must never select a distro. + +Resolve that value with the same precedence already used to launch WSL: + +1. distro in an explicit WSL UNC `cwd`; +2. distro in the worktree encoded by the daemon session ID; +3. trimmed `terminalWindowsWslDistro` selected for the spawn. + +Centralize this resolution with the existing WSL session-context code so the parser and subprocess cannot disagree. Do not infer local WSL context for `connectionId`/SSH PTYs. + +## Non-goals + +- Do not change native Windows UNC, POSIX SSH, or remote Windows drive-path semantics. +- Do not redesign terminal history, sleep, renderer link routing, or PTY identity. +- Do not probe WSL, DNS, the filesystem, or the network on the output path. +- Do not infer a distro from an OSC hostname or mutable global/default-distro state. + +## Design + +### 1. Pure conversion and parser context + +Move `toWindowsWslPath` to `src/shared/wsl-paths.ts` and re-export it from `src/main/wsl.ts` for existing callers. Preserve its current rules: lowercase `/mnt/` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\`. `/MNT` and `/mnt/C` are case-sensitive Linux paths, not drvfs aliases. + +Add `wslDistro?: string` to `ParseFileUriPathOptions`. When present, `parseFileUriPathParts` must: + +- decode `url.pathname` exactly once; +- construct the path with `toWindowsWslPath(decodedPath, wslDistro)`, regardless of URI authority; +- continue returning the normalized URI hostname as metadata; +- reject malformed URLs/percent encoding as today and leave the prior CWD unchanged. + +Without `wslDistro`, retain every existing `pathFlavor` and `remotePosixAuthority` branch. + +Thread the option through `TerminalOscCwdTitleScanner` and `HeadlessEmulator`. Scanner options are constructor-scoped, so split sequences retain the same context without global state. + +### 2. Daemon and history ownership + +`TerminalHost` resolves the immutable distro before spawning. Pass it both to `createPtySubprocess` and to a new `SessionOptions.wslDistro`; `Session` passes it to its `HeadlessEmulator`. Store the resolved value on `Session` and return it on create/attach so a later window observes the live session's context instead of reinterpreting it from current settings. Make the response field additive/optional for compatibility with an older preserved daemon. + +Extend `HistoryReader.detectColdRestore` with optional parser context and use it for the scratch `HeadlessEmulator` in `restoreFromIncrementalLog`. Every detect path in `DaemonPtyAdapter`--initial detection, probe/create race recovery, and failed history seeding--must pass the same resolved distro. This is required; fixing only the live `Session` leaves incremental restore able to reproduce the bug. + +### 3. Narrow legacy checkpoint recovery + +Immediately after each cold-restore detection, normalize `restoreInfo.cwd` before it becomes `effectiveCwd` or `coldRestore.cwd`. Recovery runs only on native Windows with a resolved local WSL distro: + +1. Preserve an absolute drive path. +2. Preserve a `\\wsl.localhost\...` or `\\wsl$\...` path only when its distro matches the resolved distro case-insensitively. +3. Convert an absolute POSIX path through the resolved distro. +4. Repair the known legacy shape only when the UNC server equals `os.hostname()` case-insensitively: strip the server, interpret the share and tail as the Linux absolute path, then convert it. Thus `\\HOST\mnt\c\x` becomes `C:\x`. +5. For a mismatched-distro WSL UNC, another UNC server, a relative path, or malformed input, discard the recovered CWD and fall back to the current requested CWD (or the normal spawn default if none was supplied). Do not guess. + +Apply the corrected/fallback value to both subprocess creation and the returned cold-restore payload. This keeps runtime seeding, the sticky restore cache, `initialCwds`, and the next checkpoint consistent. The repair is idempotent and never mutates history files in place. + +### 4. Runtime ownership and races + +Add `wslDistro: string | null` to `RuntimePtyWorktreeRecord`; a boolean `isWsl` is insufficient for multi-distro parsing. Pass the resolved distro from the spawn result into runtime registration. For reconstructed local records, a WSL UNC worktree may supply a fallback distro via `parseWslUncPath`; never do this for SSH records. + +Daemon PTYs can emit output before `provider.spawn` resolves, and cold-restore seeding currently runs before `registerPty`. Register the expected daemon session's execution context before spawn, then replace it with the daemon-returned immutable value before snapshot/cold-restore seeding. If late discovery changes a context after a runtime emulator was created, discard that emulator and re-seed it from the authoritative provider snapshot; never keep a buffer whose CWD was parsed under mixed contexts. + +Clear execution context with the other per-PTY parser maps on exit, pruning, failed spawn, and provider-generation reset. Reusing a PTY ID must not inherit a prior distro. + +## Data flow + +```text +spawn cwd/session/preference + -> resolve one immutable local WSL distro + -> daemon Session scanner + HistoryReader replay scanner + -> decoded OSC 7 pathname (authority retained only as metadata) + -> toWindowsWslPath(pathname, distro) + -> daemon checkpoint + runtime live/headless CWD + -> sleep/wake with a valid WSL UNC or drive CWD +``` + +Legacy recovery is a boundary repair: + +```text +WSL cold restore + old checkpoint CWD + -> exact allowlist normalization + -> corrected spawn and coldRestore payload + -> next checkpoint naturally persists the corrected live CWD +``` + +## Consistency and failure modes + +- Context is per PTY incarnation, not global, so simultaneous Ubuntu and Debian panes cannot contaminate each other. +- The first creator owns a daemon session's immutable context. Concurrent/multi-window attaches consume the stored value; changed settings do not mutate a live session. +- A distro mismatch in externally changed history falls back to the requested worktree CWD instead of launching the wrong distro. +- Atomic checkpoint replacement remains unchanged. Concurrent restore callers may read different complete generations, but each normalizes before spawn and daemon create/attach still selects one live session. +- Missing context deliberately preserves current behavior. Tests must cover every local WSL construction path so this fallback cannot silently remain on the affected path. +- Native UNC and SSH behavior remain isolated because neither receives local WSL context. +- No filesystem/network work is added per output chunk; parsing remains bounded string work on completed OSC sequences. + +## Test plan + +- `osc7-file-uri.test.ts` and shared WSL-path tests: hostname/localhost/empty WSL authorities; hostname metadata; `/home`, `/`, lowercase `/mnt/c`, `/MNT`, `/mnt/C`; spaces/percent decoding; invalid encoding; native UNC; POSIX SSH; Windows SSH drive paths. +- `headless-emulator.test.ts`: WSL context propagation across ordinary and split OSC sequences; two emulators with different distros. +- `terminal-host.test.ts`/`session.test.ts`: resolved context reaches the daemon emulator; attach returns the session's stored context and does not adopt a conflicting later preference. +- `history-reader.test.ts`: incremental-log replay containing the exact hostname OSC 7 yields the correct WSL CWD. +- `daemon-pty-adapter.test.ts`: cover every `detectColdRestore` branch plus sticky-cache output. Repair hostname UNC and POSIX CWDs; preserve matching WSL UNC and drive paths; reject mismatched distro, other UNC, relative, native, and SSH/non-WSL cases. Assert both create/attach CWD and `coldRestore.cwd`. +- `orca-runtime.test.ts` and IPC PTY tests: context exists before early daemon output and before headless seeding; Windows-host worktree with a selected WSL distro; WSL UNC fallback after reconstruction; attach correction; PTY-ID reuse; simultaneous distros; local native and SSH isolation. +- Run focused tests, then `pnpm typecheck` and `pnpm lint`. +- Electron on native Windows: in a real Ubuntu WSL pane, emit the exact hostname OSC 7, sleep/wake twice, run `pwd` after each wake, and verify no `DaemonProtocolError`. Native UNC semantics are covered by deterministic unit tests; a screenshot of an arbitrary UNC string is not meaningful validation. + +## UI quality and review evidence + +There is no UI, layout, copy, or interaction change, so the Stage 5 visual-quality loop is skipped. Terminal content, CWD, and adjacent native/SSH behavior must remain unchanged. + +Electron validation still requires three evidence screenshots for user review: + +1. Before sleep: the WSL terminal shows the printed hostname/emission command and `pwd`. +2. After the first wake: the same pane shows the same `pwd` and no wake error. +3. After the second wake: the same pane again shows the same `pwd` and no wake error. + +## Lightweight engineering review + +- **Scope:** Parser context, immutable context propagation, history replay, and narrow legacy recovery. Runtime/IPC fields are necessary because parsing begins outside the daemon and may precede spawn completion. +- **Architecture/data flow:** One resolved distro drives every parser for one PTY incarnation. The URI hostname remains metadata; it never selects path namespace or distro. +- **Failure modes:** Covers missing/stale context, incremental replay, probe/create races, sticky restore, mismatched external history, split chunks, concurrent attaches, multi-window reuse, multi-distro isolation, PTY-ID reuse, and SSH/native boundaries. +- **Tests:** Requires parser tables, propagation tests at every emulator construction site, all adapter restore branches, runtime early-byte/seed races, and the deterministic Windows Electron reproduction twice. +- **Performance/blast radius:** One optional string per PTY/session and bounded conversion per OSC 7; no probes or new hot-path I/O. Non-WSL behavior is unchanged when the option is absent. +- **UI/screenshots:** No design-review loop. Three Electron screenshots are required as functional evidence; native UNC stays an automated regression test. +- **Residual risk:** Legacy checkpoints created from a non-machine hostname cannot be distinguished safely from a real UNC and therefore fall back to the requested CWD. A renamed/uninstalled distro still fails normally; this change does not guess a replacement. + +## Rollout + +1. Centralize distro resolution and WSL path conversion. +2. Make the parser, daemon Session, and history replay distro-aware. +3. Add allowlisted legacy recovery and keep spawn/cold-restore metadata consistent. +4. Make runtime context available before output/seeding and reset it per incarnation. +5. Add regressions, run static checks, then validate two Windows sleep/wake cycles with review screenshots. diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts new file mode 100644 index 00000000000..6e587537b82 --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -0,0 +1,24 @@ +import type { TuiAgent } from '../../shared/types' +import type { ShellReadyState, TerminalSnapshot } from './types' + +export type DaemonCreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState + historySeeded?: boolean + launchAgent?: TuiAgent + wslDistro?: string +} + +export function getDaemonSessionResultMetadata(session: { + launchAgent: TuiAgent | null + historySeeded: boolean | undefined + wslDistro: string | null +}): Pick { + return { + ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), + ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), + ...(session.wslDistro ? { wslDistro: session.wslDistro } : {}) + } +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 42a7ada9735..a9f25d5865a 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1,6 +1,6 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' +import { hostname, tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonClient } from './client' @@ -1350,6 +1350,46 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('repairs legacy hostname UNC cwd for WSL spawn and cold-restore metadata', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const sessionId = 'wsl-legacy-cwd' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: `\\\\${hostname()}\\home\\jin`, + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'legacy WSL output\r\n') + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + terminalWindowsWslDistro: 'Debian', + sessionId + }) + + const repaired = '\\\\wsl.localhost\\Ubuntu\\home\\jin' + expect(lastSpawnOpts?.cwd).toBe(repaired) + expect(result.coldRestore?.cwd).toBe(repaired) + expect(result.wslDistro).toBe('Ubuntu') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('returns cold restore OSC link ranges from checkpoint history', async () => { const sessionId = 'cold-restore-osc-links' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 6437711729b..5d44eeb71d9 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -29,9 +29,12 @@ import type { PtySpawnResult } from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' +import { resolveWslSessionContext } from './wsl-session-context' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' type ColdRestorePayload = { scrollback: string @@ -104,6 +107,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() + private wslDistrosBySessionId = new Map() // Why: React re-renders and StrictMode double-mounts can call createOrAttach // for a session the user just killed. Without tombstones, the daemon would // create a fresh session — resurrecting a terminal the user explicitly closed. @@ -196,6 +200,28 @@ export class DaemonPtyAdapter implements IPtyProvider { private async doSpawn(opts: PtySpawnOptions): Promise { const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) + let wslDistro = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro + const detectColdRestore = (options?: { ignoreCleanEnd?: boolean }): ColdRestoreInfo | null => { + const restoreInfo = + this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro }) ?? null + if (!restoreInfo) { + return null + } + return { + ...restoreInfo, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: restoreInfo.cwd, + requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + } if (this.killedSessionTombstones.has(sessionId)) { throw new TerminalKilledError(sessionId) @@ -228,7 +254,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if ((await this.getAppliedSize(sessionId)) !== null) { restoreSkippedForLiveSession = true } else { - restoreInfo = this.historyReader.detectColdRestore(sessionId) + restoreInfo = detectColdRestore() } } let effectiveCwd = restoreInfo?.cwd ?? opts.cwd @@ -275,6 +301,12 @@ export class DaemonPtyAdapter implements IPtyProvider { let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + wslDistro = result.wslDistro ?? wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } const launchIdentity = (): { launchAgent?: NonNullable } => result.launchAgent ? { launchAgent: result.launchAgent } : {} @@ -305,6 +337,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore: cachedRestore, + ...(wslDistro ? { wslDistro } : {}), ...(!result.isNew ? { isReattach: true } : {}) } } @@ -318,8 +351,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // must not null the restore here, or the openSession branch below would // delete the checkpoint instead of restoring it. if (result.isNew && restoreSkippedForLiveSession) { - restoreInfo = - this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + restoreInfo = detectColdRestore({ ignoreCleanEnd: true }) scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null if (restoreInfo && scrollback) { // Why: the aliveness probe raced with session death, so the first @@ -329,11 +361,17 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) + wslDistro = result.wslDistro ?? wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null this.initialCwds.set(sessionId, effectiveCwd) } } else if (!result.isNew && result.historySeeded === false) { - restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + restoreInfo = detectColdRestore() scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null } @@ -371,6 +409,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore, + ...(wslDistro ? { wslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(!result.isNew ? { isReattach: true } : {}) } @@ -379,6 +418,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(wslDistro ? { wslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) } } @@ -418,6 +458,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(wslDistro ? { wslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) } @@ -437,6 +478,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(wslDistro ? { wslDistro } : {}), snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, @@ -527,7 +569,19 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.checkpointInFlight } await this.checkpointSessions([id], { final: true, teardown: true }) - const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const wslDistro = this.wslDistrosBySessionId.get(id) + const detected = this.historyReader?.detectColdRestore(id, { wslDistro }) ?? null + const restoreInfo = detected + ? { + ...detected, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: detected.cwd, + requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + : null const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null if (coldRestore) { this.coldRestoreCache.set(id, coldRestore) @@ -552,6 +606,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.lastFullCheckpointAt.delete(id) this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) + this.wslDistrosBySessionId.delete(id) // Why: history removal is for the "user explicitly closed this terminal" // path. Sleep also calls shutdown but expects scrollback to survive — wake // re-spawns and the cold-restore reader needs the dir intact. Caller @@ -903,6 +958,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() @@ -941,6 +997,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() // Why: the detached daemon keeps these PTYs alive for warm reattach; a // pause left behind would block their shells for a failsafe window. for (const id of this.pausedProducerSessionIds) { @@ -1386,6 +1443,7 @@ export class DaemonPtyAdapter implements IPtyProvider { .catch((err) => console.warn('[history] closeSession failed:', event.sessionId, err)) } this.initialCwds.delete(event.sessionId) + this.wslDistrosBySessionId.delete(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { listener({ id: event.sessionId, code: event.payload.code }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index c33e618bf02..2ee3aeba35b 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -467,6 +467,7 @@ export class DaemonServer { pid: result.pid, shellState: result.shellState, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + ...(result.wslDistro ? { wslDistro: result.wslDistro } : {}), ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index b9630b8ff7e..37633f32334 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -263,6 +263,22 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split-escape')) }) + + it('keeps split WSL OSC-7 parsing scoped to each emulator distro', async () => { + const ubuntu = new HeadlessEmulator({ cols: 80, rows: 24, wslDistro: 'Ubuntu' }) + const debian = new HeadlessEmulator({ cols: 80, rows: 24, wslDistro: 'Debian' }) + try { + await ubuntu.write('\x1b]7;file://machine/home/jin') + await debian.write('\x1b]7;file://machine/home/jin/repo\x07') + await ubuntu.write('/repo\x07') + + expect(ubuntu.getSnapshot().cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + expect(debian.getSnapshot().cwd).toBe('\\\\wsl.localhost\\Debian\\home\\jin\\repo') + } finally { + ubuntu.dispose() + debian.dispose() + } + }) }) describe('OSC title tracking', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e7da2a4589e..767c9904a6c 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -35,6 +35,7 @@ export type HeadlessEmulatorOptions = { onQueryReply?: (reply: string) => void pathFlavor?: 'posix' | 'win32' remotePosixFileUriAuthority?: boolean + wslDistro?: string } export type HeadlessEmulatorWriteOptions = { @@ -93,7 +94,8 @@ export class HeadlessEmulator { this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true this.oscText = new TerminalOscCwdTitleScanner({ pathFlavor: this.pathFlavor, - remotePosixAuthority: this.remotePosixFileUriAuthority + remotePosixAuthority: this.remotePosixFileUriAuthority, + wslDistro: opts.wslDistro }) this.terminal = new Terminal({ cols: opts.cols, diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 50caff6d486..4330b0e2c9e 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { HistoryReader } from './history-reader' import { getHistorySessionDirName } from './history-paths' import type { SessionMeta } from './history-manager' +import { encodeLogBatch, encodeLogHeader } from './terminal-history-log' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'history-reader-test-')) @@ -164,6 +165,24 @@ describe('HistoryReader', () => { }) }) + it('replays incremental hostname OSC-7 with the same WSL context', () => { + writeSessionWithCheckpoint(dir, 'wsl-log', makeMeta(), makeCheckpoint({ generation: 7 })) + const sessionDir = join(dir, getHistorySessionDirName('wsl-log')) + writeFileSync( + join(sessionDir, 'output.log'), + Buffer.concat([ + encodeLogHeader(7), + encodeLogBatch(1, [ + { kind: 'output', data: '\x1b]7;file://DESKTOP-ORCA/home/user/project\x07' } + ]) + ]) + ) + + const info = reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' }) + + expect(info?.cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\user\\project') + }) + describe('detectColdRestore — scrollback.bin fallback (backward compatibility)', () => { it('restores from scrollback.bin when checkpoint.json is absent', () => { writeSessionWithScrollback(dir, 'old-sess', makeMeta(), 'old format data\r\n') diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 8d31e3c638d..e219d58cfc4 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -39,7 +39,7 @@ export class HistoryReader { detectColdRestore( sessionId: string, - opts?: { ignoreCleanEnd?: boolean } + opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } ): ColdRestoreInfo | null { const meta = this.readMeta(sessionId) if (!meta) { @@ -69,7 +69,7 @@ export class HistoryReader { // byte-exact output up to ~5s before the crash (up to the full-snapshot // cooldown, ~45s, for a streaming session mid-deferral), while the // checkpoint can be a full log-cap (~5MB of output) stale. - const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint) + const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint, opts?.wslDistro) if (logRestore) { return logRestore } @@ -122,7 +122,8 @@ export class HistoryReader { private restoreFromIncrementalLog( sessionDir: string, meta: SessionMeta, - checkpoint: TerminalCheckpointFile | null + checkpoint: TerminalCheckpointFile | null, + wslDistro?: string ): ColdRestoreInfo | null { let logBuffer: Buffer try { @@ -148,7 +149,8 @@ export class HistoryReader { const emulator = new HeadlessEmulator({ cols: checkpoint?.cols ?? meta.cols, - rows: checkpoint?.rows ?? meta.rows + rows: checkpoint?.rows ?? meta.rows, + wslDistro }) try { if (checkpoint) { diff --git a/src/main/daemon/osc7-file-uri.test.ts b/src/main/daemon/osc7-file-uri.test.ts index faa84879c55..4aa20a0dbfc 100644 --- a/src/main/daemon/osc7-file-uri.test.ts +++ b/src/main/daemon/osc7-file-uri.test.ts @@ -3,7 +3,9 @@ import { parseFileUriPath, parseFileUriPathParts } from './osc7-file-uri' describe('parseFileUriPath', () => { it('accepts host-qualified POSIX file URI authorities as plain paths', () => { - expect(parseFileUriPath('file://remote-host/tmp/result.json')).toBe('/tmp/result.json') + expect(parseFileUriPath('file://remote-host/tmp/result.json', { pathFlavor: 'posix' })).toBe( + '/tmp/result.json' + ) }) it('accepts empty and localhost POSIX file URI authorities', () => { @@ -51,4 +53,20 @@ describe('parseFileUriPath', () => { } } }) + + it.each(['machine-name', 'localhost', ''])( + 'uses the resolved WSL distro for authority %s', + (authority) => { + const uri = authority ? `file://${authority}/home/me/my%20repo` : 'file:///home/me/my%20repo' + expect(parseFileUriPathParts(uri, { pathFlavor: 'win32', wslDistro: 'Ubuntu' })).toEqual({ + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\my repo', + hostname: authority === 'localhost' ? '' : authority + }) + } + ) + + it('maps lowercase drvfs paths and rejects malformed percent encoding in WSL context', () => { + expect(parseFileUriPath('file://host/mnt/c/work', { wslDistro: 'Ubuntu' })).toBe('C:\\work') + expect(parseFileUriPath('file://host/home/%ZZ', { wslDistro: 'Ubuntu' })).toBeNull() + }) }) diff --git a/src/main/daemon/osc7-file-uri.ts b/src/main/daemon/osc7-file-uri.ts index f931174ee03..ce7b8493462 100644 --- a/src/main/daemon/osc7-file-uri.ts +++ b/src/main/daemon/osc7-file-uri.ts @@ -1,3 +1,5 @@ +import { toWindowsWslPath } from '../../shared/wsl-paths' + export type ParsedFileUriPath = { path: string hostname: string @@ -6,6 +8,7 @@ export type ParsedFileUriPath = { export type ParseFileUriPathOptions = { pathFlavor?: 'posix' | 'win32' remotePosixAuthority?: boolean + wslDistro?: string } export function parseFileUriPath( @@ -27,6 +30,9 @@ export function parseFileUriPathParts( const decodedPath = decodeURIComponent(url.pathname) const hostname = url.hostname.toLowerCase() + if (options.wslDistro) { + return { path: toWindowsWslPath(decodedPath, options.wslDistro), hostname } + } const pathFlavor = options.pathFlavor ?? (process.platform === 'win32' ? 'win32' : 'posix') if (pathFlavor !== 'win32') { return { path: decodedPath, hostname } diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 2ab88945689..60a8af8ac70 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -39,7 +39,7 @@ import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' -import { getWslContextFromSessionId } from './wsl-session-context' +import { resolveWslSessionContext } from './wsl-session-context' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { POWERLEVEL10K_WIZARD_DISABLE_ENV, @@ -177,16 +177,6 @@ function removeInheritedDevAgentHookEndpoint( } } -/** - * Resolves a WSL launch context from a user-selected distro name. - */ -function getWslContextFromPreferredDistro( - distro: string | null | undefined -): { distro: string } | undefined { - const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined -} - /** * Strips Electron's internal run-as-node flag from user shell environments. */ @@ -605,18 +595,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // setting, relayed by main) takes priority over env.COMSPEC — otherwise // Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback, // no matter which shell the user actually picked. - const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null - const sessionWslContext = - process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined - const preferredWslContext = - process.platform === 'win32' - ? getWslContextFromPreferredDistro(opts.terminalWindowsWslDistro) - : undefined + const resolvedWslContext = resolveWslSessionContext(opts) // Why: WSL worktree cwd is the repo's execution environment. Older persisted // tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and // daemon-backed terminals enter the WSL distro just like LocalPtyProvider. - let shellPath = - cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] let startupCommandDeliveredInShellArgs = false let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] @@ -672,7 +655,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, cwd: spawnCwd, defaultCwd: getDefaultCwd(), - wslContext: sessionWslContext ?? preferredWslContext, + wslContext: resolvedWslContext, startupCommand: opts.command }) const primaryAttempt = windowsFallbackAttempts[0] @@ -687,7 +670,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext ?? preferredWslContext, + resolvedWslContext, opts.command ) shellArgs = resolved.shellArgs @@ -703,8 +686,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { if (codexHomeWslInfo) { - const launchWslDistro = - cwdWslInfo?.distro ?? sessionWslContext?.distro ?? preferredWslContext?.distro + const launchWslDistro = resolvedWslContext?.distro if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { delete env.CODEX_HOME delete env.ORCA_CODEX_HOME diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index c022604ceb2..3b8f068868c 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -106,12 +106,14 @@ describe('Session', () => { cols?: number rows?: number launchAgent?: TuiAgent + wslDistro?: string }): Session { session = new Session({ sessionId: 'test-session', cols: opts?.cols ?? 80, rows: opts?.rows ?? 24, ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), + wslDistro: opts?.wslDistro, subprocess, shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.shellReadyTimeoutMs !== undefined @@ -665,6 +667,13 @@ describe('Session', () => { }) describe('snapshot', () => { + it('parses live OSC-7 output in the session WSL distro', () => { + createSession({ wslDistro: 'Ubuntu' }) + + subprocess.simulateData('\x1b]7;file://DESKTOP-ORCA/home/jin/repo\x07') + + expect(session.getCwd()).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + }) it('returns a terminal snapshot', async () => { createSession() subprocess.simulateData('$ hello\r\n') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index a40cce8480a..7ca52cf0c2a 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -90,6 +90,7 @@ export type SessionOptions = { shellReadyTimeoutMs?: number historySeed?: string scrollback?: number + wslDistro?: string // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — // dispose the headless emulator and drop it from its session map. Without a @@ -108,6 +109,7 @@ export class Session { readonly sessionId: string readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null + readonly wslDistro: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -138,13 +140,15 @@ export class Session { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null + this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) this.emulator = new HeadlessEmulator({ cols: size.cols, rows: size.rows, - scrollback: opts.scrollback + scrollback: opts.scrollback, + wslDistro: opts.wslDistro // No onData wiring: the daemon-side emulator must never reply to // terminal query sequences. The renderer's xterm is the authoritative // responder; any daemon reply races ahead via in-process parsing and diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index a60570ac5fe..5c4ad2d9a69 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -29,5 +29,6 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent + wslDistro?: string attachToken: symbol } diff --git a/src/main/daemon/terminal-host-wsl-context.test.ts b/src/main/daemon/terminal-host-wsl-context.test.ts new file mode 100644 index 00000000000..e9fccb7b737 --- /dev/null +++ b/src/main/daemon/terminal-host-wsl-context.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session' +import { resolveWslSessionContext } from './wsl-session-context' + +function createSubprocess(): SubprocessHandle { + let onExit: ((code: number) => void) | undefined + return { + pid: 123, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((callback: (code: number) => void) => { + onExit = callback + }), + dispose: vi.fn() + } +} + +describe('TerminalHost WSL context', () => { + let host: TerminalHost | undefined + + afterEach(async () => { + await host?.dispose() + }) + + it('returns the first creator context on conflicting later attaches', 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-wsl', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin', + terminalWindowsWslDistro: 'Debian', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const attached = await host.createOrAttach({ + sessionId: 'session-wsl', + cols: 80, + rows: 24, + terminalWindowsWslDistro: 'Debian', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(created.wslDistro).toBe('Ubuntu') + expect(attached.wslDistro).toBe('Ubuntu') + 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' }) + try { + expect( + resolveWslSessionContext({ + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + ).toBeUndefined() + expect( + resolveWslSessionContext({ + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + ).toBeUndefined() + expect( + resolveWslSessionContext({ + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: ' Ubuntu ' + }) + ).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 7bf06f57012..ba15c6ed814 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -13,6 +13,8 @@ import { import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import { TerminalSessionTeardown } from './terminal-session-teardown' +import { resolveWslSessionContext } from './wsl-session-context' +import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' @@ -89,8 +91,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, - ...(existing.launchAgent ? { launchAgent: existing.launchAgent } : {}), - ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), + ...getDaemonSessionResultMetadata(existing), attachToken: token } } @@ -110,6 +111,7 @@ export class TerminalHost { // Clear tombstone if re-creating a killed session this.killedTombstones.delete(opts.sessionId) const size = normalizePtySize(opts.cols, opts.rows) + const wslDistro = resolveWslSessionContext(opts)?.distro const subprocess = this.spawnSubprocess({ sessionId: opts.sessionId, @@ -145,6 +147,7 @@ export class TerminalHost { subprocess, shellReadySupported, historySeed: opts.historySeed, + wslDistro, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -185,8 +188,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, - ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), - ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), + ...getDaemonSessionResultMetadata(session), attachToken: token } } diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts index f3736d7c009..91f592e3796 100644 --- a/src/main/daemon/terminal-osc-cwd-title-scanner.ts +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -11,6 +11,7 @@ const OSC_SCAN_TAIL_LIMIT = 4096 export type TerminalOscCwdTitleScannerOptions = { pathFlavor?: 'posix' | 'win32' remotePosixAuthority?: boolean + wslDistro?: string } export class TerminalOscCwdTitleScanner { @@ -39,7 +40,8 @@ export class TerminalOscCwdTitleScanner { scanOsc7Uris(input, (uri) => { const parsed = parseFileUriPath(uri, { pathFlavor: this.parseOptions.pathFlavor, - remotePosixAuthority: this.parseOptions.remotePosixAuthority + remotePosixAuthority: this.parseOptions.remotePosixAuthority, + wslDistro: this.parseOptions.wslDistro }) if (parsed) { this.cwd = parsed diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 6f48e1ccdbf..63c5edc577b 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -359,14 +359,7 @@ export type RpcResponseError = { export type RpcResponse = RpcResponseOk | RpcResponseError -export type CreateOrAttachResult = { - isNew: boolean - snapshot: TerminalSnapshot | null - pid: number | null - shellState: ShellReadyState - historySeeded?: boolean - launchAgent?: TuiAgent -} +export type { DaemonCreateOrAttachResult as CreateOrAttachResult } from './daemon-create-or-attach-result' export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } diff --git a/src/main/daemon/wsl-cold-restore-cwd.test.ts b/src/main/daemon/wsl-cold-restore-cwd.test.ts new file mode 100644 index 00000000000..d9c5cf54770 --- /dev/null +++ b/src/main/daemon/wsl-cold-restore-cwd.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' + +const base = { + platform: 'win32' as const, + hostname: 'DESKTOP-ORCA', + requestedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + wslDistro: 'Ubuntu' +} + +describe('normalizeWslColdRestoreCwd', () => { + it.each([ + ['C:\\work', 'C:\\work'], + ['\\\\wsl.localhost\\ubuntu\\home\\jin', '\\\\wsl.localhost\\ubuntu\\home\\jin'], + ['/home/jin', '\\\\wsl.localhost\\Ubuntu\\home\\jin'], + ['\\\\desktop-orca\\home\\jin', '\\\\wsl.localhost\\Ubuntu\\home\\jin'], + ['\\\\DESKTOP-ORCA\\mnt\\c\\work', 'C:\\work'] + ])('allows or repairs %s', (recoveredCwd, expected) => { + expect(normalizeWslColdRestoreCwd({ ...base, recoveredCwd })).toBe(expected) + }) + + it.each([ + '\\\\wsl.localhost\\Debian\\home\\jin', + '\\\\server\\share\\repo', + '//server/share/repo', + 'relative/path', + '\\\\other-host\\home\\jin' + ])('falls back instead of guessing for %s', (recoveredCwd) => { + expect(normalizeWslColdRestoreCwd({ ...base, recoveredCwd })).toBe(base.requestedCwd) + }) + + it('leaves native and missing-context restores unchanged', () => { + expect( + normalizeWslColdRestoreCwd({ ...base, platform: 'linux', recoveredCwd: '/home/jin' }) + ).toBe('/home/jin') + expect( + normalizeWslColdRestoreCwd({ ...base, wslDistro: undefined, recoveredCwd: '\\\\server\\x' }) + ).toBe('\\\\server\\x') + }) +}) diff --git a/src/main/daemon/wsl-cold-restore-cwd.ts b/src/main/daemon/wsl-cold-restore-cwd.ts new file mode 100644 index 00000000000..b3f921106dc --- /dev/null +++ b/src/main/daemon/wsl-cold-restore-cwd.ts @@ -0,0 +1,37 @@ +import { hostname as getHostname } from 'node:os' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' + +export function normalizeWslColdRestoreCwd(args: { + recoveredCwd: string + requestedCwd?: string + wslDistro?: string + platform?: NodeJS.Platform + hostname?: string +}): string | undefined { + if ((args.platform ?? process.platform) !== 'win32' || !args.wslDistro) { + return args.recoveredCwd + } + + if (/^[A-Za-z]:[\\/]/.test(args.recoveredCwd)) { + return args.recoveredCwd + } + + const wslPath = parseWslUncPath(args.recoveredCwd) + if (wslPath) { + return wslPath.distro.toLowerCase() === args.wslDistro.toLowerCase() + ? args.recoveredCwd + : args.requestedCwd + } + + if (/^\/(?!\/)/.test(args.recoveredCwd)) { + return toWindowsWslPath(args.recoveredCwd, args.wslDistro) + } + + const uncMatch = args.recoveredCwd.match(/^[\\/]{2}([^\\/]+)[\\/]([^\\/]+)([\\/].*)?$/) + if (uncMatch?.[1].toLowerCase() === (args.hostname ?? getHostname()).toLowerCase()) { + const linuxPath = `/${uncMatch[2]}${(uncMatch[3] ?? '').replace(/\\/g, '/')}` + return toWindowsWslPath(linuxPath, args.wslDistro) + } + + return args.requestedCwd +} diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index f6d568a7042..582e8a66a05 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -1,6 +1,8 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { isWslShellName } from '../../shared/local-windows-terminal-runtime' import { parseWslPath } from '../wsl' import { parsePtySessionId } from './pty-session-id' +import { parseWslUncPath } from '../../shared/wsl-paths' export type WslSessionContext = { distro: string @@ -15,3 +17,31 @@ export function getWslContextFromSessionId(sessionId: string): WslSessionContext const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } + +export function getWslContextFromPreferredDistro( + distro: string | null | undefined +): WslSessionContext | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined +} + +export function resolveWslSessionContext(args: { + cwd?: string + sessionId?: string + shellOverride?: string + terminalWindowsWslDistro?: string | null +}): WslSessionContext | undefined { + if (process.platform !== 'win32') { + return undefined + } + const cwdDistro = args.cwd ? parseWslUncPath(args.cwd)?.distro : undefined + if (cwdDistro) { + return { distro: cwdDistro, treatPosixCwdAsWsl: true } + } + return ( + (args.sessionId ? getWslContextFromSessionId(args.sessionId) : undefined) ?? + (isWslShellName(args.shellOverride) + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined) + ) +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index b3a41ef10eb..a8dd387dffe 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -2301,13 +2301,24 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } handlers.clear() - registerPtyHandlers(mainWindow as never) + registerPtyHandlers(mainWindow as never, runtime as never) await expect( handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) ).rejects.toThrow(/spawn boom/) expect(openCodeClearPtyMock).toHaveBeenCalled() expect(piClearPtyMock).toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + expect.any(String), + null, + { resetIncarnation: true } + ) }) it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => { @@ -2329,8 +2340,14 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } handlers.clear() - registerPtyHandlers(mainWindow as never) + registerPtyHandlers(mainWindow as never, runtime as never) await expect( handlers.get('pty:spawn')!(null, { cols: 80, @@ -2341,6 +2358,11 @@ describe('registerPtyHandlers', () => { ).rejects.toThrow(/spawn boom/) expect(openCodeClearPtyMock).not.toHaveBeenCalled() expect(piClearPtyMock).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + 'caller-owned-session', + null, + { resetIncarnation: true } + ) }) it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 03a99cb2946..0c1803d4555 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -66,6 +66,7 @@ import { import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' +import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -3044,6 +3045,14 @@ export function registerPtyHandlers( const effectiveSessionAppId = sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined const isMintedSessionId = requestedSessionId === undefined && isDaemonHostSpawn + const expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd, + sessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true let hostSessionBinding: { store: NonNullable @@ -3199,12 +3208,20 @@ export function registerPtyHandlers( ? reservePaneSpawn(materializedPaneKey) : null let result: PtySpawnResult + let preparedProvisionalExecutionContext = false try { try { if (args.preAllocatedHandle) { trustedTerminalHandleEnv.add(args.preAllocatedHandle) } const expectedPtyId = effectiveSessionAppId ?? sessionId + if (isDaemonHostSpawn && expectedPtyId) { + preparedProvisionalExecutionContext = + runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { + resetIncarnation: isMintedSessionId, + preserveExisting: !isMintedSessionId + }) ?? false + } const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 @@ -3216,7 +3233,16 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + runtime?.preparePtyExecutionContext?.( + result.id, + args.connectionId ? null : (result.wslDistro ?? expectedWslDistro) + ) } catch (err) { + if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { + runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { + resetIncarnation: true + }) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = @@ -3865,6 +3891,14 @@ export function registerPtyHandlers( effectiveSessionId !== undefined ? getRelayPtyId(args.connectionId, effectiveSessionId) : undefined + const expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd, + sessionId: effectiveSessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) const preSpawnStartupTerminalColorReplyPtyId = startupTerminalColorQueryReplyColors && effectiveSessionId !== undefined @@ -4158,6 +4192,7 @@ export function registerPtyHandlers( markHiddenRendererPty(preSpawnHiddenMarkId) } let result: PtySpawnResult + let preparedProvisionalExecutionContext = false try { try { if (preAllocatedHandle) { @@ -4173,6 +4208,13 @@ export function registerPtyHandlers( } spawnTiming.mark('options') const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + if (isDaemonHostSpawn && expectedPtyId) { + preparedProvisionalExecutionContext = + runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { + resetIncarnation: isMintedSessionId, + preserveExisting: !isMintedSessionId + }) ?? false + } const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 @@ -4184,8 +4226,17 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + runtime?.preparePtyExecutionContext?.( + result.id, + args.connectionId ? null : (result.wslDistro ?? expectedWslDistro) + ) spawnTiming.mark('provider_spawn') } catch (err) { + if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { + runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { + resetIncarnation: true + }) + } // Why: a failed spawn must not leave a stale hidden mark on a session // id a later visible attach may reuse. if (preSpawnHiddenMarkId !== null) { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 2e98dbca243..8b04b0b096a 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -111,6 +111,8 @@ 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 /** 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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 44bb1117c43..8045a45cd6a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8648,6 +8648,7 @@ describe('OrcaRuntimeService', () => { it('keeps SSH OSC7 cwd POSIX when the desktop runtime is on Windows', async () => { setPlatform('win32') const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-ssh', 'Ubuntu', { resetIncarnation: true }) runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-conn-1') runtime.onPtyData('pty-ssh', '\x1b]7;file://remote-host/home/me/repo/src\x07', 123) @@ -8655,9 +8656,78 @@ describe('OrcaRuntimeService', () => { const internals = runtime as unknown as { terminalCwdByPtyId: Map terminalFileUriHostnameByPtyId: Map + wslDistroByPtyId: Map } expect(internals.terminalCwdByPtyId.get('pty-ssh')).toBe('/home/me/repo/src') expect(internals.terminalFileUriHostnameByPtyId.get('pty-ssh')).toBe('remote-host') + expect(internals.wslDistroByPtyId.has('pty-ssh')).toBe(false) + }) + + it('uses per-incarnation WSL context before registration and across simultaneous distros', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true }) + runtime.preparePtyExecutionContext('pty-debian', 'Debian', { resetIncarnation: true }) + runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID) + runtime.registerPty('pty-debian', TEST_WORKTREE_ID) + + runtime.onPtyData('pty-ubuntu', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + runtime.onPtyData('pty-debian', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + + const cwds = (runtime as unknown as { terminalCwdByPtyId: Map }) + .terminalCwdByPtyId + expect(cwds.get('pty-ubuntu')).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + expect(cwds.get('pty-debian')).toBe('\\\\wsl.localhost\\Debian\\home\\me\\repo') + }) + + it('does not retain WSL context when a PTY id is reused', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-reused', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-reused', TEST_WORKTREE_ID) + runtime.onPtyExit('pty-reused', 0) + + runtime.preparePtyExecutionContext('pty-reused', null, { resetIncarnation: true }) + runtime.registerPty('pty-reused', TEST_WORKTREE_ID) + runtime.onPtyData('pty-reused', '\x1b]7;file://server/share/repo\x07', 1) + + const cwds = (runtime as unknown as { terminalCwdByPtyId: Map }) + .terminalCwdByPtyId + expect(cwds.get('pty-reused')).toBe('\\\\server\\share\\repo') + }) + + it('preserves immutable context while a live daemon attach is unresolved', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-attached', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-attached', TEST_WORKTREE_ID) + + const changed = runtime.preparePtyExecutionContext('pty-attached', 'Debian', { + preserveExisting: true + }) + runtime.onPtyData('pty-attached', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + + const cwd = ( + runtime as unknown as { terminalCwdByPtyId: Map } + ).terminalCwdByPtyId.get('pty-attached') + expect(changed).toBe(false) + expect(cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + }) + + it('infers local reconstructed WSL context from a WSL UNC worktree', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty( + 'pty-reconstructed', + `${TEST_REPO_ID}::\\\\wsl.localhost\\Ubuntu\\home\\me\\repo` + ) + + runtime.onPtyData('pty-reconstructed', '\x1b]7;file://DESKTOP/home/me/repo/src\x07', 1) + + const cwd = ( + runtime as unknown as { terminalCwdByPtyId: Map } + ).terminalCwdByPtyId.get('pty-reconstructed') + expect(cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\src') }) it('clears stale terminal file URI hostnames after empty-host OSC7 cwd updates', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 60160117a6a..cf880c038db 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -245,7 +245,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, isWorkspaceKey, @@ -1014,6 +1014,7 @@ type RuntimePtyWorktreeRecord = { // Why: a Windows host can own both native and WSL panes; preamble command // selection must follow the pane that executes it, not process.platform. isWsl: boolean | null + wslDistro: string | null // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the // spawn-time tab/pane identity so later reveals can adopt under the env key. tabId: string | null @@ -1246,7 +1247,7 @@ type RuntimePtyController = { leafId?: string sessionId?: string persistHostSessionBinding?: boolean - }): Promise<{ id: string }> + }): Promise<{ id: string; wslDistro?: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise @@ -2323,6 +2324,7 @@ export class OrcaRuntimeService { // iterates them all. Listeners are cleaned up via subscriptionCleanups. private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() + private wslDistroByPtyId = new Map() private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() @@ -6007,6 +6009,44 @@ export class OrcaRuntimeService { } } + preparePtyExecutionContext( + ptyId: string, + wslDistro: string | null, + options: { resetIncarnation?: boolean; preserveExisting?: boolean } = {} + ): boolean { + const pty = this.ptysById.get(ptyId) + const hadExistingContext = this.wslDistroByPtyId.has(ptyId) || pty !== undefined + if (options.preserveExisting && hadExistingContext) { + // Why: attach-time settings are only a fallback; a live PTY's recorded + // execution namespace remains authoritative until its provider replies. + return false + } + + if (options.resetIncarnation) { + this.disposeHeadlessTerminal(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + } + + const previous = this.wslDistroByPtyId.get(ptyId) ?? null + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + if (pty) { + pty.wslDistro = wslDistro + } + if (!options.resetIncarnation && previous !== wslDistro && this.headlessTerminals.has(ptyId)) { + // Why: bytes parsed with two distro namespaces would leave an internally + // inconsistent CWD; rebuild from the provider's authoritative snapshot. + this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId) + } + return options.resetIncarnation === true || !hadExistingContext || previous !== wslDistro + } + /** Record the spawn launch command so the per-PTY Command Code detector can * arm from it (renderer startupCommand parity). Best-effort: a chunk that * beats this call falls back to the detector's banner arming. */ @@ -6828,7 +6868,10 @@ export class OrcaRuntimeService { return uri ? parseFileUriPathParts(uri, { pathFlavor, - remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32' + remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32', + wslDistro: pty?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined) }) : null } @@ -7036,6 +7079,13 @@ export class OrcaRuntimeService { this.disposeHeadlessTerminal(ptyId) } this.providerModeTrackersByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.wslDistro = null + } if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { this.resetTrackedTerminalStateForProviderGeneration(ptyId) } @@ -7521,6 +7571,9 @@ export class OrcaRuntimeService { pathFlavor, remotePosixFileUriAuthority: !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + wslDistro: this.ptysById.get(ptyId)?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), // Why: replies take the provider input path (same entry as pty:write — // daemon shell-ready gating and the SSH relay write apply unchanged), // NOT writePtyInput, so renderer interactive-output metering never @@ -7573,6 +7626,37 @@ export class OrcaRuntimeService { return state } + private replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void { + this.disposeHeadlessTerminal(ptyId) + this.providerSnapshotPreferredPtys.add(ptyId) + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) + this.headlessTerminals.set(ptyId, state) + state.writeChain = state.writeChain + .then(async () => { + const snapshot = await this.serializeProviderTerminalBuffer(ptyId) + if (!snapshot) { + return + } + const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + this.recordOsc7MetadataForPty(ptyId, data) + await state.emulator.write(data) + if (snapshot.cwd !== undefined) { + state.emulator.setCwd(snapshot.cwd) + } + if (snapshot.oscLinks !== undefined) { + state.emulator.setRestoredOscLinks(snapshot.oscLinks) + } + state.outputSequence = snapshot.seq + }) + .catch(() => { + // Best-effort: live bytes already chain behind this replacement state. + }) + .finally(() => { + this.providerSnapshotPreferredPtys.delete(ptyId) + }) + } + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { const state = this.headlessTerminals.get(ptyId) if (!state) { @@ -9117,6 +9201,7 @@ export class OrcaRuntimeService { this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. @@ -18918,6 +19003,9 @@ export class OrcaRuntimeService { : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } this.registerPty(result.id, workspace.id, workspace.connectionId) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { @@ -20091,6 +20179,9 @@ export class OrcaRuntimeService { preAllocatedHandle }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } this.registerPty(result.id, workspace.id, workspace.connectionId) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { @@ -21582,17 +21673,29 @@ export class OrcaRuntimeService { | 'title' | 'connectionId' | 'isWsl' + | 'wslDistro' > > = {} ): RuntimePtyWorktreeRecord { let pty = this.ptysById.get(ptyId) if (!pty) { const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null + const connectionId = state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + const fallbackWslDistro = + process.platform === 'win32' && connectionId === null && worktreePath + ? parseWslUncPath(worktreePath)?.distro + : undefined + const wslDistro = + connectionId === null + ? (state.wslDistro ?? this.wslDistroByPtyId.get(ptyId) ?? fallbackWslDistro ?? null) + : null pty = { ptyId, worktreeId, - connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null, + connectionId, isWsl: state.isWsl ?? null, + wslDistro, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, launchConfig: null, @@ -21625,6 +21728,13 @@ export class OrcaRuntimeService { this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) } this.ptysById.set(ptyId, pty) + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else if (connectionId !== null) { + // Why: restored SSH IDs can collide with stale local parser state; + // connection ownership must win before their first output is parsed. + this.wslDistroByPtyId.delete(ptyId) + } // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) @@ -21634,10 +21744,22 @@ export class OrcaRuntimeService { pty.worktreeId = worktreeId if (state.connectionId !== undefined) { pty.connectionId = state.connectionId + if (state.connectionId !== null) { + pty.wslDistro = null + this.wslDistroByPtyId.delete(ptyId) + } } if (state.isWsl !== undefined) { pty.isWsl = state.isWsl } + if (state.wslDistro !== undefined) { + pty.wslDistro = state.wslDistro + if (state.wslDistro) { + this.wslDistroByPtyId.set(ptyId, state.wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + } if (state.tabId !== undefined) { pty.tabId = state.tabId } @@ -21799,6 +21921,7 @@ export class OrcaRuntimeService { this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 1d27b694d2c..504ac981010 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,5 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' -import { parseWslUncPath } from '../shared/wsl-paths' +import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' + +export { toWindowsWslPath } from '../shared/wsl-paths' export type WslPathInfo = { distro: string @@ -89,26 +91,6 @@ export function toLinuxPath(windowsPath: string): string { return `/mnt/${driveLetter}/${rest}` } -/** - * Convert a Linux path inside a WSL distro to a Windows path. - * - * Why two forms: paths under /mnt//... are Windows-native filesystem - * paths that WSL exposes via the DrvFs mount. These map back to their native - * Windows form (e.g. /mnt/c/Users → C:\Users). All other paths live on the - * WSL virtual filesystem and use the UNC form (\\wsl.localhost\Distro\...). - */ -export function toWindowsWslPath(linuxPath: string, distro: string): string { - // /mnt/c/Users/... → C:\Users\... - const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) - if (mntMatch) { - const driveLetter = mntMatch[1].toUpperCase() - const rest = (mntMatch[2] || '').replace(/\//g, '\\') - return `${driveLetter}:${rest || '\\'}` - } - - return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` -} - // ─── WSL home directory resolution ────────────────────────────────── const wslHomeCache = new Map() diff --git a/src/shared/wsl-paths.test.ts b/src/shared/wsl-paths.test.ts index 498e3c02860..6a9641d41ac 100644 --- a/src/shared/wsl-paths.test.ts +++ b/src/shared/wsl-paths.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { foldWslUncPathCaseInsensitiveParts, isWslUncPath, parseWslUncPath } from './wsl-paths' +import { + foldWslUncPathCaseInsensitiveParts, + isWslUncPath, + parseWslUncPath, + toWindowsWslPath +} from './wsl-paths' describe('wsl path helpers', () => { it('parses modern and legacy WSL UNC paths without platform checks', () => { @@ -17,6 +22,16 @@ describe('wsl path helpers', () => { expect(isWslUncPath('C:\\Users\\jin\\repo')).toBe(false) expect(isWslUncPath('/home/jin/repo')).toBe(false) }) + + it.each([ + ['/home/jin/repo', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'], + ['/', '\\\\wsl.localhost\\Ubuntu\\'], + ['/mnt/c/Users/jin', 'C:\\Users\\jin'], + ['/MNT/c/Repo', '\\\\wsl.localhost\\Ubuntu\\MNT\\c\\Repo'], + ['/mnt/C/Repo', '\\\\wsl.localhost\\Ubuntu\\mnt\\C\\Repo'] + ])('converts %s without folding case-sensitive Linux paths', (linuxPath, expected) => { + expect(toWindowsWslPath(linuxPath, 'Ubuntu')).toBe(expected) + }) }) describe('foldWslUncPathCaseInsensitiveParts', () => { diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 3db38712251..f8acb9d69d6 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -20,6 +20,17 @@ export function isWslUncPath(path: string): boolean { return parseWslUncPath(path) !== null } +/** Convert an absolute Linux path in a known WSL distro to its Windows form. */ +export function toWindowsWslPath(linuxPath: string, distro: string): string { + const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) + if (mntMatch) { + const rest = (mntMatch[2] || '').replace(/\//g, '\\') + return `${mntMatch[1].toUpperCase()}:${rest || '\\'}` + } + + return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` +} + // Why: Windows folds the share (\\wsl$ aliases \\wsl.localhost), the distro, and // drvfs /mnt/ tails case-insensitively; the rest of the Linux path is not. export function foldWslUncPathCaseInsensitiveParts(path: string): string | null {