mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge pull request #2300 from deepseek-harness/feat/pwsh-persistent-pty
feat(pty): persistent pwsh over the PTY seam on Windows
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md
|
||||
2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602
|
||||
2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f
|
||||
@@ -0,0 +1,65 @@
|
||||
# Agent Note: Persistent pwsh over the terminal seam on Windows
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-11-pwsh-persistent-pty.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work.
|
||||
|
||||
The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX.
|
||||
|
||||
Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows.
|
||||
|
||||
## Decision
|
||||
|
||||
A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows.
|
||||
|
||||
### Windows substrate in `@deepseek-ai/dsh-subprocess-local`
|
||||
|
||||
`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only.
|
||||
|
||||
`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend.
|
||||
|
||||
### Shell dialect in `@deepseek-ai/dsh-terminal-bash`
|
||||
|
||||
One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred).
|
||||
|
||||
### `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform.
|
||||
|
||||
Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash.
|
||||
|
||||
### Composition
|
||||
|
||||
The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh.
|
||||
|
||||
### Testing
|
||||
|
||||
The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors.
|
||||
- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap.
|
||||
- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around.
|
||||
- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end.
|
||||
- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text.
|
||||
- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred.
|
||||
- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms.
|
||||
|
||||
**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`.
|
||||
|
||||
**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier.
|
||||
|
||||
**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead.
|
||||
|
||||
**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks.
|
||||
|
||||
**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-11-pwsh-persistent-pty.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。
|
||||
|
||||
这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。
|
||||
|
||||
两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。
|
||||
|
||||
## 决定
|
||||
|
||||
模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。
|
||||
|
||||
### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座
|
||||
|
||||
`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。
|
||||
|
||||
`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。
|
||||
|
||||
### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言
|
||||
|
||||
一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。
|
||||
|
||||
### `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。
|
||||
|
||||
命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。
|
||||
|
||||
### 组合
|
||||
|
||||
minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。
|
||||
|
||||
### 测试
|
||||
|
||||
Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。
|
||||
- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。
|
||||
- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。
|
||||
- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。
|
||||
- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。
|
||||
- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。
|
||||
- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。
|
||||
|
||||
## 后果
|
||||
|
||||
**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。
|
||||
|
||||
**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。
|
||||
|
||||
**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。
|
||||
|
||||
**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。
|
||||
|
||||
**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。
|
||||
|
||||
**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。
|
||||
@@ -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 .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
|
||||
2026-07-16-persistent-pty-sessions.md: 56fb4542bef2f6ad43f6b3a2c94772d54d198534
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 87c7d7c5f41c91e1c57b33e876c13ee6b35f82cc
|
||||
2026-07-16-persistent-pty-sessions.md: 3ca35cdd59f38570be478fddf4213335665556a5
|
||||
2026-07-16-persistent-pty-sessions.zh.md: ae31481560b446b894abd215ba4434f7a504a3ea
|
||||
|
||||
@@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
|
||||
- Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited.
|
||||
- Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol.
|
||||
- Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work.
|
||||
- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics.
|
||||
- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ plugins:
|
||||
- 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。
|
||||
- harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。
|
||||
- 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。
|
||||
- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。
|
||||
- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。
|
||||
|
||||
## 备选方案
|
||||
|
||||
|
||||
@@ -395,7 +395,17 @@ jobs:
|
||||
- name: Install Wine
|
||||
run: |
|
||||
if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
|
||||
sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
|
||||
# The restored archive is the full --download-only closure of
|
||||
# `wine` for this runner image, so installing the .debs directly
|
||||
# with dpkg needs no repository access. apt-get would instead
|
||||
# re-download the same 100+ MB closure from the mirror, which has
|
||||
# stalled the job past its budget on a degraded runner network.
|
||||
# If the archive cannot satisfy the closure, fall back to the apt
|
||||
# network install.
|
||||
if ! sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$HOME"/wine-debs/*.deb; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
|
||||
sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
|
||||
fi
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends --download-only wine
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# The persona is the complete system prompt, so global identity, Web orientation,
|
||||
# tool guidance, and later assembly listeners cannot add prompt text. Runtime
|
||||
# context snapshots are suppressed for this preset, and the model composes only
|
||||
# persistent `bash` and `str_replace_editor`. Context compaction is absent.
|
||||
# the persistent shell (`bash` on POSIX, `pwsh` on win32) and
|
||||
# `str_replace_editor`. Context compaction is absent.
|
||||
|
||||
- id: persona
|
||||
name: '@deepseek-ai/dsh-persona'
|
||||
@@ -15,6 +16,8 @@
|
||||
# The PTY registry is an agent-owned service, so it lives in an entry-local
|
||||
# realm. The backend still consumes the host sandbox policy and subprocess
|
||||
# implementation, while the tool registers into this agent's scoped catalog.
|
||||
# Exactly one shell stack mounts per host: the bash stack gates off win32 and
|
||||
# its pwsh twin gates off POSIX, mirroring the one-shot shell rows.
|
||||
- id: persistent-shell
|
||||
name: cordis:group
|
||||
group: true
|
||||
@@ -26,11 +29,13 @@
|
||||
|
||||
- id: terminal-bash
|
||||
name: '@deepseek-ai/dsh-terminal-bash'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
disabled: !!js process.platform === 'win32'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
description: |-
|
||||
@@ -43,6 +48,27 @@
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
|
||||
|
||||
- id: terminal-pwsh
|
||||
name: '@deepseek-ai/dsh-terminal-bash'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
config:
|
||||
shellDialect: pwsh
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
disabled: !!js process.platform !== 'win32'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
description: |-
|
||||
Run commands in a PowerShell shell
|
||||
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
|
||||
* You don't have access to the internet via this tool.
|
||||
* State is persistent across command calls and discussions with the user.
|
||||
* Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash.
|
||||
* Please avoid commands that may produce a very large amount of output.
|
||||
* Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process.
|
||||
|
||||
# The bare local filesystem shadows the host's sandboxed provider only for this
|
||||
# preset. The editor shares that realm and requires absolute paths.
|
||||
- id: filesystem
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('shipped agent presets gate both shell tools by platform', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => {
|
||||
it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => {
|
||||
const entries: unknown = yaml.load(
|
||||
readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'),
|
||||
{ schema: entryListSchema },
|
||||
@@ -133,5 +133,26 @@ describe('shipped agent presets gate both shell tools by platform', () => {
|
||||
typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === id
|
||||
)), `${id} must be absent from minimal`).toBe(false)
|
||||
}
|
||||
const group = entries.find((entry): entry is Record<string, unknown> => (
|
||||
typeof entry === 'object' && entry !== null && (entry as Record<string, unknown>).id === 'persistent-shell'
|
||||
))
|
||||
if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell')
|
||||
const rows = group.config as unknown[]
|
||||
if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list')
|
||||
const byId = new Map(rows
|
||||
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||
.map(entry => [entry.id, entry]))
|
||||
// The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the
|
||||
// pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on
|
||||
// win32 only — exactly one persistent shell per host.
|
||||
for (const id of ['terminal-bash', 'persistent-bash']) {
|
||||
expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true)
|
||||
expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false)
|
||||
}
|
||||
for (const id of ['terminal-pwsh', 'persistent-pwsh']) {
|
||||
expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false)
|
||||
expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true)
|
||||
}
|
||||
expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: 4528cbd248d237d9a9235e2e76aca07c04d0f71d
|
||||
config-catalog.zh.md: 95f0ff27907884f29d6d86c74427d5a6672c98f9
|
||||
config-catalog.md: b865806bcc4d3e494a0ebf2a9331928991f9e6d1
|
||||
config-catalog.zh.md: 6e4ce4cc4d3bfa856987c0d698fddc1c8c7712a9
|
||||
|
||||
+31
-4
@@ -2387,9 +2387,11 @@ Requires: `terminals` · `sandboxPolicy` · `subprocess`
|
||||
export interface Config {
|
||||
/** Backend registry type (default: `shell`). */
|
||||
backendType?: string
|
||||
/** Interactive shell executable (default: `/bin/bash`). */
|
||||
/** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
|
||||
shellDialect?: ShellDialect
|
||||
/** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
|
||||
shellPath?: string
|
||||
/** Shell arguments (default: `--noprofile --norc -i`). */
|
||||
/** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
|
||||
shellArgs?: string[]
|
||||
/** Terminal rows. */
|
||||
rows?: number
|
||||
@@ -2417,9 +2419,12 @@ export interface Config {
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** One supported interactive shell dialect. */
|
||||
export type ShellDialect = 'bash' | 'pwsh'
|
||||
```
|
||||
|
||||
Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts)
|
||||
Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts)
|
||||
|
||||
<a id="deepseek-aidsh-time-context"></a>
|
||||
|
||||
@@ -2502,7 +2507,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts)
|
||||
Source: [`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-fs"></a>
|
||||
|
||||
@@ -2647,6 +2652,28 @@ export interface Config {
|
||||
|
||||
Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
Requires: `tools` · `terminals`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the persistent pwsh tool. */
|
||||
export interface Config {
|
||||
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
|
||||
backendType?: string
|
||||
/** Wall-clock limit for one command (default 300000). */
|
||||
timeoutMs?: number
|
||||
/** Maximum returned command-output characters before clipping (default 16000). */
|
||||
maxOutputChars?: number
|
||||
/** Model-facing tool description; deployments may describe their environment. */
|
||||
description?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-ralph"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ralph`
|
||||
|
||||
+44
-17
@@ -420,7 +420,7 @@ export interface ConnectionConfig {
|
||||
|
||||
## `@deepseek-ai/dsh-client-hmr`
|
||||
|
||||
需要:`clientModuleHost` · `webServer`
|
||||
需要:`clientModules` · `webServer`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config, validated by the same-named schemastery schema. */
|
||||
@@ -684,7 +684,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-hooks-claude-code`
|
||||
|
||||
需要:`bash`
|
||||
需要:`shell`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: where the CC hook config lives + substitution roots. */
|
||||
@@ -722,7 +722,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-hooks-codex`
|
||||
|
||||
需要:`bash`
|
||||
需要:`shell`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
@@ -749,7 +749,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-apiproxy`
|
||||
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Gateway plugin configuration. */
|
||||
@@ -1385,7 +1385,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-permission-presets`
|
||||
|
||||
需要:`bash` · `approval` · `sessions`
|
||||
需要:`shell` · `approval` · `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** The {@link PermissionPresetService} config: preset table and composition default. */
|
||||
@@ -2383,16 +2383,18 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-terminal-bash`
|
||||
|
||||
需要:`pty` · `sandboxPolicy` · `subprocess`
|
||||
需要:`terminals` · `sandboxPolicy` · `subprocess`
|
||||
|
||||
```ts config-catalog
|
||||
/** Public plugin configuration. */
|
||||
export interface Config {
|
||||
/** Backend registry type (default: `shell`). */
|
||||
backendType?: string
|
||||
/** Interactive shell executable (default: `/bin/bash`). */
|
||||
/** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
|
||||
shellDialect?: ShellDialect
|
||||
/** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
|
||||
shellPath?: string
|
||||
/** Shell arguments (default: `--noprofile --norc -i`). */
|
||||
/** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
|
||||
shellArgs?: string[]
|
||||
/** Terminal rows. */
|
||||
rows?: number
|
||||
@@ -2420,9 +2422,12 @@ export interface Config {
|
||||
/** Grace before teardown escalates to `SIGKILL`. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** One supported interactive shell dialect. */
|
||||
export type ShellDialect = 'bash' | 'pwsh'
|
||||
```
|
||||
|
||||
来源:[`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts)
|
||||
来源:[`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts)
|
||||
|
||||
<a id="deepseek-aidsh-time-context"></a>
|
||||
|
||||
@@ -2473,7 +2478,7 @@ export type TokenMeterConfig = Record<string, never>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash`
|
||||
|
||||
需要:`tools` · `bash` · `systemPrompt` · `bashEnv`
|
||||
需要:`tools` · `shell` · `systemPrompt` · `shellEnv`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the bash tool. */
|
||||
@@ -2489,7 +2494,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash-persistent`
|
||||
|
||||
需要:`tools` · `pty`
|
||||
需要:`tools` · `terminals`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the persistent Bash tool. */
|
||||
@@ -2505,7 +2510,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts)
|
||||
来源:[`packages/shell/tool-bash-persistent/src/index.ts:432`](../packages/shell/tool-bash-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-fs"></a>
|
||||
|
||||
@@ -2584,7 +2589,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-tool-jobs`
|
||||
|
||||
需要:`tools` · `tasks` · `systemPrompt`
|
||||
需要:`tools` · `jobs` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configures bounded `job_output` waits and completion-notice delivery. */
|
||||
@@ -2638,7 +2643,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh`
|
||||
|
||||
需要:`tools` · `bash` · `systemPrompt` · `bashEnv`
|
||||
需要:`tools` · `shell` · `systemPrompt` · `shellEnv`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the pwsh tool. */
|
||||
@@ -2650,11 +2655,33 @@ export interface Config {
|
||||
|
||||
来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
需要:`tools` · `terminals`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the persistent pwsh tool. */
|
||||
export interface Config {
|
||||
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
|
||||
backendType?: string
|
||||
/** Wall-clock limit for one command (default 300000). */
|
||||
timeoutMs?: number
|
||||
/** Maximum returned command-output characters before clipping (default 16000). */
|
||||
maxOutputChars?: number
|
||||
/** Model-facing tool description; deployments may describe their environment. */
|
||||
description?: string
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-tool-ralph"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ralph`
|
||||
|
||||
需要:`tools` · `workflows` · `subagents` · `systemPrompt`
|
||||
需要:`tools` · `workflowEngine` · `subagents` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment policy for the fixed Ralph workflow. */
|
||||
@@ -2834,7 +2861,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-tool-terminal`
|
||||
|
||||
需要:`pty` · `tools` · `systemPrompt`
|
||||
需要:`terminals` · `tools` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Model-facing terminal tool configuration. */
|
||||
@@ -2902,7 +2929,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-tool-workflow`
|
||||
|
||||
需要:`tools` · `workflows` · `systemPrompt`
|
||||
需要:`tools` · `workflowEngine` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config: the model-facing tool name plus result rendering caps. */
|
||||
|
||||
@@ -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 docs/module-graph.md
|
||||
module-graph.md: 46a8ac8380692fb57ad7234e4509a36a8851aae9
|
||||
module-graph.zh.md: 68e89b7375b68c1cda35a72db31add4c71dd6e39
|
||||
module-graph.md: ac5faf74ca156b859859c08d0c9872c332b7fd1c
|
||||
module-graph.zh.md: 71de2ac3d566bec5f878619ca68ea779ddabc387
|
||||
|
||||
@@ -292,6 +292,7 @@ flowchart TD
|
||||
pkg_tool_bash["tool-bash"]
|
||||
pkg_tool_bash_persistent["tool-bash-persistent"]
|
||||
pkg_tool_pwsh["tool-pwsh"]
|
||||
pkg_tool_pwsh_persistent["tool-pwsh-persistent"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
pkg_storage["storage"]
|
||||
@@ -940,6 +941,11 @@ flowchart TD
|
||||
pkg_tool_bash_persistent --> pkg_terminal
|
||||
pkg_tool_bash_persistent --> pkg_timeout
|
||||
pkg_tool_bash_persistent --> pkg_tools
|
||||
pkg_tool_pwsh_persistent --> pkg_agent
|
||||
pkg_tool_pwsh_persistent --> pkg_invariants
|
||||
pkg_tool_pwsh_persistent --> pkg_terminal
|
||||
pkg_tool_pwsh_persistent --> pkg_timeout
|
||||
pkg_tool_pwsh_persistent --> pkg_tools
|
||||
pkg_tool_terminal --> pkg_agent
|
||||
pkg_tool_terminal --> pkg_invariants
|
||||
pkg_tool_terminal --> pkg_jobs
|
||||
@@ -1594,6 +1600,7 @@ flowchart TD
|
||||
| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) |
|
||||
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -294,6 +294,7 @@ flowchart TD
|
||||
pkg_tool_bash["tool-bash"]
|
||||
pkg_tool_bash_persistent["tool-bash-persistent"]
|
||||
pkg_tool_pwsh["tool-pwsh"]
|
||||
pkg_tool_pwsh_persistent["tool-pwsh-persistent"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
pkg_storage["storage"]
|
||||
@@ -942,6 +943,11 @@ flowchart TD
|
||||
pkg_tool_bash_persistent --> pkg_terminal
|
||||
pkg_tool_bash_persistent --> pkg_timeout
|
||||
pkg_tool_bash_persistent --> pkg_tools
|
||||
pkg_tool_pwsh_persistent --> pkg_agent
|
||||
pkg_tool_pwsh_persistent --> pkg_invariants
|
||||
pkg_tool_pwsh_persistent --> pkg_terminal
|
||||
pkg_tool_pwsh_persistent --> pkg_timeout
|
||||
pkg_tool_pwsh_persistent --> pkg_tools
|
||||
pkg_tool_terminal --> pkg_agent
|
||||
pkg_tool_terminal --> pkg_invariants
|
||||
pkg_tool_terminal --> pkg_jobs
|
||||
@@ -1596,6 +1602,7 @@ flowchart TD
|
||||
| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) |
|
||||
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
|
||||
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -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 docs/tool-catalog.md
|
||||
tool-catalog.md: 3ffc2f1c4211b93812e240d2a02a23d70b7ef5fe
|
||||
tool-catalog.zh.md: e5751ae05d019b88f26cd65a9588052b7c73c390
|
||||
tool-catalog.md: 13c56ed6d21c5aaac9909ed839ff111a867805df
|
||||
tool-catalog.zh.md: e1b9581f25103cf5857af6de09bfbe03b3e52993
|
||||
|
||||
@@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. |
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
|
||||
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. |
|
||||
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
|
||||
@@ -527,6 +528,33 @@ Source: [`packages/shell/tool-bash-persistent/src/index.ts`](../packages/shell/t
|
||||
|
||||
One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.
|
||||
|
||||
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
### `pwsh`
|
||||
|
||||
Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The PowerShell command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.
|
||||
|
||||
<a id="deepseek-aidsh-tool-str-replace-editor"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-str-replace-editor`
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 |
|
||||
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
|
||||
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 |
|
||||
| `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-observation-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 |
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
|
||||
@@ -529,6 +530,33 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
|
||||
|
||||
一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。
|
||||
|
||||
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-pwsh-persistent`
|
||||
|
||||
### `pwsh`
|
||||
|
||||
在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The PowerShell command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts)
|
||||
|
||||
一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。
|
||||
|
||||
<a id="deepseek-aidsh-tool-str-replace-editor"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-tool-str-replace-editor`
|
||||
|
||||
@@ -75,6 +75,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
|
||||
const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
|
||||
const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url))
|
||||
const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url))
|
||||
const PERSISTENT_PWSH_CONFIG = fileURLToPath(new URL('./persistent-pwsh.cordis.yml', import.meta.url))
|
||||
const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath(
|
||||
new URL('../background-job-admission.cordis.yml', import.meta.url),
|
||||
)
|
||||
@@ -296,6 +297,15 @@ const SCENARIOS: Scenario[] = [
|
||||
// newline and one recording replays on every host.
|
||||
pwshOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'persistent-pwsh-tool-turn',
|
||||
hasModelTurn: true,
|
||||
recorded: true,
|
||||
pinsHeader: true,
|
||||
headerClass: 'persistent-pwsh',
|
||||
configPath: PERSISTENT_PWSH_CONFIG,
|
||||
pwshOnly: true,
|
||||
},
|
||||
// Authored keyless replay through a test-only partial-Landlock provider:
|
||||
// the exact compatibility notice must stay ordinary stderr when the wrapped
|
||||
// `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Keyless replay counterpart to persistent-pwsh.cordis.yml.
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-terminal'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
- id: terminal-pwsh
|
||||
name: '@deepseek-ai/dsh-terminal-bash'
|
||||
config:
|
||||
shellDialect: pwsh
|
||||
timeoutMs: 30000
|
||||
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-pro
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: none
|
||||
workspaceContext: false
|
||||
skills:
|
||||
enabled: false
|
||||
toolBash: false
|
||||
toolJobs: false
|
||||
goals: false
|
||||
persona: You are a concise snapshot agent working in {{cwd}}.
|
||||
|
||||
- id: tool-pwsh-persistent
|
||||
name: '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
@@ -0,0 +1,42 @@
|
||||
# Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition.
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
models:
|
||||
- id: deepseek-v4-pro
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-terminal'
|
||||
|
||||
- id: terminal-pwsh
|
||||
name: '@deepseek-ai/dsh-terminal-bash'
|
||||
config:
|
||||
shellDialect: pwsh
|
||||
timeoutMs: 30000
|
||||
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-pro
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
|
||||
workspaceContext: false
|
||||
skills:
|
||||
enabled: false
|
||||
toolBash: false
|
||||
toolJobs: false
|
||||
goals: false
|
||||
persona: You are a concise snapshot agent working in {{cwd}}.
|
||||
|
||||
- id: tool-pwsh-persistent
|
||||
name: '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,305],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"","}"]}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":56,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":57,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":58,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}
|
||||
{"type":"tool/result","seq":59,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[58],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":60,"time":1785898456933,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":61,"time":1785898456939,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":63,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":91,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}}
|
||||
{"type":"assistant/chunk","seq":92,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":93,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}}
|
||||
{"type":"assistant/chunk","seq":94,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":95,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":96,"time":1785898456944,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":97,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,3 @@
|
||||
You are an AI agent powered by DeepSeek Harness.
|
||||
|
||||
You are a concise snapshot agent working in {{cwd}}.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"initial": [
|
||||
{
|
||||
"name": "pwsh",
|
||||
"description": "Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The PowerShell command to run. Relative path is preferred in the command."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"changes": []
|
||||
}
|
||||
@@ -96,6 +96,7 @@
|
||||
"@deepseek-ai/dsh-tool-lsp": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-terminal": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-ralph": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:*",
|
||||
|
||||
@@ -223,7 +223,7 @@ async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
|
||||
message.source.kind === 'agent-instructions')
|
||||
expect(context).toBeDefined()
|
||||
return context!
|
||||
})
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop',
|
||||
'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep',
|
||||
'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
|
||||
'list_agents', 'list_agents', 'lsp', 'pwsh', 'ralph',
|
||||
'list_agents', 'list_agents', 'lsp', 'pwsh', 'pwsh', 'ralph',
|
||||
'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete',
|
||||
'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
|
||||
'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',
|
||||
|
||||
@@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const backend = new BashTerminalBackend(ctx, {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
|
||||
backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
|
||||
rows: 24, cols: 80,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
|
||||
pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
|
||||
|
||||
@@ -475,7 +475,17 @@ describe('readTextForDiff', () => {
|
||||
const reached = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let statCalls = 0
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
const allocUnsafe = Buffer.allocUnsafe.bind(Buffer)
|
||||
// Buffer.allocUnsafe is also called by vitest's fork IPC
|
||||
// (node:internal/child_process serialization), so a process-wide call count
|
||||
// is timing-racy under CI load. Attribute allocations to the fsio read path
|
||||
// instead: the abort must prevent the diff-basis buffer allocation.
|
||||
const fsioAllocations: string[] = []
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe').mockImplementation((size: number) => {
|
||||
const stack = new Error().stack ?? ''
|
||||
if (stack.includes('readTextForDiff')) fsioAllocations.push(stack)
|
||||
return allocUnsafe(size)
|
||||
})
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
@@ -509,12 +519,11 @@ describe('readTextForDiff', () => {
|
||||
const controller = new AbortController()
|
||||
const pending = isolatedReadTextForDiff(file, 8, controller.signal)
|
||||
await reached.promise
|
||||
const allocationCalls = allocate.mock.calls.length
|
||||
controller.abort()
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(statCalls).toBe(stage === 'open' ? 0 : 1)
|
||||
expect(allocate).toHaveBeenCalledTimes(allocationCalls)
|
||||
expect(fsioAllocations).toEqual([])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
allocate.mockRestore()
|
||||
|
||||
@@ -186,6 +186,36 @@ function renderShellExitStatus(
|
||||
return appendStatusMarker(content, marker)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exited-session result, reset the owner's shell, and reset the
|
||||
* message that tells the model the next call starts fresh.
|
||||
* @param shells - the owner-scoped registry to reset.
|
||||
* @param status - the exited session status (exit code and signal).
|
||||
* @returns the complete model-facing result.
|
||||
*/
|
||||
async function respondToSessionExit(
|
||||
ctx: Context,
|
||||
shells: PersistentShells,
|
||||
owner: Agent,
|
||||
id: TerminalSessionId,
|
||||
status: { exitCode: number | null; signal: NodeJS.Signals | null },
|
||||
marker: CommandMarkers,
|
||||
fallback: string,
|
||||
fallbackTruncated: boolean,
|
||||
config: ResolvedConfig,
|
||||
): Promise<string> {
|
||||
const snapshot = retainedScrollback(ctx, owner, id)
|
||||
await shells.reset(owner, 'persistent bash shell exited')
|
||||
return [
|
||||
renderShellExitStatus(
|
||||
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
|
||||
status.exitCode,
|
||||
status.signal,
|
||||
),
|
||||
SHELL_RESET_MESSAGE,
|
||||
].filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
|
||||
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
|
||||
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
|
||||
const live = new Map<Agent, TerminalSessionId>()
|
||||
@@ -277,6 +307,15 @@ async function executeCommand(
|
||||
let fallbackTruncated = false
|
||||
|
||||
while (true) {
|
||||
// The shell may flip to exited between iterations (a fast `exit` can
|
||||
// settle the previous send while its exit event is still in flight);
|
||||
// re-observing status before the next send closes that gap.
|
||||
const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
|
||||
if (status?.kind === 'exited') {
|
||||
return await respondToSessionExit(
|
||||
ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config,
|
||||
)
|
||||
}
|
||||
let operation
|
||||
let result
|
||||
try {
|
||||
@@ -319,16 +358,9 @@ async function executeCommand(
|
||||
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
||||
}
|
||||
if (result.sessionStatus.kind === 'exited') {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
await shells.reset(owner, 'persistent bash shell exited')
|
||||
return [
|
||||
renderShellExitStatus(
|
||||
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
|
||||
result.sessionStatus.exitCode,
|
||||
result.sessionStatus.signal,
|
||||
),
|
||||
SHELL_RESET_MESSAGE,
|
||||
].filter(part => part.length > 0).join('\n')
|
||||
return await respondToSessionExit(
|
||||
ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config,
|
||||
)
|
||||
}
|
||||
// The shell reads stdin again (its prompt, or a foreground child's own
|
||||
// read) without having printed the end marker — e.g. `exec`, an interrupt,
|
||||
|
||||
@@ -98,6 +98,7 @@ type StubMode =
|
||||
| 'incremental-fallback'
|
||||
| 'empty-page-after-latest'
|
||||
| 'paged-scrollback'
|
||||
| 'exit-after-send'
|
||||
|
||||
class StubPtySession implements TerminalBackendSession {
|
||||
readonly motd = 'stub> '
|
||||
@@ -109,6 +110,7 @@ class StubPtySession implements TerminalBackendSession {
|
||||
sends = 0
|
||||
pendingText = ''
|
||||
historyTruncated = false
|
||||
throwOnSend = false
|
||||
|
||||
constructor(mode: StubMode) {
|
||||
this.mode = mode
|
||||
@@ -127,6 +129,7 @@ class StubPtySession implements TerminalBackendSession {
|
||||
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'send-error') throw new Error('stub send failed')
|
||||
if (this.throwOnSend) throw new Error('PTY session has exited')
|
||||
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
|
||||
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
@@ -171,6 +174,18 @@ class StubPtySession implements TerminalBackendSession {
|
||||
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
|
||||
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
|
||||
}
|
||||
if (this.mode === 'exit-after-send') {
|
||||
// A fast `exit` settles the send while the exit event is still in
|
||||
// flight; the shell flips to exited before the tool's next poll,
|
||||
// exactly like the real backend. The tool must re-observe status
|
||||
// instead of sending.
|
||||
const output = `${start ?? ''}\n`
|
||||
this.scrollback += output
|
||||
const settled = this.result(output, 'inferred_idle')
|
||||
this.statusValue = { kind: 'exited', exitCode: 9, signal: null }
|
||||
this.throwOnSend = true
|
||||
return this.operation(Promise.resolve(settled))
|
||||
}
|
||||
if (this.mode === 'torn-status') {
|
||||
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
|
||||
this.scrollback += output
|
||||
@@ -395,6 +410,21 @@ describe('tool-bash-persistent', () => {
|
||||
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
|
||||
})
|
||||
|
||||
it('reports the exit path when the shell exits between send settlement and the next poll', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
session.mode = 'exit-after-send'
|
||||
|
||||
const result = text(await call(ctx, owner, 'exit'))
|
||||
expect(result).toContain('[shell exited: code 9]')
|
||||
expect(result).toContain('next bash call starts from the workspace')
|
||||
expect(session.closed).toContain('persistent bash shell exited')
|
||||
|
||||
expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports a shell exit when the backend has no code or signal', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/shell/tool-pwsh-persistent/README.md
|
||||
README.md: a57c940801606c2eef450f0e54fecb434c62a406
|
||||
README.zh.md: 4bd7ecdad08e504daa3ff6283f8b77ac9428385f
|
||||
@@ -0,0 +1,55 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh-persistent
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---:|---|
|
||||
| `backendType` | `shell` | Registered terminal backend used for each Agent shell. |
|
||||
| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. |
|
||||
| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. |
|
||||
| `description` | Persistent-shell description | Model-facing environment contract. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Tool schema
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost while `pwsh` is visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the configured description and schema remain unchanged.
|
||||
|
||||
### Tool results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only tool results follow the reusable request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh).
|
||||
- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`.
|
||||
- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body).
|
||||
- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path.
|
||||
- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell.
|
||||
- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process.
|
||||
- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page.
|
||||
- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation.
|
||||
@@ -0,0 +1,55 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh-persistent
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh` 的 `terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---:|---|
|
||||
| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 |
|
||||
| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 |
|
||||
| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 |
|
||||
| `description` | 持久 shell 描述 | 模型可见的环境契约。 |
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
`pwsh` 可见期间每个请求有固定的 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
配置的 description 与 schema 不变时前缀稳定。
|
||||
|
||||
### 工具结果
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
追加式工具结果跟随可复用的请求前缀。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backend(Windows ConPTY 或 POSIX 上的 pwsh)。
|
||||
- **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。
|
||||
- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。
|
||||
- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。
|
||||
- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。
|
||||
- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。
|
||||
- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker。命令仍可通过可打印提示符和静默档结算,但非 ASCII 输出可能沿用宿主代码页。
|
||||
- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pwsh-persistent",
|
||||
"description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service",
|
||||
"version": "0.1.0-rc.7",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/shell/tool-pwsh-persistent"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:^",
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
/* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty):
|
||||
the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */
|
||||
/**
|
||||
* Model-facing persistent `pwsh` tool over the owner-scoped PTY seam.
|
||||
* @module @deepseek-ai/dsh-tool-pwsh-persistent
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
|
||||
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.</NOTE>'
|
||||
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
|
||||
const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.'
|
||||
const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ '
|
||||
const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT'
|
||||
// One page is enough to find a just-emitted completion marker; the full
|
||||
// scrollback is assembled only when a command settles or needs partial output.
|
||||
const SCROLLBACK_PAGE_LINES = 1_000
|
||||
const POLL_INTERVAL_MS = 25
|
||||
|
||||
const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
|
||||
|
||||
interface ResolvedConfig {
|
||||
backendType: string
|
||||
timeoutMs: number
|
||||
maxOutputChars: number
|
||||
description: string
|
||||
}
|
||||
|
||||
interface CommandMarkers {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
interface RetainedOutput {
|
||||
text: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
interface CapturedOutput {
|
||||
text: string
|
||||
incomplete: boolean
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
interface PersistentShells {
|
||||
get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
|
||||
reset(owner: Agent, reason: string): Promise<void>
|
||||
}
|
||||
|
||||
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
|
||||
if (content.length <= maxOutputChars && !incomplete) return content
|
||||
return content.length <= maxOutputChars
|
||||
? content + TRUNCATED_MESSAGE
|
||||
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
|
||||
}
|
||||
|
||||
function markers(): CommandMarkers {
|
||||
const nonce = randomUUID()
|
||||
return {
|
||||
start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`,
|
||||
end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a command body for embedding in the wrapper's double-quoted string.
|
||||
* Backtick escapes keep every character literal: backtick first so the
|
||||
* escapes this function inserts are never re-escaped, `$` so no expansion
|
||||
* happens at wrapper construction, and `\r\n`/ESC so multi-line commands and
|
||||
* raw control bytes ride one physical input line without PSReadLine mangling.
|
||||
* @param value - the model's PowerShell command text.
|
||||
* @returns the escaped double-quoted-string body.
|
||||
*/
|
||||
function quoteForPwsh(value: string): string {
|
||||
return value
|
||||
.replaceAll('`', '``')
|
||||
.replaceAll('"', '`"')
|
||||
.replaceAll('$', '`$')
|
||||
.replaceAll('\r', '')
|
||||
.replaceAll('\n', '`n')
|
||||
.replaceAll('\x1b', '`e')
|
||||
}
|
||||
|
||||
function wrapCommand(command: string, marker: CommandMarkers): string {
|
||||
// Keep the wrapper on one physical line: PSReadLine renders the echoed
|
||||
// input, and a wrapped line would split the echo the extraction strips.
|
||||
// The echoed END nonce can never fabricate completion because the status
|
||||
// regex needs digits immediately after it and the echo continues with
|
||||
// quote characters.
|
||||
const body = quoteForPwsh(command)
|
||||
return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)`
|
||||
}
|
||||
|
||||
function stripPrompt(text: string): string {
|
||||
let result = text.replace(/\r?\n$/, '')
|
||||
while (result.endsWith(SHELL_PROMPT)) {
|
||||
result = result.slice(0, -SHELL_PROMPT.length)
|
||||
}
|
||||
return result.endsWith('\n') ? result.slice(0, -1) : result
|
||||
}
|
||||
|
||||
function commandOutput(
|
||||
snapshot: RetainedOutput,
|
||||
marker: CommandMarkers,
|
||||
wrapper: string,
|
||||
): CapturedOutput | undefined {
|
||||
const text = snapshot.text
|
||||
const end = text.lastIndexOf(marker.end)
|
||||
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
|
||||
if (status === undefined) return undefined
|
||||
const startMarker = text.lastIndexOf(marker.start, end)
|
||||
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
|
||||
let captured = text.slice(start, end)
|
||||
// The PSReadLine echo carries the wrapper source (including both marker
|
||||
// nonces) before the real markers; anchor on the real markers excludes it,
|
||||
// and stripping the wrapper covers the rare case where the real START
|
||||
// scrolled out and extraction fell back to the echoed copy.
|
||||
captured = captured.replaceAll(wrapper, '')
|
||||
return {
|
||||
text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''),
|
||||
incomplete: startMarker < 0,
|
||||
exitCode: Number(status),
|
||||
}
|
||||
}
|
||||
|
||||
function promptCompleted(result: TerminalSendResult): boolean {
|
||||
return result.viewport.endsWith(SHELL_PROMPT)
|
||||
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|
||||
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
|
||||
}
|
||||
|
||||
function partialOutput(
|
||||
snapshot: RetainedOutput,
|
||||
marker: CommandMarkers,
|
||||
wrapper: string,
|
||||
fallback: string,
|
||||
fallbackTruncated = false,
|
||||
): CapturedOutput {
|
||||
const startMarker = snapshot.text.lastIndexOf(marker.start)
|
||||
if (startMarker >= 0) {
|
||||
return {
|
||||
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
|
||||
incomplete: false,
|
||||
}
|
||||
}
|
||||
const fallbackStart = fallback.lastIndexOf(marker.start)
|
||||
const afterStart = fallbackStart < 0
|
||||
? fallback
|
||||
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
|
||||
const fallbackEnd = afterStart.lastIndexOf(marker.end)
|
||||
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
|
||||
return {
|
||||
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')),
|
||||
incomplete: fallbackTruncated || fallbackStart < 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
}
|
||||
|
||||
function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
|
||||
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
|
||||
return page.lineEnd
|
||||
}
|
||||
|
||||
function retainedScrollback(
|
||||
ctx: Context,
|
||||
owner: Agent,
|
||||
id: TerminalSessionId,
|
||||
latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
|
||||
): RetainedOutput {
|
||||
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
|
||||
let offset = latest.lineEnd
|
||||
let truncated = latest.truncated
|
||||
while (true) {
|
||||
if (offset >= latest.totalLines) break
|
||||
const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
|
||||
truncated ||= page.truncated
|
||||
if (page.text.length > 0) pages.unshift(page.text)
|
||||
const next = nextScrollbackOffset(page, offset)
|
||||
if (next === undefined || next >= page.totalLines) break
|
||||
offset = next
|
||||
}
|
||||
return { text: pages.join('\n'), truncated }
|
||||
}
|
||||
|
||||
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
|
||||
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
|
||||
const withPrefix = output.incomplete && output.text.length > 0
|
||||
? LOST_PREFIX_MESSAGE + rendered
|
||||
: rendered
|
||||
const marker = output.exitCode !== undefined && output.exitCode !== 0
|
||||
? `[exit code: ${output.exitCode}]`
|
||||
: undefined
|
||||
return appendStatusMarker(withPrefix, marker)
|
||||
}
|
||||
|
||||
function appendStatusMarker(content: string, marker: string | undefined): string {
|
||||
if (marker === undefined) return content
|
||||
return content.length === 0 ? marker : `${content}\n${marker}`
|
||||
}
|
||||
|
||||
function renderShellExitStatus(
|
||||
content: string,
|
||||
exitCode: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): string {
|
||||
const marker = signal !== null
|
||||
? `[shell killed by signal: ${signal}]`
|
||||
: exitCode !== null
|
||||
? `[shell exited: code ${exitCode}]`
|
||||
: '[shell exited]'
|
||||
return appendStatusMarker(content, marker)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exited-session result, reset the owner's shell, and reset the
|
||||
* message that tells the model the next call starts fresh.
|
||||
* @param shells - the owner-scoped registry to reset.
|
||||
* @param status - the exited session status (exit code and signal).
|
||||
* @returns the complete model-facing result.
|
||||
*/
|
||||
async function respondToSessionExit(
|
||||
ctx: Context,
|
||||
shells: PersistentShells,
|
||||
owner: Agent,
|
||||
id: TerminalSessionId,
|
||||
status: { exitCode: number | null; signal: NodeJS.Signals | null },
|
||||
marker: CommandMarkers,
|
||||
wrapped: string,
|
||||
fallback: string,
|
||||
fallbackTruncated: boolean,
|
||||
config: ResolvedConfig,
|
||||
): Promise<string> {
|
||||
const snapshot = retainedScrollback(ctx, owner, id)
|
||||
await shells.reset(owner, 'persistent pwsh shell exited')
|
||||
return [
|
||||
renderShellExitStatus(
|
||||
renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars),
|
||||
status.exitCode,
|
||||
status.signal,
|
||||
),
|
||||
SHELL_RESET_MESSAGE,
|
||||
].filter(part => part.length > 0).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The pwsh prompt function that overrides the backend bootstrap value with
|
||||
* this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime
|
||||
* because raw ESC characters in submitted input are unreliable under
|
||||
* PSReadLine.
|
||||
*/
|
||||
const PWSH_PROMPT_SETUP =
|
||||
"function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }"
|
||||
|
||||
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
|
||||
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
|
||||
const live = new Map<Agent, TerminalSessionId>()
|
||||
const creating = new Set<Promise<TerminalSessionId>>()
|
||||
const ownerCleanupInstalled = new WeakSet<Agent>()
|
||||
const lifecycle = new AbortController()
|
||||
|
||||
const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
|
||||
if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
|
||||
await ctx.terminals.kill(owner, id, reason)
|
||||
}
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation'))
|
||||
await Promise.allSettled([...creating])
|
||||
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') })
|
||||
await Promise.all(closing)
|
||||
live.clear()
|
||||
}, 'tool-pwsh-persistent shell cleanup')
|
||||
|
||||
const reset = async (owner: Agent, reason: string): Promise<void> => {
|
||||
pending.delete(owner)
|
||||
const id = live.get(owner)
|
||||
live.delete(owner)
|
||||
if (id !== undefined) await close(owner, id, reason)
|
||||
}
|
||||
|
||||
const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
|
||||
const existing = pending.get(owner)
|
||||
if (existing !== undefined) return existing
|
||||
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
|
||||
const creation = (async () => {
|
||||
try {
|
||||
const cwd = owner.session.header.cwd
|
||||
const spawned = await ctx.terminals.spawn(owner, {
|
||||
type: config.backendType,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
}, combinedSignal)
|
||||
live.set(owner, spawned.sessionId)
|
||||
if (!ownerCleanupInstalled.has(owner)) {
|
||||
ownerCleanupInstalled.add(owner)
|
||||
owner.ctx.effect(() => () => {
|
||||
pending.delete(owner)
|
||||
live.delete(owner)
|
||||
}, 'tool-pwsh-persistent owner cache cleanup')
|
||||
}
|
||||
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
|
||||
text: PWSH_PROMPT_SETUP,
|
||||
submit: true,
|
||||
signal: combinedSignal,
|
||||
})
|
||||
const result = await setup.done
|
||||
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
|
||||
throw new Error('persistent pwsh shell did not accept initialization')
|
||||
}
|
||||
return spawned.sessionId
|
||||
} catch (error: unknown) {
|
||||
await reset(owner, 'persistent pwsh initialization failed')
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
const tracked = creation.finally(() => {
|
||||
creating.delete(tracked)
|
||||
})
|
||||
creating.add(tracked)
|
||||
pending.set(owner, tracked)
|
||||
return tracked
|
||||
}
|
||||
|
||||
return { get, reset }
|
||||
}
|
||||
|
||||
async function executeCommand(
|
||||
ctx: Context,
|
||||
shells: PersistentShells,
|
||||
owner: Agent,
|
||||
command: string,
|
||||
config: ResolvedConfig,
|
||||
upstream: AbortSignal,
|
||||
): Promise<string> {
|
||||
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
|
||||
const id = await shells.get(owner, commandDeadline.signal)
|
||||
const marker = markers()
|
||||
const wrapped = wrapCommand(command, marker)
|
||||
let first = true
|
||||
let fallback = ''
|
||||
let fallbackTruncated = false
|
||||
|
||||
while (true) {
|
||||
// The shell may flip to exited between iterations (a fast `exit` can
|
||||
// settle the previous send while its exit event is still in flight, and
|
||||
// the echoed wrapper can then carry a marker end without status digits);
|
||||
// re-observing status before the next send closes that gap.
|
||||
const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status
|
||||
if (status?.kind === 'exited') {
|
||||
return await respondToSessionExit(
|
||||
ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config,
|
||||
)
|
||||
}
|
||||
let operation
|
||||
let result
|
||||
try {
|
||||
operation = ctx.terminals.startSend(owner, id, {
|
||||
text: first ? wrapped : '',
|
||||
submit: first,
|
||||
signal: commandDeadline.signal,
|
||||
})
|
||||
first = false
|
||||
result = await operation.done
|
||||
} catch (error: unknown) {
|
||||
await shells.reset(owner, 'persistent pwsh send failed')
|
||||
throw error
|
||||
}
|
||||
const incremental = operation.readOutput()
|
||||
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
|
||||
fallbackTruncated ||= incremental.truncated || result.truncated
|
||||
const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
|
||||
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
|
||||
if (timedOut !== undefined) {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
const partial = renderCaptured(
|
||||
partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
|
||||
config.maxOutputChars,
|
||||
)
|
||||
await shells.reset(owner, 'persistent pwsh command timed out')
|
||||
return [
|
||||
// TODO: Report a timeout only; this signal does not establish an OOM.
|
||||
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
|
||||
partial,
|
||||
SHELL_RESET_MESSAGE,
|
||||
].join('\n')
|
||||
}
|
||||
if (commandDeadline.signal.aborted) {
|
||||
await shells.reset(owner, 'persistent pwsh command aborted')
|
||||
commandDeadline.signal.throwIfAborted()
|
||||
}
|
||||
if (latest.text.includes(marker.end)) {
|
||||
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped)
|
||||
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
|
||||
}
|
||||
if (result.sessionStatus.kind === 'exited') {
|
||||
return await respondToSessionExit(
|
||||
ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config,
|
||||
)
|
||||
}
|
||||
if (promptCompleted(result)) {
|
||||
const snapshot = retainedScrollback(ctx, owner, id, latest)
|
||||
return renderCaptured(
|
||||
partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated),
|
||||
config.maxOutputChars,
|
||||
)
|
||||
}
|
||||
await pause()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the model-facing persistent `pwsh` tool.
|
||||
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
|
||||
* @param config - selected PTY backend and command deadline.
|
||||
*/
|
||||
function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void {
|
||||
const shells = persistentShells(ctx, config)
|
||||
const queues = new WeakMap<Agent, Promise<void>>()
|
||||
|
||||
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
|
||||
const prior = queues.get(owner) ?? Promise.resolve()
|
||||
const run = prior.then(operation, operation)
|
||||
const tail = run.then(() => undefined, () => undefined)
|
||||
queues.set(owner, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (queues.get(owner) === tail) queues.delete(owner)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: config.description,
|
||||
parameters: {
|
||||
command: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The PowerShell command to run. Relative path is preferred in the command.',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
|
||||
const owner = exec.agent
|
||||
if (owner === undefined) throw new Error('pwsh requires an owning agent session')
|
||||
return serialized(owner, async () => {
|
||||
exec.signal.throwIfAborted()
|
||||
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
|
||||
})
|
||||
},
|
||||
presentCall: args => ({ card: 'terminal', title: args.command }),
|
||||
}))
|
||||
}
|
||||
|
||||
export const name = 'tool-pwsh-persistent'
|
||||
export const inject = ['tools', 'terminals']
|
||||
|
||||
/** Configuration for the persistent pwsh tool. */
|
||||
export interface Config {
|
||||
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
|
||||
backendType?: string
|
||||
/** Wall-clock limit for one command (default 300000). */
|
||||
timeoutMs?: number
|
||||
/** Maximum returned command-output characters before clipping (default 16000). */
|
||||
maxOutputChars?: number
|
||||
/** Model-facing tool description; deployments may describe their environment. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the persistent pwsh tool. */
|
||||
export const Config: z<Config> = z.object({
|
||||
backendType: z.string().default('shell'),
|
||||
timeoutMs: z.number().default(300_000),
|
||||
maxOutputChars: z.number().default(16_000),
|
||||
description: z.string().default(DEFAULT_DESCRIPTION),
|
||||
})
|
||||
|
||||
/** Register one owner-scoped persistent `pwsh` tool. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = {
|
||||
backendType: config.backendType ?? 'shell',
|
||||
timeoutMs: config.timeoutMs ?? 300_000,
|
||||
maxOutputChars: config.maxOutputChars ?? 16_000,
|
||||
description: config.description ?? DEFAULT_DESCRIPTION,
|
||||
}
|
||||
if (resolved.backendType.trim().length === 0) {
|
||||
throw new Error('tool-pwsh-persistent: backendType must be non-empty')
|
||||
}
|
||||
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
|
||||
throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
|
||||
throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer')
|
||||
}
|
||||
if (resolved.description.trim().length === 0) {
|
||||
throw new Error('tool-pwsh-persistent: description must be non-empty')
|
||||
}
|
||||
registerPersistentPwsh(ctx, resolved)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`.
|
||||
* @module @deepseek-ai/dsh-tool-pwsh-persistent/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-pwsh-persistent-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the adapter's private owner-to-shell cache has no
|
||||
* observable event or data relation. Lifecycle tests prove its cleanup without
|
||||
* adding a public API solely for an invariant.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,167 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
|
||||
import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash'
|
||||
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 { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
|
||||
const hasPwsh = spawnSync(
|
||||
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
|
||||
{ encoding: 'utf8' },
|
||||
).status === 0
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await context?.fiber.dispose()
|
||||
context = undefined
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
})
|
||||
|
||||
class PassthroughSandbox extends SandboxProvider {
|
||||
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context, cwd: string): Agent {
|
||||
const id = SessionId('persistent-pwsh-loader-agent')
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
|
||||
const value: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: scope.ctx,
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => {
|
||||
it('preserves cwd and environment across calls', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
"- name: '@deepseek-ai/dsh-agent'",
|
||||
"- name: '@deepseek-ai/dsh-system-prompt'",
|
||||
"- name: '@deepseek-ai/dsh-tools'",
|
||||
"- name: '@deepseek-ai/dsh-terminal'",
|
||||
"- name: '@deepseek-ai/dsh-test-sandbox'",
|
||||
"- name: '@deepseek-ai/dsh-sandbox-policy'",
|
||||
' config:',
|
||||
' mode: danger-full-access',
|
||||
` workspaceRoot: ${JSON.stringify(root)}`,
|
||||
"- name: '@deepseek-ai/dsh-subprocess-local'",
|
||||
"- name: '@deepseek-ai/dsh-terminal-bash'",
|
||||
' config:',
|
||||
' shellDialect: pwsh',
|
||||
' pollIntervalMs: 10',
|
||||
' exactProbeAfterMs: 20',
|
||||
' idleSilenceMs: 300',
|
||||
' handoffGraceMs: 300',
|
||||
' scrollbackLines: 20000',
|
||||
' timeoutMs: 8000',
|
||||
' disposeGraceMs: 500',
|
||||
"- name: '@deepseek-ai/dsh-tool-pwsh-persistent'",
|
||||
' config:',
|
||||
' timeoutMs: 20000',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-agent', AgentRegistry],
|
||||
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
|
||||
['@deepseek-ai/dsh-tools', ToolRegistry],
|
||||
['@deepseek-ai/dsh-terminal', TerminalSessionService],
|
||||
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
|
||||
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
|
||||
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService],
|
||||
['@deepseek-ai/dsh-terminal-bash', TerminalBash],
|
||||
['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
|
||||
await context.loader.await()
|
||||
|
||||
const owner = agent(context, root)
|
||||
const signal = new AbortController().signal
|
||||
const execute = (id: string, command: string) => context!.tools.execute({
|
||||
signal,
|
||||
callId: CallId(id),
|
||||
name: 'pwsh',
|
||||
arguments: { command },
|
||||
agent: owner,
|
||||
})
|
||||
|
||||
expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh'])
|
||||
await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested')
|
||||
const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"'))
|
||||
expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
|
||||
expect(observed).not.toContain('DSH_PERSISTENT_PWSH')
|
||||
|
||||
const multiline = text(await execute(
|
||||
'multiline',
|
||||
'$value = "line one"\nWrite-Output "${value}:it\'s fine"',
|
||||
))
|
||||
expect(multiline).toBe("line one:it's fine")
|
||||
expect(multiline).not.toContain('DSH_PERSISTENT_PWSH')
|
||||
|
||||
const hereString = text(await execute(
|
||||
'here-string',
|
||||
"$h = @'\nalpha\nbeta\n'@\nWrite-Output $h",
|
||||
))
|
||||
expect(hereString).toBe('alpha\nbeta')
|
||||
|
||||
const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }'))
|
||||
expect(large.startsWith('1\n2\n3\n')).toBe(true)
|
||||
expect(large).toContain('<response clipped>')
|
||||
expect(large).not.toContain('beginning of this command output was dropped')
|
||||
|
||||
const exited = text(await execute('exit', 'exit'))
|
||||
expect(exited).toContain('next pwsh call starts from the workspace')
|
||||
expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,636 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
|
||||
import type {
|
||||
TerminalBackend,
|
||||
TerminalBackendSession,
|
||||
TerminalReadRequest,
|
||||
TerminalSendOperation,
|
||||
TerminalSendRequest,
|
||||
TerminalSessionStatus,
|
||||
TerminalSignal,
|
||||
TerminalWaitReason,
|
||||
} from '@deepseek-ai/dsh-terminal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
|
||||
const contexts: Context[] = []
|
||||
let callNumber = 0
|
||||
|
||||
afterEach(async () => {
|
||||
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
function agent(ctx: Context, cwd: string | undefined): Agent {
|
||||
const id = SessionId(`persistent-pwsh-owner-${callNumber}`)
|
||||
const scope = ctx.plugin(() => {})
|
||||
const session = Session.create(id, [], {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: 0,
|
||||
...cwd === undefined ? {} : { cwd },
|
||||
})
|
||||
const value: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session,
|
||||
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: scope.ctx,
|
||||
send: () => {},
|
||||
followup: () => {},
|
||||
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
|
||||
inject: () => {},
|
||||
cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
}
|
||||
|
||||
function call(
|
||||
ctx: Context,
|
||||
owner: Agent | undefined,
|
||||
command: string,
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId(`persistent-pwsh-${++callNumber}`),
|
||||
name: 'pwsh',
|
||||
arguments: { command },
|
||||
...owner === undefined ? {} : { agent: owner },
|
||||
})
|
||||
}
|
||||
|
||||
type StubMode =
|
||||
| 'normal'
|
||||
| 'prompt-only'
|
||||
| 'prompt-crlf'
|
||||
| 'empty-read'
|
||||
| 'stalled-read'
|
||||
| 'exit'
|
||||
| 'signal-exit'
|
||||
| 'unknown-exit'
|
||||
| 'wait-for-abort'
|
||||
| 'end-on-abort'
|
||||
| 'idle-then-normal'
|
||||
| 'large'
|
||||
| 'nonzero'
|
||||
| 'torn-status'
|
||||
| 'finish-torn-status'
|
||||
| 'end-only'
|
||||
| 'init-exit'
|
||||
| 'init-timeout'
|
||||
| 'spawn-error'
|
||||
| 'send-error'
|
||||
| 'prompt-after-idle'
|
||||
| 'incremental-fallback'
|
||||
| 'empty-page-after-latest'
|
||||
| 'paged-scrollback'
|
||||
| 'with-echo'
|
||||
| 'exit-after-send'
|
||||
| 'prompt-collision'
|
||||
|
||||
const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/
|
||||
const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/
|
||||
|
||||
class StubTerminalSession implements TerminalBackendSession {
|
||||
readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ '
|
||||
readonly pid = 123
|
||||
statusValue: TerminalSessionStatus = { kind: 'running' }
|
||||
scrollback = this.motd
|
||||
closed: string[] = []
|
||||
mode: StubMode
|
||||
sends = 0
|
||||
pendingText = ''
|
||||
historyTruncated = false
|
||||
throwOnSend = false
|
||||
|
||||
constructor(mode: StubMode) {
|
||||
this.mode = mode
|
||||
}
|
||||
|
||||
startSend(request: TerminalSendRequest): TerminalSendOperation {
|
||||
this.sends += 1
|
||||
if (request.text.startsWith('function prompt')) {
|
||||
if (this.mode === 'init-exit') {
|
||||
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
|
||||
return this.operation(Promise.resolve(this.result('', 'session_exit')))
|
||||
}
|
||||
if (this.mode === 'init-timeout') {
|
||||
return this.operation(Promise.resolve(this.result('', 'timeout')))
|
||||
}
|
||||
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'send-error') throw new Error('stub send failed')
|
||||
if (this.throwOnSend) throw new Error('PTY session has exited')
|
||||
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
|
||||
const done = new Promise<ReturnType<StubTerminalSession['result']>>((resolve) => {
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
const start = START_PATTERN.exec(request.text)?.[0]
|
||||
const end = END_PATTERN.exec(request.text)?.[0]
|
||||
const output = this.mode === 'end-on-abort'
|
||||
? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
|
||||
: 'partial output'
|
||||
this.scrollback += output
|
||||
resolve(this.result(output, 'stdin_read'))
|
||||
}, { once: true })
|
||||
})
|
||||
return this.operation(done)
|
||||
}
|
||||
if (this.mode === 'idle-then-normal') {
|
||||
this.mode = 'normal'
|
||||
this.pendingText = request.text
|
||||
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
|
||||
}
|
||||
if (this.mode === 'prompt-after-idle') {
|
||||
if (request.text.length > 0) {
|
||||
const start = START_PATTERN.exec(request.text)?.[0]
|
||||
const output = `${start ?? ''}\npartial syntax output\n`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
|
||||
}
|
||||
const output = `pwsh: syntax error\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
|
||||
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
|
||||
const output = `pwsh: syntax error${newline}${this.motd}${newline}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
const sent = request.text.length > 0 ? request.text : this.pendingText
|
||||
this.pendingText = ''
|
||||
const start = START_PATTERN.exec(sent)?.[0]
|
||||
const end = END_PATTERN.exec(sent)?.[0]
|
||||
if (this.mode === 'with-echo') {
|
||||
// The PSReadLine echo renders the submitted wrapper before the real
|
||||
// markers; the tool must strip it from the captured result.
|
||||
const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'exit-after-send') {
|
||||
// A fast `exit` settles the send with an echoed wrapper (marker end,
|
||||
// no status digits) while the exit event is still in flight; the shell
|
||||
// flips to exited before the tool's next poll, exactly like the real
|
||||
// ConPTY backend. The tool must re-observe status instead of sending.
|
||||
const output = `${sent}\n${start ?? ''}\n`
|
||||
this.scrollback += output
|
||||
const settled = this.result(output, 'inferred_idle')
|
||||
this.statusValue = { kind: 'exited', exitCode: 9, signal: null }
|
||||
this.throwOnSend = true
|
||||
return this.operation(Promise.resolve(settled))
|
||||
}
|
||||
if (this.mode === 'incremental-fallback') {
|
||||
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
|
||||
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
|
||||
}
|
||||
if (this.mode === 'torn-status') {
|
||||
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
|
||||
this.scrollback += output
|
||||
this.mode = 'finish-torn-status'
|
||||
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
|
||||
}
|
||||
if (this.mode === 'finish-torn-status') {
|
||||
const output = `7\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
if (this.mode === 'end-only') {
|
||||
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
|
||||
this.scrollback += output
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
const commandOutput = this.mode === 'large'
|
||||
? 'x'.repeat(100)
|
||||
: this.mode === 'nonzero' ? ''
|
||||
: this.mode === 'prompt-collision' ? this.motd
|
||||
: 'hello from stub'
|
||||
const exitCode = this.mode === 'nonzero' ? 7 : 0
|
||||
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
|
||||
this.scrollback += output
|
||||
if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
|
||||
const exitedOutput = `${start ?? ''}\nhello from stub\n`
|
||||
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
|
||||
this.statusValue = this.mode === 'signal-exit'
|
||||
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
|
||||
: this.mode === 'exit'
|
||||
? { kind: 'exited', exitCode: 9, signal: null }
|
||||
: { kind: 'exited', exitCode: null, signal: null }
|
||||
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
|
||||
}
|
||||
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
|
||||
}
|
||||
|
||||
read(request: TerminalReadRequest) {
|
||||
if (this.mode === 'empty-read') {
|
||||
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
|
||||
}
|
||||
if (this.mode === 'stalled-read') {
|
||||
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
|
||||
}
|
||||
if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
|
||||
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
|
||||
}
|
||||
const lines = this.scrollback.split('\n')
|
||||
if (this.mode === 'paged-scrollback') {
|
||||
const offset = request.offset ?? 0
|
||||
const end = lines.length - offset
|
||||
const start = Math.max(0, end - 3)
|
||||
const returnedLines = end - start
|
||||
return {
|
||||
text: lines.slice(start, end).join('\n'),
|
||||
totalLines: lines.length,
|
||||
lineBegin: offset,
|
||||
lineEnd: offset + returnedLines,
|
||||
truncated: this.historyTruncated,
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: this.scrollback,
|
||||
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
|
||||
lineBegin: 0,
|
||||
lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
|
||||
truncated: this.historyTruncated,
|
||||
}
|
||||
}
|
||||
|
||||
signal(_signal: TerminalSignal) {
|
||||
return Promise.resolve({ delivered: true as const, targetPgid: 123 })
|
||||
}
|
||||
|
||||
status() {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
async close(reason: string) {
|
||||
this.closed.push(reason)
|
||||
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
|
||||
}
|
||||
|
||||
private result(viewport: string, waitReason: TerminalWaitReason) {
|
||||
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
|
||||
}
|
||||
|
||||
private operation(done: Promise<ReturnType<StubTerminalSession['result']>>, delta = ''): TerminalSendOperation {
|
||||
return {
|
||||
done,
|
||||
readOutput: () => ({ delta, truncated: false }),
|
||||
cancel: () => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stubBackend(initialMode: StubMode = 'normal') {
|
||||
const sessions: StubTerminalSession[] = []
|
||||
const backend: TerminalBackend = {
|
||||
type: 'stub',
|
||||
async spawn() {
|
||||
if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
|
||||
const session = new StubTerminalSession(initialMode)
|
||||
sessions.push(session)
|
||||
return session
|
||||
},
|
||||
}
|
||||
return { backend, sessions }
|
||||
}
|
||||
|
||||
async function setup(
|
||||
config: ToolPwshPersistent.Config = { backendType: 'stub' },
|
||||
initialMode: StubMode = 'normal',
|
||||
) {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
const stub = stubBackend(initialMode)
|
||||
ctx.terminals.registerBackend(stub.backend)
|
||||
const fiber = await ctx.plugin(ToolPwshPersistent, config)
|
||||
return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
|
||||
}
|
||||
|
||||
describe('tool-pwsh-persistent', () => {
|
||||
it('registers a configurable schema and reuses one owner shell', async () => {
|
||||
const { ctx, owner, stub, fiber } = await setup({
|
||||
backendType: 'stub',
|
||||
description: 'deployment-specific persistent shell',
|
||||
})
|
||||
const schema = ctx.tools.schemas()[0]
|
||||
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh'])
|
||||
expect(schema?.description).toBe('deployment-specific persistent shell')
|
||||
expect(schema?.parameters).toMatchObject({
|
||||
required: ['command'],
|
||||
properties: { command: { type: 'string' } },
|
||||
})
|
||||
expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' }))
|
||||
.toEqual({ card: 'terminal', title: 'pwd' })
|
||||
|
||||
expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub')
|
||||
expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(1)
|
||||
expect(stub.sessions[0]?.sends).toBe(3)
|
||||
|
||||
const ownerWithoutCwd = agent(ctx, undefined)
|
||||
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toEqual([])
|
||||
expect(ctx.tools.get('pwsh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('strips the echoed wrapper from captured output', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'with-echo'
|
||||
const result = text(await call(ctx, owner, 'Write-Output hi'))
|
||||
expect(result).toBe('hello from stub')
|
||||
expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_')
|
||||
expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_')
|
||||
expect(result).not.toContain('Invoke-Expression')
|
||||
})
|
||||
|
||||
it('preserves command output that equals the private shell prompt', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
|
||||
session.mode = 'prompt-collision'
|
||||
expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd)
|
||||
})
|
||||
|
||||
it('reports the exit path when the shell exits between send settlement and the next poll', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
session.mode = 'exit-after-send'
|
||||
|
||||
const result = text(await call(ctx, owner, 'exit'))
|
||||
expect(result).toContain('[shell exited: code 9]')
|
||||
expect(result).toContain('next pwsh call starts from the workspace')
|
||||
expect(session.closed).toContain('persistent pwsh shell exited')
|
||||
|
||||
expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
|
||||
const { ctx, owner, stub, fiber } = await setup({
|
||||
backendType: 'stub',
|
||||
maxOutputChars: 10,
|
||||
})
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
|
||||
session.mode = 'idle-then-normal'
|
||||
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
|
||||
|
||||
session.mode = 'incremental-fallback'
|
||||
session.scrollback = ''
|
||||
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
|
||||
|
||||
session.mode = 'prompt-only'
|
||||
const promptFallback = text(await call(ctx, owner, 'bad {'))
|
||||
expect(promptFallback).toContain('pwsh: synt')
|
||||
expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
|
||||
session.mode = 'prompt-crlf'
|
||||
session.scrollback = ''
|
||||
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
|
||||
expect(crlfPromptFallback).toContain('pwsh: synt')
|
||||
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
|
||||
session.mode = 'end-only'
|
||||
session.scrollback = ''
|
||||
const missingStart = text(await call(ctx, owner, 'recover marker'))
|
||||
expect(missingStart).toContain('recovered')
|
||||
expect(missingStart).toContain('beginning of this command output was dropped')
|
||||
expect(missingStart).toContain('<response clipped>')
|
||||
|
||||
session.mode = 'large'
|
||||
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
|
||||
|
||||
session.mode = 'nonzero'
|
||||
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
|
||||
|
||||
session.mode = 'exit'
|
||||
const exited = text(await call(ctx, owner, 'exit'))
|
||||
expect(exited).toContain('hello from')
|
||||
expect(exited).toContain('[shell exited: code 9]')
|
||||
expect(exited).not.toContain('[exit code: 9]')
|
||||
expect(exited).toContain('next pwsh call starts from the workspace')
|
||||
expect(session.closed).toContain('persistent pwsh shell exited')
|
||||
|
||||
await call(ctx, owner, 'new shell')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
const replacement = stub.sessions[1]!
|
||||
replacement.mode = 'signal-exit'
|
||||
expect(text(await call(ctx, owner, 'kill shell')))
|
||||
.toContain('[shell killed by signal: SIGTERM]')
|
||||
|
||||
await call(ctx, owner, 'another shell')
|
||||
expect(stub.sessions).toHaveLength(3)
|
||||
const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId
|
||||
expect(externallyClosed).toBeDefined()
|
||||
await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup')
|
||||
await fiber.dispose()
|
||||
expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
|
||||
})
|
||||
|
||||
it('waits for status digits after a torn completion marker', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'torn-status'
|
||||
stub.sessions[0]!.scrollback = ''
|
||||
|
||||
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
|
||||
})
|
||||
|
||||
it('reports a shell exit when the backend has no code or signal', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'unknown-exit'
|
||||
|
||||
expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
|
||||
})
|
||||
|
||||
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
|
||||
session.mode = 'end-only'
|
||||
session.scrollback = ''
|
||||
expect(text(await call(ctx, owner, 'missing start')))
|
||||
.toContain('beginning of this command output was dropped')
|
||||
|
||||
session.mode = 'empty-read'
|
||||
expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
|
||||
|
||||
session.mode = 'stalled-read'
|
||||
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
|
||||
|
||||
session.mode = 'empty-page-after-latest'
|
||||
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
|
||||
})
|
||||
|
||||
it('assembles retained output across backward scrollback pages', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
session.mode = 'paged-scrollback'
|
||||
session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
|
||||
|
||||
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
|
||||
})
|
||||
|
||||
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
const session = stub.sessions[0]!
|
||||
session.mode = 'prompt-after-idle'
|
||||
session.scrollback = ''
|
||||
const result = text(await call(ctx, owner, 'bad {'))
|
||||
expect(result).toContain('partial syntax output')
|
||||
expect(result).toContain('pwsh: syntax error')
|
||||
expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT')
|
||||
expect(result).not.toContain('DSH_PERSISTENT_PWSH_START')
|
||||
})
|
||||
|
||||
it('does not attribute old scrollback truncation to a complete current command', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.historyTruncated = true
|
||||
const result = text(await call(ctx, owner, 'short command'))
|
||||
expect(result).toBe('hello from stub')
|
||||
expect(result).not.toContain('<response clipped>')
|
||||
expect(result).not.toContain('beginning of this command output was dropped')
|
||||
})
|
||||
|
||||
it('closes a timed-out shell and reports bounded partial output', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'wait-for-abort'
|
||||
const result = await call(ctx, owner, 'hang')
|
||||
expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
|
||||
expect(text(result)).toContain('partial output')
|
||||
expect(text(result)).toContain('next pwsh call starts from the workspace')
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out')
|
||||
})
|
||||
|
||||
it.each(['wait-for-abort', 'end-on-abort'] as const)(
|
||||
'cancels %s work, resets the shell, and releases a queued call',
|
||||
async (mode) => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = mode
|
||||
const controller = new AbortController()
|
||||
const cancelled = call(ctx, owner, 'hang', controller.signal)
|
||||
const queued = call(ctx, owner, 'after cancellation')
|
||||
setTimeout(() => {
|
||||
controller.abort(new Error('caller stopped'))
|
||||
}, 5)
|
||||
|
||||
expect((await cancelled).isError).toBe(true)
|
||||
expect(text(await queued)).toBe('hello from stub')
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['init-exit', 'init-timeout'] as const)(
|
||||
'fails initialization and closes the unusable shell for %s',
|
||||
async (mode) => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
|
||||
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed')
|
||||
},
|
||||
)
|
||||
|
||||
it('clears a failed spawn without trying to close an unpublished shell', async () => {
|
||||
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
|
||||
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
|
||||
expect(stub.sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('resets a cached shell after startSend fails', async () => {
|
||||
const { ctx, owner, stub } = await setup()
|
||||
await call(ctx, owner, 'warm up')
|
||||
stub.sessions[0]!.mode = 'send-error'
|
||||
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
|
||||
expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed')
|
||||
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
|
||||
expect(stub.sessions).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
const spawnStarted = Promise.withResolvers<undefined>()
|
||||
const spawnAborted = Promise.withResolvers<undefined>()
|
||||
ctx.terminals.registerBackend({
|
||||
type: 'slow',
|
||||
spawn: spec => new Promise((_resolve, reject) => {
|
||||
spawnStarted.resolve(undefined)
|
||||
spec.signal?.addEventListener('abort', () => {
|
||||
spawnAborted.resolve(undefined)
|
||||
const reason: unknown = spec.signal?.reason
|
||||
reject(reason instanceof Error
|
||||
? reason
|
||||
: new Error('slow PTY spawn aborted', { cause: reason }))
|
||||
}, { once: true })
|
||||
}),
|
||||
})
|
||||
const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' })
|
||||
const owner = agent(ctx, '/workspace')
|
||||
const running = call(ctx, owner, 'pwd')
|
||||
await spawnStarted.promise
|
||||
await fiber.dispose()
|
||||
await spawnAborted.promise
|
||||
expect((await running).isError).toBe(true)
|
||||
expect(ctx.terminals.list(owner)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects invalid config and invalid calls', async () => {
|
||||
const { ctx, owner, stub } = await setup()
|
||||
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
|
||||
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('caller stopped'))
|
||||
expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
|
||||
expect(stub.sessions).toHaveLength(0)
|
||||
|
||||
expect(() => {
|
||||
ToolPwshPersistent.apply(new Context(), { backendType: '' })
|
||||
}).toThrow('backendType must be non-empty')
|
||||
expect(() => {
|
||||
ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 })
|
||||
}).toThrow('timeoutMs must be a positive safe integer')
|
||||
expect(() => {
|
||||
ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 })
|
||||
}).toThrow('maxOutputChars must be a positive safe integer')
|
||||
expect(() => {
|
||||
ToolPwshPersistent.apply(new Context(), { description: ' ' })
|
||||
}).toThrow('description must be non-empty')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../core/agent" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../terminal/terminal" },
|
||||
{ "path": "../../runtime-diagnostics/invariants" },
|
||||
{ "path": "../../util/timeout" }
|
||||
]
|
||||
}
|
||||
@@ -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/shell/tool-pwsh/README.md
|
||||
README.md: af1de3d84c8815f9875faaa8e2fd6a79dff018c2
|
||||
README.zh.md: 1f662094e5d423299ae704efddc6a0b27bdfc128
|
||||
README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716
|
||||
README.zh.md: e03a980acfe05583721a1f084cbf53545c076126
|
||||
|
||||
@@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.
|
||||
|
||||
@@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。
|
||||
|
||||
@@ -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/subprocess/subprocess-local/README.md
|
||||
README.md: a88eab6776c7259bb8ee8fdad2d6002d8f57e133
|
||||
README.zh.md: ce088557875daceb5643f13bef78d6ea9a61e12f
|
||||
README.md: 0935bb309bd10dec7503a74708a28442223bf296
|
||||
README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d
|
||||
|
||||
@@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA
|
||||
- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement.
|
||||
- **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd.
|
||||
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
||||
- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations.
|
||||
- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes.
|
||||
- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md).
|
||||
|
||||
@@ -26,7 +26,7 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows tree support is best-effort** — termination routes through `taskkill /PID <pid> /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary.
|
||||
- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots.
|
||||
- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest.
|
||||
- **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor.
|
||||
- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner.
|
||||
- **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。
|
||||
- **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。
|
||||
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
|
||||
- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。
|
||||
- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。
|
||||
- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID <pid> /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。
|
||||
- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。
|
||||
- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。
|
||||
- **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。
|
||||
- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
|
||||
- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"node-pty": "1.2.0-beta.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
|
||||
import { createWindowsProcessInspector } from './windows-inspector.ts'
|
||||
|
||||
/** PID plus start identity, preventing teardown escalation after PID reuse. */
|
||||
export interface ProcessIdentity {
|
||||
@@ -370,5 +371,6 @@ export function createProcessInspector(
|
||||
): ProcessInspector {
|
||||
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
|
||||
if (platform === 'darwin') return new MacProcessInspector(internals)
|
||||
if (platform === 'win32') return createWindowsProcessInspector()
|
||||
throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
|
||||
}
|
||||
|
||||
@@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
* @param terminal - allocated node-pty process.
|
||||
* @param inspector - platform process/session operations.
|
||||
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
||||
* @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
|
||||
*/
|
||||
constructor(
|
||||
private readonly terminal: IPty,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly graceMs: number,
|
||||
private readonly platform: NodeJS.Platform = process.platform,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
|
||||
@@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
if (this.platform === 'win32') {
|
||||
if (signal === 'SIGINT') {
|
||||
// Windows has no process-group signalling: a `\x03` input write is the
|
||||
// Ctrl-C delivery path conhost turns into a console-wide CTRL_C event
|
||||
// for attached processes. node-pty's signal kills throw on Windows, so
|
||||
// no signal ever reaches the inspector.
|
||||
this.terminal.write('\x03')
|
||||
return foreground.processGroupId
|
||||
}
|
||||
if (signal === 'SIGTSTP' || signal === 'SIGHUP') {
|
||||
throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`)
|
||||
}
|
||||
}
|
||||
this.inspector.signalGroup(foreground.processGroupId, signal)
|
||||
return foreground.processGroupId
|
||||
}
|
||||
@@ -214,6 +229,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
|
||||
private async stopShell(): Promise<void> {
|
||||
if (this.platform === 'win32') {
|
||||
await this.stopShellWindows()
|
||||
return
|
||||
}
|
||||
if (!this.exited) {
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
@@ -233,6 +252,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
|
||||
}
|
||||
|
||||
private async stopShellWindows(): Promise<void> {
|
||||
// node-pty's Windows kill(signal) throws ("Signals not supported on
|
||||
// windows"), and its bare kill() delegates to a console-list agent that
|
||||
// fails when the parent has no console. taskkill tree escalation is the
|
||||
// teardown path, fenced on the shell's start identity like every
|
||||
// descendant; a root identity miss falls back to the bare kill. taskkill
|
||||
// termination also does not reliably fire node-pty's exit notification
|
||||
// (the same console-list agent), so the tiers verify the shell's absence
|
||||
// through the inspector instead of waiting on `done` alone.
|
||||
const shellGone = (): boolean =>
|
||||
this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity))
|
||||
if (!shellGone() && this.rootIdentity !== undefined) {
|
||||
this.inspector.signalProcess(this.rootIdentity, 'SIGTERM')
|
||||
await this.waitForWindowsShellExit()
|
||||
}
|
||||
if (!shellGone() && this.rootIdentity === undefined) {
|
||||
try {
|
||||
this.terminal.kill()
|
||||
} catch (_topLevelAlreadyExitedDuringKill) {
|
||||
// The exit callback is authoritative.
|
||||
}
|
||||
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
|
||||
}
|
||||
if (!shellGone() && this.rootIdentity !== undefined) {
|
||||
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
|
||||
await this.waitForWindowsShellExit()
|
||||
}
|
||||
if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
|
||||
}
|
||||
|
||||
private async waitForWindowsShellExit(): Promise<void> {
|
||||
const until = Date.now() + this.graceMs
|
||||
while (!this.exited && Date.now() < until) {
|
||||
if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return
|
||||
await delay(Math.min(25, Math.max(1, until - Date.now())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(): Promise<void> {
|
||||
let survivors = await this.stopDescendants()
|
||||
if (survivors.length > 0) {
|
||||
@@ -243,7 +300,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
this.settleExitIfGone()
|
||||
this.dataDisposable.dispose()
|
||||
this.exitDisposable.dispose()
|
||||
}
|
||||
|
||||
private settleExitIfGone(): void {
|
||||
// An externally taskkilled Windows shell may never fire node-pty's exit
|
||||
// notification (its console-list agent fails without a parent console),
|
||||
// which would leave `done` — and every consumer awaiting it — unsettled
|
||||
// forever. Teardown has just verified the shell's absence through the
|
||||
// inspector, so a missing exit event is itself the outcome.
|
||||
if (this.platform !== 'win32') return
|
||||
if (this.exited) return
|
||||
/* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
|
||||
the identity re-check is a defensive fence for a future caller. */
|
||||
if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return
|
||||
this.exited = true
|
||||
this.output.end()
|
||||
this.outcome.resolve({ exitCode: null, signal: null })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Windows process-table operations for terminal readiness, signalling, and
|
||||
* teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
|
||||
* identity and process-handle wait-state liveness, the shell pid as a pseudo
|
||||
* process group (Windows has no POSIX groups), and taskkill tree signalling.
|
||||
* The koffi bindings load lazily so
|
||||
* non-Windows processes never touch Win32 libraries; all decision logic takes
|
||||
* an injectable internals boundary so suites can pin it on any host.
|
||||
* @module dsh-subprocess-local/windows-inspector
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import koffi from 'koffi'
|
||||
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
|
||||
|
||||
/** One Toolhelp32 process-table row. */
|
||||
export interface ProcessEntry {
|
||||
pid: number
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
/** Creation identity plus the process object's current wait state. */
|
||||
export interface WindowsProcessState {
|
||||
/** GetProcessTimes creation identity used to fence PID reuse. */
|
||||
started: string
|
||||
/** Whether a zero-time process-handle wait reports the process still running. */
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/** Injectable Windows process operations used by one local PTY session. */
|
||||
export interface WindowsProcessInspectorInternals {
|
||||
/** Enumerate the current process table (pid/parent pairs). */
|
||||
snapshot(): ProcessEntry[]
|
||||
/** Return one process's creation identity and wait state, or undefined when unreadable. */
|
||||
processState(pid: number): WindowsProcessState | undefined
|
||||
/** Terminate one process tree; `force` maps to taskkill `/F`. */
|
||||
taskkill(pid: number, force: boolean): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a process table from one root in children-first order, retaining only
|
||||
* members whose start identity is readable (unreadable members are detector
|
||||
* misses, exactly like an unreadable `/proc` entry on Linux).
|
||||
* @param entries - the process table snapshot.
|
||||
* @param rootPid - the tree root to descend from.
|
||||
* @param started - creation-time identity resolver for one member.
|
||||
* @returns the root and its current transitive descendants, children first.
|
||||
*/
|
||||
/* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts:
|
||||
the decision logic (tree walk, identity fencing, group signalling) is the same contract over
|
||||
Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */
|
||||
export function windowsProcessTree(
|
||||
entries: ProcessEntry[],
|
||||
rootPid: number,
|
||||
started: (pid: number) => string | undefined,
|
||||
): 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, ProcessEntry[]>()
|
||||
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: ProcessEntry): void => {
|
||||
if (visited.has(entry.pid)) return
|
||||
visited.add(entry.pid)
|
||||
for (const child of byParent.get(entry.pid) ?? []) visit(child)
|
||||
const identity = started(entry.pid)
|
||||
if (identity !== undefined) result.push({ pid: entry.pid, started: identity })
|
||||
}
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows {@link ProcessInspector}. The shell pid stands in for a foreground
|
||||
* process group: it is a stable pseudo-group that lets the prompt-marker
|
||||
* readiness path compare foreground identities, while every actual signal
|
||||
* targets the console-wide tree through taskkill (SIGINT is delivered by the
|
||||
* terminal handle as a `\x03` input write and never reaches this layer).
|
||||
*/
|
||||
export class WindowsProcessInspector implements ProcessInspector {
|
||||
constructor(
|
||||
private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
|
||||
) {}
|
||||
|
||||
foregroundPgid(shellPid: number): number {
|
||||
return shellPid
|
||||
}
|
||||
|
||||
isStdinWaiting(_pgid: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started)
|
||||
}
|
||||
|
||||
processSession(_sessionId: number): ProcessIdentity[] {
|
||||
return []
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
const state = this.internals.processState(identity.pid)
|
||||
return state?.active === true && state.started === identity.started
|
||||
}
|
||||
|
||||
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
|
||||
this.internals.taskkill(pgid, signal === 'SIGKILL')
|
||||
}
|
||||
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL')
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Create the Windows process inspector.
|
||||
* @param internals - injectable process operations; defaults to the koffi-backed table.
|
||||
* @returns the Windows inspector.
|
||||
*/
|
||||
export function createWindowsProcessInspector(
|
||||
internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
|
||||
): WindowsProcessInspector {
|
||||
return new WindowsProcessInspector(internals)
|
||||
}
|
||||
|
||||
/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */
|
||||
function taskkillTree(pid: number, force: boolean): void {
|
||||
if (pid <= 0) return
|
||||
// Outcome deliberately unchecked: an already-absent tree, exit races, and a
|
||||
// missing taskkill binary are as tolerable here as ESRCH is for POSIX.
|
||||
spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' })
|
||||
}
|
||||
|
||||
declare const nativePtr: unique symbol
|
||||
/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */
|
||||
export type NativePtr = bigint & { readonly [nativePtr]: true }
|
||||
|
||||
/**
|
||||
* True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
|
||||
* @param value - a handle as koffi may hand it back (pointer, null, or 0n).
|
||||
* @returns whether the value signals an invalid handle.
|
||||
*/
|
||||
export function isInvalidHandle(value: NativePtr | null | undefined): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
const asBigInt = value as bigint
|
||||
return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n
|
||||
}
|
||||
|
||||
/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */
|
||||
interface Win32Bindings {
|
||||
createToolhelp32Snapshot(flags: number, processId: number): NativePtr
|
||||
process32FirstW(snapshot: NativePtr, entry: NativePtr): number
|
||||
process32NextW(snapshot: NativePtr, entry: NativePtr): number
|
||||
openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
|
||||
getProcessTimes(
|
||||
process: NativePtr,
|
||||
creation: NativePtr,
|
||||
exit: NativePtr,
|
||||
kernel: NativePtr,
|
||||
user: NativePtr,
|
||||
): number
|
||||
waitForSingleObject(handle: NativePtr, milliseconds: number): number
|
||||
closeHandle(handle: NativePtr): number
|
||||
}
|
||||
|
||||
const PVOID: ReturnType<typeof koffi.pointer> = koffi.pointer('void')
|
||||
|
||||
/**
|
||||
* Resolve the koffi Win32 struct types once. Registration is lazy and cached
|
||||
* because koffi's type registry is global per process: test runners that
|
||||
* re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
|
||||
* re-register the names.
|
||||
*/
|
||||
function win32Structs(): { PROCESSENTRY32W: ReturnType<typeof koffi.struct>; FILETIME: ReturnType<typeof koffi.struct> } {
|
||||
if (cachedStructs !== undefined) return cachedStructs
|
||||
// koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout.
|
||||
const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', {
|
||||
dwSize: 'uint32',
|
||||
cntUsage: 'uint32',
|
||||
th32ProcessID: 'uint32',
|
||||
th32DefaultHeapID: PVOID,
|
||||
th32ModuleID: 'uint32',
|
||||
cCntThreads: 'uint32',
|
||||
th32ParentProcessID: 'uint32',
|
||||
pcPriClassBase: 'int32',
|
||||
dwFlags: 'uint32',
|
||||
szExeFile: koffi.array('char16', 260),
|
||||
})
|
||||
// koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp.
|
||||
const FILETIME = koffi.struct('FILETIME', {
|
||||
dwLowDateTime: 'uint32',
|
||||
dwHighDateTime: 'uint32',
|
||||
})
|
||||
/* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */
|
||||
if (PROCESSENTRY32W.size !== 568) {
|
||||
throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
cachedStructs = { PROCESSENTRY32W, FILETIME }
|
||||
return cachedStructs
|
||||
}
|
||||
|
||||
let cachedStructs: ReturnType<typeof win32Structs> | undefined
|
||||
|
||||
const TH32CS_SNAPPROCESS = 0x2
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
const SYNCHRONIZE = 0x00100000
|
||||
const WAIT_OBJECT_0 = 0
|
||||
const WAIT_TIMEOUT = 0x102
|
||||
|
||||
let cachedBindings: Win32Bindings | undefined
|
||||
|
||||
/**
|
||||
* Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
|
||||
* @returns the cached binding table.
|
||||
*/
|
||||
function win32Bindings(): Win32Bindings {
|
||||
if (cachedBindings !== undefined) return cachedBindings
|
||||
const { PROCESSENTRY32W, FILETIME } = win32Structs()
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const bind = (
|
||||
name: string,
|
||||
result: ReturnType<typeof koffi.pointer> | string,
|
||||
args: Array<ReturnType<typeof koffi.pointer> | string>,
|
||||
): unknown => kernel32.func('__stdcall', name, result, args)
|
||||
cachedBindings = {
|
||||
createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']),
|
||||
process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
|
||||
process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
|
||||
openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
|
||||
getProcessTimes: bind('GetProcessTimes', 'int', [
|
||||
PVOID,
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
]),
|
||||
waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']),
|
||||
closeHandle: bind('CloseHandle', 'int', [PVOID]),
|
||||
} as unknown as Win32Bindings
|
||||
return cachedBindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are
|
||||
* `any`, so the cast goes through `unknown` to keep the unsafe surface here.
|
||||
* @param type - the koffi type to allocate.
|
||||
* @param count - element count.
|
||||
* @returns the branded allocation pointer.
|
||||
*/
|
||||
function allocNative(type: Parameters<typeof koffi.alloc>[0], count: number): NativePtr {
|
||||
const value: unknown = koffi.alloc(type, count)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/** Enumerate the current process table through Toolhelp32. */
|
||||
function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
|
||||
const { PROCESSENTRY32W } = win32Structs()
|
||||
const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
/* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
|
||||
the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */
|
||||
if (isInvalidHandle(snapshot)) return []
|
||||
const entries: ProcessEntry[] = []
|
||||
try {
|
||||
const entry = allocNative(PROCESSENTRY32W, 1)
|
||||
koffi.encode(entry, 'uint32', PROCESSENTRY32W.size)
|
||||
let ok = bindings.process32FirstW(snapshot, entry)
|
||||
while (ok !== 0) {
|
||||
const record = koffi.decode(entry, PROCESSENTRY32W) as {
|
||||
th32ProcessID: number
|
||||
th32ParentProcessID: number
|
||||
}
|
||||
entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID })
|
||||
ok = bindings.process32NextW(snapshot, entry)
|
||||
}
|
||||
} finally {
|
||||
bindings.closeHandle(snapshot)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Read one process's creation identity and current wait state. */
|
||||
function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined {
|
||||
const { FILETIME } = win32Structs()
|
||||
const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid)
|
||||
if (isInvalidHandle(handle)) return undefined
|
||||
try {
|
||||
const creation = allocNative(FILETIME, 1)
|
||||
const exit = allocNative(FILETIME, 1)
|
||||
const kernel = allocNative(FILETIME, 1)
|
||||
const user = allocNative(FILETIME, 1)
|
||||
/* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and
|
||||
cannot be staged deterministically; the absent-process path is covered and the caller
|
||||
treats undefined as a detector miss. */
|
||||
if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined
|
||||
const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number }
|
||||
const wait = bindings.waitForSingleObject(handle, 0)
|
||||
/* v8 ignore next -- an opened process handle has exactly one of these two
|
||||
zero-time wait states; an unexpected Win32 failure is an unreadable process. */
|
||||
if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined
|
||||
return {
|
||||
started: `${record.dwHighDateTime}:${record.dwLowDateTime}`,
|
||||
active: wait === WAIT_TIMEOUT,
|
||||
}
|
||||
} finally {
|
||||
bindings.closeHandle(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/** The koffi-backed default internals; bindings resolve lazily on first use. */
|
||||
function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals {
|
||||
return {
|
||||
snapshot: () => snapshotWindowsProcesses(win32Bindings()),
|
||||
processState: pid => windowsProcessState(win32Bindings(), pid),
|
||||
taskkill: taskkillTree,
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS
|
||||
import { childEnv } from '../src/spawn.ts'
|
||||
|
||||
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
|
||||
// Windows has no bash; the suite's simple commands translate to node one-liners.
|
||||
const argv = process.platform === 'win32'
|
||||
? [process.execPath, '-e', {
|
||||
'echo managed': 'console.log("managed")',
|
||||
'sleep 60': 'setTimeout(() => {}, 60000)',
|
||||
'true': '',
|
||||
}[command] ?? command]
|
||||
: ['bash', '-c', command]
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
argv,
|
||||
cwd: process.cwd(),
|
||||
stdio: {
|
||||
stdin: 'ignore',
|
||||
@@ -148,9 +156,9 @@ describe('LocalSubprocessRuntime', () => {
|
||||
const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' })
|
||||
expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path'])
|
||||
expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt'])
|
||||
expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD'])
|
||||
expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')])
|
||||
expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' }))
|
||||
.toEqual(['/explicit/tool.EXE'])
|
||||
.toEqual([resolve('/explicit', 'tool.EXE')])
|
||||
expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
|
||||
expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
|
||||
await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
|
||||
@@ -398,7 +406,8 @@ describe('LocalSubprocessRuntime', () => {
|
||||
const handle = ctx.subprocess.spawn(spec('sleep 60'))
|
||||
await fiber.dispose()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
// Windows teardown terminates through taskkill, which reports no signal.
|
||||
expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseProcStat,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-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)]
|
||||
@@ -237,12 +238,13 @@ describe('macOS process inspector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
|
||||
it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => {
|
||||
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 on platform win32')
|
||||
expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector)
|
||||
expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
childEnv,
|
||||
killGroup,
|
||||
OutputCollector,
|
||||
spawnSubprocess,
|
||||
@@ -11,6 +12,49 @@ import {
|
||||
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Translate the suite's POSIX command strings into node one-liners on Windows,
|
||||
* where no bash exists; the translated commands keep the same observable
|
||||
* stdout/stderr/exit-code contract the bash originals pin on POSIX.
|
||||
* @param command - the bash `-c` command string used by the test.
|
||||
* @returns the argv to spawn.
|
||||
*/
|
||||
function shellArgv(command: string): string[] {
|
||||
if (process.platform !== 'win32') return ['bash', '-c', command]
|
||||
const node = (script: string): string[] => [process.execPath, '-e', script]
|
||||
switch (command) {
|
||||
case 'true': return node('')
|
||||
case 'echo hello': return node('console.log("hello")')
|
||||
case 'echo hi': return node('console.log("hi")')
|
||||
case 'echo oops >&2': return node('console.error("oops")')
|
||||
case 'echo err >&2': return node('console.error("err")')
|
||||
case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")')
|
||||
case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")')
|
||||
case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")')
|
||||
case 'exit 42': return node('process.exit(42)')
|
||||
case 'exit 7': return node('process.exit(7)')
|
||||
case 'pwd': return node('console.log(process.cwd())')
|
||||
case 'sleep 60': return node('setTimeout(() => {}, 60000)')
|
||||
case 'cat': return node('process.stdin.pipe(process.stdout)')
|
||||
case 'unused': return node('')
|
||||
case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")')
|
||||
case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)')
|
||||
case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)')
|
||||
case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")')
|
||||
case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"':
|
||||
return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")')
|
||||
case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"':
|
||||
return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")')
|
||||
case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))')
|
||||
case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2':
|
||||
return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))')
|
||||
case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done':
|
||||
return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))')
|
||||
default:
|
||||
throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
failNextUnlink: { value: false },
|
||||
@@ -48,7 +92,7 @@ type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
|
||||
function spec(command: string, overrides: SpecOverrides = {}) {
|
||||
const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
argv: shellArgv(command),
|
||||
cwd: process.cwd(),
|
||||
stdio: {
|
||||
stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const,
|
||||
@@ -161,7 +205,7 @@ describe('spawnSubprocess', () => {
|
||||
expect(result.stdout.text).toBe('callers-choice\n')
|
||||
})
|
||||
|
||||
it('runs in the requested cwd', async () => {
|
||||
it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => {
|
||||
const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' })))
|
||||
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
@@ -176,11 +220,12 @@ describe('spawnSubprocess', () => {
|
||||
setTimeout(() => { controller.abort('deadline') }, 100)
|
||||
const result = await running.done
|
||||
expect(Date.now() - start).toBeLessThan(5_000)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.exitCode).toBeNull()
|
||||
// Windows teardown terminates through taskkill, which reports no signal.
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null)
|
||||
})
|
||||
|
||||
it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.terminate()
|
||||
@@ -231,12 +276,16 @@ describe('spawnSubprocess', () => {
|
||||
expect(forceSignals).toBe(0)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
process.kill(helper, 'SIGKILL')
|
||||
try {
|
||||
process.kill(helper, 'SIGKILL')
|
||||
} catch {
|
||||
// taskkill already took the helper down on Windows.
|
||||
}
|
||||
await waitGone(helper)
|
||||
}
|
||||
})
|
||||
|
||||
it('terminates the whole process group (grandchildren die too)', async () => {
|
||||
it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => {
|
||||
// The subshell writes the sleep's pid then waits on it; terminating the
|
||||
// group must take the sleep down with bash.
|
||||
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
||||
@@ -255,7 +304,7 @@ describe('spawnSubprocess', () => {
|
||||
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('throws when the signal is already aborted before spawn', () => {
|
||||
@@ -275,10 +324,10 @@ describe('spawnSubprocess', () => {
|
||||
running.terminate()
|
||||
running.terminate()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('does not wait for a Linux group that has only zombie members', async () => {
|
||||
it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => {
|
||||
const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), {
|
||||
platform: 'linux',
|
||||
@@ -296,7 +345,7 @@ describe('spawnSubprocess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||
const started = Date.now()
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
@@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
expect(result.stdout.text).toBe('')
|
||||
})
|
||||
|
||||
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
// With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device).
|
||||
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
||||
const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other')))
|
||||
@@ -619,7 +668,7 @@ describe('windows tree semantics (injected platform)', () => {
|
||||
running.terminate()
|
||||
const outcome = await running.done
|
||||
expect(killed).toContain(running.pid)
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL')
|
||||
})
|
||||
|
||||
it('waitForExit falls back to direct-child liveness where groups do not exist', async () => {
|
||||
@@ -630,7 +679,7 @@ describe('windows tree semantics (injected platform)', () => {
|
||||
})
|
||||
|
||||
describe('waitForExit', () => {
|
||||
it('waits for the whole detached tree, not just the shell', async () => {
|
||||
it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => {
|
||||
const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
const grandchild = await waitForPidFile(pidFile)
|
||||
@@ -650,7 +699,7 @@ describe('waitForExit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('synchronous host-exit termination', () => {
|
||||
describe.skipIf(process.platform === 'win32')('synchronous host-exit termination', () => {
|
||||
it('force-kills the current process tree without waiting for the normal grace', async () => {
|
||||
const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 }))
|
||||
running.terminateForHostExit()
|
||||
@@ -667,7 +716,7 @@ describe('synchronous host-exit termination', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
|
||||
// The leader spawns a TERM-trapping helper with all stdio detached from
|
||||
// the collected pipes, then exits: the helper holds the GROUP alive while
|
||||
@@ -733,6 +782,97 @@ describe('coverage seams', () => {
|
||||
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('covers the injected POSIX group paths on any host', async () => {
|
||||
// Windows has no POSIX groups, so the tree-liveness probe, group
|
||||
// signalling, and the SIGKILL escalation timer only run here through the
|
||||
// injected platform; the mock keeps the group alive through TERM and
|
||||
// terminates the direct child when the escalation tier delivers SIGKILL.
|
||||
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
|
||||
platform: 'linux',
|
||||
linuxProcessGroupHasLiveMembers: () => false,
|
||||
})
|
||||
const realKill = process.kill.bind(process)
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (typeof target === 'number' && target < 0) {
|
||||
if (signal === 0) return true
|
||||
if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL')
|
||||
return true
|
||||
}
|
||||
return realKill(target, signal)
|
||||
})
|
||||
try {
|
||||
running.terminate()
|
||||
await running.done
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a vanished group probe as quiescent without signalling', async () => {
|
||||
const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' })
|
||||
const realKill = process.kill.bind(process)
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (typeof target === 'number' && target < 0) {
|
||||
throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' })
|
||||
}
|
||||
return realKill(target, signal)
|
||||
})
|
||||
try {
|
||||
running.terminate()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
realKill(running.pid, 'SIGKILL')
|
||||
await running.done
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('childEnv keeps the POSIX spread on non-Windows hosts', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
try {
|
||||
expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1')
|
||||
} finally {
|
||||
platform.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => {
|
||||
// The leader spawns a detached grandchild inheriting the collected stdout
|
||||
// pipe, then exits: `close` cannot settle while the grandchild holds the
|
||||
// pipe, so the bounded pipe-drain timer must settle the outcome.
|
||||
const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`)
|
||||
const childScript = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 1, 2],
|
||||
})
|
||||
writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid))
|
||||
helper.unref()
|
||||
`
|
||||
const running = spawnSubprocess({
|
||||
...spec('unused', { graceMs: 100 }),
|
||||
argv: [process.execPath, '-e', childScript],
|
||||
})
|
||||
// The drain timer starts when the child's stdio closes, which can precede
|
||||
// the pid file becoming visible; measure from before that wait so the
|
||||
// lower bound cannot be eroded by the pid-file handoff.
|
||||
const started = Date.now()
|
||||
const helper = await waitForPidFile(pidFile)
|
||||
const outcome = await running.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(90)
|
||||
try {
|
||||
process.kill(helper, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone; the drain bound is the point under test.
|
||||
}
|
||||
await waitGone(helper)
|
||||
})
|
||||
|
||||
it('a spawn-failed handle rejects done while waitForExit reports gone', async () => {
|
||||
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
|
||||
await expect(running.done).rejects.toThrow()
|
||||
@@ -869,7 +1009,7 @@ describe('argv validation', () => {
|
||||
expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
|
||||
})
|
||||
|
||||
it('spawns argv verbatim without shell interpretation', async () => {
|
||||
it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => {
|
||||
const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }))
|
||||
expect(result.stdout.text).toBe('$HOME')
|
||||
})
|
||||
@@ -889,7 +1029,7 @@ describe('abort edge cases', () => {
|
||||
.toThrow(/aborted before spawn: aborted/)
|
||||
})
|
||||
|
||||
it('reports the terminating signal of an externally self-killed command', async () => {
|
||||
it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => {
|
||||
// spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the
|
||||
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
||||
const result = await finish(spawnSubprocess(spec('kill -TERM $$')))
|
||||
@@ -930,7 +1070,7 @@ describe('environment and spill-file hardening', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await finish(spawnSubprocess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
@@ -941,7 +1081,7 @@ describe('environment and spill-file hardening', () => {
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
it('defaults spills into a private per-process directory', async () => {
|
||||
it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => {
|
||||
const result = await finish(spawnSubprocess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
))
|
||||
@@ -967,6 +1107,6 @@ describe('environment and spill-file hardening', () => {
|
||||
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector {
|
||||
this.groups.push([pgid, signal])
|
||||
}
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
|
||||
// Mirrors the real inspectors' alive-gated signalling.
|
||||
if (!this.alive.has(identity.pid)) return
|
||||
if (this.throwProcess) throw new Error('process raced')
|
||||
if (!this.isAlive(identity)) return
|
||||
this.processes.push([identity.pid, signal])
|
||||
@@ -82,6 +84,12 @@ class FakeInspector implements ProcessInspector {
|
||||
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle {
|
||||
// The suite pins POSIX signalling semantics deterministically on every host;
|
||||
// the win32 branches get their own platform-explicit tests below.
|
||||
return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux')
|
||||
}
|
||||
|
||||
describe('LocalTerminalHandle', () => {
|
||||
it('force-kills descendants around the shell during synchronous host exit', () => {
|
||||
const pty = new FakePty()
|
||||
@@ -166,7 +174,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.waiting = true
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
const chunks: Buffer[] = []
|
||||
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
|
||||
@@ -187,7 +195,7 @@ describe('LocalTerminalHandle', () => {
|
||||
it('rejects unsafe foreground signals and writes after exit', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
inspector.pgid = undefined
|
||||
@@ -207,7 +215,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
const quiescent = handle.terminate()
|
||||
expect(handle.terminate()).toBe(quiescent)
|
||||
@@ -228,7 +236,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
pty.emitExit()
|
||||
const waiting = handle.terminate()
|
||||
let settled = false
|
||||
@@ -247,7 +255,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const disowned = { pid: 124, started: 'disowned' }
|
||||
inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
|
||||
inspector.alive.add(124)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
pty.emitExit()
|
||||
|
||||
@@ -261,7 +269,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const descendant = { pid: 124, started: 'observed' }
|
||||
inspector.members = [descendant]
|
||||
inspector.alive.add(descendant.pid)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
await handle.inspectForeground()
|
||||
inspector.members = []
|
||||
@@ -274,7 +282,7 @@ describe('LocalTerminalHandle', () => {
|
||||
it('does not adopt the children of a recycled shell pid', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
pty.emitExit()
|
||||
const imposterChild = { pid: 999, started: 'imposter-child' }
|
||||
@@ -293,7 +301,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const orphan = { pid: 321, started: 'unverifiable' }
|
||||
inspector.members = [orphan]
|
||||
inspector.alive.add(orphan.pid)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([])
|
||||
@@ -318,7 +326,7 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
return []
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
@@ -332,7 +340,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.sessionMembers = [late]
|
||||
inspector.alive.add(late.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
await handle.terminate()
|
||||
|
||||
@@ -350,7 +358,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.sessionMembers = [late]
|
||||
inspector.alive.add(late.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
const first = handle.terminate()
|
||||
const failed = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
@@ -377,7 +385,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await quiescent
|
||||
@@ -388,7 +396,7 @@ describe('LocalTerminalHandle', () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
pty.autoExitOnKill = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
|
||||
const handle = makeHandle(pty, new FakeInspector(), 10)
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
@@ -406,7 +414,98 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.throwProcess = true
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
|
||||
const handle = makeHandle(pty, inspector, 1)
|
||||
await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTerminalHandle on Windows', () => {
|
||||
const win32 = 'win32' as NodeJS.Platform
|
||||
|
||||
it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await expect(handle.signalForeground('SIGINT')).resolves.toBe(456)
|
||||
expect(pty.writes).toEqual(['\x03'])
|
||||
expect(inspector.groups).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => {
|
||||
const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32)
|
||||
await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows')
|
||||
await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows')
|
||||
})
|
||||
|
||||
it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456)
|
||||
expect(inspector.groups).toEqual([[456, 'SIGTERM']])
|
||||
expect(pty.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('still refuses to SIGKILL the terminal shell on Windows', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
})
|
||||
|
||||
it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
pty.emitExit()
|
||||
await quiescent
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a shell that survives both taskkill tiers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
pty.emitExit()
|
||||
await handle.terminate()
|
||||
})
|
||||
|
||||
it('skips taskkill escalation entirely when the shell already exited', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
pty.emitExit()
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([])
|
||||
expect(pty.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the bare node-pty kill when the shell identity was never observable', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.root = undefined
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await handle.terminate()
|
||||
expect(pty.kills).toHaveLength(1)
|
||||
expect(inspector.processes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createWindowsProcessInspector,
|
||||
isInvalidHandle,
|
||||
windowsProcessTree,
|
||||
WindowsProcessInspector,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
|
||||
import type {
|
||||
NativePtr,
|
||||
ProcessEntry,
|
||||
WindowsProcessInspectorInternals,
|
||||
WindowsProcessState,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
|
||||
|
||||
function fakeInternals() {
|
||||
const entries: ProcessEntry[] = []
|
||||
const states = new Map<number, WindowsProcessState>()
|
||||
const kills: Array<[number, boolean]> = []
|
||||
return {
|
||||
internals: {
|
||||
snapshot: () => [...entries],
|
||||
processState: pid => states.get(pid),
|
||||
taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) },
|
||||
} satisfies WindowsProcessInspectorInternals,
|
||||
add(entry: ProcessEntry, started?: string, active = true): void {
|
||||
entries.push(entry)
|
||||
if (started !== undefined) states.set(entry.pid, { started, active })
|
||||
},
|
||||
kills,
|
||||
}
|
||||
}
|
||||
|
||||
describe('windowsProcessTree', () => {
|
||||
it('walks a table children-first with readable identities only', () => {
|
||||
const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}`
|
||||
expect(windowsProcessTree([
|
||||
{ pid: 10, parentPid: 0 },
|
||||
{ pid: 11, parentPid: 10 },
|
||||
{ pid: 12, parentPid: 11 },
|
||||
{ pid: 13, parentPid: 11 },
|
||||
{ pid: 14, parentPid: 10 },
|
||||
], 10, started)).toEqual([
|
||||
{ pid: 13, started: 't13' },
|
||||
{ pid: 11, started: 't11' },
|
||||
{ pid: 14, started: 't14' },
|
||||
{ pid: 10, started: 't10' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty walk for an absent root', () => {
|
||||
expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminates on a parent cycle instead of recursing forever', () => {
|
||||
const entries = [
|
||||
{ pid: 10, parentPid: 11 },
|
||||
{ pid: 11, parentPid: 10 },
|
||||
]
|
||||
expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WindowsProcessInspector (injected internals)', () => {
|
||||
it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => {
|
||||
const fake = fakeInternals()
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
expect(inspector.foregroundPgid(77)).toBe(77)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
expect(inspector.processSession(77)).toEqual([])
|
||||
})
|
||||
|
||||
it('delegates tree walks and identity checks to the internals', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.add({ pid: 10, parentPid: 0 }, 't10')
|
||||
fake.add({ pid: 11, parentPid: 10 }, 't11')
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 11, started: 't11' },
|
||||
{ pid: 10, started: 't10' },
|
||||
])
|
||||
expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true)
|
||||
expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false)
|
||||
expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false)
|
||||
|
||||
fake.add({ pid: 12, parentPid: 10 }, 't12', false)
|
||||
expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false)
|
||||
})
|
||||
|
||||
it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => {
|
||||
const fake = fakeInternals()
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
inspector.signalGroup(77, 'SIGKILL')
|
||||
inspector.signalGroup(77, 'SIGTERM')
|
||||
inspector.signalGroup(0, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[77, true], [77, false], [0, true]])
|
||||
})
|
||||
|
||||
it('signals a process only while its start identity matches', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.add({ pid: 10, parentPid: 0 }, 't10')
|
||||
fake.add({ pid: 11, parentPid: 10 }, 't11', false)
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL')
|
||||
inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL')
|
||||
inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM')
|
||||
expect(fake.kills).toEqual([[10, true]])
|
||||
})
|
||||
|
||||
it('accepts an injected internals factory through the creator', () => {
|
||||
const fake = fakeInternals()
|
||||
expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector)
|
||||
expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInvalidHandle', () => {
|
||||
it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => {
|
||||
const ptr = (value: bigint): NativePtr => value as NativePtr
|
||||
expect(isInvalidHandle(null)).toBe(true)
|
||||
expect(isInvalidHandle(undefined)).toBe(true)
|
||||
expect(isInvalidHandle(ptr(0n))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(-1n))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(1234n))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const win32 = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
win32('WindowsProcessInspector over the real koffi bindings', () => {
|
||||
it('walks the live process table from the test runner itself', () => {
|
||||
const inspector = createWindowsProcessInspector()
|
||||
const tree = inspector.processTree(process.pid)
|
||||
const self = tree.find(member => member.pid === process.pid)
|
||||
expect(self).toBeDefined()
|
||||
expect(inspector.isAlive(self!)).toBe(true)
|
||||
expect(inspector.foregroundPgid(process.pid)).toBe(process.pid)
|
||||
})
|
||||
|
||||
it('reports unreadable identities for absent processes and no-ops tree signalling', () => {
|
||||
const inspector = createWindowsProcessInspector()
|
||||
expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false)
|
||||
expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow()
|
||||
expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow()
|
||||
expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow()
|
||||
expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -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/terminal/terminal-bash/README.md
|
||||
README.md: 72f5b57335febe40b36de85e7df9b6df4bf7cb10
|
||||
README.zh.md: 48c051564130d283717828de35d625d65e825052
|
||||
README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba
|
||||
README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36
|
||||
|
||||
@@ -8,6 +8,8 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal`
|
||||
|
||||
The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic.
|
||||
|
||||
Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`.
|
||||
@@ -31,6 +33,7 @@ A standing-policy change appends an owner-rendered superseding runtime-context s
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported.
|
||||
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness.
|
||||
- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound.
|
||||
- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page.
|
||||
- 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.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。
|
||||
|
||||
`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。
|
||||
|
||||
就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。
|
||||
|
||||
取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。
|
||||
@@ -31,6 +33,7 @@
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 输出按行规范化;不支持全屏备用缓冲区交互。
|
||||
- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。
|
||||
- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。
|
||||
- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。
|
||||
- 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。
|
||||
- harness 进程退出后,会话无法继续存在。
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/** Validated configuration for the local PTY backend. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
|
||||
/** One supported interactive shell dialect. */
|
||||
export type ShellDialect = 'bash' | 'pwsh'
|
||||
|
||||
/** Public plugin configuration. */
|
||||
export interface Config {
|
||||
/** Backend registry type (default: `shell`). */
|
||||
backendType?: string
|
||||
/** Interactive shell executable (default: `/bin/bash`). */
|
||||
/** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
|
||||
shellDialect?: ShellDialect
|
||||
/** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
|
||||
shellPath?: string
|
||||
/** Shell arguments (default: `--noprofile --norc -i`). */
|
||||
/** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
|
||||
shellArgs?: string[]
|
||||
/** Terminal rows. */
|
||||
rows?: number
|
||||
@@ -37,14 +43,49 @@ export interface Config {
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Configuration after Schemastery defaults. */
|
||||
export type ResolvedConfig = Required<Config>
|
||||
/** Configuration after Schemastery defaults and dialect resolution. */
|
||||
export type ResolvedConfig = Omit<Required<Config>, 'shellDialect' | 'shellPath' | 'shellArgs'> & {
|
||||
shellDialect: ShellDialect
|
||||
shellPath: string
|
||||
shellArgs: string[]
|
||||
}
|
||||
|
||||
/** Bash dialect default executable. */
|
||||
export const DEFAULT_BASH_SHELL = '/bin/bash'
|
||||
/** Bash dialect default arguments (interactive, profile-free). */
|
||||
export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i']
|
||||
/** Pwsh dialect default arguments (interactive host, profile-free). */
|
||||
export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile']
|
||||
|
||||
/**
|
||||
* Resolve the effective per-dialect shell specification. Defaulting is this
|
||||
* explicit step: an unset or empty `shellPath`/`shellArgs` selects the
|
||||
* dialect's defaults, while a non-empty explicit value always wins.
|
||||
* (Schemastery materializes an absent optional array as `[]`, so emptiness —
|
||||
* not just `undefined` — means "dialect default".)
|
||||
* @param config - Schemastery-resolved plugin configuration.
|
||||
* @returns the fully resolved configuration.
|
||||
*/
|
||||
export function resolveConfig(config: Config): ResolvedConfig {
|
||||
const shellDialect = config.shellDialect ?? 'bash'
|
||||
return {
|
||||
...(config as Required<Config>),
|
||||
shellDialect,
|
||||
shellPath: config.shellPath !== undefined && config.shellPath.length > 0
|
||||
? config.shellPath
|
||||
: (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL),
|
||||
shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0
|
||||
? config.shellArgs
|
||||
: (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS),
|
||||
}
|
||||
}
|
||||
|
||||
/** Schemastery config exposed by the plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
backendType: z.string().default('shell'),
|
||||
shellPath: z.string().default('/bin/bash'),
|
||||
shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']),
|
||||
shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'),
|
||||
shellPath: z.string().required(false),
|
||||
shellArgs: z.array(z.string()).required(false),
|
||||
rows: z.number().default(40),
|
||||
cols: z.number().default(160),
|
||||
scrollbackLines: z.number().default(10_000),
|
||||
@@ -59,7 +100,7 @@ export const Config: z<Config> = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Assert every numeric config field is a positive safe integer and bounds compose.
|
||||
* Assert every effective numeric config field is a positive safe integer and bounds compose.
|
||||
* @param config - Schemastery-resolved plugin configuration.
|
||||
* @returns Narrows the input to the fully resolved configuration.
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh
|
||||
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts'
|
||||
import { LocalPtySession } from './session.ts'
|
||||
import { CONTROLLED_PROMPT } from './sanitize.ts'
|
||||
|
||||
@@ -52,25 +53,42 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
|
||||
}, { global: true })
|
||||
}
|
||||
|
||||
function childEnvironment(spec: TerminalBackendSpawnSpec): Record<string, string> {
|
||||
function childEnvironment(spec: TerminalBackendSpawnSpec, dialect: ShellDialect): Record<string, string> {
|
||||
// The subprocess provider supplies its own scrubbed ambient base; these are
|
||||
// deliberate terminal-specific overrides layered after it.
|
||||
return {
|
||||
const common = {
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: spec.owner.id,
|
||||
DSH_PTY_SESSION_ID: spec.sessionId,
|
||||
}
|
||||
if (dialect === 'pwsh') {
|
||||
// pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup
|
||||
// bootstrap instead, and NO_COLOR keeps the renderer quiet.
|
||||
return { ...common, NO_COLOR: '1' }
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
PS1: CONTROLLED_PROMPT,
|
||||
// Re-asserting PS1 after the marker keeps prompt readiness working when a
|
||||
// command overwrote the shell variable: bash runs PROMPT_COMMAND before
|
||||
// rendering each prompt, so an override never survives to the next prompt.
|
||||
PROMPT_COMMAND: `printf "\\033]133;D;%s\\007" "$?"; PS1='${CONTROLLED_PROMPT}'`,
|
||||
BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: spec.owner.id,
|
||||
DSH_PTY_SESSION_ID: spec.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
|
||||
* before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7`
|
||||
* build the control bytes at runtime because raw ESC characters in submitted
|
||||
* input are unreliable under PSReadLine.
|
||||
*/
|
||||
export const PWSH_PROMPT_SETUP =
|
||||
"function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }"
|
||||
|
||||
function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] {
|
||||
const argv = [config.shellPath, ...config.shellArgs]
|
||||
if (policy.mode === 'danger-full-access') return argv
|
||||
@@ -85,9 +103,45 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio
|
||||
// TODO(pty-initialize-race-home): Fold this outer abort race into
|
||||
// LocalPtySession.initialize when the send-state consolidation lands; the
|
||||
// session already owns the send lifecycle the race protects.
|
||||
async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise<void> {
|
||||
async function startupSession(
|
||||
session: LocalPtySession,
|
||||
dialect: ShellDialect,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const start = async (): Promise<void> => {
|
||||
if (dialect === 'bash') {
|
||||
await session.initialize(signal)
|
||||
return
|
||||
}
|
||||
// pwsh cannot install its prompt from the environment: write the prompt
|
||||
// function through the session and wait for the first marker prompt,
|
||||
// which is also the readiness contract of the bash initialize path. The
|
||||
// first send also pins UTF-8 output (the shared pwsh-local preamble)
|
||||
// before anything runs: the session decode path treats PTY bytes as
|
||||
// UTF-8, and an un-pinned console writes its host code page for
|
||||
// non-ASCII output. The banner-to-prompt gap can outlast the silence
|
||||
// bound, so the wait loops over follow-up sends until the controlled
|
||||
// prompt is actually visible (in the viewport or the retained scrollback
|
||||
// when it landed between sends), bounded by the send deadline.
|
||||
let viewport = ''
|
||||
for (;;) {
|
||||
const first = viewport.length === 0
|
||||
const operation = session.startSend({
|
||||
text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '',
|
||||
submit: first,
|
||||
...signal !== undefined ? { signal } : {},
|
||||
})
|
||||
const result = await operation.done
|
||||
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
||||
viewport = result.viewport
|
||||
const scrollback = session.read({ offset: 0, count: 20 }).text
|
||||
if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break
|
||||
}
|
||||
session.motd = viewport
|
||||
}
|
||||
if (signal === undefined) {
|
||||
await session.initialize(signal)
|
||||
await start()
|
||||
return
|
||||
}
|
||||
const aborted = Promise.withResolvers<never>()
|
||||
@@ -95,7 +149,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
await Promise.race([session.initialize(signal), aborted.promise])
|
||||
await Promise.race([start(), aborted.promise])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
@@ -128,7 +182,7 @@ export class BashTerminalBackend implements TerminalBackend {
|
||||
const terminal = await this.spawnTerminal({
|
||||
argv,
|
||||
cwd: spec.cwd ?? policy.workspaceRoot,
|
||||
env: childEnvironment(spec),
|
||||
env: childEnvironment(spec, this.config.shellDialect),
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
graceMs: this.config.disposeGraceMs,
|
||||
@@ -136,7 +190,7 @@ export class BashTerminalBackend implements TerminalBackend {
|
||||
})
|
||||
const session = this.createSession(terminal, this.config)
|
||||
try {
|
||||
await initializeSession(session, spec.signal)
|
||||
await startupSession(session, this.config.shellDialect, spec.signal)
|
||||
return session
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -151,6 +205,7 @@ export class BashTerminalBackend implements TerminalBackend {
|
||||
|
||||
/** Register the local PTY backend. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
validateConfig(config)
|
||||
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, config))
|
||||
const resolved = resolveConfig(config)
|
||||
validateConfig(resolved)
|
||||
ctx.terminals.registerBackend(new BashTerminalBackend(ctx, resolved))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Config } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
import { validateConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
|
||||
function config(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
|
||||
backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160,
|
||||
scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000,
|
||||
disposeGraceMs: 100,
|
||||
@@ -30,3 +30,43 @@ describe('terminal-bash config', () => {
|
||||
expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-bash dialect resolution', () => {
|
||||
it('defaults bash argv to the interactive profile-free form', () => {
|
||||
const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 })
|
||||
expect(shellDialect).toBe('bash')
|
||||
expect(shellPath).toBe('/bin/bash')
|
||||
expect(shellArgs).toEqual(['--noprofile', '--norc', '-i'])
|
||||
})
|
||||
|
||||
it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => {
|
||||
const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })
|
||||
expect(resolved.shellDialect).toBe('pwsh')
|
||||
expect(resolved.shellPath.length).toBeGreaterThan(0)
|
||||
expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile'])
|
||||
})
|
||||
|
||||
it('lets an explicit shell specification win over the dialect defaults', () => {
|
||||
const resolved = resolveConfig({
|
||||
backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80,
|
||||
})
|
||||
expect(resolved.shellPath).toBe('/custom/pwsh')
|
||||
expect(resolved.shellArgs).toEqual(['-NoProfile'])
|
||||
})
|
||||
|
||||
it('treats empty shell values as unset so Schemastery materialization cannot drop the dialect defaults', () => {
|
||||
// Schemastery materializes an absent optional array as `[]`; the resolver
|
||||
// must treat that shape like an unset value or a real bash spawn would
|
||||
// start non-interactive without the controlled prompt.
|
||||
const resolved = resolveConfig({
|
||||
backendType: 'shell', shellDialect: 'bash', shellPath: '', shellArgs: [], rows: 24, cols: 80,
|
||||
})
|
||||
expect(resolved.shellPath).toBe('/bin/bash')
|
||||
expect(resolved.shellArgs).toEqual(['--noprofile', '--norc', '-i'])
|
||||
})
|
||||
|
||||
it('validates the effective shell path, not only the raw one', () => {
|
||||
expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 })) }).not.toThrow()
|
||||
expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })) }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -8,7 +9,9 @@ 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'
|
||||
import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
|
||||
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
|
||||
import type { TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal'
|
||||
import { BashTerminalBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-terminal-bash'
|
||||
import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||||
import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
|
||||
@@ -37,7 +40,7 @@ class RecordingSandbox extends SandboxProvider {
|
||||
|
||||
function config(): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
|
||||
disposeGraceMs: 10,
|
||||
@@ -216,7 +219,7 @@ describe('BashTerminalBackend startup rollback', () => {
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' },
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') },
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -244,11 +247,11 @@ describe('BashTerminalBackend startup rollback', () => {
|
||||
|
||||
expect(spawned).toMatchObject({
|
||||
argv: ['/sandbox', '--', '/bin/bash', '-i'],
|
||||
cwd: '/session-workspace',
|
||||
cwd: resolve('/session-workspace'),
|
||||
})
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' },
|
||||
policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') },
|
||||
}])
|
||||
})
|
||||
|
||||
@@ -338,6 +341,132 @@ describe('BashTerminalBackend startup rollback', () => {
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
await session.close('test complete')
|
||||
})
|
||||
|
||||
it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
let spawned: SubprocessTerminalSpawnSpec | undefined
|
||||
let sent: TerminalSendRequest | undefined
|
||||
const session = {
|
||||
motd: '',
|
||||
startSend: (request: TerminalSendRequest) => {
|
||||
sent = request
|
||||
return {
|
||||
done: Promise.resolve({
|
||||
viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const,
|
||||
sessionStatus: { kind: 'running' as const }, truncated: false,
|
||||
}),
|
||||
readOutput: () => ({ delta: '', truncated: false }),
|
||||
cancel: () => false,
|
||||
}
|
||||
},
|
||||
read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
|
||||
} as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
{ ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
|
||||
async (spec) => { spawned = spec; return terminalHandle() },
|
||||
() => session,
|
||||
)
|
||||
expect(await backend.spawn(spec(agent(ctx)))).toBe(session)
|
||||
expect(sent).toMatchObject({ text: ENCODING_PREAMBLE + PWSH_PROMPT_SETUP, submit: true })
|
||||
expect(session.motd).toBe('setup-echo dsh> ')
|
||||
expect(spawned?.env).toMatchObject({
|
||||
TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
})
|
||||
expect(spawned?.env?.PS1).toBeUndefined()
|
||||
expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps waiting for the marker prompt when the first send settles on silence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
const sends: TerminalSendRequest[] = []
|
||||
const session = {
|
||||
motd: '',
|
||||
startSend: (request: TerminalSendRequest) => {
|
||||
sends.push(request)
|
||||
const second = sends.length > 1
|
||||
return {
|
||||
done: Promise.resolve({
|
||||
viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n',
|
||||
waitReason: 'inferred_idle' as const,
|
||||
sessionStatus: { kind: 'running' as const }, truncated: false,
|
||||
}),
|
||||
readOutput: () => ({ delta: '', truncated: false }),
|
||||
cancel: () => false,
|
||||
}
|
||||
},
|
||||
read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
|
||||
} as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
{ ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
|
||||
async () => terminalHandle(),
|
||||
() => session,
|
||||
)
|
||||
await backend.spawn(spec(agent(ctx)))
|
||||
expect(sends).toHaveLength(2)
|
||||
expect(sends[1]).toMatchObject({ text: '', submit: false })
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
})
|
||||
|
||||
it('rejects a pwsh bootstrap whose shell exits or times out', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
const sessionFor = (waitReason: TerminalWaitReason): LocalPtySession => ({
|
||||
startSend: () => ({
|
||||
done: Promise.resolve({
|
||||
viewport: 'no-prompt', waitReason,
|
||||
sessionStatus: { kind: 'running' as const }, truncated: false,
|
||||
}),
|
||||
readOutput: () => ({ delta: '', truncated: false }),
|
||||
cancel: () => false,
|
||||
}),
|
||||
read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
|
||||
close: () => Promise.resolve(),
|
||||
}) as unknown as LocalPtySession
|
||||
const exited = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit'))
|
||||
await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup')
|
||||
const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout'))
|
||||
await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout')
|
||||
})
|
||||
|
||||
it('forwards the spawn signal into the pwsh bootstrap sends', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
const sends: TerminalSendRequest[] = []
|
||||
const session = {
|
||||
motd: '',
|
||||
startSend: (request: TerminalSendRequest) => {
|
||||
sends.push(request)
|
||||
return {
|
||||
done: Promise.resolve({
|
||||
viewport: 'dsh> ', waitReason: 'stdin_read' as const,
|
||||
sessionStatus: { kind: 'running' as const }, truncated: false,
|
||||
}),
|
||||
readOutput: () => ({ delta: '', truncated: false }),
|
||||
cancel: () => false,
|
||||
}
|
||||
},
|
||||
read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }),
|
||||
} as unknown as LocalPtySession
|
||||
const backend = new BashTerminalBackend(
|
||||
ctx,
|
||||
{ ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' },
|
||||
async () => terminalHandle(),
|
||||
() => session,
|
||||
)
|
||||
const signal = new AbortController().signal
|
||||
const spawned = await backend.spawn({ ...spec(agent(ctx)), signal })
|
||||
expect(spawned.motd).toBe('dsh> ')
|
||||
expect(sends).toHaveLength(1)
|
||||
expect(sends[0]?.signal).toBe(signal)
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-bash plugin shape', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -12,6 +13,7 @@ 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 LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
||||
async function harness(
|
||||
mode: 'danger-full-access' | 'workspace-write',
|
||||
timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {},
|
||||
dialect: 'bash' | 'pwsh' = 'bash',
|
||||
) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-'))
|
||||
roots.push(root)
|
||||
@@ -60,6 +63,7 @@ async function harness(
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
shellDialect: dialect,
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
idleSilenceMs: timing.idleSilenceMs ?? 250,
|
||||
@@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal-bash real shell', () => {
|
||||
// The real-shell suite drives a POSIX bash over the actual node-pty terminal;
|
||||
// Windows has no bash, and its pwsh counterpart lives in the describe below.
|
||||
describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => {
|
||||
it('persists cwd and environment across sends, scrubs secrets, and closes', async () => {
|
||||
const previous = process.env.DSH_TEST_SECRET
|
||||
process.env.DSH_TEST_SECRET = 'must-not-leak'
|
||||
@@ -267,3 +273,72 @@ describe('terminal-bash real shell', () => {
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
}, 35_000)
|
||||
})
|
||||
|
||||
const hasPwsh = spawnSync(
|
||||
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
|
||||
{ encoding: 'utf8' },
|
||||
).status === 0
|
||||
|
||||
describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => {
|
||||
it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => {
|
||||
const previous = process.env.DSH_TEST_SECRET
|
||||
process.env.DSH_TEST_SECRET = 'must-not-leak'
|
||||
try {
|
||||
const { ctx, root, agent } = await harness('danger-full-access', {
|
||||
idleSilenceMs: 300,
|
||||
handoffGraceMs: 300,
|
||||
timeoutMs: 8_000,
|
||||
}, 'pwsh')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
|
||||
expect(created.motd).toContain('dsh> ')
|
||||
|
||||
const first = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: '$env:KEEP = "ok"; Set-Location /',
|
||||
submit: true,
|
||||
})
|
||||
expect((await first.done).waitReason).toBe('stdin_read')
|
||||
const second = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"',
|
||||
submit: true,
|
||||
})
|
||||
const result = await second.done
|
||||
expect(result.viewport).toContain('keep=ok')
|
||||
expect(result.viewport).toContain('secret=')
|
||||
expect(result.viewport).not.toContain('must-not-leak')
|
||||
|
||||
expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok')
|
||||
expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true)
|
||||
expect(ctx.terminals.list(agent)).toEqual([])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.DSH_TEST_SECRET
|
||||
else process.env.DSH_TEST_SECRET = previous
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('pins UTF-8 output encoding so non-ASCII output survives the byte decode', async () => {
|
||||
const { ctx, root, agent } = await harness('danger-full-access', {
|
||||
idleSilenceMs: 300,
|
||||
handoffGraceMs: 300,
|
||||
timeoutMs: 8_000,
|
||||
}, 'pwsh')
|
||||
const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root })
|
||||
// The bootstrap itself must have pinned both encodings: the session byte
|
||||
// decode is UTF-8, so an un-pinned console writing its host code page
|
||||
// garbles every non-ASCII byte that follows.
|
||||
const pinned = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: '"console=" + [Console]::OutputEncoding.WebName + " out=" + $OutputEncoding.WebName',
|
||||
submit: true,
|
||||
})
|
||||
const pinnedResult = await pinned.done
|
||||
expect(pinnedResult.viewport).toContain('console=utf-8 out=utf-8')
|
||||
// Char codes keep the submitted line ASCII-only, so the assertion is a
|
||||
// pure output-decode check.
|
||||
const sent = ctx.terminals.startSend(agent, created.sessionId, {
|
||||
text: "[Console]::Write([char]0x4E2D + [char]0x6587 + ' encoding-ok')",
|
||||
submit: true,
|
||||
})
|
||||
const result = await sent.done
|
||||
expect(result.viewport).toContain('中文 encoding-ok')
|
||||
await ctx.terminals.kill(agent, created.sessionId)
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ function makeSession(
|
||||
|
||||
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
||||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
|
||||
disposeGraceMs: 20,
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../shell/pwsh-local"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -398,9 +398,10 @@ describe('typert loader', () => {
|
||||
|
||||
await ctx.loader.create({ name: '@fixture/steady-failure' })
|
||||
await ctx.loader.await()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
|
||||
expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' }))
|
||||
// The failing contributor's error is reported on the post-await flush.
|
||||
await vi.waitFor(() => {
|
||||
expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' }))
|
||||
}, { timeout: 10_000 })
|
||||
expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+70
@@ -270,6 +270,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-pwsh':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shell/tool-pwsh
|
||||
'@deepseek-ai/dsh-tool-pwsh-persistent':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shell/tool-pwsh-persistent
|
||||
'@deepseek-ai/dsh-tool-ralph':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/workflow/tool-ralph
|
||||
@@ -688,6 +691,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-pwsh':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/shell/tool-pwsh
|
||||
'@deepseek-ai/dsh-tool-pwsh-persistent':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/shell/tool-pwsh-persistent
|
||||
'@deepseek-ai/dsh-tool-ralph':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/workflow/tool-ralph
|
||||
@@ -6952,6 +6958,61 @@ importers:
|
||||
specifier: workspace:^
|
||||
version: link:../../interaction/user-approval
|
||||
|
||||
packages/shell/tool-pwsh-persistent:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/cordis-plugin-include':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/include
|
||||
'@deepseek-ai/cordis-plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../pwsh-local
|
||||
'@deepseek-ai/dsh-sandbox':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../sandbox/sandbox-policy
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-subprocess-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess-local
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-terminal':
|
||||
specifier: workspace:^
|
||||
version: link:../../terminal/terminal
|
||||
'@deepseek-ai/dsh-terminal-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../../terminal/terminal-bash
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
packages/skill/skill:
|
||||
dependencies:
|
||||
'@deepseek-ai/schemastery':
|
||||
@@ -7721,6 +7782,9 @@ importers:
|
||||
|
||||
packages/subprocess/subprocess-local:
|
||||
dependencies:
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.1
|
||||
node-pty:
|
||||
specifier: 1.2.0-beta.15
|
||||
version: 1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0)
|
||||
@@ -7761,6 +7825,9 @@ importers:
|
||||
|
||||
packages/terminal/terminal-bash:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../shell/pwsh-local
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -8686,6 +8753,9 @@ importers:
|
||||
'@deepseek-ai/dsh-plan-mode':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/plan/plan-mode
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/shell/pwsh-local
|
||||
'@deepseek-ai/dsh-repeat-tool-reminder':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/guard/repeat-tool-reminder
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-persona": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal": "workspace:^",
|
||||
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^",
|
||||
|
||||
@@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
|
||||
import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
@@ -283,6 +284,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
note:
|
||||
'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-pwsh-persistent',
|
||||
dir: 'tool-pwsh-persistent',
|
||||
source: 'packages/shell/tool-pwsh-persistent/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.terminals', 'an owning Agent at execution time'],
|
||||
writes: ['tool/call', 'PTY shell state', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(TerminalSessionService)
|
||||
await ctx.plugin(ToolPwshPersistent)
|
||||
},
|
||||
note:
|
||||
'One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-str-replace-editor',
|
||||
dir: 'tool-str-replace-editor',
|
||||
|
||||
@@ -203,6 +203,7 @@
|
||||
{ "path": "./packages/terminal/terminal" },
|
||||
{ "path": "./packages/terminal/terminal-bash" },
|
||||
{ "path": "./packages/shell/tool-bash-persistent" },
|
||||
{ "path": "./packages/shell/tool-pwsh-persistent" },
|
||||
{ "path": "./packages/terminal/tool-terminal" },
|
||||
{ "path": "./packages/code-runtime/code-runtime" },
|
||||
{ "path": "./packages/code-runtime/code-runtime-python" },
|
||||
|
||||
@@ -57,6 +57,10 @@ const windowsUnsupportedCoveragePackages = process.platform === 'win32'
|
||||
const windowsOnlyCoverageExclusions = process.platform !== 'win32'
|
||||
? [
|
||||
'packages/sandbox/sandbox-windows-acl/src/**/*.ts',
|
||||
// The koffi-backed Win32 table (Toolhelp32/GetProcessTimes/taskkill)
|
||||
// executes only on win32; its decision logic is unit-pinned on every
|
||||
// host through the injected-internals suites.
|
||||
'packages/subprocess/subprocess-local/src/windows-inspector.ts',
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user