diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 80a1959b5a..b1391a43f8 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -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/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 +2026-08-11-pwsh-persistent-pty.md: 4c523d3c7c45e6d86942868df92b981576e76859 +2026-08-11-pwsh-persistent-pty.zh.md: 4f87490fd60ecc37ad9390e0ce990173bbafc3b8 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 8353b3ab3c..4c523d3c7c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -24,7 +24,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### 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). +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`; the bash argv and environment defaults remain unchanged). 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 accepts only the backend's `stdin_read` result; a printable prompt literal in echoed setup input is not readiness. One `timeoutMs` deadline owns the complete startup retry loop, so `inferred_idle` follow-up sends cannot restart the bound. A zero-scrollback `@xterm/headless` instance consumes raw PTY data and emits terminal-protocol replies through `SubprocessTerminalHandle`; the backend drains those writes before caller input and accepts foreground state only when protocol work stayed quiet throughout inspection, so a caller's input cannot be consumed as a cursor-position response. One parser write stays active while later raw chunks coalesce into the next batch, preventing high-volume output from creating one scheduled parse per chunk. The existing sanitizer and bounded buffers remain the output projection. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic stay shared — the marker remains a readiness signal with an unconsumed payload, and the deferred BEL event channel stays deferred. ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### 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. +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 session suite pins split cursor-position queries, response-write ordering, and parse batching without a real shell; real-pwsh suites on macOS and Windows prove persistent cwd/env, secret scrubbing, UTF-8 output, multiline and here-string commands, large-output clipping, and exit/reset. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -46,6 +46,7 @@ The Windows test surface follows master's exemption structure: terminal-bash and - **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. +- **Hand-written cursor-position replies.** Rejected: the response must reflect cursor movement, wrapping, and control sequences already emitted by the shell. Fixed coordinates amplify console redraws and can exhaust bounded output; `@xterm/headless` maintains that protocol state without replacing the line-oriented output projection. - **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. @@ -62,4 +63,6 @@ The Windows test surface follows master's exemption structure: terminal-bash and **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. +**Terminal protocol replies precede caller input.** The headless emulator retains no scrollback and contributes no model-visible text; it tracks terminal control state and emits replies through the mounted subprocess provider. This adds the maintained `@xterm/headless` runtime dependency and prevents a cursor query from consuming a later tool command. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; if marker readiness remains unavailable, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. A later model redefinition of the `prompt` function degrades command readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi and `@xterm/headless` add process-substrate and terminal-backend dependency review respectively. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 95048a0241..4f87490fd6 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -24,7 +24,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@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 事件通道保持延后)。 +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`;bash 的 argv 和环境默认值保持不变)。有效 `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 函数,并且只接受 backend 的 `stdin_read` 结果;回显引导输入中的可打印提示符字面量不代表就绪。一条 `timeoutMs` 绝对超时计时器负责限制完整启动重试循环,因此 `inferred_idle` 后续 send 无法重新计时。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 数据,并通过 `SubprocessTerminalHandle` 发出终端协议响应;backend 会在调用方输入前排空这些写入,并且只接受协议工作在整次检查期间保持静止时的前台状态,因此调用方输入不会被当作光标位置响应而消费。一个 parser 写入保持活跃,随后到达的原始 chunk 会合并为下一批,从而避免高输出量为每个 chunk 分别调度解析。现有 sanitizer 与有界缓冲区仍负责输出投影。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 `PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑保持共享——标记仍是载荷不被消费的就绪信号,延后的 BEL 事件通道也继续保持延后。 ### `@deepseek-ai/dsh-tool-pwsh-persistent` @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -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 与结果。 +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 模式矩阵并加回显剥离模式。session 套件无需真实 shell 即可固定拆分的光标位置查询、响应写入顺序与解析批处理;macOS 和 Windows 上的真实 pwsh 套件证明持久 cwd/env、密钥清洗、UTF-8 输出、多行与 here-string 命令、大输出裁剪及退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -46,6 +46,7 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo - **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 - **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 - **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **手写光标位置响应。** 拒绝:响应必须反映 shell 已经发出的光标移动、换行折叠和控制序列。固定坐标会放大控制台重绘并可能耗尽有界输出;`@xterm/headless` 会维护这份协议状态,但不取代逐行输出投影。 - **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 - **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 - **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 @@ -62,4 +63,6 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**终端协议响应先于调用方输入。** headless 模拟器不保留 scrollback,也不贡献模型可见文本;它跟踪终端控制状态,并通过已挂载的进程管理提供方发出响应。这会增加受维护的 `@xterm/headless` 运行时依赖,并避免光标查询消费后续工具命令。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;若 marker 就绪持续不可用,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。模型后来重定义 `prompt` 函数会使命令就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 与 `@xterm/headless` 分别增加进程基座和终端后端的依赖评审。 diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml index 56c6d31682..f6b59921ba 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.i18n.yaml @@ -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/process/2026-08-06-in-repository-landlock-release.md -2026-08-06-in-repository-landlock-release.md: 82b21cc0c30338ad11583797f011794b8dbcc90c -2026-08-06-in-repository-landlock-release.zh.md: 554967fbce454fc9a45b54d735f485006f9dee51 +2026-08-06-in-repository-landlock-release.md: 25c31c3cdcc57cbcc8bd09b82ca24898ebca8268 +2026-08-06-in-repository-landlock-release.zh.md: 71cd2b7fe342003bc458e98ee3d2e25496b535bb diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md index 82b21cc0c3..25c31c3cdc 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.md @@ -22,7 +22,7 @@ The public npm boundary is three organization-owned packages with one launcher-f The main repository owns both native CI and publication. `Landlock Run` runs for relevant pull requests and `master` pushes and builds each platform on its matching native runner. The manually dispatched `Landlock Run Release` workflow builds both platform binaries, transfers them as workflow artifacts, assembles and verifies the complete package family, packs immutable npm tarballs, installs and exercises those tarballs, and only then permits the protected publish job. Platform tarballs publish before the entry tarball that optionally depends on them. Publication uses `landlock-run-vX.Y.Z` tags so launcher releases cannot collide with other release families in the monorepo; prereleases use the npm `next` dist-tag. -The sandbox packed-install rehearsal no longer permits the npm registry to supply the launcher. It packs the current checkout's entry and matching native package alongside the harness dependency closure, installs those local tarballs into an external plain-Node consumer, and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. +The sandbox packed-install rehearsal does not permit the npm registry to supply the launcher. It derives the harness closure transitively from current workspace `dependencies`, `optionalDependencies`, and required `peerDependencies`; the native family stays separate because its mode-preserving pack script supplies the entry and matching platform package. The rehearsal installs those local tarballs into an external plain-Node consumer and proves that the installed launcher is executable, byte-identical to the native build, and the correct ELF architecture before testing confinement or fail-closed behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md index 554967fbce..71cd2b7fe3 100644 --- a/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-in-repository-landlock-release.zh.md @@ -22,7 +22,7 @@ Status: implemented 主仓库同时负责原生 CI 和发布。`Landlock Run` 会为相关 PR 和 `master` 推送运行,并在各自匹配的原生 runner 上构建每个平台包。手动触发的 `Landlock Run Release` 工作流会构建两个平台的二进制文件,将其作为工作流产物传递,组装并验证完整的包家族,打包出内容不可变的 npm tarball,安装并实际运行这些 tarball,之后才允许受保护的发布作业执行。发布顺序是平台 tarball 在前,最后发布将它们列为可选依赖的入口 tarball。发布使用 `landlock-run-vX.Y.Z` tag,避免启动器版本与 monorepo 中其他发布家族发生冲突;预发布版本使用 npm 的 `next` dist-tag。 -沙箱打包安装演练不再允许 npm 注册表提供启动器。它会将当前 checkout 的入口包、匹配的原生包和 harness 依赖闭包一起打包,把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 +沙箱打包安装演练不允许 npm 注册表提供启动器。它会根据当前 workspace 的 `dependencies`、`optionalDependencies` 与必需 `peerDependencies` 递归推导 harness 闭包;原生包家族保持独立,因为保留文件模式的打包脚本会提供入口包和匹配平台包。演练把这些本地 tarball 安装到仓库外部的纯 Node 消费方中,并在测试约束效果或失败闭合行为之前,证明所安装的启动器可执行、与原生构建产物字节完全一致,且具有正确的 ELF 架构。 ## 曾考虑的替代方案 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 436dc11642..b82665e1c9 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -53,6 +53,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c9e8aa0367..775907aed0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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: 7636b1e3c7933f6d70a8e40d761b3617e746c13d -config-catalog.zh.md: 4567f09059a05d3d87f336caa55fa7db831145af +config-catalog.md: f255e38fdbc3c5831a625510110bb8a52ea280ad +config-catalog.zh.md: f1f6774d957858e452f7120baeb78ef1712c6543 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7636b1e3c7..f255e38fdb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2440,7 +2440,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4567f09059..f1f6774d95 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2442,7 +2442,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 135ebb8494..8f353ffcd2 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { packedWorkspaceClosure, readWorkspacePackages } from './packed-workspace-closure.ts' /** * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework @@ -25,31 +26,7 @@ const nativeDir = join(repoRoot, 'native/landlock-run') const sourceLauncher = join(nativeDir, 'packages', `linux-${process.arch}`, 'bin', 'landlock-run') const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${process.arch}` -/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */ -const WORKSPACE_CLOSURE = [ - 'packages/sandbox/sandbox-local', - // sandbox-local's win32 chain rung is a runtime dependency: a packed - // consumer resolves it like any other @deepseek-ai peer (koffi arrives - // from the registry). - 'packages/sandbox/sandbox-windows-acl', - 'packages/subprocess/win32-process', - 'packages/sandbox/sandbox', - 'packages/core/session', - 'packages/core/scope', - 'packages/llm/llm', - 'packages/typert/protocol', - 'packages/attachment/attachment', - 'packages/util/brand', - 'packages/util/timeout', - 'packages/runtime-diagnostics/invariants', - // The framework and the vendored packages the closure declares outright: - // rescoped into @deepseek-ai, so the consumer installs this repository's - // copies. Schemastery is a hard dependency of three members above, not a - // peer, so npm resolves it while installing them. - 'vendor/cordis', - 'vendor/cosmokit', - 'vendor/schemastery', -] +const NATIVE_PACKAGE_PREFIX = '@deepseek-ai/node-addon-landlock-run' /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ const E_MACHINE = { x64: 62, arm64: 183 }[process.arch as 'x64' | 'arm64'] @@ -93,15 +70,22 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- .split('\n') .map(tarball => join(nativePackDest, tarball)) + // Derive the current runtime closure so a newly introduced workspace + // dependency cannot fall through to an unpublished registry version. + const workspaceClosure = packedWorkspaceClosure( + '@deepseek-ai/dsh-sandbox-local', + readWorkspacePackages(repoRoot), + ).filter(member => !member.name.startsWith(NATIVE_PACKAGE_PREFIX)) + // Pack each harness closure member with the exact bytes publish would upload. const tarballs: string[] = [] - for (const pkg of WORKSPACE_CLOSURE) { + for (const pkg of workspaceClosure) { const pack = spawnSync('pnpm', ['pack', '--pack-destination', packDest], { - cwd: join(repoRoot, pkg), + cwd: pkg.directory, encoding: 'utf8', timeout: 120_000, }) - expect(pack.status, `pnpm pack failed for ${pkg}:\n${pack.stdout}\n${pack.stderr}`).toBe(0) + expect(pack.status, `pnpm pack failed for ${pkg.name}:\n${pack.stdout}\n${pack.stderr}`).toBe(0) const lines = pack.stdout.trim().split('\n') tarballs.push(lines[lines.length - 1] as string) } diff --git a/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts new file mode 100644 index 0000000000..103a4e4b16 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { packedWorkspaceClosure, type WorkspacePackage } from './packed-workspace-closure.ts' + +function pkg(name: string, manifest: Record = {}): WorkspacePackage { + return { name, directory: `/workspace/${name}`, manifest } +} + +describe('packed workspace closure', () => { + it('follows install edges and required peers but excludes development and optional peers', () => { + const packages = new Map([ + ['root', pkg('root', { + dependencies: { installed: 'workspace:^' }, + optionalDependencies: { optional: 'workspace:^' }, + peerDependencies: { required: 'workspace:^', omitted: 'workspace:^' }, + peerDependenciesMeta: { omitted: { optional: true } }, + devDependencies: { development: 'workspace:^' }, + })], + ['installed', pkg('installed', { dependencies: { transitive: 'workspace:^', external: '^1.0.0' } })], + ['optional', pkg('optional')], + ['required', pkg('required')], + ['omitted', pkg('omitted')], + ['development', pkg('development')], + ['transitive', pkg('transitive')], + ]) + + expect(packedWorkspaceClosure('root', packages).map(entry => entry.name)) + .toEqual(['installed', 'optional', 'required', 'root', 'transitive']) + }) + + it('fails when a workspace dependency is absent from the inventory', () => { + const packages = new Map([ + ['root', pkg('root', { dependencies: { missing: 'workspace:^' } })], + ]) + expect(() => packedWorkspaceClosure('root', packages)) + .toThrow('packed workspace closure cannot resolve missing') + }) +}) diff --git a/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts new file mode 100644 index 0000000000..8f24e953f2 --- /dev/null +++ b/packages/sandbox/sandbox-local/tests/packed-workspace-closure.ts @@ -0,0 +1,101 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const + +interface WorkspaceListEntry { + name: string + path: string +} + +/** One workspace manifest available to the packed-install rehearsal. */ +export interface WorkspacePackage { + name: string + directory: string + manifest: Record +} + +function dependencyEntries( + manifest: Record, + section: (typeof RUNTIME_SECTIONS)[number], +): [string, string][] { + const value = manifest[section] + if (value === null || typeof value !== 'object' || Array.isArray(value)) return [] + return Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') +} + +function optionalPeer(manifest: Record, name: string): boolean { + const metadata = manifest.peerDependenciesMeta + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return false + const entry = (metadata as Record)[name] + return entry !== null && typeof entry === 'object' && !Array.isArray(entry) + && (entry as Record).optional === true +} + +/** + * Read the root pnpm workspace inventory and its package manifests. + * @param repoRoot - repository root containing the pnpm workspace. + * @returns Workspace packages indexed by package name. + */ +export function readWorkspacePackages(repoRoot: string): Map { + const listed = spawnSync('pnpm', ['list', '--recursive', '--depth', '-1', '--json'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_000, + }) + if (listed.status !== 0) { + throw new Error(`pnpm workspace inventory failed:\n${listed.stdout}\n${listed.stderr}`) + } + const parsed: unknown = JSON.parse(listed.stdout) + if (!Array.isArray(parsed)) throw new Error('pnpm workspace inventory is not an array') + const packages = new Map() + for (const value of parsed) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('pnpm workspace inventory contains a non-object entry') + } + const { name, path } = value as Partial + if (typeof name !== 'string' || typeof path !== 'string') { + throw new Error('pnpm workspace inventory entry lacks name/path') + } + const parsedManifest: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) + if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) { + throw new Error(`${path}/package.json is not an object`) + } + const manifest = parsedManifest as Record + if (manifest.name !== name) throw new Error(`${path}/package.json does not declare ${name}`) + if (packages.has(name)) throw new Error(`pnpm workspace inventory repeats ${name}`) + packages.set(name, { name, directory: path, manifest }) + } + return packages +} + +/** + * Follow install dependencies and required peers inside one workspace. + * @param rootName - package whose consumer closure is required. + * @param packages - workspace packages indexed by package name. + * @returns Transitive runtime closure sorted by package directory. + */ +export function packedWorkspaceClosure( + rootName: string, + packages: ReadonlyMap, +): WorkspacePackage[] { + const closure: WorkspacePackage[] = [] + const visited = new Set() + const visit = (name: string): void => { + if (visited.has(name)) return + visited.add(name) + const current = packages.get(name) + if (current === undefined) throw new Error(`packed workspace closure cannot resolve ${name}`) + closure.push(current) + for (const section of RUNTIME_SECTIONS) { + for (const [dependency, range] of dependencyEntries(current.manifest, section)) { + if (!range.startsWith('workspace:')) continue + if (section === 'peerDependencies' && optionalPeer(current.manifest, dependency)) continue + visit(dependency) + } + } + } + visit(rootName) + return closure.sort((left, right) => left.directory.localeCompare(right.directory)) +} diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 1a95d7fe23..2a1df9e944 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -72,7 +72,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { 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-')) + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-'))) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-agent'", @@ -93,7 +93,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ' idleSilenceMs: 300', ' handoffGraceMs: 300', ' scrollbackLines: 20000', - ' timeoutMs: 8000', + ' timeoutMs: 60000', ' disposeGraceMs: 500', "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", ' config:', diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index d6e544137a..4f1c384f4a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -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: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba -README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 +README.md: 2bc6dab36c8160976928a0ccf743407d27a314f2 +README.zh.md: 828f65dd5cfe71482bf74163837df75c2ed16d6a diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 2f3f59b1ac..2bc6dab36c 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ 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. +`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 accepts startup only after the backend reports `stdin_read`; echoed setup text cannot publish the shell. One `timeoutMs` deadline starts before the complete pwsh startup loop, so an `inferred_idle` follow-up send does not restart the bound. Its environment drops the bash-only markers and adds `NO_COLOR`, while the first send prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs. A zero-scrollback `@xterm/headless` instance consumes the raw PTY stream and writes terminal-protocol replies, including cursor-position reports required by Unix pwsh, through the same terminal handle. The backend drains those replies before every caller input and samples foreground state only after the protocol state stayed quiet throughout inspection. It keeps one parser write active and coalesces later raw chunks into the next batch, so high-volume output does not schedule one parser task per chunk. The line-oriented sanitizer and bounded buffers remain the only output projection. 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. @@ -32,8 +32,8 @@ 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. +- A headless xterm instance maintains control-sequence state only for terminal-protocol replies. Returned output remains line-oriented and 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. 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. +- 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. When that prevents marker readiness, startup rejects at `timeoutMs` instead of publishing a shell whose setup did not complete. - 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. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index f3daa0a3bc..828f65dd5c 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `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 标记,因此就绪机制与消费方与方言无关。 +`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` 函数,并且只在后端报告 `stdin_read` 后才接受启动;回显的引导文本不能发布 shell。系统会在完整的 pwsh 启动循环之前启动一条 `timeoutMs` 绝对超时计时器,因此 `inferred_idle` 后续 send 不会重新计时。其环境去掉 bash 专属标记并加 `NO_COLOR`,同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8。一个不保留 scrollback 的 `@xterm/headless` 实例会消费原始 PTY 流,并通过同一终端句柄写回终端协议响应,包括 Unix pwsh 所需的光标位置报告。后端会在每次调用方输入前排空这些响应,并且只使用协议状态在整次检查期间保持静止后采样的前台状态。它只保留一个活跃 parser 写入,并把后来到达的原始 chunk 合并为下一批,因此高输出量不会为每个 chunk 分别调度 parser 任务。逐行 sanitizer 与有界缓冲区仍是唯一的输出投影。两种方言发出相同的 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 合并为一个换行。 @@ -32,8 +32,8 @@ ## 已知限制与暂缓事项 -- 输出按行规范化;不支持全屏备用缓冲区交互。 +- headless xterm 实例仅为终端协议响应维护控制序列状态。返回输出仍按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。若因此无法获得 marker 就绪,启动会在 `timeoutMs` 到期时拒绝,而不会发布引导未完成的 shell。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index cb3dccce08..9123778a22 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -43,7 +43,8 @@ }, "dependencies": { "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@xterm/headless": "^6.0.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 848fd8bf9a..9a13bd9f90 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -37,7 +37,7 @@ export interface Config { * regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`. */ handoffGraceMs?: number - /** Absolute send wait bound. */ + /** Absolute bound for one send and the complete pwsh startup sequence. */ timeoutMs?: number /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 79296959ad..5ddbf8a4b7 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' -import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal' +import type { TerminalBackend, TerminalBackendSpawnSpec, TerminalSendOperation } from '@deepseek-ai/dsh-terminal' 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' @@ -106,52 +106,59 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio async function startupSession( session: LocalPtySession, dialect: ShellDialect, + timeoutMs: number, signal?: AbortSignal, ): Promise { + let startupOperation: TerminalSendOperation | undefined const start = async (): Promise => { 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. + // pwsh cannot install its prompt from the environment. Write the prompt + // function through the session, pin UTF-8 output before user input, and + // accept only backend stdin_read evidence; echoed setup source containing + // the printable prompt is not readiness. Follow-up sends bridge silence + // settlements during startup, while one absolute deadline bounds them. let viewport = '' for (;;) { const first = viewport.length === 0 - const operation = session.startSend({ + startupOperation = session.startSend({ text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', submit: first, ...signal !== undefined ? { signal } : {}, }) - const result = await operation.done + const result = await startupOperation.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 + if (result.waitReason === 'stdin_read') break } session.motd = viewport } - if (signal === undefined) { - await start() - return + const races: Promise[] = [] + let onAbort: (() => void) | undefined + if (signal !== undefined) { + const aborted = Promise.withResolvers() + onAbort = () => { aborted.reject(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + races.push(aborted.promise) + } + let deadlineTimer: NodeJS.Timeout | undefined + if (dialect === 'pwsh') { + const deadline = Promise.withResolvers() + deadlineTimer = setTimeout(() => { + startupOperation?.cancel() + deadline.reject(new Error('PTY shell did not reach readiness before startup timeout')) + }, timeoutMs) + races.push(deadline.promise) } - const aborted = Promise.withResolvers() - const onAbort = (): void => { aborted.reject(signal.reason) } - signal.addEventListener('abort', onAbort, { once: true }) try { - signal.throwIfAborted() - await Promise.race([start(), aborted.promise]) + signal?.throwIfAborted() + await Promise.race([start(), ...races]) } finally { - signal.removeEventListener('abort', onAbort) + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer) + if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort) } } @@ -190,7 +197,7 @@ export class BashTerminalBackend implements TerminalBackend { }) const session = this.createSession(terminal, this.config) try { - await startupSession(session, this.config.shellDialect, spec.signal) + await startupSession(session, this.config.shellDialect, this.config.timeoutMs, spec.signal) return session } catch (error) { try { diff --git a/packages/terminal/terminal-bash/src/session.ts b/packages/terminal/terminal-bash/src/session.ts index de0c411a60..d40db28c10 100644 --- a/packages/terminal/terminal-bash/src/session.ts +++ b/packages/terminal/terminal-bash/src/session.ts @@ -1,6 +1,8 @@ -/** Persistent PTY session over the subprocess seam's terminal primitive. */ +/** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */ import { Buffer } from 'node:buffer' +import { createRequire } from 'node:module' +import type { IDisposable, Terminal as HeadlessTerminalType } from '@xterm/headless' import type { SubprocessOutcome, SubprocessTerminalForeground, @@ -23,6 +25,9 @@ import type { import type { ResolvedConfig } from './config.ts' import { CONTROLLED_PROMPT, TerminalSanitizer } from './sanitize.ts' +// Node exposes this package's CommonJS main as default-only, so load its named export through require. +const { Terminal: HeadlessTerminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') + function utf8Tail(text: string, maxBytes: number): { text: string; truncated: boolean } { if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false } const chars = Array.from(text) @@ -157,6 +162,9 @@ export class LocalPtySession implements TerminalBackendSession { motd = '' readonly pid: number private readonly decoder = new TextDecoder() + /** Protocol state only; the sanitizer and bounded buffers own returned text. */ + private readonly emulator: HeadlessTerminalType + private readonly emulatorData: IDisposable private readonly sanitizer: TerminalSanitizer private readonly scrollback: BoundedTextBuffer private readonly outputEnded = Promise.withResolvers() @@ -164,9 +172,8 @@ export class LocalPtySession implements TerminalBackendSession { private statusValue: TerminalSessionStatus = { kind: 'running' } // TODO(pty-send-state-consolidation): Fold the per-send fields below // (active/activeTimer/activeDeadlineTimer/activeAbort/interrupting/ - // activeWrite/pollingReady/polling) into one send-lifecycle owner; the - // cancellation/readiness interplay now has enough pinned tests to carry - // that refactor safely. + // activeWrite/pollingReady/polling and terminal-protocol work) into one send-lifecycle + // owner; the cancellation/readiness interplay has enough pinned tests to carry that refactor safely. private active: LocalSendOperation | undefined private activeTimer: NodeJS.Timeout | undefined private activeDeadlineTimer: NodeJS.Timeout | undefined @@ -184,12 +191,31 @@ export class LocalPtySession implements TerminalBackendSession { private closing = false private closePromise: Promise | undefined private transportFailure: Error | undefined + private emulatorWrites = Promise.resolve() + private emulatorWriteDone: (() => void) | undefined + private emulatorBuffer = '' + private emulatorWriting = false + private responseWrites = Promise.resolve() + private pendingResponseWrites = 0 + private emulatorClosed = false constructor( private readonly terminal: SubprocessTerminalHandle, private readonly config: ResolvedConfig, ) { this.pid = terminal.pid + this.emulator = new HeadlessTerminal({ cols: config.cols, rows: config.rows, scrollback: 0 }) + this.emulatorData = this.emulator.onData((data) => { + this.pendingResponseWrites += 1 + const response = this.responseWrites.then(async () => { await this.terminal.write(data) }) + this.responseWrites = response.then( + () => { this.finishResponseWrite() }, + (error: unknown) => { + this.finishResponseWrite() + if (!this.emulatorClosed && !this.closing) this.onTransportFailure(error) + }, + ) + }) this.sanitizer = new TerminalSanitizer(config.maxReadBytes) this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines) terminal.output.on('data', this.onTerminalData) @@ -250,7 +276,9 @@ export class LocalPtySession implements TerminalBackendSession { } this.activeDeadlineTimer = setTimeout(() => { if (this.active === operation) { - this.settleActive('timeout', this.activeWrite !== undefined || this.interrupting === operation) + this.settleActive('timeout', this.activeWrite !== undefined + || this.interrupting === operation + || this.protocolWorkPending()) } }, this.config.timeoutMs) void this.beginSend(operation, request) @@ -260,8 +288,15 @@ export class LocalPtySession implements TerminalBackendSession { private async beginSend(operation: LocalSendOperation, request: TerminalSendRequest): Promise { let foreground: SubprocessTerminalForeground | undefined try { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites foreground = await this.terminal.inspectForeground() + if (this.protocolStateChanged(emulatorWrites, responseWrites)) { + foreground = await this.inspectForegroundAfterProtocol() + } } catch (error: unknown) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() // A pre-write inspection failure while cancellation owns the slot must not // release it: interruptOnce's in-flight foreground signal could land on a // successor's foreground group. The interrupt path's post-signal tail @@ -290,7 +325,7 @@ export class LocalPtySession implements TerminalBackendSession { // Cancellation owns post-write signalling and reservation release. if (operation.cancelRequested) return if (this.active === operation && operation.settled) { - this.clearActive() + this.releaseSettledActive() return } // Closing can race the awaited provider write even though static analysis sees only local assignments. @@ -301,7 +336,7 @@ export class LocalPtySession implements TerminalBackendSession { } } catch (error: unknown) { if (this.active === operation && !this.closing) { - if (operation.settled) this.clearActive() + if (operation.settled) this.releaseSettledActive() else this.failActive(error) } } @@ -363,16 +398,20 @@ export class LocalPtySession implements TerminalBackendSession { private readonly onTerminalData = (chunk: Buffer | Uint8Array | string): void => { const bytes = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk - this.onData(this.decoder.decode(bytes, { stream: true })) + const data = this.decoder.decode(bytes, { stream: true }) + this.queueEmulatorData(data) + this.onData(data) } private readonly onTerminalEnd = (): void => { this.onData(this.decoder.decode()) this.appendOutput(this.sanitizer.flush()) + this.closeEmulator() this.outputEnded.resolve() } private readonly onTerminalError = (error: Error): void => { + this.closeEmulator() this.onTransportFailure(error) this.outputEnded.resolve() } @@ -409,6 +448,7 @@ export class LocalPtySession implements TerminalBackendSession { const failure = error instanceof Error ? error : new Error(String(error)) this.transportFailure ??= failure this.statusValue = { kind: 'exited', exitCode: null, signal: null } + this.closeEmulator() this.failActive(failure) void this.terminal.terminate().catch(() => {}) } @@ -437,7 +477,13 @@ export class LocalPtySession implements TerminalBackendSession { this.settleActive('session_exit') return } - const foreground = await this.terminal.inspectForeground() + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites + let foreground = await this.terminal.inspectForeground() + if (this.protocolStateChanged(emulatorWrites, responseWrites)) { + foreground = await this.inspectForegroundAfterProtocol() + } if (this.active !== operation || this.closing || this.interrupting === operation) return const idleFor = Date.now() - this.lastOutputAt if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) { @@ -465,6 +511,7 @@ export class LocalPtySession implements TerminalBackendSession { this.settleActive('inferred_idle') } } catch (error: unknown) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error) } finally { this.polling = false @@ -475,6 +522,101 @@ export class LocalPtySession implements TerminalBackendSession { } } + /** Wait until generated replies reach the provider before another send can publish. */ + private async drainTerminalProtocol(): Promise { + for (;;) { + const emulatorWrites = this.emulatorWrites + await emulatorWrites + const responseWrites = this.responseWrites + await responseWrites + if (emulatorWrites === this.emulatorWrites && responseWrites === this.responseWrites + && !this.protocolWorkPending()) return + } + } + + /** Sample foreground state only after protocol replies are quiet for the entire inspection. */ + private async inspectForegroundAfterProtocol(): Promise { + for (;;) { + if (this.protocolWorkPending()) await this.drainTerminalProtocol() + const emulatorWrites = this.emulatorWrites + const responseWrites = this.responseWrites + const foreground = await this.terminal.inspectForeground() + if (!this.protocolStateChanged(emulatorWrites, responseWrites)) return foreground + } + } + + private protocolStateChanged(emulatorWrites: Promise, responseWrites: Promise): boolean { + return emulatorWrites !== this.emulatorWrites || responseWrites !== this.responseWrites + || this.protocolWorkPending() + } + + private protocolWorkPending(): boolean { + return this.emulatorWriteDone !== undefined || this.pendingResponseWrites > 0 + } + + private queueEmulatorData(data: string): void { + if (this.emulatorClosed) return + this.emulatorBuffer += data + if (this.emulatorWriteDone === undefined) { + const idle = Promise.withResolvers() + this.emulatorWrites = idle.promise + this.emulatorWriteDone = () => { idle.resolve(undefined) } + } + this.pumpEmulator() + } + + private pumpEmulator(): void { + if (this.emulatorWriting || this.emulatorClosed) return + if (this.emulatorBuffer.length === 0) { + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.releaseSettledActive() + return + } + const data = this.emulatorBuffer + this.emulatorBuffer = '' + this.emulatorWriting = true + try { + this.emulator.write(data, () => { + this.emulatorWriting = false + this.pumpEmulator() + }) + } catch (error: unknown) { + this.emulatorWriting = false + this.emulatorBuffer = '' + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.releaseSettledActive() + if (!this.closing) this.onTransportFailure(error) + } + } + + private finishResponseWrite(): void { + this.pendingResponseWrites -= 1 + this.releaseSettledActive() + } + + private releaseSettledActive(): void { + const operation = this.active + if (operation === undefined || !operation.settled || this.activeWrite !== undefined + || this.interrupting === operation || this.protocolWorkPending()) return + this.clearActive() + } + + private closeEmulator(): void { + if (this.emulatorClosed) return + this.emulatorClosed = true + this.emulatorBuffer = '' + this.emulatorWriting = false + const done = this.emulatorWriteDone + this.emulatorWriteDone = undefined + done?.() + this.emulatorData.dispose() + this.emulator.dispose() + } + private settleActive(waitReason: TerminalWaitReason, retainOwnership = false): void { const operation = this.active if (operation === undefined) return @@ -537,7 +679,7 @@ export class LocalPtySession implements TerminalBackendSession { if (this.interrupting === operation) this.interrupting = undefined } if (this.active === operation && operation.settled) { - this.clearActive() + this.releaseSettledActive() } else if (this.active === operation && !this.closing) { this.pollingReady = operation this.schedulePoll(operation, 0) @@ -549,6 +691,7 @@ export class LocalPtySession implements TerminalBackendSession { // it as session_exit below, so an in-flight send is never mis-settled as // stdin_read/inferred_idle/timeout during the grace period. this.stopPolling() + this.closeEmulator() try { await this.terminal.terminate() } catch (error: unknown) { diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index d7557a2d90..252a49c3d1 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -29,6 +29,7 @@ describe('terminal-bash config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 9, pollIntervalMs: 10 })) }).toThrow('handoffGraceMs must be at least pollIntervalMs') expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) + }) describe('terminal-bash dialect resolution', () => { diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 4f8c347221..5207317910 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -379,7 +379,7 @@ describe('BashTerminalBackend startup rollback', () => { expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() }) - it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + it('keeps waiting for stdin_read when the first settled output only echoes the prompt literal', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) @@ -391,8 +391,8 @@ describe('BashTerminalBackend startup rollback', () => { const second = sends.length > 1 return { done: Promise.resolve({ - viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', - waitReason: 'inferred_idle' as const, + viewport: second ? 'dsh> ' : "function prompt { 'dsh> ' }\n", + waitReason: second ? 'stdin_read' as const : 'inferred_idle' as const, sessionStatus: { kind: 'running' as const }, truncated: false, }), readOutput: () => ({ delta: '', truncated: false }), @@ -435,6 +435,60 @@ describe('BashTerminalBackend startup rollback', () => { await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') }) + it('bounds all pwsh startup retries with one deadline', async () => { + vi.useFakeTimers() + try { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const pending = Promise.withResolvers<{ + viewport: string + waitReason: 'inferred_idle' + sessionStatus: { kind: 'running' } + truncated: boolean + }>() + let sends = 0 + let cancellations = 0 + let closes = 0 + const session = { + motd: '', + startSend: () => { + sends += 1 + return { + done: sends === 1 + ? Promise.resolve({ + viewport: 'setup echo', waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }) + : pending.promise, + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => { cancellations += 1; return true }, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => { closes += 1; return Promise.resolve() }, + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + + const spawning = backend.spawn(spec(agent(ctx))) + await vi.advanceTimersByTimeAsync(0) + expect(sends).toBe(2) + const rejected = expect(spawning).rejects.toThrow('did not reach readiness before startup timeout') + await vi.advanceTimersByTimeAsync(100) + + await rejected + expect(cancellations).toBe(1) + expect(closes).toBe(1) + } finally { + vi.useRealTimers() + } + }) + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index bf46317c5c..897690cbcb 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -148,6 +148,310 @@ async function initialize(session: LocalPtySession, terminal: FakeTerminal): Pro } describe('LocalPtySession readiness and output', () => { + it('answers split cursor-position queries before publishing prompt readiness', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + let initialized = false + const pending = session.initialize().then(() => { initialized = true }) + terminal.emitData('\x1b]133;D;0\x07dsh> \x1b[') + terminal.emitData('6n') + await vi.advanceTimersByTimeAsync(20) + + expect(terminal.writes).toContain('\x1b[1;6R') + expect(initialized).toBe(false) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(10) + await pending + expect(session.motd).toBe('dsh> ') + }) + + it('drains terminal replies before caller input and re-inspects after concurrent output', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + const secondInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + if (inspections === 1) return await firstInspection.promise + if (inspections === 2) return await secondInspection.promise + return { processGroupId: 456, inputWaiting: false } + } + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + if (data === '\x1b[1;6R') await responseGate.promise + } + + const operation = session.startSend({ text: 'caller input', submit: true }) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + firstInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await Promise.resolve() + + expect(terminal.writes).toEqual(['\x1b[1;6R']) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + expect(inspections).toBe(2) + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + secondInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await vi.advanceTimersByTimeAsync(0) + expect(inspections).toBe(3) + expect(terminal.writes).toEqual(['\x1b[1;6R', '\x1b[1;6R', 'caller input\r']) + + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('drains a terminal reply that is pending when caller input starts', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + if (data === '\x1b[1;6R') await responseGate.promise + } + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + + const operation = session.startSend({ text: 'caller input', submit: true }) + await Promise.resolve() + expect(terminal.writes).toEqual(['\x1b[1;6R']) + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + expect(terminal.writes).toEqual(['\x1b[1;6R', 'caller input\r']) + + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('resamples readiness foreground state after protocol activity during inspection', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const operation = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const internal = session as unknown as { + stopReadinessPolling(): void + pollReadiness(operation: TerminalSendOperation): Promise + settleActive(reason: 'timeout'): void + } + internal.stopReadinessPolling() + await vi.advanceTimersByTimeAsync(20) + + const firstInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + return inspections === 1 + ? await firstInspection.promise + : { processGroupId: 456, inputWaiting: false } + } + const polling = internal.pollReadiness(operation) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + firstInspection.resolve({ processGroupId: 456, inputWaiting: true }) + await polling + + expect(inspections).toBe(2) + expect((operation as unknown as { settled: boolean }).settled).toBe(false) + internal.settleActive('timeout') + expect((await operation.done).waitReason).toBe('timeout') + }) + + it('retains send ownership while a failed inspection drains a terminal reply', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const operation = session.startSend({ text: '', submit: false }) + await Promise.resolve() + await Promise.resolve() + const internal = session as unknown as { + stopReadinessPolling(): void + pollReadiness(operation: TerminalSendOperation): Promise + } + internal.stopReadinessPolling() + + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + terminal.inspectForeground = async () => await inspection.promise + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + const polling = internal.pollReadiness(operation) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + inspection.reject(new Error('inspection failed with reply pending')) + await Promise.resolve() + + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + responseGate.resolve(undefined) + await polling + await expect(operation.done).rejects.toThrow('inspection failed with reply pending') + }) + + it('retains pre-write ownership when inspection fails with a terminal reply pending', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>() + terminal.inspectForeground = async () => await inspection.promise + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + const operation = session.startSend({ text: 'must not execute', submit: true }) + await Promise.resolve() + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(0) + inspection.reject(new Error('pre-write inspection failed with reply pending')) + await Promise.resolve() + + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + responseGate.resolve(undefined) + await expect(operation.done).rejects.toThrow('pre-write inspection failed with reply pending') + expect(terminal.writes).toEqual(['\x1b[1;6R']) + }) + + it('retains a timed-out send until its terminal-protocol response settles', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + const responseGate = Promise.withResolvers() + terminal.write = async (data) => { + terminal.writes.push(data) + await responseGate.promise + } + + const operation = session.startSend({ text: '', submit: false }) + terminal.emitData('\x1b[6n') + await vi.advanceTimersByTimeAsync(100) + expect((await operation.done).waitReason).toBe('timeout') + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send') + + responseGate.resolve(undefined) + await vi.advanceTimersByTimeAsync(0) + const successor = session.startSend({ text: '', submit: false }) + terminal.emitData('\x1b]133;D;0\x07dsh> ') + await vi.advanceTimersByTimeAsync(10) + expect((await successor.done).waitReason).toBe('stdin_read') + }) + + it('contains terminal emulator and protocol-response failures', async () => { + const responseTerminal = new FakeTerminal() + responseTerminal.throwWrite = true + const responseSession = new LocalPtySession(responseTerminal, config()) + const responseOperation = responseSession.startSend({ text: '', submit: false }) + responseTerminal.emitData('\x1b[6n') + await expect(responseOperation.done).rejects.toThrow('write failed') + expect(responseSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) + + const emulatorTerminal = new FakeTerminal() + const emulatorSession = new LocalPtySession(emulatorTerminal, config()) + const emulatorOperation = emulatorSession.startSend({ text: '', submit: false }) + const emulator = (emulatorSession as unknown as { + emulator: { write(data: string, callback?: () => void): void } + }).emulator + emulator.write = () => { throw new Error('emulator failed') } + emulatorTerminal.emitData('output') + await expect(emulatorOperation.done).rejects.toThrow('emulator failed') + expect(emulatorSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: null }) + }) + + it('ignores terminal-protocol failures after closing starts and drains changing queues', async () => { + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + const internal = session as unknown as { + closing: boolean + emulator: { write(data: string, callback?: () => void): void } + emulatorWrites: Promise + responseWrites: Promise + drainTerminalProtocol(): Promise + closeEmulator(): void + } + internal.closing = true + terminal.throwWrite = true + terminal.emitData('\x1b[6n') + await internal.emulatorWrites + await internal.responseWrites + expect(session.status()).toEqual({ kind: 'running' }) + + internal.emulator.write = () => { throw new Error('late emulator failure') } + terminal.emitData('late output') + await internal.emulatorWrites + expect(session.status()).toEqual({ kind: 'running' }) + + const first = Promise.withResolvers() + internal.emulatorWrites = first.promise + const draining = internal.drainTerminalProtocol() + internal.emulatorWrites = Promise.resolve() + first.resolve(undefined) + await draining + internal.closeEmulator() + internal.closeEmulator() + terminal.emitData('after emulator close') + expect(session.status()).toEqual({ kind: 'running' }) + }) + + it('coalesces terminal output that arrives while an emulator parse is pending', async () => { + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + const writes: Array<{ data: string; done: () => void }> = [] + const internal = session as unknown as { + emulator: { write(data: string, callback?: () => void): void } + emulatorWrites: Promise + closeEmulator(): void + } + internal.emulator.write = (data, callback) => { + writes.push({ data, done: callback ?? (() => {}) }) + } + + terminal.emitData('first') + terminal.emitData('second') + terminal.emitData('third') + await Promise.resolve() + expect(writes.map(write => write.data)).toEqual(['first']) + + writes[0]!.done() + await Promise.resolve() + expect(writes.map(write => write.data)).toEqual(['first', 'secondthird']) + writes[1]!.done() + await internal.emulatorWrites + internal.closeEmulator() + }) + it('lets queued terminal output run before the first post-write readiness poll', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() @@ -995,6 +1299,7 @@ describe('LocalPtySession readiness and output', () => { await Promise.resolve() await Promise.resolve() inspection.resolve({ processGroupId: 456, inputWaiting: false }) + await vi.advanceTimersByTimeAsync(0) await stalePoll await vi.advanceTimersByTimeAsync(10) @@ -1190,6 +1495,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => { message: 'PTY cleanup failed (survivor)', cause: terminal.terminateError, }) + expect((session as unknown as { emulatorClosed: boolean }).emulatorClosed).toBe(true) expect(terminal.kills).toEqual([]) terminal.terminateError = undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9311ceec08..db280b365d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8644,6 +8644,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -12857,6 +12860,9 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@yarnpkg/cli-dist@4.17.1': resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==} engines: {node: '>=18.12.0'} @@ -18167,6 +18173,8 @@ snapshots: transitivePeerDependencies: - typescript + '@xterm/headless@6.0.0': {} + '@yarnpkg/cli-dist@4.17.1': {} '@yarnpkg/parsers@3.1.0': diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index ed264e49e2..e9821e16ab 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -430,24 +430,6 @@ const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', expect: 1, }, - { - // The framework peer is a rescoped package, so the rehearsal installs this - // repository's vendored copies; cosmokit arrives as cordis's dependency. - id: 'packed-install-vendored-peer', - file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', - find: ` 'packages/runtime-diagnostics/invariants', -]`, - replace: ` 'packages/runtime-diagnostics/invariants', - // The framework and the vendored packages the closure declares outright: - // rescoped into @deepseek-ai, so the consumer installs this repository's - // copies. Schemastery is a hard dependency of three members above, not a - // peer, so npm resolves it while installing them. - 'vendor/cordis', - 'vendor/cosmokit', - 'vendor/schemastery', -]`, - expect: 1, - }, { id: 'packed-install-registry-spec', file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',