Merge remote-tracking branch 'origin/master' into codex/tool-json-schema-dsl
This commit is contained in:
@@ -12,6 +12,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
|
||||
@@ -386,6 +386,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'pty',
|
||||
summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'registerBackend(backend: PtyBackend): () => void',
|
||||
jsDoc: '/**\n * Register one backend type for this effect scope.\n * @param backend - provider with a non-empty unique type.\n * @returns disposer that removes exactly this contribution.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'listBackends(): string[]',
|
||||
jsDoc: '/**\n * List registered backend types in registration order.\n * @returns fresh backend type names.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult>',
|
||||
jsDoc: '/**\n * Create and publish one owner-scoped session after backend setup succeeds.\n * @param owner - exact registered Agent that owns access and cleanup.\n * @param request - backend type plus optional owner-local name and cwd.\n * @param signal - cancellation of unpublished setup.\n * @returns published identity, metadata, status, and MOTD.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation',
|
||||
jsDoc: '/**\n * Start one exclusive interactive send.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - explicit text, submit behavior, and cancellation.\n * @returns live operation handle for foreground await or task registration.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult',
|
||||
jsDoc: '/**\n * Read one bounded scrollback page from an owned session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param request - optional newest-relative offset and line count.\n * @returns bounded retained text and pagination metadata.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult>',
|
||||
jsDoc: '/**\n * Deliver an allowed signal through an owned backend session.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param signal - allowed POSIX signal name.\n * @returns delivered foreground process-group identity.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async kill(owner: Agent, id: PtySessionId, reason = \'model request\'): Promise<boolean>',
|
||||
jsDoc: '/**\n * Close one owned session and remove it only after quiescent backend cleanup.\n * @param owner - exact session owner.\n * @param id - target PTY identity.\n * @param reason - diagnostic cleanup reason.\n * @returns true for a newly closed session, false when the same close is already in flight.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(owner: Agent): PtySessionSnapshot[]',
|
||||
jsDoc: '/**\n * List fresh snapshots for exactly one owner.\n * @param owner - exact owner whose sessions are visible.\n * @returns owner-visible snapshots in publication order.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
summary: 'Abstract process-sandbox service.',
|
||||
@@ -1495,6 +1533,78 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'PruneResult',
|
||||
declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyBackend',
|
||||
declaration: 'export interface PtyBackend {\n readonly type: string;\n spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyBackendSession',
|
||||
declaration: 'export interface PtyBackendSession {\n readonly motd: string;\n readonly pid?: number;\n startSend(request: PtySendRequest): PtySendOperation;\n read(request: PtyReadRequest): PtyReadResult;\n signal(signal: PtySignal): Promise<PtySignalResult>;\n status(): PtySessionStatus;\n close(reason: string): Promise<void>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyBackendSpawnSpec',
|
||||
declaration: 'export interface PtyBackendSpawnSpec extends PtySpawnRequest {\n sessionId: PtySessionIdValue;\n owner: Agent;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyReadRequest',
|
||||
declaration: 'export interface PtyReadRequest {\n offset?: number;\n count?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyReadResult',
|
||||
declaration: 'export interface PtyReadResult {\n text: string;\n totalLines: number;\n lineBegin: number;\n lineEnd: number;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySendOperation',
|
||||
declaration: 'export interface PtySendOperation {\n done: Promise<PtySendResult>;\n readOutput(): PtySendRead;\n cancel(): boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySendRead',
|
||||
declaration: 'export interface PtySendRead {\n delta: string;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySendRequest',
|
||||
declaration: 'export interface PtySendRequest {\n text: string;\n submit: boolean;\n signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySendResult',
|
||||
declaration: 'export interface PtySendResult {\n viewport: string;\n waitReason: PtyWaitReason;\n sessionStatus: PtySessionStatus;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySessionId',
|
||||
declaration: 'export type PtySessionId = PtySessionIdValue;',
|
||||
},
|
||||
{
|
||||
name: 'PtySessionIdValue',
|
||||
declaration: 'export type PtySessionIdValue = Branded<\'PtySessionId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'PtySessionSnapshot',
|
||||
declaration: 'export interface PtySessionSnapshot {\n sessionId: PtySessionIdValue;\n name?: string;\n type: string;\n pid?: number;\n status: PtySessionStatus;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySessionStatus',
|
||||
declaration: 'export type PtySessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};',
|
||||
},
|
||||
{
|
||||
name: 'PtySignal',
|
||||
declaration: 'export type PtySignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';',
|
||||
},
|
||||
{
|
||||
name: 'PtySignalResult',
|
||||
declaration: 'export interface PtySignalResult {\n delivered: true;\n targetPgid: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySpawnRequest',
|
||||
declaration: 'export interface PtySpawnRequest {\n type: string;\n name?: string;\n cwd?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtySpawnResult',
|
||||
declaration: 'export interface PtySpawnResult extends PtySessionSnapshot {\n motd: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'PtyWaitReason',
|
||||
declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
|
||||
},
|
||||
{
|
||||
name: 'ReasoningBlock',
|
||||
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
11
packages/pty/README.md
Normal file
11
packages/pty/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# pty/ — persistent PTY capability family
|
||||
|
||||
`PTY` stands for **Pseudo-Terminal**(伪终端). This capability provides persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`pty`](pty/README.md) (`@deepseek-ai/dsh-pty`) | Backend registry, branded ids, exact-Agent ownership, session operations, and awaited cleanup | `ctx.pty` |
|
||||
| `pty-local` (`@deepseek-ai/dsh-pty-local`) | Local `node-pty` backend, readiness detection, bounded terminal state, sandboxing, and process-session supervision | registers on `ctx.pty` |
|
||||
| `tool-pty` (`@deepseek-ai/dsh-tool-pty`) | Six model-facing tools and generic task integration for background sends | registers on `ctx.tools` |
|
||||
|
||||
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).
|
||||
32
packages/pty/pty-local/README.md
Normal file
32
packages/pty/pty-local/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# @deepseek-ai/dsh-pty-local
|
||||
|
||||
Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, strips credential-shaped ambient environment variables, retains bounded line-oriented output, detects readiness, and tears down the captured process tree rooted at the `node-pty` child.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Indirect consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the consumer owns prompts, schemas, and appended results.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
|
||||
- Linux exact probes support x64 and arm64 UAPI tables; other architectures use prompt-marker and silence/timeout readiness.
|
||||
- A descendant that daemonizes and reparents before teardown leaves the captured tree; cleanup never broadens to the launcher PID's POSIX session because that can include unrelated processes.
|
||||
- Sessions do not survive harness process exit.
|
||||
52
packages/pty/pty-local/package.json
Normal file
52
packages/pty/pty-local/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pty-local",
|
||||
"description": "Local node-pty backend for persistent DeepSeek Harness PTY sessions",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node src/ensure-spawn-helper.mjs"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@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",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-pty": "^1.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
72
packages/pty/pty-local/src/config.ts
Normal file
72
packages/pty/pty-local/src/config.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Validated configuration for the local PTY backend. */
|
||||
|
||||
import z from 'schemastery'
|
||||
|
||||
/** Public plugin configuration. */
|
||||
export interface Config {
|
||||
/** Backend registry type (default: `shell`). */
|
||||
backendType?: string
|
||||
/** Interactive shell executable (default: `/bin/bash`). */
|
||||
shellPath?: string
|
||||
/** Shell arguments (default: `--noprofile --norc -i`). */
|
||||
shellArgs?: string[]
|
||||
/** Terminal rows. */
|
||||
rows?: number
|
||||
/** Terminal columns. */
|
||||
cols?: number
|
||||
/** Maximum retained logical lines. */
|
||||
scrollbackLines?: number
|
||||
/** Maximum retained UTF-8 bytes. */
|
||||
scrollbackMaxBytes?: number
|
||||
/** Maximum bytes returned by one read or settled viewport. */
|
||||
maxReadBytes?: number
|
||||
/** Readiness polling interval. */
|
||||
pollIntervalMs?: number
|
||||
/** Delay before Linux exact syscall probes. */
|
||||
exactProbeAfterMs?: number
|
||||
/** Silence duration that yields `inferred_idle`. */
|
||||
idleSilenceMs?: number
|
||||
/** Absolute send wait bound. */
|
||||
timeoutMs?: number
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Configuration after Schemastery defaults. */
|
||||
export type ResolvedConfig = Required<Config>
|
||||
|
||||
/** Schemastery config exposed by the plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
backendType: z.string().default('shell'),
|
||||
shellPath: z.string().default('/bin/bash'),
|
||||
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
|
||||
rows: z.number().default(40),
|
||||
cols: z.number().default(160),
|
||||
scrollbackLines: z.number().default(10_000),
|
||||
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
|
||||
maxReadBytes: z.number().default(256 * 1024),
|
||||
pollIntervalMs: z.number().default(50),
|
||||
exactProbeAfterMs: z.number().default(150),
|
||||
idleSilenceMs: z.number().default(3_000),
|
||||
timeoutMs: z.number().default(30_000),
|
||||
disposeGraceMs: z.number().default(3_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* Assert every numeric config field is a positive safe integer and bounds compose.
|
||||
* @param config - Schemastery-resolved plugin configuration.
|
||||
* @returns Narrows the input to the fully resolved configuration.
|
||||
*/
|
||||
export function validateConfig(config: Config): asserts config is ResolvedConfig {
|
||||
const resolved = config as ResolvedConfig
|
||||
if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty')
|
||||
if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty')
|
||||
for (const [name, value] of Object.entries(resolved)) {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
||||
throw new Error(`pty-local: ${name} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
|
||||
throw new Error('pty-local: maxReadBytes must not exceed scrollbackMaxBytes')
|
||||
}
|
||||
}
|
||||
16
packages/pty/pty-local/src/ensure-spawn-helper.mjs
Normal file
16
packages/pty/pty-local/src/ensure-spawn-helper.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Restore the executable bit stripped from node-pty's prebuilt helper. */
|
||||
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const entry = fileURLToPath(import.meta.resolve('node-pty'))
|
||||
const packageRoot = dirname(dirname(entry))
|
||||
const candidates = [
|
||||
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
|
||||
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
||||
]
|
||||
|
||||
for (const helper of candidates) {
|
||||
if (existsSync(helper)) chmodSync(helper, 0o755)
|
||||
}
|
||||
108
packages/pty/pty-local/src/index.ts
Normal file
108
packages/pty/pty-local/src/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Local persistent PTY backend using public `node-pty` APIs, shared sandbox
|
||||
* policy, bounded output, platform readiness probes, and process-session cleanup.
|
||||
* @module @deepseek-ai/dsh-pty-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IPtyForkOptions } from 'node-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'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import { LocalPtySession } from './session.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
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. */
|
||||
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
|
||||
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
PS1: 'dsh> ',
|
||||
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
|
||||
BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: spec.owner.id,
|
||||
DSH_PTY_SESSION_ID: spec.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
|
||||
if (mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, {
|
||||
mode: mode,
|
||||
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
|
||||
}).argv
|
||||
}
|
||||
|
||||
/** Local shell backend registered under the configured type. */
|
||||
export class LocalPtyBackend implements PtyBackend {
|
||||
readonly type: string
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn,
|
||||
private readonly createSession: (
|
||||
terminal: ReturnType<typeof nodePty.spawn>,
|
||||
inspector: ProcessInspector,
|
||||
config: ResolvedConfig,
|
||||
) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config),
|
||||
) {
|
||||
this.type = config.backendType
|
||||
}
|
||||
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
if (spec.signal?.aborted === true) throw new Error('PTY spawn aborted')
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
const file = argv[0]
|
||||
if (file === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
const options: IPtyForkOptions = {
|
||||
name: 'dumb',
|
||||
cols: this.config.cols,
|
||||
rows: this.config.rows,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
}
|
||||
const terminal = this.spawnTerminal(file, argv.slice(1), options)
|
||||
const session = this.createSession(terminal, this.inspector, this.config)
|
||||
try {
|
||||
await session.initialize(spec.signal)
|
||||
return session
|
||||
} catch (error) {
|
||||
try {
|
||||
await session.close('PTY startup failed')
|
||||
} catch (closeError: unknown) {
|
||||
throw new AggregateError([error, closeError], 'PTY startup and cleanup both failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the local PTY backend. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
validateConfig(config)
|
||||
const inspector = createProcessInspector()
|
||||
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config, inspector))
|
||||
}
|
||||
30
packages/pty/pty-local/src/invariant.ts
Normal file
30
packages/pty/pty-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pty-local`.
|
||||
* @module @deepseek-ai/dsh-pty-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pty-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pty-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: readiness, terminal buffers, and process-tree state are private per-session
|
||||
* implementation state, and the backend publishes no independent lifecycle stream or snapshot.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
326
packages/pty/pty-local/src/process-inspector.ts
Normal file
326
packages/pty/pty-local/src/process-inspector.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/** Platform process-table inspection used for readiness, signals, and teardown. */
|
||||
|
||||
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import type { PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
/** PID plus start identity, preventing teardown escalation after PID reuse. */
|
||||
export interface ProcessIdentity {
|
||||
pid: number
|
||||
started: string
|
||||
}
|
||||
|
||||
/** Injectable OS process operations used by one local PTY session. */
|
||||
export interface ProcessInspector {
|
||||
foregroundPgid(shellPid: number): number | undefined
|
||||
isStdinWaiting(pgid: number): boolean
|
||||
/** Return the root and its current transitive descendants, children first. */
|
||||
processTree(rootPid: number): ProcessIdentity[]
|
||||
isAlive(identity: ProcessIdentity): boolean
|
||||
signalGroup(pgid: number, signal: PtySignal): void
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
|
||||
}
|
||||
|
||||
/** Testable boundary around filesystem, process-table, and signal syscalls. */
|
||||
export interface ProcessInspectorInternals {
|
||||
readFile(path: string): string
|
||||
readDir(path: string): string[]
|
||||
open(path: string): number
|
||||
read(fd: number, buffer: Buffer, length: number, position: number): number
|
||||
close(fd: number): void
|
||||
exec(file: string, args: string[]): string
|
||||
kill(pid: number, signal: NodeJS.Signals): void
|
||||
}
|
||||
|
||||
/* v8 ignore start -- thin OS bindings; injected logic is unit-tested and real platform composition exercises them. */
|
||||
const DEFAULT_INTERNALS: ProcessInspectorInternals = {
|
||||
readFile: path => readFileSync(path, 'utf8'),
|
||||
readDir: path => readdirSync(path),
|
||||
open: path => openSync(path, 'r'),
|
||||
read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position),
|
||||
close: closeSync,
|
||||
exec: (file, args) => execFileSync(file, args, { encoding: 'utf8' }),
|
||||
kill: (pid, signal) => process.kill(pid, signal),
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
interface ProcStat {
|
||||
pid: number
|
||||
parentPid: number
|
||||
pgrp: number
|
||||
session: number
|
||||
tpgid: number
|
||||
started: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse fields used from Linux `/proc/<pid>/stat`, including parenthesized comm text.
|
||||
* @param text - complete stat line.
|
||||
* @returns Parsed identity/group fields, or undefined for malformed input.
|
||||
*/
|
||||
export function parseProcStat(text: string): ProcStat | undefined {
|
||||
const open = text.indexOf('(')
|
||||
const close = text.lastIndexOf(')')
|
||||
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 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 }
|
||||
}
|
||||
|
||||
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
|
||||
try {
|
||||
return parseProcStat(internals.readFile(`/proc/${pid}/stat`))
|
||||
} catch (_unreadableProcEntry) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
|
||||
try {
|
||||
return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
|
||||
} catch (_unreadableProcDirectory) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface SyscallInfo {
|
||||
number: number
|
||||
args: number[]
|
||||
}
|
||||
|
||||
function readSyscall(internals: ProcessInspectorInternals, pid: number, tid: number): SyscallInfo | undefined {
|
||||
try {
|
||||
const text = internals.readFile(`/proc/${pid}/task/${tid}/syscall`).trim()
|
||||
if (text === 'running' || text.startsWith('-1 ')) return undefined
|
||||
const fields = text.split(/\s+/)
|
||||
const number = Number(fields[0])
|
||||
const args = fields.slice(1, 7).map(field => Number.parseInt(field, 16))
|
||||
if (!Number.isSafeInteger(number) || args.some(value => !Number.isSafeInteger(value))) return undefined
|
||||
return { number, args }
|
||||
} catch (_unreadableSyscall) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function readMemory(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
address: number,
|
||||
length: number,
|
||||
): Buffer | undefined {
|
||||
let fd: number | undefined
|
||||
try {
|
||||
fd = internals.open(`/proc/${pid}/mem`)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const count = internals.read(fd, buffer, length, address)
|
||||
return buffer.subarray(0, count)
|
||||
} catch (_unreadableProcessMemory) {
|
||||
return undefined
|
||||
} finally {
|
||||
if (fd !== undefined) internals.close(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function fdSetHasStdin(internals: ProcessInspectorInternals, pid: number, address: number): boolean {
|
||||
return address !== 0 && (readMemory(internals, pid, address, 8)?.[0] ?? 0) % 2 === 1
|
||||
}
|
||||
|
||||
function pollHasStdin(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
address: number,
|
||||
count: number,
|
||||
): boolean {
|
||||
if (address === 0 || count <= 0) return false
|
||||
const memory = readMemory(internals, pid, address, Math.min(count, 1024) * 8)
|
||||
if (memory === undefined) return false
|
||||
for (let offset = 0; offset + 8 <= memory.length; offset += 8) {
|
||||
if (memory.readInt32LE(offset) === 0 && (memory.readInt16LE(offset + 4) & 0x001) !== 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function epollHasStdin(internals: ProcessInspectorInternals, pid: number, epfd: number): boolean {
|
||||
try {
|
||||
return internals.readFile(`/proc/${pid}/fdinfo/${epfd}`)
|
||||
.split('\n')
|
||||
.some(line => /^tfd:\s+0\b/.test(line.trim()))
|
||||
} catch (_unreadableFdInfo) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface SyscallTable {
|
||||
read: number
|
||||
select?: number
|
||||
pselect: number
|
||||
poll?: number
|
||||
ppoll: number
|
||||
epollWait?: number
|
||||
epollPwait: number
|
||||
}
|
||||
|
||||
const SYSCALLS: Partial<Record<NodeJS.Architecture, SyscallTable>> = {
|
||||
x64: { read: 0, select: 23, pselect: 270, poll: 7, ppoll: 271, epollWait: 232, epollPwait: 281 },
|
||||
arm64: { read: 63, pselect: 72, ppoll: 73, epollPwait: 22 },
|
||||
}
|
||||
|
||||
function syscallWaitsOnStdin(
|
||||
internals: ProcessInspectorInternals,
|
||||
pid: number,
|
||||
syscall: SyscallInfo,
|
||||
table: SyscallTable,
|
||||
): boolean {
|
||||
const [a0 = 0, a1 = 0, a2 = 0] = syscall.args
|
||||
if (syscall.number === table.read) return a0 === 0
|
||||
if (syscall.number === table.select || syscall.number === table.pselect) {
|
||||
return a0 >= 1 && fdSetHasStdin(internals, pid, a1)
|
||||
}
|
||||
if (syscall.number === table.poll || syscall.number === table.ppoll) {
|
||||
return a1 >= 1 && pollHasStdin(internals, pid, a0, a1)
|
||||
}
|
||||
if (syscall.number === table.epollWait || syscall.number === table.epollPwait) {
|
||||
return a2 >= 1 && epollHasStdin(internals, pid, a0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
abstract class PosixProcessInspector implements ProcessInspector {
|
||||
constructor(protected readonly internals: ProcessInspectorInternals) {}
|
||||
|
||||
abstract foregroundPgid(shellPid: number): number | undefined
|
||||
abstract isStdinWaiting(pgid: number): boolean
|
||||
abstract processTree(rootPid: number): ProcessIdentity[]
|
||||
abstract isAlive(identity: ProcessIdentity): boolean
|
||||
|
||||
signalGroup(pgid: number, signal: PtySignal): void {
|
||||
this.internals.kill(-pgid, signal)
|
||||
}
|
||||
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
if (this.isAlive(identity)) this.internals.kill(identity.pid, signal)
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessTreeEntry extends ProcessIdentity {
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] {
|
||||
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
|
||||
const root = byPid.get(rootPid)
|
||||
if (root === undefined) return []
|
||||
const byParent = new Map<number, ProcessTreeEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const children = byParent.get(entry.parentPid) ?? []
|
||||
children.push(entry)
|
||||
byParent.set(entry.parentPid, children)
|
||||
}
|
||||
const visited = new Set<number>()
|
||||
const result: ProcessIdentity[] = []
|
||||
const visit = (entry: ProcessTreeEntry): void => {
|
||||
if (visited.has(entry.pid)) return
|
||||
visited.add(entry.pid)
|
||||
for (const child of byParent.get(entry.pid) ?? []) visit(child)
|
||||
result.push({ pid: entry.pid, started: entry.started })
|
||||
}
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
class LinuxProcessInspector extends PosixProcessInspector {
|
||||
constructor(
|
||||
private readonly arch: NodeJS.Architecture,
|
||||
internals: ProcessInspectorInternals,
|
||||
) {
|
||||
super(internals)
|
||||
}
|
||||
|
||||
foregroundPgid(shellPid: number): number | undefined {
|
||||
const tpgid = readLinuxStat(this.internals, shellPid)?.tpgid
|
||||
return tpgid !== undefined && tpgid > 0 ? tpgid : undefined
|
||||
}
|
||||
|
||||
isStdinWaiting(pgid: number): boolean {
|
||||
const table = SYSCALLS[this.arch]
|
||||
if (table === undefined) return false
|
||||
for (const pid of numericEntries(this.internals, '/proc')) {
|
||||
if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue
|
||||
for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
|
||||
const syscall = readSyscall(this.internals, pid, tid)
|
||||
if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
const entries = numericEntries(this.internals, '/proc').flatMap((pid) => {
|
||||
const stat = readLinuxStat(this.internals, pid)
|
||||
return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }]
|
||||
})
|
||||
return processTree(entries, rootPid)
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface PsEntry extends ProcessTreeEntry {}
|
||||
|
||||
function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] {
|
||||
return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
|
||||
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
|
||||
return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }]
|
||||
})
|
||||
}
|
||||
|
||||
class MacProcessInspector extends PosixProcessInspector {
|
||||
foregroundPgid(shellPid: number): number | undefined {
|
||||
try {
|
||||
const value = Number(this.internals.exec('/bin/ps', ['-o', 'tpgid=', '-p', String(shellPid)]).trim())
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : undefined
|
||||
} catch (_missingProcess) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
isStdinWaiting(_pgid: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
return processTree(macProcessTable(this.internals), rootPid)
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the supported platform inspector or fail at plugin load.
|
||||
* @param platform - target Node platform.
|
||||
* @param arch - target CPU architecture for Linux syscall numbers.
|
||||
* @param internals - filesystem/process boundary, injectable for deterministic tests.
|
||||
* @returns Platform process inspector.
|
||||
*/
|
||||
export function createProcessInspector(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: NodeJS.Architecture = process.arch,
|
||||
internals: ProcessInspectorInternals = DEFAULT_INTERNALS,
|
||||
): ProcessInspector {
|
||||
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
|
||||
if (platform === 'darwin') return new MacProcessInspector(internals)
|
||||
throw new Error(`pty-local: unsupported platform ${platform}`)
|
||||
}
|
||||
152
packages/pty/pty-local/src/sanitize.ts
Normal file
152
packages/pty/pty-local/src/sanitize.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
/** OSC marker emitted by the controlled bash before each prompt. */
|
||||
export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
|
||||
/** One sanitized chunk plus whether it contained the owned prompt marker. */
|
||||
export interface SanitizedChunk {
|
||||
text: string
|
||||
prompt: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
||||
* Full terminal emulation is deliberately deferred; ordinary line output and
|
||||
* the private prompt marker are the supported contract.
|
||||
*/
|
||||
export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
/**
|
||||
* Consume one decoded `node-pty` data chunk.
|
||||
* @param chunk - decoded terminal data.
|
||||
* @returns Printable text and whether the private prompt marker completed.
|
||||
*/
|
||||
push(chunk: string): SanitizedChunk {
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let index = 0
|
||||
while (index < this.pending.length) {
|
||||
const escape = this.pending.indexOf('\x1b', index)
|
||||
if (escape < 0) {
|
||||
text += this.pending.slice(index)
|
||||
index = this.pending.length
|
||||
break
|
||||
}
|
||||
text += this.pending.slice(index, escape)
|
||||
if (escape + 1 >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
const kind = this.pending[escape + 1]
|
||||
if (kind === ']') {
|
||||
const bel = this.pending.indexOf('\x07', escape + 2)
|
||||
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
|
||||
let end = -1
|
||||
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
|
||||
else if (bel >= 0) end = bel + 1
|
||||
else if (stringTerminator >= 0) end = stringTerminator + 2
|
||||
if (end < 0) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
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
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (kind === '[') {
|
||||
let end = escape + 2
|
||||
while (end < this.pending.length) {
|
||||
const code = this.pending.charCodeAt(end)
|
||||
if (code >= 0x40 && code <= 0x7e) break
|
||||
end += 1
|
||||
}
|
||||
if (end >= this.pending.length) {
|
||||
index = escape
|
||||
break
|
||||
}
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
// Two-byte escape family (save/restore cursor and similar).
|
||||
index = escape + 2
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: normalizeTerminalText(text), prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a trailing printable fragment when the PTY exits.
|
||||
* @returns Remaining printable text; incomplete escapes are discarded.
|
||||
*/
|
||||
flush(): string {
|
||||
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
return normalizeTerminalText(text)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
||||
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
||||
this.pending = ''
|
||||
}
|
||||
|
||||
private discardPrefix(chunk: string): string {
|
||||
if (this.discardMode === undefined) return chunk
|
||||
if (this.discardMode === 'csi') {
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let index = 0
|
||||
if (this.discardOscEscape) {
|
||||
this.discardOscEscape = false
|
||||
if (chunk.startsWith('\\')) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(1)
|
||||
}
|
||||
}
|
||||
while (index < chunk.length) {
|
||||
if (chunk[index] === '\x07') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
if (chunk[index] === '\x1b') {
|
||||
if (chunk[index + 1] === '\\') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 2)
|
||||
}
|
||||
if (index + 1 === chunk.length) this.discardOscEscape = true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
||||
* @param text - sanitized terminal text.
|
||||
* @returns Line-normalized text with BEL removed.
|
||||
*/
|
||||
export function normalizeTerminalText(text: string): string {
|
||||
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
|
||||
}
|
||||
394
packages/pty/pty-local/src/session.ts
Normal file
394
packages/pty/pty-local/src/session.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
|
||||
|
||||
import { constants } from 'node:os'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import type {
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
PtyReadResult,
|
||||
PtySendOperation,
|
||||
PtySendRead,
|
||||
PtySendRequest,
|
||||
PtySendResult,
|
||||
PtySessionStatus,
|
||||
PtySignal,
|
||||
PtySignalResult,
|
||||
PtyWaitReason,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import { TerminalSanitizer } from './sanitize.ts'
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
|
||||
const chars = Array.from(text)
|
||||
let bytes = 0
|
||||
let start = chars.length
|
||||
while (start > 0) {
|
||||
const next = Buffer.byteLength(chars[start - 1] as string)
|
||||
if (bytes + next > maxBytes) break
|
||||
bytes += next
|
||||
start -= 1
|
||||
}
|
||||
return { text: chars.slice(start).join(''), truncated: true }
|
||||
}
|
||||
|
||||
class BoundedTextBuffer {
|
||||
private value = ''
|
||||
private dropped = false
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly maxLines?: number,
|
||||
) {}
|
||||
|
||||
append(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.value += text
|
||||
if (this.maxLines !== undefined) {
|
||||
const lines = this.value.split('\n')
|
||||
if (lines.length > this.maxLines) {
|
||||
this.value = lines.slice(lines.length - this.maxLines).join('\n')
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
const tail = utf8Tail(this.value, this.maxBytes)
|
||||
this.value = tail.text
|
||||
this.dropped ||= tail.truncated
|
||||
}
|
||||
|
||||
consume(): PtySendRead {
|
||||
const delta = this.value
|
||||
const truncated = this.dropped
|
||||
this.value = ''
|
||||
this.dropped = false
|
||||
return { delta, truncated }
|
||||
}
|
||||
|
||||
snapshot(): { text: string; truncated: boolean } {
|
||||
return { text: this.value, truncated: this.dropped }
|
||||
}
|
||||
}
|
||||
|
||||
class LocalSendOperation implements PtySendOperation {
|
||||
private readonly output: BoundedTextBuffer
|
||||
private readonly promise: PromiseWithResolvers<PtySendResult>
|
||||
private finished = false
|
||||
|
||||
constructor(
|
||||
maxBytes: number,
|
||||
readonly startedAt: number,
|
||||
private readonly onCancel: () => void,
|
||||
) {
|
||||
this.output = new BoundedTextBuffer(maxBytes)
|
||||
this.promise = Promise.withResolvers<PtySendResult>()
|
||||
}
|
||||
|
||||
get done(): Promise<PtySendResult> {
|
||||
return this.promise.promise
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (!this.finished) this.output.append(text)
|
||||
}
|
||||
|
||||
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
const read = this.output.snapshot()
|
||||
this.promise.resolve({
|
||||
viewport: read.text,
|
||||
waitReason,
|
||||
sessionStatus,
|
||||
truncated: read.truncated || inheritedTruncation,
|
||||
})
|
||||
}
|
||||
|
||||
fail(error: unknown): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
this.promise.reject(error)
|
||||
}
|
||||
|
||||
readOutput(): PtySendRead {
|
||||
return this.output.consume()
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
if (this.finished) return false
|
||||
this.onCancel()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function signalName(number: number | undefined): NodeJS.Signals | null {
|
||||
if (number === undefined || number === 0) return null
|
||||
for (const [name, value] of Object.entries(constants.signals)) {
|
||||
if (value === number) return name as NodeJS.Signals
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Backend session wrapping one `node-pty` process and its captured process tree. */
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
private readonly exitDisposable: IDisposable
|
||||
private statusValue: PtySessionStatus = { kind: 'running' }
|
||||
private active: LocalSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closePromise: Promise<void> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly terminal: IPty,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
|
||||
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
|
||||
const tail = this.sanitizer.flush()
|
||||
this.appendOutput(tail)
|
||||
this.statusValue = { kind: 'exited', exitCode, signal: signalName(signal) }
|
||||
this.settleActive('session_exit')
|
||||
this.exitPromise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture startup output through the same readiness contract as later sends.
|
||||
* @param signal - optional cancellation while the shell reaches its first prompt.
|
||||
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
|
||||
*/
|
||||
async initialize(signal?: AbortSignal): Promise<void> {
|
||||
this.initializing = true
|
||||
try {
|
||||
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
|
||||
const result = await operation.done
|
||||
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
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
}
|
||||
|
||||
startSend(request: PtySendRequest): PtySendOperation {
|
||||
if (this.closePromise !== undefined) 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)
|
||||
}
|
||||
})
|
||||
this.active = operation
|
||||
this.lastOutputAt = Date.now()
|
||||
this.promptSeen = false
|
||||
|
||||
if (request.signal !== undefined) {
|
||||
const onAbort = (): void => { operation.cancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.text.length > 0) this.terminal.write(request.text)
|
||||
if (request.submit) this.terminal.write('\r')
|
||||
} catch (error: unknown) {
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
return operation
|
||||
}
|
||||
|
||||
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
|
||||
return operation
|
||||
}
|
||||
|
||||
read(request: PtyReadRequest): PtyReadResult {
|
||||
const snapshot = this.scrollback.snapshot()
|
||||
const lines = snapshot.text.split('\n')
|
||||
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
|
||||
const offset = request.offset ?? 0
|
||||
const count = request.count ?? 500
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
|
||||
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
|
||||
if (offset >= totalLines) {
|
||||
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
|
||||
}
|
||||
const end = totalLines - offset
|
||||
const start = Math.max(0, end - count)
|
||||
const requested = lines.slice(start, end).join('\n')
|
||||
const bounded = utf8Tail(requested, this.config.maxReadBytes)
|
||||
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
|
||||
return {
|
||||
text: bounded.text,
|
||||
totalLines,
|
||||
lineBegin: offset,
|
||||
lineEnd: offset + returnedLines,
|
||||
truncated: snapshot.truncated || bounded.truncated,
|
||||
}
|
||||
}
|
||||
|
||||
signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
return Promise.resolve().then(() => {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
if (signal === 'SIGKILL' && pgid === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
|
||||
}
|
||||
this.inspector.signalGroup(pgid, signal)
|
||||
return { delivered: true, targetPgid: pgid }
|
||||
})
|
||||
}
|
||||
|
||||
status(): PtySessionStatus {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
close(reason: string): Promise<void> {
|
||||
this.closePromise ??= this.closeOnce(reason)
|
||||
return this.closePromise
|
||||
}
|
||||
|
||||
private onData(data: string): void {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
|
||||
this.promptSeen = true
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.lastOutputAt = Date.now()
|
||||
this.scrollback.append(text)
|
||||
this.active?.append(text)
|
||||
}
|
||||
|
||||
private pollReadiness(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
if (this.statusValue.kind === 'exited') {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
if (this.promptSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - operation.startedAt
|
||||
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
|
||||
if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
|
||||
this.settleActive('inferred_idle')
|
||||
return
|
||||
}
|
||||
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
|
||||
}
|
||||
|
||||
private settleActive(waitReason: PtyWaitReason): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
const scrollbackTruncated = this.scrollback.snapshot().truncated
|
||||
this.clearActive()
|
||||
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
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()
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/pty/pty-local/tests/config.spec.ts
Normal file
27
packages/pty/pty-local/tests/config.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
|
||||
function config(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, timeoutMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('pty-local config', () => {
|
||||
it('accepts resolved positive bounds', () => {
|
||||
expect(() => { validateConfig(config()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty names, invalid numbers, and a read cap above retention', () => {
|
||||
expect(() => { validateConfig(config({ backendType: '' })) }).toThrow('backendType')
|
||||
expect(() => { validateConfig(config({ shellPath: '' })) }).toThrow('shellPath')
|
||||
expect(() => { validateConfig(config({ rows: 0 })) }).toThrow('rows')
|
||||
expect(() => { validateConfig(config({ rows: 1.5 })) }).toThrow('rows')
|
||||
expect(() => { validateConfig(config({ maxReadBytes: 2048 })) }).toThrow('must not exceed')
|
||||
})
|
||||
})
|
||||
186
packages/pty/pty-local/tests/index.spec.ts
Normal file
186
packages/pty/pty-local/tests/index.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPty, IPtyForkOptions } from 'node-pty'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import type { ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
|
||||
class EmptySandbox extends SandboxProvider {
|
||||
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingSandbox extends SandboxProvider {
|
||||
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function config(): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
|
||||
disposeGraceMs: 10,
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context): Agent {
|
||||
const id = SessionId('agent')
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
const inspector = {
|
||||
foregroundPgid: () => undefined,
|
||||
isStdinWaiting: () => false,
|
||||
processTree: () => [],
|
||||
isAlive: () => false,
|
||||
signalGroup() {},
|
||||
signalProcess() {},
|
||||
} satisfies ProcessInspector
|
||||
|
||||
function spec(owner: Agent, signal?: AbortSignal) {
|
||||
return {
|
||||
sessionId: PtySessionId('pty-1'), owner, type: 'shell',
|
||||
...signal !== undefined ? { signal } : {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('LocalPtyBackend startup rollback', () => {
|
||||
it('rejects pre-aborted setup and empty sandbox argv', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(backend.spawn(spec(agent(ctx), controller.signal))).rejects.toThrow('spawn aborted')
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('empty argv')
|
||||
})
|
||||
|
||||
it('closes failed startup and aggregates cleanup failure', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
const spawnTerminal = (() => ({} as IPty)) as never
|
||||
|
||||
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => failed)
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
|
||||
expect(closed).toHaveBeenCalledWith('PTY startup failed')
|
||||
|
||||
const doublyFailed = {
|
||||
initialize: () => Promise.reject(new Error('startup failed')),
|
||||
close: () => Promise.reject(new Error('cleanup failed')),
|
||||
} as unknown as LocalPtySession
|
||||
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toThrow('startup and cleanup both failed')
|
||||
})
|
||||
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
|
||||
const terminal = {} as IPty
|
||||
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
|
||||
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
|
||||
spawned = { file, args, options }
|
||||
return terminal
|
||||
}) as never
|
||||
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const session = { initialize: initialized } as unknown as LocalPtySession
|
||||
const backend = new LocalPtyBackend(
|
||||
ctx,
|
||||
{ ...config(), shellArgs: ['-i'] },
|
||||
inspector,
|
||||
spawnTerminal,
|
||||
() => session,
|
||||
)
|
||||
const previous = process.env.PTY_TEST_SECRET
|
||||
process.env.PTY_TEST_SECRET = 'must-not-leak'
|
||||
try {
|
||||
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PTY_TEST_SECRET
|
||||
else process.env.PTY_TEST_SECRET = previous
|
||||
}
|
||||
|
||||
expect(spawned).toMatchObject({
|
||||
file: '/sandbox',
|
||||
args: ['--', '/bin/bash', '-i'],
|
||||
options: {
|
||||
name: 'dumb', cols: 80, rows: 24, cwd: '/work',
|
||||
env: {
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('composes the default local session around a spawned terminal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
const terminal = {
|
||||
pid: 123, cols: 80, rows: 24, process: 'bash', handleFlowControl: false,
|
||||
onData(listener: (data: string) => void) {
|
||||
queueMicrotask(() => { listener('\x1b]133;D;0\x07dsh> ') })
|
||||
return { dispose() {} }
|
||||
},
|
||||
onExit(listener: (event: { exitCode: number; signal?: number }) => void) {
|
||||
exitListener = listener
|
||||
return { dispose() {} }
|
||||
},
|
||||
write() {},
|
||||
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
|
||||
resize() {}, clear() {}, pause() {}, resume() {},
|
||||
} as IPty
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector, () => terminal)
|
||||
const session = await backend.spawn(spec(agent(ctx)))
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
await session.close('test complete')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pty-local plugin shape', () => {
|
||||
it('keeps name, inject, and Config through Loader unwrapExports', () => {
|
||||
expect('default' in ptyLocal).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
|
||||
expect(unwrapped.name).toBe('pty-local')
|
||||
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
it('validates config and registers the configured backend', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
const fiber = await ctx.plugin(ptyLocal, config())
|
||||
expect(ctx.pty.listBackends()).toEqual(['shell'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
})
|
||||
})
|
||||
122
packages/pty/pty-local/tests/local.spec.ts
Normal file
122
packages/pty/pty-local/tests/local.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import PtyService from '@deepseek-ai/dsh-pty'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
calls: { argv: readonly string[]; policy: SandboxPolicy }[] = []
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scope = ctx.plugin(() => {})
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(mode: 'danger-full-access' | 'workspace-write') {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||
roots.push(root)
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(PassthroughSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
idleSilenceMs: 250,
|
||||
timeoutMs: 2000,
|
||||
disposeGraceMs: 500,
|
||||
scrollbackLines: 100,
|
||||
scrollbackMaxBytes: 32_768,
|
||||
maxReadBytes: 16_384,
|
||||
})
|
||||
const agent = stubAgent(ctx, `agent-${mode}`)
|
||||
ctx.agents.register(agent)
|
||||
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
|
||||
}
|
||||
|
||||
describe('pty-local real shell', () => {
|
||||
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
|
||||
const previous = process.env.DSH_TEST_SECRET
|
||||
process.env.DSH_TEST_SECRET = 'must-not-leak'
|
||||
try {
|
||||
const { ctx, root, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root })
|
||||
expect(created.motd).toContain('dsh> ')
|
||||
|
||||
const first = ctx.pty.startSend(agent, created.sessionId, { text: 'export KEEP=ok; cd /', submit: true })
|
||||
expect((await first.done).waitReason).toBe('stdin_read')
|
||||
const second = ctx.pty.startSend(agent, created.sessionId, { text: 'printf "cwd=%s keep=%s secret=%s\\n" "$PWD" "$KEEP" "${DSH_TEST_SECRET-unset}"', submit: true })
|
||||
expect((await second.done).viewport).toContain('cwd=/ keep=ok secret=unset')
|
||||
|
||||
expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 20 }).text).toContain('cwd=/ keep=ok secret=unset')
|
||||
expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true)
|
||||
expect(ctx.pty.list(agent)).toEqual([])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DSH_TEST_SECRET
|
||||
else process.env.DSH_TEST_SECRET = previous
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
it('wraps the exact shell argv under confined policy and unregisters on reload', async () => {
|
||||
const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write')
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||
expect(sandbox.calls).toEqual([{
|
||||
argv: ['/bin/bash', '--noprofile', '--norc', '-i'],
|
||||
policy: { mode: 'workspace-write', workspaceRoot: realpathSync.native(root) },
|
||||
}])
|
||||
await fiber.dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
expect(ctx.pty.list(agent)).toHaveLength(1)
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
}, 10_000)
|
||||
|
||||
it('signals a foreground command and kills a TERM-ignoring background descendant', async () => {
|
||||
const { ctx, agent } = await harness('danger-full-access')
|
||||
const created = await ctx.pty.spawn(agent, { type: 'shell' })
|
||||
|
||||
const foreground = ctx.pty.startSend(agent, created.sessionId, { text: 'sleep 60', submit: true })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect((await ctx.pty.signal(agent, created.sessionId, 'SIGINT')).delivered).toBe(true)
|
||||
expect((await foreground.done).waitReason).toBe('stdin_read')
|
||||
|
||||
const background = ctx.pty.startSend(agent, created.sessionId, {
|
||||
text: 'sh -c \'trap "" TERM; sleep 60\' & echo CHILD=$!',
|
||||
submit: true,
|
||||
})
|
||||
const output = (await background.done).viewport
|
||||
const child = /CHILD=(\d+)/.exec(output)?.[1]
|
||||
expect(child).toBeDefined()
|
||||
const pid = Number(child)
|
||||
expect(() => process.kill(pid, 0)).not.toThrow()
|
||||
await ctx.pty.kill(agent, created.sessionId)
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
}, 10_000)
|
||||
})
|
||||
215
packages/pty/pty-local/tests/process-inspector.spec.ts
Normal file
215
packages/pty/pty-local/tests/process-inspector.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
|
||||
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
while (rest.length < 19) rest.push('0')
|
||||
rest.push(started)
|
||||
return `${pid} (command with space) ${rest.join(' ')}`
|
||||
}
|
||||
|
||||
function syscall(number: number, ...args: number[]): string {
|
||||
const six = [...args]
|
||||
while (six.length < 6) six.push(0)
|
||||
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
|
||||
}
|
||||
|
||||
function fakeInternals() {
|
||||
const files = new Map<string, string>()
|
||||
const dirs = new Map<string, string[]>()
|
||||
const memories = new Map<string, Buffer>()
|
||||
const fds = new Map<number, string>()
|
||||
const kills: Array<[number, NodeJS.Signals]> = []
|
||||
let nextFd = 10
|
||||
let ps = ''
|
||||
let tpgid = '0'
|
||||
const internals: ProcessInspectorInternals = {
|
||||
readFile(path) {
|
||||
const value = files.get(path)
|
||||
if (value === undefined) throw new Error(`missing ${path}`)
|
||||
return value
|
||||
},
|
||||
readDir(path) {
|
||||
const value = dirs.get(path)
|
||||
if (value === undefined) throw new Error(`missing ${path}`)
|
||||
return value
|
||||
},
|
||||
open(path) {
|
||||
if (!memories.has(path)) throw new Error(`missing ${path}`)
|
||||
const fd = nextFd++
|
||||
fds.set(fd, path)
|
||||
return fd
|
||||
},
|
||||
read(fd, buffer, length, position) {
|
||||
const path = fds.get(fd)
|
||||
if (path === undefined) throw new Error('bad fd')
|
||||
const source = memories.get(path)
|
||||
if (source === undefined) throw new Error('missing memory')
|
||||
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
|
||||
},
|
||||
close(fd) { fds.delete(fd) },
|
||||
exec(_file, args) {
|
||||
if (args.includes('tpgid=')) return tpgid
|
||||
return ps
|
||||
},
|
||||
kill(pid, signal) { kills.push([pid, signal]) },
|
||||
}
|
||||
return {
|
||||
internals, files, dirs, memories, kills,
|
||||
setPs(value: string) { ps = value },
|
||||
setTpgid(value: string) { tpgid = value },
|
||||
}
|
||||
}
|
||||
|
||||
describe('Linux process inspector', () => {
|
||||
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
|
||||
expect(parseProcStat('bad')).toBeUndefined()
|
||||
expect(parseProcStat('1 () S')).toBeUndefined()
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
|
||||
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
|
||||
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
|
||||
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
|
||||
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
|
||||
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
expect(inspector.foregroundPgid(10)).toBe(40)
|
||||
expect(inspector.foregroundPgid(11)).toBeUndefined()
|
||||
expect(inspector.foregroundPgid(99)).toBeUndefined()
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 13, started: '503' },
|
||||
{ pid: 12, started: '502' },
|
||||
{ pid: 10, started: '500' },
|
||||
])
|
||||
expect(inspector.processTree(99)).toEqual([])
|
||||
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
|
||||
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
|
||||
inspector.signalGroup(40, 'SIGINT')
|
||||
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
|
||||
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
|
||||
})
|
||||
|
||||
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100', '101'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
fake.dirs.set('/proc/101/task', ['101', '102'])
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', 'running')
|
||||
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
|
||||
const fdSet = Buffer.alloc(0x11)
|
||||
fdSet[0x10] = 1
|
||||
fake.memories.set('/proc/101/mem', fdSet)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
const poll = Buffer.alloc(8)
|
||||
poll.writeInt32LE(0, 0)
|
||||
poll.writeInt16LE(1, 4)
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
|
||||
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
|
||||
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
|
||||
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(999))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.dirs.delete('/proc/100/task')
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.dirs.set('/proc', ['100', '200'])
|
||||
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
|
||||
const noStdinPoll = Buffer.alloc(0x28)
|
||||
noStdinPoll.writeInt32LE(2, 0x20)
|
||||
noStdinPoll.writeInt16LE(1, 0x24)
|
||||
fake.memories.set('/proc/100/mem', noStdinPoll)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('macOS process inspector', () => {
|
||||
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.setTpgid('55\n')
|
||||
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
|
||||
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
|
||||
expect(inspector.foregroundPgid(10)).toBe(55)
|
||||
expect(inspector.isStdinWaiting(55)).toBe(false)
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
|
||||
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
|
||||
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
|
||||
])
|
||||
expect(inspector.processTree(99)).toEqual([])
|
||||
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
|
||||
inspector.signalGroup(55, 'SIGTSTP')
|
||||
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
|
||||
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
|
||||
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
|
||||
|
||||
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
|
||||
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.setTpgid('-1')
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
fake.internals.exec = () => { throw new Error('gone') }
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported platform win32')
|
||||
})
|
||||
})
|
||||
62
packages/pty/pty-local/tests/sanitize.spec.ts
Normal file
62
packages/pty/pty-local/tests/sanitize.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-local/src/sanitize.ts'
|
||||
|
||||
describe('TerminalSanitizer', () => {
|
||||
it('removes split CSI and owned OSC prompt markers', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
|
||||
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
|
||||
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
|
||||
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true })
|
||||
})
|
||||
|
||||
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
|
||||
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
|
||||
expect(sanitizer.flush()).toBe('')
|
||||
expect(sanitizer.flush()).toBe('')
|
||||
expect(sanitizer.push('\x1b]0;one\x07middle\x1b\\')).toEqual({ text: 'middle', prompt: false })
|
||||
expect(sanitizer.push('\x1b]0;one\x1b\\middle\x07')).toEqual({ text: 'middle', prompt: false })
|
||||
expect(sanitizer.push('\x1b]0;title\x1b\\')).toEqual({ text: '', prompt: false })
|
||||
})
|
||||
|
||||
it('normalizes CRLF and standalone carriage returns', () => {
|
||||
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
|
||||
})
|
||||
|
||||
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 })
|
||||
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscSt = new TerminalSanitizer(8)
|
||||
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
|
||||
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscDirectSt = new TerminalSanitizer(8)
|
||||
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscFalseSt = new TerminalSanitizer(8)
|
||||
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
oscFalseSt.push('\x1b')
|
||||
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
|
||||
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscNonTerminatingEscape = new TerminalSanitizer(8)
|
||||
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const csi = new TerminalSanitizer(8)
|
||||
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('123')).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
|
||||
|
||||
const flushed = new TerminalSanitizer(8)
|
||||
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(flushed.flush()).toBe('')
|
||||
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
|
||||
})
|
||||
})
|
||||
358
packages/pty/pty-local/tests/session.spec.ts
Normal file
358
packages/pty/pty-local/tests/session.spec.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
class FakeTerminal {
|
||||
pid = 123
|
||||
cols = 80
|
||||
rows = 24
|
||||
process = 'bash'
|
||||
handleFlowControl = false
|
||||
writes: string[] = []
|
||||
kills: string[] = []
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
private dataListeners = new Set<(data: string) => void>()
|
||||
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
|
||||
|
||||
readonly onData = (listener: (data: string) => void): IDisposable => {
|
||||
this.dataListeners.add(listener)
|
||||
return { dispose: () => this.dataListeners.delete(listener) }
|
||||
}
|
||||
|
||||
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
|
||||
this.exitListeners.add(listener)
|
||||
return { dispose: () => this.exitListeners.delete(listener) }
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const listener of this.dataListeners) listener(data)
|
||||
}
|
||||
|
||||
emitExit(exitCode = 0, signal?: number): void {
|
||||
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(data)
|
||||
}
|
||||
|
||||
kill(signal?: string): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push(signal ?? 'SIGHUP')
|
||||
this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
||||
}
|
||||
|
||||
resize() {}
|
||||
clear() {}
|
||||
pause() {}
|
||||
resume() {}
|
||||
|
||||
asPty(): IPty {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeInspector implements ProcessInspector {
|
||||
pgid: number | undefined = 456
|
||||
waiting = false
|
||||
members: ProcessIdentity[] = []
|
||||
alive = new Set<number>()
|
||||
groups: Array<[number, PtySignal]> = []
|
||||
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
|
||||
throwGroup = false
|
||||
throwProcess = false
|
||||
removeOnSignal = true
|
||||
|
||||
foregroundPgid() { return this.pgid }
|
||||
isStdinWaiting() { return this.waiting }
|
||||
processTree() { return this.members }
|
||||
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
|
||||
signalGroup(pgid: number, signal: PtySignal) {
|
||||
if (this.throwGroup) throw new Error('group failed')
|
||||
this.groups.push([pgid, signal])
|
||||
}
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
|
||||
if (this.throwProcess) throw new Error('process raced')
|
||||
this.processes.push([identity.pid, signal])
|
||||
if (this.removeOnSignal) this.alive.delete(identity.pid)
|
||||
}
|
||||
}
|
||||
|
||||
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, timeoutMs: 100,
|
||||
disposeGraceMs: 20,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
|
||||
const pending = session.initialize()
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await pending
|
||||
}
|
||||
|
||||
describe('LocalPtySession readiness and output', () => {
|
||||
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
|
||||
inspector.waiting = true
|
||||
const operation = session.startSend({ text: 'python3', submit: true })
|
||||
expect(terminal.writes).toEqual(['python3', '\r'])
|
||||
terminal.emitData('Python\r\n>>> ')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
|
||||
expect(operation.cancel()).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
inspector.pgid = undefined
|
||||
|
||||
const inferred = session.startSend({ text: 'sleep', submit: false })
|
||||
terminal.emitData('working')
|
||||
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect((await inferred.done).waitReason).toBe('inferred_idle')
|
||||
|
||||
const timeout = session.startSend({ text: 'blocked', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
terminal.emitData('.')
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
terminal.emitData('.')
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
expect((await timeout.done).waitReason).toBe('timeout')
|
||||
|
||||
const exiting = session.startSend({ text: 'exit', submit: true })
|
||||
terminal.emitExit(7, 9)
|
||||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
it('cancels with Ctrl-C, observes AbortSignal, and contains write failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const controller = new AbortController()
|
||||
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
|
||||
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
|
||||
controller.abort()
|
||||
expect(terminal.writes.at(-1)).toBe('\x03')
|
||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await operation.done
|
||||
|
||||
const aborted = new AbortController()
|
||||
aborted.abort()
|
||||
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
|
||||
|
||||
terminal.throwWrite = true
|
||||
const failed = session.startSend({ text: 'x', submit: false })
|
||||
await expect(failed.done).rejects.toThrow('write failed')
|
||||
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
|
||||
failedInternal.append('ignored')
|
||||
failedInternal.fail(new Error('ignored'))
|
||||
})
|
||||
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const startupTerminal = new FakeTerminal()
|
||||
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
|
||||
const initializing = startup.initialize(new AbortController().signal)
|
||||
startupTerminal.emitExit(1)
|
||||
await expect(initializing).rejects.toThrow('exited during startup')
|
||||
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
const operationInternal = operation as unknown as {
|
||||
append(text: string): void
|
||||
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
|
||||
}
|
||||
operationInternal.append('')
|
||||
const sessionInternal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
statusValue: PtySessionStatus
|
||||
appendOutput(text: string): void
|
||||
}
|
||||
sessionInternal.appendOutput('')
|
||||
sessionInternal.pollReadiness({} as PtySendOperation)
|
||||
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
||||
sessionInternal.pollReadiness(operation)
|
||||
await operation.done
|
||||
operationInternal.settle('timeout', { kind: 'running' }, false)
|
||||
|
||||
const unknownTerminal = new FakeTerminal()
|
||||
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
|
||||
unknownTerminal.emitExit(1, 999)
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
|
||||
const cancelTerminal = new FakeTerminal()
|
||||
const cancel = new LocalPtySession(cancelTerminal.asPty(), new FakeInspector(), config())
|
||||
await initialize(cancel, cancelTerminal)
|
||||
const cancellable = cancel.startSend({ text: '', submit: false })
|
||||
cancelTerminal.throwWrite = true
|
||||
expect(cancellable.cancel()).toBe(true)
|
||||
await expect(cancellable.done).rejects.toThrow('write failed')
|
||||
})
|
||||
|
||||
it('does not treat zero-output startup silence as readiness and fails on startup timeout', 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 })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect(settled).toBe(false)
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await initializing
|
||||
|
||||
const timeoutTerminal = new FakeTerminal()
|
||||
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
|
||||
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
let settled = false
|
||||
void operation.done.then(() => { settled = true })
|
||||
inspector.pgid = 789
|
||||
terminal.emitData('\x1b]133;D;0\x07spoofed')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.pgid = 456
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
it('validates pagination and enforces line/UTF-8 bounds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(
|
||||
terminal.asPty(),
|
||||
new FakeInspector(),
|
||||
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
|
||||
)
|
||||
expect(session.read({})).toMatchObject({ text: '' })
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
terminal.emitData('一\n二\n三\n四')
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect((await operation.done).truncated).toBe(true)
|
||||
const page = session.read({ offset: 0, count: 3 })
|
||||
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
|
||||
expect(page.truncated).toBe(true)
|
||||
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
|
||||
expect(() => session.read({ offset: -1 })).toThrow('offset')
|
||||
expect(() => session.read({ count: 0 })).toThrow('count')
|
||||
|
||||
const tinyTerminal = new FakeTerminal()
|
||||
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
|
||||
await initialize(tiny, tinyTerminal)
|
||||
const tinyOperation = tiny.startSend({ text: '', submit: false })
|
||||
tinyTerminal.emitData('一')
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
await tinyOperation.done
|
||||
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
|
||||
})
|
||||
|
||||
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
|
||||
inspector.pgid = terminal.pid
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
|
||||
inspector.pgid = undefined
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||||
})
|
||||
|
||||
it('closes idempotently, contains signal races, and reports survivors', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 123, started: 'a' }]
|
||||
inspector.alive.add(123)
|
||||
inspector.throwProcess = true
|
||||
terminal.throwKill = true
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
|
||||
const closing = session.close('test')
|
||||
expect(session.close('other')).toBe(closing)
|
||||
await expect(closing).rejects.toThrow('surviving pids: 123')
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('settles an active send as session_exit when closed mid-operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
// The shell returns to its prompt while the send is active; a running
|
||||
// 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
|
||||
const closing = session.close('mid-send')
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
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 () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
let settled = false
|
||||
const closing = session.close('test').then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await closing
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
})
|
||||
33
packages/pty/pty-local/tsconfig.json
Normal file
33
packages/pty/pty-local/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
packages/pty/pty/README.md
Normal file
34
packages/pty/pty/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @deepseek-ai/dsh-pty
|
||||
|
||||
Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opaque session ids, routes creation through named backends, fences every operation to the exact live `Agent`, and awaits backend quiescence when that agent or the service disposes.
|
||||
|
||||
## Contract
|
||||
|
||||
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Indirect consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing directly. This package registers no prompt or tool; `@deepseek-ai/dsh-tool-pty` owns visible schemas and result text.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None directly. Live session state stays process-local until a consumer returns a bounded result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Sessions are process-local and are not restored after a harness restart.
|
||||
- Cross-agent sharing is intentionally absent; a future shared-session design needs a separate authority contract.
|
||||
42
packages/pty/pty/package.json
Normal file
42
packages/pty/pty/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pty",
|
||||
"description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
362
packages/pty/pty/src/index.ts
Normal file
362
packages/pty/pty/src/index.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Owner-scoped persistent PTY registry. Backends own terminal mechanics while
|
||||
* this service owns ids, publication, authorization, and awaited cleanup.
|
||||
* @module @deepseek-ai/dsh-pty
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
PtyBackend,
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
PtyReadResult,
|
||||
PtySendOperation,
|
||||
PtySendRequest,
|
||||
PtySessionIdValue,
|
||||
PtySessionSnapshot,
|
||||
PtySignal,
|
||||
PtySignalResult,
|
||||
PtySpawnRequest,
|
||||
PtySpawnResult,
|
||||
} from './types.ts'
|
||||
|
||||
export type {
|
||||
PtyBackend,
|
||||
PtyBackendSession,
|
||||
PtyBackendSpawnSpec,
|
||||
PtyReadRequest,
|
||||
PtyReadResult,
|
||||
PtySendOperation,
|
||||
PtySendRead,
|
||||
PtySendRequest,
|
||||
PtySendResult,
|
||||
PtySessionSnapshot,
|
||||
PtySessionStatus,
|
||||
PtySignal,
|
||||
PtySignalResult,
|
||||
PtySpawnRequest,
|
||||
PtySpawnResult,
|
||||
PtyWaitReason,
|
||||
} from './types.ts'
|
||||
|
||||
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
|
||||
export type PtySessionId = PtySessionIdValue
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
pty: PtyService
|
||||
}
|
||||
}
|
||||
|
||||
/** Machine-routable PTY service failures. */
|
||||
export type PtyErrorCode =
|
||||
| 'DUPLICATE_BACKEND'
|
||||
| 'DUPLICATE_NAME'
|
||||
| 'FOREIGN_SESSION'
|
||||
| 'NO_BACKEND'
|
||||
| 'NO_SESSION'
|
||||
| 'OWNER_NOT_LIVE'
|
||||
| 'SEND_ACTIVE'
|
||||
| 'SERVICE_DISPOSING'
|
||||
|
||||
/** Error carrying a stable {@link PtyErrorCode}. */
|
||||
export class PtyError extends Error {
|
||||
constructor(message: string, readonly code: PtyErrorCode) {
|
||||
super(message)
|
||||
this.name = 'PtyError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand one registry-minted string as a {@link PtySessionId}.
|
||||
* @param value - raw registry-issued id.
|
||||
* @returns Same string with the PTY session brand.
|
||||
*/
|
||||
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
|
||||
readonly name: string | undefined
|
||||
readonly type: string
|
||||
readonly session: PtyBackendSession
|
||||
active: PtySendOperation | undefined
|
||||
closing: Promise<void> | undefined
|
||||
}
|
||||
|
||||
/** 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 ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
||||
private readonly disposedOwners = new WeakSet<Agent>()
|
||||
private nextId = 0
|
||||
private disposing = false
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'pty')
|
||||
ctx.effect(() => () => this.disposeAll(), 'pty teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one backend type for this effect scope.
|
||||
* @param backend - provider with a non-empty unique type.
|
||||
* @returns disposer that removes exactly this contribution.
|
||||
*/
|
||||
registerBackend(backend: PtyBackend): () => void {
|
||||
if (backend.type.length === 0) throw new Error('pty backend type must be non-empty')
|
||||
if (this.backends.has(backend.type)) {
|
||||
throw new PtyError(`a PTY backend named "${backend.type}" is already registered`, 'DUPLICATE_BACKEND')
|
||||
}
|
||||
const dispose = this.ctx.effect(() => {
|
||||
this.backends.set(backend.type, backend)
|
||||
return () => {
|
||||
if (this.backends.get(backend.type) === backend) this.backends.delete(backend.type)
|
||||
}
|
||||
}, 'pty.registerBackend()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* List registered backend types in registration order.
|
||||
* @returns fresh backend type names.
|
||||
*/
|
||||
listBackends(): string[] {
|
||||
return [...this.backends.keys()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and publish one owner-scoped session after backend setup succeeds.
|
||||
* @param owner - exact registered Agent that owns access and cleanup.
|
||||
* @param request - backend type plus optional owner-local name and cwd.
|
||||
* @param signal - cancellation of unpublished setup.
|
||||
* @returns published identity, metadata, status, and MOTD.
|
||||
*/
|
||||
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
|
||||
this.assertActive()
|
||||
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 sessionId = PtySessionId(`pty-${++this.nextId}`)
|
||||
let session: PtyBackendSession | undefined
|
||||
try {
|
||||
session = await backend.spawn({
|
||||
sessionId,
|
||||
owner,
|
||||
type: request.type,
|
||||
...request.name !== undefined ? { name: request.name } : {},
|
||||
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
if (this.disposing || isAborted(signal) || !this.isLiveOwner(owner)) {
|
||||
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
|
||||
}
|
||||
const record: SessionRecord = {
|
||||
id: sessionId,
|
||||
owner,
|
||||
name: request.name,
|
||||
type: request.type,
|
||||
session,
|
||||
active: undefined,
|
||||
closing: undefined,
|
||||
}
|
||||
this.sessions.set(sessionId, record)
|
||||
return this.snapshot(record, session.motd)
|
||||
} catch (error) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
releaseName()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start one exclusive interactive send.
|
||||
* @param owner - exact session owner.
|
||||
* @param id - target PTY identity.
|
||||
* @param request - explicit text, submit behavior, and cancellation.
|
||||
* @returns live operation handle for foreground await or task registration.
|
||||
*/
|
||||
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation {
|
||||
const record = this.expectOwned(owner, id)
|
||||
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
|
||||
if (record.active !== undefined) throw new PtyError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE')
|
||||
const operation = record.session.startSend(request)
|
||||
record.active = operation
|
||||
void operation.done.then(
|
||||
() => { record.active = undefined },
|
||||
() => { record.active = undefined },
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one bounded scrollback page from an owned session.
|
||||
* @param owner - exact session owner.
|
||||
* @param id - target PTY identity.
|
||||
* @param request - optional newest-relative offset and line count.
|
||||
* @returns bounded retained text and pagination metadata.
|
||||
*/
|
||||
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult {
|
||||
return this.expectOwned(owner, id).session.read(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an allowed signal through an owned backend session.
|
||||
* @param owner - exact session owner.
|
||||
* @param id - target PTY identity.
|
||||
* @param signal - allowed POSIX signal name.
|
||||
* @returns delivered foreground process-group identity.
|
||||
*/
|
||||
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult> {
|
||||
return this.expectOwned(owner, id).session.signal(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close one owned session and remove it only after quiescent backend cleanup.
|
||||
* @param owner - exact session owner.
|
||||
* @param id - target PTY identity.
|
||||
* @param reason - diagnostic cleanup reason.
|
||||
* @returns true for a newly closed session, false when the same close is already in flight.
|
||||
*/
|
||||
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean> {
|
||||
const record = this.expectOwned(owner, id)
|
||||
if (record.closing !== undefined) {
|
||||
await record.closing
|
||||
return false
|
||||
}
|
||||
const closing = record.session.close(reason)
|
||||
record.closing = closing
|
||||
try {
|
||||
await closing
|
||||
this.sessions.delete(id)
|
||||
return true
|
||||
} catch (error) {
|
||||
record.closing = undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List fresh snapshots for exactly one owner.
|
||||
* @param owner - exact owner whose sessions are visible.
|
||||
* @returns owner-visible snapshots in publication order.
|
||||
*/
|
||||
list(owner: Agent): PtySessionSnapshot[] {
|
||||
return [...this.sessions.values()]
|
||||
.filter(record => record.owner === owner)
|
||||
.map(record => this.snapshot(record))
|
||||
}
|
||||
|
||||
private assertActive(): void {
|
||||
if (this.disposing) throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
|
||||
}
|
||||
|
||||
private isLiveOwner(owner: Agent): boolean {
|
||||
return !this.disposedOwners.has(owner) && this.ctx.get('agents')?.get(owner.id) === owner
|
||||
}
|
||||
|
||||
private ensureOwnerCleanup(owner: Agent): void {
|
||||
if (!this.isLiveOwner(owner)) {
|
||||
throw new PtyError(`agent "${owner.id}" is not the registered PTY owner`, 'OWNER_NOT_LIVE')
|
||||
}
|
||||
if (this.ownerCleanups.has(owner)) return
|
||||
const detach = owner.ctx.effect(() => async () => {
|
||||
this.disposedOwners.add(owner)
|
||||
this.ownerCleanups.delete(owner)
|
||||
await this.disposeOwned(owner)
|
||||
}, 'pty.ownerCleanup()')
|
||||
this.ownerCleanups.set(owner, detach)
|
||||
}
|
||||
|
||||
private reserveName(owner: Agent, name: string | undefined): () => void {
|
||||
if (name === undefined) return () => {}
|
||||
if ([...this.sessions.values()].some(record => record.owner === owner && record.name === name)) {
|
||||
throw new PtyError(`PTY session name "${name}" already exists for this owner`, 'DUPLICATE_NAME')
|
||||
}
|
||||
const reserved = this.reservedNames.get(owner) ?? new Set<string>()
|
||||
if (reserved.has(name)) throw new PtyError(`PTY session name "${name}" is already being created`, 'DUPLICATE_NAME')
|
||||
reserved.add(name)
|
||||
this.reservedNames.set(owner, reserved)
|
||||
return () => {
|
||||
reserved.delete(name)
|
||||
if (reserved.size === 0) this.reservedNames.delete(owner)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
if (record.owner !== owner) throw new PtyError(`PTY session ${id} belongs to another agent`, 'FOREIGN_SESSION')
|
||||
return record
|
||||
}
|
||||
|
||||
private snapshot(record: SessionRecord): PtySessionSnapshot
|
||||
private snapshot(record: SessionRecord, motd: string): PtySpawnResult
|
||||
private snapshot(record: SessionRecord, motd?: string): PtySpawnResult | PtySessionSnapshot {
|
||||
return {
|
||||
sessionId: record.id,
|
||||
...record.name !== undefined ? { name: record.name } : {},
|
||||
type: record.type,
|
||||
...record.session.pid !== undefined ? { pid: record.session.pid } : {},
|
||||
status: record.session.status(),
|
||||
...motd !== undefined ? { motd } : {},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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')
|
||||
} finally {
|
||||
this.backends.clear()
|
||||
this.reservedNames.clear()
|
||||
const cleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeRecords(records: SessionRecord[], reason: string): Promise<void> {
|
||||
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)
|
||||
}))
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map<unknown>(result => result.reason as unknown)
|
||||
if (failures.length > 0) throw new AggregateError(failures, `failed to close ${failures.length} PTY session(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
export default PtyService
|
||||
30
packages/pty/pty/src/invariant.ts
Normal file
30
packages/pty/pty/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-pty`.
|
||||
* @module @deepseek-ai/dsh-pty/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pty'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pty-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: backend and owner-scoped session registries are private mutable state,
|
||||
* and the service exposes neither an independent lifecycle stream nor an unscoped snapshot.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
158
packages/pty/pty/src/types.ts
Normal file
158
packages/pty/pty/src/types.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Types shared by PTY backends, the owner-scoped registry, and tool consumers.
|
||||
* Runtime service code lives in `./index.ts`.
|
||||
* @module @deepseek-ai/dsh-pty/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Internal exported basis for the public `PtySessionId` type/value pair. */
|
||||
export type PtySessionIdValue = Branded<'PtySessionId'>
|
||||
|
||||
/** Why one interactive send returned control to its caller. */
|
||||
export type PtyWaitReason = 'stdin_read' | 'inferred_idle' | 'timeout' | 'session_exit'
|
||||
|
||||
/** Signals the model-facing PTY surface permits for foreground process groups. */
|
||||
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
|
||||
|
||||
/** Top-level PTY process status, independent of a send's wait reason. */
|
||||
export type PtySessionStatus =
|
||||
| { kind: 'running' }
|
||||
| { kind: 'exited'; exitCode: number | null; signal: NodeJS.Signals | null }
|
||||
|
||||
/** Request to create one owner-scoped PTY session. */
|
||||
export interface PtySpawnRequest {
|
||||
/** Registered backend type. */
|
||||
type: string
|
||||
/** Optional owner-local display name. */
|
||||
name?: string
|
||||
/** Optional initial working directory interpreted by the backend. */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** Fully identified request handed from the registry to a backend. */
|
||||
export interface PtyBackendSpawnSpec extends PtySpawnRequest {
|
||||
/** Registry-minted session identity. */
|
||||
sessionId: PtySessionIdValue
|
||||
/** Exact live owner for authority-aware backend setup. */
|
||||
owner: Agent
|
||||
/** Cancellation of unpublished backend setup. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Input for one line-oriented terminal interaction. */
|
||||
export interface PtySendRequest {
|
||||
/** UTF-8 text to write. */
|
||||
text: string
|
||||
/** Whether to write the backend's Enter sequence after {@link text}. */
|
||||
submit: boolean
|
||||
/** Cancellation for the wait; backends also interrupt the foreground command. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Incremental output consumed from one live send operation. */
|
||||
export interface PtySendRead {
|
||||
/** Output produced since the previous operation read. */
|
||||
delta: string
|
||||
/** Whether unread operation output was dropped by the backend's bound. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Settled result for one foreground or background send. */
|
||||
export interface PtySendResult {
|
||||
/** Bounded rendered terminal delta remaining at settlement. */
|
||||
viewport: string
|
||||
/** Why the wait returned; this does not imply arbitrary child-process exit. */
|
||||
waitReason: PtyWaitReason
|
||||
/** Top-level session status observed at settlement. */
|
||||
sessionStatus: PtySessionStatus
|
||||
/** Whether output was dropped from the operation or retained scrollback. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Live backend-owned send; exactly one may be active per PTY session. */
|
||||
export interface PtySendOperation {
|
||||
/** Resolves after readiness, timeout, cancellation, or top-level process exit. */
|
||||
done: Promise<PtySendResult>
|
||||
/** Consume output produced since the prior call. */
|
||||
readOutput(): PtySendRead
|
||||
/** Request `SIGINT`; returns false after the operation settled. */
|
||||
cancel(): boolean
|
||||
}
|
||||
|
||||
/** Request for one backward scrollback page. */
|
||||
export interface PtyReadRequest {
|
||||
/** Offset from the newest retained line; defaults are backend-owned. */
|
||||
offset?: number
|
||||
/** Requested line count; backend limits still apply. */
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Bounded scrollback page. */
|
||||
export interface PtyReadResult {
|
||||
/** Retained text in chronological order. */
|
||||
text: string
|
||||
/** Number of lines currently retained. */
|
||||
totalLines: number
|
||||
/** Inclusive newest-relative offset of the first returned line. */
|
||||
lineBegin: number
|
||||
/** Exclusive newest-relative offset after the returned page. */
|
||||
lineEnd: number
|
||||
/** Whether older retained output or the requested result exceeded a bound. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** Result of delivering a signal to a verified foreground process group. */
|
||||
export interface PtySignalResult {
|
||||
/** True only after the backend delivered the signal. */
|
||||
delivered: true
|
||||
/** Process group that received the signal. */
|
||||
targetPgid: number
|
||||
}
|
||||
|
||||
/** Owner-visible summary of one published PTY session. */
|
||||
export interface PtySessionSnapshot {
|
||||
/** Registry-minted identity used by every operation. */
|
||||
sessionId: PtySessionIdValue
|
||||
/** Optional owner-local display name. */
|
||||
name?: string
|
||||
/** Backend type that created the session. */
|
||||
type: string
|
||||
/** Top-level process id when the backend has one. */
|
||||
pid?: number
|
||||
/** Current top-level process status. */
|
||||
status: PtySessionStatus
|
||||
}
|
||||
|
||||
/** Backend-owned live session retained by {@link PtyService}. */
|
||||
export interface PtyBackendSession {
|
||||
/** Initial bounded terminal output returned from `terminal_open`. */
|
||||
readonly motd: string
|
||||
/** Top-level process id when one exists. */
|
||||
readonly pid?: number
|
||||
/** Start one exclusive send operation. */
|
||||
startSend(request: PtySendRequest): PtySendOperation
|
||||
/** Read one bounded page from retained scrollback. */
|
||||
read(request: PtyReadRequest): PtyReadResult
|
||||
/** Signal the verified foreground process group. */
|
||||
signal(signal: PtySignal): Promise<PtySignalResult>
|
||||
/** Observe top-level process status. */
|
||||
status(): PtySessionStatus
|
||||
/** Idempotently close the captured owned process tree and await quiescence. */
|
||||
close(reason: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Replaceable provider for one PTY session type. */
|
||||
export interface PtyBackend {
|
||||
/** Stable type selected by {@link PtySpawnRequest.type}. */
|
||||
readonly type: string
|
||||
/** Create an unpublished session or reject after cleaning partial resources. */
|
||||
spawn(spec: PtyBackendSpawnSpec): Promise<PtyBackendSession>
|
||||
}
|
||||
|
||||
/** Successful publication returned by {@link PtyService.spawn}. */
|
||||
export interface PtySpawnResult extends PtySessionSnapshot {
|
||||
/** Initial bounded output captured before publication. */
|
||||
motd: string
|
||||
}
|
||||
367
packages/pty/pty/tests/service.spec.ts
Normal file
367
packages/pty/pty/tests/service.spec.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import type {
|
||||
PtyBackend,
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
PtySendOperation,
|
||||
PtySendRequest,
|
||||
PtySessionId as PtySessionIdType,
|
||||
PtySessionStatus,
|
||||
PtySignal,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
|
||||
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
|
||||
const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>()
|
||||
|
||||
function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
const id = SessionId(rawId)
|
||||
const scopeFiber = ctx.plugin(() => {})
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
ctx: scopeFiber.ctx,
|
||||
send() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
|
||||
return agent
|
||||
}
|
||||
|
||||
async function disposeAgentScope(agent: Agent): Promise<void> {
|
||||
const dispose = agentScopeDisposers.get(agent)
|
||||
if (dispose === undefined) throw new Error('missing agent scope')
|
||||
await dispose()
|
||||
}
|
||||
|
||||
class StubSession implements PtyBackendSession {
|
||||
readonly motd = 'stub ready'
|
||||
readonly pid = 123
|
||||
closed: string[] = []
|
||||
statusValue: PtySessionStatus = { kind: 'running' }
|
||||
operation: PtySendOperation | undefined
|
||||
rejectSend = false
|
||||
rejectClose = false
|
||||
closeGate: PromiseWithResolvers<undefined> | undefined
|
||||
|
||||
startSend(_request: PtySendRequest): PtySendOperation {
|
||||
if (this.rejectSend) {
|
||||
return { done: Promise.reject(new Error('send failed')), readOutput: () => ({ delta: '', truncated: false }), cancel: () => false }
|
||||
}
|
||||
let settle!: () => void
|
||||
let settled = false
|
||||
const done = new Promise<void>((resolve) => { settle = resolve }).then(() => ({
|
||||
viewport: 'done',
|
||||
waitReason: 'stdin_read' as const,
|
||||
sessionStatus: this.statusValue,
|
||||
truncated: false,
|
||||
}))
|
||||
const operation: PtySendOperation = {
|
||||
done,
|
||||
readOutput: () => ({ delta: 'delta', truncated: false }),
|
||||
cancel: () => {
|
||||
if (settled) return false
|
||||
settled = true
|
||||
settle()
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.operation = operation
|
||||
return operation
|
||||
}
|
||||
|
||||
read(request: PtyReadRequest) {
|
||||
return { text: `${request.offset ?? 0}:${request.count ?? 0}`, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
|
||||
}
|
||||
|
||||
async signal(signal: PtySignal) {
|
||||
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 12 : 13 }
|
||||
}
|
||||
|
||||
status(): PtySessionStatus {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
async close(reason: string): Promise<void> {
|
||||
this.closed.push(reason)
|
||||
if (this.rejectClose) throw new Error('close failed')
|
||||
if (this.closeGate !== undefined) await this.closeGate.promise
|
||||
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
|
||||
this.operation?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function backend(type = 'stub') {
|
||||
const sessions: StubSession[] = []
|
||||
const provider: PtyBackend = {
|
||||
type,
|
||||
async spawn() {
|
||||
const session = new StubSession()
|
||||
sessions.push(session)
|
||||
return session
|
||||
},
|
||||
}
|
||||
return { provider, sessions }
|
||||
}
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const fiber = await ctx.plugin(PtyService)
|
||||
ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() })
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function disposePtyService(ctx: Context): Promise<void> {
|
||||
const dispose = ptyServiceDisposers.get(ctx)
|
||||
if (dispose === undefined) throw new Error('missing PTY service fiber')
|
||||
await dispose()
|
||||
}
|
||||
|
||||
describe('PtyService backend registry', () => {
|
||||
it('preserves the id brand and disposes exact backend contributions', async () => {
|
||||
expectTypeOf(PtySessionId('pty-1')).toEqualTypeOf<PtySessionIdType>()
|
||||
const ctx = await harness()
|
||||
const first = backend()
|
||||
const dispose = ctx.pty.registerBackend(first.provider)
|
||||
expect(ctx.pty.listBackends()).toEqual(['stub'])
|
||||
expect(() => ctx.pty.registerBackend(backend().provider)).toThrow(PtyError)
|
||||
const internal = ctx.pty as unknown as { backends: Map<string, PtyBackend> }
|
||||
internal.backends.set('stub', backend('replacement').provider)
|
||||
dispose()
|
||||
expect(ctx.pty.listBackends()).toEqual(['stub'])
|
||||
internal.backends.clear()
|
||||
})
|
||||
|
||||
it('rejects empty backend types', async () => {
|
||||
const ctx = await harness()
|
||||
expect(() => ctx.pty.registerBackend(backend('').provider)).toThrow('must be non-empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PtyService ownership and lifecycle', () => {
|
||||
it('publishes only after spawn and fences every operation to the exact owner', async () => {
|
||||
const ctx = await harness()
|
||||
const b = backend()
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
const foreign = stubAgent(ctx, 'foreign')
|
||||
ctx.agents.register(owner)
|
||||
ctx.agents.register(foreign)
|
||||
|
||||
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.list(owner)).toHaveLength(1)
|
||||
expect(ctx.pty.list(foreign)).toEqual([])
|
||||
expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent')
|
||||
expect(() => ctx.pty.signal(foreign, created.sessionId, 'SIGINT')).toThrow('belongs to another agent')
|
||||
await expect(Promise.resolve().then(() => ctx.pty.kill(foreign, created.sessionId))).rejects.toThrow('belongs to another agent')
|
||||
})
|
||||
|
||||
it('rejects unknown backends, non-live owners, duplicate names, and active sends', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
|
||||
ctx.agents.register(owner)
|
||||
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
|
||||
const b = backend()
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
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')
|
||||
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 })
|
||||
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(PtyError)
|
||||
expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false })
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await operation.done
|
||||
const next = ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })
|
||||
next.cancel()
|
||||
await next.done
|
||||
|
||||
b.sessions[0]!.rejectSend = true
|
||||
await expect(ctx.pty.startSend(owner, created.sessionId, { text: 'bad', submit: true }).done).rejects.toThrow('send failed')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
it('reserves concurrent names and rolls back a spawn whose owner disappears', 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 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)
|
||||
gate.resolve(session)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('keeps independent reservations and handles provider failure before publication', async () => {
|
||||
const ctx = await harness()
|
||||
const firstGate = Promise.withResolvers<PtyBackendSession>()
|
||||
const secondGate = Promise.withResolvers<PtyBackendSession>()
|
||||
let count = 0
|
||||
ctx.pty.registerBackend({
|
||||
type: 'slow',
|
||||
spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
|
||||
})
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const first = ctx.pty.spawn(owner, { type: 'slow', name: 'one' })
|
||||
const second = ctx.pty.spawn(owner, { type: 'slow', name: 'two' })
|
||||
firstGate.resolve(new StubSession())
|
||||
await first
|
||||
secondGate.resolve(new StubSession())
|
||||
await second
|
||||
|
||||
ctx.pty.registerBackend({ type: 'throwing', spawn: () => Promise.reject(new Error('provider failed')) })
|
||||
await expect(ctx.pty.spawn(owner, { type: 'throwing' })).rejects.toThrow('provider failed')
|
||||
|
||||
const controller = new AbortController()
|
||||
const b = backend('signaled')
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
await ctx.pty.spawn(owner, { type: 'signaled' }, controller.signal)
|
||||
})
|
||||
|
||||
it('omits optional pid metadata when a backend has no process id', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const session = new StubSession()
|
||||
Object.defineProperty(session, 'pid', { value: undefined })
|
||||
ctx.pty.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
|
||||
expect(await ctx.pty.spawn(owner, { type: 'virtual' })).not.toHaveProperty('pid')
|
||||
})
|
||||
|
||||
it('reports rollback and close failures without publishing false success', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const failedSpawn = new StubSession()
|
||||
failedSpawn.rejectClose = true
|
||||
ctx.pty.registerBackend({
|
||||
type: 'bad-spawn',
|
||||
async spawn() {
|
||||
await disposeAgentScope(owner)
|
||||
return failedSpawn
|
||||
},
|
||||
})
|
||||
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
|
||||
|
||||
const nextOwner = stubAgent(ctx, 'next')
|
||||
ctx.agents.register(nextOwner)
|
||||
const b = backend('bad-close')
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
const created = await ctx.pty.spawn(nextOwner, { type: 'bad-close' })
|
||||
b.sessions[0]!.rejectClose = true
|
||||
await expect(ctx.pty.kill(nextOwner, created.sessionId)).rejects.toThrow('close failed')
|
||||
expect(ctx.pty.list(nextOwner)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('joins an already-running close and refuses new sends while closing', async () => {
|
||||
const ctx = await harness()
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const b = backend()
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
|
||||
const first = ctx.pty.kill(owner, created.sessionId)
|
||||
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
|
||||
const second = ctx.pty.kill(owner, created.sessionId)
|
||||
b.sessions[0]!.closeGate?.resolve(undefined)
|
||||
expect(await first).toBe(true)
|
||||
expect(await second).toBe(false)
|
||||
expect(() => ctx.pty.read(owner, created.sessionId)).toThrow('unknown PTY')
|
||||
})
|
||||
|
||||
it('awaits owner cleanup and removes sessions while backend registration may reload', async () => {
|
||||
const ctx = await harness()
|
||||
const b = backend()
|
||||
const disposeBackend = ctx.pty.registerBackend(b.provider)
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
const created = await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
disposeBackend()
|
||||
expect(ctx.pty.listBackends()).toEqual([])
|
||||
expect(ctx.pty.read(owner, created.sessionId).text).toBe('0:0')
|
||||
|
||||
await disposeAgentScope(owner)
|
||||
expect(b.sessions[0]?.closed).toEqual(['PTY owner disposed'])
|
||||
expect(ctx.pty.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('kills idempotently and service disposal closes all owners', async () => {
|
||||
const ctx = await harness()
|
||||
const b = backend()
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
const first = stubAgent(ctx, 'first')
|
||||
const second = stubAgent(ctx, 'second')
|
||||
ctx.agents.register(first)
|
||||
ctx.agents.register(second)
|
||||
const a = await ctx.pty.spawn(first, { type: 'stub' })
|
||||
await ctx.pty.spawn(second, { type: 'stub' })
|
||||
expect(await ctx.pty.kill(first, a.sessionId)).toBe(true)
|
||||
expect(b.sessions[0]?.closed).toEqual(['model request'])
|
||||
|
||||
const service = ctx.pty
|
||||
await disposePtyService(ctx)
|
||||
expect(b.sessions[1]?.closed).toEqual(['PTY service disposed'])
|
||||
await expect(service.spawn(first, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
|
||||
})
|
||||
|
||||
it('aggregates service-disposal close failures after attempting every record', async () => {
|
||||
const ctx = await harness()
|
||||
const service = ctx.pty
|
||||
const b = backend()
|
||||
ctx.pty.registerBackend(b.provider)
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
await ctx.pty.spawn(owner, { type: 'stub' })
|
||||
b.sessions[0]!.rejectClose = true
|
||||
const internal = service as unknown as {
|
||||
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')
|
||||
b.sessions[0]!.rejectClose = false
|
||||
await disposePtyService(ctx)
|
||||
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
|
||||
})
|
||||
|
||||
it('clears registries and runs owner cleanups even when a session close fails', async () => {
|
||||
const ctx = await harness()
|
||||
const service = ctx.pty
|
||||
const b = backend()
|
||||
service.registerBackend(b.provider)
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
await service.spawn(owner, { type: 'stub' })
|
||||
b.sessions[0]!.rejectClose = true
|
||||
const internal = service as unknown as {
|
||||
disposeAll(): Promise<void>
|
||||
backends: Map<string, unknown>
|
||||
ownerCleanups: Map<Agent, unknown>
|
||||
}
|
||||
// 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')
|
||||
expect(internal.backends.size).toBe(0)
|
||||
expect(internal.ownerCleanups.size).toBe(0)
|
||||
})
|
||||
})
|
||||
27
packages/pty/pty/tsconfig.json
Normal file
27
packages/pty/pty/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
packages/pty/tool-pty/README.md
Normal file
60
packages/pty/tool-pty/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# @deepseek-ai/dsh-tool-pty
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The plugin contributes this fixed guidance section:
|
||||
|
||||
##### Terminal guidance
|
||||
|
||||
```markdown
|
||||
Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost on every request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and guidance text are unchanged.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The six generated schemas are listed in the [`dsh-tool-pty` catalog section](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pty). Their fixed schema tokens are present whenever this plugin is active; agent-scoped tool filtering may hide them.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on requests where the tools are visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while tool visibility and definitions are unchanged.
|
||||
|
||||
### Tool results and task context
|
||||
|
||||
#### 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.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; new results follow the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
|
||||
- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.
|
||||
56
packages/pty/tool-pty/package.json
Normal file
56
packages/pty/tool-pty/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pty",
|
||||
"description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"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-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
224
packages/pty/tool-pty/src/index.ts
Normal file
224
packages/pty/tool-pty/src/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Six model-facing persistent terminal tools. Owner identity comes from the exact
|
||||
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
|
||||
* @module @deepseek-ai/dsh-tool-pty
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
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 { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-tasks' {
|
||||
interface TaskKindMap {
|
||||
'pty-send': 'pty-send'
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'tool-pty'
|
||||
/** Required capability, registry, and prompt services. */
|
||||
export const inject = ['pty', 'tools', 'systemPrompt']
|
||||
|
||||
interface SpawnArgs {
|
||||
type: string
|
||||
name?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
interface SessionArgs {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
interface SendArgs extends SessionArgs {
|
||||
text: string
|
||||
submit?: boolean
|
||||
run_in_background?: boolean
|
||||
}
|
||||
|
||||
interface ReadArgs extends SessionArgs {
|
||||
offset?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface SignalArgs extends SessionArgs {
|
||||
signal: PtySignal
|
||||
}
|
||||
|
||||
function requireAgent(agent: Agent | undefined): Agent {
|
||||
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
|
||||
return agent
|
||||
}
|
||||
|
||||
function sessionId(args: SessionArgs): PtySessionIdType {
|
||||
if (args.sessionId.length === 0) {
|
||||
throw new Error('sessionId must be a non-empty string')
|
||||
}
|
||||
return PtySessionId(args.sessionId)
|
||||
}
|
||||
|
||||
function textResult(text: string): ContentBlock[] {
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
|
||||
function rawResultText(result: ToolResult): string | undefined {
|
||||
if (result.content.length !== 1) return undefined
|
||||
const block = result.content[0]
|
||||
return block?.type === 'text' ? block.text : undefined
|
||||
}
|
||||
|
||||
function sendDetail(result: PtySendResult): string {
|
||||
return result.sessionStatus.kind === 'running'
|
||||
? `wait: ${result.waitReason}`
|
||||
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
|
||||
}
|
||||
|
||||
/** Register all terminal tools and the minimal usage guidance. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pty',
|
||||
order: 106,
|
||||
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_open',
|
||||
description: 'Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.',
|
||||
parameters: {
|
||||
type: { type: 'string', required: true, description: 'Registered terminal backend type, usually "shell".' },
|
||||
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.' },
|
||||
},
|
||||
async execute(args: SpawnArgs, exec) {
|
||||
if (args.type.length === 0) throw new Error('type must be a non-empty string')
|
||||
const result = await ctx.pty.spawn(requireAgent(exec.agent), {
|
||||
type: args.type,
|
||||
...args.name !== undefined ? { name: args.name } : {},
|
||||
...args.cwd !== undefined ? { cwd: args.cwd } : {},
|
||||
}, exec.signal)
|
||||
return textResult(renderSpawn(result))
|
||||
},
|
||||
presentCall: (args) => {
|
||||
const parsed = args
|
||||
return { card: 'generic', title: `Open terminal ${parsed.name ?? parsed.type}`, kind: 'execute' }
|
||||
},
|
||||
}))
|
||||
|
||||
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.',
|
||||
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.' },
|
||||
},
|
||||
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
|
||||
const owner = requireAgent(exec.agent)
|
||||
const id = sessionId(args)
|
||||
const request = { text: args.text, submit: args.submit ?? true }
|
||||
if (args.run_in_background === true) {
|
||||
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
|
||||
const taskId = tasks.start({
|
||||
kind: 'pty-send',
|
||||
label: `${id}: ${args.text || '(input)'}`,
|
||||
owner,
|
||||
run: () => {
|
||||
const operation = ctx.pty.startSend(owner, id, request)
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelRequested = true
|
||||
operation.cancel()
|
||||
},
|
||||
done: operation.done.then(
|
||||
result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
|
||||
(error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
|
||||
),
|
||||
readOutput: () => renderSendRead(operation.readOutput()),
|
||||
}
|
||||
},
|
||||
})
|
||||
return { content: textResult(`started background task ${taskId}`), isError: false }
|
||||
}
|
||||
const operation = ctx.pty.startSend(owner, id, { ...request, signal: exec.signal })
|
||||
const result = await operation.done
|
||||
if (exec.signal.aborted) throw new Error('terminal send aborted')
|
||||
return { content: textResult(renderSend(result)), isError: false, meta: result }
|
||||
},
|
||||
presentCall(args) {
|
||||
const parsed = args as Partial<SendArgs>
|
||||
if (parsed.run_in_background === true) {
|
||||
return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
|
||||
}
|
||||
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
|
||||
const raw = rawResultText(result)
|
||||
return raw === undefined ? undefined : { card: 'terminal', output: raw }
|
||||
},
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_read',
|
||||
description: 'Read a bounded page of retained output from a persistent terminal without sending input.',
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
|
||||
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
|
||||
},
|
||||
execute(args: ReadArgs, exec) {
|
||||
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
|
||||
...args.offset !== undefined ? { offset: args.offset } : {},
|
||||
...args.count !== undefined ? { count: args.count } : {},
|
||||
})
|
||||
return Promise.resolve(textResult(renderRead(result)))
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `Read terminal ${(args).sessionId}`, kind: 'read', rawInput: args }),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_signal',
|
||||
description: 'Send an allowed signal to the current foreground process group of a persistent terminal.',
|
||||
parameters: {
|
||||
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.' },
|
||||
},
|
||||
async execute(args: SignalArgs, exec) {
|
||||
const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
|
||||
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`)
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `Signal terminal ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_close',
|
||||
description: 'Close one persistent terminal and wait until its captured owned process tree is gone.',
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
},
|
||||
async execute(args: SessionArgs, exec) {
|
||||
const id = sessionId(args)
|
||||
const closed = await ctx.pty.kill(requireAgent(exec.agent), id)
|
||||
return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`)
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'terminal_list',
|
||||
description: 'List persistent terminal sessions owned by the current agent.',
|
||||
parameters: {},
|
||||
execute(_args: Record<string, never>, exec) {
|
||||
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
|
||||
},
|
||||
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
|
||||
}))
|
||||
}
|
||||
30
packages/pty/tool-pty/src/invariant.ts
Normal file
30
packages/pty/tool-pty/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pty`.
|
||||
* @module @deepseek-ai/dsh-tool-pty/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pty'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-pty-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless adapter contributes tools and prompt guidance, while PTY
|
||||
* lifecycle and background-task relationships remain owned by the services it composes.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
62
packages/pty/tool-pty/src/render.ts
Normal file
62
packages/pty/tool-pty/src/render.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/** Model and ACP rendering for persistent terminal tool results. */
|
||||
|
||||
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
/**
|
||||
* Render one created session and its bounded MOTD.
|
||||
* @param result - published spawn result.
|
||||
* @returns Model-facing session acknowledgement.
|
||||
*/
|
||||
export function renderSpawn(result: PtySpawnResult): 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)'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one settled interactive send.
|
||||
* @param result - settled send outcome.
|
||||
* @returns Terminal output plus wait/session markers.
|
||||
*/
|
||||
export function renderSend(result: PtySendResult): 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]' : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one incremental background operation read.
|
||||
* @param read - consuming operation delta.
|
||||
* @returns Delta plus truncation marker when needed.
|
||||
*/
|
||||
export function renderSendRead(read: PtySendRead): string {
|
||||
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one bounded historical page.
|
||||
* @param result - retained scrollback page.
|
||||
* @returns Page text plus pagination and truncation markers.
|
||||
*/
|
||||
export function renderRead(result: PtyReadResult): string {
|
||||
const output = result.text || '(no retained output)'
|
||||
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render owner-visible live sessions.
|
||||
* @param sessions - fresh owner-scoped snapshots.
|
||||
* @returns One line per session or the empty marker.
|
||||
*/
|
||||
export function renderList(sessions: PtySessionSnapshot[]): string {
|
||||
if (sessions.length === 0) return '(no terminal sessions)'
|
||||
return sessions.map((session) => {
|
||||
const name = session.name === undefined ? '' : ` (${session.name})`
|
||||
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
|
||||
const status = session.status.kind === 'running'
|
||||
? 'running'
|
||||
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
|
||||
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
|
||||
}).join('\n')
|
||||
}
|
||||
120
packages/pty/tool-pty/tests/loader-composition.spec.ts
Normal file
120
packages/pty/tool-pty/tests/loader-composition.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import PtyService from '@deepseek-ai/dsh-pty'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId('pty-loader-agent')
|
||||
const value: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function resultText(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
|
||||
|
||||
suite('terminal real Loader composition through cordis.yml', () => {
|
||||
it('boots cordis.yml and preserves shell state across real tool calls', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-pty-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-agent'",
|
||||
"- name: '@deepseek-ai/dsh-system-prompt'",
|
||||
"- name: '@deepseek-ai/dsh-tools'",
|
||||
"- name: '@deepseek-ai/dsh-pty'",
|
||||
"- name: '@deepseek-ai/dsh-test-sandbox'",
|
||||
"- name: '@deepseek-ai/dsh-sandbox-policy'",
|
||||
' config:',
|
||||
' mode: danger-full-access',
|
||||
` workspaceRoot: ${JSON.stringify(root)}`,
|
||||
"- name: '@deepseek-ai/dsh-pty-local'",
|
||||
' config:',
|
||||
' pollIntervalMs: 10',
|
||||
' exactProbeAfterMs: 20',
|
||||
' idleSilenceMs: 250',
|
||||
' timeoutMs: 2000',
|
||||
' disposeGraceMs: 500',
|
||||
"- name: '@deepseek-ai/dsh-tool-pty'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-agent', AgentRegistry],
|
||||
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
|
||||
['@deepseek-ai/dsh-tools', ToolRegistry],
|
||||
['@deepseek-ai/dsh-pty', PtyService],
|
||||
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
|
||||
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
|
||||
['@deepseek-ai/dsh-pty-local', PtyLocal],
|
||||
['@deepseek-ai/dsh-tool-pty', ToolPty],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
|
||||
const owner = agent(context)
|
||||
const signal = new AbortController().signal
|
||||
const spawn = await context.tools.execute({
|
||||
signal, callId: CallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
|
||||
})
|
||||
expect(resultText(spawn)).toContain('started terminal session pty-1 (main)')
|
||||
|
||||
await context.tools.execute({
|
||||
signal, callId: CallId('state'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner,
|
||||
})
|
||||
const read = await context.tools.execute({
|
||||
signal, callId: CallId('read'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner,
|
||||
})
|
||||
expect(resultText(read)).toContain('cwd=/ keep=loader')
|
||||
expect(context.pty.list(owner)).toHaveLength(1)
|
||||
}, 15_000)
|
||||
})
|
||||
39
packages/pty/tool-pty/tests/render.spec.ts
Normal file
39
packages/pty/tool-pty/tests/render.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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'
|
||||
|
||||
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: '' }))
|
||||
.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' }))
|
||||
.toContain('pty-2 (main)')
|
||||
})
|
||||
|
||||
it('renders running, exited, empty, and truncated sends', () => {
|
||||
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
|
||||
.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 }))
|
||||
.toContain('exited code=null signal=SIGTERM')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
|
||||
.toContain('exited code=2 signal=null')
|
||||
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
|
||||
.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]')
|
||||
expect(renderSendRead({ delta: 'x\n', truncated: true })).toBe('x\n[output truncated]')
|
||||
expect(renderSendRead({ delta: 'x', truncated: false })).toBe('x')
|
||||
})
|
||||
|
||||
it('renders history and every list status shape', () => {
|
||||
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
|
||||
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
|
||||
expect(renderList([])).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')
|
||||
})
|
||||
})
|
||||
246
packages/pty/tool-pty/tests/tools.spec.ts
Normal file
246
packages/pty/tool-pty/tests/tools.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
|
||||
|
||||
function fakeAgent(ctx: Context, rawId: string): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const id = SessionId(rawId)
|
||||
const agent: Agent = {
|
||||
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
|
||||
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
}
|
||||
|
||||
class StubSession implements PtyBackendSession {
|
||||
readonly motd = 'stub prompt'
|
||||
readonly pid = 42
|
||||
statusValue: PtySessionStatus = { kind: 'running' }
|
||||
operation: PtySendOperation | undefined
|
||||
autoSettle = true
|
||||
rejectOperation = false
|
||||
closeGate: PromiseWithResolvers<undefined> | undefined
|
||||
|
||||
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',
|
||||
waitReason: 'stdin_read' as const,
|
||||
sessionStatus: this.statusValue,
|
||||
truncated: false,
|
||||
}))
|
||||
const operation: PtySendOperation = {
|
||||
done,
|
||||
readOutput: () => ({ delta: 'live output', truncated: false }),
|
||||
cancel: () => {
|
||||
if (cancelled) return false
|
||||
cancelled = true
|
||||
settle()
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.operation = operation
|
||||
if (this.rejectOperation) queueMicrotask(() => { reject(new Error('operation failed')) })
|
||||
else if (this.autoSettle) queueMicrotask(settle)
|
||||
return operation
|
||||
}
|
||||
|
||||
read() {
|
||||
return { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
|
||||
}
|
||||
|
||||
async signal(signal: PtySignal) {
|
||||
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 10 : 11 }
|
||||
}
|
||||
|
||||
status() { return this.statusValue }
|
||||
|
||||
async close() {
|
||||
if (this.closeGate !== undefined) await this.closeGate.promise
|
||||
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
|
||||
}
|
||||
}
|
||||
|
||||
function stubBackend() {
|
||||
const sessions: StubSession[] = []
|
||||
const backend: PtyBackend = {
|
||||
type: 'stub',
|
||||
async spawn() {
|
||||
const session = new StubSession()
|
||||
sessions.push(session)
|
||||
return session
|
||||
},
|
||||
}
|
||||
return { backend, sessions }
|
||||
}
|
||||
|
||||
async function setup(tasks: boolean) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(PtyService)
|
||||
const stub = stubBackend()
|
||||
ctx.pty.registerBackend(stub.backend)
|
||||
if (tasks) {
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(ToolTasks)
|
||||
}
|
||||
await ctx.plugin(ToolPty)
|
||||
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
|
||||
}
|
||||
|
||||
let callNumber = 0
|
||||
const testToolSignal = new AbortController().signal
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
|
||||
return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} })
|
||||
}
|
||||
|
||||
function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) {
|
||||
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal })
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
describe('tool-pty foreground surface', () => {
|
||||
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
|
||||
const { ctx, agent } = await setup(false)
|
||||
expect(['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'].every(name => ctx.tools.get(name) !== undefined)).toBe(true)
|
||||
|
||||
const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent)
|
||||
expect(text(spawned)).toContain('started terminal session pty-1 (main)')
|
||||
expect(text(await call(ctx, 'terminal_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42')
|
||||
expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]')
|
||||
expect(text(await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10')
|
||||
const sent = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'echo hi' }, agent)
|
||||
expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]')
|
||||
expect(text(await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent))).toBe('closed terminal session pty-1')
|
||||
expect(text(await call(ctx, 'terminal_list', {}, agent))).toBe('(no terminal sessions)')
|
||||
})
|
||||
|
||||
it('fails without an initiating agent and rejects background before writing', async () => {
|
||||
const { ctx, agent, stub } = await setup(false)
|
||||
expect((await call(ctx, 'terminal_open', { type: 'stub' })).isError).toBe(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
const result = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep 1', run_in_background: true }, agent)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(stub.sessions[0]?.operation).toBeUndefined()
|
||||
})
|
||||
|
||||
it('validates required values and forwards optional spawn/read arguments', async () => {
|
||||
const { ctx, agent } = await setup(false)
|
||||
expect((await call(ctx, 'terminal_open', { type: '' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
|
||||
expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1', offset: 2, count: 3 }, agent))).toContain('history')
|
||||
})
|
||||
|
||||
it('declares terminal presentation only for foreground sends', async () => {
|
||||
const { ctx } = await setup(false)
|
||||
const definition = ctx.tools.get('terminal_send')
|
||||
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'python3' })).toMatchObject({ card: 'terminal', title: 'python3' })
|
||||
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'make', run_in_background: true })).toMatchObject({ card: 'generic' })
|
||||
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: '' })).toMatchObject({ card: 'terminal', title: '(send input)' })
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x', run_in_background: true }, { content: [], isError: false })).toBeUndefined()
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: true })).toBeUndefined()
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: false })).toBeUndefined()
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }], isError: false })).toBeUndefined()
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [undefined as never], isError: false })).toBeUndefined()
|
||||
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'ok' }], isError: false })).toEqual({ card: 'terminal', output: 'ok' })
|
||||
|
||||
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Open terminal stub' })
|
||||
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Open terminal main' })
|
||||
expect(ctx.tools.get('terminal_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal terminal pty-1' })
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pty task integration', () => {
|
||||
it('registers a generic task and exposes incremental output', async () => {
|
||||
const { ctx, agent } = await setup(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1')
|
||||
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
|
||||
expect(text(output)).toContain('live output')
|
||||
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
|
||||
})
|
||||
|
||||
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)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
expect((await callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
|
||||
|
||||
stub.sessions[0]!.autoSettle = false
|
||||
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
|
||||
expect(text(await call(ctx, 'task_kill', { task_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-1' }, agent))).toContain('[status: killed')
|
||||
|
||||
stub.sessions[0]!.rejectOperation = true
|
||||
stub.sessions[0]!.autoSettle = false
|
||||
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-2' }, agent))).toContain('[status: failed')
|
||||
})
|
||||
|
||||
it('reports foreground cancellation after the terminal operation settles', async () => {
|
||||
const { ctx, agent, stub } = await setup(false)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
stub.sessions[0]!.autoSettle = false
|
||||
const controller = new AbortController()
|
||||
const pending = callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
stub.sessions[0]!.operation?.cancel()
|
||||
expect((await pending).isError).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the already-closing kill result', async () => {
|
||||
const { ctx, agent, stub } = await setup(false)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
stub.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
|
||||
const first = ctx.pty.kill(agent, PtySessionId('pty-1'))
|
||||
const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
|
||||
stub.sessions[0]!.closeGate?.resolve(undefined)
|
||||
await first
|
||||
expect(text(await second)).toBe('terminal session pty-1 was already closing')
|
||||
})
|
||||
|
||||
it('renders an exited session detail for background completion', async () => {
|
||||
const { ctx, agent, stub } = await setup(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
|
||||
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
|
||||
expect(text(output)).toContain('session exited: unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pty plugin shape', () => {
|
||||
it('is a named function plugin with no default export', () => {
|
||||
expect('default' in ToolPty).toBe(false)
|
||||
expect(ToolPty.name).toBe('tool-pty')
|
||||
expect(ToolPty.inject).toEqual(['pty', 'tools', 'systemPrompt'])
|
||||
})
|
||||
})
|
||||
39
packages/pty/tool-pty/tsconfig.json
Normal file
39
packages/pty/tool-pty/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../pty"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user