feat: add persistent PTY sessions

This commit is contained in:
NI0317
2026-07-21 16:01:00 +08:00
parent b4750f6333
commit 58cde5103a
71 changed files with 5677 additions and 82 deletions

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
function config(overrides: Partial<Config> = {}): Config {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, timeoutMs: 1000,
disposeGraceMs: 100,
...overrides,
}
}
describe('pty-local config', () => {
it('accepts resolved positive bounds', () => {
expect(() => { validateConfig(config()) }).not.toThrow()
})
it('rejects empty names, invalid numbers, and a read cap above retention', () => {
expect(() => { validateConfig(config({ backendType: '' })) }).toThrow('backendType')
expect(() => { validateConfig(config({ shellPath: '' })) }).toThrow('shellPath')
expect(() => { validateConfig(config({ rows: 0 })) }).toThrow('rows')
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
})
})

View File

@@ -0,0 +1,186 @@
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 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 { 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'
import type { ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
class RecordingSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function config(): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 10,
}
}
function agent(ctx: Context): Agent {
const id = SessionId('agent')
return {
id, options: {}, session: new Session(id), status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
isAlive: () => false,
signalGroup() {},
signalProcess() {},
} satisfies ProcessInspector
function spec(owner: Agent, signal?: AbortSignal) {
return {
sessionId: PtySessionId('pty-1'), owner, type: 'shell',
...signal !== undefined ? { signal } : {},
}
}
describe('LocalPtyBackend startup rollback', () => {
it('rejects pre-aborted setup and empty sandbox argv', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
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')
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
})
it('closes failed startup and aggregates cleanup failure', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const spawnTerminal = (() => ({} as IPty)) as never
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
const backend = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => failed)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
const doublyFailed = {
initialize: () => Promise.reject(new Error('startup failed')),
close: () => Promise.reject(new Error('cleanup failed')),
} 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')
})
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const terminal = {} as IPty
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
spawned = { file, args, options }
return terminal
}) as never
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new LocalPtyBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
inspector,
spawnTerminal,
() => session,
)
const previous = process.env.PTY_TEST_SECRET
process.env.PTY_TEST_SECRET = 'must-not-leak'
try {
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
} finally {
if (previous === undefined) delete process.env.PTY_TEST_SECRET
else process.env.PTY_TEST_SECRET = previous
}
expect(spawned).toMatchObject({
file: '/sandbox',
args: ['--', '/bin/bash', '-i'],
options: {
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
env: {
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
},
},
})
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
})
it('composes the default local session around a spawned terminal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
const terminal = {
pid: 123, cols: 80, rows: 24, process: 'bash', handleFlowControl: false,
onData(listener: (data: string) => void) {
queueMicrotask(() => { listener('\x1b]133;D;0\x07dsh> ') })
return { dispose() {} }
},
onExit(listener: (event: { exitCode: number; signal?: number }) => void) {
exitListener = listener
return { dispose() {} }
},
write() {},
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
resize() {}, clear() {}, pause() {}, resume() {},
} as IPty
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
await session.close('test complete')
})
})
describe('pty-local plugin shape', () => {
it('keeps name, inject, and Config through Loader unwrapExports', () => {
expect('default' in ptyLocal).toBe(false)
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.Config).toBeDefined()
})
it('validates config and registers the configured backend', async () => {
const ctx = new Context()
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const fiber = await ctx.plugin(ptyLocal, config())
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
})
})

View File

