mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: preserve WSL CWD across daemon sleep wake
This commit is contained in:
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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>()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user