Ver Fonte

fix(ssh): join transport cleanup and preserve collected output

Tianyi Cui há 1 mês atrás
pai
commit
abb7c6a49d
46 ficheiros alterados com 2027 adições e 394 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml
  2. 2 0
      .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
  3. 2 0
      .agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md
  4. 1 1
      AGENTS.md
  5. 2 2
      docs/capability-seams.i18n.yaml
  6. 16 3
      docs/capability-seams.md
  7. 16 3
      docs/capability-seams.zh.md
  8. 2 2
      docs/config-catalog.i18n.yaml
  9. 37 0
      docs/config-catalog.md
  10. 37 0
      docs/config-catalog.zh.md
  11. 2 2
      docs/subsystems/ssh.i18n.yaml
  12. 106 0
      docs/subsystems/ssh.md
  13. 106 0
      docs/subsystems/ssh.zh.md
  14. 38 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  15. 8 0
      packages/ssh/fs-ssh/tests/provider.spec.ts
  16. 2 2
      packages/ssh/ssh/README.i18n.yaml
  17. 2 2
      packages/ssh/ssh/README.md
  18. 2 2
      packages/ssh/ssh/README.zh.md
  19. 1 1
      packages/ssh/ssh/package.json
  20. 17 0
      packages/ssh/ssh/src/helper-entry.ts
  21. 89 20
      packages/ssh/ssh/src/helper-processes.ts
  22. 93 79
      packages/ssh/ssh/src/helper.ts
  23. 76 24
      packages/ssh/ssh/src/index.ts
  24. 51 16
      packages/ssh/ssh/src/protocol.ts
  25. 15 1
      packages/ssh/ssh/src/schemas.ts
  26. 70 0
      packages/ssh/ssh/tests/collection.spec.ts
  27. 129 9
      packages/ssh/ssh/tests/connection-disposal.spec.ts
  28. 53 0
      packages/ssh/ssh/tests/fixtures/helper.ts
  29. 231 0
      packages/ssh/ssh/tests/helper-boundaries.spec.ts
  30. 96 0
      packages/ssh/ssh/tests/helper-runtime.spec.ts
  31. 176 0
      packages/ssh/ssh/tests/management-capacity.spec.ts
  32. 221 0
      packages/ssh/ssh/tests/protocol-boundaries.spec.ts
  33. 34 0
      packages/ssh/ssh/tests/stream-timeout.spec.ts
  34. 1 1
      packages/ssh/ssh/tsdown.config.ts
  35. 2 2
      packages/ssh/subprocess-ssh/README.i18n.yaml
  36. 2 2
      packages/ssh/subprocess-ssh/README.md
  37. 2 2
      packages/ssh/subprocess-ssh/README.zh.md
  38. 48 9
      packages/ssh/subprocess-ssh/src/index.ts
  39. 1 1
      packages/subprocess/subprocess-local/src/index.ts
  40. 212 2
      packages/subprocess/subprocess-local/src/output.ts
  41. 1 202
      packages/subprocess/subprocess-local/src/spawn.ts
  42. 4 1
      packages/subprocess/subprocess-local/tests/local.spec.ts
  43. 1 1
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  44. 5 0
      scripts/gen-cordis-catalog.ts
  45. 11 0
      scripts/type-equiv.manifest.json
  46. 2 0
      vitest.config.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.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-09-11-posix-ssh-runtime.md
-2026-09-11-posix-ssh-runtime.md: 50d69b731a6deac8cf7c9afd42ffa3d3138b979e
-2026-09-11-posix-ssh-runtime.zh.md: 9ddeec2a5e7627c77b4cf75ae09150c183c6d681
+2026-09-11-posix-ssh-runtime.md: 252677358f4ecf26d85d3eede81e3d2faf34c17f
+2026-09-11-posix-ssh-runtime.zh.md: 7a5468644927fa22630ea6c82e939b08efac8e03

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md

@@ -20,6 +20,8 @@ Private administrative RPC and each program stream use independent SSH channels.
 
 Each stream receives a fresh 256-bit TLS pre-shared key through private administrative RPC. TLS 1.2 with `PSK-AES256-GCM-SHA384` authenticates both endpoints and protects subsequent bytes before the stream can publish. The key never travels as a stream preface. Socket permissions alone are insufficient: file-effect confinement can permit same-user connections or pathname replacement in writable temporary directories. No administrative Unix listener or stream secret is exposed to the payload.
 
+Collected stdout and stderr carry bounded tail snapshots through handlers that only update output observations. Capture continues while a snapshot is waiting for transport. Final snapshots preserve raw-byte offsets, and completed spill files use the local provider’s retained-output storage after connection disposal.
+
 Readiness verifies the installed helper digest and, when PTC is configured, the installed Node bootstrap digest. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations.
 
 Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH, so Node execution requires an explicitly installed remote bootstrap.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md

@@ -20,6 +20,8 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec 
 
 每条流通过私有管理 RPC 获得新的 256 位 TLS 预共享密钥。TLS 1.2 配合 `PSK-AES256-GCM-SHA384` 在流发布前认证两端并保护后续字节。密钥绝不作为流前缀传输。仅靠套接字权限不足:文件效果限制可能允许同用户连接,或允许在可写临时目录中替换路径名。程序不会获得管理 Unix 监听端点或流密钥。
 
+收集的 stdout 与 stderr 通过仅更新输出观测的处理器传递有界尾部快照。快照等待传输时,输出捕获继续进行。最终快照保留原始字节偏移,已完成的 spill 文件在连接释放后使用本地提供方的保留输出存储。
+
 就绪流程验证已安装辅助程序的摘要,并在配置 PTC 时验证已安装 Node 引导程序的摘要。这些检查固定预期部署产物,不用于认证恶意远端操作系统。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。
 
 路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影,因此 Node 执行需要显式安装的远端引导程序。

+ 1 - 1
AGENTS.md

@@ -19,7 +19,7 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   llm/         LLM capability: Service Definition/Consumer + DeepSeek providers
   shell/        bash capability: Service Definition + local/pwsh providers + shell Consumers
   subprocess/  subprocess capability + local process-tree provider + shared Win32 library
-  ssh/         POSIX SSH connection + remote filesystem/subprocess/sandbox providers
+  ssh/         SSH connection + remote filesystem/subprocess/sandbox providers
   terminal/         persistent sessions
   fs/          filesystem capability + policy
   lsp/         language-server capability

+ 2 - 2
docs/capability-seams.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/capability-seams.md
-capability-seams.md: 2fbae75691dba96b7b2ab03b1a800d8ddd48f3e1
-capability-seams.zh.md: 2e9c785f05fe53f3976e4655b60786f2c571d147
+capability-seams.md: 1e97a1a95974ca91c2039b1ab5cb6739fb10d96d
+capability-seams.zh.md: 15ee35f38f70d863d5ada9d4019895c08319c284

+ 16 - 3
docs/capability-seams.md

@@ -133,6 +133,11 @@ flowchart LR
   pkg_sdk_minimal["sdk-minimal"]
   pkg_goal["goal"]
   svc_goals["ctx.goals<br/>Same-session goal domain"]
+  pkg_ssh["ssh"]
+  svc_ssh["ctx.ssh<br/>POSIX SSH connection owner"]
+  pkg_fs_ssh["fs-ssh"]
+  pkg_subprocess_ssh["subprocess-ssh"]
+  pkg_sandbox_ssh["sandbox-ssh"]
   pkg_subprocess["subprocess"]
   svc_subprocess["ctx.subprocess<br/>Subprocess seam"]
   pkg_subprocess_local["subprocess-local"]
@@ -259,6 +264,7 @@ flowchart LR
   pkg_fs --> svc_fs
   pkg_fs_local --> svc_fs
   pkg_fs_sandbox --> svc_fs
+  pkg_fs_ssh --> svc_fs
   pkg_goal --> svc_goals
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
@@ -282,6 +288,7 @@ flowchart LR
   pkg_sandbox --> svc_sandbox
   pkg_sandbox_local --> svc_sandbox
   pkg_sandbox_policy --> svc_sandboxPolicy
+  pkg_sandbox_ssh --> svc_sandbox
   pkg_session --> svc_sessions
   pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions
   pkg_session_persistence --> svc_sessionPersistence
@@ -305,6 +312,7 @@ flowchart LR
   pkg_skill_filesystem --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
+  pkg_ssh --> svc_ssh
   pkg_storage --> svc_storage
   pkg_storage_domain --> svc_storageDomain
   pkg_storage_json --> svc_storage
@@ -318,6 +326,7 @@ flowchart LR
   pkg_subagent_spawn_in_process --> svc_subagents
   pkg_subprocess --> svc_subprocess
   pkg_subprocess_local --> svc_subprocess
+  pkg_subprocess_ssh --> svc_subprocess
   pkg_system_prompt --> svc_systemPrompt
   pkg_terminal --> svc_terminals
   pkg_terminal_bash --> svc_terminals
@@ -417,6 +426,9 @@ flowchart LR
   svc_shellEnv --> pkg_tool_pwsh
   svc_skills --> pkg_tool_skill
   svc_spillStore --> pkg_spill_policy
+  svc_ssh --> pkg_fs_ssh
+  svc_ssh --> pkg_sandbox_ssh
+  svc_ssh --> pkg_subprocess_ssh
   svc_storage --> pkg_storage_domain
   svc_storageDomain --> pkg_workspace
   svc_subagentModelSelection --> pkg_tool_subagent
@@ -511,16 +523,17 @@ flowchart LR
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
 | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
-| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. |
+| `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers. |
+| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. |
 | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing shell tools and hook bridges consume this seam; sandboxed, remote, or PowerShell executors replace bash-local without touching them. |
 | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | Plugins declare effect-scoped DSH_* facts; each shell tool collects one trusted snapshot per execution and its executor rebuilds the namespace. |
 | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-terminal exposes the owner-scoped model tools. |
-| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
+| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
 | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. |
 | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
 | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
 | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-node`](../packages/code-runtime/code-runtime-node), [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for PTC mode). |
-| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. |
+| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. |
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |

+ 16 - 3
docs/capability-seams.zh.md

@@ -135,6 +135,11 @@ flowchart LR
   pkg_sdk_minimal["sdk-minimal"]
   pkg_goal["goal"]
   svc_goals["ctx.goals<br/>Same-session goal domain"]
+  pkg_ssh["ssh"]
+  svc_ssh["ctx.ssh<br/>POSIX SSH connection owner"]
+  pkg_fs_ssh["fs-ssh"]
+  pkg_subprocess_ssh["subprocess-ssh"]
+  pkg_sandbox_ssh["sandbox-ssh"]
   pkg_subprocess["subprocess"]
   svc_subprocess["ctx.subprocess<br/>Subprocess seam"]
   pkg_subprocess_local["subprocess-local"]
@@ -261,6 +266,7 @@ flowchart LR
   pkg_fs --> svc_fs
   pkg_fs_local --> svc_fs
   pkg_fs_sandbox --> svc_fs
+  pkg_fs_ssh --> svc_fs
   pkg_goal --> svc_goals
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
@@ -284,6 +290,7 @@ flowchart LR
   pkg_sandbox --> svc_sandbox
   pkg_sandbox_local --> svc_sandbox
   pkg_sandbox_policy --> svc_sandboxPolicy
+  pkg_sandbox_ssh --> svc_sandbox
   pkg_session --> svc_sessions
   pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions
   pkg_session_persistence --> svc_sessionPersistence
@@ -307,6 +314,7 @@ flowchart LR
   pkg_skill_filesystem --> svc_skills
   pkg_spill --> svc_spillStore
   pkg_spill_local --> svc_spillStore
+  pkg_ssh --> svc_ssh
   pkg_storage --> svc_storage
   pkg_storage_domain --> svc_storageDomain
   pkg_storage_json --> svc_storage
@@ -320,6 +328,7 @@ flowchart LR
   pkg_subagent_spawn_in_process --> svc_subagents
   pkg_subprocess --> svc_subprocess
   pkg_subprocess_local --> svc_subprocess
+  pkg_subprocess_ssh --> svc_subprocess
   pkg_system_prompt --> svc_systemPrompt
   pkg_terminal --> svc_terminals
   pkg_terminal_bash --> svc_terminals
@@ -419,6 +428,9 @@ flowchart LR
   svc_shellEnv --> pkg_tool_pwsh
   svc_skills --> pkg_tool_skill
   svc_spillStore --> pkg_spill_policy
+  svc_ssh --> pkg_fs_ssh
+  svc_ssh --> pkg_sandbox_ssh
+  svc_ssh --> pkg_subprocess_ssh
   svc_storage --> pkg_storage_domain
   svc_storageDomain --> pkg_workspace
   svc_subagentModelSelection --> pkg_tool_subagent
@@ -513,16 +525,17 @@ flowchart LR
 | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 |
 | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
 | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 |
-| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 |
+| `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | 负责一条经过认证的 OpenSSH 连接、已安装辅助程序身份、独立程序流,以及配套远端提供方的断连清理。 |
+| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 |
 | `ctx.shell` | `seam` | [`shell`](../packages/shell/shell) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`pwsh-local`](../packages/shell/pwsh-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | - | 面向模型的 shell 工具和钩子桥接消费此 seam;沙箱、远程或 PowerShell 执行器可以替换 bash-local,而无需改动这些消费方。 |
 | `ctx.shellEnv` | `core` | [`shell-env`](../packages/shell/shell-env) | - | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh) | - | 插件声明限定于 effect 作用域的 DSH_* 事实;每个 shell 工具在每次执行时收集一份可信快照,其执行器据此重建命名空间。 |
 | `ctx.terminals` | `seam` | [`terminal`](../packages/terminal/terminal) | [`terminal-bash`](../packages/terminal/terminal-bash) | [`tool-terminal`](../packages/terminal/tool-terminal) | - | 注册表负责精确到 Agent 的会话身份和清理;后端负责终端机制,tool-terminal 则提供限定于所有者作用域的模型接口。 |
-| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 消费方交出即将执行 spawn 的确切 argv;与宿主共享文件系统和内核的后端按每次调用的策略包装该 argv,并报告强制执行情况。 |
+| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 消费方交出即将执行 spawn 的确切 argv;与配套子进程提供方共享执行环境的后端按每次调用的策略包装该 argv,并报告强制执行情况。 |
 | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 |
 | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 |
 | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 |
 | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-node`](../packages/code-runtime/code-runtime-node), [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | [`tools`](../packages/core/tools) | - | 使用 Host 提供的异步绑定运行一段由模型编写的程序;各后端采用不同的基础环境和语言(工具注册表在 PTC mode 下消费该服务)。 |
-| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 |
+| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 |
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |

+ 2 - 2
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: 687983a33f3fe0ed539580aa9beef08524b008ba
-config-catalog.zh.md: 71df45198b5b6be1aaff3acccced30c62566304c
+config-catalog.md: 01c41d7447ff6a770a7533300a6efbe727fa2a49
+config-catalog.zh.md: 68c87ddbde5a0b3382e7903926c90a90b16b52fa

+ 37 - 0
docs/config-catalog.md

@@ -2261,6 +2261,40 @@ export interface Config {
 
 Source: [`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
+<a id="deepseek-aidsh-ssh"></a>
+
+## `@deepseek-ai/dsh-ssh`
+
+```ts config-catalog
+/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
+export interface Config {
+  /** OpenSSH host alias, including its existing user, key and known-host configuration. */
+  host: string
+  /** Absolute remote Node executable. */
+  node: string
+  /** Absolute path to the installed, bundled helper entry. */
+  helper: string
+  /** SHA-256 of that bundled helper; mismatches refuse the connection. */
+  helperHash: string
+  /** Absolute remote default workspace. */
+  workspace: string
+  /** Optional preinstalled built PTC entry, paired with its expected digest. */
+  bootstrapPath?: string
+  /** SHA-256 of bootstrapPath; both fields must be supplied together. */
+  bootstrapHash?: string
+  /** Bound on connection establishment and individual administrative requests. */
+  requestTimeoutMs?: number
+  /** Maximum JSON payload bytes per helper request or response. */
+  maxFrameBytes?: number
+  /** Maximum outstanding administrative requests. */
+  maxPending?: number
+  /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
+  leaseMs?: number
+}
+```
+
+Source: [`packages/ssh/ssh/src/index.ts:19`](../packages/ssh/ssh/src/index.ts)
+
 <a id="deepseek-aidsh-storage-domain"></a>
 
 ## `@deepseek-ai/dsh-storage-domain`
@@ -3477,12 +3511,14 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions` ([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-experimental-client-ui-agent-team` ([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts))
 - `@deepseek-ai/dsh-fs-observation-policy` ([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts))
+- `@deepseek-ai/dsh-fs-ssh` — requires `ssh` · `sandboxPolicy` ([`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts))
 - `@deepseek-ai/dsh-goal-round-driver` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `webServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
 - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
+- `@deepseek-ai/dsh-sandbox-ssh` — requires `ssh` ([`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts))
 - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts))
 - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
 - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
@@ -3493,6 +3529,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
 - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
+- `@deepseek-ai/dsh-subprocess-ssh` — requires `ssh` ([`packages/ssh/subprocess-ssh/src/index.ts`](../packages/ssh/subprocess-ssh/src/index.ts))
 - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts))
 - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts))
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))

+ 37 - 0
docs/config-catalog.zh.md

@@ -2263,6 +2263,40 @@ export interface Config {
 
 来源:[`packages/spill/spill-policy/src/index.ts:61`](../packages/spill/spill-policy/src/index.ts)
 
+<a id="deepseek-aidsh-ssh"></a>
+
+## `@deepseek-ai/dsh-ssh`
+
+```ts config-catalog
+/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
+export interface Config {
+  /** OpenSSH host alias, including its existing user, key and known-host configuration. */
+  host: string
+  /** Absolute remote Node executable. */
+  node: string
+  /** Absolute path to the installed, bundled helper entry. */
+  helper: string
+  /** SHA-256 of that bundled helper; mismatches refuse the connection. */
+  helperHash: string
+  /** Absolute remote default workspace. */
+  workspace: string
+  /** Optional preinstalled built PTC entry, paired with its expected digest. */
+  bootstrapPath?: string
+  /** SHA-256 of bootstrapPath; both fields must be supplied together. */
+  bootstrapHash?: string
+  /** Bound on connection establishment and individual administrative requests. */
+  requestTimeoutMs?: number
+  /** Maximum JSON payload bytes per helper request or response. */
+  maxFrameBytes?: number
+  /** Maximum outstanding administrative requests. */
+  maxPending?: number
+  /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
+  leaseMs?: number
+}
+```
+
+来源: [`packages/ssh/ssh/src/index.ts:19`](../packages/ssh/ssh/src/index.ts)
+
 <a id="deepseek-aidsh-storage-domain"></a>
 
 ## `@deepseek-ai/dsh-storage-domain`
@@ -3479,12 +3513,14 @@ export interface Config {
 - `@deepseek-ai/dsh-deepseek-llm-api-extensions`([`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts))
 - `@deepseek-ai/dsh-experimental-client-ui-agent-team`([`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts))
 - `@deepseek-ai/dsh-fs-observation-policy`([`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts))
+- `@deepseek-ai/dsh-fs-ssh` — 需要 `ssh` · `sandboxPolicy`([`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts))
 - `@deepseek-ai/dsh-goal-round-driver` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `webServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts))
 - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts))
 - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
 - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
+- `@deepseek-ai/dsh-sandbox-ssh` — 需要 `ssh`([`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts))
 - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts))
 - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
 - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
@@ -3495,6 +3531,7 @@ export interface Config {
 - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
 - `@deepseek-ai/dsh-subprocess-local`([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts))
+- `@deepseek-ai/dsh-subprocess-ssh` — 需要 `ssh`([`packages/ssh/subprocess-ssh/src/index.ts`](../packages/ssh/subprocess-ssh/src/index.ts))
 - `@deepseek-ai/dsh-terminal`([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts))
 - `@deepseek-ai/dsh-tool-ask-user` — 需要 `tools` · `userInteraction`([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts))
 - `@deepseek-ai/dsh-tool-call-timeout-policy` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts))

+ 2 - 2
docs/subsystems/ssh.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/subsystems/ssh.md
-ssh.md: 9f9aff76f93089bca71da0000094b39d419b6e0d
-ssh.zh.md: 49feabbbe02b41d4ad720ec0347ff996dead79f2
+ssh.md: 427a5b9181cb43e9fc859b879794217fa0637de5
+ssh.zh.md: 572d4729e7e83221d31b99fb4c48a8dfc95895d8

