feat(wsl): resolve guest foreground processes

This commit is contained in:
Merge Sim
2026-08-31 16:11:13 -07:00
parent aa658d28e3
commit 2d5f0a47f9
29 changed files with 1190 additions and 91 deletions
@@ -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
}
@@ -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<PtyProcessInfo[]> {
@@ -45,12 +51,53 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti
const admission = new PtyProcessListAdmission()
const processes: PtyProcessInfo[] = []
const aliveSessionIds = new Set<string>()
const evidenceEpoch = Date.now()
const wslByDistro = new Map<string, WslGuestProcessInventoryRead>()
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)
})
)
@@ -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
}
}
@@ -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
}
}
+18
View File
@@ -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']
+24 -24
View File
@@ -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 {
@@ -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"')
+3
View File
@@ -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"')
)
@@ -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
+9 -45
View File
@@ -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
+3
View File
@@ -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).
@@ -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<string |
proc.process || null,
ptyShellName.get(id)
)
const wslDistro = ptyWslDistroById.get(id)
if (process.platform === 'win32' && wslDistro) {
const anchor = ptyWslShellAnchors.get(id)
if (!anchor) {
return null
}
const read = await readWslGuestProcessInventory(wslDistro)
if (ptyProcesses.get(id) !== proc || read.status !== 'ok') {
return null
}
const resolution = resolveWslGuestForegroundProcess(read.inventory, anchor)
if (ptyProcesses.get(id) !== proc) {
return null
}
if (resolution.status === 'unverifiable') {
return null
}
ptyWslShellAnchors.set(id, resolution.anchor)
if (resolution.processName) {
ptyLastRecognizedForeground.set(id, {
name: resolution.processName,
pid: resolution.anchor.shellPid,
at: Date.now()
})
return resolution.processName
}
return null
}
const cachedEntry = ptyLastRecognizedForeground.get(id)
const cachedAgent = cachedEntry?.name ?? null
let paneMembershipUnavailable = false
@@ -156,6 +190,23 @@ export async function confirmLocalPtyForegroundProcess(id: string): Promise<stri
if (!proc) {
return null
}
const wslDistro = ptyWslDistroById.get(id)
if (process.platform === 'win32' && wslDistro) {
const anchor = ptyWslShellAnchors.get(id)
if (!anchor) {
return null
}
const read = await readWslGuestProcessInventory(wslDistro)
if (ptyProcesses.get(id) !== proc || read.status !== 'ok') {
return null
}
const resolution = resolveWslGuestForegroundProcess(read.inventory, anchor)
if (ptyProcesses.get(id) !== proc || resolution.status !== 'live') {
return null
}
ptyWslShellAnchors.set(id, resolution.anchor)
return resolution.processName
}
try {
const resolution = await resolveAgentForegroundProcessWithAvailability(
proc.pid,
@@ -3,6 +3,7 @@ import type { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import type { PtyStartupIngress } from '../../shared/pty-startup-ingress'
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import { normalizeLocalCallerSessionId } from './local-pty-launch-helpers'
import type { WslShellProcessAnchor } from '../../shared/wsl-shell-process-anchor'
export type PtyShutdownOperation = {
promise: Promise<void>
@@ -52,6 +53,9 @@ export const ptyWorktreeId = new Map<string, string>()
export const ptyInitialCwd = new Map<string, string>()
// 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<string, string | null>()
/** Guest shell identity observed from the WSL wrapper's OSC startup marker. */
export type WslPtyShellAnchor = WslShellProcessAnchor
export const ptyWslShellAnchors = new Map<string, WslPtyShellAnchor>()
// Why: node-pty callbacks dispose before env teardown, but onExit separately owns physical-exit proof during termination.
export const ptyDisposables = new Map<string, { dispose: () => void }[]>()
export const ptyExitDisposables = new Map<string, { dispose: () => 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)
@@ -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)
@@ -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<PtyProcessInfo[]> {
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<string, string[]>()
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<string, WslGuestProcessInventoryRead>()
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(
@@ -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')
@@ -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<number>()
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 }
}
@@ -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<number>()
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 }
}
@@ -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]+$/)
})
})
@@ -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 <<EOF',
'$_orca_ps',
'EOF',
'_orca_seen=0',
'while IFS= read -r _orca_line; do',
' [ -n "$_orca_line" ] || { continue; }',
' IFS=" " read -r _orca_pid _orca_ppid _orca_sid _orca_pgid _orca_tpgid _orca_tty _orca_stat _orca_args <<EOF',
'$_orca_line',
'EOF',
' _orca_procstat=$(cat "/proc/$_orca_pid/stat" 2>/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 <<EOF',
'$_orca_after',
'EOF',
' case "$_orca_start" in ""|*[!0-9]*) printf "error start_time\\n"; exit 1 ;; esac',
' printf "row %s %s %s %s %s %s %s %s %s\\n" "$_orca_pid" "$_orca_ppid" "$_orca_sid" "$_orca_pgid" "$_orca_tpgid" "$_orca_tty" "$_orca_stat" "$_orca_start" "$_orca_args"',
' _orca_seen=$((_orca_seen + 1))',
'done <<EOF',
'$_orca_ps',
'EOF',
'printf "count %s %s\\n" "$_orca_seen" "$_orca_expected"'
].join('\n')
type ReaderDeps = {
run?: (distro: string) => Promise<WslGuestProcessInventoryRead>
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<WslGuestProcessInventoryRead>
reset: () => void
} {
const now = deps.now ?? (() => Date.now())
const ttlMs = deps.ttlMs ?? INVENTORY_TTL_MS
const cached = new Map<string, { value: WslGuestProcessInventoryRead; at: number }>()
const inFlight = new Map<string, Promise<WslGuestProcessInventoryRead>>()
const run = deps.run ?? runWslGuestProcessInventory
const read = (distro: string): Promise<WslGuestProcessInventoryRead> => {
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<WslGuestProcessInventoryRead> {
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<WslGuestProcessInventoryRead> {
return defaultReader.read(distro)
}
export function resetWslGuestProcessInventoryForTests(): void {
defaultReader.reset()
}
+10 -5
View File
@@ -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)', () => {
+21
View File
@@ -58,6 +58,26 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): 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<string, string>): 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',
@@ -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'
+43 -1
View File
@@ -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)
+25 -1
View File
@@ -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.
+3
View File
@@ -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'
@@ -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.
+53 -1
View File
@@ -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)',
+8
View File
@@ -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
}