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

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/README.md
README.md: 9c8206464d45b1be1d6ee3861c57c128e77686c5
README.zh.md: 70d081e60a7db61443ed616b64586a93c119a640
README.md: a4f743056b4a524be9623b0f700f37e0534b463f
README.zh.md: c84ad3f1b59afcdbbd111f1b82c57c56aa24fdcf

View File

@@ -2,13 +2,12 @@
English | [中文](README.zh.md)
This family provides persistent, owner-scoped pseudo-terminal sessions for interactive or stateful terminal work. It complements one-shot bash execution.
`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) | Defines the PTY service and session lifecycle | `ctx.pty` |
| [`pty-local/`](pty-local/README.md) | Provides local persistent terminal sessions | registers on `ctx.pty` |
| [`tool-pty/`](tool-pty/README.md) | Exposes PTY session operations to the model | registers on `ctx.tools` |
| [`tool-bash-persistent/`](tool-bash-persistent/README.md) | Exposes a reusable PTY-backed bash tool | registers on `ctx.tools` |
| [`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`) | Shell backend over `ctx.subprocess.spawnTerminal`: readiness detection, bounded terminal state, sandbox policy, and session operations | 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 [persistent PTY decision](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) records the family boundary.
The design and deferred boundaries live in the [persistent PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md).

View File

@@ -2,13 +2,12 @@
[English](README.md) | 中文
本家族为交互式或有状态的终端工作提供持久且限定所有者范围的伪终端会话,是单次 bash 执行的补充。
`PTY` 的全称是 **Pseudo-Terminal(伪终端)**。这项能力提供持久且限定所有者范围的终端会话,适用于需要跨工具调用保留状态或使用交互式 stdin 的工作流。PTY 是单次 bash 与文件系统工具的补充,不会取代后两者更严格的逐操作契约。
| 包 | 职责 | ctx 键 |
|---|---|---|
| [`pty/`](pty/README.md) | 定义 PTY 服务和会话生命周期 | `ctx.pty` |
| [`pty-local/`](pty-local/README.md) | 提供本地持久终端会话 | 注册到 `ctx.pty` |
| [`tool-pty/`](tool-pty/README.md) | 向模型公开 PTY 会话操作 | 注册到 `ctx.tools` |
| [`tool-bash-persistent/`](tool-bash-persistent/README.md) | 公开可复用的 PTY 后端 bash 工具 | 注册到 `ctx.tools` |
| [`pty`](pty/README.md)(`@deepseek-ai/dsh-pty`) | 后端注册表、品牌化 id、精确的 Agent 所有权、会话操作与等待完成的清理 | `ctx.pty` |
| `pty-local`(`@deepseek-ai/dsh-pty-local`) | `ctx.subprocess.spawnTerminal` 之上的 shell 后端:就绪检测、有界终端状态、沙箱策略与会话操作 | 注册到 `ctx.pty` |
| `tool-pty`(`@deepseek-ai/dsh-tool-pty`) | 6 个面向模型的工具,并为后台发送集成通用任务 | 注册到 `ctx.tools` |
[持久 PTY 决策](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md)记录了该家族的边界。
设计与暂缓边界记录在[持久 PTY Agent Note](../../.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md) 中。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md
README.md: ba05495318127b63b3d2a6a60ec743e1ff1c5821
README.zh.md: 81987ea0685d761507b535b7ed6eefa0888fbd54
README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75
README.zh.md: 353c2a4bdac7e8402fc63071dfb6fb85dcff66d5

View File

@@ -2,15 +2,15 @@
English | [中文](README.zh.md)
Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platform fails as unsupported. 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.
Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It starts an interactive shell under the shared `ctx.sandboxPolicy`, retains bounded line-oriented output, and detects readiness while the subprocess provider owns PTY allocation, environment scrubbing, foreground process groups, signalling, and complete terminal-session cleanup. The same PTY backend therefore composes with local or remote execution-world providers.
## 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`. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
## Model Experience
@@ -31,6 +31,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s
## 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.
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness.
- Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer.
- Sessions do not survive harness process exit.

