refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -1,22 +1,18 @@
|
||||
/**
|
||||
* Local persistent PTY backend using public `node-pty` APIs, shared sandbox
|
||||
* policy, bounded output, platform readiness probes, and process-session cleanup.
|
||||
* Persistent shell PTY backend over the subprocess terminal primitive, shared
|
||||
* sandbox policy, bounded output, and provider-owned session cleanup.
|
||||
* @module @deepseek-ai/dsh-pty-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPtyForkOptions } from 'node-pty'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import { LocalPtySession } from './session.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
@@ -24,8 +20,8 @@ export type { Config as PtyLocalConfig } from './config.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'pty-local'
|
||||
/** Required services: PTY registry plus the one shared confinement policy. */
|
||||
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
|
||||
/** Required services: PTY registry, shared confinement policy, and process substrate. */
|
||||
export const inject = ['pty', 'sandbox', 'sandboxPolicy', 'subprocess']
|
||||
|
||||
interface SandboxModeFenceState {
|
||||
pty: Context['pty']
|
||||
@@ -55,10 +51,10 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
// node-pty owns the spawn; the base env shares the subprocess seam's scrub.
|
||||
function childEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
|
||||
// The subprocess provider supplies its own scrubbed ambient base; these are
|
||||
// deliberate terminal-specific overrides layered after it.
|
||||
return {
|
||||
...scrubbedParentEnv(),
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
@@ -71,11 +67,14 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
}
|
||||
}
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
if (policy.mode === 'danger-full-access') return argv
|
||||
// Re-state the discriminant because object spread does not preserve its narrowed type.
|
||||
return ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
|
||||
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
|
||||
if (mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, {
|
||||
mode: mode,
|
||||
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
|
||||
}).argv
|
||||
}
|
||||
|
||||
/** Local shell backend registered under the configured type. */
|
||||
@@ -85,13 +84,13 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn,
|
||||
private readonly spawnTerminal: (
|
||||
spec: SubprocessTerminalSpawnSpec,
|
||||
) => Promise<SubprocessTerminalHandle> = spec => ctx.subprocess.spawnTerminal(spec),
|
||||
private readonly createSession: (
|
||||
terminal: ReturnType<typeof nodePty.spawn>,
|
||||
inspector: ProcessInspector,
|
||||
terminal: SubprocessTerminalHandle,
|
||||
config: ResolvedConfig,
|
||||
) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config),
|
||||
) => LocalPtySession = (terminal, config) => new LocalPtySession(terminal, config),
|
||||
) {
|
||||
this.type = config.backendType
|
||||
}
|
||||
@@ -99,19 +98,18 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
spec.signal?.throwIfAborted()
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
|
||||
const argv = spawnArgv(this.ctx, this.config, policy)
|
||||
const file = argv[0]
|
||||
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
const options: IPtyForkOptions = {
|
||||
name: 'dumb',
|
||||
cols: this.config.cols,
|
||||
rows: this.config.rows,
|
||||
cwd: spec.cwd ?? policy.workspaceRoot,
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
}
|
||||
const terminal = this.spawnTerminal(file, argv.slice(1), options)
|
||||
const session = this.createSession(terminal, this.inspector, this.config)
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
signal: spec.signal,
|
||||
})
|
||||
const session = this.createSession(terminal, this.config)
|
||||
try {
|
||||
await session.initialize(spec.signal)
|
||||
return session
|
||||
@@ -129,6 +127,5 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
/** Register the local PTY backend. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
validateConfig(config)
|
||||
const inspector = createProcessInspector()
|
||||
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
|
||||
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config))
|
||||
}
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
/** Platform process-table inspection used for readiness, signals, and teardown. */
|
||||
|
||||
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import type { PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
/** PID plus start identity, preventing teardown escalation after PID reuse. */
|
||||
export interface ProcessIdentity {
|
||||
pid: number
|
||||
started: string
|
||||
}
|
||||
|
||||
/** Injectable OS process operations used by one local PTY session. */
|
||||
export interface ProcessInspector {
|
||||
foregroundPgid(shellPid: number): number | undefined
|
||||
isStdinWaiting(pgid: number): boolean
|
||||
/** Return the root and its current transitive descendants, children first. */
|
||||
processTree(rootPid: number): ProcessIdentity[]
|
||||
/** Return whether the exact identity remains a non-quiescent process. */
|
||||
isAlive(identity: ProcessIdentity): boolean
|
||||
signalGroup(pgid: number, signal: PtySignal): void
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
|
||||
}
|
||||
|
||||
/** Testable boundary around filesystem, process-table, and signal syscalls. */
|
||||
export interface ProcessInspectorInternals {
|
||||
readFile(path: string): string
|
||||
readDir(path: string): string[]
|
||||
open(path: string): number
|
||||
read(fd: number, buffer: Buffer, length: number, position: number): number
|
||||
close(fd: number): void
|
||||
exec(file: string, args: string[]): string
|
||||
kill(pid: number, signal: NodeJS.Signals): void
|
||||
}
|
||||
|
||||
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
|
||||
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
|
||||
readFile: path => readFileSync(path, 'utf8'),
|
||||
readDir: path => readdirSync(path),
|
||||
open: path => openSync(path, 'r'),
|
||||
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
|
||||
close: closeSync,
|
||||
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
|
||||
kill: (pid, signal) => process.kill(pid, signal),
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
interface ProcStat {
|
||||
pid: number
|
||||
parentPid: number
|
||||
pgrp: number
|
||||
session: number
|
||||
state: string
|
||||
tpgid: number
|
||||
started: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
|
||||
* @param text - complete stat line.
|
||||
* @returns Parsed identity/group fields, or undefined for malformed input.
|
||||
*/
|
||||
export function parseProcStat(text: string): ProcStat | undefined {
|
||||
const open = text.indexOf('(')
|
||||
const close = text.lastIndexOf(')')
|
||||
if (open <= 0 || close <= open) return undefined
|
||||
const pid = Number(text.slice(0, open).trim())
|
||||
const rest = text.slice(close + 2).trim().split(/\s+/)
|
||||
const state = rest[0] || ''
|
||||
const parentPid = Number(rest[1])
|
||||
const pgrp = Number(rest[2])
|
||||
const session = Number(rest[3])
|
||||
const tpgid = Number(rest[5])
|
||||
const started = rest[19]
|
||||
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|
||||
|| state.length !== 1 || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, state, tpgid, started }
|
||||
}
|
||||
|
||||
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
|
||||
try {
|
||||
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
|
||||
} catch (_unreadableProcEntry) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
|
||||
try {
|
||||
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
|
||||
} catch (_unreadableProcDirectory) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface SyscallInfo {
|
||||
number: number
|
||||
args: number[]
|
||||
}
|
||||
|
||||
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
|
||||
try {
|
||||
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
|
||||
if (text === 'running' || text.startsWith('-1 ')) return undefined
|
||||
const fields = text.split(/\s+/)
|
||||
const number = Number(fields[0])
|
||||
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
|
||||
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
|
||||
return { number, args }
|
||||
} catch (_unreadableSyscall) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function readMemory(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
address: number,
|
||||
length: number,
|
||||
): Buffer | undefined {
|
||||
let fd: number | undefined
|
||||
try {
|
||||
fd = internals.open(`/proc/${pid}/mem`)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const count = internals.read(fd, buffer, length, address)
|
||||
return buffer.subarray(0, count)
|
||||
} catch (_unreadableProcessMemory) {
|
||||
return undefined
|
||||
} finally {
|
||||
if (fd !== undefined) internals.close(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
|
||||
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
|
||||
}
|
||||
|
||||
function pollHasStdin(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
address: number,
|
||||
count: number,
|
||||
): boolean {
|
||||
if (address === 0 || count <= 0) return false
|
||||
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
|
||||
if (memory === undefined) return false
|
||||
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
|
||||
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
|
||||
try {
|
||||
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
|
||||
.split('\n')
|
||||
.some(line => /^tfd:\s+0\b/.test(line.trim()))
|
||||
} catch (_unreadableFdInfo) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface SyscallTable {
|
||||
read: number
|
||||
select?: number
|
||||
pselect: number
|
||||
poll?: number
|
||||
ppoll: number
|
||||
epollWait?: number
|
||||
epollPwait: number
|
||||
}
|
||||
|
||||
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
|
||||
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
|
||||
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
|
||||
}
|
||||
|
||||
function syscallWaitsOnStdin(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
syscall: SyscallInfo,
|
||||
table: SyscallTable,
|
||||
): boolean {
|
||||
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
|
||||
if (syscall.number === table.read) return a0 === 0
|
||||
if (syscall.number === table.select || syscall.number === table.pselect) {
|
||||
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
|
||||
}
|
||||
if (syscall.number === table.poll || syscall.number === table.ppoll) {
|
||||
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
|
||||
}
|
||||
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
|
||||
return a2 >= 1 && epollHasStdin(internals, pid, a0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
abstract class PosixProcessInspector implements ProcessInspector {
|
||||
constructor(protected readonly internals: ProcessInspectorInternals) {}
|
||||
|
||||
abstract foregroundPgid(shellPid: number): number | undefined
|
||||
abstract isStdinWaiting(pgid: number): boolean
|
||||
abstract processTree(rootPid: number): ProcessIdentity[]
|
||||
abstract isAlive(identity: ProcessIdentity): boolean
|
||||
|
||||
signalGroup(pgid: number, signal: PtySignal): void {
|
||||
this.internals.kill(-pgid, signal)
|
||||
}
|
||||
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessTreeEntry extends ProcessIdentity {
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
|
||||
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
|
||||
const root = byPid.get(rootPid)
|
||||
if (root === undefined) return []
|
||||
const byParent = new Map<number, ProcessTreeEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const children = byParent.get(entry.parentPid) ?? []
|
||||
children.push(entry)
|
||||
byParent.set(entry.parentPid, children)
|
||||
}
|
||||
const visited = new Set<number>()
|
||||
const result: ProcessIdentity[] = []
|
||||
const visit = (entry: ProcessTreeEntry): void => {
|
||||
if (visited.has(entry.pid)) return
|
||||
visited.add(entry.pid)
|
||||
for (const child of byParent.get(entry.pid) ?? []) visit(child)
|
||||
result.push({ pid: entry.pid, started: entry.started })
|
||||
}
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
class LinuxProcessInspector extends PosixProcessInspector {
|
||||
constructor(
|
||||
private readonly arch: NodeJS.Architecture,
|
||||
internals: ProcessInspectorInternals,
|
||||
) {
|
||||
super(internals)
|
||||
}
|
||||
|
||||
foregroundPgid(shellPid: number): number | undefined {
|
||||
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
|
||||
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
|
||||
}
|
||||
|
||||
isStdinWaiting(pgid: number): boolean {
|
||||
const table = SYSCALLS[this.arch]
|
||||
if (table === undefined) return false
|
||||
for (const pid of numericEntries(this.internals, '/proc')) {
|
||||
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
|
||||
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
|
||||
const syscall = readSyscall(this.internals, pid, tid)
|
||||
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
|
||||
const stat = readLinuxStat(this.internals, pid)
|
||||
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
|
||||
})
|
||||
return processTree(entries, rootPid)
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
const stat = readLinuxStat(this.internals, identity.pid)
|
||||
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface PsEntry extends ProcessTreeEntry {}
|
||||
|
||||
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
|
||||
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
|
||||
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
|
||||
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
|
||||
})
|
||||
}
|
||||
|
||||
class MacProcessInspector extends PosixProcessInspector {
|
||||
foregroundPgid(shellPid: number): number | undefined {
|
||||
try {
|
||||
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : undefined
|
||||
} catch (_missingProcess) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
isStdinWaiting(_pgid: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
return processTree(macProcessTable(this.internals), rootPid)
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the supported platform inspector or fail at plugin load.
|
||||
* @param platform - target Node platform.
|
||||
* @param arch - target CPU architecture for Linux syscall numbers.
|
||||
* @param internals - filesystem/process boundary, injectable for deterministic tests.
|
||||
* @returns Platform process inspector.
|
||||
*/
|
||||
export function createProcessInspector(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: NodeJS.Architecture = process.arch,
|
||||
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
|
||||
): ProcessInspector {
|
||||
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
|
||||
if (platform === 'darwin') return new MacProcessInspector(internals)
|
||||
throw new Error(`pty-local: unsupported platform ${platform}`)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
|
||||
/** Persistent PTY session over the subprocess seam's terminal primitive. */
|
||||
|
||||
import { constants } from 'node:os'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
SubprocessTerminalHandle,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
@@ -17,13 +20,8 @@ import type {
|
||||
PtyWaitReason,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
|
||||
import { TerminalSanitizer } from './sanitize.ts'
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
|
||||
const chars = Array.from(text)
|
||||
@@ -80,17 +78,16 @@ class LocalSendOperation implements PtySendOperation {
|
||||
private readonly promise: PromiseWithResolvers<PtySendResult>
|
||||
private finished = false
|
||||
private initialForegroundLeftWait: boolean
|
||||
private initialForegroundPgid: number | undefined
|
||||
|
||||
constructor(
|
||||
maxBytes: number,
|
||||
readonly startedAt: number,
|
||||
private readonly initialForegroundPgid: number | undefined,
|
||||
initialForegroundWasWaiting: boolean,
|
||||
private readonly onCancel: () => void,
|
||||
) {
|
||||
this.output = new BoundedTextBuffer(maxBytes)
|
||||
this.promise = Promise.withResolvers<PtySendResult>()
|
||||
this.initialForegroundLeftWait = !initialForegroundWasWaiting
|
||||
this.initialForegroundLeftWait = true
|
||||
}
|
||||
|
||||
get done(): Promise<PtySendResult> {
|
||||
@@ -123,6 +120,11 @@ class LocalSendOperation implements PtySendOperation {
|
||||
return this.output.consume()
|
||||
}
|
||||
|
||||
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
|
||||
this.initialForegroundPgid = foreground?.processGroupId
|
||||
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
|
||||
}
|
||||
|
||||
acceptsStdinWait(pgid: number, waiting: boolean): boolean {
|
||||
// The same group may still expose the wait that existed before terminal.write.
|
||||
// Observe every poll so a departure before the exact-settlement threshold
|
||||
@@ -139,27 +141,21 @@ class LocalSendOperation implements PtySendOperation {
|
||||
}
|
||||
}
|
||||
|
||||
function signalName(number: number | undefined): NodeJS.Signals | null {
|
||||
if (number === undefined || number === 0) return null
|
||||
for (const [name, value] of Object.entries(constants.signals)) {
|
||||
if (value === number) return name as NodeJS.Signals
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Backend session wrapping one `node-pty` process and its captured process tree. */
|
||||
/** Backend session wrapping one provider-owned terminal process. */
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
private readonly exitDisposable: IDisposable
|
||||
private readonly outputEnded = Promise.withResolvers<void>()
|
||||
private readonly completion: Promise<void>
|
||||
private statusValue: PtySessionStatus = { kind: 'running' }
|
||||
private active: LocalSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private shellPgid: number | undefined
|
||||
@@ -167,23 +163,22 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private lastOutputAt = Date.now()
|
||||
private closing = false
|
||||
private closePromise: Promise<void> | undefined
|
||||
private transportFailure: Error | undefined
|
||||
|
||||
constructor(
|
||||
private readonly terminal: IPty,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly terminal: SubprocessTerminalHandle,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
|
||||
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
|
||||
const tail = this.sanitizer.flush()
|
||||
this.appendOutput(tail)
|
||||
this.statusValue = { kind: 'exited', exitCode, signal: signalName(signal) }
|
||||
this.settleActive('session_exit')
|
||||
this.exitPromise.resolve()
|
||||
})
|
||||
terminal.output.on('data', this.onTerminalData)
|
||||
terminal.output.once('end', this.onTerminalEnd)
|
||||
terminal.output.once('error', this.onTerminalError)
|
||||
this.completion = terminal.done.then(
|
||||
outcome => this.onExit(outcome),
|
||||
(error: unknown) => { this.onTransportFailure(error) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,14 +208,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.active !== undefined) throw new Error('PTY session already has an active send')
|
||||
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
|
||||
|
||||
const initialForegroundPgid = this.inspector.foregroundPgid(this.pid)
|
||||
const initialForegroundWasWaiting = initialForegroundPgid !== undefined
|
||||
&& this.inspector.isStdinWaiting(initialForegroundPgid)
|
||||
const operation = new LocalSendOperation(
|
||||
this.config.maxReadBytes,
|
||||
Date.now(),
|
||||
initialForegroundPgid,
|
||||
initialForegroundWasWaiting,
|
||||
() => { this.interrupt(operation) },
|
||||
)
|
||||
this.active = operation
|
||||
@@ -233,20 +223,28 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.text.length > 0) this.terminal.write(request.text)
|
||||
if (request.submit) this.terminal.write('\r')
|
||||
} catch (error: unknown) {
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
return operation
|
||||
}
|
||||
|
||||
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
|
||||
this.activeDeadlineTimer = setTimeout(() => {
|
||||
if (this.active === operation) this.settleActive('timeout')
|
||||
}, this.config.timeoutMs)
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||
try {
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
if (this.active !== operation || this.closing) return
|
||||
operation.setInitialForeground(foreground)
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0) await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
// Closing can race the awaited provider write even though static analysis sees only local assignments.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.active === operation && !this.closing) this.schedulePoll(operation, 0)
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
}
|
||||
}
|
||||
|
||||
read(request: PtyReadRequest): PtyReadResult {
|
||||
const snapshot = this.scrollback.snapshot()
|
||||
const lines = snapshot.text.split('\n')
|
||||
@@ -272,16 +270,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
}
|
||||
|
||||
signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
return Promise.resolve().then(() => {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
if (signal === 'SIGKILL' && pgid === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
|
||||
}
|
||||
this.inspector.signalGroup(pgid, signal)
|
||||
return { delivered: true, targetPgid: pgid }
|
||||
})
|
||||
async signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
const targetPgid = await this.terminal.signalForeground(signal)
|
||||
return { delivered: true, targetPgid }
|
||||
}
|
||||
|
||||
status(): PtySessionStatus {
|
||||
@@ -300,12 +291,35 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
return closing
|
||||
}
|
||||
|
||||
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
|
||||
try {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY emitted invalid UTF-8', { cause: error }))
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onTerminalEnd = (): void => {
|
||||
try {
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY ended with invalid UTF-8', { cause: error }))
|
||||
} finally {
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onTerminalError = (error: Error): void => {
|
||||
this.onTransportFailure(error)
|
||||
this.outputEnded.resolve()
|
||||
}
|
||||
|
||||
private onData(data: string): void {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
// Bash can print PROMPT_COMMAND before the kernel publishes its return
|
||||
// to the foreground process group. Retain the marker; polling below is
|
||||
// the authority that accepts it only after bash owns the foreground.
|
||||
@@ -317,6 +331,21 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async onExit(outcome: SubprocessOutcome): Promise<void> {
|
||||
await this.outputEnded.promise
|
||||
if (this.transportFailure !== undefined) return
|
||||
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
|
||||
this.settleActive('session_exit')
|
||||
}
|
||||
|
||||
private onTransportFailure(error: unknown): void {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
this.transportFailure ??= failure
|
||||
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
this.failActive(failure)
|
||||
this.terminal.terminate()
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.lastOutputAt = Date.now()
|
||||
@@ -324,44 +353,56 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.active?.append(text)
|
||||
}
|
||||
|
||||
private pollReadiness(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
if (this.statusValue.kind === 'exited') {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (this.shellPgid !== undefined && pgid === this.shellPgid) {
|
||||
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
|
||||
if (this.active !== operation || this.polling) return
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = setTimeout(() => {
|
||||
this.activeTimer = undefined
|
||||
void this.pollReadiness(operation)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
|
||||
if (this.active !== operation || this.polling) return
|
||||
this.polling = true
|
||||
try {
|
||||
if (this.statusValue.kind === 'exited') {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
if (this.active !== operation) return
|
||||
const idleFor = Date.now() - this.lastOutputAt
|
||||
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
|
||||
this.shellPgid = foreground.processGroupId
|
||||
}
|
||||
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
|
||||
&& foreground?.processGroupId === this.shellPgid) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - operation.startedAt
|
||||
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
|
||||
const acceptsStdinWait = startupHasOutput && foreground !== undefined
|
||||
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
|
||||
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
// A prompt candidate can race bash's foreground handoff, but an interactive
|
||||
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
|
||||
// on waiting for shell ownership instead of letting a child marker suppress
|
||||
// readiness until the absolute timeout.
|
||||
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
|
||||
this.settleActive('inferred_idle')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
if (this.active === operation) this.schedulePoll(operation)
|
||||
}
|
||||
const elapsed = Date.now() - operation.startedAt
|
||||
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
|
||||
let acceptsStdinWait = false
|
||||
if (startupHasOutput) {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
acceptsStdinWait = pgid !== undefined
|
||||
&& operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid))
|
||||
}
|
||||
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
// A prompt candidate can race bash's foreground handoff, but an interactive
|
||||
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
|
||||
// on waiting for shell ownership instead of letting a child marker suppress
|
||||
// readiness until the absolute timeout. When a prompt marker was seen, the
|
||||
// configured grace holds the fallback past the silence bound so polls in
|
||||
// that window can observe the foreground handoff and settle as stdin_read.
|
||||
const idleFor = Date.now() - this.lastOutputAt
|
||||
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
|
||||
this.settleActive('inferred_idle')
|
||||
return
|
||||
}
|
||||
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
|
||||
}
|
||||
|
||||
private settleActive(waitReason: PtyWaitReason): void {
|
||||
@@ -373,8 +414,10 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||
this.activeDeadlineTimer = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
@@ -393,104 +436,30 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
try {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
this.inspector.signalGroup(pgid, 'SIGINT')
|
||||
} catch (error: unknown) {
|
||||
this.failActive(error)
|
||||
}
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
return members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
|
||||
private descendants(): ProcessIdentity[] {
|
||||
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
|
||||
}
|
||||
|
||||
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
|
||||
const deadline = Date.now() + this.config.disposeGraceMs
|
||||
let survivors = this.survivors(members)
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
|
||||
survivors = this.survivors(members)
|
||||
}
|
||||
return survivors
|
||||
}
|
||||
|
||||
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
for (const member of members) {
|
||||
try {
|
||||
this.inspector.signalProcess(member, signal)
|
||||
} catch (_alreadyExitedDuringSignal) {
|
||||
// Identity is rechecked by the inspector; a same-tick exit is success.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
|
||||
const members: ProcessIdentity[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const member of group) {
|
||||
const key = JSON.stringify([member.pid, member.started])
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
members.push(member)
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
private async stopDescendants(): Promise<ProcessIdentity[]> {
|
||||
const captured = this.descendants()
|
||||
this.signalMembers(captured, 'SIGTERM')
|
||||
const capturedSurvivors = await this.waitForExit(captured)
|
||||
// A TERM-handling descendant may have forked while winding down. Rescan
|
||||
// while the shell can still reap every member, then kill both the fresh
|
||||
// tree and captured survivors that were reparented out of that tree.
|
||||
const members = this.unionMembers(capturedSurvivors, this.descendants())
|
||||
this.signalMembers(members, 'SIGKILL')
|
||||
const survivors = await this.waitForExit(members)
|
||||
return this.survivors(this.unionMembers(survivors, this.descendants()))
|
||||
}
|
||||
|
||||
private async stopShell(): Promise<void> {
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
} catch (_topLevelAlreadyExitedDuringTerm) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
try {
|
||||
this.terminal.kill('SIGKILL')
|
||||
} catch (_topLevelAlreadyExitedDuringKill) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
|
||||
}
|
||||
void this.terminal.signalForeground('SIGINT').catch((error: unknown) => {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
})
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
this.dataDisposable.dispose()
|
||||
// Stop readiness polling but retain the active operation: teardown settles
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
const survivors = await this.stopDescendants()
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
this.terminal.terminate()
|
||||
const quiescent = await this.terminal.waitForExit()
|
||||
if (!quiescent) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
|
||||
}
|
||||
await this.stopShell()
|
||||
// Whole-session cleanup can fail before the top-level process exits. Wait
|
||||
// for it first so that failure is reported instead of blocking forever on
|
||||
// `done`; successful quiescence guarantees `done` can now settle status and
|
||||
// drain the terminal output.
|
||||
await this.completion
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
this.terminal.output.off('data', this.onTerminalData)
|
||||
this.terminal.output.off('end', this.onTerminalEnd)
|
||||
this.terminal.output.off('error', this.onTerminalError)
|
||||
if (this.transportFailure !== undefined) throw this.transportFailure
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user