diff --git a/src/main/daemon/daemon-pending-output-protocol.ts b/src/main/daemon/daemon-pending-output-protocol.ts new file mode 100644 index 00000000000..0e6478e2a31 --- /dev/null +++ b/src/main/daemon/daemon-pending-output-protocol.ts @@ -0,0 +1,44 @@ +import type { TerminalSnapshot } from './terminal-snapshot' + +// Why: the 5s checkpoint used to re-serialize the full emulator buffer per +// tick, stalling the daemon's PTY pump for O(buffer). Incremental checkpoints +// take only the raw records accumulated since the last take; the emulator is +// serialized only when a full snapshot is explicitly requested. +export type PendingOutputRecord = + | { kind: 'output'; data: string } + | { kind: 'resize'; cols: number; rows: number } + | { kind: 'clear' } + +export type TakePendingOutputRequest = { + id: string + type: 'takePendingOutput' + payload: { + sessionId: string + /** When true, the daemon serializes a full snapshot in the SAME + * synchronous turn as the take. This atomicity is load-bearing: a + * snapshot taken in a separate request could include bytes that a later + * take would replay again, duplicating content on cold restore. */ + includeSnapshot?: boolean + /** True only for final checkpoints taken immediately before PTY teardown. + * This lets the daemon release pending parser-state bytes that should be + * preserved before the backing PTY is destroyed, without disturbing live + * full checkpoints or warm-reconnect checkpoints. */ + teardownSnapshot?: boolean + } +} + +export type TakePendingOutputResult = { + records: PendingOutputRecord[] + /** Drained pending queue. Absent on older daemons. includeSnapshot still + * keeps `records` as held-only so mixed-version adapters do not double-replay. */ + drainedRecords?: PendingOutputRecord[] + /** Non-decreasing per-session batch sequence. The history log stores it so the + * cold-restore reader can detect a lost batch (gap) and discard the log + * instead of replaying a stream with missing bytes. Snapshot, record, and + * overflow takes advance it; empty incremental takes repeat the prior value. */ + seq: number + /** True when the session's pending buffer exceeded its cap and records were + * dropped. The caller must fall back to a full snapshot checkpoint. */ + overflowed: boolean + snapshot: TerminalSnapshot | null +} diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index 42d728bc994..d6203b69524 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -14,6 +14,12 @@ import { parsePtySessionId } from './pty-session-id' import type { ListSessionsResult, SessionInfo } from './types' import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' import type { PtyProcessInfo } from '../providers/types' +import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' +import { + readWslGuestProcessInventory, + resolveWslGuestForegroundProcess, + type WslGuestProcessInventoryRead +} from '../providers/wsl-guest-process-inventory' export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { async listProcesses(opts?: { deadlineMs?: number }): Promise { @@ -45,12 +51,53 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti const admission = new PtyProcessListAdmission() const processes: PtyProcessInfo[] = [] const aliveSessionIds = new Set() + const evidenceEpoch = Date.now() + const wslByDistro = new Map() + if (process.platform === 'win32') { + const distros = new Set( + result.sessions + .filter((session) => session.isAlive && session.wslDistro) + .map((session) => session.wslDistro as string) + ) + await Promise.all( + [...distros].map(async (distro) => { + wslByDistro.set(distro, await readWslGuestProcessInventory(distro)) + }) + ) + } for (const session of result.sessions) { if (!session.isAlive) { continue } aliveSessionIds.add(session.sessionId) const { worktreeId } = parsePtySessionId(session.sessionId) + const inventory = session.wslDistro ? wslByDistro.get(session.wslDistro) : undefined + const resolution = + session.wslDistro && session.wslShellAnchor && inventory?.status === 'ok' + ? resolveWslGuestForegroundProcess(inventory.inventory, session.wslShellAnchor) + : null + const foregroundProcessEvidence: ForegroundProcessEvidence | undefined = session.wslDistro + ? resolution?.status === 'live' + ? { + verdict: 'live', + processName: resolution.processName, + authorityGeneration: session.incarnationId ?? 'daemon-wsl', + observationEpoch: evidenceEpoch, + capturedAgeMs: 0 + } + : { + verdict: 'unverifiable', + reason: + resolution?.status === 'unverifiable' + ? resolution.reason + : inventory?.status === 'unverifiable' + ? inventory.reason + : 'anchor_missing', + authorityGeneration: session.incarnationId ?? 'daemon-wsl', + observationEpoch: evidenceEpoch, + capturedAgeMs: 0 + } + : undefined processes.push( admission.admit({ id: session.sessionId, @@ -58,10 +105,14 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti ...(session.pid ? { rootProcessId: session.pid } : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', + title: + resolution?.status === 'live' && resolution.processName + ? resolution.processName + : 'shell', ...(worktreeId ? { worktreeId } : {}), ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...(foregroundProcessEvidence ? { foregroundProcessEvidence } : {}), ...this.validatedAgentSessionOwners(session.agentSessionOwners) }) ) diff --git a/src/main/daemon/session-checkpoint-access.ts b/src/main/daemon/session-checkpoint-access.ts new file mode 100644 index 00000000000..722a8a4152b --- /dev/null +++ b/src/main/daemon/session-checkpoint-access.ts @@ -0,0 +1,53 @@ +import type { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import type { SessionOutputPlane } from './session-output-plane' +import type { SessionShellReadyBarrier } from './session-shell-ready-barrier' +import type { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' +import type { TakePendingOutputResult, TerminalSnapshot } from './types' + +type SessionCheckpointAccessDeps = { + output: SessionOutputPlane + shellReady: SessionShellReadyBarrier + startupIngress: PtyStartupIngress + recoveryBarrier: TerminalShellRecoveryBarrier + isDisposed: () => boolean +} + +export class SessionCheckpointAccess { + constructor(private readonly deps: SessionCheckpointAccessDeps) {} + + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { + this.deps.startupIngress.snapshotBarrier() + return this.deps.output.getSnapshot(opts) + } + + getPartialEscapeTailAnsi(): string { + return this.deps.output.getPartialEscapeTailAnsi() + } + + getAppliedSize(): { cols: number; rows: number } | null { + return this.deps.output.getAppliedSize() + } + + takePendingOutput( + includeSnapshot: boolean, + opts: { teardownSnapshot?: boolean } = {} + ): TakePendingOutputResult | null { + if (this.deps.isDisposed()) { + return null + } + const releasedHeldBytes = + includeSnapshot && opts.teardownSnapshot === true ? this.prepareForFinalSnapshot() : '' + return this.deps.output.takePendingOutput(includeSnapshot, releasedHeldBytes, () => + this.getSnapshot() + ) + } + + prepareForFinalSnapshot(): string { + const held = this.deps.shellReady.releaseHeldBytes() + this.deps.startupIngress.snapshotBarrier() + // Why last: snapshotBarrier can emit held spans into the barrier, and a + // teardown checkpoint mid-episode must not lose the barrier's queued bytes. + this.deps.recoveryBarrier.flushPending() + return held + } +} diff --git a/src/main/daemon/session-wsl-shell-anchor-tracker.ts b/src/main/daemon/session-wsl-shell-anchor-tracker.ts new file mode 100644 index 00000000000..fe24a8413e5 --- /dev/null +++ b/src/main/daemon/session-wsl-shell-anchor-tracker.ts @@ -0,0 +1,36 @@ +import { + createShellStartupIdentityScanState, + scanForShellStartupIdentity, + type ShellStartupIdentityScanState +} from '../shell-startup-identity-scanner' +import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' + +export class SessionWslShellAnchorTracker { + private scanState: ShellStartupIdentityScanState | null + private _anchor: WslShellProcessAnchor | null = null + + constructor(private readonly distro: string | null) { + this.scanState = distro ? createShellStartupIdentityScanState() : null + } + + get anchor(): WslShellProcessAnchor | null { + return this._anchor + } + + scan(data: string): string { + if (!this.scanState) { + return data + } + const scanned = scanForShellStartupIdentity(this.scanState, data) + if (scanned.shellIdentity) { + if (scanned.shellIdentity.distro.toLowerCase() === this.distro?.toLowerCase()) { + this._anchor = scanned.shellIdentity + } + this.scanState = null + } else if (scanned.shellPid) { + // Legacy PID-only markers are consumed but never accepted as evidence. + this.scanState = null + } + return scanned.output + } +} diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 191b8ee7eaa..93993e38d78 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -166,6 +166,24 @@ describe('Session', () => { }) describe('data flow', () => { + it('captures and strips the complete WSL shell anchor from live output', () => { + createSession({ wslDistro: 'Ubuntu' }) + const marker = + '\x1b]777;orca-shell-start:v2:Ubuntu:01234567-89ab-cdef-0123-456789abcdef:123:456:/dev/pts/8\x07' + const received: string[] = [] + session.attachClient({ onData: (data) => received.push(data), onExit: () => {} }) + subprocess.simulateData(`before${marker}after`) + expect(received.join('')).toContain('beforeafter') + expect(received.join('')).not.toContain('orca-shell-start') + expect(session.getWslShellAnchor()).toEqual({ + distro: 'Ubuntu', + bootId: '01234567-89ab-cdef-0123-456789abcdef', + shellPid: 123, + shellStartTime: 456, + tty: '/dev/pts/8' + }) + }) + it('does not confirm shell ownership from historical replay bytes', () => { createSession({ historySeedChunks: ['\x1b[?1049hOLD-TUI\x1b]133;D;137\x07old-shell-marker'] diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b0f1dfa538d..25935631e51 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -14,13 +14,11 @@ import type { SessionOptions } from './session-options' import type { TuiAgent } from '../../shared/tui-agent' import { randomUUID } from 'node:crypto' import { PtyStartupIngress } from '../../shared/pty-startup-ingress' +import { SessionCheckpointAccess } from './session-checkpoint-access' +import { SessionWslShellAnchorTracker } from './session-wsl-shell-anchor-tracker' -import type { - SessionState, - ShellReadyState, - TakePendingOutputResult, - TerminalSnapshot -} from './types' +import type { SessionState, ShellReadyState, TakePendingOutputResult } from './types' +import type { TerminalSnapshot } from './terminal-snapshot' import type { TerminalExitCause } from '../../shared/terminal-exit-cause' export class Session { @@ -40,6 +38,8 @@ export class Session { private readonly termination: SessionTerminationController private readonly startupIngress: PtyStartupIngress private readonly recoveryBarrier: TerminalShellRecoveryBarrier + private readonly checkpoint: SessionCheckpointAccess + private readonly wslShellAnchorTracker: SessionWslShellAnchorTracker constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -86,9 +86,18 @@ export class Session { write: (data) => this.subprocess.write(data), onEmission: (emission) => this.recoveryBarrier.accept(emission) }) + this.checkpoint = new SessionCheckpointAccess({ + output: this.output, + shellReady: this.shellReady, + startupIngress: this.startupIngress, + recoveryBarrier: this.recoveryBarrier, + isDisposed: () => this._disposed + }) + this.wslShellAnchorTracker = new SessionWslShellAnchorTracker(this.wslDistro) this.shellReady.startPromptReadinessProbe() this.subprocess.onData((data) => { if (!this._disposed) { + data = this.wslShellAnchorTracker.scan(data) this.shellReady.ingestSubprocessData(data) } }) @@ -134,6 +143,10 @@ export class Session { return this.subprocess.pid } + getWslShellAnchor() { + return this.wslShellAnchorTracker.anchor + } + /** Terminate this session's pty job object. `unavailable` is not proof of death. */ terminateOwnedTree(): JobTerminationOutcome { return this.subprocess.terminateOwnedTree() @@ -222,30 +235,22 @@ export class Session { } getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { - this.startupIngress.snapshotBarrier() - return this.output.getSnapshot(opts) + return this.checkpoint.getSnapshot(opts) } getPartialEscapeTailAnsi(): string { - return this.output.getPartialEscapeTailAnsi() + return this.checkpoint.getPartialEscapeTailAnsi() } getAppliedSize(): { cols: number; rows: number } | null { - return this.output.getAppliedSize() + return this.checkpoint.getAppliedSize() } takePendingOutput( includeSnapshot: boolean, opts: { teardownSnapshot?: boolean } = {} ): TakePendingOutputResult | null { - if (this._disposed) { - return null - } - const releasedHeldBytes = - includeSnapshot && opts.teardownSnapshot === true ? this.prepareForFinalSnapshot() : '' - return this.output.takePendingOutput(includeSnapshot, releasedHeldBytes, () => - this.getSnapshot() - ) + return this.checkpoint.takePendingOutput(includeSnapshot, opts) } getCwd(): string | null { @@ -275,12 +280,7 @@ export class Session { } prepareForFinalSnapshot(): string { - const held = this.shellReady.releaseHeldBytes() - this.startupIngress.snapshotBarrier() - // Why last: snapshotBarrier can emit held spans into the barrier, and a - // teardown checkpoint mid-episode must not lose the barrier's queued bytes. - this.recoveryBarrier.flushPending() - return held + return this.checkpoint.prepareForFinalSnapshot() } dispose(): void { diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029..25644eeba70 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -70,6 +70,9 @@ describePosix('daemon shell-ready bash wrapper', () => { expect(bashRc).toContain('printf "\\033]133;D;%s\\007"') expect(bashRc).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + expect(bashRc).toContain( + 'printf "\\033]777;orca-shell-start:v2:%s:%s:%s:%s:%s\\007" "$_orca_distro" "$_orca_boot" "$$" "$_orca_start" "$_orca_tty"' + ) expect(bashRc).toContain('printf "\\033]133;C\\007"') expect(bashRc).toContain('__orca_prepend_prompt_command "__orca_osc133_precmd"') expect(bashRc).toContain('__orca_append_prompt_command "__orca_osc133_epilogue"') diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 6772a52d46e..27e8f0beaf5 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -436,6 +436,9 @@ describePosix('daemon shell-ready launch config', () => { expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + expect(zshenv).toContain( + 'printf "\\033]777;orca-shell-start:v2:%s:%s:%s:%s:%s\\007" "$_orca_distro" "$_orca_boot" "$$" "$_orca_start" "$_orca_tty"' + ) expect(zshenv.indexOf('builtin export ZDOTDIR=')).toBeLessThan( zshenv.indexOf('builtin source -- "$_orca_user_zshenv"') ) diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index 3ae2d5e845e..a3cf59d669a 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -25,7 +25,8 @@ export function listLiveTerminalHostSessions( cols: size?.cols ?? 0, rows: size?.rows ?? 0, createdAt: 0, - agentSessionOwners: agentSessionOwners.listForPty(session.sessionId) + agentSessionOwners: agentSessionOwners.listForPty(session.sessionId), + ...(session.getWslShellAnchor() ? { wslShellAnchor: session.getWslShellAnchor()! } : {}) }) } return result diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index bffdc1d2ed5..8718a801f78 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -21,7 +21,14 @@ import type { AgentSessionOwnerBinding, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' +import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' +import type { TakePendingOutputRequest } from './daemon-pending-output-protocol' +export type { + PendingOutputRecord, + TakePendingOutputRequest, + TakePendingOutputResult +} from './daemon-pending-output-protocol' export type { TerminalModes } from './terminal-modes' import type { TerminalSnapshot } from './terminal-snapshot' export type { TerminalSnapshot } from './terminal-snapshot' @@ -256,51 +263,6 @@ export type GetSizeRequest = { } } -// ─── Incremental checkpoint records (v13+) ────────────────────────── -// Why: the 5s checkpoint used to re-serialize the full emulator buffer per -// tick, stalling the daemon's PTY pump for O(buffer). Incremental checkpoints -// take only the raw records accumulated since the last take; the emulator is -// serialized only when a full snapshot is explicitly requested (clean -// shutdown, pending-buffer overflow, or the on-disk log reaching its cap). -export type PendingOutputRecord = - | { kind: 'output'; data: string } - | { kind: 'resize'; cols: number; rows: number } - | { kind: 'clear' } - -export type TakePendingOutputRequest = { - id: string - type: 'takePendingOutput' - payload: { - sessionId: string - /** When true, the daemon serializes a full snapshot in the SAME - * synchronous turn as the take. This atomicity is load-bearing: a - * snapshot taken in a separate request could include bytes that a later - * take would replay again, duplicating content on cold restore. */ - includeSnapshot?: boolean - /** True only for final checkpoints taken immediately before PTY teardown. - * This lets the daemon release pending parser-state bytes that should be - * preserved before the backing PTY is destroyed, without disturbing live - * full checkpoints or warm-reconnect checkpoints. */ - teardownSnapshot?: boolean - } -} - -export type TakePendingOutputResult = { - records: PendingOutputRecord[] - /** Drained pending queue. Absent on older daemons. includeSnapshot still - * keeps `records` as held-only so mixed-version adapters do not double-replay. */ - drainedRecords?: PendingOutputRecord[] - /** Non-decreasing per-session batch sequence. The history log stores it so the - * cold-restore reader can detect a lost batch (gap) and discard the log - * instead of replaying a stream with missing bytes. Snapshot, record, and - * overflow takes advance it; empty incremental takes repeat the prior value. */ - seq: number - /** True when the session's pending buffer exceeded its cap and records were - * dropped. The caller must fall back to a full snapshot checkpoint. */ - overflowed: boolean - snapshot: TerminalSnapshot | null -} - export type DaemonRequest = | CreateOrAttachRequest | HistorySeedProtocol.TerminalHistorySeedTransferRequest @@ -379,6 +341,8 @@ export type SessionInfo = { rows: number createdAt: number agentSessionOwners?: AgentSessionOwnerBinding[] + /** Optional identity emitted by an Orca-owned WSL shell wrapper. */ + wslShellAnchor?: WslShellProcessAnchor } // Why: SessionInfo + source protocol version, so the Manage Sessions UI can diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 9f99d1e099c..6336c8cbefc 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -55,6 +55,9 @@ export function installPtyInspectIpcHandlers(deps: { id: session.id, cwd: session.cwd, title: session.title, + ...(session.foregroundProcessEvidence + ? { foregroundProcessEvidence: session.foregroundProcessEvidence } + : {}), // Why: the renderer's binding map is empty during restore, so ownership is the only // liveness evidence it has. Absence is authoritative only from a provider that // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts index c42c06d9121..f8e272c36af 100644 --- a/src/main/providers/local-pty-foreground-inspection.ts +++ b/src/main/providers/local-pty-foreground-inspection.ts @@ -8,8 +8,14 @@ import { ptyAgentForegroundContextPaths, ptyLastRecognizedForeground, ptyProcesses, - ptyShellName + ptyShellName, + ptyWslDistroById, + ptyWslShellAnchors } from './local-pty-provider-state' +import { + readWslGuestProcessInventory, + resolveWslGuestForegroundProcess +} from './wsl-guest-process-inventory' import { resolveStableForegroundProcess } from './stable-foreground-process' import { canRevalidateCachedAgentWithoutScan, @@ -45,6 +51,34 @@ export async function getLocalPtyForegroundProcess(id: string): Promise @@ -52,6 +53,9 @@ export const ptyWorktreeId = new Map() export const ptyInitialCwd = new Map() // Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity. export const ptyWslDistroById = new Map() +/** Guest shell identity observed from the WSL wrapper's OSC startup marker. */ +export type WslPtyShellAnchor = WslShellProcessAnchor +export const ptyWslShellAnchors = new Map() // Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination. export const ptyDisposables = new Map void }[]>() export const ptyExitDisposables = new Map void }>() @@ -123,6 +127,7 @@ export function clearPtyState(id: string): void { ptyWorktreeId.delete(id) ptyInitialCwd.delete(id) ptyWslDistroById.delete(id) + ptyWslShellAnchors.delete(id) ptyLoadGeneration.delete(id) ptyTerminationMode.delete(id) ptyReportsChildExitStatus.delete(id) diff --git a/src/main/providers/local-pty-session-activation.ts b/src/main/providers/local-pty-session-activation.ts index a5991581f82..40d0d776f31 100644 --- a/src/main/providers/local-pty-session-activation.ts +++ b/src/main/providers/local-pty-session-activation.ts @@ -28,11 +28,17 @@ import { ptyTerminationMode, ptyWorktreeId, ptyWslDistroById, + ptyWslShellAnchors, startupIngressByPty } from './local-pty-provider-state' import { createLocalPtyShellReadinessSession } from './local-pty-shell-readiness-session' import { destroyPtyProcess, createPtyPhysicalExit } from './local-pty-termination' import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready-startup-command' +import { + createShellStartupIdentityScanState, + scanForShellStartupIdentity, + type ShellStartupIdentityScanState +} from '../shell-startup-identity-scanner' import type { PtySpawnOptions, PtySpawnResult } from './types' export function activateLocalPtySession(args: { @@ -105,6 +111,11 @@ export function activateLocalPtySession(args: { onEmission: emitIngressData }) startupIngressByPty.set(id, startupIngress) + // A Windows host only sees wsl.exe. Keep the guest PID marker separate from + // the shell-ready scanner so WSL panes without a startup command are still + // anchored, and strip it before bytes reach xterm/the user shell. + let wslIdentityScanState: ShellStartupIdentityScanState | null = + process.platform === 'win32' && spawnedWslDistro ? createShellStartupIdentityScanState() : null // Shell-ready startup command support const readiness = createLocalPtyShellReadinessSession({ @@ -117,7 +128,23 @@ export function activateLocalPtySession(args: { }) const disposables: { dispose: () => void }[] = [] const onDataDisposable = proc.onData((rawData) => { - readiness.acceptData(rawData) + let data = rawData + if (wslIdentityScanState) { + const scanned = scanForShellStartupIdentity(wslIdentityScanState, data) + data = scanned.output + if (scanned.shellIdentity) { + // The marker carries boot/start/TTY fencing. A legacy PID-only marker + // is intentionally ignored and remains unverifiable. + if (scanned.shellIdentity.distro.toLowerCase() === spawnedWslDistro!.toLowerCase()) { + ptyWslShellAnchors.set(id, scanned.shellIdentity) + } + wslIdentityScanState = null + } else if (scanned.shellPid) { + // Consume legacy PID-only markers without accepting them as evidence. + wslIdentityScanState = null + } + } + readiness.acceptData(data) }) if (onDataDisposable) { disposables.push(onDataDisposable) diff --git a/src/main/providers/local-pty-session-operations.ts b/src/main/providers/local-pty-session-operations.ts index 0d02b45e5df..699c49f22b4 100644 --- a/src/main/providers/local-pty-session-operations.ts +++ b/src/main/providers/local-pty-session-operations.ts @@ -16,12 +16,19 @@ import { ptyTerminalHandle, ptyWorktreeId, ptyWslDistroById, + ptyWslShellAnchors, startupIngressByPty, type DataCallback, type ExitCallback } from './local-pty-provider-state' import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtyProcessInfo } from './types' +import { + readWslGuestProcessInventory, + resolveWslGuestForegroundProcess, + type WslGuestProcessInventoryRead +} from './wsl-guest-process-inventory' +import type { ForegroundProcessEvidence } from '../../shared/foreground-process-evidence' export function writeLocalPty(id: string, data: string): boolean { // Cooked PTYs echo private DSR/OSC replies; CPR/DA stay immediate unless one of @@ -116,15 +123,72 @@ export function closeLocalPtyStartupQueryAuthority(id: string): number { } export async function listLocalPtyProcesses(): Promise { - return Array.from(ptyProcesses.entries()).map(([id, proc]) => ({ - id, - ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), - cwd: ptyInitialCwd.get(id) ?? '', - title: proc.process || ptyShellName.get(id) || 'shell', - ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), - ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), - ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}) - })) + const entries = Array.from(ptyProcesses.entries()) + const evidenceEpoch = Date.now() + const wslByDistro = new Map() + for (const [id] of entries) { + const distro = ptyWslDistroById.get(id) + if (distro) { + const ids = wslByDistro.get(distro) ?? [] + ids.push(id) + wslByDistro.set(distro, ids) + } + } + const inventories = new Map() + await Promise.all( + [...wslByDistro.keys()].map(async (distro) => { + inventories.set(distro, await readWslGuestProcessInventory(distro)) + }) + ) + + return entries.map(([id, proc]) => { + const distro = ptyWslDistroById.get(id) + let title = proc.process || ptyShellName.get(id) || 'shell' + let foregroundProcessEvidence: ForegroundProcessEvidence | undefined + if (distro) { + const read = inventories.get(distro) + const anchor = ptyWslShellAnchors.get(id) + const resolution = + read?.status === 'ok' && anchor + ? resolveWslGuestForegroundProcess(read.inventory, anchor) + : { + status: 'unverifiable' as const, + reason: read?.status === 'unverifiable' ? read.reason : 'anchor_missing' + } + foregroundProcessEvidence = + resolution.status === 'live' + ? { + verdict: 'live', + processName: resolution.processName, + authorityGeneration: ptyIncarnations.get(id) ?? 'local-wsl', + observationEpoch: evidenceEpoch, + capturedAgeMs: 0 + } + : { + verdict: 'unverifiable', + reason: resolution.reason, + authorityGeneration: ptyIncarnations.get(id) ?? 'local-wsl', + observationEpoch: evidenceEpoch, + capturedAgeMs: 0 + } + if (resolution.status === 'live') { + ptyWslShellAnchors.set(id, resolution.anchor) + if (resolution.processName) { + title = resolution.processName + } + } + } + return { + id, + ...(ptyIncarnations.get(id) ? { incarnationId: ptyIncarnations.get(id) } : {}), + cwd: ptyInitialCwd.get(id) ?? '', + title, + ...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}), + ...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {}), + ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), + ...(foregroundProcessEvidence ? { foregroundProcessEvidence } : {}) + } + }) } export async function getDefaultLocalPtyShell( diff --git a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts index af0fc4a5deb..2da020c785b 100644 --- a/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts +++ b/src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts @@ -287,7 +287,7 @@ describePosix('local PTY shell-ready launch config', () => { const zshenv = readFileSync(join(getShellReadyWrapperRoot(), 'zsh', '.zshenv'), 'utf8') expect(zshenv).toContain('builtin export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"') - expect(zshenv).toContain('printf "\\033]777;orca-shell-start:%s\\007" "$$"') + expect(zshenv).toContain('orca-shell-start:v2:') // The handback is what makes a nested Orca unable to inherit this dir, and // what stops /etc/zshrc deriving HISTFILE from it. expect(zshenv).toContain('builtin unset ORCA_ORIG_ZDOTDIR ORCA_ZSHENV_SOURCE_DIR') diff --git a/src/main/providers/wsl-guest-foreground-process-resolution.ts b/src/main/providers/wsl-guest-foreground-process-resolution.ts new file mode 100644 index 00000000000..e8959cdd618 --- /dev/null +++ b/src/main/providers/wsl-guest-foreground-process-resolution.ts @@ -0,0 +1,93 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor' +import type { + WslGuestProcessInventory, + WslGuestProcessRow +} from './wsl-guest-process-inventory-parser' + +export type WslGuestProcessAnchor = WslShellProcessAnchor + +export type WslGuestForegroundResolution = + | { status: 'live'; processName: string | null; anchor: WslGuestProcessAnchor } + | { status: 'unverifiable'; reason: string } + +function normalizeTty(tty: string): string { + return tty.startsWith('/dev/') ? tty : tty === '?' ? '' : `/dev/${tty}` +} + +/** Correlate one shell anchor to its foreground group and strict agent recognizer. */ +export function resolveWslGuestForegroundProcess( + inventory: WslGuestProcessInventory, + anchor: WslGuestProcessAnchor +): WslGuestForegroundResolution { + if (inventory.distro.toLowerCase() !== anchor.distro.toLowerCase()) { + return { status: 'unverifiable', reason: 'distro_mismatch' } + } + if (inventory.bootId !== anchor.bootId) { + return { status: 'unverifiable', reason: 'boot_id_mismatch' } + } + const shell = inventory.rows.find((row) => row.pid === anchor.shellPid) + if (!shell) { + return { status: 'unverifiable', reason: 'anchor_missing' } + } + const tty = normalizeTty(shell.tty) + if (!tty || (anchor.tty !== undefined && normalizeTty(anchor.tty) !== tty)) { + return { status: 'unverifiable', reason: 'tty_mismatch' } + } + if (shell.startTimeTicks !== anchor.shellStartTime) { + return { status: 'unverifiable', reason: 'pid_reused' } + } + if (shell.tpgid <= 0) { + return { status: 'unverifiable', reason: 'foreground_group_missing' } + } + const group = inventory.rows.filter( + (row) => row.pgid === shell.tpgid && normalizeTty(row.tty) === tty + ) + if (group.length === 0) { + return { status: 'unverifiable', reason: 'foreground_group_missing' } + } + // Multiplexers move the real command to another PTY/session. Without a + // session-aware anchor, the outer shell cannot make a truthful claim. + const isMultiplexer = (command: string): boolean => + /(?:^|\s)(?:tmux|screen)(?:\s|$)/.test(command) + if (group.some((row) => isMultiplexer(row.command))) { + return { status: 'unverifiable', reason: 'multiplexer_boundary' } + } + const byPid = new Map(inventory.rows.map((row) => [row.pid, row])) + const isShellDescendant = (row: WslGuestProcessRow): boolean => { + const seen = new Set() + let current: WslGuestProcessRow | undefined = row + while (current && !seen.has(current.pid)) { + if (current.pid === shell.pid) { + return true + } + seen.add(current.pid) + current = byPid.get(current.ppid) + } + return false + } + if ( + inventory.rows.some( + (row) => + row.pid !== shell.pid && + normalizeTty(row.tty) !== tty && + isMultiplexer(row.command) && + isShellDescendant(row) + ) + ) { + return { status: 'unverifiable', reason: 'multiplexer_boundary' } + } + const recognized = group + .map((row) => recognizeAgentProcessFromCommandLine(row.command)?.processName ?? null) + .filter((name): name is string => name !== null) + if (new Set(recognized).size > 1) { + return { status: 'unverifiable', reason: 'ambiguous_foreground_group' } + } + const nextAnchor = { + ...anchor, + bootId: inventory.bootId, + shellStartTime: shell.startTimeTicks, + tty + } + return { status: 'live', processName: recognized[0] ?? null, anchor: nextAnchor } +} diff --git a/src/main/providers/wsl-guest-process-inventory-parser.ts b/src/main/providers/wsl-guest-process-inventory-parser.ts new file mode 100644 index 00000000000..9b468057129 --- /dev/null +++ b/src/main/providers/wsl-guest-process-inventory-parser.ts @@ -0,0 +1,101 @@ +/** A process row read inside one WSL distro. */ +export type WslGuestProcessRow = { + pid: number + ppid: number + sid: number + pgid: number + tpgid: number + tty: string + stat: string + startTimeTicks: number + command: string +} + +export type WslGuestProcessInventory = { + distro: string + bootId: string + rows: readonly WslGuestProcessRow[] +} + +export function parseWslGuestProcessInventoryPayload( + payload: string, + distro: string +): WslGuestProcessInventory { + let bootId: string | null = null + let expectedCount: number | null = null + let seenCount: number | null = null + const rows: WslGuestProcessRow[] = [] + const pids = new Set() + for (const rawLine of payload.split(/\r?\n/)) { + // Remove only the transport CR; trailing spaces belong to the command + // remainder and must not be normalized away. + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (!line) { + continue + } + const boot = line.match(/^boot ([A-Fa-f0-9-]{8,128})$/) + if (boot) { + if (bootId !== null) { + throw new Error('duplicate_boot_id') + } + bootId = boot[1]! + continue + } + const count = line.match(/^count (\d+) (\d+)$/) + if (count) { + if (seenCount !== null) { + throw new Error('duplicate_count') + } + seenCount = Number(count[1]) + expectedCount = Number(count[2]) + continue + } + const row = line.match(/^row (\d+) (\d+) (\d+) (-?\d+) (-?\d+) (\S+) (\S+) (\d+)(?: (.*))?$/) + if (!row) { + throw new Error('malformed_row') + } + const values = [1, 2, 3, 4, 5, 8].map((index) => Number(row[index])) + const [pid, ppid, sid, pgid, tpgid, startTimeTicks] = values + if ( + !Number.isSafeInteger(pid) || + pid <= 0 || + !Number.isSafeInteger(ppid) || + ppid < 0 || + !Number.isSafeInteger(sid) || + sid < 0 || + !Number.isSafeInteger(pgid) || + pgid < 0 || + !Number.isSafeInteger(tpgid) || + (tpgid < 0 && tpgid !== -1) || + !Number.isSafeInteger(startTimeTicks) || + startTimeTicks < 0 || + pids.has(pid) + ) { + throw new Error('invalid_row') + } + pids.add(pid) + rows.push({ + pid, + ppid, + sid, + pgid, + tpgid, + tty: row[6]!, + stat: row[7]!, + startTimeTicks, + command: row[9] ?? '' + }) + } + if (!bootId) { + throw new Error('boot_id_missing') + } + if ( + seenCount === null || + expectedCount === null || + seenCount !== expectedCount || + seenCount !== rows.length + ) { + throw new Error('row_count_mismatch') + } + return { distro, bootId, rows } +} diff --git a/src/main/providers/wsl-guest-process-inventory.test.ts b/src/main/providers/wsl-guest-process-inventory.test.ts new file mode 100644 index 00000000000..a75fbd58758 --- /dev/null +++ b/src/main/providers/wsl-guest-process-inventory.test.ts @@ -0,0 +1,182 @@ +import { execFileSync } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: (...args: unknown[]) => runProcessMock(...args) +})) +import { + createWslGuestProcessInventoryReader, + parseWslGuestProcessInventoryPayload, + readWslGuestProcessInventory, + resetWslGuestProcessInventoryForTests, + resolveWslGuestForegroundProcess, + WSL_GUEST_INVENTORY_SCRIPT +} from './wsl-guest-process-inventory' + +const bootId = '01234567-89ab-cdef-0123-456789abcdef' + +function payload(rows: string, count = rows ? rows.split('\n').length : 0): string { + return `boot ${bootId}\n${rows}${rows ? '\n' : ''}count ${count} ${count}\n` +} + +describe('WSL guest process inventory', () => { + beforeEach(() => { + runProcessMock.mockReset() + }) + + it('keeps the guest inventory script valid for /bin/sh', () => { + expect(() => execFileSync('sh', ['-n'], { input: WSL_GUEST_INVENTORY_SCRIPT })).not.toThrow() + }) + + it('parses fixed fields and preserves whitespace in args', () => { + const inventory = parseWslGuestProcessInventoryPayload( + payload('row 100 90 90 100 100 pts/0 Sl+ 12345 /usr/bin/node --name "a b" --x'), + 'Ubuntu' + ) + expect(inventory.rows[0]).toMatchObject({ + pid: 100, + ppid: 90, + sid: 90, + pgid: 100, + tpgid: 100, + tty: 'pts/0', + stat: 'Sl+', + startTimeTicks: 12345, + command: '/usr/bin/node --name "a b" --x' + }) + }) + + it('keeps trailing spaces in the command remainder', () => { + const inventory = parseWslGuestProcessInventoryPayload( + payload('row 100 90 90 100 100 pts/0 Sl+ 12345 tool arg '), + 'Ubuntu' + ) + expect(inventory.rows[0]?.command).toBe('tool arg ') + }) + + it('rejects a partial capture instead of treating it as an empty inventory', () => { + expect(() => + parseWslGuestProcessInventoryPayload(`boot ${bootId}\ncount 0 1\n`, 'Ubuntu') + ).toThrow('row_count_mismatch') + }) + + it('fences boot, start-time, tty, and foreground group before recognizing agents', () => { + const inventory = parseWslGuestProcessInventoryPayload( + payload( + [ + 'row 100 90 90 100 120 pts/0 Ss+ 12345 bash', + 'row 120 100 100 120 120 pts/0 Sl+ 54321 codex --flag' + ].join('\n'), + 2 + ), + 'Ubuntu' + ) + const resolved = resolveWslGuestForegroundProcess(inventory, { + distro: 'Ubuntu', + bootId, + shellPid: 100, + shellStartTime: 12345, + tty: '/dev/pts/0' + }) + expect(resolved).toMatchObject({ status: 'live', processName: 'codex' }) + if (resolved.status === 'live') { + expect(resolved.anchor).toMatchObject({ + bootId, + shellStartTime: 12345, + tty: '/dev/pts/0' + }) + } + expect( + resolveWslGuestForegroundProcess(inventory, { + distro: 'Ubuntu', + bootId: 'different', + shellPid: 100, + shellStartTime: 12345, + tty: '/dev/pts/0' + }) + ).toEqual({ status: 'unverifiable', reason: 'boot_id_mismatch' }) + expect( + resolveWslGuestForegroundProcess(inventory, { + distro: 'Ubuntu', + bootId, + shellPid: 100, + shellStartTime: 999, + tty: '/dev/pts/0' + }) + ).toEqual({ status: 'unverifiable', reason: 'pid_reused' }) + }) + + it('does not claim identity across a multiplexer boundary', () => { + const inventory = parseWslGuestProcessInventoryPayload( + payload( + [ + 'row 100 90 90 100 110 pts/0 Ss+ 12345 bash', + 'row 110 100 100 110 110 pts/0 S+ 12346 tmux new-session', + 'row 120 110 110 120 120 pts/1 Sl+ 12347 codex' + ].join('\n'), + 3 + ), + 'Ubuntu' + ) + expect( + resolveWslGuestForegroundProcess(inventory, { + distro: 'Ubuntu', + bootId, + shellPid: 100, + shellStartTime: 12345, + tty: '/dev/pts/0' + }) + ).toEqual({ status: 'unverifiable', reason: 'multiplexer_boundary' }) + }) + + it('single-flights and memoizes independently per distro', async () => { + let calls = 0 + let now = 0 + const reader = createWslGuestProcessInventoryReader({ + now: () => now, + run: async (distro) => { + calls += 1 + return { + status: 'ok', + inventory: { distro, bootId, rows: [] } + } + } + }) + const [a, b] = await Promise.all([reader.read(' Ubuntu '), reader.read('ubuntu')]) + expect(a).toEqual(b) + expect(calls).toBe(1) + await reader.read('Debian') + expect(calls).toBe(2) + now = 501 + await reader.read('Ubuntu') + expect(calls).toBe(3) + }) + + it.each([1, 8, 32])('uses one guest inventory for a %s-pane burst', async (paneCount) => { + let calls = 0 + const reader = createWslGuestProcessInventoryReader({ + run: async (distro) => { + calls += 1 + return { status: 'ok', inventory: { distro, bootId, rows: [] } } + } + }) + await Promise.all(Array.from({ length: paneCount }, () => reader.read('Ubuntu'))) + expect(calls).toBe(1) + }) + + it('uses the fenced --exec command and reports a missing ps as unverifiable', async () => { + runProcessMock.mockResolvedValue({ code: 127, stdout: '', stderr: '', timedOut: false }) + resetWslGuestProcessInventoryForTests() + await expect(readWslGuestProcessInventory('Ubuntu')).resolves.toEqual({ + status: 'unverifiable', + reason: 'ps_unavailable' + }) + const spec = runProcessMock.mock.calls[0]?.[0] + expect(spec.args).toContain('--exec') + expect(spec.args).toContain('sh') + expect(spec.args.join(' ')).not.toMatch(/__ORCA_WSL_CAPTURE_BEGIN_[^$]/) + expect(spec.env.ORCA_WSL_CAPTURE_NONCE).toMatch(/^[a-z0-9]+$/) + }) +}) diff --git a/src/main/providers/wsl-guest-process-inventory.ts b/src/main/providers/wsl-guest-process-inventory.ts new file mode 100644 index 00000000000..8c43e11add5 --- /dev/null +++ b/src/main/providers/wsl-guest-process-inventory.ts @@ -0,0 +1,197 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { + buildWslCapturedLoginShellCommand, + buildWslExecArgs +} from '../../shared/wsl-login-shell-command' +import { resolveWslExecutablePath } from '../wsl/wsl-executable-path' +import { parseWslGuestProcessInventoryPayload } from './wsl-guest-process-inventory-parser' +import type { WslGuestProcessInventory } from './wsl-guest-process-inventory-parser' + +export { parseWslGuestProcessInventoryPayload } from './wsl-guest-process-inventory-parser' +export type { + WslGuestProcessInventory, + WslGuestProcessRow +} from './wsl-guest-process-inventory-parser' +export { resolveWslGuestForegroundProcess } from './wsl-guest-foreground-process-resolution' +export type { + WslGuestForegroundResolution, + WslGuestProcessAnchor +} from './wsl-guest-foreground-process-resolution' + +export type WslGuestProcessInventoryRead = + | { status: 'ok'; inventory: WslGuestProcessInventory } + | { status: 'unverifiable'; reason: WslGuestProcessInventoryFailureReason } + +export type WslGuestProcessInventoryFailureReason = + | 'wsl_unavailable' + | 'capture_failed' + | 'capture_timed_out' + | 'capture_malformed' + | 'ps_unavailable' + | 'boot_id_missing' + +const INVENTORY_TIMEOUT_MS = 5_000 +const INVENTORY_MAX_OUTPUT_BYTES = 4 * 1024 * 1024 +const INVENTORY_TTL_MS = 500 +const CAPTURE_NONCE_ENV = 'ORCA_WSL_CAPTURE_NONCE' + +/** + * The command is deliberately shell text behind the capture fence. `--exec` + * is mandatory: a bare `--` lets wsl.exe expand `$name` in every argument. + * The last `ps` field is read as one remainder so whitespace in args is kept. + */ +export const WSL_GUEST_INVENTORY_SCRIPT = [ + 'set -u', + '_orca_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit 125', + 'case "$_orca_boot" in *[!A-Fa-f0-9-]*|"") exit 125 ;; esac', + 'printf "boot %s\\n" "$_orca_boot"', + '_orca_ps=$(ps -axo pid=,ppid=,sid=,pgid=,tpgid=,tty=,stat=,args= 2>/dev/null) || exit 127', + '_orca_expected=0', + 'while IFS= read -r _orca_line; do', + ' [ -n "$_orca_line" ] && _orca_expected=$((_orca_expected + 1))', + 'done </dev/null) || { printf "error start_time\\n"; exit 1; }', + ' _orca_after=${_orca_procstat##*) }', + ' IFS=" " read -r _orca_dummy1 _orca_dummy2 _orca_dummy3 _orca_dummy4 _orca_dummy5 _orca_dummy6 _orca_dummy7 _orca_dummy8 _orca_dummy9 _orca_dummy10 _orca_dummy11 _orca_dummy12 _orca_dummy13 _orca_dummy14 _orca_dummy15 _orca_dummy16 _orca_dummy17 _orca_dummy18 _orca_dummy19 _orca_start _orca_rest < Promise + now?: () => number + ttlMs?: number +} + +/** Construct a per-distro single-flight/TTL reader; exported for deterministic tests. */ +export function createWslGuestProcessInventoryReader(deps: ReaderDeps = {}): { + read: (distro: string) => Promise + reset: () => void +} { + const now = deps.now ?? (() => Date.now()) + const ttlMs = deps.ttlMs ?? INVENTORY_TTL_MS + const cached = new Map() + const inFlight = new Map>() + const run = deps.run ?? runWslGuestProcessInventory + + const read = (distro: string): Promise => { + const cleanedDistro = distro.trim() + const key = cleanedDistro.toLowerCase() + const prior = cached.get(key) + if (prior && now() - prior.at < ttlMs) { + return Promise.resolve(prior.value) + } + const active = inFlight.get(key) + if (active) { + return active + } + const pending = run(cleanedDistro) + .catch((): WslGuestProcessInventoryRead => ({ + status: 'unverifiable', + reason: 'capture_failed' + })) + .then((value) => { + cached.set(key, { value, at: now() }) + return value + }) + .finally(() => { + if (inFlight.get(key) === pending) { + inFlight.delete(key) + } + }) + inFlight.set(key, pending) + return pending + } + return { + read, + reset: () => { + cached.clear() + inFlight.clear() + } + } +} + +async function runWslGuestProcessInventory(distro: string): Promise { + const captureNonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}` + const captured = buildWslCapturedLoginShellCommand(WSL_GUEST_INVENTORY_SCRIPT, captureNonce, { + nonceEnvVar: CAPTURE_NONCE_ENV + }) + let result + try { + const wslenvEntries = (process.env.WSLENV ?? '').split(':').filter(Boolean) + const nonceEntry = `${CAPTURE_NONCE_ENV}/u` + const nonceEntryIndex = wslenvEntries.findIndex( + (entry) => entry.split('/')[0] === CAPTURE_NONCE_ENV + ) + if (nonceEntryIndex === -1) { + wslenvEntries.push(nonceEntry) + } else { + wslenvEntries[nonceEntryIndex] = nonceEntry + } + result = await runProcess({ + program: resolveWslExecutablePath(), + args: buildWslExecArgs(distro, ['sh', '-c', captured.command]), + env: { + ...process.env, + WSL_UTF8: '1', + WSLENV: wslenvEntries.join(':'), + [CAPTURE_NONCE_ENV]: captureNonce + }, + timeoutMs: INVENTORY_TIMEOUT_MS, + maxOutputBytes: INVENTORY_MAX_OUTPUT_BYTES + }) + } catch { + return { status: 'unverifiable', reason: 'wsl_unavailable' } + } + if (result.timedOut) { + return { status: 'unverifiable', reason: 'capture_timed_out' } + } + if (result.code === 127) { + return { status: 'unverifiable', reason: 'ps_unavailable' } + } + const payload = captured.readStdout(result.stdout) + if (payload === null) { + return { status: 'unverifiable', reason: 'capture_malformed' } + } + if (result.code !== 0) { + return { status: 'unverifiable', reason: 'capture_failed' } + } + try { + return { status: 'ok', inventory: parseWslGuestProcessInventoryPayload(payload, distro) } + } catch (error) { + return { + status: 'unverifiable', + reason: + error instanceof Error && error.message === 'boot_id_missing' + ? 'boot_id_missing' + : 'capture_malformed' + } + } +} + +const defaultReader = createWslGuestProcessInventoryReader() + +export function readWslGuestProcessInventory( + distro: string +): Promise { + return defaultReader.read(distro) +} + +export function resetWslGuestProcessInventoryForTests(): void { + defaultReader.reset() +} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index cc79e2bb054..71663a84874 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -13,7 +13,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p:ORCA_SHELL_FEATURES/u') }) // Why this is published at all: the wrapper tree is content-addressed, so the @@ -42,6 +42,7 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV?.split(':')).toEqual([ 'ORCA_SHELL_READY_ROOT/p', + 'ORCA_SHELL_FEATURES/u', `${SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV}/u`, `${SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV}/u` ]) @@ -54,7 +55,9 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p') + expect(env.WSLENV).toBe( + 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p:ORCA_SHELL_READY_ROOT/p:ORCA_SHELL_FEATURES/u' + ) }) it('marks OMP status and hook env for Windows to WSL import', () => { @@ -195,7 +198,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WORKSPACE_NAME/u') + expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_SHELL_FEATURES/u:ORCA_WORKSPACE_NAME/u') }) it('does not register setup vars that are absent from the env', () => { @@ -203,7 +206,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') + expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p:ORCA_SHELL_FEATURES/u') }) it('marks the WSL hook relay version for import on relay spawn envs', () => { @@ -211,7 +214,9 @@ describe('addOrcaWslInteropEnv', () => { ORCA_WSL_HOOK_RELAY_VERSION: '0.1.0+abc' } addOrcaWslInteropEnv(env) - expect(env.WSLENV).toBe('ORCA_SHELL_READY_ROOT/p:ORCA_WSL_HOOK_RELAY_VERSION/u') + expect(env.WSLENV).toBe( + 'ORCA_SHELL_READY_ROOT/p:ORCA_SHELL_FEATURES/u:ORCA_WSL_HOOK_RELAY_VERSION/u' + ) }) it('crosses a guest-side OpenCode config overlay untranslated (/u)', () => { diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 262df20b3b2..a3079909883 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -58,6 +58,26 @@ export function addOrcaWslInteropEnv(env: Record): void { // are always the local file set -- windows-shell-args.ts is shared by the // in-process provider and the daemon spawner, so both resolve the same tree. env.ORCA_SHELL_READY_ROOT = getShellReadyWrapperRoot() + // WSL's host-side process is always wsl.exe. Ask the guest wrapper to emit + // its shell identity marker so process evidence can anchor to the guest PID. + // The feature selection crosses through WSLENV, never the wsl.exe argv. + const existingFeatures = env.ORCA_SHELL_FEATURES?.split(',').filter(Boolean) ?? [] + const overlayFeatures = [ + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_MIMOCODE_HOME', + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_CODEX_HOME', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_REMOTE_CLI_BIN_DIR' + ].some((key) => Boolean(env[key])) + env.ORCA_SHELL_FEATURES = [ + ...new Set([ + ...existingFeatures, + ...(overlayFeatures ? ['overlay'] : []), + 'markers', + 'identity' + ]) + ].join(',') // Why: the endpoint is a Windows path (/p-translated so the guest reads it // via /mnt/c) until the WSL hook relay reports the guest home — then it is // already a guest-side POSIX path and must cross untranslated. @@ -76,6 +96,7 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. 'ORCA_SHELL_READY_ROOT/p', + 'ORCA_SHELL_FEATURES/u', 'ORCA_CLI_COMMAND/u', 'ORCA_CODEX_LAUNCH_PREFLIGHT/p', 'ORCA_PANE_KEY/u', diff --git a/src/main/shell-startup-identity-scanner.test.ts b/src/main/shell-startup-identity-scanner.test.ts index 2b1607c23e6..80da515927a 100644 --- a/src/main/shell-startup-identity-scanner.test.ts +++ b/src/main/shell-startup-identity-scanner.test.ts @@ -18,6 +18,37 @@ describe('shell startup identity scanner', () => { }) }) + it('parses a fenced WSL identity anchor', () => { + const state = createShellStartupIdentityScanState() + const result = scanForShellStartupIdentity( + state, + 'x\x1b]777;orca-shell-start:v2:Ubuntu-24.04:01234567-89ab-cdef-0123-456789abcdef:123:456:/dev/pts/8\x07y' + ) + expect(result).toEqual({ + output: 'xy', + shellPid: 123, + shellIdentity: { + distro: 'Ubuntu-24.04', + bootId: '01234567-89ab-cdef-0123-456789abcdef', + shellPid: 123, + shellStartTime: 456, + tty: '/dev/pts/8' + } + }) + }) + + it('holds a split v2 anchor until the BEL terminator', () => { + const state = createShellStartupIdentityScanState() + const first = + '\x1b]777;orca-shell-start:v2:Ubuntu:01234567-89ab-cdef-0123-456789abcdef:12:34:/dev/pts/' + expect(scanForShellStartupIdentity(state, first)).toEqual({ output: '', shellPid: null }) + expect(scanForShellStartupIdentity(state, '2\x07ok')).toMatchObject({ + output: 'ok', + shellPid: 12, + shellIdentity: { distro: 'Ubuntu', shellStartTime: 34, tty: '/dev/pts/2' } + }) + }) + it('forwards lookalikes unchanged', () => { const state = createShellStartupIdentityScanState() const input = 'a\x1b]777;orca-shell-start:nope\x07b' diff --git a/src/main/shell-startup-identity-scanner.ts b/src/main/shell-startup-identity-scanner.ts index 234b69e6bbf..6a56413f8e7 100644 --- a/src/main/shell-startup-identity-scanner.ts +++ b/src/main/shell-startup-identity-scanner.ts @@ -1,4 +1,7 @@ +import type { WslShellProcessAnchor } from '../shared/wsl-shell-process-anchor' + export const SHELL_STARTUP_IDENTITY_PREFIX = '\x1b]777;orca-shell-start:' +const POSSIBLE_V2_SUFFIX = /^v2(?::[A-Za-z0-9._/:-]{0,220})?$/ const POSSIBLE_PID_SUFFIX = /^\d{0,20}$/ export type ShellStartupIdentityScanState = { @@ -8,6 +11,8 @@ export type ShellStartupIdentityScanState = { export type ShellStartupIdentityScanResult = { output: string shellPid: number | null + /** Full WSL identity; legacy PID-only markers intentionally do not qualify. */ + shellIdentity?: WslShellProcessAnchor } export function createShellStartupIdentityScanState(): ShellStartupIdentityScanState { @@ -28,7 +33,35 @@ function isPossibleMarker(candidate: string): boolean { return false } const suffix = candidate.slice(SHELL_STARTUP_IDENTITY_PREFIX.length) - return POSSIBLE_PID_SUFFIX.test(suffix) + return POSSIBLE_PID_SUFFIX.test(suffix) || POSSIBLE_V2_SUFFIX.test(suffix) +} + +function parseIdentitySuffix(suffix: string): WslShellProcessAnchor | null { + const fields = suffix.split(':') + if (fields.length !== 6 || fields[0] !== 'v2') { + return null + } + const [, distro, bootId, pidText, startText, tty] = fields + if ( + !distro || + !/^[A-Za-z0-9._-]{1,128}$/.test(distro) || + !bootId || + !/^[A-Fa-f0-9-]{8,128}$/.test(bootId) || + !/^\d{1,20}$/.test(pidText ?? '') || + !/^\d{1,30}$/.test(startText ?? '') || + !/^\/dev\/pts\/\d{1,8}$/.test(tty ?? '') + ) { + return null + } + const shellPid = Number(pidText) + const shellStartTime = Number(startText) + if (!Number.isSafeInteger(shellPid) || shellPid <= 0) { + return null + } + if (!Number.isSafeInteger(shellStartTime) || shellStartTime < 0) { + return null + } + return { distro, bootId, shellPid, shellStartTime, tty: tty! } } export function scanForShellStartupIdentity( @@ -63,6 +96,15 @@ export function scanForShellStartupIdentity( shellPid: Number.isSafeInteger(shellPid) && shellPid > 0 ? shellPid : null } } + const identity = parseIdentitySuffix(terminator === -1 ? suffix : suffix.slice(0, terminator)) + if (identity) { + const markerLength = SHELL_STARTUP_IDENTITY_PREFIX.length + terminator + 1 + return { + output: output + candidate.slice(markerLength), + shellPid: identity.shellPid, + shellIdentity: identity + } + } } output += candidate[0] pending = candidate.slice(1) diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e9..11e9cf35cf6 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -39,7 +39,31 @@ __orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }` // Why one line usable by both languages: __orca_has_feature is defined with the // same name and semantics in the zsh and bash channel blocks above. -export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `__orca_has_feature identity && printf "\\033]777;orca-shell-start:%s\\007" "$$"` +/** Emits a fenced WSL shell identity. The marker is output-only; no identity + * value is put in wsl.exe argv where another Windows process could read it. */ +export const SHELL_STARTUP_IDENTITY_MARKER_BLOCK = `if __orca_has_feature identity; then + if [ -n "\${WSL_DISTRO_NAME:-}" ]; then + __orca_emit_shell_identity() { + local _orca_boot _orca_stat _orca_tail _orca_start _orca_tty _orca_distro + _orca_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || return 0 + _orca_stat=$(cat /proc/$$/stat 2>/dev/null) || return 0 + _orca_tail=\${_orca_stat##*) } + set -- $_orca_tail + _orca_start=\${20:-} + _orca_tty=$(tty 2>/dev/null) || return 0 + _orca_distro=\${WSL_DISTRO_NAME:-} + case "$_orca_boot" in *[!A-Fa-f0-9-]*|"") return 0 ;; esac + case "$_orca_distro" in ""|*[!A-Za-z0-9._-]*) return 0 ;; esac + case "$_orca_start" in ""|*[!0-9]*) return 0 ;; esac + case "$_orca_tty" in /dev/pts/[0-9]*) ;; *) return 0 ;; esac + printf "\\033]777;orca-shell-start:v2:%s:%s:%s:%s:%s\\007" "$_orca_distro" "$_orca_boot" "$$" "$_orca_start" "$_orca_tty" + } + __orca_emit_shell_identity + unset -f __orca_emit_shell_identity + else + printf "\\033]777;orca-shell-start:%s\\007" "$$" + fi +fi` /** * The first executable lines of the wrapper: give ZDOTDIR back to the user. diff --git a/src/shared/pty-listed-session.ts b/src/shared/pty-listed-session.ts index 064477dd12a..a7f9bf410b5 100644 --- a/src/shared/pty-listed-session.ts +++ b/src/shared/pty-listed-session.ts @@ -22,6 +22,8 @@ export type PtyListedSession = { * Manager force-kill live agent sessions (#8459). */ agentOwnership: AgentOwnershipEvidence + /** Optional process evidence from the execution host; absent on older providers. */ + foregroundProcessEvidence?: ForegroundProcessEvidence } /** Only proven absence authorizes destroying a session without asking. */ @@ -30,3 +32,4 @@ export function mayDestroyWithoutOwnerEvidence(session: { }): boolean { return session.agentOwnership === 'absent' } +import type { ForegroundProcessEvidence } from './foreground-process-evidence' diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 76217c297bc..4c632cda37a 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildWslCapturedLoginShellCommand, + buildWslCapturedLoginShellCommandFromEnv, buildWslExecArgs, buildWslInteractiveLoginShellCommand, buildWslLoginShellCommand, @@ -241,6 +242,22 @@ describe('wsl login shell command helpers', () => { expect(captured.command).toContain('exit $_orca_capture_status') }) + it('keeps an env-delivered nonce out of the command argv', () => { + const captured = buildWslCapturedLoginShellCommandFromEnv( + 'printf payload', + 'ORCA_WSL_CAPTURE_NONCE', + 'nonce-env' + ) + expect(captured.command).not.toContain('nonce-env') + expect(captured.command).toContain('${ORCA_WSL_CAPTURE_NONCE:-}') + expectValidShSyntax(captured.command) + expect( + captured.readStdout( + `noise\n__ORCA_WSL_CAPTURE_BEGIN_nonce-env__payload__ORCA_WSL_CAPTURE_END_nonce-env__` + ) + ).toBe('payload') + }) + it('keeps payload bytes that themselves contain a fence', () => { // Why a per-call nonce: `cat` of a file quoting a fixed marker would // otherwise be truncated at the quote. diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index fd0f0b505c9..54524183ce6 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -49,6 +49,11 @@ export type WslCapturedLoginShellCommand = { endMarker: string } +export type WslCapturedLoginShellOptions = { + /** Supply the nonce through this env var instead of embedding it in argv. */ + nonceEnvVar?: string +} + // Why: the fence has to be absent from both the rc output ahead of it and the // payload behind it. A per-call nonce is the only spelling that guarantees // both -- `cat`-ing a file that happens to quote a fixed marker would otherwise @@ -70,8 +75,12 @@ function nextWslCaptureNonce(): string { */ export function buildWslCapturedLoginShellCommand( command: string, - nonce: string = nextWslCaptureNonce() + nonce: string = nextWslCaptureNonce(), + options: WslCapturedLoginShellOptions = {} ): WslCapturedLoginShellCommand { + if (options.nonceEnvVar) { + return buildWslCapturedLoginShellCommandFromEnv(command, options.nonceEnvVar, nonce) + } const begin = `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__` const end = `__ORCA_WSL_CAPTURE_END_${nonce}__` return { @@ -104,6 +113,49 @@ export function buildWslCapturedLoginShellCommand( } } +/** + * Variant of the capture fence that receives its nonce from an environment + * variable. This keeps the nonce out of the wsl.exe command line (which is + * visible to other Windows processes) while retaining the login-shell fence. + */ +export function buildWslCapturedLoginShellCommandFromEnv( + command: string, + nonceEnvVar: string, + nonce: string = nextWslCaptureNonce() +): WslCapturedLoginShellCommand { + const begin = `__ORCA_WSL_CAPTURE_BEGIN_${nonce}__` + const end = `__ORCA_WSL_CAPTURE_END_${nonce}__` + const envName = nonceEnvVar + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) { + throw new Error('invalid_capture_nonce_env') + } + const fenced = [ + `_orca_capture_nonce="\${${envName}:-}"`, + '[ -n "$_orca_capture_nonce" ] || exit 125', + '_orca_capture_begin="__ORCA_WSL_CAPTURE_BEGIN_${_orca_capture_nonce}__"', + '_orca_capture_end="__ORCA_WSL_CAPTURE_END_${_orca_capture_nonce}__"', + 'printf %s "$_orca_capture_begin"', + command, + '_orca_capture_status=$?', + 'printf %s "$_orca_capture_end"', + 'exit $_orca_capture_status' + ].join('\n') + return { + beginMarker: begin, + endMarker: end, + command: buildWslLoginShellCommand(fenced), + readStdout: (stdout) => { + const beginIndex = stdout.lastIndexOf(begin) + if (beginIndex === -1) { + return null + } + const payloadStart = beginIndex + begin.length + const endIndex = stdout.indexOf(end, payloadStart) + return endIndex === -1 ? stdout.slice(payloadStart) : stdout.slice(payloadStart, endIndex) + } + } +} + export function buildWslInteractiveLoginShellCommand(): string { return [ '_orca_wsl_shell=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)', diff --git a/src/shared/wsl-shell-process-anchor.ts b/src/shared/wsl-shell-process-anchor.ts new file mode 100644 index 00000000000..a87fd3c2c2e --- /dev/null +++ b/src/shared/wsl-shell-process-anchor.ts @@ -0,0 +1,8 @@ +/** Identity of the shell process that owns a WSL PTY. */ +export type WslShellProcessAnchor = { + distro: string + bootId: string + shellPid: number + shellStartTime: number + tty: string +}