View File

@@ -2,15 +2,15 @@
[English](README.md) | 中文
这个本地 Linux/macOS `node-pty` 后端实现 `ctx.pty`;在其他平台加载时会以不支持为由失败。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,移除形似凭据的环境变量,保留有界的逐行输出,检测就绪状态,并清理以 `node-pty` 子进程为根的已捕获进程树。
这是一个基于 `ctx.subprocess.spawnTerminal`、为 `ctx.pty` 提供的持久 shell 后端。它在共享 `ctx.sandboxPolicy` 下启动交互式 shell,保留有界的逐行输出并检测就绪状态;进程管理提供方则负责 PTY 分配、环境清理、前台进程组、信号发送和完整终端会话清理。因此,同一个 PTY 后端可以与本地或远程执行世界提供方组合。
## 插件(`pty-local`)
该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。Spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。Spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表 dispose(资源释放)时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。在停止 shell 前,系统会确认每个保留的进程身份都已消失,或者在 Linux 上已成为不再执行的僵尸进程;僵尸进程条目视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
## 模型体验
@@ -31,6 +31,6 @@ Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash
## 已知限制与暂缓事项
- 输出按行规范化;不支持全屏备用缓冲区交互。
- Linux 精确探针支持 x64 与 arm64 UAPI 表;其他架构使用提示符标记和静默/超时就绪机制。
- 如果后代进程在清理前守护化并重新设定父进程,它会脱离已捕获的进程树;清理绝不会扩大到启动器 PID 所属的整个 POSIX 会话,因为其中可能包含无关进程。
- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。
- 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的契约,而非这个 PTY 消费方。
- harness 进程退出后,会话无法继续存在。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-pty-local",
"description": "Local node-pty backend for persistent DeepSeek Harness PTY sessions",
"description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -21,12 +21,8 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"scripts/ensure-spawn-helper.mjs",
"lib/types/**/*.d.ts"
],
"scripts": {
"postinstall": "node scripts/ensure-spawn-helper.mjs"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
@@ -39,7 +35,6 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-pty": "^1.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
@@ -50,6 +45,7 @@
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,16 +0,0 @@
/** 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)
}

View File

@@ -1,31 +1,28 @@
/**
* Local persistent PTY backend using public `node-pty` APIs, shared sandbox
* policy, bounded output, platform readiness probes, and process-session cleanup.
* Persistent shell PTY backend over the subprocess terminal primitive, shared
* sandbox policy, bounded output, and provider-owned 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 { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SandboxExecutionPolicy } 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'
import { CONTROLLED_PROMPT } from './sanitize.ts'
export { Config } from './config.ts'
export type { Config as PtyLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'pty-local'
/** Required services: PTY registry plus the one shared confinement policy. */
export const inject = ['pty', 'sandbox', 'sandboxPolicy']
/** Required services: PTY registry, shared confinement policy, and process substrate. */
export const inject = ['pty', 'sandboxPolicy', 'subprocess']
interface SandboxModeFenceState {
pty: Context['pty']
@@ -55,14 +52,14 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
}, { global: true })
}
function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
// node-pty owns the spawn; the base env shares the subprocess seam's scrub.
function childEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
// The subprocess provider supplies its own scrubbed ambient base; these are
// deliberate terminal-specific overrides layered after it.
return {
...scrubbedParentEnv(),
TERM: 'dumb',
PAGER: 'cat',
GIT_PAGER: 'cat',
PS1: 'dsh> ',
PS1: CONTROLLED_PROMPT,
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
BASH_SILENCE_DEPRECATION_WARNING: '1',
DSH_SHELL: '1',
@@ -74,8 +71,31 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
const argv = [config.shellPath, ...config.shellArgs]
if (policy.mode === 'danger-full-access') return argv
const sandbox = ctx.get('sandbox')
if (sandbox === undefined) {
throw new Error(`pty-local: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
}
// Re-state the discriminant because object spread does not preserve its narrowed type.
return ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
return sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
}
// TODO(pty-initialize-race-home): Fold this outer abort race into
// LocalPtySession.initialize when the send-state consolidation lands; the
// session already owns the send lifecycle the race protects.
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
if (signal === undefined) {
await session.initialize(signal)
return
}
const aborted = Promise.withResolvers<never>()
const onAbort = (): void => { aborted.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
signal.throwIfAborted()
await Promise.race([session.initialize(signal), aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
/** Local shell backend registered under the configured type. */
@@ -85,13 +105,13 @@ export class LocalPtyBackend implements PtyBackend {
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
private readonly inspector: ProcessInspector,
private readonly spawnTerminal: typeof nodePty.spawn = nodePty.spawn,
private readonly spawnTerminal: (
spec: SubprocessTerminalSpawnSpec,
) => Promise<SubprocessTerminalHandle> = spec => ctx.subprocess.spawnTerminal(spec),
private readonly createSession: (
terminal: ReturnType<typeof nodePty.spawn>,
inspector: ProcessInspector,
terminal: SubprocessTerminalHandle,
config: ResolvedConfig,
) => LocalPtySession = (terminal, inspector, config) => new LocalPtySession(terminal, inspector, config),
) => LocalPtySession = (terminal, config) => new LocalPtySession(terminal, config),
) {
this.type = config.backendType
}
@@ -101,19 +121,19 @@ export class LocalPtyBackend implements PtyBackend {
ensureSandboxModeFence(this.ctx, spec.owner)
const policy = this.ctx.sandboxPolicy.resolve({ session: spec.owner.session })
const argv = spawnArgv(this.ctx, this.config, policy)
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,
if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
const terminal = await this.spawnTerminal({
argv,
cwd: spec.cwd ?? policy.workspaceRoot,
env: childEnvironment(spec),
}
const terminal = this.spawnTerminal(file, argv.slice(1), options)
const session = this.createSession(terminal, this.inspector, this.config)
rows: this.config.rows,
cols: this.config.cols,
graceMs: this.config.disposeGraceMs,
signal: spec.signal,
})
const session = this.createSession(terminal, this.config)
try {
await session.initialize(spec.signal)
await initializeSession(session, spec.signal)
return session
} catch (error) {
try {
@@ -129,6 +149,5 @@ export class LocalPtyBackend implements PtyBackend {
/** 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))
ctx.pty.registerBackend(new LocalPtyBackend(ctx, config))
}

View File

@@ -1,331 +0,0 @@
/** 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[]
/** Return whether the exact identity remains a non-quiescent process. */
isAlive(identity: ProcessIdentity): boolean
signalGroup(pgid: number, signal: PtySignal): void
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
}
/** 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
state: string
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 state = rest[0] || ''
const parentPid = Number(rest[1])
const pgrp = Number(rest[2])
const session = Number(rest[3])
const tpgid = Number(rest[5])
const started = rest[19]
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|| state.length !== 1 || started === undefined) return undefined
return { pid, parentPid, pgrp, session, state, 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 {
const stat = readLinuxStat(this.internals, identity.pid)
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
}
}
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}`)
}

View File

@@ -5,12 +5,15 @@ import { Buffer } from 'node:buffer'
/** OSC marker emitted by the controlled bash before each prompt. */
export const PROMPT_MARKER_PREFIX = '133;D;'
/** Exact printable prompt emitted after the private marker. */
export const CONTROLLED_PROMPT = 'dsh> '
/** One sanitized chunk plus whether it contained the owned prompt marker. */
export interface SanitizedChunk {
text: string
prompt: boolean
/** Present when printable text followed the latest owned prompt marker. */
promptText?: true
/** Printable text after the latest owned marker in this chunk. */
promptTail?: string
}
/**
@@ -23,7 +26,7 @@ export class TerminalSanitizer {
private discardMode: 'osc' | 'csi' | undefined
private discardOscEscape = false
private trailingCarriageReturn = false
private awaitingPromptText = false
private trackingPromptTail = false
constructor(private readonly maxPendingBytes: number) {}
@@ -36,24 +39,21 @@ export class TerminalSanitizer {
this.pending += this.discardPrefix(chunk)
let text = ''
let prompt = false
let promptText = false
let includePromptTail = this.trackingPromptTail
let promptTail = ''
let index = 0
const appendText = (value: string): boolean => {
const appendText = (value: string): void => {
text += value
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
this.awaitingPromptText = false
return true
}
return false
if (this.trackingPromptTail) promptTail += value
}
while (index < this.pending.length) {
const escape = this.pending.indexOf('\x1b', index)
if (escape < 0) {
promptText = appendText(this.pending.slice(index)) || promptText
appendText(this.pending.slice(index))
index = this.pending.length
break
}
promptText = appendText(this.pending.slice(index, escape)) || promptText
appendText(this.pending.slice(index, escape))
if (escape + 1 >= this.pending.length) {
index = escape
break
@@ -74,8 +74,9 @@ export class TerminalSanitizer {
const content = this.pending.slice(escape + 2, end - terminatorBytes)
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
prompt = true
promptText = false
this.awaitingPromptText = true
this.trackingPromptTail = true
includePromptTail = true
promptTail = ''
}
index = end
continue
@@ -99,7 +100,11 @@ export class TerminalSanitizer {
}
this.pending = this.pending.slice(index)
this.enforcePendingBound()
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
return {
text: this.normalizeText(text),
prompt,
...includePromptTail ? { promptTail } : {},
}
}
/**
@@ -111,7 +116,7 @@ export class TerminalSanitizer {
this.pending = ''
this.discardMode = undefined
this.discardOscEscape = false
this.awaitingPromptText = false
this.trackingPromptTail = false
const normalized = this.normalizeText(text)
if (!this.trailingCarriageReturn) return normalized
this.trailingCarriageReturn = false

View File

@@ -1,8 +1,12 @@
/** Local `node-pty` session: bounded output, readiness, signals, and teardown. */
/** Persistent PTY session over the subprocess seam's terminal primitive. */
import { constants } from 'node:os'
import { Buffer } from 'node:buffer'
import type { IDisposable, IPty } from 'node-pty'
import type {
SubprocessOutcome,
SubprocessTerminalForeground,
SubprocessTerminalHandle,
} from '@deepseek-ai/dsh-subprocess'
import { PtyError } from '@deepseek-ai/dsh-pty'
import type {
PtyBackendSession,
PtyReadRequest,
@@ -17,12 +21,7 @@ import type {
PtyWaitReason,
} from '@deepseek-ai/dsh-pty'
import type { ResolvedConfig } from './config.ts'
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
import { TerminalSanitizer } from './sanitize.ts'
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts'
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
@@ -79,24 +78,32 @@ class LocalSendOperation implements PtySendOperation {
private readonly output: BoundedTextBuffer
private readonly promise: PromiseWithResolvers<PtySendResult>
private finished = false
private cancellationRequested = false
private initialForegroundLeftWait: boolean
private initialForegroundPgid: number | undefined
constructor(
maxBytes: number,
readonly startedAt: number,
private readonly initialForegroundPgid: number | undefined,
initialForegroundWasWaiting: boolean,
private readonly onCancel: () => void,
) {
this.output = new BoundedTextBuffer(maxBytes)
this.promise = Promise.withResolvers<PtySendResult>()
this.initialForegroundLeftWait = !initialForegroundWasWaiting
this.initialForegroundLeftWait = true
}
get done(): Promise<PtySendResult> {
return this.promise.promise
}
get settled(): boolean {
return this.finished
}
get cancelRequested(): boolean {
return this.cancellationRequested
}
append(text: string): void {
if (!this.finished) this.output.append(text)
}
@@ -123,6 +130,11 @@ class LocalSendOperation implements PtySendOperation {
return this.output.consume()
}
setInitialForeground(foreground: SubprocessTerminalForeground | undefined): void {
this.initialForegroundPgid = foreground?.processGroupId
this.initialForegroundLeftWait = foreground?.inputWaiting !== true
}
acceptsStdinWait(pgid: number, waiting: boolean): boolean {
// The same group may still expose the wait that existed before terminal.write.
// Observe every poll so a departure before the exact-settlement threshold
@@ -134,56 +146,59 @@ class LocalSendOperation implements PtySendOperation {
cancel(): boolean {
if (this.finished) return false
this.cancellationRequested = true
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. */
/** Backend session wrapping one provider-owned terminal process. */
export class LocalPtySession implements PtyBackendSession {
motd = ''
readonly pid: number
private readonly decoder = new TextDecoder()
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 readonly outputEnded = Promise.withResolvers<void>()
private readonly completion: Promise<void>
private statusValue: PtySessionStatus = { kind: 'running' }
// TODO(pty-send-state-consolidation): Fold the per-send fields below
// (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/
// activeWrite/pollingReady/polling) into one send-lifecycle owner; the
// cancellation/readiness interplay now has enough pinned tests to carry
// that refactor safely.
private active: LocalSendOperation | undefined
private activeTimer: NodeJS.Timeout | undefined
private activeDeadlineTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private interrupting: LocalSendOperation | undefined
private activeWrite: Promise<boolean> | undefined
private pollingReady: LocalSendOperation | undefined
private polling = false
private promptSeen = false
private promptTextSeen = false
private promptTail = ''
private shellPgid: number | undefined
private initializing = false
private lastOutputAt = Date.now()
private closing = false
private closePromise: Promise<void> | undefined
private transportFailure: Error | undefined
constructor(
private readonly terminal: IPty,
private readonly inspector: ProcessInspector,
private readonly terminal: SubprocessTerminalHandle,
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()
})
terminal.output.on('data', this.onTerminalData)
terminal.output.once('end', this.onTerminalEnd)
terminal.output.once('error', this.onTerminalError)
this.completion = terminal.done.then(
outcome => this.onExit(outcome),
(error: unknown) => { this.onTransportFailure(error) },
)
}
/**
@@ -210,43 +225,95 @@ export class LocalPtySession implements PtyBackendSession {
startSend(request: PtySendRequest): PtySendOperation {
if (this.closing) throw new Error('PTY session is closing')
if (this.statusValue.kind === 'exited') throw new Error('PTY session has exited')
if (this.active !== undefined) throw new Error('PTY session already has an active send')
if (this.active !== undefined) {
const draining = this.activeWrite !== undefined
? ' or draining provider write'
: this.interrupting !== undefined
? ' or draining foreground interrupt'
: ''
throw new PtyError(`PTY session already has an active send${draining}`, 'SEND_ACTIVE')
}
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
const initialForegroundPgid = this.inspector.foregroundPgid(this.pid)
const initialForegroundWasWaiting = initialForegroundPgid !== undefined
&& this.inspector.isStdinWaiting(initialForegroundPgid)
const operation = new LocalSendOperation(
this.config.maxReadBytes,
Date.now(),
initialForegroundPgid,
initialForegroundWasWaiting,
() => { this.interrupt(operation) },
)
this.active = operation
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.resetReadinessEvidence()
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)
this.activeDeadlineTimer = setTimeout(() => {
if (this.active === operation) {
this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation)
}
}, this.config.timeoutMs)
void this.beginSend(operation, request)
return operation
}
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
let foreground: SubprocessTerminalForeground | undefined
try {
foreground = await this.terminal.inspectForeground()
} catch (error: unknown) {
// A pre-write inspection failure while cancellation owns the slot must not
// release it: interruptOnce's in-flight foreground signal could land on a
// successor's foreground group. The interrupt path's post-signal tail
// resumes polling, whose guarded catch propagates a persistent failure.
// A retained settled operation implies that same in-flight interrupt, so
// this guard admits only an unsettled active send.
if (this.active === operation && !this.closing && this.interrupting !== operation) {
this.failActive(error)
}
return
}
try {
if (this.active !== operation || this.closing || this.interrupting === operation) return
operation.setInitialForeground(foreground)
const input = `${request.text}${request.submit ? '\r' : ''}`
if (input.length > 0 && !operation.cancelRequested) {
this.resetReadinessEvidence()
const write = this.terminal.write(input)
this.activeWrite = write.then(() => true, () => false)
try {
await write
} finally {
this.activeWrite = undefined
}
}
// Cancellation owns post-write signalling and reservation release.
if (operation.cancelRequested) return
if (this.active === operation && operation.settled) {
this.clearActive()
return
}
// Closing can race the awaited provider write even though static analysis sees only local assignments.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited provider writes can close the session.
if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation)
}
} catch (error: unknown) {
if (this.active === operation && !this.closing) {
if (operation.settled) this.clearActive()
else this.failActive(error)
}
}
}
private resetReadinessEvidence(): void {
this.lastOutputAt = Date.now()
this.promptSeen = false
this.promptTextSeen = false
this.promptTail = ''
}
read(request: PtyReadRequest): PtyReadResult {
const snapshot = this.scrollback.snapshot()
const lines = snapshot.text.split('\n')
@@ -272,16 +339,10 @@ export class LocalPtySession implements PtyBackendSession {
}
}
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 }
})
async signal(signal: PtySignal): Promise<PtySignalResult> {
if (this.closing) throw new Error('PTY session is closing')
const targetPgid = await this.terminal.signalForeground(signal)
return { delivered: true, targetPgid }
}
status(): PtySessionStatus {
@@ -300,21 +361,56 @@ export class LocalPtySession implements PtyBackendSession {
return closing
}
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
this.onData(this.decoder.decode(bytes, { stream: true }))
}
private readonly onTerminalEnd = (): void => {
this.onData(this.decoder.decode())
this.appendOutput(this.sanitizer.flush())
this.outputEnded.resolve()
}
private readonly onTerminalError = (error: Error): void => {
this.onTransportFailure(error)
this.outputEnded.resolve()
}
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
// TODO(pty-delayed-signal-prompt): With a reproducer, define a marker-generation boundary
// before attributing a signal-delayed prompt to a later send.
// Bash can print PROMPT_COMMAND before the kernel publishes its return
// to the foreground process group. Retain the marker; polling below is
// the authority that accepts it only after bash owns the foreground.
this.promptSeen = true
this.promptTextSeen = sanitized.promptText === true
this.promptTail = ''
this.lastOutputAt = Date.now()
} else if (this.promptSeen && sanitized.promptText === true) {
this.promptTextSeen = true
}
if (this.promptSeen && sanitized.promptTail !== undefined) {
const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - this.promptTail.length)
this.promptTail += sanitized.promptTail.slice(0, remaining)
if (sanitized.promptTail.length > remaining) this.promptTail = `${CONTROLLED_PROMPT}\0`
this.promptTextSeen = this.promptTail === CONTROLLED_PROMPT
}
}
private async onExit(outcome: SubprocessOutcome): Promise<void> {
await this.outputEnded.promise
if (this.transportFailure !== undefined) return
this.statusValue = { kind: 'exited', exitCode: outcome.exitCode, signal: outcome.signal }
this.settleActive('session_exit')
}
private onTransportFailure(error: unknown): void {
const failure = error instanceof Error ? error : new Error(String(error))
this.transportFailure ??= failure
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
this.failActive(failure)
void this.terminal.terminate().catch(() => {})
}
private appendOutput(text: string): void {
@@ -324,63 +420,94 @@ export class LocalPtySession implements PtyBackendSession {
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 && this.promptTextSeen && Date.now() - this.lastOutputAt >= this.config.pollIntervalMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (this.shellPgid !== undefined && pgid === this.shellPgid) {
private schedulePoll(operation: LocalSendOperation, delayMs = this.config.pollIntervalMs): void {
if (this.active !== operation || this.interrupting === operation || this.polling) return
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = setTimeout(() => {
this.activeTimer = undefined
void this.pollReadiness(operation)
}, delayMs)
}
private async pollReadiness(operation: LocalSendOperation): Promise<void> {
if (this.active !== operation || this.polling) return
this.polling = true
try {
if (this.statusValue.kind === 'exited') {
this.settleActive('session_exit')
return
}
const foreground = await this.terminal.inspectForeground()
if (this.active !== operation || this.closing || this.interrupting === operation) return
const idleFor = Date.now() - this.lastOutputAt
if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) {
this.shellPgid = foreground.processGroupId
}
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs
&& foreground?.processGroupId === this.shellPgid) {
this.settleActive('stdin_read')
return
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
const acceptsStdinWait = startupHasOutput && foreground !== undefined
&& operation.acceptsStdinWait(foreground.processGroupId, foreground.inputWaiting)
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
this.settleActive('stdin_read')
return
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
}
} catch (error: unknown) {
if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error)
} finally {
this.polling = false
const active = this.active
// Awaited provider inspection can clear or replace the active send despite static analysis.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- awaited inspection can replace the active send.
if (active !== undefined && this.pollingReady === active) this.schedulePoll(active)
}
const elapsed = Date.now() - operation.startedAt
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
let acceptsStdinWait = false
if (startupHasOutput) {
const pgid = this.inspector.foregroundPgid(this.pid)
acceptsStdinWait = pgid !== undefined
&& operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid))
}
if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) {
this.settleActive('stdin_read')
return
}
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout. When a prompt marker was seen, the
// configured grace holds the fallback past the silence bound so polls in
// that window can observe the foreground handoff and settle as stdin_read.
const idleFor = Date.now() - this.lastOutputAt
const handoffGrace = this.promptSeen ? this.config.handoffGraceMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs + handoffGrace) {
this.settleActive('inferred_idle')
return
}
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
}
private settleActive(waitReason: PtyWaitReason): void {
private settleActive(waitReason: PtyWaitReason, retainOwnership = false): void {
const operation = this.active
if (operation === undefined) return
const scrollbackTruncated = this.scrollback.snapshot().truncated
this.clearActive()
if (retainOwnership) {
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
} else {
this.clearActive()
}
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
}
private stopPolling(): void {
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
this.stopReadinessPolling()
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
this.activeDeadlineTimer = undefined
}
private stopReadinessPolling(): void {
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
this.activeTimer = undefined
this.pollingReady = undefined
}
private clearActive(): void {
const operation = this.active
this.stopPolling()
this.activeAbort?.()
this.activeAbort = undefined
if (this.interrupting === operation) this.interrupting = undefined
this.pollingReady = undefined
this.active = undefined
}
@@ -393,104 +520,46 @@ export class LocalPtySession implements PtyBackendSession {
private interrupt(operation: LocalSendOperation): void {
if (this.active !== operation) return
this.interrupting = operation
this.stopReadinessPolling()
void this.interruptOnce(operation)
}
private async interruptOnce(operation: LocalSendOperation): Promise<void> {
try {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
this.inspector.signalGroup(pgid, 'SIGINT')
const activeWrite = this.activeWrite
if (activeWrite !== undefined && !await activeWrite) return
await this.terminal.signalForeground('SIGINT')
} catch (error: unknown) {
this.failActive(error)
if (this.active === operation && !this.closing) this.onTransportFailure(error)
return
} finally {
if (this.interrupting === operation) this.interrupting = undefined
}
}
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
return members.filter(member => this.inspector.isAlive(member))
}
private descendants(): ProcessIdentity[] {
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
}
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
const deadline = Date.now() + this.config.disposeGraceMs
let survivors = this.survivors(members)
while (survivors.length > 0 && Date.now() < deadline) {
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
survivors = this.survivors(members)
}
return survivors
}
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
for (const member of members) {
try {
this.inspector.signalProcess(member, signal)
} catch (_alreadyExitedDuringSignal) {
// Identity is rechecked by the inspector; a same-tick exit is success.
}
}
}
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
const members: ProcessIdentity[] = []
const seen = new Set<string>()
for (const group of groups) {
for (const member of group) {
const key = JSON.stringify([member.pid, member.started])
if (seen.has(key)) continue
seen.add(key)
members.push(member)
}
}
return members
}
private async stopDescendants(): Promise<ProcessIdentity[]> {
const captured = this.descendants()
this.signalMembers(captured, 'SIGTERM')
const capturedSurvivors = await this.waitForExit(captured)
// A TERM-handling descendant may have forked while winding down. Rescan
// while the shell can still reap every member, then kill both the fresh
// tree and captured survivors that were reparented out of that tree.
const members = this.unionMembers(capturedSurvivors, this.descendants())
this.signalMembers(members, 'SIGKILL')
const survivors = await this.waitForExit(members)
return this.survivors(this.unionMembers(survivors, this.descendants()))
}
private async stopShell(): Promise<void> {
try {
this.terminal.kill('SIGTERM')
} catch (_topLevelAlreadyExitedDuringTerm) {
// The exit notification remains authoritative.
}
if (this.statusValue.kind === 'running') {
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
try {
this.terminal.kill('SIGKILL')
} catch (_topLevelAlreadyExitedDuringKill) {
// The exit notification remains authoritative.
}
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
}
if (this.statusValue.kind === 'running') {
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
if (this.active === operation && operation.settled) {
this.clearActive()
} else if (this.active === operation && !this.closing) {
this.pollingReady = operation
this.schedulePoll(operation, 0)
}
}
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 survivors = await this.stopDescendants()
if (survivors.length > 0) {
throw new Error(`PTY cleanup failed (${reason}); surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
try {
await this.terminal.terminate()
} catch (error: unknown) {
throw new Error(`PTY cleanup failed (${reason})`, { cause: error })
}
await this.stopShell()
// Quiescence is the active send's terminal outcome.
this.settleActive('session_exit')
this.exitDisposable.dispose()
await this.completion
this.terminal.output.off('data', this.onTerminalData)
this.terminal.output.off('end', this.onTerminalEnd)
this.terminal.output.off('error', this.onTerminalError)
if (this.transportFailure !== undefined) throw this.transportFailure
}
}

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ describe('TerminalSanitizer', () => {
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptText: true })
expect(sanitizer.push('D;0\x07dsh> ')).toEqual({ text: 'dsh> ', prompt: true, promptTail: 'dsh> ' })
})
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
@@ -35,8 +35,8 @@ describe('TerminalSanitizer', () => {
it('reports printable prompt text that follows a marker in a later chunk', () => {
const sanitizer = new TerminalSanitizer(64)
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptText: true })
expect(sanitizer.push('\x1b]133;D;0\x07')).toEqual({ text: '', prompt: true, promptTail: '' })
expect(sanitizer.push('dsh> ')).toEqual({ text: 'dsh> ', prompt: false, promptTail: 'dsh> ' })
})
it('bounds and discards unterminated control sequences through their terminators', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,11 @@ export class PtyBackendCleanupError extends AggregateError {
/** 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. */
/**
* Signals the model-facing PTY surface permits for foreground process groups.
* Kept member-identical to `SubprocessTerminalSignal` in
* `@deepseek-ai/dsh-subprocess` without a cross-seam dependency; change both together.
*/
export type PtySignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP'
/** Top-level PTY process status, independent of a send's wait reason. */

View File

@@ -45,6 +45,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -15,6 +15,7 @@ import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
@@ -78,6 +79,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-subprocess-local'",
"- name: '@deepseek-ai/dsh-pty-local'",
' config:',
' pollIntervalMs: 10',
@@ -104,6 +106,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
])

View File

@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",

View File

@@ -16,6 +16,7 @@ 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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
@@ -72,6 +73,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-subprocess-local'",
"- name: '@deepseek-ai/dsh-pty-local'",
' config:',
' pollIntervalMs: 10',
@@ -95,6 +97,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-pty', ToolPty],
])