Merge remote-tracking branch 'origin/feat/windows-pwsh-default' into feat/windows-acl-sandbox

# Conflicts:
#	docs/module-graph.md
#	knip.json
#	packages/pty/pty-local/tests/index.spec.ts
#	scripts/check-workspace-constraints.ts
This commit is contained in:
Huanqi Cao
2026-08-09 00:29:55 +08:00
286 changed files with 15528 additions and 1684 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import type { IPty, IPtyForkOptions } from 'node-pty'
import { PassThrough } from 'node:stream'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -11,8 +11,14 @@ import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/d
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'
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
import type {
SubprocessHandle,
SubprocessSpawnSpec,
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
@@ -52,14 +58,26 @@ function agent(ctx: Context, cwd?: string): Agent {
}
}
const inspector = {
foregroundPgid: () => undefined,
isStdinWaiting: () => false,
processTree: () => [],
isAlive: () => false,
signalGroup() {},
signalProcess() {},
} satisfies ProcessInspector
function terminalHandle(): SubprocessTerminalHandle {
const output = new PassThrough()
return {
pid: 123,
output,
done: Promise.resolve({ exitCode: 0, signal: null }),
write: async () => {},
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
signalForeground: async () => 123,
terminate: async () => { output.end() },
}
}
class StubSubprocessService extends SubprocessService {
async resolveExecutable(command: string): Promise<string> { return command }
spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') }
async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
return terminalHandle()
}
}
function spec(owner: Agent, signal?: AbortSignal) {
return {
@@ -81,12 +99,11 @@ function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolv
}
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy'], (providerCtx) => {
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => {
providerCtx.pty.registerBackend(new LocalPtyBackend(
providerCtx,
{ ...config(), backendType: 'stub' },
inspector,
(() => ({})) as never,
async () => terminalHandle(),
createSession,
))
})
@@ -97,7 +114,7 @@ describe('LocalPtyBackend startup rollback', () => {
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 backend = new LocalPtyBackend(ctx, config(), async () => terminalHandle())
const controller = new AbortController()
const abortReason = new Error('spawn aborted')
controller.abort(abortReason)
@@ -107,13 +124,12 @@ describe('LocalPtyBackend startup rollback', () => {
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 spawnTerminal = async (): Promise<SubprocessTerminalHandle> => terminalHandle()
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)
const backend = new LocalPtyBackend(ctx, config(), spawnTerminal, () => failed)
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
expect(closed).toHaveBeenCalledWith('PTY startup failed')
@@ -123,7 +139,7 @@ describe('LocalPtyBackend startup rollback', () => {
initialize: () => Promise.reject(startupFailure),
close: () => Promise.reject(cleanupFailure),
} as unknown as LocalPtySession
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
const aggregate = new LocalPtyBackend(ctx, config(), spawnTerminal, () => doublyFailed)
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
name: 'PtyBackendCleanupError',
spawnError: startupFailure,
@@ -131,79 +147,191 @@ describe('LocalPtyBackend startup rollback', () => {
} satisfies Partial<PtyBackendCleanupError>))
})
it('resolves session mode and root together before wrapping the shell', async () => {
it('starts startup rollback when cancellation wins a stalled initialization', async () => {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const initialization = Promise.withResolvers<undefined>()
const initializationStarted = Promise.withResolvers<undefined>()
const close = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = {
initialize: () => {
initializationStarted.resolve(undefined)
return initialization.promise
},
close,
} as unknown as LocalPtySession
const backend = new LocalPtyBackend(ctx, config(), async () => terminalHandle(), () => session)
const controller = new AbortController()
const reason = new Error('cancel stalled startup')
const spawning = backend.spawn(spec(agent(ctx), controller.signal))
await initializationStarted.promise
controller.abort(reason)
await expect(spawning).rejects.toBe(reason)
expect(close).toHaveBeenCalledWith('PTY startup failed')
initialization.resolve(undefined)
})
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: 'read-only', workspaceRoot: '/deployment-fallback' })
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 }
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const terminal = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
spawned = spec
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'
const owner = agent(ctx, '/session-workspace')
setSandboxMode(owner.session, 'workspace-write')
try {
expect(await backend.spawn(spec(owner))).toBe(session)
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: '/session-workspace',
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',
},
argv: ['/sandbox', '--', '/bin/bash', '-i'],
cols: 80,
rows: 24,
cwd: '/work',
graceMs: 10,
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(spawned?.env?.PTY_TEST_SECRET).toBeUndefined()
expect(initialized).toHaveBeenCalledWith(undefined)
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace', sessionId: 'agent' },
policy: { mode: 'workspace-write', workspaceRoot: '/workspace' },
}])
})
it('resolves session mode and root together before wrapping the shell', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
const terminal = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
spawned = spec
return terminal
}
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const session = { initialize: initialized } as unknown as LocalPtySession
const backend = new LocalPtyBackend(
ctx,
{ ...config(), shellArgs: ['-i'] },
spawnTerminal,
() => session,
)
const owner = agent(ctx, '/session-workspace')
setSandboxMode(owner.session, 'workspace-write')
expect(await backend.spawn(spec(owner))).toBe(session)
expect(spawned).toMatchObject({
argv: ['/sandbox', '--', '/bin/bash', '-i'],
cwd: '/session-workspace',
})
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
argv: ['/bin/bash', '-i'],
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
}])
})
it('rejects a confined spawn without a sandbox provider', async () => {
const confinedCtx = new Context()
await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const confined = new LocalPtyBackend(
confinedCtx,
config(),
async () => { throw new Error('terminal spawn must not run') },
() => stubLocalSession(),
)
await expect(confined.spawn(spec(agent(confinedCtx)))).rejects.toThrow(
'sandbox mode "workspace-write" requires a ctx.sandbox provider in the execution world',
)
})
it('forwards terminal allocation cancellation directly', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const publishedController = new AbortController()
let publishedSignal: AbortSignal | undefined
const published = new LocalPtyBackend(
ctx,
config(),
async (spawnSpec) => {
publishedSignal = spawnSpec.signal
return terminalHandle()
},
() => stubLocalSession(),
)
await published.spawn(spec(agent(ctx), publishedController.signal))
expect(publishedSignal).toBe(publishedController.signal)
publishedController.abort(new Error('originating turn ended'))
expect(publishedSignal?.aborted).toBe(true)
const pendingController = new AbortController()
const seen = Promise.withResolvers<AbortSignal>()
const pending = new LocalPtyBackend(
ctx,
config(),
async spawnSpec => await new Promise<SubprocessTerminalHandle>((_resolve, reject) => {
const setupSignal = spawnSpec.signal as AbortSignal
seen.resolve(setupSignal)
const onAbort = (): void => {
reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason)))
}
setupSignal.addEventListener('abort', onAbort, { once: true })
}),
() => stubLocalSession(),
)
const spawning = pending.spawn(spec(agent(ctx), pendingController.signal))
const pendingSignal = await seen.promise
const reason = new Error('cancel pending allocation')
pendingController.abort(reason)
await expect(spawning).rejects.toBe(reason)
expect(pendingSignal.aborted).toBe(true)
})
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() {} }
const output = new PassThrough()
const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
const terminal: SubprocessTerminalHandle = {
pid: 123,
output,
done: outcome.promise,
write: async () => {},
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
signalForeground: async () => 123,
async terminate() {
output.end()
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
},
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
}
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
const backend = new LocalPtyBackend(
ctx,
config(),
{ ...inspector, foregroundPgid: () => terminal.pid },
() => terminal,
async () => terminal,
)
const session = await backend.spawn(spec(agent(ctx)))
expect(session.motd).toBe('dsh> ')
@@ -217,7 +345,7 @@ 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(['pty', 'sandboxPolicy', 'subprocess'])
expect(unwrapped.Config).toBeDefined()
})
@@ -225,8 +353,8 @@ describe('pty-local plugin shape', () => {
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' })
await ctx.plugin(StubSubprocessService)
const fiber = await ctx.plugin(ptyLocal, config())
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
@@ -240,6 +368,7 @@ describe('pty-local plugin shape', () => {
await ctx.plugin(PtyService)
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessService)
await ctx.plugin(ptyLocal, config())
const session = ctx.sessions.create(SessionId('unowned-mode'))
@@ -256,6 +385,7 @@ describe('pty-local plugin shape', () => {
await ctx.plugin(PtyService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessService)
const session = ctx.sessions.create(SessionId('mode-owner'))
const ownerFiber = await ctx.plugin(() => {})
@@ -304,6 +434,7 @@ describe('pty-local plugin shape', () => {
await ctx.plugin(PtyService)
await ctx.plugin(RecordingSandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessService)
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
const ownerFiber = await ctx.plugin(() => {})

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -11,6 +11,7 @@ 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'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
const roots: string[] = []
@@ -57,6 +58,7 @@ async function harness(
await ctx.plugin(PtyService)
await ctx.plugin(PassthroughSandbox)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
await ctx.plugin(LocalSubprocessService)
const fiber = await ctx.plugin(ptyLocal, {
pollIntervalMs: 10,
exactProbeAfterMs: 20,
@@ -96,6 +98,22 @@ function expectReadyForNextSend(waitReason: string): void {
expect(['stdin_read', 'inferred_idle']).toContain(waitReason)
}
function processIsRunning(pid: number): boolean {
try {
process.kill(pid, 0)
} catch (_missingProcess) {
return false
}
if (process.platform !== 'linux') return true
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
return !/^[ZXx]$/.test(state ?? '')
} catch (_unreadableProcEntry) {
return false
}
}
describe('pty-local real shell', () => {
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
const previous = process.env.DSH_TEST_SECRET
@@ -154,6 +172,47 @@ describe('pty-local real shell', () => {
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('quiesces a disowned same-session descendant after the shell exits naturally', async () => {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const pidFile = join(root, 'disowned.pid')
let pid: number | undefined
try {
const background = ctx.pty.startSend(agent, created.sessionId, {
text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`,
submit: true,
})
await background.done
const pidDeadline = Date.now() + 2_000
let childPid = 0
while (childPid === 0 && Date.now() < pidDeadline) {
if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8'))
if (childPid > 0) break
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(existsSync(pidFile), ctx.pty.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true)
expect(childPid).toBeGreaterThan(0)
pid = childPid
expect(() => process.kill(childPid, 0)).not.toThrow()
await ctx.pty.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done
const deadline = Date.now() + 2_000
while (ctx.pty.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(ctx.pty.list(agent)[0]?.status.kind).toBe('exited')
await ctx.pty.kill(agent, created.sessionId)
expect(processIsRunning(childPid)).toBe(false)
} finally {
if (pid !== undefined) {
try {
process.kill(pid, 'SIGKILL')
} catch (_alreadyReaped) {
// Product cleanup is the expected path; this only contains a failed regression.
}
}
}
}, 10_000)
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 10_000,

View File

@@ -1,221 +0,0 @@
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, 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(' ')}`
}
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 () ')).toBeUndefined()
expect(parseProcStat('1 () S')).toBeUndefined()
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'])
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']])
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', () => {
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

@@ -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, promptText: true })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptTail: 'dsh> ' })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
@@ -35,8 +35,8 @@ describe('TerminalSanitizer', () => {
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 })
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true, promptTail: '' })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptTail: 'dsh> ' })
})
it('bounds and discards unterminated control sequences through their terminators', () => {

File diff suppressed because it is too large Load Diff