@@ -0,0 +1,122 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
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 SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
const roots: string[] = []
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
class PassthroughSandbox extends SandboxProvider {
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
}
}
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scope = ctx.plugin(() => {})
return {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
}
async function harness(mode: 'danger-full-access' | 'workspace-write') {
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
roots.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
await ctx.plugin(PassthroughSandbox)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
idleSilenceMs: 250,
timeoutMs: 2000,
disposeGraceMs: 500,
scrollbackLines: 100,
scrollbackMaxBytes: 32_768,
maxReadBytes: 16_384,
})
const agent = stubAgent(ctx, `agent-${mode}`)
ctx.agents.register(agent)
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
process.env.DSH_TEST_SECRET = 'must-not-leak'
try {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root })
expect(created.motd).toContain('dsh> ')
const first = ctx.pty.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
expect((await first.done).waitReason).toBe('stdin_read')
const second = ctx.pty.startSend(agent, created.sessionId, { text: 'printf "cwd=%s keep=%s secret=%s\\n" "$PWD" "$KEEP" "${DSH_TEST_SECRET-unset}"', submit: true })
expect((await second.done).viewport).toContain('cwd=/ keep=ok secret=unset')
expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true)
expect(ctx.pty.list(agent)).toEqual([])
} finally {
if (previous === undefined) delete process.env.DSH_TEST_SECRET
else process.env.DSH_TEST_SECRET = previous
}
}, 10_000)
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
expect(sandbox.calls).toEqual([{
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: root },
}])
await fiber.dispose()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.list(agent)).toHaveLength(1)
await ctx.pty.kill(agent, created.sessionId)
}, 10_000)
it('signals a foreground command and kills a TERM-ignoring background descendant', async () => {
const { ctx, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const foreground = ctx.pty.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
await new Promise(resolve => setTimeout(resolve, 50))
expect((await ctx.pty.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
expect((await foreground.done).waitReason).toBe('stdin_read')
const background = ctx.pty.startSend(agent, created.sessionId, {
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
submit: true,
})
const output = (await background.done).viewport
const child = /CHILD=(\d+)/.exec(output)?.[1]
expect(child).toBeDefined()
const pid = Number(child)
expect(() => process.kill(pid, 0)).not.toThrow()
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
})

View File

@@ -0,0 +1,215 @@
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)]
while (rest.length < 19) rest.push('0')
rest.push(started)
return `${pid} (command with space) ${rest.join(' ')}`
}
function syscall(number: number, ...args: number[]): string {
const six = [...args]
while (six.length < 6) six.push(0)
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
}
function fakeInternals() {
const files = new Map<string, string>()
const dirs = new Map<string, string[]>()
const memories = new Map<string, Buffer>()
const fds = new Map<number, string>()
const kills: Array<[number, NodeJS.Signals]> = []
let nextFd = 10
let ps = ''
let tpgid = '0'
const internals: ProcessInspectorInternals = {
readFile(path) {
const value = files.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
readDir(path) {
const value = dirs.get(path)
if (value === undefined) throw new Error(`missing ${path}`)
return value
},
open(path) {
if (!memories.has(path)) throw new Error(`missing ${path}`)
const fd = nextFd++
fds.set(fd, path)
return fd
},
read(fd, buffer, length, position) {
const path = fds.get(fd)
if (path === undefined) throw new Error('bad fd')
const source = memories.get(path)
if (source === undefined) throw new Error('missing memory')
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
},
close(fd) { fds.delete(fd) },
exec(_file, args) {
if (args.includes('tpgid=')) return tpgid
return ps
},
kill(pid, signal) { kills.push([pid, signal]) },
}
return {
internals, files, dirs, memories, kills,
setPs(value: string) { ps = value },
setTpgid(value: string) { tpgid = value },
}
}
describe('Linux process inspector', () => {
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
expect(parseProcStat('bad')).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' })
const fake = fakeInternals()
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(40)
expect(inspector.foregroundPgid(11)).toBeUndefined()
expect(inspector.foregroundPgid(99)).toBeUndefined()
expect(inspector.processTree(10)).toEqual([
{ pid: 13, started: '503' },
{ pid: 12, started: '502' },
{ pid: 10, started: '500' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
inspector.signalGroup(40, 'SIGINT')
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
})
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100', '101'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
fake.dirs.set('/proc/100/task', ['100'])
fake.dirs.set('/proc/101/task', ['101', '102'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
fake.files.set('/proc/100/task/100/syscall', 'running')
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
const fdSet = Buffer.alloc(0x11)
fdSet[0x10] = 1
fake.memories.set('/proc/101/mem', fdSet)
expect(inspector.isStdinWaiting(77)).toBe(true)
const poll = Buffer.alloc(8)
poll.writeInt32LE(0, 0)
poll.writeInt16LE(1, 4)
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
expect(inspector.isStdinWaiting(77)).toBe(true)
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
expect(inspector.isStdinWaiting(77)).toBe(true)
})
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(999))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.delete('/proc/100/task')
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
fake.dirs.set('/proc', ['100', '200'])
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
})
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
const fake = fakeInternals()
fake.dirs.set('/proc', ['100'])
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
fake.dirs.set('/proc/100/task', ['100'])
const inspector = createProcessInspector('linux', 'x64', fake.internals)
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
expect(inspector.isStdinWaiting(77)).toBe(false)
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
const noStdinPoll = Buffer.alloc(0x28)
noStdinPoll.writeInt32LE(2, 0x20)
noStdinPoll.writeInt16LE(1, 0x24)
fake.memories.set('/proc/100/mem', noStdinPoll)
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
expect(inspector.isStdinWaiting(77)).toBe(false)
})
})
describe('macOS process inspector', () => {
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
const fake = fakeInternals()
fake.setTpgid('55\n')
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
expect(inspector.foregroundPgid(10)).toBe(55)
expect(inspector.isStdinWaiting(55)).toBe(false)
expect(inspector.processTree(10)).toEqual([
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
expect(inspector.processTree(99)).toEqual([])
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
inspector.signalGroup(55, 'SIGTSTP')
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
expect(inspector.processTree(10)).toEqual([
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
])
})
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
const fake = fakeInternals()
fake.setTpgid('-1')
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
fake.internals.exec = () => { throw new Error('gone') }
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported platform win32')
})
})

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-local/src/sanitize.ts'
describe('TerminalSanitizer', () => {
it('removes split CSI and owned OSC prompt markers', () => {
const sanitizer = new 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 })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
const sanitizer = new TerminalSanitizer()
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
expect(sanitizer.flush()).toBe('')
expect(sanitizer.flush()).toBe('')
expect(sanitizer.push('\x1b]0;one\x07middle\x1b\\')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;one\x1b\\middle\x07')).toEqual({ text: 'middle', prompt: false })
expect(sanitizer.push('\x1b]0;title\x1b\\')).toEqual({ text: '', prompt: false })
})
it('normalizes CRLF and standalone carriage returns', () => {
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
})
})

