fix(pty): close review lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-22 22:37:20 +08:00
parent 1167ea71b7
commit 57a47b1fb3
47 changed files with 940 additions and 192 deletions
+96 -5
View File
@@ -2,12 +2,14 @@ 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 SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import 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 SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackendSession } 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'
@@ -69,8 +71,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')
})
@@ -169,12 +172,13 @@ describe('pty-local plugin shape', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
expect(unwrapped.name).toBe('pty-local')
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy'])
expect(unwrapped.inject).toEqual(['agents', 'pty', 'sandbox', 'sandboxPolicy'])
expect(unwrapped.Config).toBeDefined()
})
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 +187,91 @@ 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('rejects an effective sandbox-mode change until the owner closes live terminals', 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('mode-owner'))
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const backendSession = {
motd: '',
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(),
} satisfies PtyBackendSession
ctx.pty.registerBackend({ type: 'stub', spawn: () => Promise.resolve(backendSession) })
const created = await ctx.pty.spawn(owner, { type: 'stub' })
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
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)
await ctx.pty.kill(owner, created.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(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const owner: Agent = {
id: session.id, options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(owner)
const gate = Promise.withResolvers<PtyBackendSession>()
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
const spawning = ctx.pty.spawn(owner, { type: 'slow' })
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
gate.resolve({
motd: '',
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(),
})
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 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)
})
+15 -1
View File
@@ -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 })
+90 -9
View File
@@ -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,23 @@ describe('LocalPtySession readiness and output', () => {
await timedOut
})
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 +360,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 +381,59 @@ 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('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'])
})
})