@deepseek-ai/dsh-subprocess-ssh 0.1.6-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md
5
+ README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9
6
+ README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ ---
2
+ description: "Managed SSH subprocess and terminal behavior for Bash, LSP and Node runtime consumers."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-subprocess-ssh
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ `dsh-subprocess-ssh` implements `ctx.subprocess` using the shared SSH helper. Executable lookup, ordinary processes, fd 7 control traffic and terminal sessions run beside the SSH filesystem. Remote native process owners govern termination and quiescence; consumers continue to own command semantics, output limits and execution deadlines.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Mount this provider with [`dsh-ssh`](../ssh/README.md) and its filesystem provider. It has no deployment configuration of its own. `resolveExecutable()` checks the remote executable namespace; fully specified spawn requests supply the remote cwd, environment, stream dispositions and cleanup grace.
29
+
30
+ Ordinary spawn returns a handle while remote allocation proceeds. Piped stdin and the optional duplex control endpoint accept writes during allocation. Terminal allocation, writes, foreground inspection, signals and termination retain their asynchronous interfaces.
31
+
32
+ A direct exit reported by `done` does not prove managed-range quiescence; `waitForExit()` observes that separately. Its optional signal bounds the entire observation, including pending allocation and termination, and cancellation returns `false` without stopping the process. An already confirmed empty range returns `true`; genuine observation failures reject. `terminate()` targets the same remote owner. Connection loss rejects unconfirmed work, and automatic replay never follows an ambiguous launch or mutation.
33
+
34
+ -----
35
+
36
+ Terminal shell facts and executable verification come from the remote helper. Lookup misses remain distinct from SSH failures. PTY creation forwards the caller's `terminalType`, and `resize()` updates the remote terminal without replacing its process.
37
+
38
+ <a id="understand-the-implementation"></a>
39
+ ## Understand the implementation
40
+
41
+ <details>
42
+ <summary>Implementation internals — click to expand</summary>
43
+
44
+ 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.
45
+
46
+ 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.
47
+
48
+ </details>
49
+
50
+ -----
51
+
52
+ <a id="further-exploration"></a>
53
+ ## Further Exploration
54
+
55
+ - [Subprocess subsystem](../../../docs/subsystems/subprocess.md) — spawn, collection, terminal and lifetime APIs.
56
+ - [SSH sandbox provider](../sandbox-ssh/README.md) — remote file-effect confinement.
57
+
58
+ -----
59
+
60
+ <a id="model-experience"></a>
61
+ ## Model Experience
62
+
63
+ Indirectly, through Bash, terminal, LSP and ptc-runtime consumers, which present their existing results and remote paths.
64
+
65
+ #### KV Cache effect
66
+
67
+ This provider contributes no request-prefix content. Its consumers own model-visible tools and results.
68
+
69
+ ## Known Limitations and Deferred Work
70
+
71
+ <a id="known-limitations-and-deferred-work"></a>
72
+
73
+ - Completed spill files survive connection disposal and remain available until external temporary-file cleanup.
74
+ - Callers must consume or close raw output streams. Destroying public stdout or stderr closes its matching transport, including when destruction precedes remote allocation. Independent channels allow control progress when stdout is paused, but do not remove per-stream backpressure or shared-network congestion.
75
+ - Loss of the SSH connection leaves remote termination unconfirmed from the client; lease cleanup is a remote action, not a successful client acknowledgement.
76
+
77
+ <a id="dev-note"></a>
78
+ ### Dev Note
79
+
80
+ <details>
81
+ <summary>Working context for maintainers — click to expand</summary>
82
+
83
+ No invariant companion is published. Wire validation and the owning filesystem, subprocess and sandbox providers enforce the observable obligations; this adapter adds no independently observed state relation.
84
+
85
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,85 @@
1
+ ---
2
+ description: "面向 Bash、LSP 及 Node 运行时消费方,说明托管 SSH 子进程与终端行为。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-subprocess-ssh
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ `dsh-subprocess-ssh` 使用共享 SSH 辅助进程实现 `ctx.subprocess`。可执行文件查找、普通进程、fd 7 控制流及终端会话与 SSH 文件系统在同一环境中运行。远端原生进程管理器负责终止与静止状态;消费方继续负责命令语义、输出上限与执行截止时限。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延后工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 将本提供方与 [`dsh-ssh`](../ssh/README.zh.md) 及其文件系统提供方一同挂载。本提供方没有独立部署配置。`resolveExecutable()` 检查远端可执行文件命名空间;完整的 spawn 请求提供远端 cwd、环境、流处置方式及清理宽限期。
29
+
30
+ 普通 spawn 在远端分配过程中返回句柄。管道 stdin 和可选双工控制端点在分配过程中接受写入。终端分配、写入、前台检查、信号及终止均保留异步接口。
31
+
32
+ `done` 报告的直接进程退出并不证明托管进程范围已经静止;`waitForExit()` 单独观察该状态。其可选信号约束整个观察过程,包括等待分配和终止;取消返回 `false`,不会停止进程。已确认清空的范围返回 `true`,实际观察故障则拒绝。`terminate()` 针对同一个远端管理器。连接丢失会拒绝未确认的操作;启动或修改结果不明确时绝不自动重放。
33
+
34
+ -----
35
+
36
+ 终端 shell 信息与可执行文件验证来自远端 helper。查找未命中与 SSH 故障分别报告。PTY 创建传递调用方的 `terminalType`,`resize()` 更新远端终端尺寸,不替换进程。
37
+
38
+ <a id="understand-the-implementation"></a>
39
+ ## 理解实现
40
+
41
+ <details>
42
+ <summary>实现细节 — 点击展开</summary>
43
+
44
+ 辅助进程在启动前预留并认证全部所需流。stdout、stderr、stdin 与 fd 7 使用独立 SSH 通道。收集输出会发布最终的有界原始字节尾部与完整流字节偏移,因此网络延迟不会改变已完成的观测。可选 spill 文件使用远端本地提供方的私有保留输出目录。
45
+
46
+ 远端执行委托给 [`subprocess-local`](../../subprocess/subprocess-local/README.zh.md)。因此,原生进程归属及平台降级方式与该远端操作系统上的本地执行具有相同含义。SSH 承载请求与观察结果,本身不限制程序权限。
47
+
48
+ </details>
49
+
50
+ -----
51
+
52
+ <a id="further-exploration"></a>
53
+ ## 进一步探索
54
+
55
+ - [子进程子系统](../../../docs/subsystems/subprocess.zh.md) — spawn、输出收集、终端及生命周期 API。
56
+ - [SSH 沙箱提供方](../sandbox-ssh/README.zh.md) — 远端文件效果限制。
57
+
58
+ -----
59
+
60
+ <a id="model-experience"></a>
61
+ ## 模型体验
62
+
63
+ 间接通过 Bash、终端、LSP 和 PTC 运行时消费方产生影响,由它们展示既有结果与远端路径。
64
+
65
+ #### KV Cache 影响
66
+
67
+ 本提供方不贡献请求前缀内容。面向模型的工具与结果由消费方负责。
68
+
69
+ ## 已知限制与延后工作
70
+
71
+ <a id="known-limitations-and-deferred-work"></a>
72
+
73
+ - 已完成的 spill 文件在连接释放后继续保留,直至外部临时文件清理。
74
+ - 调用方必须消费或关闭原始输出流。销毁公开的 stdout 或 stderr 会关闭对应传输,即使销毁发生在远端分配之前也是如此。独立通道允许在 stdout 暂停时继续传输控制消息,但不会消除逐流背压或共享网络拥塞。
75
+ - SSH 连接丢失后,客户端无法确认远端终止;租期清理是远端动作,不等于客户端收到了成功确认。
76
+
77
+ <a id="dev-note"></a>
78
+ ### 开发备注
79
+
80
+ <details>
81
+ <summary>维护者工作上下文 — 点击展开</summary>
82
+
83
+ 本包不发布不变式伴随入口。消息校验及所属文件系统、子进程和沙箱提供方执行可观察的约束;此适配器没有增加可独立观察的状态关系。
84
+
85
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,437 @@
1
+ import { Duplex, PassThrough } from "node:stream";
2
+ import { SubprocessExecutableNotFoundError, SubprocessRuntime } from "@deepseek-ai/dsh-subprocess";
3
+ import { OutputCollector } from "@deepseek-ai/dsh-subprocess-local/output";
4
+ import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from "@deepseek-ai/dsh-ssh/schemas";
5
+ import { RemoteOperationError, SshRpcPeer } from "@deepseek-ai/dsh-ssh/protocol";
6
+ import { z } from "zod";
7
+ //#region lib/types/index.js
8
+ /** Remote subprocess and PTY handles with independent SSH streams and helper-owned process lifetimes. */
9
+ function environment(env) {
10
+ return env === void 0 ? void 0 : Object.fromEntries(Object.entries(env).map(([key, value]) => [key, value ?? null]));
11
+ }
12
+ var RemoteCleanupError = class extends AggregateError {};
13
+ /** One remote ordinary process; stdin and control remain usable during asynchronous SSH allocation. */
14
+ var RemoteProcess = class {
15
+ ssh;
16
+ spec;
17
+ stdin;
18
+ stdout;
19
+ stderr;
20
+ control;
21
+ collected;
22
+ done;
23
+ streamsClosed;
24
+ inbound = new PassThrough();
25
+ out = new PassThrough();
26
+ err = new PassThrough();
27
+ toControl = new PassThrough();
28
+ fromControl = new PassThrough();
29
+ controller = new AbortController();
30
+ started;
31
+ id;
32
+ sockets = [];
33
+ quiescent = false;
34
+ committed = false;
35
+ termination;
36
+ detachAbort;
37
+ spills = {};
38
+ updateCollection = {};
39
+ constructor(ssh, spec) {
40
+ this.ssh = ssh;
41
+ this.spec = spec;
42
+ this.stdin = spec.stdio.stdin === "pipe" ? this.inbound : void 0;
43
+ this.stdout = spec.stdio.stdout === "pipe" ? this.out : void 0;
44
+ this.stderr = spec.stdio.stderr === "pipe" ? this.err : void 0;
45
+ const requestedControl = spec.stdio.control;
46
+ this.control = requestedControl === void 0 ? void 0 : Duplex.from({
47
+ writable: this.toControl,
48
+ readable: this.fromControl
49
+ });
50
+ for (const stream of [
51
+ this.inbound,
52
+ this.out,
53
+ this.err,
54
+ this.toControl,
55
+ this.fromControl,
56
+ this.control
57
+ ]) stream?.on("error", () => {});
58
+ const collect = (name, stream, mode) => {
59
+ if (mode === "pipe") return void 0;
60
+ if (mode === "inherit") {
61
+ stream.pipe(name === "stdout" ? process.stdout : process.stderr, { end: false });
62
+ return;
63
+ }
64
+ let collector = new OutputCollector(mode.maxBytes, void 0, name, "");
65
+ let base = 0;
66
+ let total = 0;
67
+ let finalized = false;
68
+ this.updateCollection[name] = (snapshot, final) => {
69
+ const bytes = Buffer.from(snapshot.tail, "base64");
70
+ if (bytes.length > mode.maxBytes || snapshot.totalBytes < bytes.length) throw new Error("SSH helper returned invalid collected output coordinates");
71
+ if (finalized) return;
72
+ if (snapshot.totalBytes < total) throw new Error("SSH helper rewound collected output");
73
+ finalized = final;
74
+ collector = new OutputCollector(mode.maxBytes, void 0, name, "");
75
+ collector.push(bytes);
76
+ base = snapshot.totalBytes - bytes.length;
77
+ total = snapshot.totalBytes;
78
+ };
79
+ return { readFrom: (offset) => {
80
+ const snapshot = collector.readFrom(offset - base);
81
+ return {
82
+ ...snapshot,
83
+ nextOffset: snapshot.nextOffset + base,
84
+ ...this.spills[name] === void 0 ? {} : { spillPath: this.spills[name] }
85
+ };
86
+ } };
87
+ };
88
+ const stdout = collect("stdout", this.out, spec.stdio.stdout);
89
+ const stderr = collect("stderr", this.err, spec.stdio.stderr);
90
+ this.collected = {
91
+ ...stdout === void 0 ? {} : { stdout },
92
+ ...stderr === void 0 ? {} : { stderr }
93
+ };
94
+ const onAbort = () => {
95
+ this.terminate();
96
+ };
97
+ spec.signal?.addEventListener("abort", onAbort, { once: true });
98
+ this.detachAbort = () => {
99
+ spec.signal?.removeEventListener("abort", onAbort);
100
+ };
101
+ this.started = this.start();
102
+ this.streamsClosed = this.started.then(async () => {
103
+ await Promise.all(this.sockets.map((socket) => socket.closed ? Promise.resolve() : new Promise((resolve) => {
104
+ socket.once("close", () => {
105
+ resolve();
106
+ });
107
+ })));
108
+ }, () => {});
109
+ this.done = this.started.then(async () => {
110
+ const result = await ssh.request("process.done", { id: this.id }, doneSchema, void 0, true);
111
+ await this.drainOutput();
112
+ this.spills = result.spills;
113
+ for (const name of ["stdout", "stderr"]) {
114
+ const snapshot = result.collected[name];
115
+ const finish = this.updateCollection[name];
116
+ if (snapshot === void 0 !== (finish === void 0)) throw new Error("SSH helper returned mismatched output collection modes");
117
+ if (snapshot !== void 0) finish?.(snapshot, true);
118
+ }
119
+ return {
120
+ exitCode: result.outcome.exitCode,
121
+ signal: result.outcome.signal
122
+ };
123
+ }).catch((error) => {
124
+ this.terminate();
125
+ for (const socket of this.sockets) socket.destroy();
126
+ for (const stream of [
127
+ this.inbound,
128
+ this.out,
129
+ this.err,
130
+ this.toControl,
131
+ this.fromControl
132
+ ]) stream.destroy(error instanceof Error ? error : new Error(String(error)));
133
+ throw error;
134
+ });
135
+ this.done.catch(() => {});
136
+ }
137
+ async start() {
138
+ this.spec.signal?.throwIfAborted();
139
+ const prepared = await this.ssh.request("process.prepare", {
140
+ ...this.spec,
141
+ signal: void 0,
142
+ env: environment(this.spec.env)
143
+ }, preparedSchema, this.controller.signal);
144
+ this.id = prepared.id;
145
+ try {
146
+ const sockets = await Promise.all(Object.entries(prepared.streams).map(async ([name, path]) => [name, await this.ssh.connectStream(path, this.controller.signal)]));
147
+ this.sockets = sockets.map(([, socket]) => socket);
148
+ for (const [name, socket] of sockets) {
149
+ if (name === "stdout" || name === "stderr") {
150
+ const mode = this.spec.stdio[name];
151
+ if (typeof mode === "object") new SshRpcPeer(socket, socket, outputSnapshotFrameLimit(mode.maxBytes), 1, (method, raw) => Promise.resolve().then(() => {
152
+ if (method !== "snapshot") throw new Error("Unexpected SSH output-stream operation");
153
+ this.updateCollection[name]?.(outputSnapshotSchema.parse(raw), false);
154
+ return null;
155
+ }));
156
+ else {
157
+ socket.end();
158
+ const output = name === "stdout" ? this.out : this.err;
159
+ const closeSocket = () => {
160
+ socket.destroy();
161
+ };
162
+ output.once("close", closeSocket);
163
+ socket.once("close", () => {
164
+ output.off("close", closeSocket);
165
+ });
166
+ if (output.destroyed) closeSocket();
167
+ else socket.pipe(output);
168
+ }
169
+ }
170
+ if (name === "stdin") this.inbound.pipe(socket);
171
+ if (name === "control") {
172
+ this.toControl.pipe(socket);
173
+ socket.pipe(this.fromControl);
174
+ }
175
+ }
176
+ this.controller.signal.throwIfAborted();
177
+ await this.ssh.request("process.start", { id: this.id }, z.object({}).strict(), this.controller.signal);
178
+ this.committed = true;
179
+ } catch (error) {
180
+ this.terminate();
181
+ await this.termination?.catch(() => {});
182
+ for (const socket of this.sockets) socket.destroy();
183
+ throw error;
184
+ }
185
+ }
186
+ terminate() {
187
+ if (this.quiescent || this.termination !== void 0) return;
188
+ if (!this.committed) this.controller.abort(/* @__PURE__ */ new Error("SSH process terminated before launch acknowledgement"));
189
+ if (this.id !== void 0) {
190
+ this.termination = this.ssh.request("process.terminate", { id: this.id }, z.null()).then(() => {
191
+ this.quiescent = true;
192
+ this.detachAbort();
193
+ }).catch((error) => {
194
+ this.ssh.dispose().catch(() => {});
195
+ throw error;
196
+ });
197
+ this.termination.catch(() => {});
198
+ }
199
+ }
200
+ closeStreams() {
201
+ for (const socket of this.sockets) socket.destroy();
202
+ for (const stream of [
203
+ this.inbound,
204
+ this.out,
205
+ this.err,
206
+ this.toControl,
207
+ this.fromControl,
208
+ this.control
209
+ ]) stream?.destroy();
210
+ }
211
+ async waitForExit(signal) {
212
+ if (this.quiescent) return true;
213
+ if (signal?.aborted) return false;
214
+ if (signal === void 0) return this.observeExit();
215
+ const cancelled = Promise.withResolvers();
216
+ const abort = () => {
217
+ cancelled.resolve(false);
218
+ };
219
+ signal.addEventListener("abort", abort, { once: true });
220
+ try {
221
+ return await Promise.race([this.observeExit(signal), cancelled.promise]);
222
+ } finally {
223
+ signal.removeEventListener("abort", abort);
224
+ }
225
+ }
226
+ async observeExit(signal) {
227
+ try {
228
+ await this.started;
229
+ } catch {
230
+ if (this.termination !== void 0) await this.termination;
231
+ this.quiescent = true;
232
+ this.detachAbort();
233
+ return true;
234
+ }
235
+ if (this.termination !== void 0) {
236
+ await this.termination;
237
+ return true;
238
+ }
239
+ const result = await this.ssh.request("process.wait", { id: this.id }, z.boolean(), signal, true);
240
+ if (result) {
241
+ this.quiescent = true;
242
+ this.detachAbort();
243
+ }
244
+ return result;
245
+ }
246
+ async drainOutput() {
247
+ const disposers = [];
248
+ const outputs = ["stdout", "stderr"].map((name) => {
249
+ if (typeof this.spec.stdio[name] === "object") return Promise.resolve();
250
+ const stream = name === "stdout" ? this.out : this.err;
251
+ if (stream.readableEnded || stream.destroyed) return Promise.resolve();
252
+ return new Promise((resolve) => {
253
+ const done = () => {
254
+ resolve();
255
+ };
256
+ for (const event of [
257
+ "end",
258
+ "close",
259
+ "error"
260
+ ]) stream.once(event, done);
261
+ disposers.push(() => {
262
+ for (const event of [
263
+ "end",
264
+ "close",
265
+ "error"
266
+ ]) stream.off(event, done);
267
+ });
268
+ });
269
+ });
270
+ let timer;
271
+ try {
272
+ await Promise.race([Promise.all(outputs), new Promise((resolve) => {
273
+ timer = setTimeout(resolve, this.spec.graceMs);
274
+ })]);
275
+ } finally {
276
+ clearTimeout(timer);
277
+ for (const dispose of disposers) dispose();
278
+ }
279
+ }
280
+ };
281
+ /** SSH provider paired with the SSH filesystem; the remote helper selects POSIX process ownership. */
282
+ var SshSubprocessRuntime = class extends SubprocessRuntime {
283
+ static inject = ["ssh"];
284
+ live = /* @__PURE__ */ new Set();
285
+ terminals = /* @__PURE__ */ new Set();
286
+ terminalAllocations = /* @__PURE__ */ new Set();
287
+ lifetime = new AbortController();
288
+ constructor(ctx) {
289
+ super(ctx);
290
+ ctx.effect(() => async () => {
291
+ this.lifetime.abort(/* @__PURE__ */ new Error("SSH subprocess provider disposed"));
292
+ for (const handle of this.live) handle.terminate();
293
+ const errors = (await Promise.allSettled([
294
+ ...[...this.live].map(async (handle) => {
295
+ try {
296
+ await handle.waitForExit();
297
+ } finally {
298
+ handle.closeStreams();
299
+ }
300
+ }),
301
+ ...[...this.terminalAllocations].map((allocation) => allocation.catch((error) => {
302
+ if (error instanceof RemoteCleanupError) throw error;
303
+ })),
304
+ ...[...this.terminals].map((handle) => handle.terminate())
305
+ ])).flatMap((result) => result.status === "rejected" ? [result.reason] : []);
306
+ if (errors.length > 0) throw new AggregateError(errors, "SSH process cleanup could not be confirmed");
307
+ });
308
+ }
309
+ async resolveExecutable(command, env, signal) {
310
+ try {
311
+ return await this.ctx.ssh.request("executable", {
312
+ command,
313
+ env
314
+ }, remotePath, signal);
315
+ } catch (error) {
316
+ if (error instanceof RemoteOperationError && error.code === "SUBPROCESS_EXECUTABLE_NOT_FOUND") throw new SubprocessExecutableNotFoundError(error.message, { cause: error });
317
+ throw error;
318
+ }
319
+ }
320
+ terminalEnvironment(signal) {
321
+ return this.ctx.ssh.request("terminal.environment", {}, z.object({
322
+ platform: z.enum(["posix", "windows"]),
323
+ defaultShell: z.string().optional()
324
+ }).strict().transform((value) => ({
325
+ platform: value.platform,
326
+ ...value.defaultShell === void 0 ? {} : { defaultShell: value.defaultShell }
327
+ })), signal);
328
+ }
329
+ spawn(spec) {
330
+ this.lifetime.signal.throwIfAborted();
331
+ spec.signal?.throwIfAborted();
332
+ const handle = new RemoteProcess(this.ctx.ssh, spec);
333
+ this.live.add(handle);
334
+ handle.done.then(() => handle.waitForExit()).then(() => handle.streamsClosed).then(() => {
335
+ this.live.delete(handle);
336
+ }).catch(() => {});
337
+ return handle;
338
+ }
339
+ async spawnTerminal(spec) {
340
+ this.lifetime.signal.throwIfAborted();
341
+ const signal = spec.signal === void 0 ? this.lifetime.signal : AbortSignal.any([spec.signal, this.lifetime.signal]);
342
+ signal.throwIfAborted();
343
+ const allocation = this.createTerminal(spec, signal);
344
+ this.terminalAllocations.add(allocation);
345
+ try {
346
+ return await allocation;
347
+ } finally {
348
+ this.terminalAllocations.delete(allocation);
349
+ }
350
+ }
351
+ async createTerminal(spec, signal) {
352
+ const ssh = this.ctx.ssh;
353
+ const prepared = await ssh.request("process.prepare", {
354
+ argv: spec.argv,
355
+ cwd: spec.cwd,
356
+ env: environment(spec.env),
357
+ graceMs: spec.graceMs,
358
+ terminal: {
359
+ rows: spec.rows,
360
+ cols: spec.cols,
361
+ terminalType: spec.terminalType
362
+ }
363
+ }, preparedSchema, signal);
364
+ const id = prepared.id;
365
+ let socket;
366
+ try {
367
+ signal.throwIfAborted();
368
+ socket = await ssh.connectStream(streamEndpointSchema.parse(prepared.streams.terminal), signal);
369
+ signal.throwIfAborted();
370
+ socket.end();
371
+ const output = new PassThrough();
372
+ socket.pipe(output);
373
+ const started = await ssh.request("process.start", { id }, z.object({ pid: z.number().int().positive() }).strict(), signal);
374
+ signal.throwIfAborted();
375
+ const done = ssh.request("process.done", { id }, doneSchema, void 0, true).then((result) => ({
376
+ exitCode: result.outcome.exitCode,
377
+ signal: result.outcome.signal
378
+ }));
379
+ done.catch(() => {});
380
+ let closing;
381
+ const abort = () => {
382
+ handle.terminate().catch(() => {
383
+ ssh.dispose().catch(() => {});
384
+ });
385
+ };
386
+ const handle = {
387
+ pid: started.pid,
388
+ output,
389
+ done,
390
+ resize: async (cols, rows) => {
391
+ await ssh.request("terminal.resize", {
392
+ id,
393
+ cols,
394
+ rows
395
+ }, z.null());
396
+ },
397
+ write: async (data) => {
398
+ await ssh.request("terminal.write", {
399
+ id,
400
+ value: data
401
+ }, z.null());
402
+ },
403
+ inspectForeground: async () => await ssh.request("terminal.inspect", { id }, foregroundSchema) ?? void 0,
404
+ signalForeground: (signal) => ssh.request("terminal.signal", {
405
+ id,
406
+ value: signal
407
+ }, z.number().int().positive()),
408
+ terminate: () => {
409
+ closing ??= ssh.request("process.terminate", { id }, z.null(), void 0, true).then(() => {
410
+ socket?.destroy();
411
+ output.destroy();
412
+ signal.removeEventListener("abort", abort);
413
+ this.terminals.delete(handle);
414
+ }).catch((error) => {
415
+ closing = void 0;
416
+ throw error;
417
+ });
418
+ return closing;
419
+ }
420
+ };
421
+ signal.addEventListener("abort", abort, { once: true });
422
+ this.terminals.add(handle);
423
+ return handle;
424
+ } catch (error) {
425
+ socket?.destroy();
426
+ try {
427
+ await ssh.request("process.terminate", { id }, z.null());
428
+ } catch (cleanupError) {
429
+ ssh.dispose().catch(() => {});
430
+ throw new RemoteCleanupError([error, cleanupError], "SSH terminal allocation failed and remote cleanup is unknown");
431
+ }
432
+ throw error;
433
+ }
434
+ }
435
+ };
436
+ //#endregion
437
+ export { SshSubprocessRuntime, SshSubprocessRuntime as default };
@@ -0,0 +1,19 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess';
3
+ import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
4
+ /** SSH provider paired with the SSH filesystem; the remote helper selects POSIX process ownership. */
5
+ export declare class SshSubprocessRuntime extends SubprocessRuntime {
6
+ static inject: string[];
7
+ private readonly live;
8
+ private readonly terminals;
9
+ private readonly terminalAllocations;
10
+ private readonly lifetime;
11
+ constructor(ctx: Context);
12
+ resolveExecutable(command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal): Promise<string>;
13
+ terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>;
14
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle;
15
+ spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle>;
16
+ private createTerminal;
17
+ }
18
+ export default SshSubprocessRuntime;
19
+ //# sourceMappingURL=index.d.ts.map
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-subprocess-ssh",
3
+ "description": "Subprocess and terminal provider over the shared POSIX SSH helper",
4
+ "version": "0.1.6-alpha.1",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/ssh/subprocess-ssh"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "@deepseek-ai/cordis": "^4.0.2",
31
+ "@deepseek-ai/dsh-ssh": "^0.1.6-alpha.1",
32
+ "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.1",
33
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.6-alpha.1"
34
+ },
35
+ "dependencies": {
36
+ "zod": "^4.4.3",
37
+ "@deepseek-ai/schemastery": "^3.18.2"
38
+ },
39
+ "devDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.2",
41
+ "@deepseek-ai/dsh-ssh": "^0.1.6-alpha.1",
42
+ "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.1",
43
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.6-alpha.1"
44
+ }
45
+ }