View File

@@ -0,0 +1,301 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { IDisposable, IPty } from 'node-pty'
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
class FakeTerminal {
pid = 123
cols = 80
rows = 24
process = 'bash'
handleFlowControl = false
writes: string[] = []
kills: string[] = []
throwWrite = false
throwKill = false
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
readonly onData = (listener: (data: string) => void): IDisposable => {
this.dataListeners.add(listener)
return { dispose: () => this.dataListeners.delete(listener) }
}
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
this.exitListeners.add(listener)
return { dispose: () => this.exitListeners.delete(listener) }
}
emitData(data: string): void {
for (const listener of this.dataListeners) listener(data)
}
emitExit(exitCode = 0, signal?: number): void {
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
}
write(data: string): void {
if (this.throwWrite) throw new Error('write failed')
this.writes.push(data)
}
kill(signal?: string): void {
if (this.throwKill) throw new Error('kill failed')
this.kills.push(signal ?? 'SIGHUP')
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
}
resize() {}
clear() {}
pause() {}
resume() {}
asPty(): IPty {
return this
}
}
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
alive = new Set<number>()
groups: Array<[number, PtySignal]> = []
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
throwGroup = false
throwProcess = false
removeOnSignal = true
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: PtySignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
if (this.throwProcess) throw new Error('process raced')
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
}
afterEach(() => { vi.useRealTimers() })
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
const pending = session.initialize()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await pending
}
describe('LocalPtySession readiness and output', () => {
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
expect(session.motd).toBe('dsh> ')
inspector.waiting = true
const operation = session.startSend({ text: 'python3', submit: true })
expect(terminal.writes).toEqual(['python3', '\r'])
terminal.emitData('Python\r\n>>> ')
await vi.advanceTimersByTimeAsync(20)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
})
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.pgid = undefined
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const inferred = session.startSend({ text: 'sleep', submit: false })
terminal.emitData('working')
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
await vi.advanceTimersByTimeAsync(60)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: 'blocked', submit: false })
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(30)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: 'exit', submit: true })
terminal.emitExit(7, 9)
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
await initialize(session, terminal)
const controller = new AbortController()
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')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
const aborted = new AbortController()
aborted.abort()
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
terminal.throwWrite = true
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('write failed')
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
failedInternal.append('ignored')
failedInternal.fail(new Error('ignored'))
})
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
vi.useFakeTimers()
const startupTerminal = new FakeTerminal()
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
const initializing = startup.initialize(new AbortController().signal)
startupTerminal.emitExit(1)
await expect(initializing).rejects.toThrow('exited during startup')
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
}
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
statusValue: PtySessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as PtySendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
operationInternal.settle('timeout', { kind: 'running' }, false)
const unknownTerminal = new FakeTerminal()
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
unknownTerminal.emitExit(1, 999)
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const cancelTerminal = new FakeTerminal()
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelTerminal.throwWrite = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {
it('validates pagination and enforces line/UTF-8 bounds', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(
terminal.asPty(),
new FakeInspector(),
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
)
expect(session.read({})).toMatchObject({ text: '' })
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
terminal.emitData('一\n二\n三\n四')
await vi.advanceTimersByTimeAsync(60)
expect((await operation.done).truncated).toBe(true)
const page = session.read({ offset: 0, count: 3 })
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
expect(page.truncated).toBe(true)
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
expect(() => session.read({ offset: -1 })).toThrow('offset')
expect(() => session.read({ count: 0 })).toThrow('count')
const tinyTerminal = new FakeTerminal()
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
await initialize(tiny, tinyTerminal)
const tinyOperation = tiny.startSend({ text: '', submit: false })
tinyTerminal.emitData('一')
await vi.advanceTimersByTimeAsync(60)
await tinyOperation.done
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
})
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = new LocalPtySession(terminal.asPty(), inspector, config())
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
inspector.pgid = terminal.pid
await expect(session.signal('SIGKILL')).rejects.toThrow('use pty_kill')
inspector.pgid = undefined
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
})
it('closes idempotently, contains signal races, and reports survivors', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.members = [{ pid: 123, started: 'a' }]
inspector.alive.add(123)
inspector.throwProcess = true
terminal.throwKill = true
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
const closing = session.close('test')
expect(session.close('other')).toBe(closing)
await expect(closing).rejects.toThrow('surviving pids: 123')
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('waits for SIGKILL recipients to leave the process table after the shell exits', 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: 20 }))
let settled = false
const closing = session.close('test').then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
expect(settled).toBe(false)
inspector.alive.delete(124)
await vi.advanceTimersByTimeAsync(20)
await closing
expect(settled).toBe(true)
})
})