fix(pty): close review lifecycle gaps
This commit is contained in:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user