fix: preserve WSL CWD across daemon sleep wake

This commit is contained in:
Jinwoo Hong
2026-07-19 16:04:05 -07:00
parent 08f446190a
commit 3efc930908
30 changed files with 887 additions and 78 deletions
+153
View File
@@ -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://<machine-hostname>/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 `\\<machine-hostname>\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\<user>\...`.
2. Emit `OSC 7;file://<machine-hostname>/home/<user>/...`.
3. Sleep for 38-69 seconds, then wake.
Both triggered attempts failed. The saved CWD became `\\<machine-hostname>\home\<user>\...`; 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/<ascii-letter>` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\<distro>`. `/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.
@@ -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<DaemonCreateOrAttachResult, 'launchAgent' | 'historySeeded' | 'wslDistro'> {
return {
...(session.launchAgent ? { launchAgent: session.launchAgent } : {}),
...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}),
...(session.wslDistro ? { wslDistro: session.wslDistro } : {})
}
}
+41 -1
View File
@@ -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))
+63 -5
View File
@@ -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<string, string>()
private wslDistrosBySessionId = new Map<string, string>()
// 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<PtySpawnResult> {
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<typeof result.launchAgent> } =>
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 })
+1
View File
@@ -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 } : {})
}
}
+16
View File
@@ -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', () => {
+3 -1
View File
@@ -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,
+19
View File
@@ -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')
+6 -4
View File
@@ -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) {
+19 -1
View File
@@ -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()
})
})
+6
View File
@@ -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 }
+6 -24
View File
@@ -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
+9
View File
@@ -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')
+5 -1
View File
@@ -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
@@ -29,5 +29,6 @@ export type CreateOrAttachResult = {
shellState: ShellReadyState
historySeeded?: boolean
launchAgent?: TuiAgent
wslDistro?: string
attachToken: symbol
}
@@ -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)
}
}
})
})
+6 -4
View File
@@ -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
}
}
@@ -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
+1 -8
View File
@@ -359,14 +359,7 @@ export type RpcResponseError = {
export type RpcResponse<T = unknown> = RpcResponseOk<T> | 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
}
@@ -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')
})
})
+37
View File
@@ -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
}
+30
View File
@@ -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)
)
}
+24 -2
View File
@@ -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 () => {
+51
View File
@@ -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<typeof store>
@@ -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) {
+2
View File
@@ -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
+70
View File
@@ -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<string, string>
terminalFileUriHostnameByPtyId: Map<string, string>
wslDistroByPtyId: Map<string, string>
}
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<string, string> })
.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<string, string> })
.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<string, string> }
).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<string, string> }
).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', () => {
+127 -4
View File
@@ -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<boolean>
@@ -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<string, RuntimePtyWorktreeRecord>()
private wslDistroByPtyId = new Map<string, string>()
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
@@ -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) {
+3 -21
View File
@@ -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/<drive>/... 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<string, string>()
+16 -1
View File
@@ -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', () => {
+11
View File
@@ -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/<drive> tails case-insensitively; the rest of the Linux path is not.
export function foldWslUncPathCaseInsensitiveParts(path: string): string | null {