Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -4,9 +4,11 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
|
||||
|
||||
## Plugin (`pty-local`)
|
||||
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pty": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
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 type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -20,10 +23,37 @@ export type { Config as PtyLocalConfig } from './config.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'pty-local'
|
||||
/** Required services: registry plus the one shared confinement policy. */
|
||||
/** Required services: PTY registry plus the one shared confinement policy. */
|
||||
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
interface SandboxModeFenceState {
|
||||
pty: Context['pty']
|
||||
sandboxPolicy: Context['sandboxPolicy']
|
||||
}
|
||||
|
||||
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
|
||||
|
||||
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
const existing = sandboxModeFences.get(owner)
|
||||
if (existing !== undefined) {
|
||||
existing.pty = ctx.pty
|
||||
existing.sandboxPolicy = ctx.sandboxPolicy
|
||||
return
|
||||
}
|
||||
const state: SandboxModeFenceState = { pty: ctx.pty, sandboxPolicy: ctx.sandboxPolicy }
|
||||
sandboxModeFences.set(owner, state)
|
||||
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (session !== owner.session || event.type !== 'sandbox/mode') return
|
||||
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
|
||||
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
|
||||
throw new Error(
|
||||
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
|
||||
)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
@@ -73,7 +103,8 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
}
|
||||
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
|
||||
spec.signal?.throwIfAborted()
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
const file = argv[0]
|
||||
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
@@ -93,7 +124,7 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
try {
|
||||
await session.close('PTY startup failed')
|
||||
} catch (closeError: unknown) {
|
||||
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
|
||||
throw new PtyBackendCleanupError(error, closeError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProcessInspector {
|
||||
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
|
||||
@@ -49,6 +50,7 @@ interface ProcStat {
|
||||
parentPid: number
|
||||
pgrp: number
|
||||
session: number
|
||||
state: string
|
||||
tpgid: number
|
||||
started: string
|
||||
}
|
||||
@@ -64,13 +66,15 @@ export function parseProcStat(text: string): ProcStat | undefined {
|
||||
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) || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, tpgid, started }
|
||||
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 {
|
||||
@@ -269,7 +273,8 @@ class LinuxProcessInspector extends PosixProcessInspector {
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
|
||||
const stat = readLinuxStat(this.internals, identity.pid)
|
||||
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
export interface SanitizedChunk {
|
||||
text: string
|
||||
prompt: boolean
|
||||
/** Present when printable text followed the latest owned prompt marker. */
|
||||
promptText?: true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,8 @@ export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
private trailingCarriageReturn = false
|
||||
private awaitingPromptText = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
@@ -32,15 +36,24 @@ export class TerminalSanitizer {
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let promptText = false
|
||||
let index = 0
|
||||
const appendText = (value: string): boolean => {
|
||||
text += value
|
||||
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
|
||||
this.awaitingPromptText = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
while (index < this.pending.length) {
|
||||
const escape = this.pending.indexOf('\x1b', index)
|
||||
if (escape < 0) {
|
||||
text += this.pending.slice(index)
|
||||
promptText = appendText(this.pending.slice(index)) || promptText
|
||||
index = this.pending.length
|
||||
break
|
||||
}
|
||||
text += this.pending.slice(index, escape)
|
||||
promptText = appendText(this.pending.slice(index, escape)) || promptText
|
||||
if (escape + 1 >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
@@ -59,7 +72,11 @@ export class TerminalSanitizer {
|
||||
}
|
||||
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
|
||||
const content = this.pending.slice(escape + 2, end - terminatorBytes)
|
||||
if (content.startsWith(PROMPT_MARKER_PREFIX)) prompt = true
|
||||
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
|
||||
prompt = true
|
||||
promptText = false
|
||||
this.awaitingPromptText = true
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
@@ -82,7 +99,7 @@ export class TerminalSanitizer {
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: normalizeTerminalText(text), prompt }
|
||||
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +111,21 @@ export class TerminalSanitizer {
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
return normalizeTerminalText(text)
|
||||
this.awaitingPromptText = false
|
||||
const normalized = this.normalizeText(text)
|
||||
if (!this.trailingCarriageReturn) return normalized
|
||||
this.trailingCarriageReturn = false
|
||||
return `${normalized}\n`
|
||||
}
|
||||
|
||||
private normalizeText(text: string): string {
|
||||
let complete = this.trailingCarriageReturn ? `\r${text}` : text
|
||||
this.trailingCarriageReturn = false
|
||||
if (complete.endsWith('\r')) {
|
||||
complete = complete.slice(0, -1)
|
||||
this.trailingCarriageReturn = true
|
||||
}
|
||||
return normalizeTerminalText(complete)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
PtyWaitReason,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
|
||||
import { TerminalSanitizer } from './sanitize.ts'
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
@@ -148,9 +148,11 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closing = false
|
||||
private closePromise: Promise<void> | undefined
|
||||
|
||||
constructor(
|
||||
@@ -184,27 +186,29 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
||||
this.motd = result.viewport
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
throw error
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
}
|
||||
|
||||
startSend(request: PtySendRequest): PtySendOperation {
|
||||
if (this.closePromise !== undefined) throw new Error('PTY session is closing')
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
|
||||
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 operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
|
||||
try {
|
||||
this.terminal.write('\x03')
|
||||
} catch (error: unknown) {
|
||||
operation.fail(error)
|
||||
}
|
||||
})
|
||||
const operation = new LocalSendOperation(
|
||||
this.config.maxReadBytes,
|
||||
Date.now(),
|
||||
() => { this.interrupt(operation) },
|
||||
)
|
||||
this.active = operation
|
||||
this.lastOutputAt = Date.now()
|
||||
this.promptSeen = false
|
||||
this.promptTextSeen = false
|
||||
|
||||
if (request.signal !== undefined) {
|
||||
const onAbort = (): void => { operation.cancel() }
|
||||
@@ -267,8 +271,15 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
close(reason: string): Promise<void> {
|
||||
this.closePromise ??= this.closeOnce(reason)
|
||||
return this.closePromise
|
||||
this.closing = true
|
||||
if (this.closePromise !== undefined) return this.closePromise
|
||||
const closing = this.closeOnce(reason).catch((error: unknown) => {
|
||||
this.closePromise = undefined
|
||||
this.failActive(error)
|
||||
throw error
|
||||
})
|
||||
this.closePromise = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
private onData(data: string): void {
|
||||
@@ -279,8 +290,11 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
|
||||
this.promptSeen = true
|
||||
this.promptTextSeen = sanitized.promptText === true
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
} else if (this.promptSeen && sanitized.promptText === true) {
|
||||
this.promptTextSeen = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +311,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
@@ -337,58 +351,113 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
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 members = this.inspector.processTree(this.pid)
|
||||
for (const member of members) {
|
||||
try {
|
||||
this.inspector.signalProcess(member, 'SIGTERM')
|
||||
} catch (_alreadyExitedDuringTerm) {
|
||||
// Identity is rechecked by the inspector; a same-tick exit is success.
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
} catch (_topLevelAlreadyExited) {
|
||||
// onExit or identity checks below remain authoritative.
|
||||
}
|
||||
|
||||
const deadline = Date.now() + this.config.disposeGraceMs
|
||||
let survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await delay(Math.min(25, this.config.disposeGraceMs))
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
for (const survivor of survivors) {
|
||||
try {
|
||||
this.inspector.signalProcess(survivor, 'SIGKILL')
|
||||
} catch (_alreadyExitedDuringKill) {
|
||||
// Final identity check below decides success.
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.terminal.kill('SIGKILL')
|
||||
} catch (_topLevelAlreadyKilled) {
|
||||
// The root may already have delivered onExit.
|
||||
}
|
||||
|
||||
const killDeadline = Date.now() + this.config.disposeGraceMs
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
while (survivors.length > 0 && Date.now() < killDeadline) {
|
||||
await delay(Math.min(25, this.config.disposeGraceMs))
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
const exitWaitMs = Math.max(0, killDeadline - Date.now())
|
||||
await Promise.race([this.exitPromise.promise, delay(exitWaitMs)])
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
const survivors = await this.stopDescendants()
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
await this.stopShell()
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPty, IPtyForkOptions } from 'node-pty'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
@@ -62,6 +62,30 @@ function spec(owner: Agent, signal?: AbortSignal) {
|
||||
}
|
||||
}
|
||||
|
||||
function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolve()): LocalPtySession {
|
||||
return {
|
||||
motd: '',
|
||||
initialize,
|
||||
startSend: () => { throw new Error('unused') },
|
||||
read: () => { throw new Error('unused') },
|
||||
signal: () => Promise.resolve({ delivered: true, targetPgid: 1 }),
|
||||
status: () => ({ kind: 'running' as const }),
|
||||
close: () => Promise.resolve(),
|
||||
} as unknown as LocalPtySession
|
||||
}
|
||||
|
||||
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
|
||||
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy'], (providerCtx) => {
|
||||
providerCtx.pty.registerBackend(new LocalPtyBackend(
|
||||
providerCtx,
|
||||
{ ...config(), backendType: 'stub' },
|
||||
inspector,
|
||||
(() => ({})) as never,
|
||||
createSession,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
describe('LocalPtyBackend startup rollback', () => {
|
||||
it('rejects pre-aborted setup and empty sandbox argv', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -69,8 +93,9 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted')
|
||||
const abortReason = new Error('spawn aborted')
|
||||
controller.abort(abortReason)
|
||||
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toBe(abortReason)
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
|
||||
})
|
||||
|
||||
@@ -86,12 +111,18 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
|
||||
expect(closed).toHaveBeenCalledWith('PTY startup failed')
|
||||
|
||||
const startupFailure = new Error('startup failed')
|
||||
const cleanupFailure = new Error('cleanup failed')
|
||||
const doublyFailed = {
|
||||
initialize: () => Promise.reject(new Error('startup failed')),
|
||||
close: () => Promise.reject(new Error('cleanup failed')),
|
||||
initialize: () => Promise.reject(startupFailure),
|
||||
close: () => Promise.reject(cleanupFailure),
|
||||
} as unknown as LocalPtySession
|
||||
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
|
||||
name: 'PtyBackendCleanupError',
|
||||
spawnError: startupFailure,
|
||||
cleanupError: cleanupFailure,
|
||||
} satisfies Partial<PtyBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
@@ -175,6 +206,7 @@ describe('pty-local plugin shape', () => {
|
||||
|
||||
it('validates config and registers the configured backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
@@ -183,4 +215,90 @@ describe('pty-local plugin shape', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores unrelated session events and mode changes without a live owner', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(ptyLocal, config())
|
||||
|
||||
const session = ctx.sessions.create(SessionId('unowned-mode'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps the owner-lifetime sandbox fence after the local provider unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const session = ctx.sessions.create(SessionId('mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
|
||||
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
await providerFiber.dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow(
|
||||
'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first',
|
||||
)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1)
|
||||
|
||||
const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const second = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
await replacementFiber.dispose()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
|
||||
await ctx.pty.kill(owner, created.sessionId)
|
||||
await ctx.pty.kill(owner, second.sessionId)
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('also fences sandbox-mode changes across unpublished PTY creation', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
|
||||
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
|
||||
const spawning = ctx.pty.spawn(owner, { type: 'stub' })
|
||||
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
gate.resolve(undefined)
|
||||
const created = await spawning
|
||||
await ctx.pty.kill(owner, created.sessionId)
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import PtyService from '@deepseek-ai/dsh-pty'
|
||||
import type { PtySendOperation } from '@deepseek-ai/dsh-pty'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -62,6 +63,16 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
|
||||
}
|
||||
|
||||
async function waitForOutput(operation: PtySendOperation, expected: string): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
let output = ''
|
||||
while (!output.includes(expected) && Date.now() < deadline) {
|
||||
output += operation.readOutput().delta
|
||||
if (!output.includes(expected)) await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(output).toContain(expected)
|
||||
}
|
||||
|
||||
describe('pty-local real shell', () => {
|
||||
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
|
||||
const previous = process.env.DSH_TEST_SECRET
|
||||
@@ -119,4 +130,26 @@ describe('pty-local real shell', () => {
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}, 10_000)
|
||||
|
||||
it('cancels a raw-mode foreground process with a real SIGINT', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||
const controller = new AbortController()
|
||||
const foreground = ctx.pty.startSend(agent, created.sessionId, {
|
||||
text: 'python3 -c \'import signal,sys,termios,time; signal.signal(signal.SIGINT, lambda *_: (print("SIGINT_SEEN", flush=True), sys.exit(0))); attrs=termios.tcgetattr(0); attrs[3] &= ~termios.ISIG; termios.tcsetattr(0, termios.TCSANOW, attrs); print("RAW_READY", flush=True); time.sleep(60)\'',
|
||||
submit: true,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await waitForOutput(foreground, 'RAW_READY')
|
||||
controller.abort()
|
||||
const result = await foreground.done
|
||||
expect(result.waitReason).toBe('stdin_read')
|
||||
const after = await ctx.pty.startSend(agent, created.sessionId, {
|
||||
text: 'echo AFTER_SIGINT',
|
||||
submit: true,
|
||||
}).done
|
||||
expect(after.viewport).toContain('AFTER_SIGINT')
|
||||
expect(after.waitReason).toBe('stdin_read')
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
|
||||
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
|
||||
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
while (rest.length < 19) rest.push('0')
|
||||
rest.push(started)
|
||||
return `${pid} (command with space) ${rest.join(' ')}`
|
||||
@@ -65,8 +65,10 @@ function fakeInternals() {
|
||||
describe('Linux process inspector', () => {
|
||||
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
|
||||
expect(parseProcStat('bad')).toBeUndefined()
|
||||
expect(parseProcStat('1 () ')).toBeUndefined()
|
||||
expect(parseProcStat('1 () S')).toBeUndefined()
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
|
||||
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
|
||||
@@ -90,6 +92,10 @@ describe('Linux process inspector', () => {
|
||||
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
|
||||
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
|
||||
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
|
||||
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
|
||||
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
|
||||
})
|
||||
|
||||
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('TerminalSanitizer', () => {
|
||||
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
|
||||
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
|
||||
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
|
||||
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true })
|
||||
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true })
|
||||
})
|
||||
|
||||
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
|
||||
@@ -25,6 +25,20 @@ describe('TerminalSanitizer', () => {
|
||||
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
|
||||
})
|
||||
|
||||
it('carries a trailing carriage return across data chunks and flushes standalone CR', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('a\r')).toEqual({ text: 'a', prompt: false })
|
||||
expect(sanitizer.push('\nb')).toEqual({ text: '\nb', prompt: false })
|
||||
expect(sanitizer.push('\r')).toEqual({ text: '', prompt: false })
|
||||
expect(sanitizer.flush()).toBe('\n')
|
||||
})
|
||||
|
||||
it('reports printable prompt text that follows a marker in a later chunk', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true })
|
||||
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true })
|
||||
})
|
||||
|
||||
it('bounds and discards unterminated control sequences through their terminators', () => {
|
||||
const oscBel = new TerminalSanitizer(8)
|
||||
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
|
||||
@@ -15,6 +15,7 @@ class FakeTerminal {
|
||||
kills: string[] = []
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
private dataListeners = new Set<(data: string) => void>()
|
||||
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
|
||||
|
||||
@@ -44,7 +45,7 @@ class FakeTerminal {
|
||||
kill(signal?: string): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push(signal ?? 'SIGHUP')
|
||||
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
||||
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
||||
}
|
||||
|
||||
resize() {}
|
||||
@@ -148,7 +149,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
|
||||
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
@@ -159,7 +160,8 @@ describe('LocalPtySession readiness and output', () => {
|
||||
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
|
||||
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
|
||||
controller.abort()
|
||||
expect(terminal.writes.at(-1)).toBe('\x03')
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
expect(terminal.writes).not.toContain('\x03')
|
||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await operation.done
|
||||
@@ -196,11 +198,13 @@ describe('LocalPtySession readiness and output', () => {
|
||||
operationInternal.append('')
|
||||
const sessionInternal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
interrupt(operation: PtySendOperation): void
|
||||
statusValue: PtySessionStatus
|
||||
appendOutput(text: string): void
|
||||
}
|
||||
sessionInternal.appendOutput('')
|
||||
sessionInternal.pollReadiness({} as PtySendOperation)
|
||||
sessionInternal.interrupt({} as PtySendOperation)
|
||||
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
||||
sessionInternal.pollReadiness(operation)
|
||||
await operation.done
|
||||
@@ -212,12 +216,23 @@ describe('LocalPtySession readiness and output', () => {
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
|
||||
const cancelTerminal = new FakeTerminal()
|
||||
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
|
||||
const cancelInspector = new FakeInspector()
|
||||
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
|
||||
await initialize(cancel, cancelTerminal)
|
||||
const cancellable = cancel.startSend({ text: '', submit: false })
|
||||
cancelTerminal.throwWrite = true
|
||||
cancelInspector.throwGroup = true
|
||||
expect(cancellable.cancel()).toBe(true)
|
||||
await expect(cancellable.done).rejects.toThrow('write failed')
|
||||
await expect(cancellable.done).rejects.toThrow('group failed')
|
||||
expect(cancellable.cancel()).toBe(false)
|
||||
|
||||
const missingGroupTerminal = new FakeTerminal()
|
||||
const missingGroupInspector = new FakeInspector()
|
||||
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
|
||||
await initialize(missingGroup, missingGroupTerminal)
|
||||
missingGroupInspector.pgid = undefined
|
||||
const unresolved = missingGroup.startSend({ text: '', submit: false })
|
||||
expect(unresolved.cancel()).toBe(true)
|
||||
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
|
||||
})
|
||||
|
||||
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
|
||||
@@ -239,6 +254,38 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
|
||||
const initializing = session.initialize(controller.signal)
|
||||
const rejected = expect(initializing).rejects.toBe(reason)
|
||||
controller.abort(reason)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
||||
let settled = false
|
||||
const initializing = session.initialize().then(() => { settled = true })
|
||||
|
||||
terminal.emitData('\x1b]133;D;0\x07')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
terminal.emitData('dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await initializing
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
})
|
||||
|
||||
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -328,14 +375,15 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
// readiness poll would otherwise mis-settle this as stdin_read once close
|
||||
// begins, so teardown must stop polling before its grace period.
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
terminal.throwKill = true
|
||||
terminal.autoExitOnKill = false
|
||||
const closing = session.close('mid-send')
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
terminal.emitExit(0, 15)
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
await closing
|
||||
})
|
||||
|
||||
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
|
||||
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
@@ -348,11 +396,81 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
const closing = session.close('test').then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(terminal.kills).toEqual([])
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await closing
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rescans for descendants forked during TERM before stopping the shell', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
let reads = 0
|
||||
inspector.processTree = () => {
|
||||
reads += 1
|
||||
if (reads === 1) {
|
||||
inspector.alive.add(124)
|
||||
return [{ pid: 124, started: 'first' }]
|
||||
}
|
||||
if (reads === 2) {
|
||||
inspector.alive.add(125)
|
||||
return [{ pid: 125, started: 'late' }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
|
||||
await session.close('test')
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('retains captured survivors that are reparented out of the teardown rescan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const captured = { pid: 124, started: 'captured' }
|
||||
let reads = 0
|
||||
inspector.alive.add(captured.pid)
|
||||
inspector.processTree = () => reads++ === 0 ? [captured] : []
|
||||
inspector.signalProcess = (identity, signal) => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
const closing = session.close('test')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await closing
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('allows teardown to retry after a descendant-survivor failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
|
||||
|
||||
const first = session.close('first')
|
||||
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await rejected
|
||||
expect(terminal.kills).toEqual([])
|
||||
|
||||
inspector.alive.delete(124)
|
||||
const second = session.close('retry')
|
||||
expect(second).not.toBe(first)
|
||||
await second
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user