@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2

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 (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1165 -1329
  6. package/lib/fs.js +307 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +18 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +36 -11
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -99
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +137 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -232
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,203 +1,265 @@
1
- /**
2
- * One asynchronously-started SSH command projected onto the subprocess seam.
3
- * Ported from UynajGI/dsh-ssh (MIT) — the raw ssh2 channel is replaced by the
4
- * engine's streaming ExecSession.
5
- */
6
-
7
- import { PassThrough } from 'node:stream'
8
- import type { SshEngine, ExecSession } from '../ssh/engine.ts'
9
- import type {
10
- SubprocessCollect,
11
- SubprocessHandle,
12
- SubprocessOutcome,
13
- SubprocessOutputMode,
14
- SubprocessSpawnSpec,
15
- } from '@deepseek-ai/dsh-subprocess'
16
- import type { WorkspaceState } from '../protocol.ts'
17
- import { quoteShellArg, readScrubbedRemoteEnvironment, serializeEnvironment } from './environment.ts'
18
- import { SshOutputCollector } from './output.ts'
19
-
20
- function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
21
- return mode !== 'pipe' && mode !== 'inherit'
22
- }
23
-
24
- /** Resolve the remote working directory for one spawn spec. */
25
- function resolveRemoteCwd(state: WorkspaceState, cwd: string | undefined): string {
26
- const root = state.remoteRoot
27
- if (root === undefined) throw new Error('subprocess-ssh: remote workspace root is not set')
28
- if (cwd !== undefined && cwd.startsWith('/')) return cwd
29
- return root
30
- }
31
-
32
- /** Build the remote command text: cd, env -i scrub, exec the argv. */
33
- async function buildCommand(engine: SshEngine, alias: string, state: WorkspaceState, spec: SubprocessSpawnSpec): Promise<string> {
34
- const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, alias), spec.env)
35
- const argv = spec.argv.map(quoteShellArg).join(' ')
36
- return `cd -- ${quoteShellArg(resolveRemoteCwd(state, spec.cwd))} && exec env -i -- ${environment} ${argv}`
37
- }
38
-
39
- /** SSH-backed subprocess handle. The channel does not expose a remote pid, so `pid` is `-1`. */
40
- export class SshSubprocessHandle implements SubprocessHandle {
41
- readonly stdin: PassThrough | undefined
42
- readonly stdout: PassThrough | undefined
43
- readonly stderr: PassThrough | undefined
44
- readonly collected: SubprocessHandle['collected']
45
- readonly done: Promise<SubprocessOutcome>
46
-
47
- private readonly terminationController = new AbortController()
48
- private readonly stdoutCollector: SshOutputCollector | undefined
49
- private readonly stderrCollector: SshOutputCollector | undefined
50
- private session: ExecSession | undefined
51
- private graceTimer: NodeJS.Timeout | undefined
52
- private settled = false
53
-
54
- constructor(
55
- private readonly engine: SshEngine,
56
- private readonly getState: () => WorkspaceState,
57
- private readonly spec: SubprocessSpawnSpec,
58
- private readonly spillDir: string,
59
- ) {
60
- const outMode = spec.stdio.stdout
61
- const errMode = spec.stdio.stderr
62
- this.stdout = outMode === 'pipe' ? new PassThrough() : undefined
63
- this.stderr = errMode === 'pipe' ? new PassThrough() : undefined
64
- this.stdoutCollector = isCollect(outMode)
65
- ? new SshOutputCollector(outMode.maxBytes, outMode.spill?.maxBytes, 'stdout', spillDir)
66
- : undefined
67
- this.stderrCollector = isCollect(errMode)
68
- ? new SshOutputCollector(errMode.maxBytes, errMode.spill?.maxBytes, 'stderr', spillDir)
69
- : undefined
70
- this.collected = {
71
- ...(this.stdoutCollector !== undefined ? { stdout: this.stdoutCollector } : {}),
72
- ...(this.stderrCollector !== undefined ? { stderr: this.stderrCollector } : {}),
73
- }
74
- this.stdin = spec.stdio.stdin === 'pipe' ? new PassThrough() : undefined
75
-
76
- spec.signal?.addEventListener('abort', this.onAbort, { once: true })
77
- this.done = this.run()
78
- void this.done.catch(() => {})
79
- if (spec.signal?.aborted === true) this.terminate()
80
- }
81
-
82
- /** Remote process id; `-1` because the SSH channel does not expose one. */
83
- get pid(): number {
84
- return -1
85
- }
86
-
87
- /** @inheritdoc */
88
- terminate(): void {
89
- if (this.settled || this.terminationController.signal.aborted) return
90
- this.terminationController.abort(new Error('subprocess-ssh: command terminated'))
91
- const session = this.session
92
- if (session !== undefined) this.signalTerm(session)
93
- }
94
-
95
- /** @inheritdoc */
96
- waitForExit(signal?: AbortSignal): Promise<boolean> {
97
- if (this.settled) return Promise.resolve(true)
98
- if (signal?.aborted === true) return Promise.resolve(false)
99
- if (signal === undefined) {
100
- return this.done.then(() => true, () => true)
101
- }
102
- return new Promise<boolean>((resolve) => {
103
- const onAbort = (): void => { cleanup(); resolve(false) }
104
- const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
105
- signal.addEventListener('abort', onAbort, { once: true })
106
- void this.done.then(() => { cleanup(); resolve(true) }, () => { cleanup(); resolve(true) })
107
- })
108
- }
109
-
110
- private readonly onAbort = (): void => { this.terminate() }
111
-
112
- private signalTerm(session: ExecSession): void {
113
- try {
114
- session.signal('TERM')
115
- } catch {
116
- // The channel closed before the signal could be delivered; close is authoritative.
117
- }
118
- this.graceTimer = setTimeout(() => {
119
- try {
120
- session.signal('KILL')
121
- } catch {
122
- // Escalation after a graceful close is a no-op.
123
- }
124
- }, this.spec.graceMs)
125
- }
126
-
127
- private settle(): void {
128
- if (this.settled) return
129
- this.settled = true
130
- if (this.graceTimer !== undefined) clearTimeout(this.graceTimer)
131
- this.graceTimer = undefined
132
- this.stdoutCollector?.seal()
133
- this.stderrCollector?.seal()
134
- this.spec.signal?.removeEventListener('abort', this.onAbort)
135
- }
136
-
137
- private async run(): Promise<SubprocessOutcome> {
138
- let session: ExecSession
139
- try {
140
- const state = this.getState()
141
- if (state.mode !== 'remote' || state.alias === undefined) {
142
- throw new Error('subprocess-ssh: not in remote mode switch the GUI to SSH mode first')
143
- }
144
- const command = await buildCommand(this.engine, state.alias, state, this.spec)
145
- session = await this.engine.openExec(state.alias, command)
146
- } catch (error) {
147
- this.settle()
148
- throw error
149
- }
150
- this.session = session
151
- if (this.terminationController.signal.aborted) this.signalTerm(session)
152
-
153
- this.wireStdout(session)
154
- this.wireStderr(session)
155
- if (this.stdin !== undefined) {
156
- this.stdin.pipe({
157
- write: (chunk: Buffer, encoding: BufferEncoding, callback?: (error?: Error | null) => void) => {
158
- try {
159
- session.send(chunk.toString(encoding ?? 'utf8'))
160
- callback?.()
161
- } catch (error) {
162
- callback?.(error instanceof Error ? error : new Error(String(error)))
163
- }
164
- },
165
- end: (callback?: () => void) => {
166
- session.end()
167
- callback?.()
168
- },
169
- } as unknown as NodeJS.WritableStream)
170
- } else if (typeof this.spec.stdio.stdin === 'object') {
171
- session.end(this.spec.stdio.stdin.data)
172
- }
173
-
174
- return await new Promise<SubprocessOutcome>((resolve, reject) => {
175
- session.onExit = (code: number | null, error?: string) => {
176
- this.settle()
177
- if (error !== undefined) {
178
- reject(new Error(`subprocess-ssh: ${error}`))
179
- } else {
180
- resolve({ exitCode: code, signal: null })
181
- }
182
- }
183
- })
184
- }
185
-
186
- private wireStdout(session: ExecSession): void {
187
- const mode = this.spec.stdio.stdout
188
- session.onData = (data: Buffer) => {
189
- if (mode === 'pipe') this.stdout?.write(data)
190
- else if (mode === 'inherit') process.stdout.write(data)
191
- else this.stdoutCollector?.push(data)
192
- }
193
- }
194
-
195
- private wireStderr(session: ExecSession): void {
196
- const mode = this.spec.stdio.stderr
197
- session.onErrData = (data: Buffer) => {
198
- if (mode === 'pipe') this.stderr?.write(data)
199
- else if (mode === 'inherit') process.stderr.write(data)
200
- else this.stderrCollector?.push(data)
201
- }
202
- }
203
- }
1
+ /**
2
+ * One asynchronously-started SSH command projected onto the subprocess seam.
3
+ * Ported from UynajGI/dsh-ssh (MIT) — the raw ssh2 channel is replaced by the
4
+ * engine's streaming ExecSession.
5
+ */
6
+
7
+ import { PassThrough, type Writable } from 'node:stream'
8
+ import type { SshEngine, ExecSession } from '../ssh/engine.ts'
9
+ import type {
10
+ SubprocessCollect,
11
+ SubprocessHandle,
12
+ SubprocessOutcome,
13
+ SubprocessOutputMode,
14
+ SubprocessSpawnSpec,
15
+ } from '@deepseek-ai/dsh-subprocess'
16
+ import type { WorkspaceState } from '../protocol.ts'
17
+ import { quoteShellArg, readScrubbedRemoteEnvironment, serializeEnvironment } from './environment.ts'
18
+ import { SshOutputCollector } from './output.ts'
19
+
20
+ function isCollect(mode: SubprocessOutputMode): mode is SubprocessCollect {
21
+ return mode !== 'pipe' && mode !== 'inherit'
22
+ }
23
+
24
+ /** Resolve the remote working directory for one spawn spec. */
25
+ function resolveRemoteCwd(state: WorkspaceState, cwd: string | undefined): string {
26
+ const root = state.remoteRoot
27
+ if (root === undefined) throw new Error('subprocess-ssh: remote workspace root is not set')
28
+ if (cwd !== undefined && cwd.startsWith('/')) return cwd
29
+ return root
30
+ }
31
+
32
+ /** Build the remote command text: cd, env -i scrub, exec the argv. */
33
+ async function buildCommand(engine: SshEngine, alias: string, state: WorkspaceState, spec: SubprocessSpawnSpec): Promise<string> {
34
+ const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, alias), spec.env)
35
+ const argv = spec.argv.map(quoteShellArg).join(' ')
36
+ return `cd -- ${quoteShellArg(resolveRemoteCwd(state, spec.cwd))} && exec env -i -- ${environment} ${argv}`
37
+ }
38
+
39
+ function toError(error: unknown): Error {
40
+ return error instanceof Error ? error : new Error(String(error))
41
+ }
42
+
43
+ /** SSH-backed subprocess handle. The channel does not expose a remote pid, so `pid` is `-1`. */
44
+ export class SshSubprocessHandle implements SubprocessHandle {
45
+ readonly stdin: PassThrough | undefined
46
+ readonly stdout: PassThrough | undefined
47
+ readonly stderr: PassThrough | undefined
48
+ readonly collected: SubprocessHandle['collected']
49
+ readonly done: Promise<SubprocessOutcome>
50
+
51
+ private readonly terminationController = new AbortController()
52
+ private readonly stdoutCollector: SshOutputCollector | undefined
53
+ private readonly stderrCollector: SshOutputCollector | undefined
54
+ private session: ExecSession | undefined
55
+ private inputSink: Writable | undefined
56
+ private inputErrorListener: ((error: unknown) => void) | undefined
57
+ private terminationPhase: 'none' | 'term' | 'kill' = 'none'
58
+ private graceTimer: NodeJS.Timeout | undefined
59
+ private forceTimer: NodeJS.Timeout | undefined
60
+ private resolveForced: ((outcome: SubprocessOutcome) => void) | undefined
61
+ private forceClosed = false
62
+ private settled = false
63
+
64
+ constructor(
65
+ private readonly engine: SshEngine,
66
+ private readonly getState: () => WorkspaceState,
67
+ private readonly spec: SubprocessSpawnSpec,
68
+ private readonly spillDir: string,
69
+ ) {
70
+ const outMode = spec.stdio.stdout
71
+ const errMode = spec.stdio.stderr
72
+ this.stdout = outMode === 'pipe' ? new PassThrough() : undefined
73
+ this.stderr = errMode === 'pipe' ? new PassThrough() : undefined
74
+ this.stdoutCollector = isCollect(outMode)
75
+ ? new SshOutputCollector(outMode.maxBytes, outMode.spill?.maxBytes, 'stdout', spillDir)
76
+ : undefined
77
+ this.stderrCollector = isCollect(errMode)
78
+ ? new SshOutputCollector(errMode.maxBytes, errMode.spill?.maxBytes, 'stderr', spillDir)
79
+ : undefined
80
+ this.collected = {
81
+ ...(this.stdoutCollector !== undefined ? { stdout: this.stdoutCollector } : {}),
82
+ ...(this.stderrCollector !== undefined ? { stderr: this.stderrCollector } : {}),
83
+ }
84
+ this.stdin = spec.stdio.stdin === 'pipe' ? new PassThrough() : undefined
85
+
86
+ const forced = new Promise<SubprocessOutcome>((resolve) => { this.resolveForced = resolve })
87
+ spec.signal?.addEventListener('abort', this.onAbort, { once: true })
88
+ // The force branch exists from construction time, so runtime disposal can
89
+ // settle `done` even while environment lookup/openExec is still pending.
90
+ this.done = Promise.race([this.run(), forced]).finally(() => { this.settle() })
91
+ void this.done.catch(() => {})
92
+ if (spec.signal?.aborted === true) this.terminate()
93
+ }
94
+
95
+ /** Remote process id; `-1` because the SSH channel does not expose one. */
96
+ get pid(): number {
97
+ return -1
98
+ }
99
+
100
+ /** @inheritdoc */
101
+ terminate(): void {
102
+ if (this.settled || this.terminationController.signal.aborted) return
103
+ this.terminationController.abort(new Error('subprocess-ssh: command terminated'))
104
+ this.startTermination()
105
+ }
106
+
107
+ /** @inheritdoc */
108
+ waitForExit(signal?: AbortSignal): Promise<boolean> {
109
+ if (this.settled) return Promise.resolve(true)
110
+ if (signal?.aborted === true) return Promise.resolve(false)
111
+ if (signal === undefined) return this.done.then(() => true, () => true)
112
+ return new Promise<boolean>((resolve) => {
113
+ const onAbort = (): void => { cleanup(); resolve(false) }
114
+ const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
115
+ signal.addEventListener('abort', onAbort, { once: true })
116
+ void this.done.then(() => { cleanup(); resolve(true) }, () => { cleanup(); resolve(true) })
117
+ })
118
+ }
119
+
120
+ private readonly onAbort = (): void => { this.terminate() }
121
+
122
+ private signalCurrentPhase(session: ExecSession): void {
123
+ if (this.terminationPhase === 'none') return
124
+ try { session.signal(this.terminationPhase === 'term' ? 'TERM' : 'KILL') } catch { /* channel already closed */ }
125
+ }
126
+
127
+ private startTermination(): void {
128
+ if (this.terminationPhase !== 'none' || this.settled) return
129
+ this.terminationPhase = 'term'
130
+ if (this.session !== undefined) this.signalCurrentPhase(this.session)
131
+ this.graceTimer = setTimeout(() => {
132
+ if (this.settled) return
133
+ this.terminationPhase = 'kill'
134
+ if (this.session !== undefined) this.signalCurrentPhase(this.session)
135
+ this.forceTimer = setTimeout(() => { this.forceClose() }, this.spec.graceMs)
136
+ this.forceTimer.unref?.()
137
+ }, this.spec.graceMs)
138
+ this.graceTimer.unref?.()
139
+ }
140
+
141
+ /** Immediately close the owned channel and settle `done`, including while
142
+ * openExec is still pending. A late-opened channel observes forceClosed and
143
+ * closes itself before any stream wiring is installed. */
144
+ forceClose(): void {
145
+ if (this.settled || this.forceClosed) return
146
+ this.forceClosed = true
147
+ const error = new Error('subprocess-ssh: command forcibly closed during teardown')
148
+ if (!this.terminationController.signal.aborted) this.terminationController.abort(error)
149
+ try { this.session?.close() } catch { /* channel already closed */ }
150
+ this.resolveForced?.({ exitCode: null, signal: 'SIGKILL' })
151
+ }
152
+
153
+ private settle(): void {
154
+ if (this.settled) return
155
+ this.settled = true
156
+ if (this.graceTimer !== undefined) clearTimeout(this.graceTimer)
157
+ if (this.forceTimer !== undefined) clearTimeout(this.forceTimer)
158
+ this.graceTimer = undefined
159
+ this.forceTimer = undefined
160
+ this.resolveForced = undefined
161
+ if (this.inputSink !== undefined && this.inputErrorListener !== undefined) {
162
+ this.inputSink.off('error', this.inputErrorListener)
163
+ }
164
+ if (this.stdin !== undefined && this.inputSink !== undefined) this.stdin.unpipe(this.inputSink)
165
+ this.stdin?.destroy()
166
+ this.inputSink = undefined
167
+ this.inputErrorListener = undefined
168
+ this.stdout?.end()
169
+ this.stderr?.end()
170
+ this.stdoutCollector?.seal()
171
+ this.stderrCollector?.seal()
172
+ this.spec.signal?.removeEventListener('abort', this.onAbort)
173
+ }
174
+
175
+ private async run(): Promise<SubprocessOutcome> {
176
+ const state = this.getState()
177
+ if (state.mode !== 'remote' || state.alias === undefined) {
178
+ throw new Error('subprocess-ssh: not in remote mode — switch the GUI to SSH mode first')
179
+ }
180
+ const command = await buildCommand(this.engine, state.alias, state, this.spec)
181
+ const session = await this.engine.openExec(state.alias, command)
182
+ this.session = session
183
+ if (this.forceClosed) {
184
+ try { session.close() } catch { /* channel already closed */ }
185
+ throw new Error('subprocess-ssh: command opened after its owner was disposed')
186
+ }
187
+ if (this.terminationController.signal.aborted) {
188
+ if (this.terminationPhase === 'none') this.startTermination()
189
+ else this.signalCurrentPhase(session)
190
+ }
191
+ return this.runSession(session)
192
+ }
193
+
194
+ /** Install every callback under one ownership boundary. Any synchronous
195
+ * wiring failure or async stdin sink failure closes the established session
196
+ * and rejects the handle instead of leaking it. */
197
+ private runSession(session: ExecSession): Promise<SubprocessOutcome> {
198
+ return new Promise<SubprocessOutcome>((resolve, reject) => {
199
+ let finished = false
200
+ const fail = (error: unknown): void => {
201
+ if (finished) return
202
+ finished = true
203
+ try { session.close() } catch { /* retain the primary error */ }
204
+ reject(toError(error))
205
+ }
206
+ session.onExit = (code: number | null, error?: string) => {
207
+ if (finished) return
208
+ finished = true
209
+ if (error !== undefined) reject(new Error(`subprocess-ssh: ${error}`))
210
+ else resolve({ exitCode: code, signal: null })
211
+ }
212
+
213
+ try {
214
+ this.wireStdout(session)
215
+ this.wireStderr(session)
216
+ if (this.stdin !== undefined) {
217
+ this.inputSink = session.stdin
218
+ this.inputErrorListener = fail
219
+ session.stdin.once('error', fail)
220
+ this.stdin.pipe(session.stdin)
221
+ } else if (typeof this.spec.stdio.stdin === 'object') {
222
+ session.stdin.end(this.spec.stdio.stdin.data)
223
+ } else {
224
+ // `ignore` is remote /dev/null semantics: close fd 0 immediately so
225
+ // commands waiting for EOF cannot hang forever.
226
+ session.stdin.end()
227
+ }
228
+ } catch (error) {
229
+ fail(error)
230
+ }
231
+ })
232
+ }
233
+
234
+ /** Route one stream through the engine's leak guard before it reaches the
235
+ * consumer. Without this, a remote `bash`/subprocess channel bypassed the
236
+ * redaction that every exec/cluster result already applies. Redaction is
237
+ * exact-match over known secrets, so it is best-effort and may change byte
238
+ * length — the same trade-off the PTY route documents. */
239
+ private redactBytes(data: Buffer): Buffer {
240
+ if (typeof this.engine.redact !== 'function') return data
241
+ const text = data.toString('utf8')
242
+ const safe = this.engine.redact(text)
243
+ return safe === text ? data : Buffer.from(safe, 'utf8')
244
+ }
245
+
246
+ private wireStdout(session: ExecSession): void {
247
+ const mode = this.spec.stdio.stdout
248
+ session.onData = (data: Buffer) => {
249
+ const safe = this.redactBytes(data)
250
+ if (mode === 'pipe') this.stdout?.write(safe)
251
+ else if (mode === 'inherit') process.stdout.write(safe)
252
+ else this.stdoutCollector?.push(safe)
253
+ }
254
+ }
255
+
256
+ private wireStderr(session: ExecSession): void {
257
+ const mode = this.spec.stdio.stderr
258
+ session.onErrData = (data: Buffer) => {
259
+ const safe = this.redactBytes(data)
260
+ if (mode === 'pipe') this.stderr?.write(safe)
261
+ else if (mode === 'inherit') process.stderr.write(safe)
262
+ else this.stderrCollector?.push(safe)
263
+ }
264
+ }
265
+ }
@@ -7,7 +7,7 @@
7
7
  * @module dsh-hardssh/remote-subprocess
