import { mkdtempSync, realpathSync, 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, { Inbox } 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' 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: [], runnerFailureRules: [] } } } function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scope = ctx.plugin(() => {}) const session = Session.create(id) return { id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx: scope.ctx, send: () => {}, followup: () => {}, steer: () => {}, inject: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } } async function harness( mode: 'danger-full-access' | 'workspace-write', timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {}, ) { 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: timing.idleSilenceMs ?? 250, handoffGraceMs: timing.handoffGraceMs ?? 250, timeoutMs: timing.timeoutMs ?? 2_000, 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 } } // PtySendOperation.append drops output once the operation settles, so this only // observes a marker the child prints while `operation` is still active. A caller // whose child is slow to print must raise the harness `timing` bounds too; // extending this deadline alone cannot recover output the operation never collected. async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs 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) } // A send the test interrupts settles when bash returns to its prompt, so the // kernel may publish the foreground handoff on either side of the silence // bound. `handoffGraceMs` widens the window that wins the exact attribution but // cannot remove the race on a loaded host, so these settles assert that the // session became usable again, not which readiness tier observed it. function expectReadyForNextSend(waitReason: string): void { expect(['stdin_read', 'inferred_idle']).toContain(waitReason) } 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: realpathSync.native(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) expectReadyForNextSend((await foreground.done).waitReason) 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) 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, timeoutMs: 15_000, }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' // Delay readiness beyond the shared harness's short send bound so this // process test owns enough slack for loaded macOS startup and shell echo. // The interactive shell echoes the command, so only child output may contain the readiness marker. const command = '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); time.sleep(2.1); print("RAW_" + "READY", flush=True); time.sleep(60)\'' expect(command).not.toContain(ready) const foreground = ctx.pty.startSend(agent, created.sessionId, { text: command, submit: true, signal: controller.signal, }) await waitForOutput(foreground, ready, 15_000) controller.abort() const result = await foreground.done expectReadyForNextSend(result.waitReason) const afterReady = 'AFTER_SIGINT' const afterCommand = 'printf "AFTER_%s\\n" SIGINT' expect(afterCommand).not.toContain(afterReady) const after = ctx.pty.startSend(agent, created.sessionId, { text: afterCommand, submit: true, }) await waitForOutput(after, afterReady, 15_000) expectReadyForNextSend((await after.done).waitReason) await ctx.pty.kill(agent, created.sessionId) }, 35_000) })