Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -4,9 +4,11 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
|
||||
|
||||
## Plugin (`pty-local`)
|
||||
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pty": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { Context } from 'cordis'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPtyForkOptions } from 'node-pty'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -20,10 +23,37 @@ export type { Config as PtyLocalConfig } from './config.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'pty-local'
|
||||
/** Required services: registry plus the one shared confinement policy. */
|
||||
/** Required services: PTY registry plus the one shared confinement policy. */
|
||||
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
interface SandboxModeFenceState {
|
||||
pty: Context['pty']
|
||||
sandboxPolicy: Context['sandboxPolicy']
|
||||
}
|
||||
|
||||
const sandboxModeFences = new WeakMap<Agent, SandboxModeFenceState>()
|
||||
|
||||
function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
const existing = sandboxModeFences.get(owner)
|
||||
if (existing !== undefined) {
|
||||
existing.pty = ctx.pty
|
||||
existing.sandboxPolicy = ctx.sandboxPolicy
|
||||
return
|
||||
}
|
||||
const state: SandboxModeFenceState = { pty: ctx.pty, sandboxPolicy: ctx.sandboxPolicy }
|
||||
sandboxModeFences.set(owner, state)
|
||||
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (session !== owner.session || event.type !== 'sandbox/mode') return
|
||||
const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
|
||||
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
|
||||
throw new Error(
|
||||
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
|
||||
)
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
@@ -73,7 +103,8 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
}
|
||||
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
|
||||
spec.signal?.throwIfAborted()
|
||||
ensureSandboxModeFence(this.ctx, spec.owner)
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
const file = argv[0]
|
||||
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
@@ -93,7 +124,7 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
try {
|
||||
await session.close('PTY startup failed')
|
||||
} catch (closeError: unknown) {
|
||||
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
|
||||
throw new PtyBackendCleanupError(error, closeError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProcessInspector {
|
||||
isStdinWaiting(pgid: number): boolean
|
||||
/** Return the root and its current transitive descendants, children first. */
|
||||
processTree(rootPid: number): ProcessIdentity[]
|
||||
/** Return whether the exact identity remains a non-quiescent process. */
|
||||
isAlive(identity: ProcessIdentity): boolean
|
||||
signalGroup(pgid: number, signal: PtySignal): void
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
|
||||
@@ -49,6 +50,7 @@ interface ProcStat {
|
||||
parentPid: number
|
||||
pgrp: number
|
||||
session: number
|
||||
state: string
|
||||
tpgid: number
|
||||
started: string
|
||||
}
|
||||
@@ -64,13 +66,15 @@ export function parseProcStat(text: string): ProcStat | undefined {
|
||||
if (open <= 0 || close <= open) return undefined
|
||||
const pid = Number(text.slice(0, open).trim())
|
||||
const rest = text.slice(close + 2).trim().split(/\s+/)
|
||||
const state = rest[0] || ''
|
||||
const parentPid = Number(rest[1])
|
||||
const pgrp = Number(rest[2])
|
||||
const session = Number(rest[3])
|
||||
const tpgid = Number(rest[5])
|
||||
const started = rest[19]
|
||||
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, tpgid, started }
|
||||
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|
||||
|| state.length !== 1 || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, state, tpgid, started }
|
||||
}
|
||||
|
||||
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
|
||||
@@ -269,7 +273,8 @@ class LinuxProcessInspector extends PosixProcessInspector {
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
|
||||
const stat = readLinuxStat(this.internals, identity.pid)
|
||||
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
export interface SanitizedChunk {
|
||||
text: string
|
||||
prompt: boolean
|
||||
/** Present when printable text followed the latest owned prompt marker. */
|
||||
promptText?: true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,8 @@ export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
private trailingCarriageReturn = false
|
||||
private awaitingPromptText = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
@@ -32,15 +36,24 @@ export class TerminalSanitizer {
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let promptText = false
|
||||
let index = 0
|
||||
const appendText = (value: string): boolean => {
|
||||
text += value
|
||||
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
|
||||
this.awaitingPromptText = false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
while (index < this.pending.length) {
|
||||
const escape = this.pending.indexOf('\x1b', index)
|
||||
if (escape < 0) {
|
||||
text += this.pending.slice(index)
|
||||
promptText = appendText(this.pending.slice(index)) || promptText
|
||||
index = this.pending.length
|
||||
break
|
||||
}
|
||||
text += this.pending.slice(index, escape)
|
||||
promptText = appendText(this.pending.slice(index, escape)) || promptText
|
||||
if (escape + 1 >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
@@ -59,7 +72,11 @@ export class TerminalSanitizer {
|
||||
}
|
||||
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
|
||||
const content = this.pending.slice(escape + 2, end - terminatorBytes)
|
||||
if (content.startsWith(PROMPT_MARKER_PREFIX)) prompt = true
|
||||
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
|
||||
prompt = true
|
||||
promptText = false
|
||||
this.awaitingPromptText = true
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
@@ -82,7 +99,7 @@ export class TerminalSanitizer {
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: normalizeTerminalText(text), prompt }
|
||||
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +111,21 @@ export class TerminalSanitizer {
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
return normalizeTerminalText(text)
|
||||
this.awaitingPromptText = false
|
||||
const normalized = this.normalizeText(text)
|
||||
if (!this.trailingCarriageReturn) return normalized
|
||||
this.trailingCarriageReturn = false
|
||||
return `${normalized}\n`
|
||||
}
|
||||
|
||||
private normalizeText(text: string): string {
|
||||
let complete = this.trailingCarriageReturn ? `\r${text}` : text
|
||||
this.trailingCarriageReturn = false
|
||||
if (complete.endsWith('\r')) {
|
||||
complete = complete.slice(0, -1)
|
||||
this.trailingCarriageReturn = true
|
||||
}
|
||||
return normalizeTerminalText(complete)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
PtyWaitReason,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
|
||||
import { TerminalSanitizer } from './sanitize.ts'
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
@@ -148,9 +148,11 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closing = false
|
||||
private closePromise: Promise<void> | undefined
|
||||
|
||||
constructor(
|
||||
@@ -184,27 +186,29 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
||||
this.motd = result.viewport
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
throw error
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
}
|
||||
|
||||
startSend(request: PtySendRequest): PtySendOperation {
|
||||
if (this.closePromise !== undefined) throw new Error('PTY session is closing')
|
||||
if (this.closing) throw new Error('PTY session is closing')
|
||||
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
|
||||
if (this.active !== undefined) throw new Error('PTY session already has an active send')
|
||||
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
|
||||
|
||||
const operation = new LocalSendOperation(this.config.maxReadBytes, Date.now(), () => {
|
||||
try {
|
||||
this.terminal.write('\x03')
|
||||
} catch (error: unknown) {
|
||||
operation.fail(error)
|
||||
}
|
||||
})
|
||||
const operation = new LocalSendOperation(
|
||||
this.config.maxReadBytes,
|
||||
Date.now(),
|
||||
() => { this.interrupt(operation) },
|
||||
)
|
||||
this.active = operation
|
||||
this.lastOutputAt = Date.now()
|
||||
this.promptSeen = false
|
||||
this.promptTextSeen = false
|
||||
|
||||
if (request.signal !== undefined) {
|
||||
const onAbort = (): void => { operation.cancel() }
|
||||
@@ -267,8 +271,15 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
close(reason: string): Promise<void> {
|
||||
this.closePromise ??= this.closeOnce(reason)
|
||||
return this.closePromise
|
||||
this.closing = true
|
||||
if (this.closePromise !== undefined) return this.closePromise
|
||||
const closing = this.closeOnce(reason).catch((error: unknown) => {
|
||||
this.closePromise = undefined
|
||||
this.failActive(error)
|
||||
throw error
|
||||
})
|
||||
this.closePromise = closing
|
||||
return closing
|
||||
}
|
||||
|
||||
private onData(data: string): void {
|
||||
@@ -279,8 +290,11 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
|
||||
this.promptSeen = true
|
||||
this.promptTextSeen = sanitized.promptText === true
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
} else if (this.promptSeen && sanitized.promptText === true) {
|
||||
this.promptTextSeen = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +311,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
if (this.promptSeen && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
@@ -337,58 +351,113 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
try {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
this.inspector.signalGroup(pgid, 'SIGINT')
|
||||
} catch (error: unknown) {
|
||||
this.failActive(error)
|
||||
}
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
return members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
|
||||
private descendants(): ProcessIdentity[] {
|
||||
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
|
||||
}
|
||||
|
||||
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
|
||||
const deadline = Date.now() + this.config.disposeGraceMs
|
||||
let survivors = this.survivors(members)
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
|
||||
survivors = this.survivors(members)
|
||||
}
|
||||
return survivors
|
||||
}
|
||||
|
||||
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
for (const member of members) {
|
||||
try {
|
||||
this.inspector.signalProcess(member, signal)
|
||||
} catch (_alreadyExitedDuringSignal) {
|
||||
// Identity is rechecked by the inspector; a same-tick exit is success.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
|
||||
const members: ProcessIdentity[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const member of group) {
|
||||
const key = JSON.stringify([member.pid, member.started])
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
members.push(member)
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
private async stopDescendants(): Promise<ProcessIdentity[]> {
|
||||
const captured = this.descendants()
|
||||
this.signalMembers(captured, 'SIGTERM')
|
||||
const capturedSurvivors = await this.waitForExit(captured)
|
||||
// A TERM-handling descendant may have forked while winding down. Rescan
|
||||
// while the shell can still reap every member, then kill both the fresh
|
||||
// tree and captured survivors that were reparented out of that tree.
|
||||
const members = this.unionMembers(capturedSurvivors, this.descendants())
|
||||
this.signalMembers(members, 'SIGKILL')
|
||||
const survivors = await this.waitForExit(members)
|
||||
return this.survivors(this.unionMembers(survivors, this.descendants()))
|
||||
}
|
||||
|
||||
private async stopShell(): Promise<void> {
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
} catch (_topLevelAlreadyExitedDuringTerm) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
try {
|
||||
this.terminal.kill('SIGKILL')
|
||||
} catch (_topLevelAlreadyExitedDuringKill) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
this.dataDisposable.dispose()
|
||||
// Stop readiness polling but retain the active operation: teardown settles
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
const members = this.inspector.processTree(this.pid)
|
||||
for (const member of members) {
|
||||
try {
|
||||
this.inspector.signalProcess(member, 'SIGTERM')
|
||||
} catch (_alreadyExitedDuringTerm) {
|
||||
// Identity is rechecked by the inspector; a same-tick exit is success.
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
} catch (_topLevelAlreadyExited) {
|
||||
// onExit or identity checks below remain authoritative.
|
||||
}
|
||||
|
||||
const deadline = Date.now() + this.config.disposeGraceMs
|
||||
let survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await delay(Math.min(25, this.config.disposeGraceMs))
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
for (const survivor of survivors) {
|
||||
try {
|
||||
this.inspector.signalProcess(survivor, 'SIGKILL')
|
||||
} catch (_alreadyExitedDuringKill) {
|
||||
// Final identity check below decides success.
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.terminal.kill('SIGKILL')
|
||||
} catch (_topLevelAlreadyKilled) {
|
||||
// The root may already have delivered onExit.
|
||||
}
|
||||
|
||||
const killDeadline = Date.now() + this.config.disposeGraceMs
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
while (survivors.length > 0 && Date.now() < killDeadline) {
|
||||
await delay(Math.min(25, this.config.disposeGraceMs))
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
const exitWaitMs = Math.max(0, killDeadline - Date.now())
|
||||
await Promise.race([this.exitPromise.promise, delay(exitWaitMs)])
|
||||
survivors = members.filter(member => this.inspector.isAlive(member))
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
const survivors = await this.stopDescendants()
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
await this.stopShell()
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ 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 { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentMessageId, 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 SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import PtyService, { PtyBackendCleanupError, 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'
|
||||
@@ -62,6 +62,30 @@ function spec(owner: Agent, signal?: AbortSignal) {
|
||||
}
|
||||
}
|
||||
|
||||
function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolve()): LocalPtySession {
|
||||
return {
|
||||
motd: '',
|
||||
initialize,
|
||||
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(),
|
||||
} as unknown as LocalPtySession
|
||||
}
|
||||
|
||||
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
|
||||
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy'], (providerCtx) => {
|
||||
providerCtx.pty.registerBackend(new LocalPtyBackend(
|
||||
providerCtx,
|
||||
{ ...config(), backendType: 'stub' },
|
||||
inspector,
|
||||
(() => ({})) as never,
|
||||
createSession,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
describe('LocalPtyBackend startup rollback', () => {
|
||||
it('rejects pre-aborted setup and empty sandbox argv', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -69,8 +93,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')
|
||||
})
|
||||
|
||||
@@ -86,12 +111,18 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
|
||||
expect(closed).toHaveBeenCalledWith('PTY startup failed')
|
||||
|
||||
const startupFailure = new Error('startup failed')
|
||||
const cleanupFailure = new Error('cleanup failed')
|
||||
const doublyFailed = {
|
||||
initialize: () => Promise.reject(new Error('startup failed')),
|
||||
close: () => Promise.reject(new Error('cleanup failed')),
|
||||
initialize: () => Promise.reject(startupFailure),
|
||||
close: () => Promise.reject(cleanupFailure),
|
||||
} 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')
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
|
||||
name: 'PtyBackendCleanupError',
|
||||
spawnError: startupFailure,
|
||||
cleanupError: cleanupFailure,
|
||||
} satisfies Partial<PtyBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
@@ -175,6 +206,7 @@ describe('pty-local plugin shape', () => {
|
||||
|
||||
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 +215,90 @@ 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('keeps the owner-lifetime sandbox fence after the local provider unloads', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const session = ctx.sessions.create(SessionId('mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
|
||||
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
await providerFiber.dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
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)
|
||||
|
||||
const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
const second = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
await replacementFiber.dispose()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
|
||||
await ctx.pty.kill(owner, created.sessionId)
|
||||
await ctx.pty.kill(owner, second.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(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
|
||||
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
|
||||
send: () => AgentMessageId('stub'), followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
await registerStubLocalBackend(ctx, () => stubLocalSession(() => gate.promise))
|
||||
const spawning = ctx.pty.spawn(owner, { type: 'stub' })
|
||||
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).toThrow('open or being created')
|
||||
gate.resolve(undefined)
|
||||
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, { AgentMessageId } 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)
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ 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)]
|
||||
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(' ')}`
|
||||
@@ -65,8 +65,10 @@ function fakeInternals() {
|
||||
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'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
|
||||
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'])
|
||||
@@ -90,6 +92,10 @@ describe('Linux process inspector', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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,38 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
|
||||
const initializing = session.initialize(controller.signal)
|
||||
const rejected = expect(initializing).rejects.toBe(reason)
|
||||
controller.abort(reason)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
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 +375,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 +396,81 @@ 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('retains captured survivors that are reparented out of the teardown rescan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const captured = { pid: 124, started: 'captured' }
|
||||
let reads = 0
|
||||
inspector.alive.add(captured.pid)
|
||||
inspector.processTree = () => reads++ === 0 ? [captured] : []
|
||||
inspector.signalProcess = (identity, signal) => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
const closing = session.close('test')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await closing
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, '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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
|
||||
@@ -4,11 +4,16 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
|
||||
|
||||
## Contract
|
||||
|
||||
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
|
||||
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources, and a failed cleanup rejects with `PtyBackendCleanupError` so the registry can retain it across cancellation.
|
||||
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
|
||||
- Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning.
|
||||
- A rollback-close or backend-reported startup cleanup failure rejects the disposing lifecycle instead of claiming quiescence. Caller-triggered cancellation still receives its exact reason; lifecycle-triggered rollback failure also rejects the pending spawn.
|
||||
- A backend cleanup failure that follows caller cancellation remains owner activity until owner or service disposal consumes and reports it, so lifecycle policy cannot mistake failed cleanup for quiescence.
|
||||
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
|
||||
- A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority.
|
||||
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
|
||||
- `PtySendResult.waitReason` and `sessionStatus` are independent. `session_exit` describes the top-level PTY process, not an arbitrary foreground command.
|
||||
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success.
|
||||
- `kill()` and disposal resolve only after the backend's captured process tree is quiescent. A cleanup failure rejects instead of claiming success and clears the matching backend and registry fences so a later close can retry without disturbing a newer attempt.
|
||||
|
||||
The seam contains no `node-pty`, sandbox, tool-schema, prompt, task, or terminal-rendering policy. Implementations own terminal mechanics; consumers own model presentation and optional background-task registration.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { PtyBackendCleanupError } from './types.ts'
|
||||
import type {
|
||||
PtyBackend,
|
||||
PtyBackendSession,
|
||||
@@ -39,6 +40,7 @@ export type {
|
||||
PtySpawnResult,
|
||||
PtyWaitReason,
|
||||
} from './types.ts'
|
||||
export { PtyBackendCleanupError } from './types.ts'
|
||||
|
||||
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
|
||||
export type PtySessionId = PtySessionIdValue
|
||||
@@ -77,10 +79,6 @@ export function PtySessionId(value: string): PtySessionId {
|
||||
return value as PtySessionId
|
||||
}
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
readonly id: PtySessionId
|
||||
readonly owner: Agent
|
||||
@@ -91,11 +89,24 @@ interface SessionRecord {
|
||||
closing: Promise<void> | undefined
|
||||
}
|
||||
|
||||
interface PendingSpawn {
|
||||
readonly owner: Agent
|
||||
readonly controller: AbortController
|
||||
readonly settled: Promise<void>
|
||||
cleanupFailure: { error: unknown } | undefined
|
||||
}
|
||||
|
||||
interface SpawnReservation {
|
||||
readonly signal: AbortSignal
|
||||
release(cleanupFailure: { error: unknown } | undefined): void
|
||||
}
|
||||
|
||||
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
||||
export class PtyService extends Service {
|
||||
private readonly backends = new Map<string, PtyBackend>()
|
||||
private readonly sessions = new Map<PtySessionId, SessionRecord>()
|
||||
private readonly reservedNames = new Map<Agent, Set<string>>()
|
||||
private readonly pendingSpawns = new Map<Agent, Set<PendingSpawn>>()
|
||||
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||
private readonly disposedOwners = new WeakSet<Agent>()
|
||||
private nextId = 0
|
||||
@@ -142,15 +153,19 @@ export class PtyService extends Service {
|
||||
*/
|
||||
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
|
||||
this.assertActive()
|
||||
signal?.throwIfAborted()
|
||||
this.ensureOwnerCleanup(owner)
|
||||
const backend = this.backends.get(request.type)
|
||||
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
|
||||
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
|
||||
if (isAborted(signal)) throw new Error('PTY spawn aborted')
|
||||
|
||||
const releaseName = this.reserveName(owner, request.name)
|
||||
const spawnReservation = this.reserveSpawn(owner)
|
||||
const backendSignal = signal === undefined
|
||||
? spawnReservation.signal
|
||||
: AbortSignal.any([signal, spawnReservation.signal])
|
||||
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
||||
let session: PtyBackendSession | undefined
|
||||
let cleanupFailure: { error: unknown } | undefined
|
||||
try {
|
||||
session = await backend.spawn({
|
||||
sessionId,
|
||||
@@ -158,9 +173,13 @@ export class PtyService extends Service {
|
||||
type: request.type,
|
||||
...request.name !== undefined ? { name: request.name } : {},
|
||||
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
signal: backendSignal,
|
||||
})
|
||||
if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) {
|
||||
signal?.throwIfAborted()
|
||||
if (this.disposing) {
|
||||
throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
|
||||
}
|
||||
if (!this.isLiveOwner(owner)) {
|
||||
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
|
||||
}
|
||||
const record: SessionRecord = {
|
||||
@@ -175,19 +194,45 @@ export class PtyService extends Service {
|
||||
this.sessions.set(sessionId, record)
|
||||
return this.snapshot(record, session.motd)
|
||||
} catch (error) {
|
||||
if (error instanceof PtyBackendCleanupError) {
|
||||
cleanupFailure = { error: error.cleanupError }
|
||||
}
|
||||
let rollbackFailure: { error: unknown } | undefined
|
||||
if (session !== undefined && !this.sessions.has(sessionId)) {
|
||||
try {
|
||||
await session.close('PTY spawn rolled back')
|
||||
} catch (closeError: unknown) {
|
||||
throw new AggregateError([error, closeError], 'PTY spawn and rollback both failed')
|
||||
rollbackFailure = { error: closeError }
|
||||
cleanupFailure = rollbackFailure
|
||||
}
|
||||
}
|
||||
throw error
|
||||
let failure: unknown = error
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
spawnReservation.signal.throwIfAborted()
|
||||
} catch (cancellation: unknown) {
|
||||
failure = cancellation
|
||||
}
|
||||
if (rollbackFailure !== undefined && signal?.aborted !== true) {
|
||||
throw new AggregateError([failure, rollbackFailure.error], 'PTY spawn and rollback both failed')
|
||||
}
|
||||
throw failure
|
||||
} finally {
|
||||
spawnReservation.release(cleanupFailure)
|
||||
releaseName()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether an exact owner has a published session or unpublished spawn.
|
||||
* @param owner - exact live owner to inspect.
|
||||
* @returns true across the entire spawn-to-close interval, with no publication gap.
|
||||
*/
|
||||
hasOwnerActivity(owner: Agent): boolean {
|
||||
return (this.pendingSpawns.get(owner)?.size ?? 0) > 0
|
||||
|| [...this.sessions.values()].some(record => record.owner === owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one exclusive interactive send.
|
||||
* @param owner - exact session owner.
|
||||
@@ -302,6 +347,43 @@ export class PtyService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private reserveSpawn(owner: Agent): SpawnReservation {
|
||||
const controller = new AbortController()
|
||||
const settlement = Promise.withResolvers<void>()
|
||||
const pending: PendingSpawn = { owner, controller, settled: settlement.promise, cleanupFailure: undefined }
|
||||
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
|
||||
owned.add(pending)
|
||||
this.pendingSpawns.set(owner, owned)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
release: (cleanupFailure) => {
|
||||
pending.cleanupFailure = cleanupFailure
|
||||
if (cleanupFailure === undefined) this.removePendingSpawn(pending)
|
||||
settlement.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingSpawn(pending: PendingSpawn): void {
|
||||
const owned = this.pendingSpawns.get(pending.owner)
|
||||
if (owned === undefined) return
|
||||
owned.delete(pending)
|
||||
if (owned.size === 0) this.pendingSpawns.delete(pending.owner)
|
||||
}
|
||||
|
||||
private async abortPendingSpawns(owner: Agent | undefined, reason: PtyError): Promise<void> {
|
||||
const pending = owner === undefined
|
||||
? [...this.pendingSpawns.values()].flatMap(owned => [...owned])
|
||||
: [...(this.pendingSpawns.get(owner) ?? [])]
|
||||
for (const spawn of pending) spawn.controller.abort(reason)
|
||||
await Promise.all(pending.map(spawn => spawn.settled))
|
||||
const failures = pending.flatMap(spawn => spawn.cleanupFailure === undefined ? [] : [spawn.cleanupFailure.error])
|
||||
for (const spawn of pending) this.removePendingSpawn(spawn)
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'failed to roll back unpublished PTY setup')
|
||||
}
|
||||
}
|
||||
|
||||
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
||||
const record = this.sessions.get(id)
|
||||
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
|
||||
@@ -322,23 +404,49 @@ export class PtyService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private async abortAndClose(owner: Agent | undefined, abortReason: PtyError, closeReason: string): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
await this.abortPendingSpawns(owner, abortReason)
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
const records = [...this.sessions.values()].filter(record => owner === undefined || record.owner === owner)
|
||||
try {
|
||||
await this.closeRecords(records, closeReason)
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'failed to clean up PTY lifecycle')
|
||||
}
|
||||
|
||||
private async disposeOwned(owner: Agent): Promise<void> {
|
||||
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
||||
await this.closeRecords(owned, 'PTY owner disposed')
|
||||
this.reservedNames.delete(owner)
|
||||
try {
|
||||
await this.abortAndClose(
|
||||
owner,
|
||||
new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE'),
|
||||
'PTY owner disposed',
|
||||
)
|
||||
} finally {
|
||||
this.reservedNames.delete(owner)
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeAll(): Promise<void> {
|
||||
this.disposing = true
|
||||
const records = [...this.sessions.values()]
|
||||
// Teardown is best-effort: a close failure still clears registries and runs
|
||||
// owner cleanups before the aggregated error propagates, so one stuck
|
||||
// session cannot orphan backends, reservations, or owner detachers.
|
||||
try {
|
||||
await this.closeRecords(records, 'PTY service disposed')
|
||||
await this.abortAndClose(
|
||||
undefined,
|
||||
new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'),
|
||||
'PTY service disposed',
|
||||
)
|
||||
} finally {
|
||||
this.backends.clear()
|
||||
this.reservedNames.clear()
|
||||
this.pendingSpawns.clear()
|
||||
const cleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
@@ -349,8 +457,14 @@ export class PtyService extends Service {
|
||||
const results = await Promise.allSettled(records.map(async (record) => {
|
||||
const closing = record.closing ?? record.session.close(reason)
|
||||
record.closing = closing
|
||||
await closing
|
||||
this.sessions.delete(record.id)
|
||||
try {
|
||||
await closing
|
||||
this.sessions.delete(record.id)
|
||||
} catch (error: unknown) {
|
||||
// A concurrent retry may already own a newer fence; never clear it.
|
||||
if (record.closing === closing) record.closing = undefined
|
||||
throw error
|
||||
}
|
||||
}))
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
|
||||
@@ -10,6 +10,21 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
/** Internal exported basis for the public `PtySessionId` type/value pair. */
|
||||
export type PtySessionIdValue = Branded<'PtySessionId'>
|
||||
|
||||
/**
|
||||
* Backend-reported failure to clean partial resources after unpublished setup failed.
|
||||
* @param spawnError - original setup or cancellation failure.
|
||||
* @param cleanupError - failure that may leave backend-owned resources alive.
|
||||
*/
|
||||
export class PtyBackendCleanupError extends AggregateError {
|
||||
constructor(
|
||||
readonly spawnError: unknown,
|
||||
readonly cleanupError: unknown,
|
||||
) {
|
||||
super([spawnError, cleanupError], 'PTY backend startup and cleanup both failed')
|
||||
this.name = 'PtyBackendCleanupError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Why one interactive send returned control to its caller. */
|
||||
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
|
||||
|
||||
@@ -147,7 +162,7 @@ export interface PtyBackendSession {
|
||||
export interface PtyBackend {
|
||||
/** Stable type selected by {@link PtySpawnRequest.type}. */
|
||||
readonly type: string
|
||||
/** Create an unpublished session or reject after cleaning partial resources. */
|
||||
/** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link PtyBackendCleanupError}. */
|
||||
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import PtyService, { PtyBackendCleanupError, PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import type {
|
||||
PtyBackend,
|
||||
PtyBackendSession,
|
||||
@@ -161,6 +161,7 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' })
|
||||
expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } })
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
|
||||
expect(ctx.pty.list(owner)).toHaveLength(1)
|
||||
expect(ctx.pty.list(foreign)).toEqual([])
|
||||
expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent')
|
||||
@@ -179,8 +180,9 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main' })
|
||||
await expect(ctx.pty.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty')
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toThrow('spawn aborted')
|
||||
const abortReason = new Error('spawn aborted')
|
||||
aborted.abort(abortReason)
|
||||
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toBe(abortReason)
|
||||
await expect(ctx.pty.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
|
||||
|
||||
const operation = ctx.pty.startSend(owner, created.sessionId, { text: 'echo hi', submit: true })
|
||||
@@ -206,12 +208,222 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
ctx.agents.register(owner)
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
|
||||
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
|
||||
await disposeAgentScope(owner)
|
||||
const disposal = disposeAgentScope(owner)
|
||||
gate.resolve(session)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
|
||||
await disposal
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('preserves caller cancellation when a pending backend spawn completes', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||
const session = new StubSession()
|
||||
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by caller')
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow' }, controller.signal)
|
||||
controller.abort(reason)
|
||||
gate.resolve(session)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
expect(ctx.agents.get(owner.id)).toBe(owner)
|
||||
})
|
||||
|
||||
it('preserves caller cancellation when unpublished rollback fails', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||
const session = new StubSession()
|
||||
session.rejectClose = true
|
||||
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by caller')
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow' }, controller.signal)
|
||||
controller.abort(reason)
|
||||
gate.resolve(session)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
|
||||
const internal = ctx.pty as unknown as { disposeAll(): Promise<void> }
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(false)
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('preserves caller cancellation when a backend rejects in response to it', async () => {
|
||||
const ctx = await harness()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const backendFailure = new Error('backend observed cancellation')
|
||||
ctx.pty.registerBackend({
|
||||
type: 'abortable',
|
||||
spawn: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
if (signal === undefined) throw new Error('missing spawn signal')
|
||||
started.resolve(undefined)
|
||||
signal.addEventListener('abort', () => { reject(backendFailure) }, { once: true })
|
||||
}),
|
||||
})
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by caller')
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'abortable' }, controller.signal)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it.each(['owner', 'service'] as const)('retains caller-triggered backend cleanup failure until %s disposal', async (scope) => {
|
||||
const ctx = await harness()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const cleanupFailure = new Error('backend cleanup failed')
|
||||
ctx.pty.registerBackend({
|
||||
type: 'cleanup-failing',
|
||||
spawn: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
if (signal === undefined) throw new Error('missing spawn signal')
|
||||
started.resolve(undefined)
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new PtyBackendCleanupError(signal.reason, cleanupFailure))
|
||||
}, { once: true })
|
||||
}),
|
||||
})
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled by caller')
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'cleanup-failing' }, controller.signal)
|
||||
await started.promise
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(true)
|
||||
const internal = ctx.pty as unknown as {
|
||||
disposeOwned(owner: Agent): Promise<void>
|
||||
disposeAll(): Promise<void>
|
||||
}
|
||||
const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll()
|
||||
await expect(disposal).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
expect(ctx.pty.hasOwnerActivity(owner)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
|
||||
{ scope: 'service', code: 'SERVICE_DISPOSING' },
|
||||
] as const)('$scope disposal aborts and awaits unpublished backend setup', async ({ scope, code }) => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const session = new StubSession()
|
||||
let backendSignal: AbortSignal | undefined
|
||||
ctx.pty.registerBackend({
|
||||
type: 'slow',
|
||||
spawn: (spec) => {
|
||||
backendSignal = spec.signal
|
||||
started.resolve(undefined)
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow' })
|
||||
const pendingFailure = pending.then(
|
||||
() => { throw new Error('pending spawn unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
await started.promise
|
||||
let disposalSettled = false
|
||||
const disposal = (scope === 'owner' ? disposeAgentScope(owner) : disposePtyService(ctx))
|
||||
.then(() => { disposalSettled = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const signalAbortedBeforeRelease = backendSignal?.aborted ?? false
|
||||
const signalReasonBeforeRelease = backendSignal?.reason as unknown
|
||||
const disposalSettledBeforeRelease = disposalSettled
|
||||
gate.resolve(session)
|
||||
|
||||
expect(await pendingFailure).toMatchObject({ code })
|
||||
await disposal
|
||||
expect(signalAbortedBeforeRelease).toBe(true)
|
||||
expect(signalReasonBeforeRelease).toMatchObject({ code })
|
||||
expect(disposalSettledBeforeRelease).toBe(false)
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('reports unpublished rollback failure through service disposal', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||
const session = new StubSession()
|
||||
session.rejectClose = true
|
||||
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow' })
|
||||
const pendingFailure = expect(pending).rejects.toThrow('PTY spawn and rollback both failed')
|
||||
const internal = ctx.pty as unknown as { disposeAll(): Promise<void> }
|
||||
const disposalFailure = expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
gate.resolve(session)
|
||||
|
||||
await pendingFailure
|
||||
await disposalFailure
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ scope: 'owner', code: 'OWNER_NOT_LIVE' },
|
||||
{ scope: 'service', code: 'SERVICE_DISPOSING' },
|
||||
] as const)('$scope disposal retains backend-side startup cleanup failure', async ({ scope, code }) => {
|
||||
const ctx = await harness()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const cleanupFailure = new Error('backend cleanup failed')
|
||||
let backendAbortReason: unknown
|
||||
ctx.pty.registerBackend({
|
||||
type: 'cleanup-failing',
|
||||
spawn: ({ signal }) => new Promise((_resolve, reject) => {
|
||||
if (signal === undefined) throw new Error('missing spawn signal')
|
||||
started.resolve(undefined)
|
||||
signal.addEventListener('abort', () => {
|
||||
backendAbortReason = signal.reason
|
||||
reject(new PtyBackendCleanupError(signal.reason, cleanupFailure))
|
||||
}, { once: true })
|
||||
}),
|
||||
})
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'cleanup-failing' })
|
||||
await started.promise
|
||||
const internal = ctx.pty as unknown as {
|
||||
disposeOwned(owner: Agent): Promise<void>
|
||||
disposeAll(): Promise<void>
|
||||
}
|
||||
const disposal = scope === 'owner' ? internal.disposeOwned(owner) : internal.disposeAll()
|
||||
const pendingError = await pending.then(
|
||||
() => { throw new Error('pending spawn unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
|
||||
expect(pendingError).toBe(backendAbortReason)
|
||||
expect(pendingError).toMatchObject({ code })
|
||||
const disposalError = await disposal.then(
|
||||
() => { throw new Error('disposal unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(disposalError).toMatchObject({ message: 'failed to clean up PTY lifecycle' })
|
||||
const rollbackError = (disposalError as AggregateError).errors[0] as unknown
|
||||
const cleanupErrors = (rollbackError as AggregateError).errors as unknown[]
|
||||
expect(cleanupErrors).toEqual([cleanupFailure])
|
||||
})
|
||||
|
||||
it('keeps independent reservations and handles provider failure before publication', async () => {
|
||||
const ctx = await harness()
|
||||
const firstGate = Promise.withResolvers<PtyBackendSession>()
|
||||
@@ -255,14 +467,27 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
ctx.agents.register(owner)
|
||||
const failedSpawn = new StubSession()
|
||||
failedSpawn.rejectClose = true
|
||||
let ownerDisposal = Promise.resolve()
|
||||
const internal = ctx.pty as unknown as {
|
||||
disposedOwners: WeakSet<Agent>
|
||||
disposeOwned(owner: Agent): Promise<void>
|
||||
}
|
||||
ctx.pty.registerBackend({
|
||||
type: 'bad-spawn',
|
||||
async spawn() {
|
||||
await disposeAgentScope(owner)
|
||||
async spawn({ signal }) {
|
||||
if (signal === undefined) throw new Error('missing spawn signal')
|
||||
internal.disposedOwners.add(owner)
|
||||
ownerDisposal = internal.disposeOwned(owner)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<undefined>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||
})
|
||||
}
|
||||
return failedSpawn
|
||||
},
|
||||
})
|
||||
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
|
||||
await expect(ownerDisposal).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
|
||||
const nextOwner = stubAgent(ctx, 'next')
|
||||
ctx.agents.register(nextOwner)
|
||||
@@ -339,8 +564,15 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
sessions: Map<PtySessionIdType, unknown>
|
||||
closeRecords(records: unknown[], reason: string): Promise<void>
|
||||
}
|
||||
await expect(internal.closeRecords([...internal.sessions.values()], 'test failure')).rejects.toThrow('failed to close 1 PTY session')
|
||||
const records = [...internal.sessions.values()]
|
||||
const firstFailure = expect(internal.closeRecords(records, 'test failure')).rejects.toThrow('failed to close 1 PTY session')
|
||||
const joinedFailure = expect(internal.closeRecords(records, 'joined failure')).rejects.toThrow('failed to close 1 PTY session')
|
||||
await firstFailure
|
||||
await joinedFailure
|
||||
b.sessions[0]!.rejectClose = false
|
||||
await expect(internal.closeRecords([...internal.sessions.values()], 'retry')).resolves.toBeUndefined()
|
||||
expect(b.sessions[0]!.closed).toEqual(['test failure', 'retry'])
|
||||
expect(internal.sessions.size).toBe(0)
|
||||
await disposePtyService(ctx)
|
||||
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
|
||||
})
|
||||
@@ -361,7 +593,7 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
}
|
||||
// Teardown surfaces the close failure, but its finally still clears the
|
||||
// backend and owner-cleanup registries instead of orphaning them.
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to close 1 PTY session')
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
expect(internal.backends.size).toBe(0)
|
||||
expect(internal.ownerCleanups.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
|
||||
|
||||
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards.
|
||||
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal ACP call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
|
||||
|
||||
## Config
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---:|---|
|
||||
| `enableRunInBackground` | `true` | expose and accept `run_in_background`; false omits the schema field and rejects a forced undeclared argument |
|
||||
| `maxResultBytes` | `262144` | UTF-8 cap (minimum `64`) for each complete terminal result or PTY task output after wait, session, pagination, truncation, and task-status metadata |
|
||||
|
||||
Both values are validated at load. The minimum result cap keeps every registry-issued session or task id visible in its creation acknowledgement. When a result exceeds `maxResultBytes`, rendering reserves space for control metadata and a truncation marker when they fit; cuts preserve UTF-8 boundaries. Each terminal definition's final-content callback applies the same cap after normalized pre-, around-, and post-execute policy failures, denials, short-circuits, replacements, or blocks; a structured multi-block policy result retains its shape.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -44,11 +53,11 @@ Prefix-stable while tool visibility and definitions are unchanged.
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded send/read DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering preserves the text above.
|
||||
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Every terminal-owned or policy-produced single-text result is capped by `maxResultBytes` after normalized tool or pipeline errors, denials, short-circuits, replacements, blocks, and generic task status text. Structured multi-block policy results retain their shape. Results remain in session history until compaction; incremental task reads do not repeat consumed output. Programmatic callers receive typed session snapshots, bounded provider read/send DTOs, signal and close outcomes, or `{ kind: "background", taskId }`; Native rendering applies the presentation cap above.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
|
||||
Terminal-owned and policy-produced single-text results are data-dependent and bounded by `maxResultBytes`; a policy that deliberately substitutes structured multi-block content owns that content's bound. Each returned result remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -26,11 +26,15 @@
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pty": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
@@ -44,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
@@ -24,6 +26,25 @@ export const name = 'tool-pty'
|
||||
/** Required capability, registry, and prompt services. */
|
||||
export const inject = ['pty', 'tools', 'systemPrompt']
|
||||
|
||||
/** Default cap for one complete model-facing terminal result. */
|
||||
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
|
||||
/** Smallest cap that preserves every counter-backed PTY and task id in its creation acknowledgement. */
|
||||
export const MIN_MAX_RESULT_BYTES = 64
|
||||
|
||||
/** Model-facing terminal tool configuration. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` and accept background sends (default true). */
|
||||
enableRunInBackground?: boolean
|
||||
/** Maximum UTF-8 bytes in one complete terminal or task-output result. */
|
||||
maxResultBytes?: number
|
||||
}
|
||||
|
||||
/** Schemastery configuration for the terminal tool consumer. */
|
||||
export const Config: z<Config> = z.object({
|
||||
enableRunInBackground: z.boolean().default(true),
|
||||
maxResultBytes: z.number().step(1).min(MIN_MAX_RESULT_BYTES).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RESULT_BYTES),
|
||||
})
|
||||
|
||||
interface SpawnArgs {
|
||||
type: string
|
||||
name?: string
|
||||
@@ -105,9 +126,13 @@ function sessionId(args: SessionArgs): PtySessionIdType {
|
||||
return PtySessionId(args.sessionId)
|
||||
}
|
||||
|
||||
function rawResultText(result: ToolResult): string | undefined {
|
||||
if (result.content.length !== 1) return undefined
|
||||
const block = result.content[0]
|
||||
function textResult(text: string, maxBytes: number): ContentBlock[] {
|
||||
return [{ type: 'text', text: boundTerminalText(text, maxBytes) }]
|
||||
}
|
||||
|
||||
function rawContentText(content: readonly ContentBlock[]): string | undefined {
|
||||
if (content.length !== 1) return undefined
|
||||
const block = content[0]
|
||||
return block?.type === 'text' ? block.text : undefined
|
||||
}
|
||||
|
||||
@@ -118,7 +143,16 @@ function sendDetail(result: PtySendResult): string {
|
||||
}
|
||||
|
||||
/** Register all terminal tools and the minimal usage guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const enableRunInBackground = config.enableRunInBackground ?? true
|
||||
const maxResultBytes = config.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES
|
||||
if (!Number.isSafeInteger(maxResultBytes) || maxResultBytes < MIN_MAX_RESULT_BYTES) {
|
||||
throw new Error(`tool-pty: maxResultBytes must be a safe integer of at least ${MIN_MAX_RESULT_BYTES}`)
|
||||
}
|
||||
const finalizeContent: NonNullable<ToolDefinition['finalizeContent']> = (_exec, result) => {
|
||||
const raw = rawContentText(result.content)
|
||||
return raw === undefined ? undefined : textResult(raw, maxResultBytes)
|
||||
}
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pty',
|
||||
order: 106,
|
||||
@@ -133,6 +167,7 @@ export function apply(ctx: Context): void {
|
||||
name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
|
||||
cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -142,7 +177,7 @@ export function apply(ctx: Context): void {
|
||||
motd: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderSpawn(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderSpawn(value, maxResultBytes) }],
|
||||
},
|
||||
async execute(args: SpawnArgs, exec) {
|
||||
if (args.type.length === 0) throw new Error('type must be a non-empty string')
|
||||
@@ -161,13 +196,17 @@ export function apply(ctx: Context): void {
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_send',
|
||||
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
|
||||
description: 'Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit.'
|
||||
+ (enableRunInBackground ? ' Background mode returns a task id for task_output/task_kill.' : ''),
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_list.' },
|
||||
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
|
||||
submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
|
||||
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
|
||||
...enableRunInBackground
|
||||
? { run_in_background: { type: 'boolean' as const, description: 'Return a task id immediately; collect with task_output or stop with task_kill.' } }
|
||||
: {},
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
oneOf: [
|
||||
@@ -193,7 +232,7 @@ export function apply(ctx: Context): void {
|
||||
type: 'text',
|
||||
text: value.kind === 'background'
|
||||
? `started background task ${value.taskId}`
|
||||
: renderSend(value),
|
||||
: renderSend(value, maxResultBytes),
|
||||
}],
|
||||
presentationMeta: (_args, value) => value.kind === 'foreground'
|
||||
? {
|
||||
@@ -209,6 +248,7 @@ export function apply(ctx: Context): void {
|
||||
const id = sessionId(args)
|
||||
const request = { text: args.text, submit: args.submit ?? true }
|
||||
if (args.run_in_background === true) {
|
||||
if (!enableRunInBackground) throw new Error('background terminal sends are disabled by tool-pty configuration')
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
let cancelRequested = false
|
||||
@@ -216,6 +256,7 @@ export function apply(ctx: Context): void {
|
||||
kind: 'pty-send',
|
||||
label: `${id}: ${args.text || '(input)'}`,
|
||||
owner,
|
||||
outputLimitBytes: maxResultBytes,
|
||||
run: () => {
|
||||
const operation = ctx.pty.startSend(owner, id, request)
|
||||
return {
|
||||
@@ -247,7 +288,7 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
presentResult(args, result) {
|
||||
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
|
||||
const raw = rawResultText(result)
|
||||
const raw = rawContentText(result.content)
|
||||
return raw === undefined ? undefined : { card: 'terminal', output: raw }
|
||||
},
|
||||
}))
|
||||
@@ -260,6 +301,7 @@ export function apply(ctx: Context): void {
|
||||
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
|
||||
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -272,7 +314,7 @@ export function apply(ctx: Context): void {
|
||||
truncated: { type: 'boolean', required: true },
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderRead(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderRead(value, maxResultBytes) }],
|
||||
},
|
||||
execute(args: ReadArgs, exec) {
|
||||
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
|
||||
@@ -291,6 +333,7 @@ export function apply(ctx: Context): void {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -314,6 +357,7 @@ export function apply(ctx: Context): void {
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: {
|
||||
type: 'object',
|
||||
@@ -342,9 +386,10 @@ export function apply(ctx: Context): void {
|
||||
name: 'terminal_list',
|
||||
description: 'List persistent terminal sessions owned by the current agent.',
|
||||
parameters: {},
|
||||
finalizeContent,
|
||||
output: {
|
||||
schema: { type: 'array', items: SESSION_SNAPSHOT_SCHEMA },
|
||||
render: (_args, value) => [{ type: 'text', text: renderList(value) }],
|
||||
render: (_args, value) => [{ type: 'text', text: renderList(value, maxResultBytes) }],
|
||||
},
|
||||
execute(_args: Record<string, never>, exec) {
|
||||
return Promise.resolve(ctx.pty.list(requireAgent(exec.agent)))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Model and ACP rendering for persistent terminal tool results. */
|
||||
|
||||
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
|
||||
interface RenderedSessionStatusRunning {
|
||||
kind: 'running'
|
||||
}
|
||||
@@ -44,56 +46,126 @@ interface RenderedReadResult {
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const TRUNCATED = '\n[output truncated]'
|
||||
|
||||
function byteLength(text: string): number {
|
||||
return encoder.encode(text).byteLength
|
||||
}
|
||||
|
||||
function retain(text: string, maxBytes: number, kind: 'head' | 'tail'): string {
|
||||
const retainer = new TextRetainer({ kind, maxBytes })
|
||||
retainer.push(text)
|
||||
return retainer.finish().text
|
||||
}
|
||||
|
||||
function fitWithSuffix(content: string, suffix: string, maxBytes: number): string {
|
||||
const fixedBytes = byteLength(suffix)
|
||||
if (fixedBytes >= maxBytes) return retain(suffix, maxBytes, 'tail')
|
||||
return `${retain(content, maxBytes - fixedBytes, 'tail')}${suffix}`
|
||||
}
|
||||
|
||||
function fitWithPrefix(prefix: string, content: string, maxBytes: number): string {
|
||||
const fixed = `${prefix}${TRUNCATED}`
|
||||
const fixedBytes = byteLength(fixed)
|
||||
if (fixedBytes >= maxBytes) return retain(fixed, maxBytes, 'head')
|
||||
return `${prefix}${retain(content, maxBytes - fixedBytes, 'tail')}${TRUNCATED}`
|
||||
}
|
||||
|
||||
function boundBodyWithSuffix(
|
||||
content: string,
|
||||
metadata: string,
|
||||
upstreamTruncated: boolean,
|
||||
maxBytes: number,
|
||||
): string {
|
||||
const suffix = `${metadata}${upstreamTruncated ? TRUNCATED : ''}`
|
||||
const complete = `${content}${suffix}`
|
||||
if (byteLength(complete) <= maxBytes) return complete
|
||||
return fitWithSuffix(content, `${metadata}${TRUNCATED}`, maxBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one complete terminal acknowledgement while preserving UTF-8 cuts.
|
||||
* @param text - complete acknowledgement text.
|
||||
* @param maxBytes - positive final result cap.
|
||||
* @returns bounded text with a truncation marker when it fits.
|
||||
*/
|
||||
export function boundTerminalText(text: string, maxBytes: number): string {
|
||||
if (byteLength(text) <= maxBytes) return text
|
||||
const markerBytes = byteLength(TRUNCATED)
|
||||
if (markerBytes >= maxBytes) return retain(TRUNCATED, maxBytes, 'tail')
|
||||
return `${retain(text, maxBytes - markerBytes, 'head')}${TRUNCATED}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one created session and its bounded MOTD.
|
||||
* @param result - published spawn result.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Model-facing session acknowledgement.
|
||||
*/
|
||||
export function renderSpawn(result: RenderedSpawnResult): string {
|
||||
export function renderSpawn(result: RenderedSpawnResult, maxBytes: number): string {
|
||||
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
|
||||
return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
|
||||
const prefix = `started terminal session ${label} [type: ${result.type}]\n`
|
||||
const motd = result.motd || '(no startup output)'
|
||||
const complete = `${prefix}${motd}`
|
||||
return byteLength(complete) <= maxBytes ? complete : fitWithPrefix(prefix, motd, maxBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one settled interactive send.
|
||||
* @param result - settled send outcome.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Terminal output plus wait/session markers.
|
||||
*/
|
||||
export function renderSend(result: RenderedSendResult): string {
|
||||
export function renderSend(result: RenderedSendResult, maxBytes: number): string {
|
||||
const output = result.viewport || '(no new output)'
|
||||
const status = result.sessionStatus.kind === 'running'
|
||||
? 'running'
|
||||
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
|
||||
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
|
||||
return boundBodyWithSuffix(
|
||||
output,
|
||||
`\n[wait: ${result.waitReason}]\n[session: ${status}]`,
|
||||
result.truncated,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one incremental background operation read.
|
||||
* @param read - consuming operation delta.
|
||||
* @returns Delta plus truncation marker when needed.
|
||||
* @returns Delta plus its upstream truncation marker. The generic task control
|
||||
* applies the producer's complete-result cap after adding task status.
|
||||
*/
|
||||
export function renderSendRead(read: RenderedSendRead): string {
|
||||
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
|
||||
const separator = read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'
|
||||
return `${read.delta}${read.truncated ? `${separator}[output truncated]` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one bounded historical page.
|
||||
* @param result - retained scrollback page.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns Page text plus pagination and truncation markers.
|
||||
*/
|
||||
export function renderRead(result: RenderedReadResult): string {
|
||||
export function renderRead(result: RenderedReadResult, maxBytes: number): string {
|
||||
const output = result.text || '(no retained output)'
|
||||
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
|
||||
return boundBodyWithSuffix(
|
||||
output,
|
||||
`\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]`,
|
||||
result.truncated,
|
||||
maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render owner-visible live sessions.
|
||||
* @param sessions - fresh owner-scoped snapshots.
|
||||
* @param maxBytes - complete UTF-8 result cap.
|
||||
* @returns One line per session or the empty marker.
|
||||
*/
|
||||
export function renderList(sessions: readonly RenderedSessionSnapshot[]): string {
|
||||
export function renderList(sessions: readonly RenderedSessionSnapshot[], maxBytes: number): string {
|
||||
if (sessions.length === 0) return '(no terminal sessions)'
|
||||
return sessions.map((session) => {
|
||||
const text = sessions.map((session) => {
|
||||
const name = session.name === undefined ? '' : ` (${session.name})`
|
||||
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
|
||||
const status = session.status.kind === 'running'
|
||||
@@ -101,4 +173,5 @@ export function renderList(sessions: readonly RenderedSessionSnapshot[]): string
|
||||
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
|
||||
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
|
||||
}).join('\n')
|
||||
return boundBodyWithSuffix(text, '', false, maxBytes)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
|
||||
import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
|
||||
|
||||
describe('tool-pty rendering', () => {
|
||||
it('renders spawn with and without names or MOTD', () => {
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }))
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }, 1024))
|
||||
.toBe('started terminal session pty-1 [type: shell]\n(no startup output)')
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }))
|
||||
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }, 1024))
|
||||
.toContain('pty-2 (main)')
|
||||
})
|
||||
|
||||
it('renders running, exited, empty, and truncated sends', () => {
|
||||
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
|
||||
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }, 1024))
|
||||
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }, 1024))
|
||||
.toContain('exited code=null signal=SIGTERM')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }, 1024))
|
||||
.toContain('exited code=2 signal=null')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }, 1024))
|
||||
.toContain('exited code=null signal=null')
|
||||
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
|
||||
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
|
||||
@@ -26,14 +26,48 @@ describe('tool-pty rendering', () => {
|
||||
})
|
||||
|
||||
it('renders history and every list status shape', () => {
|
||||
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
|
||||
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }, 1024))
|
||||
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
|
||||
expect(renderList([])).toBe('(no terminal sessions)')
|
||||
expect(renderList([], 1024)).toBe('(no terminal sessions)')
|
||||
expect(renderList([
|
||||
{ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
|
||||
{ sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
|
||||
{ sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
|
||||
{ sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
|
||||
])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
|
||||
], 1024)).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
|
||||
})
|
||||
|
||||
it('bounds complete UTF-8 results while retaining terminal metadata when it fits', () => {
|
||||
const send = renderSend({
|
||||
viewport: `prefix-${'界'.repeat(40)}`,
|
||||
waitReason: 'stdin_read',
|
||||
sessionStatus: { kind: 'running' },
|
||||
truncated: false,
|
||||
}, 64)
|
||||
expect(Buffer.byteLength(send)).toBeLessThanOrEqual(64)
|
||||
expect(send).toContain('[wait: stdin_read]')
|
||||
expect(send).toContain('[output truncated]')
|
||||
|
||||
const read = renderRead({
|
||||
text: 'x'.repeat(200), totalLines: 20, lineBegin: 0, lineEnd: 10, truncated: false,
|
||||
}, 48)
|
||||
expect(Buffer.byteLength(read)).toBeLessThanOrEqual(48)
|
||||
expect(read).toContain('[lines: 0-10 of 20]')
|
||||
|
||||
expect(Buffer.byteLength(renderSpawn({
|
||||
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
|
||||
}, 32))).toBeLessThanOrEqual(32)
|
||||
|
||||
const boundedSpawn = renderSpawn({
|
||||
sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: 'x'.repeat(200),
|
||||
}, 96)
|
||||
expect(boundedSpawn).toContain('started terminal session pty-1')
|
||||
expect(boundedSpawn).toContain('[output truncated]')
|
||||
|
||||
expect(Buffer.byteLength(renderSend({
|
||||
viewport: 'x'.repeat(200), waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false,
|
||||
}, 8))).toBeLessThanOrEqual(8)
|
||||
expect(boundTerminalText('x'.repeat(200), 8)).toHaveLength(8)
|
||||
expect(boundTerminalText('x'.repeat(200), 32).endsWith('[output truncated]')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,20 +32,23 @@ class StubSession implements PtyBackendSession {
|
||||
autoSettle = true
|
||||
rejectOperation = false
|
||||
closeGate: PromiseWithResolvers<undefined> | undefined
|
||||
viewport = 'command output'
|
||||
delta = 'live output'
|
||||
deltaTruncated = false
|
||||
|
||||
startSend(_request: PtySendRequest): PtySendOperation {
|
||||
let settle!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
let cancelled = false
|
||||
const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
|
||||
viewport: cancelled ? '^C' : 'command output',
|
||||
viewport: cancelled ? '^C' : this.viewport,
|
||||
waitReason: 'stdin_read' as const,
|
||||
sessionStatus: this.statusValue,
|
||||
truncated: false,
|
||||
}))
|
||||
const operation: PtySendOperation = {
|
||||
done,
|
||||
readOutput: () => ({ delta: 'live output', truncated: false }),
|
||||
readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }),
|
||||
cancel: () => {
|
||||
if (cancelled) return false
|
||||
cancelled = true
|
||||
@@ -88,7 +91,13 @@ function stubBackend() {
|
||||
return { backend, sessions }
|
||||
}
|
||||
|
||||
async function setup(tasks: boolean) {
|
||||
async function setup(tasks: boolean, config: ToolPty.Config = {}) {
|
||||
const base = await setupBase(tasks)
|
||||
await base.ctx.plugin(ToolPty, config)
|
||||
return base
|
||||
}
|
||||
|
||||
async function setupBase(tasks: boolean) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -100,7 +109,6 @@ async function setup(tasks: boolean) {
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
}
|
||||
await ctx.plugin(ToolPty)
|
||||
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
|
||||
}
|
||||
|
||||
@@ -291,6 +299,101 @@ describe('tool-pty foreground surface', () => {
|
||||
expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal sessions' })
|
||||
})
|
||||
|
||||
it('configuration-gates background sends and validates the final result bound', async () => {
|
||||
const disabled = await setup(true, { enableRunInBackground: false })
|
||||
const definition = disabled.ctx.tools.get('terminal_send')
|
||||
expect(definition?.parameters).not.toHaveProperty('properties.run_in_background')
|
||||
expect(definition?.description).not.toContain('Background mode')
|
||||
await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent)
|
||||
expect((await call(disabled.ctx, 'terminal_send', {
|
||||
sessionId: 'pty-1', text: 'work', run_in_background: true,
|
||||
}, disabled.agent)).isError).toBe(true)
|
||||
|
||||
const defaults = await setupBase(false)
|
||||
ToolPty.apply(defaults.ctx)
|
||||
expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background')
|
||||
|
||||
const invalid = await setupBase(false)
|
||||
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes')
|
||||
expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64')
|
||||
})
|
||||
|
||||
it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => {
|
||||
const { ctx, agent } = await setup(true, { maxResultBytes: 64 })
|
||||
const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent)
|
||||
expect(failed.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64)
|
||||
expect(text(failed)).toContain('[output truncated]')
|
||||
|
||||
const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent)
|
||||
expect(text(opened)).toContain('pty-1')
|
||||
expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64)
|
||||
const background = await call(ctx, 'terminal_send', {
|
||||
sessionId: 'pty-1', text: 'work', run_in_background: true,
|
||||
}, agent)
|
||||
expect(text(background)).toContain('pty-send-1')
|
||||
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
|
||||
})
|
||||
|
||||
it('bounds terminal results after policy decisions and pipeline failures', async () => {
|
||||
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
|
||||
ctx.on('tools/pre-execute', async (exec, next) => {
|
||||
if (exec.name === 'terminal_list') return { kind: 'deny', reason: 'd'.repeat(1_000) }
|
||||
if (exec.name === 'terminal_signal') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name === 'terminal_close') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
||||
if (exec.name === 'terminal_open') {
|
||||
return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] }
|
||||
}
|
||||
if (exec.name === 'terminal_read') {
|
||||
return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] }
|
||||
}
|
||||
if (exec.name === 'terminal_send') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
|
||||
return next()
|
||||
})
|
||||
|
||||
const denied = await call(ctx, 'terminal_list', {}, agent)
|
||||
expect(denied.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
|
||||
expect(text(denied)).toContain('[output truncated]')
|
||||
|
||||
const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
expect(replaced.isError).toBe(false)
|
||||
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
|
||||
expect(text(replaced)).toContain('[output truncated]')
|
||||
|
||||
const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent)
|
||||
expect(blocked.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
|
||||
expect(text(blocked)).toContain('[output truncated]')
|
||||
|
||||
const failures = [
|
||||
await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent),
|
||||
await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent),
|
||||
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work' }, agent),
|
||||
]
|
||||
for (const failure of failures) {
|
||||
expect(failure.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
|
||||
expect(text(failure)).toContain('[output truncated]')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a structured around-dispatch failure unchanged', async () => {
|
||||
const { ctx, agent } = await setup(false, { maxResultBytes: 64 })
|
||||
ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list'
|
||||
? { content: [], isError: true, error: { message: 'structured failure' } }
|
||||
: next())
|
||||
const result = await call(ctx, 'terminal_list', {}, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pty task integration', () => {
|
||||
@@ -305,6 +408,23 @@ describe('tool-pty task integration', () => {
|
||||
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
|
||||
})
|
||||
|
||||
it('bounds foreground and background results after terminal and task metadata', async () => {
|
||||
const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 })
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
stub.sessions[0]!.viewport = '界'.repeat(100)
|
||||
const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent)
|
||||
expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64)
|
||||
|
||||
stub.sessions[0]!.delta = '界'.repeat(100)
|
||||
stub.sessions[0]!.deltaTruncated = true
|
||||
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
|
||||
const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
|
||||
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
|
||||
expect(text(background)).toContain('[status: completed')
|
||||
expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
|
||||
expect(text(background)).toContain('[output truncated]\n[status: completed')
|
||||
})
|
||||
|
||||
it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
|
||||
const { ctx, agent, stub } = await setup(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/retention"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user