8
8
  */
9
9
 
10
- import { mkdtempSync } from 'node:fs'
10
+ import { mkdtempSync, rmSync } from 'node:fs'
11
11
  import { tmpdir } from 'node:os'
12
12
  import { join, posix } from 'node:path'
13
13
  import type { Context } from '@deepseek-ai/cordis'
@@ -34,12 +34,23 @@ function requireRepresentableGrace(graceMs: number): void {
34
34
  }
35
35
  }
36
36
 
37
+ /** Hard upper bound for plugin/workspace teardown, independent of a caller's
38
+ * potentially very large per-process TERM/KILL grace setting. */
39
+ export const SUBPROCESS_DISPOSE_DEADLINE_MS = 5_000
40
+
41
+ function delay(ms: number): Promise<void> {
42
+ return new Promise(resolve => {
43
+ const timer = setTimeout(resolve, ms)
44
+ timer.unref?.()
45
+ })
46
+ }
47
+
37
48
  /** SSH command manager registered as `ctx.subprocess` (remote mode). */
38
49
  export class SshSubprocessRuntime extends SubprocessRuntime {
39
50
  private readonly live = new Set<SshSubprocessHandle>()
40
51
  private readonly terminals = new Set<SshTerminalHandle>()
41
52
  private readonly spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-ssh-'))
42
- private disposing = false
53
+ private closePromise: Promise<void> | undefined
43
54
 
44
55
  constructor(
45
56
  ctx: Context,
@@ -48,22 +59,54 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
48
59
  ) {
49
60
  super(ctx)
50
61
  ctx.effect(() => async () => {
51
- this.disposing = true
52
- const handles = [...this.live]
53
- const terminals = [...this.terminals]
54
- const pending: Promise<unknown>[] = []
55
- for (const handle of handles) {
56
- handle.terminate()
57
- pending.push(handle.waitForExit().then(() => { this.live.delete(handle) }))
58
- }
59
- for (const terminal of terminals) {
60
- pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
61
- }
62
- const outcomes = await Promise.allSettled(pending)
62
+ // The effect's own body would duplicate close(); delegating keeps one
63
+ // idempotent teardown path for host shutdown and on-demand connection close.
64
+ await this.close()
65
+ }, 'ssh subprocess teardown')
66
+ }
67
+
68
+ /**
69
+ * Public idempotent close that releases every workspace-owned live process
70
+ * and terminal. The ctx.effect teardown cannot be invoked by a workspace
71
+ * connection (it only runs when the cordis scope disposes, which would also
72
+ * tear down the shared engine's scope), so a SshWorkspaceConnection calls
73
+ * this on-demand close() instead — the engine pool itself stays untouched.
74
+ */
75
+ close(): Promise<void> {
76
+ this.closePromise ??= this.closeOwnedResources()
77
+ return this.closePromise
78
+ }
79
+
80
+ private async closeOwnedResources(): Promise<void> {
81
+ const handles = [...this.live]
82
+ const terminals = [...this.terminals]
83
+ const pending: Promise<unknown>[] = []
84
+ for (const handle of handles) {
85
+ handle.terminate()
86
+ pending.push(handle.waitForExit().then(() => { this.live.delete(handle) }))
87
+ }
88
+ for (const terminal of terminals) {
89
+ pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
90
+ }
91
+
92
+ const all = Promise.allSettled(pending)
93
+ const graceful = await Promise.race([
94
+ all.then(outcomes => ({ outcomes })),
95
+ delay(SUBPROCESS_DISPOSE_DEADLINE_MS).then(() => undefined),
96
+ ])
97
+ if (graceful === undefined) {
98
+ for (const handle of handles) handle.forceClose()
99
+ for (const terminal of terminals) terminal.forceClose()
100
+ }
101
+
102
+ try {
103
+ const outcomes = graceful?.outcomes ?? await all
63
104
  const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : [])
64
105
  if (failures.length === 1) throw failures[0]
65
106
  if (failures.length > 1) throw new AggregateError(failures, 'subprocess-ssh: teardown failed')
66
- }, 'ssh subprocess teardown')
107
+ } finally {
108
+ rmSync(this.spillDir, { recursive: true, force: true })
109
+ }
67
110
  }