+ 106 - 0
docs/subsystems/ssh.md

@@ -27,3 +27,109 @@ Administrative deadlines bound individual RPC observations; they do not replace
 Headless records and checks Session cwd through the mounted filesystem provider. Remote FS, Bash, terminal, LSP and PTC consumers can therefore share those coordinates. Web workspace views that assume host filesystem access need separate integration; replacing providers alone does not make those views remote-aware.
 
 See the [decision record](../../.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md) for the alternatives and verification obligations.
+
+## Connection API
+
+```ts type-equiv
+/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
+interface Config {
+  /** OpenSSH host alias, including its existing user, key and known-host configuration. */
+  host: string
+  /** Absolute remote Node executable. */
+  node: string
+  /** Absolute path to the installed, bundled helper entry. */
+  helper: string
+  /** SHA-256 of that bundled helper; mismatches refuse the connection. */
+  helperHash: string
+  /** Absolute remote default workspace. */
+  workspace: string
+  /** Optional preinstalled built PTC entry, paired with its expected digest. */
+  bootstrapPath?: string
+  /** SHA-256 of bootstrapPath; both fields must be supplied together. */
+  bootstrapHash?: string
+  /** Bound on connection establishment and individual administrative requests. */
+  requestTimeoutMs?: number
+  /** Maximum JSON payload bytes per helper request or response. */
+  maxFrameBytes?: number
+  /** Maximum ordinary requests; heartbeat and bounded resource cleanup have reserved capacity. */
+  maxPending?: number
+  /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
+  leaseMs?: number
+}
+```
+
+```ts public-api
+/** One non-reconnecting SSH session; loss invalidates all active operations. */
+declare class SshConnection extends Service {
+  static Config: schema<Config>;
+  /** Verified remote helper coordinates; callers must await this before launch. */
+  readonly ready: Promise<Hello>;
+  constructor(ctx: Context, config: Config);
+  /** Hold plugin readiness until the remote identity and helper digest are verified. */
+  async [Service.init](): Promise<void>;
+  /** Verified remote Node executable for the paired code runtime. */
+  get nodeExecutable(): string;
+  /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
+  get bootstrapPath(): string;
+  /**
+     * Send a helper operation; cancellation never replays an ambiguous mutation.
+     * @param method - the private helper operation.
+     * @param params - JSON request fields validated by the helper.
+     * @param result - response validation before returning provider-visible data.
+     * @param signal - cancellation, which does not undo completed remote effects.
+     * @param wait - allow a process observation to outlast the administrative deadline.
+     * @returns the validated remote result.
+     */
+  async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T>;
+  /**
+     * Forward one authenticated stream through an independent SSH channel.
+     * @param endpoint - private coordinates issued by this connection's helper.
+     * @param signal - cancellation of allocation and the resulting socket.
+     * @returns a paused socket; attach a consumer before resuming it.
+     */
+  async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket>;
+  /** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
+  dispose(): Promise<void>;
+}
+```
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxssh--sshconnection"></a>
+
+### `ctx.ssh` — `SshConnection`
+
+One non-reconnecting SSH session; loss invalidates all active operations.
+
+```ts cordis-catalog
+/**
+ * Send a helper operation; cancellation never replays an ambiguous mutation.
+ * @param method - the private helper operation.
+ * @param params - JSON request fields validated by the helper.
+ * @param result - response validation before returning provider-visible data.
+ * @param signal - cancellation, which does not undo completed remote effects.
+ * @param wait - allow a process observation to outlast the administrative deadline.
+ * @returns the validated remote result.
+ */
+async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T>
+
+/**
+ * Forward one authenticated stream through an independent SSH channel.
+ * @param endpoint - private coordinates issued by this connection's helper.
+ * @param signal - cancellation of allocation and the resulting socket.
+ * @returns a paused socket; attach a consumer before resuming it.
+ */
+async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket>
+
+/** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
+async dispose(): Promise<void>
+```
+
+Source: [`packages/ssh/ssh/src/index.ts`](../../packages/ssh/ssh/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 106 - 0
docs/subsystems/ssh.zh.md

@@ -27,3 +27,109 @@
 headless 通过已挂载的文件系统提供方记录和检查 Session cwd。因此远端 FS、Bash、终端、LSP 及 PTC 消费方可以共享这些坐标。假定可访问主机文件系统的 Web 工作区视图需要单独集成;仅替换提供方并不会使这些视图支持远端。
 
 替代方案与验证责任见[决策记录](../../.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md)。
+
+## 连接 API
+
+```ts type-equiv
+/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
+interface Config {
+  /** OpenSSH host alias, including its existing user, key and known-host configuration. */
+  host: string
+  /** Absolute remote Node executable. */
+  node: string
+  /** Absolute path to the installed, bundled helper entry. */
+  helper: string
+  /** SHA-256 of that bundled helper; mismatches refuse the connection. */
+  helperHash: string
+  /** Absolute remote default workspace. */
+  workspace: string
+  /** Optional preinstalled built PTC entry, paired with its expected digest. */
+  bootstrapPath?: string
+  /** SHA-256 of bootstrapPath; both fields must be supplied together. */
+  bootstrapHash?: string
+  /** Bound on connection establishment and individual administrative requests. */
+  requestTimeoutMs?: number
+  /** Maximum JSON payload bytes per helper request or response. */
+  maxFrameBytes?: number
+  /** Maximum ordinary requests; heartbeat and bounded resource cleanup have reserved capacity. */
+  maxPending?: number
+  /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
+  leaseMs?: number
+}
+```
+
+```ts public-api
+/** One non-reconnecting SSH session; loss invalidates all active operations. */
+declare class SshConnection extends Service {
+  static Config: schema<Config>;
+  /** Verified remote helper coordinates; callers must await this before launch. */
+  readonly ready: Promise<Hello>;
+  constructor(ctx: Context, config: Config);
+  /** Hold plugin readiness until the remote identity and helper digest are verified. */
+  async [Service.init](): Promise<void>;
+  /** Verified remote Node executable for the paired code runtime. */
+  get nodeExecutable(): string;
+  /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
+  get bootstrapPath(): string;
+  /**
+     * Send a helper operation; cancellation never replays an ambiguous mutation.
+     * @param method - the private helper operation.
+     * @param params - JSON request fields validated by the helper.
+     * @param result - response validation before returning provider-visible data.
+     * @param signal - cancellation, which does not undo completed remote effects.
+     * @param wait - allow a process observation to outlast the administrative deadline.
+     * @returns the validated remote result.
+     */
+  async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T>;
+  /**
+     * Forward one authenticated stream through an independent SSH channel.
+     * @param endpoint - private coordinates issued by this connection's helper.
+     * @param signal - cancellation of allocation and the resulting socket.
+     * @returns a paused socket; attach a consumer before resuming it.
+     */
+  async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket>;
+  /** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
+  dispose(): Promise<void>;
+}
+```
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxssh--sshconnection"></a>
+
+### `ctx.ssh` — `SshConnection`
+
+One non-reconnecting SSH session; loss invalidates all active operations.
+
+```ts cordis-catalog
+/**
+ * Send a helper operation; cancellation never replays an ambiguous mutation.
+ * @param method - the private helper operation.
+ * @param params - JSON request fields validated by the helper.
+ * @param result - response validation before returning provider-visible data.
+ * @param signal - cancellation, which does not undo completed remote effects.
+ * @param wait - allow a process observation to outlast the administrative deadline.
+ * @returns the validated remote result.
+ */
+async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T>
+
+/**
+ * Forward one authenticated stream through an independent SSH channel.
+ * @param endpoint - private coordinates issued by this connection's helper.
+ * @param signal - cancellation of allocation and the resulting socket.
+ * @returns a paused socket; attach a consumer before resuming it.
+ */
+async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket>
+
+/** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
+async dispose(): Promise<void>
+```
+
+Source: [`packages/ssh/ssh/src/index.ts`](../../packages/ssh/ssh/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 38 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2198,6 +2198,40 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'ssh',
+    summary: 'One non-reconnecting SSH session; loss invalidates all active operations.',
+    description: 'One non-reconnecting SSH session; loss invalidates all active operations.',
+    methods: [
+      {
+        signature: 'readonly ready: Promise<Hello>',
+        description: 'Verified remote helper coordinates; callers must await this before launch.',
+        parameters: [],
+      },
+      {
+        signature: 'readonly helperPath: string',
+        description: 'Installed helper entry in the remote filesystem.',
+        parameters: [],
+      },
+      {
+        signature: 'async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T>',
+        description: 'Send a helper operation; cancellation never replays an ambiguous mutation.',
+        parameters: [{ name: 'method', description: 'the private helper operation.' }, { name: 'params', description: 'JSON request fields validated by the helper.' }, { name: 'result', description: 'response validation before returning provider-visible data.' }, { name: 'signal', description: 'cancellation, which does not undo completed remote effects.' }, { name: 'wait', description: 'allow a process observation to outlast the administrative deadline.' }],
+        returns: 'the validated remote result.',
+      },
+      {
+        signature: 'async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket>',
+        description: 'Forward one authenticated stream through an independent SSH channel.',
+        parameters: [{ name: 'endpoint', description: 'private coordinates issued by this connection\'s helper.' }, { name: 'signal', description: 'cancellation of allocation and the resulting socket.' }],
+        returns: 'a paused socket; attach a consumer before resuming it.',
+      },
+      {
+        signature: 'async dispose(): Promise<void>',
+        description: 'Tear down the helper\'s remote managed ranges before releasing the SSH master when reachable.',
+        parameters: [],
+      },
+    ],
+  },
   {
     key: 'storage',
     summary: 'The storage hub service.',
@@ -5686,6 +5720,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SpillSource',
     declaration: 'export type SpillSource = {\n    kind: \'tool\';\n    toolName: string;\n    callId: ToolCallId;\n    label: string;\n} | {\n    kind: \'session-reference\';\n    sessionId: SessionId;\n    label: string;\n};',
   },
+  {
+    name: 'SshStreamEndpoint',
+    declaration: 'export type SshStreamEndpoint = z.infer<typeof streamEndpointSchema>;',
+  },
   {
     name: 'StorageBackend',
     declaration: 'export interface StorageBackend {\n    readonly kv?: KvFacet;\n    close(): Promise<void>;\n}',

+ 8 - 0
packages/ssh/fs-ssh/tests/provider.spec.ts

@@ -166,6 +166,14 @@ describe('SSH filesystem provider', () => {
     expect(dispatch).toHaveBeenCalledTimes(1)
   })
 
+  it('preserves a primitive AbortSignal reason as a filesystem cancellation', async () => {
+    const { fs, dispatch } = await setup()
+    const signal = AbortSignal.abort('caller cancelled')
+    dispatch.mockImplementationOnce(async (_method, _params, cancellation) => { cancellation?.throwIfAborted() })
+    await expect(fs.readText(target, signal)).rejects.toMatchObject({ code: 'FS_ABORTED', message: 'caller cancelled' })
+    expect(dispatch).toHaveBeenCalledTimes(1)
+  })
+
   it.each([null, { targetKey: 'relative', displayPath: 'file' }, { targetKey: '/remote/file', displayPath: 1 }])(
     'rejects malformed target observations from the wire', async (raw) => {
       const { fs, dispatch } = await setup()

+ 2 - 2
packages/ssh/ssh/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/ssh/ssh/README.md
-README.md: 24170c47c3e83c5292a1e711d5451a52158b6821
-README.zh.md: ce955aa747f152cd924d8892e570fcf4ad240d5b
+README.md: a9d60d0613912fb6e24c054eb8feb251bce65ec0
+README.zh.md: 69980708e58a2c8649343c1becc303588b5179f9

+ 2 - 2
packages/ssh/ssh/README.md

@@ -41,7 +41,7 @@ Install the built helper and its matching runtime dependencies on the remote hos
 | `bootstrapPath`, `bootstrapHash` | omitted | Paired remote PTC entry and its lowercase SHA-256 |
 | `requestTimeoutMs` | `30000` | Connection and administrative-request deadline |
 | `maxFrameBytes` | `67108864` | Per-message JSON payload ceiling, at most 64 MiB |
-| `maxPending` | `128` | Host-side outstanding administrative-request limit |
+| `maxPending` | `128` | Ordinary outstanding requests; heartbeat and bounded cleanup requests have reserved capacity |
 | `leaseMs` | `30000` | Helper heartbeat lease, from 3000 to 600000 ms |
 
 For PTC, configure both bootstrap fields and pass the verified `ctx.ssh.nodeExecutable` and `ctx.ssh.bootstrapPath` to [`NodeCodeRuntime`](../../code-runtime/code-runtime-node/README.md). Basic filesystem and Bash use may omit the pair. The `bootstrapPath` getter refuses an unconfigured PTC deployment.
@@ -58,7 +58,7 @@ The OpenSSH master carries private administrative RPC. Each program stream uses
 
 Each stream reservation has a random 256-bit TLS pre-shared key carried only by administrative RPC. TLS authenticates both endpoints and protects every stream byte; the key is never sent as a stream preface. Socket directories are private (`0700`) and sockets use `0600`. Replacing a writable socket path cannot impersonate an endpoint or reveal the stream key; an attacker can still interrupt service or relay opaque TLS records.
 
-Transport loss rejects pending operations and invalidates the connection. The helper starts managed cleanup on SSH EOF, termination signals or heartbeat expiry. A disconnected client cannot confirm the remote outcome; operations are never reconnected or replayed automatically.
+Connection disposal joins forwarding and cancellation subprocesses and partially established streams before removing local resources. Transport loss rejects pending operations and invalidates the connection. The helper starts managed cleanup on SSH EOF, termination signals or heartbeat expiry. A disconnected client cannot confirm the remote outcome; operations are never reconnected or replayed automatically.
 
 </details>
 

+ 2 - 2
packages/ssh/ssh/README.zh.md

@@ -41,7 +41,7 @@ kind: "package-reference"
 | `bootstrapPath`、`bootstrapHash` | 省略 | 成对提供的远端 PTC 入口及其小写 SHA-256 |
 | `requestTimeoutMs` | `30000` | 连接与管理请求的截止时限 |
 | `maxFrameBytes` | `67108864` | 每条 JSON 消息的负载上限,最大为 64 MiB |
-| `maxPending` | `128` | 主机侧未完成管理请求的数量上限 |
+| `maxPending` | `128` | 普通未完成请求的数量上限;心跳与有界清理请求使用预留容量 |
 | `leaseMs` | `30000` | 辅助进程心跳租期,范围为 3000 至 600000 毫秒 |
 
 使用 PTC 时,配置两个引导字段,并将验证后的 `ctx.ssh.nodeExecutable` 与 `ctx.ssh.bootstrapPath` 传给 [`NodeCodeRuntime`](../../code-runtime/code-runtime-node/README.zh.md)。仅使用文件系统和 Bash 时可以省略这对字段。未配置 PTC 部署时,`bootstrapPath` getter 会拒绝访问。
@@ -58,7 +58,7 @@ OpenSSH 主连接承载私有管理 RPC。每条程序流使用独立转发的 U
 
 每个流预留项都有一个随机 256 位 TLS 预共享密钥,仅由管理 RPC 传递。TLS 认证两端并保护流中的每个字节;密钥绝不作为流前缀发送。套接字目录为私有目录(`0700`),套接字使用 `0600` 权限。替换可写套接字路径无法冒充端点或获知流密钥;攻击者仍可中断服务或转发不透明的 TLS 记录。
 
-传输丢失会拒绝待处理操作并使连接失效。辅助进程在 SSH EOF、终止信号或心跳到期时启动托管清理。断连客户端无法确认远端结果;操作不会自动重连或重放。
+连接释放会先等待转发与取消子进程,以及尚在建立的流结束,再删除本地资源。传输丢失会拒绝待处理操作并使连接失效。辅助进程在 SSH EOF、终止信号或心跳到期时启动托管清理。断连客户端无法确认远端结果;操作不会自动重连或重放。
 
 </details>
 

+ 1 - 1
packages/ssh/ssh/package.json

@@ -21,7 +21,7 @@
     "./src/*": "./src/*",
     "./package.json": "./package.json",
     "./helper": {
-      "types": "./lib/types/helper.d.ts",
+      "types": "./lib/types/helper-entry.d.ts",
       "default": "./lib/helper.js"
     },
     "./protocol": {

+ 17 - 0
packages/ssh/ssh/src/helper-entry.ts

@@ -0,0 +1,17 @@
+/** Private OpenSSH process entry; the helper module owns request and cleanup behavior. */
+/* v8 ignore file -- launched through plain Node in SSH acceptance; helper behavior is exercised through its explicit streams. */
+import { fileURLToPath } from 'node:url'
+import { runSshHelper } from './helper.ts'
+
+const controller = new AbortController()
+const stop = (): void => { controller.abort(new Error('SSH helper process terminated')) }
+for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT'] as const) process.once(signal, stop)
+try {
+  if (process.argv.length !== 2) throw new Error('SSH helper accepts no command arguments')
+  await runSshHelper({ input: process.stdin, output: process.stdout, entryPath: fileURLToPath(import.meta.url), signal: controller.signal })
+} catch (error) {
+  process.stderr.write(`dsh-ssh-sandbox: ${error instanceof Error ? error.message : String(error)}\n`)
+  process.exitCode = 127
+} finally {
+  for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT'] as const) process.off(signal, stop)
+}

+ 89 - 20
packages/ssh/ssh/src/helper-processes.ts

@@ -8,12 +8,14 @@ import type { Duplex, Readable, Writable } from 'node:stream'
 import { finished, pipeline } from 'node:stream/promises'
 import type { Context } from '@deepseek-ai/cordis'
 import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
-import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
-import { spawnSchema, type SshStreamEndpoint } from './schemas.ts'
+import { OutputCollector, prepareManagedProcessBinding } from '@deepseek-ai/dsh-subprocess-local/output'
+import { doneSchema, outputSnapshotFrameLimit, spawnSchema, type SshStreamEndpoint } from './schemas.ts'
 import { z } from 'zod'
 import { SSH_STREAM_TLS_OPTIONS } from './stream-security.ts'
+import { SshRpcPeer } from './protocol.ts'
 
 type Request = z.infer<typeof spawnSchema>
+type Completion = z.infer<typeof doneSchema>
 type Channel = 'stdin' | 'stdout' | 'stderr' | 'control' | 'terminal'
 interface Endpoint extends SshStreamEndpoint {
   server: Server
@@ -31,10 +33,47 @@ interface ProcessRecord {
   ordinary?: SubprocessHandle
   terminal?: SubprocessTerminalHandle
   start?: Promise<void>
-  done?: Promise<{ outcome: { exitCode: number | null; signal: NodeJS.Signals | null }; spills: { stdout?: string; stderr?: string } }>
+  done?: Promise<Completion>
   expiry: NodeJS.Timeout
 }
 
+/** Coalesce live tail updates while capture continues independently of network readers. */
+class CollectedOutputForwarder {
+  private readonly peer: SshRpcPeer
+  private dirty = false
+  private stopped = false
+  private running: Promise<void> | undefined
+
+  constructor(socket: Socket, private readonly collector: OutputCollector, maxBytes: number) {
+    this.peer = new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(maxBytes), 1)
+    this.peer.once('closed', () => { this.stopped = true })
+  }
+
+  offer(): void {
+    if (this.stopped) return
+    this.dirty = true
+    if (this.running !== undefined) return
+    this.running = this.flush().catch(() => { this.stopped = true; this.peer.close() }).finally(() => {
+      this.running = undefined
+      if (this.dirty && !this.stopped) this.offer()
+    })
+  }
+
+  private async flush(): Promise<void> {
+    while (this.dirty && !this.stopped) {
+      this.dirty = false
+      const snapshot = this.collector.snapshot()
+      await this.peer.request('snapshot', { tail: snapshot.bytes.toString('base64'), totalBytes: snapshot.totalBytes }, z.null())
+    }
+  }
+
+  async finish(): Promise<void> {
+    this.offer()
+    while (this.running !== undefined) await this.running
+    this.peer.close()
+  }
+}
+
 /** Owns remote launch reservations through final process-range quiescence. */
 export class RemoteProcesses {
   private readonly records = new Map<string, ProcessRecord>()
@@ -127,12 +166,12 @@ export class RemoteProcesses {
       }
       const socket = await (record.endpoints.terminal as Endpoint).connected
       const output = pipeline(terminal.output, socket).catch(() => {})
-      record.done = terminal.done.then(outcome => ({ outcome, spills: {} }))
+      record.done = terminal.done.then(outcome => ({ outcome, spills: {}, collected: {} }))
       void record.done.catch(() => {})
       void record.done.then(async (result) => {
         await terminal.terminate()
         await output
-        this.rememberCompleted(id, record, result)
+        await this.rememberCompleted(id, record, result)
       }).catch(() => {})
       return
     }
@@ -143,20 +182,33 @@ export class RemoteProcesses {
     const ordinary = this.ctx.subprocess.spawn(spec)
     record.ordinary = ordinary
     const collectors: Partial<Record<'stdout' | 'stderr', OutputCollector>> = {}
+    const stopCapture: Array<() => void> = []
+    const forwarders: CollectedOutputForwarder[] = []
+    const forwarded: Promise<void>[] = []
     const streams: Promise<void>[] = []
     for (const name of ['stdout', 'stderr'] as const) {
       const stream = ordinary[name] as Readable
       const mode = stdio[name]
+      const socket = await (record.endpoints[name] as Endpoint).connected
       if (typeof mode === 'object') {
-        const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, name, record.directory)
+        const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, name, prepareManagedProcessBinding().spillDir)
         collectors[name] = collector
-        stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
+        const forwarder = new CollectedOutputForwarder(socket, collector, mode.maxBytes)
+        forwarders.push(forwarder)
+        const receive = (chunk: Buffer): void => { collector.push(chunk); forwarder.offer() }
+        stream.on('data', receive)
+        stopCapture.push(() => {
+          stream.off('data', receive)
+          stream.destroy()
+          collector.seal()
+        })
+      } else {
+        streams.push(pipeline(stream, socket).catch(() => {}))
       }
-      const socket = await (record.endpoints[name] as Endpoint).connected
-      streams.push(pipeline(stream, socket).catch(() => {}))
     }
     if (record.endpoints.stdin !== undefined) {
       const socket = await record.endpoints.stdin.connected
+      socket.end()
       void pipeline(socket, ordinary.stdin as Writable).catch(() => {})
     }
     if (record.endpoints.control !== undefined) {
@@ -169,24 +221,30 @@ export class RemoteProcesses {
       streams.push(finished(socket, { readable: false, cleanup: true }).catch(() => {}))
     }
     record.done = ordinary.done.then(async (outcome) => {
+      for (const stop of stopCapture) stop()
+      for (const forwarder of forwarders) forwarded.push(forwarder.finish())
       // A paused output reader may defer EOF, but must not retain the process-range owner.
       await Promise.race([
         Promise.all(streams),
         new Promise<void>((resolve) => { const timer = setTimeout(resolve, request.graceMs); timer.unref() }),
       ])
       const spills: { stdout?: string; stderr?: string } = {}
+      const collected: Completion['collected'] = {}
       for (const name of ['stdout', 'stderr'] as const) {
-        collectors[name]?.seal()
-        const path = collectors[name]?.readFrom(0).spillPath
+        const collector = collectors[name]
+        if (collector === undefined) continue
+        const snapshot = collector.snapshot()
+        collected[name] = { tail: snapshot.bytes.toString('base64'), totalBytes: snapshot.totalBytes }
+        const path = collector.readFrom(0).spillPath
         if (path !== undefined) spills[name] = path
       }
-      return { outcome, spills }
+      return { outcome, spills, collected }
     })
     void record.done.catch(() => {})
     void record.done.then(async (result) => {
       await ordinary.waitForExit()
-      await Promise.all(streams)
-      this.rememberCompleted(id, record, result)
+      await Promise.all([...streams, ...forwarded])
+      await this.rememberCompleted(id, record, result)
     }).catch(() => {})
   }
 
@@ -284,13 +342,17 @@ export class RemoteProcesses {
     return record
   }
 
-  private rememberCompleted(id: string, record: ProcessRecord, result: unknown): void {
+  private async rememberCompleted(id: string, record: ProcessRecord, result: unknown): Promise<void> {
     if (this.records.get(id) !== record) return
     this.records.delete(id)
     this.completed.set(id, result)
     if (this.completed.size > this.limit * 4) this.completed.delete(this.completed.keys().next().value as string)
-    // Completed spill files remain in the helper's private root until connection disposal.
-    for (const endpoint of Object.values(record.endpoints)) { endpoint.server.close(); endpoint.socket?.destroy() }
+    for (const endpoint of Object.values(record.endpoints)) {
+      endpoint.server.close()
+      for (const socket of endpoint.pending) socket.destroy()
+      endpoint.socket?.destroy()
+    }
+    await rm(record.directory, { recursive: true, force: true })
   }
 
   private async endpoint(path: string): Promise<Endpoint> {
@@ -320,8 +382,15 @@ export class RemoteProcesses {
     })
     server.on('error', (error) => { connected.reject(error) })
     server.on('close', () => { if (endpoint.socket === undefined) connected.reject(new Error('SSH stream reservation closed')) })
-    await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(path, resolve) })
-    await chmod(path, 0o600)
-    return endpoint
+    try {
+      await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(path, resolve) })
+      await chmod(path, 0o600)
+      return endpoint
+    } catch (error) {
+      for (const socket of endpoint.pending) socket.destroy()
+      endpoint.socket?.destroy()
+      await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
+      throw error
+    }
   }
 }

