refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) 中。
|
||||
|
||||
@@ -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: 8a0a60139e27d98c4f506245a204723da997cd4a
|
||||
README.zh.md: 3df845931dac33abecbcd5030a1b204c68acc638
|
||||
|
||||
@@ -2,35 +2,35 @@
|
||||
|
||||
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`, `sandbox`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a 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 printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; 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. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
|
||||
Send cancellation asks the terminal handle to signal the current foreground process group with a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close starts provider-owned TERM-to-KILL whole-session cleanup and awaits quiescence after the terminal outcome. A cleanup failure does not cache a permanently rejected close; a later close retries the provider operation.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Current file policy and indirect consumer
|
||||
### Indirect consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The policy owner contributes capability-neutral `sandbox:policy` context. Through `@deepseek-ai/dsh-tool-pty` or another PTY consumer, the model may also receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
|
||||
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, and cleanup errors.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The current-policy clause is present while this backend is mounted. Retained PTY scrollback is not placed in model history until a consumer returns bounded output.
|
||||
None until a consumer returns bounded backend output. Retained PTY scrollback is not placed in model history by this package.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; consumer results remain append-only.
|
||||
No direct invalidation; the consumer owns prompts, schemas, and appended results.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
|
||||
- Linux exact probes support x64 and arm64 UAPI tables; other architectures use prompt-marker and silence/timeout readiness.
|
||||
- A descendant that daemonizes and reparents before teardown leaves the captured tree; cleanup never broadens to the launcher PID's POSIX session because that can include unrelated processes.
|
||||
- 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.
|
||||
|
||||
@@ -2,35 +2,35 @@
|
||||
|
||||
[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`、`sandbox`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 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 等待事实、静默回退和绝对超时。可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。在停止 shell 前,系统会确认每个保留的进程身份都已消失,或者在 Linux 上已成为不再执行的僵尸进程;僵尸进程条目视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。
|
||||
取消发送时,系统会请求终端句柄向当前前台进程组发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作启动由提供方负责的 TERM→KILL 全会话清理,并在终端结果之后等待完全停稳。清理失败不会缓存成永久拒绝的关闭操作;后续关闭会重试提供方操作。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 当前文件策略与间接消费方
|
||||
### 间接消费方
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。模型通过 `@deepseek-ai/dsh-tool-pty` 或其他 PTY 消费方还可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
|
||||
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因和清理错误。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
装载该后端期间,当前策略子句会一直存在。消费方返回有界输出前,保留的 PTY scrollback 不会进入模型历史。
|
||||
消费方返回有界的后端输出前没有影响。此包不会把保留的 PTY scrollback 放入模型历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;消费方结果保持仅追加。
|
||||
不会直接失效;提示词、schema 与追加结果由消费方负责。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- 输出按行规范化;不支持全屏备用缓冲区交互。
|
||||
- Linux 精确探针支持 x64 与 arm64 UAPI 表;其他架构使用提示符标记和静默/超时就绪机制。
|
||||
- 如果后代进程在清理前守护化并重新设定父进程,它会脱离已捕获的进程树;清理绝不会扩大到启动器 PID 所属的整个 POSIX 会话,因为其中可能包含无关进程。
|
||||
- harness 进程退出后,会话无法继续存在。
|
||||
- 精确 stdin 等待检测取决于挂载的进程管理提供方;无法证明该事实的提供方使用提示符标记和静默/超时就绪机制。
|
||||
- 清理保证遵循 `SubprocessTerminalHandle`;提供方特有缺口属于该实现的契约,而非此 PTY 消费方。
|
||||
- 会话无法跨 harness 进程退出保留。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
/**
|
||||
* 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 { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { createProcessInspector } from './process-inspector.ts'
|
||||
import type { ProcessInspector } from './process-inspector.ts'
|
||||
import { LocalPtySession } from './session.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
@@ -24,8 +20,8 @@ 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', 'sandbox', 'sandboxPolicy', 'subprocess']
|
||||
|
||||
interface SandboxModeFenceState {
|
||||
pty: Context['pty']
|
||||
@@ -55,10 +51,10 @@ 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',
|
||||
@@ -71,11 +67,14 @@ function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv {
|
||||
}
|
||||
}
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, spec: PtyBackendSpawnSpec): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
if (policy.mode === 'danger-full-access') return argv
|
||||
// Re-state the discriminant because object spread does not preserve its narrowed type.
|
||||
return ctx.sandbox.confine(argv, { ...policy, mode: policy.mode }).argv
|
||||
const mode: SandboxMode = effectiveSandboxMode(spec.owner.session.events) ?? ctx.sandboxPolicy.defaultMode
|
||||
if (mode === 'danger-full-access') return argv
|
||||
return ctx.sandbox.confine(argv, {
|
||||
mode: mode,
|
||||
workspaceRoot: ctx.sandboxPolicy.workspaceRoot,
|
||||
}).argv
|
||||
}
|
||||
|
||||
/** Local shell backend registered under the configured type. */
|
||||
@@ -85,13 +84,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
|
||||
}
|
||||
@@ -99,19 +98,18 @@ export class LocalPtyBackend implements PtyBackend {
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<LocalPtySession> {
|
||||
spec.signal?.throwIfAborted()
|
||||
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,
|
||||
cwd: spec.cwd ?? policy.workspaceRoot,
|
||||
const argv = spawnArgv(this.ctx, this.config, spec)
|
||||
if (argv[0] === undefined) throw new Error('pty-local: sandbox returned empty argv')
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? this.ctx.sandboxPolicy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
}
|
||||
const terminal = this.spawnTerminal(file, argv.slice(1), options)
|
||||
const session = this.createSession(terminal, this.inspector, this.config)
|
||||
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)
|
||||
return session
|
||||
@@ -129,6 +127,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))
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/** 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 type {
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
@@ -17,13 +20,8 @@ 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))
|
||||
}
|
||||
|
||||
function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
||||
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false }
|
||||
const chars = Array.from(text)
|
||||
@@ -80,17 +78,16 @@ class LocalSendOperation implements PtySendOperation {
|
||||
private readonly promise: PromiseWithResolvers<PtySendResult>
|
||||
private finished = 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> {
|
||||
@@ -123,6 +120,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
|
||||
@@ -139,27 +141,21 @@ class LocalSendOperation implements PtySendOperation {
|
||||
}
|
||||
}
|
||||
|
||||
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('utf-8', { fatal: true })
|
||||
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' }
|
||||
private active: LocalSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private polling = false
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private shellPgid: number | undefined
|
||||
@@ -167,23 +163,22 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
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) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,14 +208,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (this.active !== undefined) throw new Error('PTY session already has an active send')
|
||||
if (request.signal?.aborted === true) throw new Error('PTY send aborted before write')
|
||||
|
||||
const 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
|
||||
@@ -233,20 +223,28 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
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.config.timeoutMs)
|
||||
void this.beginSend(operation, request)
|
||||
return operation
|
||||
}
|
||||
|
||||
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||
try {
|
||||
const foreground = await this.terminal.inspectForeground()
|
||||
if (this.active !== operation || this.closing) return
|
||||
operation.setInitialForeground(foreground)
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0) await this.terminal.write(Buffer.from(input, 'utf8'))
|
||||
// Closing can race the awaited provider write even though static analysis sees only local assignments.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.active === operation && !this.closing) this.schedulePoll(operation, 0)
|
||||
} catch (error: unknown) {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
}
|
||||
}
|
||||
|
||||
read(request: PtyReadRequest): PtyReadResult {
|
||||
const snapshot = this.scrollback.snapshot()
|
||||
const lines = snapshot.text.split('\n')
|
||||
@@ -272,16 +270,9 @@ 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> {
|
||||
const targetPgid = await this.terminal.signalForeground(signal)
|
||||
return { delivered: true, targetPgid }
|
||||
}
|
||||
|
||||
status(): PtySessionStatus {
|
||||
@@ -300,12 +291,35 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
return closing
|
||||
}
|
||||
|
||||
private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => {
|
||||
try {
|
||||
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
||||
this.onData(this.decoder.decode(bytes, { stream: true }))
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY emitted invalid UTF-8', { cause: error }))
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onTerminalEnd = (): void => {
|
||||
try {
|
||||
this.onData(this.decoder.decode())
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('PTY ended with invalid UTF-8', { cause: error }))
|
||||
} finally {
|
||||
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
|
||||
// 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.
|
||||
@@ -317,6 +331,21 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
this.terminal.terminate()
|
||||
}
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.lastOutputAt = Date.now()
|
||||
@@ -324,44 +353,56 @@ 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.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) 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.failActive(error)
|
||||
} finally {
|
||||
this.polling = false
|
||||
if (this.active === operation) this.schedulePoll(operation)
|
||||
}
|
||||
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 {
|
||||
@@ -373,8 +414,10 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||
this.activeDeadlineTimer = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
@@ -393,104 +436,30 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
private interrupt(operation: LocalSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
try {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
this.inspector.signalGroup(pgid, 'SIGINT')
|
||||
} catch (error: unknown) {
|
||||
this.failActive(error)
|
||||
}
|
||||
}
|
||||
|
||||
private survivors(members: ProcessIdentity[]): ProcessIdentity[] {
|
||||
return members.filter(member => this.inspector.isAlive(member))
|
||||
}
|
||||
|
||||
private descendants(): ProcessIdentity[] {
|
||||
return this.inspector.processTree(this.pid).filter(member => member.pid !== this.pid)
|
||||
}
|
||||
|
||||
private async waitForExit(members: ProcessIdentity[]): Promise<ProcessIdentity[]> {
|
||||
const deadline = Date.now() + this.config.disposeGraceMs
|
||||
let survivors = this.survivors(members)
|
||||
while (survivors.length > 0 && Date.now() < deadline) {
|
||||
await delay(Math.min(25, Math.max(1, deadline - Date.now())))
|
||||
survivors = this.survivors(members)
|
||||
}
|
||||
return survivors
|
||||
}
|
||||
|
||||
private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
for (const member of members) {
|
||||
try {
|
||||
this.inspector.signalProcess(member, signal)
|
||||
} catch (_alreadyExitedDuringSignal) {
|
||||
// Identity is rechecked by the inspector; a same-tick exit is success.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] {
|
||||
const members: ProcessIdentity[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const member of group) {
|
||||
const key = JSON.stringify([member.pid, member.started])
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
members.push(member)
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
private async stopDescendants(): Promise<ProcessIdentity[]> {
|
||||
const captured = this.descendants()
|
||||
this.signalMembers(captured, 'SIGTERM')
|
||||
const capturedSurvivors = await this.waitForExit(captured)
|
||||
// A TERM-handling descendant may have forked while winding down. Rescan
|
||||
// while the shell can still reap every member, then kill both the fresh
|
||||
// tree and captured survivors that were reparented out of that tree.
|
||||
const members = this.unionMembers(capturedSurvivors, this.descendants())
|
||||
this.signalMembers(members, 'SIGKILL')
|
||||
const survivors = await this.waitForExit(members)
|
||||
return this.survivors(this.unionMembers(survivors, this.descendants()))
|
||||
}
|
||||
|
||||
private async stopShell(): Promise<void> {
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
} catch (_topLevelAlreadyExitedDuringTerm) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
try {
|
||||
this.terminal.kill('SIGKILL')
|
||||
} catch (_topLevelAlreadyExitedDuringKill) {
|
||||
// The exit notification remains authoritative.
|
||||
}
|
||||
await Promise.race([this.exitPromise.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (this.statusValue.kind === 'running') {
|
||||
throw new Error(`PTY cleanup failed; surviving pids: ${this.pid}`)
|
||||
}
|
||||
void this.terminal.signalForeground('SIGINT').catch((error: unknown) => {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
})
|
||||
}
|
||||
|
||||
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(', ')}`)
|
||||
this.terminal.terminate()
|
||||
const quiescent = await this.terminal.waitForExit()
|
||||
if (!quiescent) {
|
||||
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
|
||||
}
|
||||
await this.stopShell()
|
||||
// Whole-session cleanup can fail before the top-level process exits. Wait
|
||||
// for it first so that failure is reported instead of blocking forever on
|
||||
// `done`; successful quiescence guarantees `done` can now settle status and
|
||||
// drain the terminal output.
|
||||
await this.completion
|
||||
this.settleActive('session_exit')
|
||||
this.exitDisposable.dispose()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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'
|
||||
import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -11,12 +11,18 @@ 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 {
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@ class RecordingSandbox extends SandboxProvider {
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,28 +44,37 @@ function config(): ResolvedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context, cwd?: string): Agent {
|
||||
function agent(ctx: Context): Agent {
|
||||
const id = SessionId('agent')
|
||||
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
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: () => { output.end() },
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
}
|
||||
|
||||
class StubSubprocessService extends SubprocessService {
|
||||
readonly cwd = '/tmp'
|
||||
readonly runtimeRoot = '/tmp/dsh-runtime'
|
||||
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 +96,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 +111,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)
|
||||
@@ -109,11 +123,11 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
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 +137,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 +145,72 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
} satisfies Partial<PtyBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('resolves session mode and root together before wrapping the shell', async () => {
|
||||
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' },
|
||||
}])
|
||||
})
|
||||
|
||||
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,
|
||||
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
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
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 +224,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', 'sandbox', 'sandboxPolicy', 'subprocess'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -227,6 +234,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)
|
||||
const fiber = await ctx.plugin(ptyLocal, config())
|
||||
expect(ctx.pty.listBackends()).toEqual(['shell'])
|
||||
await fiber.dispose()
|
||||
@@ -240,11 +248,12 @@ 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'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
})
|
||||
@@ -256,17 +265,13 @@ 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(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
@@ -275,7 +280,7 @@ describe('pty-local plugin shape', () => {
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
@@ -304,17 +309,13 @@ 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(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
class FakeTerminal {
|
||||
pid = 123
|
||||
cols = 80
|
||||
rows = 24
|
||||
process = 'bash'
|
||||
handleFlowControl = false
|
||||
writes: string[] = []
|
||||
kills: string[] = []
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
private dataListeners = new Set<(data: string) => void>()
|
||||
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
|
||||
|
||||
readonly onData = (listener: (data: string) => void): IDisposable => {
|
||||
this.dataListeners.add(listener)
|
||||
return { dispose: () => this.dataListeners.delete(listener) }
|
||||
}
|
||||
|
||||
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
|
||||
this.exitListeners.add(listener)
|
||||
return { dispose: () => this.exitListeners.delete(listener) }
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const listener of this.dataListeners) listener(data)
|
||||
}
|
||||
|
||||
emitExit(exitCode = 0, signal?: number): void {
|
||||
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(data)
|
||||
}
|
||||
|
||||
kill(signal?: string): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push(signal ?? 'SIGHUP')
|
||||
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
||||
}
|
||||
|
||||
resize() {}
|
||||
clear() {}
|
||||
pause() {}
|
||||
resume() {}
|
||||
|
||||
asPty(): IPty {
|
||||
return this
|
||||
}
|
||||
}
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSignal,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
ProcessIdentity,
|
||||
ProcessInspector,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
|
||||
class FakeInspector implements ProcessInspector {
|
||||
pgid: number | undefined = 456
|
||||
@@ -84,6 +39,89 @@ class FakeInspector implements ProcessInspector {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTerminal implements SubprocessTerminalHandle {
|
||||
pid = 123
|
||||
readonly output = new PassThrough()
|
||||
readonly writes: string[] = []
|
||||
readonly kills: string[] = []
|
||||
readonly outcome = Promise.withResolvers<SubprocessOutcome>()
|
||||
readonly done = this.outcome.promise
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
quiescent = true
|
||||
waitError: Error | undefined
|
||||
|
||||
constructor(public inspector = new FakeInspector()) {}
|
||||
|
||||
emitData(data: string): void {
|
||||
this.output.write(Buffer.from(data, 'utf8'))
|
||||
}
|
||||
|
||||
emitBytes(data: Uint8Array): void {
|
||||
this.output.write(data)
|
||||
}
|
||||
|
||||
emitError(error: Error): void {
|
||||
this.output.emit('error', error)
|
||||
}
|
||||
|
||||
emitFailure(error: unknown): void {
|
||||
this.output.end()
|
||||
this.outcome.reject(error)
|
||||
}
|
||||
|
||||
emitExit(exitCode = 0, signal?: number): void {
|
||||
this.output.end()
|
||||
this.outcome.resolve({
|
||||
exitCode: signal === undefined || signal === 0 ? exitCode : null,
|
||||
signal: signal === 9 ? 'SIGKILL' : signal === 15 ? 'SIGTERM' : null,
|
||||
})
|
||||
}
|
||||
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(Buffer.from(data).toString('utf8'))
|
||||
}
|
||||
|
||||
async inspectForeground() {
|
||||
const processGroupId = this.inspector.foregroundPgid()
|
||||
return processGroupId === undefined
|
||||
? undefined
|
||||
: { processGroupId, inputWaiting: this.inspector.isStdinWaiting() }
|
||||
}
|
||||
|
||||
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
|
||||
const foreground = await this.inspectForeground()
|
||||
if (foreground === undefined) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
this.inspector.signalGroup(foreground.processGroupId, signal)
|
||||
return foreground.processGroupId
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push('SIGTERM')
|
||||
if (this.autoExitOnKill) this.emitExit(0, 15)
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
if (this.waitError !== undefined) throw this.waitError
|
||||
return this.quiescent
|
||||
}
|
||||
}
|
||||
|
||||
function makeSession(
|
||||
terminal: FakeTerminal,
|
||||
inspector: FakeInspector,
|
||||
resolved: ResolvedConfig,
|
||||
): LocalPtySession {
|
||||
terminal.inspector = inspector
|
||||
return new LocalPtySession(terminal, resolved)
|
||||
}
|
||||
|
||||
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
@@ -108,13 +146,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
|
||||
inspector.waiting = true
|
||||
const operation = session.startSend({ text: 'python3', submit: true })
|
||||
expect(terminal.writes).toEqual(['python3', '\r'])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(terminal.writes).toEqual(['python3\r'])
|
||||
inspector.pgid = 789
|
||||
terminal.emitData('Python\r\n>>> ')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
@@ -126,7 +166,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
inspector.waiting = true
|
||||
@@ -148,7 +188,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({
|
||||
const session = makeSession(terminal, inspector, config({
|
||||
exactProbeAfterMs: 50,
|
||||
idleSilenceMs: 100,
|
||||
timeoutMs: 200,
|
||||
@@ -173,7 +213,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
inspector.pgid = undefined
|
||||
|
||||
@@ -193,7 +233,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const exiting = session.startSend({ text: 'exit', submit: true })
|
||||
terminal.emitExit(7, 9)
|
||||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
|
||||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
@@ -201,13 +241,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const controller = new AbortController()
|
||||
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
|
||||
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
|
||||
controller.abort()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||||
expect(terminal.writes).not.toContain('\x03')
|
||||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||||
@@ -229,14 +271,14 @@ describe('LocalPtySession readiness and output', () => {
|
||||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const startupTerminal = new FakeTerminal()
|
||||
const startup = new LocalPtySession(startupTerminal.asPty(), new FakeInspector(), config())
|
||||
const startup = new LocalPtySession(startupTerminal, config())
|
||||
const initializing = startup.initialize(new AbortController().signal)
|
||||
startupTerminal.emitExit(1)
|
||||
await expect(initializing).rejects.toThrow('exited during startup')
|
||||
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
const operationInternal = operation as unknown as {
|
||||
@@ -247,11 +289,17 @@ describe('LocalPtySession readiness and output', () => {
|
||||
const sessionInternal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
interrupt(operation: PtySendOperation): void
|
||||
schedulePoll(operation: PtySendOperation): void
|
||||
polling: boolean
|
||||
statusValue: PtySessionStatus
|
||||
appendOutput(text: string): void
|
||||
}
|
||||
sessionInternal.appendOutput('')
|
||||
sessionInternal.pollReadiness({} as PtySendOperation)
|
||||
sessionInternal.schedulePoll({} as PtySendOperation)
|
||||
sessionInternal.polling = true
|
||||
sessionInternal.schedulePoll(operation)
|
||||
sessionInternal.polling = false
|
||||
sessionInternal.interrupt({} as PtySendOperation)
|
||||
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
||||
sessionInternal.pollReadiness(operation)
|
||||
@@ -259,13 +307,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
operationInternal.settle('timeout', { kind: 'running' }, false)
|
||||
|
||||
const unknownTerminal = new FakeTerminal()
|
||||
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
|
||||
const unknown = new LocalPtySession(unknownTerminal, config())
|
||||
unknownTerminal.emitExit(1, 999)
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
await vi.waitFor(() => {
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
})
|
||||
|
||||
const cancelTerminal = new FakeTerminal()
|
||||
const cancelInspector = new FakeInspector()
|
||||
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
|
||||
const cancel = makeSession(cancelTerminal, cancelInspector, config())
|
||||
await initialize(cancel, cancelTerminal)
|
||||
const cancellable = cancel.startSend({ text: '', submit: false })
|
||||
cancelInspector.throwGroup = true
|
||||
@@ -275,7 +325,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const missingGroupTerminal = new FakeTerminal()
|
||||
const missingGroupInspector = new FakeInspector()
|
||||
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
|
||||
const missingGroup = makeSession(missingGroupTerminal, missingGroupInspector, config())
|
||||
await initialize(missingGroup, missingGroupTerminal)
|
||||
missingGroupInspector.pgid = undefined
|
||||
const unresolved = missingGroup.startSend({ text: '', submit: false })
|
||||
@@ -286,7 +336,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
let settled = false
|
||||
const initializing = session.initialize().then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
@@ -296,7 +346,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await initializing
|
||||
|
||||
const timeoutTerminal = new FakeTerminal()
|
||||
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
|
||||
const timeout = new LocalPtySession(timeoutTerminal, config())
|
||||
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
@@ -306,7 +356,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
|
||||
@@ -320,7 +370,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
let settled = false
|
||||
const initializing = session.initialize().then(() => { settled = true })
|
||||
|
||||
@@ -338,7 +388,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
@@ -359,7 +409,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ handoffGraceMs: 40 }))
|
||||
const session = makeSession(terminal, inspector, config({ handoffGraceMs: 40 }))
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
@@ -380,7 +430,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'bash -i', submit: true })
|
||||
@@ -390,6 +440,156 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
expect((await operation.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('contains terminal transport failures and preserves the first failure', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
terminal.output.emit('data', 'plain text')
|
||||
terminal.emitError(new Error('output transport failed'))
|
||||
;(session as unknown as { onTransportFailure(error: unknown): void })
|
||||
.onTransportFailure(new Error('later failure'))
|
||||
await expect(operation.done).rejects.toThrow('output transport failed')
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
await expect(session.close('transport')).rejects.toThrow('output transport failed')
|
||||
|
||||
const rejectedTerminal = new FakeTerminal()
|
||||
const rejected = new LocalPtySession(rejectedTerminal, config())
|
||||
const rejectedOperation = rejected.startSend({ text: '', submit: false })
|
||||
rejectedTerminal.emitFailure('raw transport failure')
|
||||
await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 in a data chunk and at stream end', async () => {
|
||||
const chunkTerminal = new FakeTerminal()
|
||||
const chunkSession = new LocalPtySession(chunkTerminal, config())
|
||||
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
|
||||
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
|
||||
await expect(chunkOperation.done).rejects.toThrow('PTY emitted invalid UTF-8')
|
||||
|
||||
const endTerminal = new FakeTerminal()
|
||||
const endSession = new LocalPtySession(endTerminal, config())
|
||||
const endOperation = endSession.startSend({ text: '', submit: false })
|
||||
endTerminal.emitBytes(Uint8Array.from([0xe2]))
|
||||
endTerminal.emitExit()
|
||||
await expect(endOperation.done).rejects.toThrow('PTY ended with invalid UTF-8')
|
||||
})
|
||||
|
||||
it('contains readiness inspection failure and a stale inspection result', async () => {
|
||||
vi.useFakeTimers()
|
||||
const failedTerminal = new FakeTerminal()
|
||||
const failedSession = new LocalPtySession(failedTerminal, config())
|
||||
const failedOperation = failedSession.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
failedTerminal.inspectForeground = async () => { throw new Error('inspect failed') }
|
||||
const failedInternal = failedSession as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): Promise<void>
|
||||
}
|
||||
await failedInternal.pollReadiness(failedOperation)
|
||||
await expect(failedOperation.done).rejects.toThrow('inspect failed')
|
||||
|
||||
const staleTerminal = new FakeTerminal()
|
||||
const staleSession = new LocalPtySession(staleTerminal, config())
|
||||
const staleOperation = staleSession.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const gate = Promise.withResolvers<ReturnType<FakeTerminal['inspectForeground']> extends Promise<infer T> ? T : never>()
|
||||
staleTerminal.inspectForeground = async () => await gate.promise
|
||||
const staleInternal = staleSession as unknown as {
|
||||
active: PtySendOperation | undefined
|
||||
pollReadiness(operation: PtySendOperation): Promise<void>
|
||||
}
|
||||
const polling = staleInternal.pollReadiness(staleOperation)
|
||||
staleInternal.active = undefined
|
||||
gate.resolve({ processGroupId: 456, inputWaiting: false })
|
||||
await polling
|
||||
;(staleOperation as unknown as {
|
||||
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
|
||||
}).settle('timeout', { kind: 'running' }, false)
|
||||
})
|
||||
|
||||
it('contains stale timer, write, inspection, and interrupt continuations', async () => {
|
||||
vi.useFakeTimers()
|
||||
const settle = (operation: PtySendOperation): void => {
|
||||
;(operation as unknown as {
|
||||
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
|
||||
}).settle('timeout', { kind: 'running' }, false)
|
||||
}
|
||||
|
||||
const deadlineTerminal = new FakeTerminal()
|
||||
const deadlineSession = new LocalPtySession(deadlineTerminal, config())
|
||||
const deadlineOperation = deadlineSession.startSend({ text: '', submit: false })
|
||||
;(deadlineSession as unknown as { active: PtySendOperation | undefined }).active = undefined
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
settle(deadlineOperation)
|
||||
|
||||
const writeTerminal = new FakeTerminal()
|
||||
const writeGate = Promise.withResolvers<undefined>()
|
||||
writeTerminal.write = async () => { await writeGate.promise }
|
||||
const writeSession = new LocalPtySession(writeTerminal, config())
|
||||
const writeOperation = writeSession.startSend({ text: 'x', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
;(writeSession as unknown as { closing: boolean }).closing = true
|
||||
writeGate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
settle(writeOperation)
|
||||
|
||||
const beginTerminal = new FakeTerminal()
|
||||
const beginGate = Promise.withResolvers<never>()
|
||||
beginTerminal.inspectForeground = async () => await beginGate.promise
|
||||
const beginSession = new LocalPtySession(beginTerminal, config())
|
||||
const beginOperation = beginSession.startSend({ text: '', submit: false })
|
||||
;(beginSession as unknown as { active: PtySendOperation | undefined }).active = undefined
|
||||
beginGate.reject(new Error('stale begin failure'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
settle(beginOperation)
|
||||
|
||||
const scheduledTerminal = new FakeTerminal()
|
||||
const scheduledSession = new LocalPtySession(scheduledTerminal, config())
|
||||
const scheduledOperation = scheduledSession.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const scheduledInternal = scheduledSession as unknown as {
|
||||
schedulePoll(operation: PtySendOperation, delayMs?: number): void
|
||||
settleActive(reason: 'timeout'): void
|
||||
}
|
||||
scheduledInternal.schedulePoll(scheduledOperation, 5)
|
||||
scheduledInternal.settleActive('timeout')
|
||||
await scheduledOperation.done
|
||||
|
||||
const pollTerminal = new FakeTerminal()
|
||||
const pollSession = new LocalPtySession(pollTerminal, config())
|
||||
const pollOperation = pollSession.startSend({ text: '', submit: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const pollGate = Promise.withResolvers<never>()
|
||||
pollTerminal.inspectForeground = async () => await pollGate.promise
|
||||
const pollInternal = pollSession as unknown as {
|
||||
active: PtySendOperation | undefined
|
||||
pollReadiness(operation: PtySendOperation): Promise<void>
|
||||
}
|
||||
const stalePoll = pollInternal.pollReadiness(pollOperation)
|
||||
pollInternal.active = undefined
|
||||
pollGate.reject(new Error('stale poll failure'))
|
||||
await stalePoll
|
||||
settle(pollOperation)
|
||||
|
||||
const interruptTerminal = new FakeTerminal()
|
||||
const interruptGate = Promise.withResolvers<never>()
|
||||
interruptTerminal.signalForeground = async () => await interruptGate.promise
|
||||
const interruptSession = new LocalPtySession(interruptTerminal, config())
|
||||
const interruptOperation = interruptSession.startSend({ text: '', submit: false })
|
||||
expect(interruptOperation.cancel()).toBe(true)
|
||||
;(interruptSession as unknown as { active: PtySendOperation | undefined }).active = undefined
|
||||
interruptGate.reject(new Error('stale interrupt failure'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
settle(interruptOperation)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
@@ -397,8 +597,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(
|
||||
terminal.asPty(),
|
||||
new FakeInspector(),
|
||||
terminal,
|
||||
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
|
||||
)
|
||||
expect(session.read({})).toMatchObject({ text: '' })
|
||||
@@ -415,7 +614,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
expect(() => session.read({ count: 0 })).toThrow('count')
|
||||
|
||||
const tinyTerminal = new FakeTerminal()
|
||||
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
|
||||
const tiny = new LocalPtySession(tinyTerminal, config({ maxReadBytes: 1 }))
|
||||
await initialize(tiny, tinyTerminal)
|
||||
const tinyOperation = tiny.startSend({ text: '', submit: false })
|
||||
tinyTerminal.emitData('一')
|
||||
@@ -427,32 +626,38 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
|
||||
inspector.pgid = terminal.pid
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
inspector.pgid = undefined
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||||
})
|
||||
|
||||
it('closes idempotently, contains signal races, and reports survivors', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 123, started: 'a' }]
|
||||
inspector.alive.add(123)
|
||||
inspector.throwProcess = true
|
||||
terminal.throwKill = true
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
|
||||
terminal.quiescent = false
|
||||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
|
||||
const closing = session.close('test')
|
||||
expect(session.close('other')).toBe(closing)
|
||||
await expect(closing).rejects.toThrow('surviving pids: 123')
|
||||
await expect(closing).rejects.toThrow('did not reach quiescence')
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('reports cleanup failure without waiting for top-level exit', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.autoExitOnKill = false
|
||||
terminal.waitError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
|
||||
await expect(session.close('survivor')).rejects.toThrow('surviving pids: 456')
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('settles an active send as session_exit when closed mid-operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
|
||||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 50 }))
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
// The shell returns to its prompt while the send is active; a running
|
||||
@@ -467,94 +672,4 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await closing
|
||||
})
|
||||
|
||||
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
let settled = false
|
||||
const closing = session.close('test').then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(terminal.kills).toEqual([])
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await closing
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rescans for descendants forked during TERM before stopping the shell', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
let reads = 0
|
||||
inspector.processTree = () => {
|
||||
reads += 1
|
||||
if (reads === 1) {
|
||||
inspector.alive.add(124)
|
||||
return [{ pid: 124, started: 'first' }]
|
||||
}
|
||||
if (reads === 2) {
|
||||
inspector.alive.add(125)
|
||||
return [{ pid: 125, started: 'late' }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
|
||||
await session.close('test')
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('retains captured survivors that are reparented out of the teardown rescan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const captured = { pid: 124, started: 'captured' }
|
||||
let reads = 0
|
||||
inspector.alive.add(captured.pid)
|
||||
inspector.processTree = () => reads++ === 0 ? [captured] : []
|
||||
inspector.signalProcess = (identity, signal) => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
const closing = session.close('test')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await closing
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('allows teardown to retry after a descendant-survivor failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
|
||||
|
||||
const first = session.close('first')
|
||||
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await rejected
|
||||
expect(terminal.kills).toEqual([])
|
||||
|
||||
inspector.alive.delete(124)
|
||||
const second = session.close('retry')
|
||||
expect(second).not.toBe(first)
|
||||
await second
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user