68
111
 
69
112
  /** @inheritdoc */
@@ -113,7 +156,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
113
156
 
114
157
  /** @inheritdoc */
115
158
  spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
116
- if (this.disposing) throw new Error('subprocess-ssh: service is disposing')
159
+ if (this.closePromise !== undefined) throw new Error('subprocess-ssh: service is disposing')
117
160
  const program = spec.argv[0]
118
161
  if (program === undefined || program.length === 0) {
119
162
  throw new Error('invalid argv: expected a non-empty program name at argv[0]')
@@ -134,7 +177,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
134
177
 
135
178
  /** @inheritdoc */
136
179
  async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
137
- if (this.disposing) throw new Error('subprocess-ssh: service is disposing')
180
+ if (this.closePromise !== undefined) throw new Error('subprocess-ssh: service is disposing')
138
181
  const program = spec.argv[0]
139
182
  if (program === undefined || program.length === 0) {
140
183
  throw new Error('subprocess-ssh: terminal argv must contain a program')
@@ -142,7 +185,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
142
185
  requireRepresentableGrace(spec.graceMs)
143
186
  spec.signal?.throwIfAborted()
144
187
  const terminal = await spawnSshTerminal(this.engine, this.getState, spec)
145
- if (this.disposing) {
188
+ if (this.closePromise !== undefined) {
146
189
  await terminal.terminate()
147
190
  throw new Error('subprocess-ssh: service disposed during terminal setup')
148
191
  }