mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
feat(wsl): resolve guest foreground processes
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user