+ 93 - 79
packages/ssh/ssh/src/helper.ts

@@ -1,9 +1,8 @@
 /** Private POSIX SSH helper; filesystem and process effects use the installed local providers. */
-import { createHash } from 'node:crypto'
+import { createHash, randomUUID } from 'node:crypto'
 import { readFileSync } from 'node:fs'
 import { mkdtemp, rm } from 'node:fs/promises'
-import { fileURLToPath } from 'node:url'
-import { spawnSync } from 'node:child_process'
+import type { Readable, Writable } from 'node:stream'
 import { Context } from '@deepseek-ai/cordis'
 import { FsError, type FsTarget, type FsEditRequest, type FsWriteIntent, type FsVersion } from '@deepseek-ai/dsh-fs'
 import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
@@ -13,7 +12,7 @@ import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
 import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
 import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
 import { z } from 'zod'
-import { SshRpcPeer, SSH_PROTOCOL_VERSION } from './protocol.ts'
+import { SshRpcPeer, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts'
 import { RemoteProcesses } from './helper-processes.ts'
 import { editSchema, environmentSchema, intentSchema, policySchema, remotePath, targetSchema } from './schemas.ts'
 
@@ -32,52 +31,75 @@ async function services() {
   return { ctx, close: async () => { for (const fiber of fibers.reverse()) await fiber.dispose() } }
 }
 
-/** Run the private helper until the SSH channel closes or its client lease expires. */
-export async function runSshHelper(): Promise<void> {
+/** The private helper's authenticated transport and process lifetime. */
+export interface HelperTransport {
+  /** Authenticated request bytes from OpenSSH. */
+  input: Readable
+  /** Responses carried only by OpenSSH exec stdout. */
+  output: Writable
+  /** Installed entry whose digest is compared by the client. */
+  entryPath: string
+  /** Process termination joins the same cleanup as transport loss. */
+  signal: AbortSignal
+}
+
+/**
+ * Run a helper until its channel closes or its client lease expires.
+ * @param transport - private process streams, entry identity, and cancellation.
+ */
+export async function runSshHelper(transport: HelperTransport): Promise<void> {
   if (process.platform !== 'linux' && process.platform !== 'darwin') throw new Error('SSH helper requires a POSIX host')
+  transport.signal.throwIfAborted()
   const runtime = await services()
   const { ctx } = runtime
   const root = await mkdtemp('/tmp/dsh-ssh-')
-  const processes = new RemoteProcesses(ctx, root, 128, 30_000)
-  const iterators = new Map<string, AsyncIterator<string>>()
+  const processes = new RemoteProcesses(ctx, root, SSH_MAX_PROCESS_HANDLES, 30_000)
+  const lifetime = new AbortController()
+  const iterators = new Map<string, { iterator: AsyncIterator<string>; controller: AbortController }>()
   let lease: NodeJS.Timeout | undefined
   let leaseMs = 30_000
   let initialized = false
+  let workspace = process.cwd()
   let cleanup: Promise<void> | undefined
   const close = (): Promise<void> => {
     cleanup ??= (async () => {
       if (lease !== undefined) clearTimeout(lease)
-      await Promise.allSettled([...iterators.values()].map(iterator => iterator.return?.()))
-      await processes.close()
-      await runtime.close()
-      await rm(root, { recursive: true, force: true })
+      lifetime.abort(new Error('SSH helper is closing'))
+      for (const record of iterators.values()) record.controller.abort(lifetime.signal.reason)
+      await Promise.allSettled([...iterators.values()].map(record => record.iterator.return?.()))
+      iterators.clear()
+      try { await processes.close() }
+      finally {
+        try { await runtime.close() }
+        finally { await rm(root, { recursive: true, force: true }) }
+      }
     })()
     return cleanup
   }
   const touchLease = (): void => {
     if (lease !== undefined) clearTimeout(lease)
-    lease = setTimeout(() => { peer.close(new Error('SSH helper client lease expired')); void close().catch(() => { process.exitCode = 1 }) }, leaseMs)
+    lease = setTimeout(() => { peer.close(new Error('SSH helper client lease expired')) }, leaseMs)
   }
-  const policy = async (raw: unknown): Promise<SandboxExecutionPolicy> => {
+  const policy = async (raw: unknown, signal: AbortSignal): Promise<SandboxExecutionPolicy> => {
     const parsed = policySchema.parse(raw)
-    const target = await ctx.fs.resolve(parsed.workspaceRoot)
+    const target = await ctx.fs.resolve(parsed.workspaceRoot, { signal })
     return { ...parsed, workspaceRoot: ctx.fs.processPath(target) } as SandboxExecutionPolicy
   }
   const asTarget = (raw: unknown): FsTarget => targetSchema.parse(raw) as FsTarget
-  const peer = new SshRpcPeer(process.stdin, process.stdout, MAX_FRAME_BYTES, 128, async (method, raw, signal) => {
+  const peer = new SshRpcPeer(transport.input, transport.output, MAX_FRAME_BYTES, 128, async (method, raw, requestSignal) => {
+    const signal = AbortSignal.any([requestSignal, lifetime.signal])
     if (method === 'hello') {
       if (initialized) throw new Error('SSH helper handshake already completed')
       const input = z.object({
         protocol: z.literal(SSH_PROTOCOL_VERSION), workspace: remotePath, leaseMs: z.number().int().min(3000).max(600_000),
         bootstrapPath: remotePath.optional(),
       }).strict().parse(raw)
-      const workspace = ctx.fs.processPath(await ctx.fs.resolve(input.workspace, { signal }))
-      process.chdir(workspace)
+      workspace = ctx.fs.processPath(await ctx.fs.resolve(input.workspace, { signal }))
       leaseMs = input.leaseMs
       initialized = true
       touchLease()
       return {
-        protocol: SSH_PROTOCOL_VERSION, hash: createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url))).digest('hex'),
+        protocol: SSH_PROTOCOL_VERSION, hash: createHash('sha256').update(readFileSync(transport.entryPath)).digest('hex'),
         platform: process.platform, nodeVersion: process.version, node: process.execPath, root, workspace,
         ...(input.bootstrapPath === undefined ? {} : { bootstrapHash: createHash('sha256').update(readFileSync(input.bootstrapPath)).digest('hex') }),
       }
@@ -103,26 +125,36 @@ export async function runSshHelper(): Promise<void> {
     }
     if (method === 'sandbox') {
       const input = z.object({ argv: z.array(z.string()).min(1), policy: policySchema }).strict().parse(raw)
-      const resolved = await policy(input.policy)
+      const resolved = await policy(input.policy, signal)
       if (resolved.mode === 'danger-full-access') throw new Error('Unconfined argv does not need a sandbox wrapper')
-      return ctx.sandbox.confine(input.argv, resolved as SandboxPolicy)
+      return ctx.sandbox.confine(input.argv, resolved as SandboxPolicy, signal)
     }
     if (method === 'fs.resolve' || method === 'fs.lstat') {
       const input = z.object({ path: z.string(), cwd: remotePath.optional() }).strict().parse(raw)
-      return method === 'fs.resolve' ? ctx.fs.resolve(input.path, { cwd: input.cwd ?? process.cwd(), signal }) : await ctx.fs.lstat(input.path, { cwd: input.cwd ?? process.cwd() }, signal) ?? null
+      return method === 'fs.resolve' ? ctx.fs.resolve(input.path, { cwd: input.cwd ?? workspace, signal }) : await ctx.fs.lstat(input.path, { cwd: input.cwd ?? workspace }, signal) ?? null
     }
     if (method === 'fs.stat' || method === 'fs.list' || method === 'fs.readText' || method === 'fs.stream') {
       const target = asTarget(z.object({ target: targetSchema }).strict().parse(raw).target)
       if (method === 'fs.stat') return await ctx.fs.stat(target, signal) ?? null
       if (method === 'fs.list') return ctx.fs.listDir(target, signal)
-      const stream = await ctx.fs.streamText(target, signal)
       if (method === 'fs.stream') {
-        const { randomUUID } = await import('node:crypto')
-        const id = randomUUID()
-        if (iterators.size >= 128) throw new Error('SSH text stream limit reached')
-        iterators.set(id, stream[Symbol.asyncIterator]())
-        return id
+        if (iterators.size >= SSH_MAX_TEXT_STREAMS) throw new Error('SSH text stream limit reached')
+        const controller = new AbortController()
+        const stream = await ctx.fs.streamText(target, AbortSignal.any([signal, controller.signal]))
+        const iterator = stream[Symbol.asyncIterator]()
+        try {
+          signal.throwIfAborted()
+          if (iterators.size >= SSH_MAX_TEXT_STREAMS) throw new Error('SSH text stream limit reached')
+          const id = randomUUID()
+          iterators.set(id, { iterator, controller })
+          return id
+        } catch (error) {
+          controller.abort(error)
+          await iterator.return?.()
+          throw error
+        }
       }
+      const stream = await ctx.fs.streamText(target, signal)
       let text = ''
       let bytes = 0
       for await (const chunk of stream) {
@@ -134,12 +166,27 @@ export async function runSshHelper(): Promise<void> {
     }
     if (method === 'fs.next' || method === 'fs.streamClose') {
       const { id } = idRequest.parse(raw)
-      const iterator = iterators.get(id)
-      if (iterator === undefined) throw new Error('Unknown SSH text stream')
-      if (method === 'fs.streamClose') { iterators.delete(id); await iterator.return?.(); return null }
-      const next = await iterator.next()
-      if (next.done) iterators.delete(id)
-      return { done: next.done ?? false, value: next.value ?? '' }
+      const record = iterators.get(id)
+      if (record === undefined) throw new Error('Unknown SSH text stream')
+      if (method === 'fs.streamClose') {
+        iterators.delete(id)
+        record.controller.abort(new Error('SSH text stream closed'))
+        await record.iterator.return?.()
+        return null
+      }
+      const abort = (): void => { record.controller.abort(signal.reason) }
+      signal.addEventListener('abort', abort, { once: true })
+      try {
+        signal.throwIfAborted()
+        const next = await record.iterator.next()
+        if (next.done) iterators.delete(id)
+        return { done: next.done ?? false, value: next.value ?? '' }
+      } catch (error) {
+        iterators.delete(id)
+        record.controller.abort(error)
+        await record.iterator.return?.()
+        throw error
+      } finally { signal.removeEventListener('abort', abort) }
     }
     if (method === 'fs.readBytes' || method === 'fs.readRange') {
       const input = z.object({
@@ -161,7 +208,7 @@ export async function runSshHelper(): Promise<void> {
         expected: z.union([intentSchema, z.object({ version: z.string() }).strict()]).optional(), policy: policySchema,
       }).strict().parse(raw)
       const target = asTarget(input.target)
-      const resolved = await policy(input.policy)
+      const resolved = await policy(input.policy, signal)
       if (method === 'fs.write') return ctx.fs.writeText(
         target, z.string().parse(input.content),
         input.expected === undefined ? undefined : intentSchema.parse(input.expected) as FsWriteIntent, signal, resolved,
@@ -175,49 +222,16 @@ export async function runSshHelper(): Promise<void> {
     }
     throw new Error(`Unknown SSH helper operation: ${method}`)
   })
-  peer.once('closed', () => { void close().catch(() => { process.exitCode = 1 }) })
-  const onSignal = (): void => { peer.close(new Error('SSH helper transport was terminated')) }
-  process.once('SIGTERM', onSignal)
-  process.once('SIGHUP', onSignal)
-  process.once('SIGINT', onSignal)
-  touchLease()
-  try {
-    await new Promise<void>((resolve) => { peer.once('closed', () => { resolve() }) })
-    await close()
-  } finally {
-    process.off('SIGTERM', onSignal)
-    process.off('SIGHUP', onSignal)
-    process.off('SIGINT', onSignal)
-  }
-}
-
-async function confine(): Promise<void> {
-  const runtime = await services()
-  try {
-    const request = z.object({
-      policy: policySchema, runner: z.string(), enforcement: z.enum(['full', 'partial']), denialSignatures: z.array(z.string()),
-    }).strict().parse(JSON.parse(process.argv[3] ?? ''))
-    const { policy } = request
-    if (policy.mode === 'danger-full-access' || process.argv[4] !== '--') throw new Error('Invalid remote sandbox invocation')
-    const workspaceRoot = runtime.ctx.fs.processPath(await runtime.ctx.fs.resolve(policy.workspaceRoot))
-    const wrapped = await runtime.ctx.sandbox.confine(process.argv.slice(5), { ...policy, workspaceRoot } as SandboxPolicy)
-    if (wrapped.argv[0] !== request.runner || wrapped.enforcement !== request.enforcement
-      || JSON.stringify(wrapped.denialSignatures) !== JSON.stringify(request.denialSignatures)) {
-      throw new Error('Remote sandbox backend changed; this connection cannot attest the requested confinement')
-    }
-    const control = process.env.DSH_SUBPROCESS_CONTROL === 'pipe'
-    const result = spawnSync(wrapped.argv[0] as string, wrapped.argv.slice(1), {
-      stdio: control ? ['inherit', 'inherit', 'inherit', 'ignore', 'ignore', 'ignore', 'ignore', 7] : 'inherit',
-      env: process.env,
-    })
-    if (result.error !== undefined) throw result.error
-    process.exitCode = result.status ?? 128
-  } finally { await runtime.close() }
-}
-
-if (import.meta.main) {
-  void (process.argv[2] === '--confine' ? confine() : runSshHelper()).catch((error) => {
-    process.stderr.write(`dsh-ssh-sandbox: ${error instanceof Error ? error.message : String(error)}\n`)
-    process.exitCode = 127
+  const closed = Promise.withResolvers<undefined>()
+  peer.once('closed', () => {
+    void close().catch(() => {})
+    closed.resolve(undefined)
   })
+  const onAbort = (): void => { peer.close(new Error('SSH helper transport was terminated')) }
+  transport.signal.addEventListener('abort', onAbort, { once: true })
+  if (transport.signal.aborted) onAbort()
+  else touchLease()
+  try { await closed.promise; await close() }
+  finally { transport.signal.removeEventListener('abort', onAbort) }
+
 }

+ 76 - 24
packages/ssh/ssh/src/index.ts

@@ -4,7 +4,6 @@ import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child
 import { mkdtemp, rm } from 'node:fs/promises'
 import { join } from 'node:path'
 import { createConnection, type Socket } from 'node:net'
-import { promisify } from 'node:util'
 import { Context, Service } from '@deepseek-ai/cordis'
 import schema from '@deepseek-ai/schemastery'
 import { z } from 'zod'
@@ -12,7 +11,6 @@ import { SshRpcPeer, SSH_PROTOCOL_VERSION } from './protocol.ts'
 import { helloSchema, type SshStreamEndpoint } from './schemas.ts'
 import { authenticateStream } from './stream-security.ts'
 
-const execute = promisify(execFile)
 type Hello = z.infer<typeof helloSchema>
 
 /** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
@@ -35,7 +33,7 @@ export interface Config {
   requestTimeoutMs?: number
   /** Maximum JSON payload bytes per helper request or response. */
   maxFrameBytes?: number
-  /** Maximum outstanding administrative requests. */
+  /** Maximum ordinary requests; heartbeat and bounded resource cleanup have reserved capacity. */
   maxPending?: number
   /** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
   leaseMs?: number
@@ -57,14 +55,15 @@ export class SshConnection extends Service {
 
   /** Verified remote helper coordinates; callers must await this before launch. */
   readonly ready: Promise<Hello>
-  /** Installed helper entry in the remote filesystem. */
-  readonly helperPath: string
   private rpc: SshRpcPeer | undefined
   private child: ChildProcessWithoutNullStreams | undefined
   private childClosed: Promise<void> | undefined
   private directory: string | undefined
   private heartbeat: NodeJS.Timeout | undefined
   private closed = false
+  private readonly lifetime = new AbortController()
+  private readonly operations = new Set<Promise<unknown>>()
+  private disposal: Promise<void> | undefined
   private failure: Error | undefined
   private sockets = new Set<Socket>()
   private nextSocket = 0
@@ -79,13 +78,12 @@ export class SshConnection extends Service {
       node: z.string().startsWith('/'), helper: z.string().startsWith('/'), helperHash: z.string().regex(/^[0-9a-f]{64}$/),
       workspace: z.string().startsWith('/'), requestTimeoutMs: z.number().int().positive(),
       bootstrapPath: z.string().startsWith('/').optional(), bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional(),
-      maxFrameBytes: z.number().int().positive().max(64 * 1024 * 1024), maxPending: z.number().int().positive(),
+      maxFrameBytes: z.number().int().positive().max(64 * 1024 * 1024), maxPending: z.number().int().positive().max(128),
       leaseMs: z.number().int().min(3000).max(600_000),
     }).refine(value => (value.bootstrapPath === undefined) === (value.bootstrapHash === undefined), 'bootstrapPath and bootstrapHash must be paired')
       .parse(config) as typeof this.config
-    this.helperPath = this.config.helper
     this.ready = this.start()
-    void this.ready.catch((error) => { this.fail(error instanceof Error ? error : new Error(String(error))) })
+    void this.ready.catch((error: unknown) => { this.fail(error instanceof Error ? error : new Error(String(error))) })
     ctx.effect(() => () => this.dispose())
   }
 
@@ -113,9 +111,10 @@ export class SshConnection extends Service {
    * @param wait - allow a process observation to outlast the administrative deadline.
    * @returns the validated remote result.
    */
-  async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait = false): Promise<T> {
+  async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal, wait: boolean = false): Promise<T> {
+    this.assertOpen()
     await this.ready
-    if (this.failure !== undefined) throw this.failure
+    this.assertOpen()
     const bounded = wait ? signal : signal === undefined
       ? AbortSignal.timeout(this.config.requestTimeoutMs)
       : AbortSignal.any([signal, AbortSignal.timeout(this.config.requestTimeoutMs)])
@@ -129,29 +128,32 @@ export class SshConnection extends Service {
    * @returns a paused socket; attach a consumer before resuming it.
    */
   async connectStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket> {
+    return this.track(this.establishStream(endpoint, signal))
+  }
+
+  private async establishStream(endpoint: SshStreamEndpoint, signal?: AbortSignal): Promise<Socket> {
     const hello = await this.ready
+    this.assertOpen()
+    signal = signal === undefined ? this.lifetime.signal : AbortSignal.any([signal, this.lifetime.signal])
     const remote = endpoint.path
     if (!remote.startsWith(`${hello.root}/`) || /[:\r\n\0]/u.test(remote)) throw new Error('SSH helper returned an invalid stream path')
-    signal?.throwIfAborted()
+    signal.throwIfAborted()
     const local = join(this.directory as string, `s${this.nextSocket++}`)
     const forward = `${local}:${remote}`
     const cancelForward = async (): Promise<void> => {
-      if (!this.closed) await execute('ssh', ['-S', this.controlPath(), '-O', 'cancel', '-L', forward, this.config.host], {
-        timeout: this.config.requestTimeoutMs,
-      }).catch(() => {})
+      // An unavailable master already removed its forwarding listeners.
+      if (!this.closed) await this.controlCommand(['-O', 'cancel', '-L', forward]).catch(() => {})
       await rm(local, { force: true })
     }
     try {
-      await execute('ssh', ['-S', this.controlPath(), '-O', 'forward', '-o', 'ExitOnForwardFailure=yes', '-L', forward, this.config.host], {
-        timeout: this.config.requestTimeoutMs, ...(signal === undefined ? {} : { signal }), maxBuffer: 64 * 1024,
-      })
+      await this.controlCommand(['-O', 'forward', '-o', 'ExitOnForwardFailure=yes', '-L', forward], signal)
     } catch (error) { await cancelForward(); throw error }
-    if (this.closed) throw new Error('SSH connection closed before stream establishment')
-    const socket = createConnection({ path: local, allowHalfOpen: true, ...(signal === undefined ? {} : { signal }) })
+    signal.throwIfAborted()
+    const socket = createConnection({ path: local, allowHalfOpen: true, signal })
     this.sockets.add(socket)
     socket.once('close', () => {
       this.sockets.delete(socket)
-      void cancelForward().catch(() => {})
+      void this.track(cancelForward()).catch(() => {})
     })
     await new Promise<void>((resolve, reject) => {
       const cleanup = (): void => {
@@ -174,28 +176,75 @@ export class SshConnection extends Service {
   }
 
   /** Tear down the helper's remote managed ranges before releasing the SSH master when reachable. */
-  async dispose(): Promise<void> {
-    if (this.closed) return
+  dispose(): Promise<void> {
+    this.disposal ??= this.disposeOnce()
+    return this.disposal
+  }
+
+  private async disposeOnce(): Promise<void> {
     this.closed = true
+    this.lifetime.abort(new Error('SSH connection is closing'))
     if (this.heartbeat !== undefined) clearInterval(this.heartbeat)
     try {
       await this.ready.catch(() => {})
       if (this.failure === undefined) await this.rpc?.request('close', {}, z.null(), AbortSignal.timeout(this.config.requestTimeoutMs))
     } finally {
       this.rpc?.close()
-      for (const socket of this.sockets) socket.destroy()
+      const socketClosures = [...this.sockets].map(socket => new Promise<void>((resolve) => {
+        if (socket.closed) resolve()
+        else { socket.once('close', () => { resolve() }); socket.destroy() }
+      }))
       this.child?.kill('SIGTERM')
       const force = setTimeout(() => { this.child?.kill('SIGKILL') }, this.config.requestTimeoutMs)
       try { await this.childClosed } finally { clearTimeout(force) }
+      await Promise.all(socketClosures)
+      while (this.operations.size > 0) await Promise.allSettled([...this.operations])
       if (this.directory !== undefined) await rm(this.directory, { recursive: true, force: true })
     }
   }
 
   private controlPath(): string { return join(this.directory as string, 'master') }
 
+  private assertOpen(): void {
+    if (this.closed) throw new Error('SSH connection is closed')
+    if (this.failure !== undefined) throw this.failure
+  }
+
+  private track<T>(operation: Promise<T>): Promise<T> {
+    this.operations.add(operation)
+    void operation.finally(() => { this.operations.delete(operation) }).catch(() => {})
+    return operation
+  }
+
+  private async controlCommand(args: string[], signal?: AbortSignal): Promise<void> {
+    const signals = [this.lifetime.signal, AbortSignal.timeout(this.config.requestTimeoutMs)]
+    if (signal !== undefined) signals.push(signal)
+    const combined = AbortSignal.any(signals)
+    combined.throwIfAborted()
+    const result = Promise.withResolvers<undefined>()
+    const command = execFile('ssh', ['-S', this.controlPath(), ...args, this.config.host], {
+      signal: combined, maxBuffer: 64 * 1024,
+    }, (error) => { if (error === null) result.resolve(undefined); else result.reject(error) })
+    const closed = new Promise<void>((resolve) => { command.once('close', () => { resolve() }) })
+    let force: NodeJS.Timeout | undefined
+    const escalate = (): void => {
+      force = setTimeout(() => { command.kill('SIGKILL') }, this.config.requestTimeoutMs)
+      force.unref()
+    }
+    combined.addEventListener('abort', escalate, { once: true })
+    if (combined.aborted) escalate()
+    try { await result.promise }
+    finally {
+      await closed
+      combined.removeEventListener('abort', escalate)
+      if (force !== undefined) clearTimeout(force)
+    }
+  }
+
   private fail(error: Error): void {
     if (this.failure !== undefined) return
     this.failure = error
+    this.lifetime.abort(error)
     if (this.heartbeat !== undefined) clearInterval(this.heartbeat)
     this.rpc?.close(error)
     for (const socket of this.sockets) socket.destroy(error)
@@ -227,8 +276,11 @@ export class SshConnection extends Service {
     if (hello.hash !== this.config.helperHash) throw new Error('SSH helper digest differs from the configured artifact')
     if (hello.bootstrapHash !== this.config.bootstrapHash) throw new Error('SSH PTC bootstrap digest differs from the configured artifact')
     this.remote = hello
+    let heartbeatPending: Promise<unknown> | undefined
     this.heartbeat = setInterval(() => {
-      void rpc.request('heartbeat', {}, z.null(), AbortSignal.timeout(this.config.leaseMs / 2)).catch((error) => { this.fail(error instanceof Error ? error : new Error(String(error))) })
+      heartbeatPending ??= rpc.request('heartbeat', {}, z.null(), AbortSignal.timeout(this.config.leaseMs / 2))
+        .catch((error: unknown) => { this.fail(error instanceof Error ? error : new Error(String(error))) })
+        .finally(() => { heartbeatPending = undefined })
     }, Math.floor(this.config.leaseMs / 3))
     this.heartbeat.unref()
     return hello

+ 51 - 16
packages/ssh/ssh/src/protocol.ts

@@ -8,6 +8,27 @@ import { z } from 'zod'
 /** Wire version shared by the installed helper and client package. */
 export const SSH_PROTOCOL_VERSION = 1
 
+/** Maximum prepared or running process handles owned by one helper. */
+export const SSH_MAX_PROCESS_HANDLES = 128
+/** Maximum open text iterators owned by one helper. */
+export const SSH_MAX_TEXT_STREAMS = 128
+
+const managementLimits = {
+  heartbeat: 1,
+  close: 1,
+  'process.terminate': SSH_MAX_PROCESS_HANDLES,
+  'fs.streamClose': SSH_MAX_TEXT_STREAMS,
+} as const
+type RequestClass = 'ordinary' | keyof typeof managementLimits
+
+function requestClass(method: string): RequestClass {
+  switch (method) {
+    case 'heartbeat': case 'close': case 'process.terminate': case 'fs.streamClose': return method
+    // All other private operations share the configured request budget.
+    default: return 'ordinary'
+  }
+}
+
 const errorSchema = z.object({ name: z.string(), message: z.string(), code: z.string().optional() }).strict()
 const frameSchema = z.discriminatedUnion('type', [
   z.object({ type: z.literal('request'), id: z.string(), method: z.string(), params: z.unknown() }).strict(),
@@ -18,6 +39,10 @@ const frameSchema = z.discriminatedUnion('type', [
 type Frame = z.infer<typeof frameSchema>
 type RequestHandler = (method: string, params: unknown, signal: AbortSignal) => Promise<unknown>
 
+function operationError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
 /** A remote error retains its typed filesystem or sandbox code. */
 export class RemoteOperationError extends Error {
   constructor(message: string, readonly code?: string) {
@@ -28,8 +53,8 @@ export class RemoteOperationError extends Error {
 
 /** The peer owns pending calls and rejects ambiguous operations on connection loss; it never replays requests. */
 export class SshRpcPeer extends EventEmitter {
-  private readonly pending = new Map<string, { resolve(value: unknown): void; reject(error: Error): void }>()
-  private readonly active = new Map<string, AbortController>()
+  private readonly pending = new Map<string, { resolve(value: unknown): void; reject(error: Error): void; requestClass: RequestClass }>()
+  private readonly active = new Map<string, { controller: AbortController; requestClass: RequestClass }>()
   private writeTail = Promise.resolve()
   private queuedBytes = 0
   private failure: Error | undefined
@@ -45,7 +70,7 @@ export class SshRpcPeer extends EventEmitter {
     input.on('error', (error) => { this.close(error) })
     output.on('error', (error) => { this.close(error) })
     output.on('close', () => { this.close() })
-    void this.readFrames().catch((error) => { this.close(error instanceof Error ? error : new Error(String(error))) })
+    void this.readFrames().catch((error: unknown) => { this.close(operationError(error)) })
   }
 
   /**
@@ -59,24 +84,26 @@ export class SshRpcPeer extends EventEmitter {
   async request<T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
     signal?.throwIfAborted()
     if (this.failure !== undefined) throw this.failure
-    if (this.pending.size >= this.maxPending) throw new Error('SSH helper pending request limit reached')
+    const kind = requestClass(method)
+    if (this.atCapacity(kind, this.pending.values())) throw new Error('SSH helper pending request limit reached')
     const id = randomUUID()
     const result = Promise.withResolvers<unknown>()
     void result.promise.catch(() => {})
-    this.pending.set(id, result)
+    this.pending.set(id, { ...result, requestClass: kind })
     const abort = (): void => {
-      this.pending.delete(id)
+      // Keep the credit until the remote handler replies; cancellation does
+      // not mean its resource cleanup has finished.
       result.reject(new Error('SSH operation cancelled; a completed remote mutation is not rolled back'))
       void this.send({ type: 'cancel', id }).catch(() => {})
     }
     signal?.addEventListener('abort', abort, { once: true })
     try {
       void this.send({ type: 'request', id, method, params }).catch((error: unknown) => {
-        result.reject(error instanceof Error ? error : new Error(String(error)))
+        this.pending.delete(id)
+        result.reject(operationError(error))
       })
       return schema.parse(await result.promise)
     } finally {
-      this.pending.delete(id)
       signal?.removeEventListener('abort', abort)
     }
   }
@@ -90,14 +117,14 @@ export class SshRpcPeer extends EventEmitter {
     this.failure = error
     for (const pending of this.pending.values()) pending.reject(error)
     this.pending.clear()
-    for (const controller of this.active.values()) controller.abort(error)
+    for (const { controller } of this.active.values()) controller.abort(error)
     this.active.clear()
     this.input.destroy()
     this.output.destroy()
     this.emit('closed', error)
   }
 
-  private send(frame: Frame): Promise<void> {
+  private async send(frame: Frame): Promise<void> {
     if (this.failure !== undefined) return Promise.reject(this.failure)
     const body = Buffer.from(JSON.stringify(frame))
     if (body.length > this.maxFrameBytes || this.queuedBytes + body.length + 4 > this.maxFrameBytes * 2) {
@@ -121,7 +148,7 @@ export class SshRpcPeer extends EventEmitter {
         if (this.failure !== undefined) closed(this.failure)
       })
     })
-    this.writeTail = write.catch((error) => { this.close(error instanceof Error ? error : new Error(String(error))) })
+    this.writeTail = write.catch((error: unknown) => { this.close(operationError(error)) })
     return write.finally(() => { this.queuedBytes -= bytes.length })
   }
 
@@ -170,24 +197,32 @@ export class SshRpcPeer extends EventEmitter {
       return
     }
     if (frame.type === 'cancel') {
-      this.active.get(frame.id)?.abort(new Error('SSH caller cancelled the operation'))
+      this.active.get(frame.id)?.controller.abort(new Error('SSH caller cancelled the operation'))
       return
     }
-    if (this.handler === undefined || this.active.has(frame.id) || this.active.size >= this.maxPending) {
+    const kind = requestClass(frame.method)
+    if (this.handler === undefined || this.active.has(frame.id) || this.atCapacity(kind, this.active.values())) {
       throw new Error('SSH helper received an unexpected or excessive request')
     }
     const controller = new AbortController()
-    this.active.set(frame.id, controller)
+    this.active.set(frame.id, { controller, requestClass: kind })
     void this.handler(frame.method, frame.params, controller.signal).then(
       value => this.send({ type: 'result', id: frame.id, value: value ?? null }),
       (error: unknown) => {
-        const detail = error instanceof Error ? error : new Error(String(error))
+        const detail = operationError(error)
         const code = 'code' in detail && typeof detail.code === 'string' ? detail.code : undefined
         return this.send({ type: 'error', id: frame.id, error: {
           name: detail.name, message: detail.message, ...(code === undefined ? {} : { code }),
         } })
       },
-    ).catch((error) => { this.close(error instanceof Error ? error : new Error(String(error))) })
+    ).catch((error: unknown) => { this.close(operationError(error)) })
       .finally(() => { this.active.delete(frame.id) })
   }
+
+  private atCapacity(kind: RequestClass, requests: Iterable<{ requestClass: RequestClass }>): boolean {
+    const limit = kind === 'ordinary' ? this.maxPending : managementLimits[kind]
+    let count = 0
+    for (const request of requests) if (request.requestClass === kind) count++
+    return count >= limit
+  }
 }

+ 15 - 1
packages/ssh/ssh/src/schemas.ts

@@ -50,10 +50,24 @@ export type SshStreamEndpoint = z.infer<typeof streamEndpointSchema>
 export const preparedSchema = z.object({ id: z.string().uuid(), streams: z.partialRecord(z.enum(['stdin', 'stdout', 'stderr', 'control', 'terminal']), streamEndpointSchema) }).strict()
 /** Direct process exit facts. */
 export const outcomeSchema = z.object({ exitCode: z.number().int().nullable(), signal: z.string().nullable() }).strict()
-/** Final output locations are remote paths, never host copies. */
+/** A bounded raw tail positioned in whole-stream byte coordinates. */
+export const outputSnapshotSchema = z.object({ tail: z.string().base64(), totalBytes: z.number().int().nonnegative() }).strict()
+/**
+ * Bound one encoded tail and its RPC envelope by the declared collection budget.
+ * @param maxBytes - the collector's retained raw-byte limit.
+ * @returns the private snapshot frame limit, capped by the helper protocol ceiling.
+ */
+export function outputSnapshotFrameLimit(maxBytes: number): number {
+  return Math.min(64 * 1024 * 1024, maxBytes * 2 + 1024)
+}
+/** Direct exit, retained output snapshots, and optional complete spill locations. */
 export const doneSchema = z.object({
   outcome: outcomeSchema,
   spills: z.object({ stdout: remotePath.optional(), stderr: remotePath.optional() }).strict(),
+  collected: z.object({
+    stdout: outputSnapshotSchema.optional(),
+    stderr: outputSnapshotSchema.optional(),
+  }).strict(),
 }).strict()
 /** Remote terminal foreground observation. */
 export const foregroundSchema = z.object({ processGroupId: z.number().int().positive(), inputWaiting: z.boolean() }).strict().nullable()

+ 70 - 0
packages/ssh/ssh/tests/collection.spec.ts

@@ -0,0 +1,70 @@
+/** Collected output remains byte-addressable and recoverable across transport lag and helper teardown. */
+import { createHash } from 'node:crypto'
+import { readFile, rm } from 'node:fs/promises'
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
+import { SshSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-ssh'
+import { createHelperHarness } from './fixtures/helper.ts'
+import { doneSchema, preparedSchema } from '../src/schemas.ts'
+import { z } from 'zod'
+
+const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
+
+describe.skipIf(process.platform === 'win32')('SSH collected output', () => {
+  it('captures the full stream while the snapshot reader is paused and retains its spill after disconnect', async () => {
+    const helper = await createHelperHarness()
+    let spill: string | undefined
+    try {
+      const payload = 'abcdef'.repeat(12000)
+      const prepared = await helper.client.request('process.prepare', {
+        argv: [process.execPath, '-e', `process.stdout.write(${JSON.stringify(payload)})`], cwd: helper.root, graceMs: 500,
+        stdio: { stdin: 'ignore', stdout: { maxBytes: 16, spill: { maxBytes: 100000 } }, stderr: 'pipe' },
+      }, preparedSchema)
+      const output = await helper.connectStream(prepared.streams.stdout!)
+      const error = await helper.connectStream(prepared.streams.stderr!)
+      error.end()
+      error.resume()
+      expect(output.readableFlowing).toBe(false)
+      await helper.client.request('process.start', { id: prepared.id }, z.object({}).strict())
+      const completed = await helper.client.request('process.done', { id: prepared.id }, doneSchema)
+      expect(completed.outcome.exitCode).toBe(0)
+      expect(completed.collected.stdout?.totalBytes).toBe(Buffer.byteLength(payload))
+      expect(Buffer.from(completed.collected.stdout!.tail, 'base64').toString()).toBe(payload.slice(-16))
+      expect(await helper.client.request('process.wait', { id: prepared.id }, z.boolean())).toBe(true)
+      spill = completed.spills.stdout
+      expect(spill).toBeDefined()
+      await helper.close()
+      expect(digest(await readFile(spill!))).toBe(digest(Buffer.from(payload)))
+    } finally {
+      await helper.close()
+      if (spill !== undefined) await rm(spill, { force: true })
+    }
+  }, 15_000)
+
+  it('uses raw byte offsets for the final tail and exposes the complete remote spill', async () => {
+    const helper = await createHelperHarness()
+    const ctx = new Context()
+    ctx.provide('ssh', helper.connection as never)
+    const fiber = await ctx.plugin(SshSubprocessRuntime)
+    let spill: string | undefined
+    try {
+      const bytes = Buffer.from([0x61, 0x62, 0x63, 0x64, 0xff, 0x65, 0x66, 0x67])
+      const handle = ctx.subprocess.spawn({
+        argv: [process.execPath, '-e', `process.stdout.write(Buffer.from(${JSON.stringify([...bytes])}))`], cwd: helper.root, graceMs: 500,
+        stdio: { stdin: 'ignore', stdout: { maxBytes: 4, spill: { maxBytes: 16 } }, stderr: { maxBytes: 8 } },
+      })
+      expect(await handle.done).toEqual({ exitCode: 0, signal: null })
+      expect(await handle.waitForExit()).toBe(true)
+      const tail = handle.collected.stdout!.readFrom(0)
+      expect(tail).toMatchObject({ text: '\ufffdefg', nextOffset: 8, lossy: true })
+      expect(handle.collected.stdout!.readFrom(7)).toMatchObject({ text: 'g', nextOffset: 8, lossy: false })
+      spill = tail.spillPath
+      expect(await readFile(spill!)).toEqual(bytes)
+      expect(handle.collected.stderr!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false })
+    } finally {
+      await fiber.dispose()
+      await helper.close()
+      if (spill !== undefined) await rm(spill, { force: true })
+    }
+  }, 15_000)
+})

+ 129 - 9
packages/ssh/ssh/tests/connection-disposal.spec.ts

@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
 import { Duplex, PassThrough } from 'node:stream'
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { z } from 'zod'
 import { SshRpcPeer } from '../src/protocol.ts'
 import { SshConnection } from '../src/index.ts'
 
@@ -21,6 +22,13 @@ vi.mock('node:tls', async importOriginal => ({
   connect: transport.tlsConnect,
 }))
 
+// These transport-only tests own no filesystem or native process; cleanup
+// settles in microtasks so it cannot hide an unjoined control command.
+vi.mock('node:fs/promises', () => ({
+  mkdtemp: async () => '/virtual/dsh-ssh-test',
+  rm: async () => {},
+}))
+
 class PendingSocket extends Duplex {
   override _read(): void {}
   override _write(_chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
@@ -35,8 +43,10 @@ class HelperChild extends EventEmitter {
   readonly stdout = new PassThrough()
   readonly stderr = new PassThrough()
   private stopped = false
+  readonly signals: (string | undefined)[] = []
 
-  kill(): boolean {
+  kill(signal?: string): boolean {
+    this.signals.push(signal)
     if (!this.stopped) {
       this.stopped = true
       queueMicrotask(() => { this.emit('close', 0, null) })
@@ -45,10 +55,18 @@ class HelperChild extends EventEmitter {
   }
 }
 
-async function setup(phase: 'connect' | 'authenticate') {
+async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward' | 'cancel', holdHeartbeat = false, holdClose = false) {
   const child = new HelperChild()
+  const heartbeat = Promise.withResolvers<null>()
+  const ordinary = Promise.withResolvers<null>()
+  const ordinaryEntered = Promise.withResolvers<undefined>()
+  const closing = Promise.withResolvers<null>()
+  const closeEntered = Promise.withResolvers<undefined>()
+  let heartbeatCalls = 0
   const helper = new SshRpcPeer(child.stdin, child.stdout, 4096, 8, async (method) => {
-    if (method === 'close' || method === 'heartbeat') return null
+    if (method === 'close') { closeEntered.resolve(undefined); return holdClose ? closing.promise : null }
+    if (method === 'heartbeat') { heartbeatCalls++; return holdHeartbeat ? heartbeat.promise : null }
+    if (method === 'ordinary') { ordinaryEntered.resolve(undefined); return ordinary.promise }
     if (method !== 'hello') throw new Error(`unexpected helper operation: ${method}`)
     return { protocol: 1, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', node: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
   })
@@ -56,11 +74,26 @@ async function setup(phase: 'connect' | 'authenticate') {
   const secureSocket = new PendingSocket()
   const authenticating = Promise.withResolvers<undefined>()
   const allocated = Promise.withResolvers<undefined>()
+  const controlEntered = Promise.withResolvers<undefined>()
+  const controlAborted = Promise.withResolvers<undefined>()
+  let finishControl: (() => void) | undefined
+  let controlChild: HelperChild | undefined
   transport.spawn.mockReturnValue(child)
   transport.execFile.mockImplementation((
-    _file: string, _args: string[], _options: unknown, callback: (error: null, stdout: string, stderr: string) => void,
+    _file: string, args: string[], options: { signal: AbortSignal },
+    callback: (error: Error | null, stdout: string, stderr: string) => void,
   ) => {
-    queueMicrotask(() => { callback(null, '', '') })
+    const command = new HelperChild()
+    if (pauseControl !== undefined && args.includes(pauseControl)) {
+      controlChild = command
+      finishControl = () => { command.kill() }
+      options.signal.addEventListener('abort', () => {
+        callback(new Error('control command cancelled'), '', '')
+        controlAborted.resolve(undefined)
+      }, { once: true })
+      controlEntered.resolve(undefined)
+    } else queueMicrotask(() => { callback(null, '', ''); command.kill() })
+    return command
   })
   transport.connect.mockImplementation(() => {
     allocated.resolve(undefined)
@@ -78,9 +111,13 @@ async function setup(phase: 'connect' | 'authenticate') {
   const ctx = new Context()
   const fiber = ctx.plugin(SshConnection, {
     host: 'hermetic-test', node: '/usr/bin/node', helper: '/opt/dsh/helper.js', helperHash: 'a'.repeat(64), workspace: '/workspace',
-    requestTimeoutMs: 10_000, maxFrameBytes: 4096, maxPending: 8, leaseMs: 30_000,
+    requestTimeoutMs: 10_000, maxFrameBytes: 4096, maxPending: holdHeartbeat ? 1 : 8, leaseMs: 30_000,
   })
   onTestFinished(async () => {
+    heartbeat.resolve(null)
+    ordinary.resolve(null)
+    closing.resolve(null)
+    finishControl?.()
     rawSocket.destroy()
     secureSocket.destroy()
     helper.close()
@@ -91,13 +128,96 @@ async function setup(phase: 'connect' | 'authenticate') {
   })
   await fiber
   const pending = ctx.ssh.connectStream({ path: '/tmp/remote-helper/control', capability: 'b'.repeat(64) })
-  const observed = pending.then(value => ({ value }), error => ({ error: error as unknown }))
-  await allocated.promise
+  const observed = pending.then(value => ({ value }), (error: unknown) => ({ error }))
+  if (pauseControl === 'forward') await controlEntered.promise
+  else await allocated.promise
   if (phase === 'authenticate') await authenticating.promise
-  return { service: ctx.ssh, socket, observed }
+  return { service: ctx.ssh, socket, observed, controlEntered: controlEntered.promise,
+    controlAborted: controlAborted.promise, finishControl: () => { finishControl?.() },
+    controlSignals: () => controlChild?.signals, heartbeatCalls: () => heartbeatCalls,
+    ordinaryEntered: ordinaryEntered.promise, releaseOrdinary: () => { ordinary.resolve(null) },
+    releaseHeartbeat: () => { heartbeat.resolve(null) }, closeEntered: closeEntered.promise,
+    releaseClose: () => { closing.resolve(null) } }
 }
 
 describe.skipIf(process.platform === 'win32')('SSH stream establishment disposal', () => {
+  it.each(['ready continuation', 'already closing'] as const)('rejects new requests during the %s without sending them', async (phase) => {
+    const state = await setup('connect', undefined, false, true)
+    const request = () => state.service.request('ordinary', {}, z.null(), undefined, true)
+      .then(() => ({ kind: 'completed' as const }), (error: unknown) => ({ kind: 'rejected' as const, error }))
+    const pending = phase === 'ready continuation' ? request() : undefined
+    const disposal = state.service.dispose()
+    await state.closeEntered
+    const outcome = await Promise.race([
+      pending ?? request(),
+      state.ordinaryEntered.then(() => ({ kind: 'admitted' as const })),
+    ])
+    expect(outcome.kind).toBe('rejected')
+    if (outcome.kind !== 'rejected') throw new Error('request was sent after disposal began')
+    expect(String(outcome.error)).toContain('SSH connection is closed')
+    state.releaseClose()
+    await disposal
+  })
+
+  it('keeps one heartbeat outstanding when ordinary capacity is full and its reply is delayed', async () => {
+    vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
+    let state: Awaited<ReturnType<typeof setup>> | undefined
+    try {
+      state = await setup('connect', undefined, true)
+      let ordinarySettled = false
+      const pending = state.service.request('ordinary', {}, z.null(), undefined, true)
+        .then(value => ({ value }), (error: unknown) => ({ error }))
+        .finally(() => { ordinarySettled = true })
+      await state.ordinaryEntered
+      await vi.advanceTimersByTimeAsync(10_000)
+      expect(state.heartbeatCalls()).toBe(1)
+      await vi.advanceTimersByTimeAsync(10_000)
+      expect(state.heartbeatCalls()).toBe(1)
+      expect(ordinarySettled).toBe(false)
+      state.releaseHeartbeat()
+      await vi.advanceTimersByTimeAsync(0)
+      await vi.advanceTimersByTimeAsync(10_000)
+      expect(state.heartbeatCalls()).toBe(2)
+      state.releaseOrdinary()
+      expect(await pending).toEqual({ value: null })
+    } finally {
+      state?.releaseHeartbeat()
+      state?.releaseOrdinary()
+      await state?.service.dispose()
+      vi.useRealTimers()
+    }
+  })
+
+  it('force-kills a control command that outlives cancellation and joins its close', async () => {
+    const { service, observed, controlAborted, controlSignals } = await setup('connect', 'forward')
+    vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+    try {
+      const disposal = service.dispose()
+      await controlAborted
+      await vi.advanceTimersByTimeAsync(10_000)
+      await disposal
+      expect(controlSignals()).toContain('SIGKILL')
+      expect(await observed).toHaveProperty('error')
+    } finally { vi.useRealTimers() }
+  })
+
+  it.each(['forward', 'cancel'] as const)('joins a pending SSH %s command after its cancellation callback', async (command) => {
+    const { service, socket, observed, controlEntered, controlAborted, finishControl } = await setup('connect', command)
+    if (command === 'cancel') socket.destroy()
+    await controlEntered
+    let disposed = false
+    const disposal = service.dispose().then(() => { disposed = true })
+    await controlAborted
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(disposed).toBe(false)
+    finishControl()
+    await disposal
+    expect(await observed).toHaveProperty('error')
+    const commands = transport.execFile.mock.calls.length
+    await expect(service.connectStream({ path: '/tmp/remote-helper/control', capability: 'b'.repeat(64) })).rejects.toThrow(/closed|disconnected|lost/)
+    expect(transport.execFile.mock.calls).toHaveLength(commands)
+  })
+
   it.each(['connect', 'authenticate'] as const)('rejects a close-only socket while awaiting %s', async (phase) => {
     const { socket, observed } = await setup(phase)
     const errors: Error[] = []

+ 53 - 0
packages/ssh/ssh/tests/fixtures/helper.ts

@@ -0,0 +1,53 @@
+/** Real helper and authenticated stream endpoints over an in-memory administrative transport. */
+import { PassThrough } from 'node:stream'
+import { createConnection, type Socket } from 'node:net'
+import { once } from 'node:events'
+import { mkdtemp, realpath, rm } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { z } from 'zod'
+import { runSshHelper } from '../../src/helper.ts'
+import { SshRpcPeer } from '../../src/protocol.ts'
+import { helloSchema, type SshStreamEndpoint } from '../../src/schemas.ts'
+import { authenticateStream } from '../../src/stream-security.ts'
+
+export async function createHelperHarness(handshake = true, leaseMs = 30_000) {
+  const root = await realpath(await mkdtemp('/tmp/dsh-ssh-rpc-'))
+  const input = new PassThrough()
+  const output = new PassThrough()
+  const controller = new AbortController()
+  const serving = runSshHelper({ input, output, entryPath: fileURLToPath(new URL('../../src/helper-entry.ts', import.meta.url)), signal: controller.signal })
+  void serving.catch(() => {})
+  const client = new SshRpcPeer(output, input, 64 * 1024 * 1024, 128)
+  const sockets = new Set<Socket>()
+  const hello = () => client.request('hello', { protocol: 1, workspace: root, leaseMs }, helloSchema)
+  let closing: Promise<void> | undefined
+  const close = () => {
+    closing ??= (async () => {
+      for (const socket of sockets) socket.destroy()
+      client.close()
+      controller.abort()
+      try { await serving } finally { await rm(root, { recursive: true, force: true }) }
+    })()
+    return closing
+  }
+  const connectStream = async (endpoint: SshStreamEndpoint, signal?: AbortSignal) => {
+    const raw = createConnection({ path: endpoint.path, allowHalfOpen: true, ...(signal === undefined ? {} : { signal }) })
+    sockets.add(raw)
+    raw.once('close', () => { sockets.delete(raw) })
+    await once(raw, 'connect')
+    const secured = await authenticateStream(raw, endpoint.capability, 5000)
+    sockets.add(secured)
+    secured.on('error', () => {})
+    secured.once('close', () => { sockets.delete(secured) })
+    return secured
+  }
+  let facts: z.infer<typeof helloSchema> | undefined
+  try { if (handshake) facts = await hello() } catch (error) { await close(); throw error }
+  const connection = {
+    ready: Promise.resolve(facts), nodeExecutable: process.execPath,
+    request: <T>(method: string, params: unknown, schema: z.ZodType<T>, signal?: AbortSignal) =>
+      client.request(method, params, schema, signal),
+    connectStream, dispose: close,
+  }
+  return { root, client, hello, close, serving, controller, facts, connection, connectStream }
+}

+ 231 - 0
packages/ssh/ssh/tests/helper-boundaries.spec.ts

@@ -0,0 +1,231 @@
+/** Helper leases, stream ownership and request validation over the administrative wire. */
+import { createHash, randomUUID } from 'node:crypto'
+import { readFile, stat, writeFile } from 'node:fs/promises'
+import { PassThrough } from 'node:stream'
+import { fileURLToPath } from 'node:url'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { describe, expect, it, vi } from 'vitest'
+import { z } from 'zod'
+import { createHelperHarness } from './fixtures/helper.ts'
+import { runSshHelper } from '../src/helper.ts'
+import { RemoteProcesses } from '../src/helper-processes.ts'
+import { helloSchema, preparedSchema, targetSchema } from '../src/schemas.ts'
+import { SSH_MAX_TEXT_STREAMS } from '../src/protocol.ts'
+
+const entryPath = fileURLToPath(new URL('../src/helper-entry.ts', import.meta.url))
+const nextSchema = z.object({ done: z.boolean(), value: z.string() })
+
+describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bounds', () => {
+  it('rejects unsupported hosts and cancellation before service startup', async () => {
+    const input = new PassThrough()
+    const output = new PassThrough()
+    const controller = new AbortController()
+    const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
+    try {
+      await expect(runSshHelper({ input, output, entryPath, signal: controller.signal })).rejects.toThrow('POSIX host')
+    } finally { platform.mockRestore() }
+    controller.abort(new Error('startup cancelled'))
+    try {
+      await expect(runSshHelper({ input, output, entryPath, signal: controller.signal })).rejects.toThrow('startup cancelled')
+    } finally { input.destroy(); output.destroy() }
+  })
+
+  it('joins teardown when cancellation arrives while services are mounting', async () => {
+    const input = new PassThrough()
+    const output = new PassThrough()
+    const controller = new AbortController()
+    const serving = runSshHelper({ input, output, entryPath, signal: controller.signal })
+    controller.abort()
+    try {
+      await serving
+      expect(input.destroyed).toBe(true)
+      expect(output.destroyed).toBe(true)
+    } finally { input.destroy(); output.destroy() }
+  })
+
+  it('returns the negotiated bootstrap digest and rejects invalid handshake fields', async () => {
+    const test = await createHelperHarness(false)
+    try {
+      for (const params of [
+        { protocol: 2, workspace: test.root, leaseMs: 3000 },
+        { protocol: 1, workspace: 'relative', leaseMs: 3000 },
+        { protocol: 1, workspace: test.root, leaseMs: 2999 },
+      ]) await expect(test.client.request('hello', params, helloSchema)).rejects.toThrow()
+      const bootstrapPath = `${test.root}/bootstrap.js`
+      await writeFile(bootstrapPath, 'export const identity = "test"\n')
+      const facts = await test.client.request('hello', { protocol: 1, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema)
+      expect(facts.bootstrapHash).toBe(createHash('sha256').update(await readFile(bootstrapPath)).digest('hex'))
+    } finally { await test.close() }
+  })
+
+  it('expires the client lease and joins open text streams and reserved sockets', async () => {
+    vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+    let test: Awaited<ReturnType<typeof createHelperHarness>> | undefined
+    try {
+      test = await createHelperHarness(true, 3000)
+      await writeFile(`${test.root}/text`, 'pending stream')
+      const target = await test.client.request('fs.resolve', { path: 'text' }, targetSchema)
+      await test.client.request('fs.stream', { target }, z.string())
+      const reservation = await test.client.request('process.prepare', {
+        argv: ['true'], cwd: test.root, graceMs: 100,
+        stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
+      }, preparedSchema)
+      const endpoint = reservation.streams.stdout
+      expect(endpoint).toBeDefined()
+      await test.client.request('heartbeat', {}, z.null())
+      await vi.advanceTimersByTimeAsync(3000)
+      await test.serving
+      await expect(stat(test.facts!.root)).rejects.toMatchObject({ code: 'ENOENT' })
+      await expect(stat(endpoint!.path)).rejects.toMatchObject({ code: 'ENOENT' })
+      await expect(test.client.request('heartbeat', {}, z.null())).rejects.toThrow('connection lost')
+    } finally {
+      try { await test?.close() }
+      finally { vi.useRealTimers() }
+    }
+  })
+
+  it('bounds whole-text and byte-range transfers while preserving streaming access', async () => {
+    const test = await createHelperHarness()
+    try {
+      await writeFile(`${test.root}/large`, Buffer.alloc(8 * 1024 * 1024 + 1, 0x61))
+      const target = await test.client.request('fs.resolve', { path: 'large' }, targetSchema)
+      await expect(test.client.request('fs.readText', { target }, z.string())).rejects.toMatchObject({ code: 'FS_TOO_LARGE' })
+      await expect(test.client.request('fs.readRange', { target, offset: 0, length: 8 * 1024 * 1024 + 1 }, z.string())).rejects.toThrow()
+      const stream = await test.client.request('fs.stream', { target }, z.string())
+      const next = await test.client.request('fs.next', { id: stream }, nextSchema)
+      expect(next.done).toBe(false)
+      expect(next.value.length).toBeGreaterThan(0)
+      await test.client.request('fs.streamClose', { id: stream }, z.null())
+    } finally { await test.close() }
+  })
+
+  it('enforces stream capacity before and after overlapping allocation', async () => {
+    const test = await createHelperHarness()
+    const bothEntered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    // oxlint-disable-next-line typescript/unbound-method -- The delayed call supplies the original service receiver.
+    const original = LocalFileSystem.prototype.streamText
+    const spy = vi.spyOn(LocalFileSystem.prototype, 'streamText')
+    try {
+      await writeFile(`${test.root}/text`, 'available')
+      const target = await test.client.request('fs.resolve', { path: 'text' }, targetSchema)
+      for (let index = 0; index < SSH_MAX_TEXT_STREAMS - 1; index++) {
+        await test.client.request('fs.stream', { target }, z.string())
+      }
+      let entered = 0
+      spy.mockImplementation(async function (this: LocalFileSystem, target, signal) {
+        if (++entered === 2) bothEntered.resolve(undefined)
+        await release.promise
+        return original.call(this, target, signal)
+      })
+      const overlap = Promise.allSettled([
+        test.client.request('fs.stream', { target }, z.string()),
+        test.client.request('fs.stream', { target }, z.string()),
+      ])
+      await bothEntered.promise
+      release.resolve(undefined)
+      const overlapping = await overlap
+      spy.mockRestore()
+      expect(overlapping.filter(result => result.status === 'fulfilled')).toHaveLength(1)
+      const rejected = overlapping.find(result => result.status === 'rejected')
+      expect(rejected?.status === 'rejected' ? rejected.reason : undefined).toMatchObject({ message: 'SSH text stream limit reached' })
+      await expect(test.client.request('fs.stream', { target }, z.string())).rejects.toThrow('text stream limit')
+      const created = overlapping.find(result => result.status === 'fulfilled')
+      if (created?.status !== 'fulfilled') throw new Error('no stream was admitted')
+      await test.client.request('fs.streamClose', { id: created.value }, z.null())
+      expect(await test.client.request('fs.stream', { target }, z.string())).toBeTypeOf('string')
+    } finally { release.resolve(undefined); spy.mockRestore(); await test.close() }
+  })
+
+  it('returns an iterator allocated after its remote request was cancelled', async () => {
+    const test = await createHelperHarness()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<AsyncIterable<string>>()
+    const returned = vi.fn(() => Promise.resolve({ done: true as const, value: undefined }))
+    const stream: AsyncIterable<string> = { [Symbol.asyncIterator]: () => ({ next: returned, return: returned }) }
+    const spy = vi.spyOn(LocalFileSystem.prototype, 'streamText').mockImplementationOnce(() => { entered.resolve(undefined); return release.promise })
+    const controller = new AbortController()
+    try {
+      const target = await test.client.request('fs.resolve', { path: 'delayed' }, targetSchema)
+      const request = test.client.request('fs.stream', { target }, z.string(), controller.signal)
+      const rejected = expect(request).rejects.toThrow('cancelled')
+      await entered.promise
+      controller.abort()
+      await test.client.request('heartbeat', {}, z.null())
+      release.resolve(stream)
+      await rejected
+      await expect.poll(() => returned.mock.calls.length).toBe(1)
+    } finally { release.resolve(stream); spy.mockRestore(); await test.close() }
+  })
+
+  it('removes failed text iterators and reports the underlying filesystem error', async () => {
+    const test = await createHelperHarness()
+    try {
+      const target = await test.client.request('fs.resolve', { path: 'missing' }, targetSchema)
+      const id = await test.client.request('fs.stream', { target }, z.string())
+      await expect(test.client.request('fs.next', { id }, nextSchema)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
+      await expect(test.client.request('fs.next', { id }, nextSchema)).rejects.toThrow('Unknown SSH text stream')
+    } finally { await test.close() }
+  })
+
+  it('cancels a pending iterator read and closes it before accepting another read', async () => {
+    const test = await createHelperHarness()
+    const entered = Promise.withResolvers<undefined>()
+    const pending = Promise.withResolvers<IteratorResult<string>>()
+    const returned = vi.fn(() => Promise.resolve({ done: true as const, value: undefined }))
+    const spy = vi.spyOn(LocalFileSystem.prototype, 'streamText').mockImplementationOnce((_target, signal) => {
+      signal!.addEventListener('abort', () => { pending.reject(signal!.reason) }, { once: true })
+      let reads = 0
+      return Promise.resolve({ [Symbol.asyncIterator]: () => ({
+        next: () => {
+          if (reads++ === 0) return Promise.resolve({ value: 'before wait' })
+          entered.resolve(undefined)
+          return pending.promise
+        }, return: returned,
+      }) })
+    })
+    const controller = new AbortController()
+    try {
+      const target = await test.client.request('fs.resolve', { path: 'pending' }, targetSchema)
+      const id = await test.client.request('fs.stream', { target }, z.string())
+      expect(await test.client.request('fs.next', { id }, nextSchema)).toEqual({ done: false, value: 'before wait' })
+      const request = test.client.request('fs.next', { id }, nextSchema, controller.signal)
+      const rejected = expect(request).rejects.toThrow('cancelled')
+      await entered.promise
+      controller.abort()
+      await rejected
+      await expect.poll(() => returned.mock.calls.length).toBe(1)
+      await expect(test.client.request('fs.next', { id }, nextSchema)).rejects.toThrow('Unknown SSH text stream')
+    } finally { pending.resolve({ done: true, value: undefined }); spy.mockRestore(); await test.close() }
+  })
+
+  it('reports cleanup failure through the serving lifetime after transport loss', async () => {
+    const test = await createHelperHarness()
+    const failure = new Error('native process range cleanup failed')
+    const close = vi.spyOn(RemoteProcesses.prototype, 'close').mockRejectedValueOnce(failure)
+    try {
+      const rejected = expect(test.serving).rejects.toBe(failure)
+      test.controller.abort()
+      await rejected
+      await expect(stat(test.facts!.root)).rejects.toMatchObject({ code: 'ENOENT' })
+    } finally { close.mockRestore(); await expect(test.close()).rejects.toBe(failure) }
+  })
+
+  it('routes process and terminal lifecycle operations while rejecting unknown ids', async () => {
+    const test = await createHelperHarness()
+    try {
+      const prepared = await test.client.request('process.prepare', {
+        argv: ['true'], cwd: test.root, graceMs: 100,
+        stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
+      }, preparedSchema)
+      for (const method of ['terminal.write', 'terminal.inspect', 'terminal.signal']) {
+        await expect(test.client.request(method, { id: prepared.id, value: 'x' }, z.unknown())).rejects.toThrow('does not own a terminal')
+      }
+      await test.client.request('process.terminate', { id: prepared.id }, z.null())
+      for (const method of ['process.start', 'process.done', 'process.wait', 'process.terminate']) {
+        await expect(test.client.request(method, { id: randomUUID() }, z.unknown())).rejects.toThrow('Unknown or expired SSH process handle')
+      }
+      await expect(test.client.request('process.wait', { id: 'malformed' }, z.unknown())).rejects.toThrow()
+    } finally { await test.close() }
+  })
+})

+ 96 - 0
packages/ssh/ssh/tests/helper-runtime.spec.ts

@@ -0,0 +1,96 @@
+/** Real helper dispatch over private in-memory transport, without changing the Harness process cwd. */
+import { symlink, writeFile } from 'node:fs/promises'
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { createHelperHarness as helper } from './fixtures/helper.ts'
+import { targetSchema, writeResultSchema, editResultSchema, infoSchema, entriesSchema } from '../src/schemas.ts'
+
+
+const policy = (workspaceRoot: string) => ({ mode: 'workspace-write', workspaceRoot })
+
+describe.skipIf(process.platform === 'win32')('SSH helper runtime', () => {
+  it('binds relative filesystem operations to the negotiated workspace without chdir', async () => {
+    const before = process.cwd()
+    const test = await helper(false)
+    try {
+      await expect(test.client.request('heartbeat', {}, z.null())).rejects.toThrow('not accepting')
+      const facts = await test.hello()
+      expect(facts.workspace).toBe(test.root)
+      expect(process.cwd()).toBe(before)
+      await expect(test.hello()).rejects.toThrow('already completed')
+      await expect(test.client.request('unrecognized', {}, z.unknown())).rejects.toThrow('Unknown SSH helper operation')
+      await test.client.request('heartbeat', {}, z.null())
+      expect(await test.client.request('fs.resolve', { path: '.' }, targetSchema)).toMatchObject({ targetKey: test.root })
+      await test.client.request('close', {}, z.null())
+      await expect(test.client.request('fs.stat', {}, z.unknown())).rejects.toThrow('not accepting')
+    } finally { await test.close() }
+  })
+
+  it('preserves guarded write/edit outcomes and filesystem error codes across RPC', async () => {
+    const test = await helper()
+    try {
+      const target = await test.client.request('fs.resolve', { path: 'file.txt' }, targetSchema)
+      const created = await test.client.request('fs.write', { target, content: 'first text', policy: policy(test.root), expected: { kind: 'createIfAbsent' } }, writeResultSchema)
+      expect(created).toMatchObject({ operation: 'create', before: null, after: 'first text' })
+      await expect(test.client.request('fs.write', { target, content: 'stale', policy: policy(test.root), expected: { kind: 'replaceIfVersion', version: 'stale' } }, writeResultSchema)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
+      const updated = await test.client.request('fs.write', { target, content: 'second text', policy: policy(test.root), expected: { kind: 'replaceIfVersion', version: created.version } }, writeResultSchema)
+      const edited = await test.client.request('fs.edit', { target, edit: { oldString: 'second', newString: 'third', replaceAll: false }, expected: { version: updated.version }, policy: policy(test.root) }, editResultSchema)
+      expect(edited.after).toBe('third text')
+      await test.client.request('fs.edit', { target, edit: { oldString: 'third', newString: 'fourth', replaceAll: false }, policy: policy(test.root) }, editResultSchema)
+      expect(await test.client.request('fs.readText', { target }, z.string())).toBe('fourth text')
+      await expect(test.client.request('fs.write', { target, content: 'denied', policy: { mode: 'read-only', workspaceRoot: test.root } }, writeResultSchema)).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+    } finally { await test.close() }
+  })
+
+  it('returns metadata, byte ranges and canonical symlink observations', async () => {
+    const test = await helper()
+    try {
+      await writeFile(`${test.root}/bytes`, 'abcdef')
+      await symlink(`${test.root}/bytes`, `${test.root}/link`)
+      const target = await test.client.request('fs.resolve', { path: 'link', cwd: test.root }, targetSchema)
+      expect(target.targetKey).toBe(`${test.root}/bytes`)
+      expect(await test.client.request('fs.lstat', { path: 'link', cwd: test.root }, z.unknown())).toMatchObject({ type: 'symlink' })
+      expect(await test.client.request('fs.lstat', { path: 'absent' }, z.null())).toBeNull()
+      expect(await test.client.request('fs.stat', { target }, infoSchema)).toMatchObject({ type: 'file', size: 6 })
+      const missing = await test.client.request('fs.resolve', { path: 'absent' }, targetSchema)
+      expect(await test.client.request('fs.stat', { target: missing }, z.null())).toBeNull()
+      expect(Buffer.from(await test.client.request('fs.readBytes', { target, maxBytes: 16 }, z.string()), 'base64').toString()).toBe('abcdef')
+      expect(Buffer.from(await test.client.request('fs.readRange', { target, offset: 1, length: 3 }, z.string()), 'base64').toString()).toBe('bcd')
+      const directory = await test.client.request('fs.resolve', { path: '.' }, targetSchema)
+      expect((await test.client.request('fs.list', { target: directory }, entriesSchema)).map(entry => entry.name)).toContain('bytes')
+      await expect(test.client.request('fs.readRange', { target, offset: -1, length: 3 }, z.string())).rejects.toThrow()
+    } finally { await test.close() }
+  })
+
+  it('closes remote text iterators after completion and explicit early return', async () => {
+    const test = await helper()
+    try {
+      await writeFile(`${test.root}/text`, 'streamed')
+      const target = await test.client.request('fs.resolve', { path: 'text' }, targetSchema)
+      const nextSchema = z.object({ done: z.boolean(), value: z.string() })
+      const id = await test.client.request('fs.stream', { target }, z.string())
+      expect(await test.client.request('fs.next', { id }, nextSchema)).toEqual({ done: false, value: 'streamed' })
+      let next = await test.client.request('fs.next', { id }, nextSchema)
+      while (!next.done) {
+        expect(next.value).toBe('')
+        next = await test.client.request('fs.next', { id }, nextSchema)
+      }
+      expect(next.value).toBe('')
+      await expect(test.client.request('fs.next', { id }, nextSchema)).rejects.toThrow('Unknown SSH text stream')
+      const early = await test.client.request('fs.stream', { target }, z.string())
+      await test.client.request('fs.streamClose', { id: early }, z.null())
+      await expect(test.client.request('fs.next', { id: early }, nextSchema)).rejects.toThrow('Unknown SSH text stream')
+    } finally { await test.close() }
+  })
+
+  it('refuses unconfined sandbox requests and resolves executables in the helper world', async () => {
+    const test = await helper()
+    try {
+      await expect(test.client.request('sandbox', { argv: ['true'], policy: { mode: 'danger-full-access', workspaceRoot: test.root } }, z.unknown())).rejects.toThrow('does not need')
+      expect(await test.client.request('executable', { command: process.execPath, env: { REMOVED: null } }, z.string())).toBe(process.execPath)
+      expect(await test.client.request('executable', { command: process.execPath }, z.string())).toBe(process.execPath)
+      const wrapped = await test.client.request('sandbox', { argv: ['true'], policy: policy(test.root) }, z.object({ argv: z.array(z.string()), enforcement: z.enum(['full', 'partial']) }).passthrough())
+      expect(wrapped.argv.at(-1)).toBe('true')
+    } finally { await test.close() }
+  })
+})

+ 176 - 0
packages/ssh/ssh/tests/management-capacity.spec.ts

@@ -0,0 +1,176 @@
+/** Independent request capacities keep helper health and cleanup available under ordinary load. */
+import { PassThrough } from 'node:stream'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { z } from 'zod'
+import { SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SshRpcPeer } from '../src/protocol.ts'
+
+const frameBytes = 128 * 1024
+const management = [
+  { method: 'heartbeat', limit: 1 },
+  { method: 'close', limit: 1 },
+  { method: 'process.terminate', limit: SSH_MAX_PROCESS_HANDLES },
+  { method: 'fs.streamClose', limit: SSH_MAX_TEXT_STREAMS },
+] as const
+const allClasses = [{ method: 'process.done', limit: 1 }, ...management]
+const paramsSchema = z.object({ index: z.number(), immediate: z.boolean().optional() })
+
+interface Admission {
+  readonly method: string
+  readonly signal: AbortSignal
+  readonly aborted: Promise<undefined>
+  release(): void
+}
+
+function setup(clientLimit = 1, serverLimit = 1, settleOnAbort = true) {
+  const inbound = new PassThrough({ highWaterMark: frameBytes * 2 })
+  const outbound = new PassThrough({ highWaterMark: frameBytes * 2 })
+  const admissions = new Map<number, ReturnType<typeof Promise.withResolvers<Admission>>>()
+  const entered: Admission[] = []
+  const handlers: Promise<undefined>[] = []
+  const requests: Promise<unknown>[] = []
+  const serverClosed = Promise.withResolvers<Error>()
+  let index = 0
+  const server = new SshRpcPeer(outbound, inbound, frameBytes, serverLimit, async (method, raw, signal) => {
+    const params = paramsSchema.parse(raw)
+    if (params.immediate) return null
+    const gate = Promise.withResolvers<undefined>()
+    const aborted = Promise.withResolvers<undefined>()
+    const entry: Admission = { method, signal, aborted: aborted.promise, release: () => { gate.resolve(undefined) } }
+    handlers.push(gate.promise)
+    signal.addEventListener('abort', () => {
+      aborted.resolve(undefined)
+      if (settleOnAbort) gate.resolve(undefined)
+    }, { once: true })
+    entered.push(entry)
+    admissions.get(params.index)?.resolve(entry)
+    await gate.promise
+    return null
+  })
+  server.once('closed', (error: Error) => { serverClosed.resolve(error) })
+  const client = new SshRpcPeer(inbound, outbound, frameBytes, clientLimit)
+  const hold = (method: string, signal?: AbortSignal) => {
+    const id = ++index
+    const admitted = Promise.withResolvers<Admission>()
+    admissions.set(id, admitted)
+    const result = client.request(method, { index: id }, z.null(), signal)
+    void result.catch(() => {})
+    requests.push(result)
+    return { result, admitted: admitted.promise }
+  }
+  const immediate = (method: string) => client.request(method, { index: ++index, immediate: true }, z.null())
+  const extraFrame = (method: string) => {
+    const id = ++index
+    const admitted = Promise.withResolvers<Admission>()
+    admissions.set(id, admitted)
+    const body = Buffer.from(JSON.stringify({ type: 'request', id: `raw-overflow-${id}`, method, params: { index: id } }))
+    const header = Buffer.alloc(4)
+    header.writeUInt32BE(body.length)
+    outbound.write(Buffer.concat([header, body]))
+    return admitted.promise
+  }
+  onTestFinished(async () => {
+    client.close()
+    server.close()
+    for (const entry of entered) entry.release()
+    await Promise.allSettled(requests)
+    await Promise.all(handlers)
+  })
+  return { client, server, hold, immediate, extraFrame, entered, closed: serverClosed.promise }
+}
+
+describe('SSH management request capacity', () => {
+  it.each(allClasses)('retains $method credit while a cancelled remote handler is still cleaning up', async ({ method, limit }) => {
+    const peer = setup(1, 1, false)
+    const controller = new AbortController()
+    const held = Array.from({ length: limit }, (_, index) => peer.hold(method, index === 0 ? controller.signal : undefined))
+    const entries = await Promise.all(held.map(call => call.admitted))
+    const first = held[0]!
+    const entry = entries[0]!
+    const rejected = expect(first.result).rejects.toThrow('not rolled back')
+    controller.abort()
+    await rejected
+    await entry.aborted
+    await expect(peer.immediate(method)).rejects.toThrow('pending request limit reached')
+    const independent = method === 'heartbeat' ? 'close' : 'heartbeat'
+    await expect(peer.immediate(independent)).resolves.toBeNull()
+    entry.release()
+    await peer.immediate(independent)
+    await expect(peer.immediate(method)).resolves.toBeNull()
+    for (const admitted of entries) admitted.release()
+    await Promise.allSettled(held.map(call => call.result))
+  })
+
+  it('keeps heartbeat and close available with one ordinary call and all cleanup handles outstanding', async () => {
+    const peer = setup()
+    const ordinary = peer.hold('process.done')
+    const processes = Array.from({ length: SSH_MAX_PROCESS_HANDLES }, () => peer.hold('process.terminate'))
+    const streams = Array.from({ length: SSH_MAX_TEXT_STREAMS }, () => peer.hold('fs.streamClose'))
+    const held = [ordinary, ...processes, ...streams]
+    const entries = await Promise.all(held.map(call => call.admitted))
+    expect(entries).toHaveLength(1 + SSH_MAX_PROCESS_HANDLES + SSH_MAX_TEXT_STREAMS)
+    await expect(peer.immediate('heartbeat')).resolves.toBeNull()
+    await expect(peer.immediate('close')).resolves.toBeNull()
+    for (const method of ['process.done', 'process.terminate', 'fs.streamClose']) {
+      await expect(peer.immediate(method)).rejects.toThrow('pending request limit reached')
+    }
+    for (const entry of entries) entry.release()
+    await expect(Promise.all(held.map(call => call.result))).resolves.toHaveLength(entries.length)
+    await expect(peer.immediate('process.done')).resolves.toBeNull()
+  })
+
+  it.each(management.slice(0, 2))('admits only one pending $method without consuming ordinary capacity', async ({ method }) => {
+    const peer = setup()
+    const ordinary = peer.hold('process.done')
+    const managementCall = peer.hold(method)
+    const [ordinaryEntry, entry] = await Promise.all([ordinary.admitted, managementCall.admitted])
+    await expect(peer.immediate(method)).rejects.toThrow('pending request limit reached')
+    await expect(peer.immediate(method === 'heartbeat' ? 'close' : 'heartbeat')).resolves.toBeNull()
+    entry.release()
+    await managementCall.result
+    await expect(peer.immediate(method)).resolves.toBeNull()
+    ordinaryEntry.release()
+    await ordinary.result
+  })
+
+  it.each(allClasses)('rejects receive-side overflow for $method at its own quota', async ({ method, limit }) => {
+    // The ordinary sender has a larger allowance; raw frames also bypass fixed management sender limits.
+    const peer = setup(2, 1)
+    const held = Array.from({ length: limit }, () => peer.hold(method))
+    await Promise.all(held.map(call => call.admitted))
+    const extra = peer.extraFrame(method)
+    const outcome = await Promise.race([
+      peer.closed.then(error => ({ kind: 'closed' as const, error })),
+      extra.then(() => ({ kind: 'admitted' as const })),
+    ])
+    expect(outcome.kind).toBe('closed')
+    if (outcome.kind !== 'closed') throw new Error('receiver admitted a request above its own quota')
+    expect(outcome.error.message).toContain('unexpected or excessive request')
+    expect(peer.entered).toHaveLength(limit)
+    expect(peer.entered.every(entry => entry.signal.aborted)).toBe(true)
+    const results = await Promise.allSettled(held.map(call => call.result))
+    expect(results.every(result => result.status === 'rejected')).toBe(true)
+  })
+
+  it.each(allClasses)('releases the $method quota after cancellation settles its handler', async ({ method, limit }) => {
+    const peer = setup()
+    const controller = new AbortController()
+    const held = Array.from({ length: limit }, (_, index) => peer.hold(method, index === limit - 1 ? controller.signal : undefined))
+    const entries = await Promise.all(held.map(call => call.admitted))
+    const cancelled = held.at(-1)
+    const entry = entries.at(-1)
+    if (cancelled === undefined || entry === undefined) throw new Error('expected a held cancellation slot')
+    const rejection = expect(cancelled.result).rejects.toThrow('not rolled back')
+    controller.abort()
+    await rejection
+    await entry.aborted
+    // A completed independent exchange follows the cancel frame and lets the aborted handler publish its reply.
+    await peer.immediate(method === 'heartbeat' ? 'close' : 'heartbeat')
+    const replacement = peer.hold(method)
+    const replacementEntry = await replacement.admitted
+    expect(replacementEntry.signal.aborted).toBe(false)
+    expect(peer.entered).toHaveLength(limit + 1)
+    for (const admitted of [...entries, replacementEntry]) admitted.release()
+    await Promise.allSettled(held.map(call => call.result))
+    await expect(replacement.result).resolves.toBeNull()
+  })
+})

+ 221 - 0
packages/ssh/ssh/tests/protocol-boundaries.spec.ts

@@ -0,0 +1,221 @@
+import { once } from 'node:events'
+import { PassThrough, Readable, Writable } from 'node:stream'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { z } from 'zod'
+import { SshRpcPeer } from '../src/protocol.ts'
+
+function frame(value: unknown): Buffer {
+  const body = Buffer.from(JSON.stringify(value))
+  const header = Buffer.alloc(4)
+  header.writeUInt32BE(body.length)
+  return Buffer.concat([header, body])
+}
+
+function peer(options: { limit?: number; pending?: number; handler?: ConstructorParameters<typeof SshRpcPeer>[4] } = {}) {
+  const input = new PassThrough()
+  const output = new PassThrough()
+  output.resume()
+  const connection = new SshRpcPeer(input, output, options.limit ?? 4096, options.pending ?? 8, options.handler)
+  onTestFinished(() => { connection.close() })
+  return { input, output, connection }
+}
+
+function pair(handler: NonNullable<ConstructorParameters<typeof SshRpcPeer>[4]>) {
+  const outbound = new PassThrough()
+  const inbound = new PassThrough()
+  const server = new SshRpcPeer(outbound, inbound, 4096, 8, handler)
+  const client = new SshRpcPeer(inbound, outbound, 4096, 8)
+  onTestFinished(() => { client.close(); server.close() })
+  return { client, server }
+}
+
+describe('SSH protocol wire and allocation bounds', () => {
+  it('rejects an already-aborted call before transmitting any request', async () => {
+    const { connection, output } = peer()
+    const controller = new AbortController()
+    controller.abort('caller cancelled before allocation')
+    const chunks: Buffer[] = []
+    output.on('data', (chunk) => { chunks.push(chunk) })
+    await expect(connection.request('read', {}, z.null(), controller.signal)).rejects.toBe('caller cancelled before allocation')
+    expect(chunks).toEqual([])
+  })
+
+  it('bounds pending calls without evicting an admitted request', async () => {
+    const { connection, input, output } = peer({ pending: 1 })
+    const sent = once(output, 'data')
+    const first = connection.request('read', {}, z.string())
+    const [chunk] = await sent
+    const request = JSON.parse(Buffer.from(chunk).subarray(4).toString()) as { id: string }
+    await expect(connection.request('read', {}, z.string())).rejects.toThrow('pending request limit')
+    input.write(frame({ type: 'result', id: request.id, value: 'first response' }))
+    expect(await first).toBe('first response')
+  })
+
+  it('refuses an oversized outbound frame before writing to the transport', async () => {
+    const { connection, output } = peer({ limit: 128 })
+    let writes = 0
+    output.on('data', () => { writes++ })
+    await expect(connection.request('write', { content: 'x'.repeat(256) }, z.null())).rejects.toThrow('frame or write queue limit')
+    expect(writes).toBe(0)
+  })
+
+  it('bounds queued bytes while the first frame is backpressured', async () => {
+    const input = new PassThrough()
+    let finishWrite: (() => void) | undefined
+    const output = new Writable({ highWaterMark: 1, write(_chunk, _encoding, callback) { finishWrite = callback } })
+    const connection = new SshRpcPeer(input, output, 256, 8)
+    onTestFinished(() => { connection.close(); finishWrite?.() })
+    const first = connection.request('write', { content: 'a'.repeat(100) }, z.null())
+    const second = connection.request('write', { content: 'b'.repeat(100) }, z.null())
+    const settled = Promise.allSettled([first, second])
+    await expect(connection.request('write', { content: 'c'.repeat(100) }, z.null())).rejects.toThrow('frame or write queue limit')
+    connection.close()
+    expect((await settled).map(result => result.status)).toEqual(['rejected', 'rejected'])
+  })
+
+  it('resumes a backpressured write after drain and accepts its response', async () => {
+    const input = new PassThrough()
+    const entered = Promise.withResolvers<Buffer>()
+    let finishWrite: (() => void) | undefined
+    const output = new Writable({
+      highWaterMark: 1,
+      write(chunk: Buffer, _encoding, callback) { finishWrite = callback; entered.resolve(chunk) },
+    })
+    const connection = new SshRpcPeer(input, output, 4096, 8)
+    onTestFinished(() => { connection.close(); finishWrite?.() })
+    const request = connection.request('read', {}, z.string())
+    const payload = JSON.parse((await entered.promise).subarray(4).toString()) as { id: string }
+    const drained = once(output, 'drain')
+    finishWrite!()
+    await drained
+    input.write(frame({ type: 'result', id: payload.id, value: 'after drain' }))
+    expect(await request).toBe('after drain')
+    expect(output.listenerCount('drain')).toBe(0)
+  })
+
+  it('settles reentrant transport closure before a drain listener is attached', async () => {
+    const input = new PassThrough()
+    const output = new Writable({
+      highWaterMark: 1,
+      write(_chunk, _encoding, callback) { connection.close(new Error('closed during write')); callback() },
+    })
+    const connection = new SshRpcPeer(input, output, 4096, 8)
+    onTestFinished(() => { connection.close() })
+    await expect(connection.request('write', {}, z.null())).rejects.toThrow('closed during write')
+    expect(output.listenerCount('drain')).toBe(0)
+  })
+
+  it('contains cancellation racing transport closure', async () => {
+    const { connection } = peer()
+    const controller = new AbortController()
+    const pending = connection.request('write', {}, z.null(), controller.signal)
+    const rejected = expect(pending).rejects.toThrow('unknown')
+    connection.close()
+    controller.abort()
+    await rejected
+  })
+
+  it.each(['input', 'output'] as const)('reports %s I/O errors to pending requests', async (which) => {
+    const state = peer()
+    const pending = state.connection.request('read', {}, z.null())
+    const rejected = expect(pending).rejects.toThrow('I/O failure')
+    state[which].destroy(new Error('I/O failure'))
+    await rejected
+  })
+
+  it.each([0, 4097])('rejects incoming payload length %i before allocation', async (length) => {
+    const { connection, input } = peer()
+    const closed = once(connection, 'closed')
+    const header = Buffer.alloc(4)
+    header.writeUInt32BE(length)
+    input.write(header)
+    expect((await closed)[0]).toMatchObject({ message: 'SSH helper sent an invalid frame length' })
+  })
+
+  it.each([null, { type: 'unknown' }, { type: 'result', id: 1, value: null }])('rejects malformed JSON protocol fields', async (raw) => {
+    const { connection, input } = peer()
+    const closed = once(connection, 'closed')
+    input.write(frame(raw))
+    expect((await closed)[0]).toBeInstanceOf(Error)
+  })
+
+  it('rejects invalid JSON bytes within a complete frame', async () => {
+    const { connection, input } = peer()
+    const closed = once(connection, 'closed')
+    input.write(Buffer.from([0, 0, 0, 1, 123]))
+    expect((await closed)[0]).toBeInstanceOf(SyntaxError)
+  })
+
+  it.each(['header', 'payload'] as const)('reports an unknown outcome when EOF splits the %s', async (part) => {
+    const { connection, input } = peer()
+    const closed = once(connection, 'closed')
+    const full = frame({ type: 'cancel', id: 'cancelled' })
+    input.end(full.subarray(0, part === 'header' ? 2 : full.length - 1))
+    expect((await closed)[0]).toMatchObject({ message: 'SSH helper disconnected during a frame; outcome is unknown' })
+  })
+
+  it('accepts fragmented Uint8Array chunks and more than one frame per chunk', async () => {
+    const whole = frame({ type: 'request', id: 'one', method: 'echo', params: 'first' })
+    const second = frame({ type: 'request', id: 'two', method: 'echo', params: 'second' })
+    const input = new Readable({ objectMode: true, read() {} })
+    const output = new PassThrough()
+    output.resume()
+    const received = Promise.withResolvers<undefined>()
+    const values: unknown[] = []
+    const connection = new SshRpcPeer(input, output, 4096, 8, async (_method, value) => {
+      values.push(value)
+      if (values.length === 2) received.resolve(undefined)
+      return value
+    })
+    onTestFinished(() => { connection.close() })
+    input.push(new Uint8Array(whole.subarray(0, 2)))
+    await new Promise((resolve) => { setImmediate(resolve) })
+    input.push(new Uint8Array(whole.subarray(2, 9)))
+    await new Promise((resolve) => { setImmediate(resolve) })
+    input.push(new Uint8Array(Buffer.concat([whole.subarray(9), second])))
+    await received.promise
+    expect(values).toEqual(['first', 'second'])
+  })
+
+  it.each(['unexpected', 'duplicate', 'excessive'] as const)('rejects an %s inbound request', async (kind) => {
+    const released = Promise.withResolvers<undefined>()
+    const { connection, input } = peer({
+      pending: 1,
+      ...(kind === 'unexpected' ? {} : { handler: async () => { await released.promise; return null } }),
+    })
+    onTestFinished(() => { released.resolve(undefined) })
+    const closed = once(connection, 'closed')
+    input.write(frame({ type: 'request', id: 'one', method: 'read', params: {} }))
+    if (kind !== 'unexpected') input.write(frame({ type: 'request', id: kind === 'duplicate' ? 'one' : 'two', method: 'read', params: {} }))
+    expect((await closed)[0]).toMatchObject({ message: 'SSH helper received an unexpected or excessive request' })
+  })
+
+  it('ignores late replies and unknown cancellation identities', async () => {
+    const { connection, input } = peer()
+    input.write(Buffer.concat([
+      frame({ type: 'result', id: 'expired', value: null }),
+      frame({ type: 'error', id: 'expired', error: { name: 'Error', message: 'late' } }),
+      frame({ type: 'cancel', id: 'absent' }),
+    ]))
+    await new Promise((resolve) => { setImmediate(resolve) })
+    expect(input.destroyed).toBe(false)
+    connection.close()
+  })
+
+  it('encodes an operation without a return value as JSON null', async () => {
+    const { client } = pair(async () => undefined)
+    expect(await client.request('notify', {}, z.null())).toBeNull()
+  })
+
+  it('serializes a primitive AbortSignal reason as a remote error', async () => {
+    const controller = new AbortController()
+    controller.abort('operation owner cancelled')
+    const { client } = pair(async () => { controller.signal.throwIfAborted() })
+    await expect(client.request('read', {}, z.null())).rejects.toMatchObject({ message: 'operation owner cancelled', code: undefined })
+  })
+
+  it('omits non-string native error codes from the wire error metadata', async () => {
+    const { client } = pair(async () => { throw Object.assign(new Error('native operation failed'), { code: 5 }) })
+    await expect(client.request('read', {}, z.null())).rejects.toMatchObject({ message: 'native operation failed', code: undefined })
+  })
+})

+ 34 - 0
packages/ssh/ssh/tests/stream-timeout.spec.ts

@@ -0,0 +1,34 @@
+import { once } from 'node:events'
+import { createConnection, createServer, type Socket } from 'node:net'
+import { describe, expect, it } from 'vitest'
+import { authenticateStream } from '../src/stream-security.ts'
+
+describe('SSH TLS authentication deadline', () => {
+  it('closes a transport whose peer accepts a connection but never completes authentication', async () => {
+    const accepted = new Set<Socket>()
+    const server = createServer((socket) => {
+      accepted.add(socket)
+      socket.on('error', () => {})
+      socket.on('close', () => { accepted.delete(socket) })
+      socket.resume()
+    })
+    server.listen(0, '127.0.0.1')
+    await once(server, 'listening')
+    const address = server.address()
+    if (address === null || typeof address === 'string') throw new Error('test listener has no TCP address')
+    const peerConnected = once(server, 'connection')
+    const raw = createConnection(address.port, '127.0.0.1')
+    try {
+      await once(raw, 'connect')
+      await peerConnected
+      const closed = once(raw, 'close')
+      await expect(authenticateStream(raw, 'a'.repeat(64), 25)).rejects.toThrow('authentication timed out')
+      await closed
+      expect(raw.destroyed).toBe(true)
+    } finally {
+      raw.destroy()
+      for (const socket of accepted) socket.destroy()
+      await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
+    }
+  })
+})

+ 1 - 1
packages/ssh/ssh/tsdown.config.ts

@@ -1,6 +1,6 @@
 import { defineConfig } from 'tsdown'
 
 export default defineConfig({
-  entry: { index: 'lib/types/index.js', protocol: 'lib/types/protocol.js', schemas: 'lib/types/schemas.js', helper: 'lib/types/helper.js' },
+  entry: { index: 'lib/types/index.js', protocol: 'lib/types/protocol.js', schemas: 'lib/types/schemas.js', helper: 'lib/types/helper-entry.js' },
   outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false,
 })

+ 2 - 2
packages/ssh/subprocess-ssh/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/ssh/subprocess-ssh/README.md
-README.md: a2e5d9d8c308f76b827a8e644f9cb9fa8cf405e0
-README.zh.md: c9d498066ad29121e5f065bceda790e72ebe37cd
+README.md: a22f237d3c84485c3ae42e8ff86cbcd3550aa77c
+README.zh.md: 993ef5fe6bd1e86a8ab5e712c088470af0dbda7c

+ 2 - 2
packages/ssh/subprocess-ssh/README.md

@@ -39,7 +39,7 @@ A direct exit reported by `done` does not prove managed-range quiescence; `waitF
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The helper reserves and authenticates all required streams before launch. Stdout, stderr, stdin and fd 7 use separate SSH channels; collected output retains a bounded host tail, and optional complete spill files live in the remote helper’s private directory until connection disposal.
+The helper reserves and authenticates all required streams before launch. Stdout, stderr, stdin and fd 7 use separate SSH channels. Collected output publishes a final bounded raw-tail snapshot with whole-stream byte offsets, so network lag cannot change the completed observation. Optional spill files use the local provider’s private retained-output directory on the remote host.
 
 Remote execution delegates to [`subprocess-local`](../../subprocess/subprocess-local/README.md). Native process ownership and platform fallbacks therefore have the same meaning as local execution on that remote OS. SSH carries requests and observations; it does not itself confine the payload.
 
@@ -68,7 +68,7 @@ This provider contributes no request-prefix content. Its consumers own model-vis
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Completed spill paths remain usable only while their helper connection lives.
+- Completed spill files survive connection disposal and remain available until external temporary-file cleanup.
 - Callers must consume or close raw output streams. Independent channels allow control progress when stdout is paused, but do not remove per-stream backpressure or shared-network congestion.
 - Loss of the SSH connection leaves remote termination unconfirmed from the client; lease cleanup is a remote action, not a successful client acknowledgement.
 

+ 2 - 2
packages/ssh/subprocess-ssh/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-辅助进程在启动前预留并认证全部所需流。stdout、stderr、stdin 与 fd 7 使用独立 SSH 通道;收集输出在主机保留有界尾部,可选完整 spill 文件存放于远端辅助进程的私有目录中,直至连接释放。
+辅助进程在启动前预留并认证全部所需流。stdout、stderr、stdin 与 fd 7 使用独立 SSH 通道。收集输出会发布最终的有界原始字节尾部与完整流字节偏移,因此网络延迟不会改变已完成的观测。可选 spill 文件使用远端本地提供方的私有保留输出目录。
 
 远端执行委托给 [`subprocess-local`](../../subprocess/subprocess-local/README.zh.md)。因此,原生进程归属及平台降级方式与该远端操作系统上的本地执行具有相同含义。SSH 承载请求与观察结果,本身不限制程序权限。
 
@@ -68,7 +68,7 @@ kind: "package-reference"
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 已完成进程的 spill 路径仅在所属辅助进程连接存活时可用。
+- 已完成的 spill 文件在连接释放后继续保留,直至外部临时文件清理。
 - 调用方必须消费或关闭原始输出流。独立通道允许在 stdout 暂停时继续传输控制消息,但不会消除逐流背压或共享网络拥塞。
 - SSH 连接丢失后,客户端无法确认远端终止;租期清理是远端动作,不等于客户端收到了成功确认。
 

+ 48 - 9
packages/ssh/subprocess-ssh/src/index.ts

@@ -6,7 +6,8 @@ import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
 import type { SshConnection } from '@deepseek-ai/dsh-ssh'
-import { doneSchema, foregroundSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
+import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
+import { SshRpcPeer } from '@deepseek-ai/dsh-ssh/protocol'
 import { z } from 'zod'
 
 function environment(env?: NodeJS.ProcessEnv): Record<string, string | null> | undefined {
@@ -39,6 +40,7 @@ class RemoteProcess implements SubprocessHandle {
   private termination: Promise<void> | undefined
   private readonly detachAbort: () => void
   private spills: { stdout?: string | undefined; stderr?: string | undefined } = {}
+  private readonly updateCollection: Partial<Record<'stdout' | 'stderr', (snapshot: { tail: string; totalBytes: number }, final: boolean) => void>> = {}
 
   constructor(private readonly ssh: SshConnection, private readonly spec: SubprocessSpawnSpec) {
     this.stdin = spec.stdio.stdin === 'pipe' ? this.inbound : undefined
@@ -50,12 +52,29 @@ class RemoteProcess implements SubprocessHandle {
     const collect = (name: 'stdout' | 'stderr', stream: PassThrough, mode: SubprocessOutputMode) => {
       if (mode === 'pipe') return undefined
       if (mode === 'inherit') { stream.pipe(name === 'stdout' ? process.stdout : process.stderr, { end: false }); return undefined }
-      const collector = new OutputCollector(mode.maxBytes, undefined, name, '')
-      stream.on('data', (chunk: Buffer) => { collector.push(chunk) })
+      let collector = new OutputCollector(mode.maxBytes, undefined, name, '')
+      let base = 0
+      let total = 0
+      let finalized = false
+      this.updateCollection[name] = (snapshot, final): void => {
+        const bytes = Buffer.from(snapshot.tail, 'base64')
+        if (bytes.length > mode.maxBytes || snapshot.totalBytes < bytes.length) throw new Error('SSH helper returned invalid collected output coordinates')
+        if (finalized) return
+        if (snapshot.totalBytes < total) throw new Error('SSH helper rewound collected output')
+        finalized = final
+        collector = new OutputCollector(mode.maxBytes, undefined, name, '')
+        collector.push(bytes)
+        base = snapshot.totalBytes - bytes.length
+        total = snapshot.totalBytes
+      }
       return {
-        readFrom: (offset: number) => ({
-          ...collector.readFrom(offset), ...(this.spills[name] === undefined ? {} : { spillPath: this.spills[name] }),
-        }),
+        readFrom: (offset: number) => {
+          const snapshot = collector.readFrom(offset - base)
+          return {
+            ...snapshot, nextOffset: snapshot.nextOffset + base,
+            ...(this.spills[name] === undefined ? {} : { spillPath: this.spills[name] }),
+          }
+        },
       }
     }
     const stdout = collect('stdout', this.out, spec.stdio.stdout)
@@ -74,6 +93,12 @@ class RemoteProcess implements SubprocessHandle {
       const result = await ssh.request('process.done', { id: this.id }, doneSchema, undefined, true)
       await this.drainOutput()
       this.spills = result.spills
+      for (const name of ['stdout', 'stderr'] as const) {
+        const snapshot = result.collected[name]
+        const finish = this.updateCollection[name]
+        if ((snapshot === undefined) !== (finish === undefined)) throw new Error('SSH helper returned mismatched output collection modes')
+        if (snapshot !== undefined) finish?.(snapshot, true)
+      }
       return { exitCode: result.outcome.exitCode, signal: result.outcome.signal as NodeJS.Signals | null }
     }).catch((error: unknown) => {
       this.terminate()
@@ -97,8 +122,19 @@ class RemoteProcess implements SubprocessHandle {
         [name, await this.ssh.connectStream(path, this.controller.signal)] as const))
       this.sockets = sockets.map(([, socket]) => socket)
       for (const [name, socket] of sockets) {
-        if (name === 'stdout') socket.pipe(this.out)
-        if (name === 'stderr') socket.pipe(this.err)
+        if (name === 'stdout' || name === 'stderr') {
+          const mode = this.spec.stdio[name]
+          if (typeof mode === 'object') {
+            new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(mode.maxBytes), 1, async (method, raw) => {
+              if (method !== 'snapshot') throw new Error('Unexpected SSH output-stream operation')
+              this.updateCollection[name]?.(outputSnapshotSchema.parse(raw), false)
+              return null
+            })
+          } else {
+            socket.end()
+            socket.pipe(name === 'stdout' ? this.out : this.err)
+          }
+        }
         if (name === 'stdin') this.inbound.pipe(socket)
         if (name === 'control') { this.toControl.pipe(socket); socket.pipe(this.fromControl) }
       }
@@ -154,7 +190,9 @@ class RemoteProcess implements SubprocessHandle {
 
   private async drainOutput(): Promise<void> {
     const disposers: Array<() => void> = []
-    const outputs = [this.out, this.err].map((stream) => {
+    const outputs = (['stdout', 'stderr'] as const).map((name) => {
+      if (typeof this.spec.stdio[name] === 'object') return Promise.resolve()
+      const stream = name === 'stdout' ? this.out : this.err
       if (stream.readableEnded || stream.destroyed) return Promise.resolve()
       return new Promise<void>((resolve) => {
         const done = (): void => { resolve() }
@@ -237,6 +275,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
       signal.throwIfAborted()
       socket = await ssh.connectStream(streamEndpointSchema.parse(prepared.streams.terminal), signal)
       signal.throwIfAborted()
+      socket.end()
       const output = new PassThrough()
       socket.pipe(output)
       const started = await ssh.request('process.start', { id }, z.object({ pid: z.number().int().positive() }).strict(), signal)

+ 1 - 1
packages/subprocess/subprocess-local/src/index.ts

@@ -25,10 +25,10 @@ import type {
 import {
   bindManagedProcess,
   childEnv,
-  prepareManagedProcessBinding,
   spawnSubprocess,
   validateSubprocessSpec,
 } from './spawn.ts'
+import { prepareManagedProcessBinding } from './output.ts'
 import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
 import {
   launchLinuxScope,

+ 212 - 2
packages/subprocess/subprocess-local/src/output.ts

@@ -1,2 +1,212 @@
-/** Bounded stream collection shared by local processes and SSH stream adapters. */
-export { OutputCollector } from './spawn.ts'
+/** Bounded output tails and private spill files shared by process providers. */
+import { randomBytes } from 'node:crypto'
+import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import type { CollectedOutput } from '@deepseek-ai/dsh-subprocess'
+
+let spillCounter = 0
+let defaultSpillDir: string | undefined
+
+/**
+ * The default spill location: a private (0700) per-process directory under
+ * the OS tmpdir, created lazily. Predictable world-readable paths would let
+ * other local users read command output or pre-create symlinks. At a
+ * JavaScript-observable process exit the directory is removed only when it
+ * holds no completed spill file (spill files are retained as full-output
+ * recovery artifacts until an external cleanup).
+ */
+function privateSpillDir(): string {
+  defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
+  return defaultSpillDir
+}
+
+// The per-process spill directory is removed at process exit when it holds no
+// completed spill file: a directory that never spilled is empty and is safe to
+// remove, while a directory holding completed spill files keeps them (their
+// content is retained until an external cleanup). A SIGKILLed process cannot
+// run this at all; its residue is left to OS temp hygiene.
+/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
+process.once('exit', () => {
+  if (defaultSpillDir === undefined) return
+  try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
+})
+
+/**
+ * Prepare fallible output storage before starting a managed native process.
+ * @param internals - optional caller-owned spill directory.
+ * @returns binding inputs whose spill directory is ready for use.
+ */
+export function prepareManagedProcessBinding(
+  internals: { spillDir?: string } = {},
+): { spillDir: string } {
+  return { spillDir: internals.spillDir ?? privateSpillDir() }
+}
+
+
+/**
+ * Collects one stream with a bounded in-memory tail. With a spill cap, on
+ * first overflow a spill file is created and every chunk (including those
+ * already collected) is appended there while the full stream remains within
+ * the cap; without one, only the in-memory tail is ever retained (the
+ * diagnostic-tail shape — a language server's stderr).
+ *
+ * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
+ * end of command output; the spill file covers the head.
+ */
+export class OutputCollector {
+  private chunks: Buffer[] = []
+  private bytes = 0
+  private dropped = false
+  private spillFd: number | undefined
+  private spillFile: string | undefined
+  private spillDisabled: boolean
+  /** Total bytes ever pushed (not just retained). */
+  private total = 0
+
+  constructor(
+    private readonly maxBytes: number,
+    private readonly maxSpillBytes: number | undefined,
+    private readonly label: string,
+    private readonly spillDir: string,
+  ) {
+    this.spillDisabled = maxSpillBytes === undefined
+  }
+
+  /**
+   * Ingest one stream chunk, counting it toward the whole-stream total. On
+   * first overflow of the in-memory cap a spill file is opened (when spilling
+   * is enabled) and every chunk (already-collected ones included) is appended
+   * there from then on; the in-memory tail then drops whole chunks from its
+   * head (or the head of a single over-cap chunk) until it fits the cap again.
+   * @param chunk - the raw bytes from one stream 'data' event.
+   */
+  push(chunk: Buffer): void {
+    this.total += chunk.length
+    const overflows = this.bytes + chunk.length > this.maxBytes
+    if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
+    this.chunks.push(chunk)
+    this.bytes += chunk.length
+    while (this.bytes > this.maxBytes) {
+      const head = this.chunks[0] as Buffer
+      const excess = this.bytes - this.maxBytes
+      if (head.length <= excess) {
+        // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap).
+        this.chunks.shift()
+        this.bytes -= head.length
+      } else {
+        // Trim the head so the retained window is byte-exact at the cap — a
+        // diagnostic tail (an LSP server's stderr) must hold the LAST
+        // maxBytes regardless of how the stream was chunked.
+        this.chunks[0] = head.subarray(excess)
+        this.bytes -= excess
+      }
+      this.dropped = true
+    }
+  }
+
+  /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
+  private spillAll(chunk: Buffer): void {
+    if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
+      this.discardSpill()
+      return
+    }
+    if (this.spillFd === undefined) {
+      // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
+      // existing path, symlink or not) + owner-only mode: defeats spill-path
+      // prediction and symlink planting in shared tmp dirs.
+      this.spillFile = join(
+        this.spillDir,
+        `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
+      )
+      this.spillFd = openSync(this.spillFile, 'wx', 0o600)
+      for (const prior of this.chunks) writeSync(this.spillFd, prior)
+    }
+    writeSync(this.spillFd, chunk)
+  }
+
+  /** Stop spilling and remove the file once it can no longer hold the complete stream. */
+  private discardSpill(): void {
+    const fd = this.spillFd
+    const file = this.spillFile
+    this.spillFd = undefined
+    this.spillFile = undefined
+    this.spillDisabled = true
+    if (fd !== undefined) {
+      try {
+        closeSync(fd)
+      } catch {
+        // Retain the descriptor so finalize can retry the failed close.
+        this.spillFd = fd
+      }
+    }
+    if (file !== undefined) {
+      try {
+        unlinkSync(file)
+      } catch {
+        // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
+      }
+    }
+  }
+
+  /**
+   * Incremental read in whole-stream byte coordinates: returns everything
+   * pushed since `fromByte`. When `fromByte` has already slid out of the
+   * in-memory tail window, the read is `lossy` — it returns the whole
+   * retained tail and the gap is only recoverable from the spill file.
+   * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
+   * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
+   */
+  readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
+    const windowStart = this.total - this.bytes
+    const buffer = Buffer.concat(this.chunks)
+    const lossy = fromByte < windowStart
+    const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
+    return {
+      text: slice.toString('utf8'),
+      nextOffset: this.total,
+      lossy,
+      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
+    }
+  }
+
+  /**
+   * Copy the retained raw tail with its position in the complete observed stream.
+   * @returns independent tail bytes and the total byte count before truncation.
+   */
+  snapshot(): { bytes: Buffer; totalBytes: number } {
+    return { bytes: Buffer.concat(this.chunks), totalBytes: this.total }
+  }
+
+  /**
+   * Close the spill file once the stream has ended. A failed close (delayed
+   * writeback fault) stops advertising the spill path — the file may be
+   * missing its tail — while every in-memory read keeps working. Idempotent;
+   * the spawn path seals both collectors at settlement so reads after exit
+   * never point at a still-open file.
+   */
+  seal(): void {
+    if (this.spillFd === undefined) return
+    try {
+      closeSync(this.spillFd)
+    } catch {
+      // A delayed writeback failure makes the spill unreliable; keep the
+      // in-memory result but stop advertising that file.
+      this.spillFile = undefined
+    }
+    this.spillFd = undefined
+  }
+
+  /**
+   * Seal the spill file and return the final output.
+   * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
+   */
+  finalize(): CollectedOutput {
+    this.seal()
+    return {
+      text: Buffer.concat(this.chunks).toString('utf8'),
+      truncated: this.dropped,
+      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
+    }
+  }
+}

+ 1 - 202
packages/subprocess/subprocess-local/src/spawn.ts

@@ -10,15 +10,10 @@
 
 import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process'
 import type { Readable } from 'node:stream'
-import { randomBytes } from 'node:crypto'
-import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { setTimeout as sleepMs } from 'node:timers/promises'
 import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import type {
-  CollectedOutput,
   SubprocessCollect,
   SubprocessHandle,
   SubprocessOutcome,
@@ -30,6 +25,7 @@ import { waitWithAbort } from './managed-owner.ts'
 import { controlEnvironment, controlPipe } from './control-spawn.ts'
 import { SUBPROCESS_CONTROL_FD } from '@deepseek-ai/dsh-subprocess/control'
 import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
+import { OutputCollector, prepareManagedProcessBinding } from './output.ts'
 
 type SpawnProcess = (
   program: string,
@@ -91,203 +87,6 @@ function sleepTick(): Promise<void> {
   return sleepMs(15)
 }
 
-let spillCounter = 0
-let defaultSpillDir: string | undefined
-
-/**
- * The default spill location: a private (0700) per-process directory under
- * the OS tmpdir, created lazily. Predictable world-readable paths would let
- * other local users read command output or pre-create symlinks. At a
- * JavaScript-observable process exit the directory is removed only when it
- * holds no completed spill file (spill files are retained as full-output
- * recovery artifacts until an external cleanup).
- */
-function privateSpillDir(): string {
-  defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
-  return defaultSpillDir
-}
-
-// The per-process spill directory is removed at process exit when it holds no
-// completed spill file: a directory that never spilled is empty and is safe to
-// remove, while a directory holding completed spill files keeps them (their
-// content is retained until an external cleanup). A SIGKILLed process cannot
-// run this at all; its residue is left to OS temp hygiene.
-/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
-process.once('exit', () => {
-  if (defaultSpillDir === undefined) return
-  try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
-})
-
-/**
- * Prepare fallible output storage before starting a managed native process.
- * @param internals - optional caller-owned spill directory.
- * @returns binding inputs whose spill directory is ready for use.
- */
-export function prepareManagedProcessBinding(
-  internals: Pick<SpawnInternals, 'spillDir'> = {},
-): { spillDir: string } {
-  return { spillDir: internals.spillDir ?? privateSpillDir() }
-}
-
-/**
- * Collects one stream with a bounded in-memory tail. With a spill cap, on
- * first overflow a spill file is created and every chunk (including those
- * already collected) is appended there while the full stream remains within
- * the cap; without one, only the in-memory tail is ever retained (the
- * diagnostic-tail shape — a language server's stderr).
- *
- * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
- * end of command output; the spill file covers the head.
- */
-export class OutputCollector {
-  private chunks: Buffer[] = []
-  private bytes = 0
-  private dropped = false
-  private spillFd: number | undefined
-  private spillFile: string | undefined
-  private spillDisabled: boolean
-  /** Total bytes ever pushed (not just retained). */
-  private total = 0
-
-  constructor(
-    private readonly maxBytes: number,
-    private readonly maxSpillBytes: number | undefined,
-    private readonly label: string,
-    private readonly spillDir: string,
-  ) {
-    this.spillDisabled = maxSpillBytes === undefined
-  }
-
-  /**
-   * Ingest one stream chunk, counting it toward the whole-stream total. On
-   * first overflow of the in-memory cap a spill file is opened (when spilling
-   * is enabled) and every chunk (already-collected ones included) is appended
-   * there from then on; the in-memory tail then drops whole chunks from its
-   * head (or the head of a single over-cap chunk) until it fits the cap again.
-   * @param chunk - the raw bytes from one stream 'data' event.
-   */
-  push(chunk: Buffer): void {
-    this.total += chunk.length
-    const overflows = this.bytes + chunk.length > this.maxBytes
-    if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk)
-    this.chunks.push(chunk)
-    this.bytes += chunk.length
-    while (this.bytes > this.maxBytes) {
-      const head = this.chunks[0] as Buffer
-      const excess = this.bytes - this.maxBytes
-      if (head.length <= excess) {
-        // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap).
-        this.chunks.shift()
-        this.bytes -= head.length
-      } else {
-        // Trim the head so the retained window is byte-exact at the cap — a
-        // diagnostic tail (an LSP server's stderr) must hold the LAST
-        // maxBytes regardless of how the stream was chunked.
-        this.chunks[0] = head.subarray(excess)
-        this.bytes -= excess
-      }
-      this.dropped = true
-    }
-  }
-
-  /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
-  private spillAll(chunk: Buffer): void {
-    if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) {
-      this.discardSpill()
-      return
-    }
-    if (this.spillFd === undefined) {
-      // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
-      // existing path, symlink or not) + owner-only mode: defeats spill-path
-      // prediction and symlink planting in shared tmp dirs.
-      this.spillFile = join(
-        this.spillDir,
-        `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
-      )
-      this.spillFd = openSync(this.spillFile, 'wx', 0o600)
-      for (const prior of this.chunks) writeSync(this.spillFd, prior)
-    }
-    writeSync(this.spillFd, chunk)
-  }
-
-  /** Stop spilling and remove the file once it can no longer hold the complete stream. */
-  private discardSpill(): void {
-    const fd = this.spillFd
-    const file = this.spillFile
-    this.spillFd = undefined
-    this.spillFile = undefined
-    this.spillDisabled = true
-    if (fd !== undefined) {
-      try {
-        closeSync(fd)
-      } catch {
-        // Retain the descriptor so finalize can retry the failed close.
-        this.spillFd = fd
-      }
-    }
-    if (file !== undefined) {
-      try {
-        unlinkSync(file)
-      } catch {
-        // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file.
-      }
-    }
-  }
-
-  /**
-   * Incremental read in whole-stream byte coordinates: returns everything
-   * pushed since `fromByte`. When `fromByte` has already slid out of the
-   * in-memory tail window, the read is `lossy` — it returns the whole
-   * retained tail and the gap is only recoverable from the spill file.
-   * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
-   * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
-   */
-  readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
-    const windowStart = this.total - this.bytes
-    const buffer = Buffer.concat(this.chunks)
-    const lossy = fromByte < windowStart
-    const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
-    return {
-      text: slice.toString('utf8'),
-      nextOffset: this.total,
-      lossy,
-      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
-    }
-  }
-
-  /**
-   * Close the spill file once the stream has ended. A failed close (delayed
-   * writeback fault) stops advertising the spill path — the file may be
-   * missing its tail — while every in-memory read keeps working. Idempotent;
-   * the spawn path seals both collectors at settlement so reads after exit
-   * never point at a still-open file.
-   */
-  seal(): void {
-    if (this.spillFd === undefined) return
-    try {
-      closeSync(this.spillFd)
-    } catch {
-      // A delayed writeback failure makes the spill unreliable; keep the
-      // in-memory result but stop advertising that file.
-      this.spillFile = undefined
-    }
-    this.spillFd = undefined
-  }
-
-  /**
-   * Seal the spill file and return the final output.
-   * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
-   */
-  finalize(): CollectedOutput {
-    this.seal()
-    return {
-      text: Buffer.concat(this.chunks).toString('utf8'),
-      truncated: this.dropped,
-      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
-    }
-  }
-}
-
 /**
  * Send `sig` to a detached POSIX process group. Never throws: delivery races
  * process exit and may run in a timer callback, so failures are contained and

+ 4 - 1
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -734,9 +734,11 @@ describe('LocalSubprocessRuntime', () => {
     vi.doMock('../src/spawn.ts', async importOriginal => ({
       ...await importOriginal<typeof import('../src/spawn.ts')>(),
       bindManagedProcess,
-      prepareManagedProcessBinding,
       spawnSubprocess,
     }))
+    vi.doMock('../src/output.ts', async importOriginal => ({
+      ...await importOriginal<typeof import('../src/output.ts')>(), prepareManagedProcessBinding,
+    }))
     const fibers: Array<{ dispose(): Promise<void> }> = []
     try {
       const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
@@ -778,6 +780,7 @@ describe('LocalSubprocessRuntime', () => {
       vi.doUnmock('../src/linux-scope.ts')
       vi.doUnmock('../src/windows-job.ts')
       vi.doUnmock('../src/spawn.ts')
+      vi.doUnmock('../src/output.ts')
       unmockWin32ForIsolatedRuntime()
       vi.resetModules()
     }

+ 1 - 1
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -1,3 +1,4 @@
+import { OutputCollector } from '../src/output.ts'
 import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process'
 import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
@@ -7,7 +8,6 @@ import {
   bindManagedProcess,
   childEnv,
   killGroup,
-  OutputCollector,
   spawnSubprocess,
   taskkillProcessTree,
   validateSubprocessSpec,

+ 5 - 0
scripts/gen-cordis-catalog.ts

@@ -90,6 +90,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   terminals: 'terminal.md',
   sandbox: 'sandbox.md',
   sandboxPolicy: 'sandbox.md',
+  ssh: 'ssh.md',
   sessionPersistence: 'persistence.md',
   sessionQuery: 'session-query.md',
   sessionFileReferences: 'session-reference.md',
@@ -468,6 +469,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   TerminalSpawnRequest: 'terminal.md',
   TerminalSpawnResult: 'terminal.md',
   SandboxPolicyRequest: 'sandbox.md',
+  SshConnection: 'ssh.md',
+  SshStreamEndpoint: 'ssh.md',
   ScopeKey: 'scope.md',
   Scoped: 'scope.md',
   EpochHeader: 'session.md',
@@ -689,6 +692,8 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
 
 /** Project types deliberately documented outside the subsystems catalog. */
 export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
+  'z.ZodType': 'Zod response validation API is owned by https://zod.dev/packages/zod',
+  Socket: 'Node.js byte stream API is owned by https://nodejs.org/api/net.html#class-netsocket',
   z: 'schemastery schema constructor is owned by vendor/schemastery (vendored upstream)',
   BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
   InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',

+ 11 - 0
scripts/type-equiv.manifest.json

@@ -2120,6 +2120,17 @@
       "doc": "docs/subsystems/code-runtime.md",
       "symbol": "CodeRunSandbox",
       "source": "packages/code-runtime/code-runtime/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/ssh.md",
+      "symbol": "Config",
+      "source": "packages/ssh/ssh/src/index.ts"
+    },
+    {
+      "doc": "docs/subsystems/ssh.md",
+      "symbol": "SshConnection",
+      "source": "packages/ssh/ssh/src/index.ts",
+      "projection": "public-api"
     }
   ]
 }

+ 2 - 0
vitest.config.ts

@@ -33,6 +33,8 @@ const windowsUnsupportedPackages = process.platform === 'win32'
       'packages/terminal/terminal-bash',
       'packages/experimental/code-runtime-python',
       'packages/sandbox/sandbox-local',
+      // OpenSSH multiplexing and Unix-socket helper streams require POSIX endpoints.
+      'packages/ssh/*',
     ]
   : []