dsh-remote-vs 0.0.0-stage → 0.8.3

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.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
@@ -0,0 +1,802 @@
1
+ /**
2
+ * Remote subprocess runtime over ssh2 exec channels: stdout/stderr separation,
3
+ * collect-mode offset readers, stdin dispositions, TERM→KILL escalation and
4
+ * exit facts. No PTY, no control channel, no spill files in P2-v1 (honest
5
+ * limits, documented at each seam).
6
+ *
7
+ * Wrapper design (proven by .spike/dbg6 + dbg7 against util-linux 2.37.2):
8
+ *
9
+ * - `setsid --wait sh -c 'echo RV_PID=$$; exec "$@"' _ <argv...>` puts the
10
+ * target in its own session/process group AND propagates its exit code.
11
+ * Plain `setsid` (no --wait) double-forks: the outer shell exits at once
12
+ * (target reparented to PPID=1) and the channel reports the outer shell's
13
+ * exit 0 instead of the target's code — exit facts would be fiction.
14
+ * - Group kill (`kill -TERM -- -<pgid>`) is the ONLY reliable termination:
15
+ * `channel.signal()` addresses the outer shell, which is useless once the
16
+ * wrapper detaches. When the pgid is not learned yet, terminate() waits for
17
+ * the marker (bounded) instead of signalling blindly.
18
+ * - Platforms without a compatible setsid fall back to a leader-only line
19
+ * (`cd … && env … exec <argv...>`, no new session): channel.signal hits the
20
+ * exec'd target directly. First failure auto-retries inside the same spawn;
21
+ * later spawns skip setsid (self-healing, no caller-visible error).
22
+ *
23
+ * Managed range = the exec channel lifetime plus the target's process group.
24
+ * Double-fork daemons that escape the group survive termination (documented,
25
+ * same as Yan-Zero); waitForExit observes channel close.
26
+ */
27
+ import { PassThrough, Writable, type Readable } from 'node:stream'
28
+ import { closeSync, mkdirSync, openSync, readdirSync, rmSync, statSync, writeSync } from 'node:fs'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+ import { randomUUID } from 'node:crypto'
32
+ import type { ClientChannel } from 'ssh2'
33
+ import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
34
+ import type {
35
+ SubprocessCollect,
36
+ SubprocessHandle,
37
+ SubprocessOutcome,
38
+ SubprocessOutputMode,
39
+ SubprocessOutputReader,
40
+ SubprocessSpawnSpec,
41
+ SubprocessStdinMode,
42
+ } from '@deepseek-ai/dsh-subprocess'
43
+ import type { RemotePool } from '../pool.ts'
44
+
45
+ /** Single-quote one argv/env word for POSIX sh. */
46
+ function shq(word: string): string {
47
+ if (word.includes('\0')) throw new Error('NUL byte in spawn argument')
48
+ return `'${word.replace(/'/g, `'\\''`)}'`
49
+ }
50
+
51
+ /** Strip a Windows absolute executable to a remote-resolvable bare name. */
52
+ export function remoteCommand(input: string): string {
53
+ const base = input.replace(/\\/g, '/').split('/').pop() ?? input
54
+ return base.replace(/\.exe$/i, '')
55
+ }
56
+
57
+ /** How long terminate() waits for the RV_PID marker before last-resort kill. */
58
+ const MARKER_TIMEOUT_MS = 8000
59
+
60
+ /** Safety net after KILL: force local channel close if sshd stays silent. */
61
+ const SAFETY_CLOSE_MS = 5000
62
+
63
+ /** Host-side spill dir for over-cap collected output (seam-internal, transient). */
64
+ function spillDir(): string {
65
+ const dir = join(tmpdir(), 'dsh-remote-spills')
66
+ mkdirSync(dir, { recursive: true })
67
+ return dir
68
+ }
69
+
70
+ /** Best-effort TTL sweep: spills older than a day are dead (crashed owner). */
71
+ function sweepSpills(now: number): void {
72
+ let dir: string
73
+ try {
74
+ dir = spillDir()
75
+ } catch {
76
+ return
77
+ }
78
+ let names: string[]
79
+ try {
80
+ names = readdirSync(dir)
81
+ } catch {
82
+ return
83
+ }
84
+ for (const name of names) {
85
+ if (!name.startsWith('dsh-remote-spill-')) continue
86
+ try {
87
+ if (now - statSync(join(dir, name)).mtimeMs > 24 * 3600 * 1000) rmSync(join(dir, name), { force: true })
88
+ } catch {
89
+ // Ignore per-file failures.
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * One collected stream: tail window + offset readers + optional full-stream
96
+ * spill file (Host disk). Mirrors the stock local runtime's contract
97
+ * (dsh-subprocess SubprocessCollect): with `spill` configured, overflow
98
+ * appends to the spill file; readFrom ALWAYS returns the tail window (with
99
+ * honest lossy flags) and attaches spillPath when the spill is intact — the
100
+ * complete stream lives in the FILE (read by model/tool via fs), never
101
+ * inline. Past spill.maxBytes the incomplete spill is discarded (tail only).
102
+ * Without `spill`, tail-only, no path (diagnostic-tail shape).
103
+ */
104
+ class CollectStream {
105
+ private buffers: Buffer[] = []
106
+ private bufferedBytes = 0
107
+ private totalBytes = 0
108
+ private readonly maxBytes: number
109
+ private readonly spillCap: number | undefined
110
+ private readonly streamName: string
111
+ private spillPath: string | undefined
112
+ private spillFd: number | undefined
113
+ private spilledBytes = 0
114
+ private spillDead = false
115
+ private spillWarned = false
116
+
117
+ constructor(collect: SubprocessCollect, streamName: string) {
118
+ this.maxBytes = Math.max(1, Math.floor(collect.maxBytes))
119
+ this.spillCap = collect.spill === undefined ? undefined : Math.max(1, Math.floor(collect.spill.maxBytes))
120
+ this.streamName = streamName
121
+ }
122
+
123
+ push(chunk: Buffer): void {
124
+ if (this.spillCap !== undefined && !this.spillDead) {
125
+ if (this.totalBytes + chunk.length > this.spillCap) {
126
+ this.discardSpill()
127
+ } else {
128
+ // Overflow begins (or continues): spill holds [0, total) complete.
129
+ if (this.spillPath === undefined && this.bufferedBytes + chunk.length > this.maxBytes) {
130
+ this.spillCreate()
131
+ // Flush the bytes buffered so far, then the new chunk below.
132
+ if (!this.spillDead) {
133
+ for (const b of this.buffers) this.spillWrite(b)
134
+ }
135
+ }
136
+ if (!this.spillDead) this.spillWrite(chunk)
137
+ }
138
+ }
139
+ this.totalBytes += chunk.length
140
+ this.buffers.push(chunk)
141
+ this.bufferedBytes += chunk.length
142
+ while (this.bufferedBytes > this.maxBytes && this.buffers.length > 1) {
143
+ const head = this.buffers.shift() as Buffer
144
+ this.bufferedBytes -= head.length
145
+ }
146
+ if (this.bufferedBytes > this.maxBytes) {
147
+ const only = this.buffers[0] as Buffer
148
+ this.buffers = [only.subarray(only.length - this.maxBytes)]
149
+ this.bufferedBytes = this.buffers[0]?.length ?? 0
150
+ }
151
+ }
152
+
153
+ reader(): SubprocessOutputReader {
154
+ return {
155
+ readFrom: (fromByte: number) => {
156
+ // Stock-faithful: text is ALWAYS the tail window; the spill file
157
+ // (when intact) carries the complete stream out-of-band.
158
+ const spill = this.spillPathNow()
159
+ const windowStart = this.totalBytes - this.bufferedBytes
160
+ if (fromByte < windowStart) {
161
+ return {
162
+ text: Buffer.concat(this.buffers).toString('utf8'),
163
+ nextOffset: this.totalBytes,
164
+ lossy: true,
165
+ ...(spill === undefined ? {} : { spillPath: spill }),
166
+ }
167
+ }
168
+ const slice = Buffer.concat(this.buffers).subarray(Math.max(0, fromByte - windowStart))
169
+ return {
170
+ text: slice.toString('utf8'),
171
+ nextOffset: this.totalBytes,
172
+ lossy: false,
173
+ ...(spill === undefined ? {} : { spillPath: spill }),
174
+ }
175
+ },
176
+ }
177
+ }
178
+
179
+ /** Release the spill fd (the FILE persists for model readback; TTL-swept). */
180
+ dispose(): void {
181
+ if (this.spillFd !== undefined) {
182
+ try {
183
+ closeSync(this.spillFd)
184
+ } catch {
185
+ // Ignore.
186
+ }
187
+ this.spillFd = undefined
188
+ }
189
+ }
190
+
191
+ private spillCreate(): void {
192
+ try {
193
+ sweepSpills(Date.now())
194
+ const path = join(spillDir(), `dsh-remote-spill-${this.streamName}-${randomUUID()}.bin`)
195
+ this.spillFd = openSync(path, 'w')
196
+ this.spillPath = path
197
+ } catch {
198
+ this.warnSpill('cannot create spill file; degrading to tail-only')
199
+ this.spillDead = true
200
+ }
201
+ }
202
+
203
+ private spillWrite(chunk: Buffer): void {
204
+ if (this.spillFd === undefined) return
205
+ try {
206
+ writeSync(this.spillFd, chunk)
207
+ this.spilledBytes += chunk.length
208
+ } catch {
209
+ this.warnSpill('cannot append spill file; degrading to tail-only')
210
+ this.discardSpill()
211
+ }
212
+ }
213
+
214
+ private discardSpill(): void {
215
+ if (this.spillFd !== undefined) {
216
+ try {
217
+ closeSync(this.spillFd)
218
+ } catch {
219
+ // Ignore.
220
+ }
221
+ this.spillFd = undefined
222
+ }
223
+ if (this.spillPath !== undefined) {
224
+ try {
225
+ rmSync(this.spillPath, { force: true })
226
+ } catch {
227
+ // Ignore.
228
+ }
229
+ this.spillPath = undefined
230
+ }
231
+ this.spillDead = true
232
+ }
233
+
234
+ /** Intact spill path, or undefined (discarded, unconfigured, or desynced). */
235
+ private spillPathNow(): string | undefined {
236
+ if (this.spillPath === undefined || this.spillDead) return undefined
237
+ // Only advertise when the file holds every byte (a post-dispose stray
238
+ // push would desync it — withhold the path instead of lying).
239
+ if (this.spilledBytes !== this.totalBytes) return undefined
240
+ return this.spillPath
241
+ }
242
+
243
+ private warnSpill(message: string): void {
244
+ if (this.spillWarned) return
245
+ this.spillWarned = true
246
+ // eslint-disable-next-line no-console
247
+ console.warn(`[dsh-remote-vs] ${message}`)
248
+ }
249
+ }
250
+
251
+ function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
252
+ return typeof mode === 'object'
253
+ }
254
+
255
+ /** Build the remote shell line: cd + env + setsid --wait wrapper + argv. */
256
+ function buildCommand(spec: SubprocessSpawnSpec): string {
257
+ const parts: string[] = [`cd ${shq(spec.cwd)}`, '&&']
258
+ const env = spec.env ?? {}
259
+ const unsets = Object.entries(env).filter(([, value]) => value === undefined).map(([key]) => key)
260
+ const sets = Object.entries(env).filter(([, value]) => value !== undefined) as [string, string][]
261
+ const inner: string[] = []
262
+ if (unsets.length > 0 || sets.length > 0) {
263
+ inner.push('env')
264
+ for (const key of unsets) inner.push('-u', key)
265
+ for (const [key, value] of sets) inner.push(`${key}=${shq(value)}`)
266
+ }
267
+ inner.push(...spec.argv.map(shq))
268
+ parts.push(`setsid --wait sh -c ${shq('echo RV_PID=$$; exec "$@"')} _ ${inner.join(' ')}`)
269
+ if (spec.stdio.stdin === 'ignore') {
270
+ // Seam contract: 'ignore' leaves fd 0 on /dev/null (NOT an empty pipe:
271
+ // tools like rg treat no-path + pipe-stdin as "search stdin" and return
272
+ // zero matches — 2026-10-13 grep-omitted-path incident).
273
+ parts.push('< /dev/null')
274
+ }
275
+ return parts.join(' ')
276
+ }
277
+
278
+ /**
279
+ * Same line without setsid (platforms lacking it; leader-only range). `exec`
280
+ * replaces the outer shell so channel.signal addresses the target itself.
281
+ */
282
+ function buildCommandNoSetsid(spec: SubprocessSpawnSpec): string {
283
+ const parts: string[] = [`cd ${shq(spec.cwd)}`, '&&']
284
+ const env = spec.env ?? {}
285
+ const unsets = Object.entries(env).filter(([, value]) => value === undefined).map(([key]) => key)
286
+ const sets = Object.entries(env).filter(([, value]) => value !== undefined) as [string, string][]
287
+ if (unsets.length > 0 || sets.length > 0) {
288
+ parts.push('env')
289
+ for (const key of unsets) parts.push('-u', key)
290
+ for (const [key, value] of sets) parts.push(`${key}=${shq(value)}`)
291
+ }
292
+ parts.push('exec', ...spec.argv.map(shq))
293
+ // Seam contract: 'ignore' leaves fd 0 on /dev/null (see buildCommand).
294
+ if (spec.stdio.stdin === 'ignore') parts.push('< /dev/null')
295
+ return parts.join(' ')
296
+ }
297
+
298
+ export class SshProcRuntime {
299
+ private readonly live = new Set<() => void>()
300
+ /** Whether the remote offers a compatible setsid(1); optimistic until disproven. */
301
+ private setsid: boolean | undefined
302
+
303
+ constructor(
304
+ private readonly pool: RemotePool,
305
+ private readonly alias: string,
306
+ ) {}
307
+
308
+ /** Resolve via remote `command -v` (Windows paths reduce to bare names). */
309
+ async resolveExecutable(command: string): Promise<string> {
310
+ const name = /^[A-Za-z]:[\\/]/.test(command) || command.includes('\\') ? remoteCommand(command) : command
311
+ const result = await this.pool.exec(this.alias, `command -v -- ${shq(name)}`, { timeoutMs: 15000 })
312
+ const resolved = result.stdout.trim().split('\n')[0]?.trim() ?? ''
313
+ if (!result.success || resolved === '') {
314
+ throw new SubprocessExecutableNotFoundError(`remote executable not found: ${command}`)
315
+ }
316
+ return resolved
317
+ }
318
+
319
+ terminalEnvironment(): { platform: 'posix'; defaultShell: string } {
320
+ return { platform: 'posix', defaultShell: '/bin/bash' }
321
+ }
322
+
323
+ /** Spawn one managed remote process. Throws synchronously on invalid spec. */
324
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
325
+ if (spec.argv.length === 0 || typeof spec.argv[0] !== 'string' || spec.argv[0] === '') {
326
+ throw new Error('spawn requires a non-empty argv[0]')
327
+ }
328
+ if (typeof spec.cwd !== 'string' || !spec.cwd.startsWith('/')) {
329
+ throw new Error(`remote spawn needs an absolute POSIX cwd, got: ${spec.cwd}`)
330
+ }
331
+ if (!Number.isFinite(spec.graceMs) || spec.graceMs < 0) {
332
+ throw new Error(`remote spawn needs a non-negative graceMs, got: ${spec.graceMs}`)
333
+ }
334
+ if (spec.stdio.control === 'pipe') {
335
+ throw new Error('remote control channels are not implemented in P2-v1')
336
+ }
337
+ if (spec.signal?.aborted === true) {
338
+ throw new Error('spawn aborted before start')
339
+ }
340
+ const useSetsid = this.setsid ?? true
341
+ return new SshProcHandle(this.pool, this.alias, spec, useSetsid, () => {
342
+ this.setsid = false
343
+ }, this.live)
344
+ }
345
+
346
+ /** Terminate every live remote process (service disposal). */
347
+ async dispose(): Promise<void> {
348
+ const pending = [...this.live]
349
+ this.live.clear()
350
+ await Promise.allSettled(pending.map(async (terminate) => {
351
+ try {
352
+ terminate()
353
+ } catch {
354
+ // Ignore teardown races.
355
+ }
356
+ }))
357
+ }
358
+ }
359
+
360
+ class SshProcHandle implements SubprocessHandle {
361
+ readonly stdin: Writable | undefined
362
+ readonly stdout: Readable | undefined
363
+ readonly stderr: Readable | undefined
364
+ readonly control = undefined
365
+ readonly collected: { stdout?: SubprocessOutputReader; stderr?: SubprocessOutputReader }
366
+ readonly done: Promise<SubprocessOutcome>
367
+
368
+ private channel: ClientChannel | undefined
369
+ private settled = false
370
+ private killRequested = false
371
+ private terminating = false
372
+ private killedSignal: NodeJS.Signals | undefined
373
+ private termFired = false
374
+ private useSetsid = true
375
+ private retriedNoSetsid = false
376
+ private pgid: number | undefined
377
+ private pgidWaiters: Array<() => void> = []
378
+ private markerPending = true
379
+ private markerBuf = Buffer.alloc(0)
380
+ private stderrHead = ''
381
+ private readonly outCollect: CollectStream | undefined
382
+ private readonly errCollect: CollectStream | undefined
383
+ private readonly outPipe: PassThrough | undefined
384
+ private readonly errPipe: PassThrough | undefined
385
+ private readonly stdoutStripped = new PassThrough()
386
+ private stdinFlush: () => void = () => {}
387
+ private readonly abortListener: (() => void) | undefined
388
+ private readonly resolveDone: (outcome: SubprocessOutcome) => void
389
+ private readonly rejectDone: (error: unknown) => void
390
+ private readonly finish: () => void
391
+
392
+ constructor(
393
+ private readonly pool: RemotePool,
394
+ private readonly alias: string,
395
+ private readonly spec: SubprocessSpawnSpec,
396
+ useSetsid: boolean,
397
+ private readonly onSetsidMissing: () => void,
398
+ live: Set<() => void>,
399
+ ) {
400
+ this.useSetsid = useSetsid
401
+ this.markerPending = useSetsid
402
+ const terminate = (): void => {
403
+ this.terminate()
404
+ }
405
+ live.add(terminate)
406
+ this.finish = (): void => {
407
+ live.delete(terminate)
408
+ }
409
+
410
+ if (isCollect(spec.stdio.stdout)) {
411
+ this.outCollect = new CollectStream(spec.stdio.stdout, 'stdout')
412
+ } else if (spec.stdio.stdout === 'pipe') {
413
+ this.outPipe = new PassThrough()
414
+ }
415
+ if (isCollect(spec.stdio.stderr)) {
416
+ this.errCollect = new CollectStream(spec.stdio.stderr, 'stderr')
417
+ } else if (spec.stdio.stderr === 'pipe') {
418
+ this.errPipe = new PassThrough()
419
+ }
420
+ this.collected = {
421
+ ...(this.outCollect !== undefined ? { stdout: this.outCollect.reader() } : {}),
422
+ ...(this.errCollect !== undefined ? { stderr: this.errCollect.reader() } : {}),
423
+ }
424
+
425
+ let resolveDone!: (outcome: SubprocessOutcome) => void
426
+ let rejectDone!: (error: unknown) => void
427
+ this.done = new Promise<SubprocessOutcome>((resolve, reject) => {
428
+ resolveDone = resolve
429
+ rejectDone = reject
430
+ })
431
+ this.resolveDone = resolveDone
432
+ this.rejectDone = rejectDone
433
+ // Latch rejection fast: a stored rejection nobody awaits is an unhandled rejection.
434
+ void this.done.catch(() => {})
435
+
436
+ if (spec.signal !== undefined) {
437
+ const onAbort = (): void => {
438
+ this.terminate()
439
+ }
440
+ spec.signal.addEventListener('abort', onAbort, { once: true })
441
+ this.abortListener = () => spec.signal?.removeEventListener('abort', onAbort)
442
+ }
443
+
444
+ const stdinMode: SubprocessStdinMode = spec.stdio.stdin
445
+ if (stdinMode === 'pipe') {
446
+ const pending: Buffer[] = []
447
+ let ended = false
448
+ let bound: ClientChannel | undefined
449
+ const flush = (): void => {
450
+ const channel = bound ?? this.channel
451
+ if (channel === undefined) return
452
+ bound = channel
453
+ while (pending.length > 0) {
454
+ const chunk = pending.shift() as Buffer
455
+ try {
456
+ channel.write(chunk)
457
+ } catch {
458
+ // Channel raced away.
459
+ }
460
+ }
461
+ if (ended) {
462
+ try {
463
+ channel.end()
464
+ } catch {
465
+ // Ignore.
466
+ }
467
+ }
468
+ }
469
+ this.stdinFlush = flush
470
+ this.stdin = new Writable({
471
+ write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
472
+ pending.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
473
+ flush()
474
+ callback()
475
+ },
476
+ final(callback: (error?: Error | null) => void): void {
477
+ ended = true
478
+ flush()
479
+ callback()
480
+ },
481
+ })
482
+ } else {
483
+ this.stdin = undefined
484
+ }
485
+ this.stdout = this.outPipe
486
+ this.stderr = this.errPipe
487
+
488
+ // Output fan-out. Stdout passes a marker stripper that consumes the
489
+ // leading `RV_PID=<pgid>` line (setsid sessions only).
490
+ const forward = (
491
+ stream: NodeJS.ReadableStream,
492
+ mode: SubprocessOutputMode,
493
+ collect: CollectStream | undefined,
494
+ pipe: PassThrough | undefined,
495
+ host: NodeJS.WritableStream,
496
+ ): void => {
497
+ stream.on('data', (chunk: Buffer) => {
498
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
499
+ if (collect !== undefined) collect.push(buffer)
500
+ if (pipe !== undefined) pipe.write(buffer)
501
+ if (mode === 'inherit') {
502
+ try {
503
+ host.write(buffer)
504
+ } catch {
505
+ // Ignore host stream errors.
506
+ }
507
+ }
508
+ })
509
+ }
510
+ forward(this.stdoutStripped, spec.stdio.stdout, this.outCollect, this.outPipe, process.stdout)
511
+
512
+ void this.open()
513
+ }
514
+
515
+ /** Open the exec channel (once; setsid-incompatible remotes retry internally). */
516
+ private async open(): Promise<void> {
517
+ const { spec } = this
518
+ const stdinMode: SubprocessStdinMode = spec.stdio.stdin
519
+ try {
520
+ const channel = await this.pool.execChannel(this.alias, this.useSetsid ? buildCommand(spec) : buildCommandNoSetsid(spec))
521
+ if (this.settled) {
522
+ channel.close()
523
+ return
524
+ }
525
+ this.channel = channel
526
+ // Listeners first: a pending terminate() needs the marker and close
527
+ // events (attaching after the killRequested check would strand the
528
+ // handle with no pgid and no settlement).
529
+ channel.on('data', (chunk: Buffer) => {
530
+ this.stripMarker(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array))
531
+ })
532
+ channel.stderr.on('data', (chunk: Buffer) => {
533
+ if (this.stderrHead.length < 2048) {
534
+ this.stderrHead += chunk.toString('utf8').slice(0, 2048 - this.stderrHead.length)
535
+ }
536
+ })
537
+ const forwardErr = (chunk: Buffer): void => {
538
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
539
+ if (this.errCollect !== undefined) this.errCollect.push(buffer)
540
+ if (this.errPipe !== undefined) this.errPipe.write(buffer)
541
+ if (spec.stdio.stderr === 'inherit') {
542
+ try {
543
+ process.stderr.write(buffer)
544
+ } catch {
545
+ // Ignore host stream errors.
546
+ }
547
+ }
548
+ }
549
+ channel.stderr.on('data', forwardErr)
550
+ channel.on('close', (code: number | null, signalName?: string | null) => {
551
+ this.onClose(code, signalName)
552
+ })
553
+ channel.on('error', (error: Error) => {
554
+ this.fail(error)
555
+ })
556
+ if (this.killRequested) {
557
+ this.terminate()
558
+ return
559
+ }
560
+ // stdin disposition (skipped when already terminating: no point feeding
561
+ // a process we are about to kill).
562
+ if (stdinMode === 'ignore') {
563
+ try {
564
+ channel.end()
565
+ } catch {
566
+ // Ignore.
567
+ }
568
+ } else if (typeof stdinMode === 'object') {
569
+ try {
570
+ channel.write(stdinMode.data)
571
+ } catch {
572
+ // Ignore.
573
+ }
574
+ try {
575
+ channel.end()
576
+ } catch {
577
+ // Ignore.
578
+ }
579
+ } else {
580
+ this.stdinFlush()
581
+ }
582
+ } catch (error) {
583
+ this.fail(error)
584
+ }
585
+ }
586
+
587
+ private onClose(code: number | null, signalName?: string | null): void {
588
+ // setsid-incompatible remote (missing binary or no --wait): the wrapper
589
+ // died pre-exec, the target never ran. Degrade once, internally.
590
+ if (this.useSetsid && this.pgid === undefined && !this.retriedNoSetsid && /setsid/i.test(this.stderrHead)) {
591
+ this.retriedNoSetsid = true
592
+ this.useSetsid = false
593
+ this.markerPending = false
594
+ this.onSetsidMissing()
595
+ this.channel = undefined
596
+ this.stderrHead = ''
597
+ void this.open()
598
+ return
599
+ }
600
+ this.flushMarker()
601
+ if (this.killedSignal !== undefined) {
602
+ // We killed it: report our signal, not the channel's code (the wrapper
603
+ // chain's exit status is noise once we intervene).
604
+ this.settle({ exitCode: null, signal: this.killedSignal })
605
+ return
606
+ }
607
+ this.settle({
608
+ exitCode: signalName !== undefined && signalName !== null ? null : (code ?? 0),
609
+ signal: signalName !== undefined && signalName !== null ? (`SIG${signalName}` as NodeJS.Signals) : null,
610
+ })
611
+ }
612
+
613
+ terminate(): void {
614
+ this.killRequested = true
615
+ if (this.settled || this.terminating) return
616
+ if (this.channel === undefined) return // Post-open hook re-enters.
617
+ this.terminating = true
618
+ this.killedSignal = 'SIGTERM'
619
+ if (!this.useSetsid) {
620
+ // Leader-only line: the outer shell exec'd the target, so the channel
621
+ // signal addresses the target itself.
622
+ this.signalLeader('TERM')
623
+ this.scheduleEscalation()
624
+ return
625
+ }
626
+ // setsid world: group kill once the pgid is learned (bounded wait);
627
+ // signalling the channel would only hit the wrapper chain.
628
+ this.onPgidKnown(() => this.fireTerm())
629
+ setTimeout(() => {
630
+ if (!this.termFired && !this.settled) this.fallbackKill()
631
+ }, MARKER_TIMEOUT_MS).unref?.()
632
+ this.scheduleEscalation()
633
+ }
634
+
635
+ private scheduleEscalation(): void {
636
+ const grace = Math.min(Math.max(0, this.spec.graceMs), 2147483647)
637
+ setTimeout(() => {
638
+ if (this.settled) return
639
+ this.killedSignal = 'SIGKILL'
640
+ if (this.useSetsid) {
641
+ this.onPgidKnown(() => this.groupKill('KILL'))
642
+ } else {
643
+ this.signalLeader('KILL')
644
+ }
645
+ setTimeout(() => {
646
+ if (this.settled) return
647
+ try {
648
+ this.channel?.close()
649
+ } catch {
650
+ // Ignore.
651
+ }
652
+ }, SAFETY_CLOSE_MS).unref?.()
653
+ }, grace).unref?.()
654
+ }
655
+
656
+ private fireTerm(): void {
657
+ if (this.termFired || this.settled) return
658
+ this.termFired = true
659
+ this.groupKill('TERM')
660
+ }
661
+
662
+ /** Marker never arrived: last resort (channel signal + warn about orphans). */
663
+ private fallbackKill(): void {
664
+ if (this.settled) return
665
+ this.termFired = true
666
+ try {
667
+ this.channel?.signal('TERM')
668
+ } catch {
669
+ // Ignore.
670
+ }
671
+ // eslint-disable-next-line no-console
672
+ console.warn(`[dsh-remote-vs] remote kill without pgid for ${this.alias} (marker missing): the wrapper chain was signalled but detached descendants may survive`)
673
+ }
674
+
675
+ private groupKill(signal: 'TERM' | 'KILL'): void {
676
+ const pgid = this.pgid
677
+ if (pgid === undefined || this.settled) return
678
+ void this.pool.exec(this.alias, `kill -${signal} -- -${pgid}`, { timeoutMs: 15000 }).catch(() => undefined)
679
+ }
680
+
681
+ private signalLeader(signal: 'TERM' | 'KILL'): void {
682
+ try {
683
+ this.channel?.signal(signal)
684
+ } catch {
685
+ // Fall through to close.
686
+ }
687
+ }
688
+
689
+ /** Run cb now if the pgid is known, else when the marker lands. */
690
+ private onPgidKnown(cb: () => void): void {
691
+ if (this.pgid !== undefined) {
692
+ cb()
693
+ return
694
+ }
695
+ this.pgidWaiters.push(cb)
696
+ }
697
+
698
+ /** Consume a leading `RV_PID=<pgid>` line; forward everything else. */
699
+ private stripMarker(chunk: Buffer): void {
700
+ const out = this.stdoutStripped
701
+ if (!this.markerPending) {
702
+ out.write(chunk)
703
+ return
704
+ }
705
+ this.markerBuf = Buffer.concat([this.markerBuf, chunk])
706
+ const newline = this.markerBuf.indexOf(0x0a)
707
+ if (newline === -1) {
708
+ if (this.markerBuf.length > 1024) {
709
+ // Not a marker after all: flush and stop looking.
710
+ this.markerPending = false
711
+ out.write(this.markerBuf)
712
+ this.markerBuf = Buffer.alloc(0)
713
+ }
714
+ return
715
+ }
716
+ const line = this.markerBuf.subarray(0, newline).toString('utf8').trim()
717
+ const rest = this.markerBuf.subarray(newline + 1)
718
+ this.markerBuf = Buffer.alloc(0)
719
+ this.markerPending = false
720
+ const match = /^RV_PID=(\d+)$/.exec(line)
721
+ if (match?.[1] !== undefined) {
722
+ this.pgid = Number(match[1])
723
+ const waiters = this.pgidWaiters
724
+ this.pgidWaiters = []
725
+ for (const waiter of waiters) {
726
+ try {
727
+ waiter()
728
+ } catch {
729
+ // Kill dispatch must never throw.
730
+ }
731
+ }
732
+ } else {
733
+ // First line wasn't ours (unexpected wrapper output): keep it.
734
+ out.write(Buffer.concat([Buffer.from(`${line}\n`, 'utf8'), rest]))
735
+ return
736
+ }
737
+ if (rest.length > 0) out.write(rest)
738
+ }
739
+
740
+ /** Flush a partial marker buffer at close (no pgid learned). */
741
+ private flushMarker(): void {
742
+ if (this.markerBuf.length > 0) {
743
+ this.stdoutStripped.write(this.markerBuf)
744
+ this.markerBuf = Buffer.alloc(0)
745
+ }
746
+ this.markerPending = false
747
+ }
748
+
749
+ async waitForExit(signal?: AbortSignal): Promise<boolean> {
750
+ if (this.settled) return true
751
+ if (signal?.aborted === true) return false
752
+ return new Promise<boolean>((resolve) => {
753
+ const cleanup = (): void => {
754
+ signal?.removeEventListener('abort', onAbort)
755
+ }
756
+ const onAbort = (): void => {
757
+ cleanup()
758
+ resolve(false)
759
+ }
760
+ void this.done.then(
761
+ () => {
762
+ cleanup()
763
+ resolve(true)
764
+ },
765
+ () => {
766
+ cleanup()
767
+ resolve(true)
768
+ },
769
+ )
770
+ signal?.addEventListener('abort', onAbort, { once: true })
771
+ })
772
+ }
773
+
774
+ private settle(outcome: SubprocessOutcome): void {
775
+ if (this.settled) return
776
+ this.settled = true
777
+ this.outPipe?.end()
778
+ this.errPipe?.end()
779
+ this.outCollect?.dispose()
780
+ this.errCollect?.dispose()
781
+ this.abortListener?.()
782
+ this.resolveDone(outcome)
783
+ this.finish()
784
+ }
785
+
786
+ private fail(error: unknown): void {
787
+ if (this.settled) return
788
+ this.settled = true
789
+ this.outPipe?.destroy()
790
+ this.errPipe?.destroy()
791
+ this.outCollect?.dispose()
792
+ this.errCollect?.dispose()
793
+ try {
794
+ this.channel?.close()
795
+ } catch {
796
+ // Ignore.
797
+ }
798
+ this.abortListener?.()
799
+ this.rejectDone(error)
800
+ this.finish()
801
+ }
802
+ }