@tiphareth/dsh-hardssh 0.1.2-alpha

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 (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,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 } 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
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Remote subprocess provider for the `ctx.subprocess` capability seam: each
3
+ * spawn opens a streaming exec channel (or a PTY for terminals) on the
4
+ * current SSH-mode host through the dsh-ssh engine; output spill files stay
5
+ * on the local host. Ported from UynajGI/dsh-ssh (MIT).
6
+ *
7
+ * @module dsh-hardssh/remote-subprocess
8
+ */
9
+
10
+ import { mkdtempSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, posix } from 'node:path'
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type { SshEngine } from '../ssh/engine.ts'
15
+ import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
16
+ import type {
17
+ SubprocessHandle,
18
+ SubprocessSpawnSpec,
19
+ SubprocessTerminalHandle,
20
+ SubprocessTerminalSpawnSpec,
21
+ } from '@deepseek-ai/dsh-subprocess'
22
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
23
+ import type { WorkspaceState } from '../protocol.ts'
24
+ import { quoteShellArg } from './environment.ts'
25
+ import { SshSubprocessHandle } from './remote-process.ts'
26
+ import { SshTerminalHandle, spawnSshTerminal } from './remote-terminal.ts'
27
+
28
+ /**
29
+ * Enforce the seam's documented grace bound (positive, finite, one Node timer).
30
+ */
31
+ function requireRepresentableGrace(graceMs: number): void {
32
+ if (!Number.isFinite(graceMs) || graceMs <= 0 || graceMs > MAX_TIMER_DELAY_MS) {
33
+ throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
34
+ }
35
+ }
36
+
37
+ /** SSH command manager registered as `ctx.subprocess` (remote mode). */
38
+ export class SshSubprocessRuntime extends SubprocessRuntime {
39
+ private readonly live = new Set<SshSubprocessHandle>()
40
+ private readonly terminals = new Set<SshTerminalHandle>()
41
+ private readonly spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-ssh-'))
42
+ private disposing = false
43
+
44
+ constructor(
45
+ ctx: Context,
46
+ private readonly engine: SshEngine,
47
+ private readonly getState: () => WorkspaceState,
48
+ ) {
49
+ super(ctx)
50
+ 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)
63
+ const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : [])
64
+ if (failures.length === 1) throw failures[0]
65
+ if (failures.length > 1) throw new AggregateError(failures, 'subprocess-ssh: teardown failed')
66
+ }, 'ssh subprocess teardown')
67
+ }
68
+
69
+ /** @inheritdoc */
70
+ async resolveExecutable(
71
+ command: string,
72
+ env?: Readonly<Record<string, string>>,
73
+ signal?: AbortSignal,
74
+ ): Promise<string> {
75
+ if (command.length === 0) throw new Error('subprocess-ssh: executable name must be non-empty')
76
+ signal?.throwIfAborted()
77
+ const state = this.getState()
78
+ if (state.mode !== 'remote' || state.alias === undefined) {
79
+ throw new Error('subprocess-ssh: not in remote mode — switch the GUI to SSH mode first')
80
+ }
81
+ if (posix.isAbsolute(command)) {
82
+ const result = await this.engine.exec(
83
+ state.alias,
84
+ `test -f ${quoteShellArg(command)} -a -x ${quoteShellArg(command)}`,
85
+ 10_000,
86
+ )
87
+ signal?.throwIfAborted()
88
+ if (!result.success || result.exitCode !== 0) {
89
+ throw new Error(`subprocess-ssh: command ${JSON.stringify(command)} is not an executable file`)
90
+ }
91
+ return command
92
+ }
93
+ if (command.includes('/')) {
94
+ throw new Error(
95
+ `subprocess-ssh: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
96
+ )
97
+ }
98
+ const path = env?.PATH
99
+ const prefix = path === undefined ? '' : `PATH=${quoteShellArg(path)} `
100
+ const result = await this.engine.exec(state.alias, `${prefix}command -v -- ${quoteShellArg(command)}`, 10_000)
101
+ signal?.throwIfAborted()
102
+ const executable = result.stdout.trim()
103
+ if (!result.success || result.exitCode !== 0
104
+ || executable.length === 0
105
+ || executable.includes('\n')
106
+ || (!posix.isAbsolute(executable) && !executable.includes('/'))) {
107
+ throw new Error(`subprocess-ssh: executable ${JSON.stringify(command)} did not resolve to one absolute path`)
108
+ }
109
+ const root = state.remoteRoot
110
+ if (root === undefined) throw new Error('subprocess-ssh: remote workspace root is not set')
111
+ return posix.isAbsolute(executable) ? executable : posix.resolve(root, executable)
112
+ }
113
+
114
+ /** @inheritdoc */
115
+ spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
116
+ if (this.disposing) throw new Error('subprocess-ssh: service is disposing')
117
+ const program = spec.argv[0]
118
+ if (program === undefined || program.length === 0) {
119
+ throw new Error('invalid argv: expected a non-empty program name at argv[0]')
120
+ }
121
+ requireRepresentableGrace(spec.graceMs)
122
+ if (spec.signal?.aborted === true) {
123
+ throw new Error(`aborted before spawn: ${String(spec.signal.reason)}`)
124
+ }
125
+ const handle = new SshSubprocessHandle(this.engine, this.getState, spec, this.spillDir)
126
+ this.live.add(handle)
127
+ const release = async (): Promise<void> => {
128
+ await handle.waitForExit()
129
+ this.live.delete(handle)
130
+ }
131
+ void handle.done.then(release, release).catch(() => {})
132
+ return handle
133
+ }
134
+
135
+ /** @inheritdoc */
136
+ async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
137
+ if (this.disposing) throw new Error('subprocess-ssh: service is disposing')
138
+ const program = spec.argv[0]
139
+ if (program === undefined || program.length === 0) {
140
+ throw new Error('subprocess-ssh: terminal argv must contain a program')
141
+ }
142
+ requireRepresentableGrace(spec.graceMs)
143
+ spec.signal?.throwIfAborted()
144
+ const terminal = await spawnSshTerminal(this.engine, this.getState, spec)
145
+ if (this.disposing) {
146
+ await terminal.terminate()
147
+ throw new Error('subprocess-ssh: service disposed during terminal setup')
148
+ }
149
+ this.terminals.add(terminal)
150
+ const release = async (): Promise<void> => {
151
+ await terminal.terminate()
152
+ this.terminals.delete(terminal)
153
+ }
154
+ void terminal.done.then(release, release).catch(() => {})
155
+ return terminal
156
+ }
157
+ }
158
+
159
+ export default SshSubprocessRuntime
@@ -0,0 +1,141 @@
1
+ /**
2
+ * SSH PTY allocation and process-session ownership for the subprocess seam.
3
+ * Ported from UynajGI/dsh-ssh (MIT) — the raw ssh2 shell channel is replaced
4
+ * by the engine's openShell session.
5
+ */
6
+
7
+ import { PassThrough } from 'node:stream'
8
+ import type { SshEngine } from '../ssh/engine.ts'
9
+ import type {
10
+ SubprocessOutcome,
11
+ SubprocessTerminalForeground,
12
+ SubprocessTerminalHandle,
13
+ SubprocessTerminalSignal,
14
+ SubprocessTerminalSpawnSpec,
15
+ } from '@deepseek-ai/dsh-subprocess'
16
+ import type { WorkspaceState } from '../protocol.ts'
17
+ import { quoteShellArg, readScrubbedRemoteEnvironment, serializeEnvironment } from './environment.ts'
18
+
19
+ /** Resolve after one duration. */
20
+ function delay(ms: number): Promise<void> {
21
+ return new Promise(resolve => setTimeout(resolve, ms))
22
+ }
23
+
24
+ /** One SSH PTY and its remote login shell, projected onto the subprocess terminal seam. */
25
+ export class SshTerminalHandle implements SubprocessTerminalHandle {
26
+ readonly pid = -1
27
+ readonly output = new PassThrough()
28
+ readonly done: Promise<SubprocessOutcome>
29
+
30
+ private exitResolve!: (outcome: SubprocessOutcome) => void
31
+ topLevelExited = false
32
+ private cleanup: Promise<void> | undefined
33
+
34
+ constructor(
35
+ private readonly closeChannel: () => void,
36
+ private readonly sendChannel: (data: string) => void,
37
+ private readonly signalChannel: (name: string) => void,
38
+ private readonly graceMs: number,
39
+ ) {
40
+ this.done = new Promise<SubprocessOutcome>((resolve) => { this.exitResolve = resolve })
41
+ }
42
+
43
+ /** Settle the exit promise (called by the runtime when the channel closes). */
44
+ resolveExit(outcome: SubprocessOutcome): void {
45
+ this.exitResolve(outcome)
46
+ }
47
+
48
+ /** @inheritdoc */
49
+ write(data: string): Promise<void> {
50
+ if (this.topLevelExited) return Promise.reject(new Error('terminal process has exited'))
51
+ return new Promise<void>((resolve, reject) => {
52
+ try {
53
+ this.sendChannel(data)
54
+ resolve()
55
+ } catch (error) {
56
+ reject(error instanceof Error ? error : new Error(String(error)))
57
+ }
58
+ })
59
+ }
60
+
61
+ /** The SSH channel does not expose a foreground process group. */
62
+ inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
63
+ return Promise.resolve(undefined)
64
+ }
65
+
66
+ /** @inheritdoc */
67
+ signalForeground(_signal: SubprocessTerminalSignal): Promise<number> {
68
+ return Promise.reject(new Error('subprocess-ssh: cannot resolve the foreground process group over an SSH channel'))
69
+ }
70
+
71
+ /** @inheritdoc */
72
+ terminate(): Promise<void> {
73
+ if (this.cleanup !== undefined) return this.cleanup
74
+ const cleanup = this.closeOnce()
75
+ this.cleanup = cleanup
76
+ void cleanup.catch(() => { this.cleanup = undefined })
77
+ return cleanup
78
+ }
79
+
80
+ private signal(name: string): void {
81
+ try {
82
+ this.signalChannel(name)
83
+ } catch {
84
+ // The channel closed before the signal could be delivered.
85
+ }
86
+ }
87
+
88
+ private async closeOnce(): Promise<void> {
89
+ this.signal('TERM')
90
+ await Promise.race([this.done.then(() => undefined, () => undefined), delay(this.graceMs)])
91
+ if (!this.topLevelExited) this.signal('KILL')
92
+ await Promise.race([this.done.then(() => undefined, () => undefined), delay(this.graceMs)])
93
+ if (!this.topLevelExited) {
94
+ this.closeChannel()
95
+ throw new Error('subprocess-ssh: terminal cleanup failed; channel still open')
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Allocate an SSH PTY, replace its login shell with the requested argv, and
102
+ * return the live terminal handle.
103
+ */
104
+ export async function spawnSshTerminal(
105
+ engine: SshEngine,
106
+ getState: () => WorkspaceState,
107
+ spec: SubprocessTerminalSpawnSpec,
108
+ ): Promise<SshTerminalHandle> {
109
+ spec.signal?.throwIfAborted()
110
+ const program = spec.argv[0]
111
+ if (program === undefined || program.length === 0) {
112
+ throw new Error('subprocess-ssh: terminal argv must contain a program')
113
+ }
114
+ const state = getState()
115
+ if (state.mode !== 'remote' || state.alias === undefined) {
116
+ throw new Error('subprocess-ssh: not in remote mode — switch the GUI to SSH mode first')
117
+ }
118
+ const environment = serializeEnvironment(await readScrubbedRemoteEnvironment(engine, state.alias), spec.env)
119
+ const session = await engine.openShell(state.alias, { cols: spec.cols, rows: spec.rows })
120
+ const root = state.remoteRoot
121
+ if (root === undefined) throw new Error('subprocess-ssh: remote workspace root is not set')
122
+ const cwd = spec.cwd !== undefined && spec.cwd.startsWith('/') ? spec.cwd : root
123
+ const handle = new SshTerminalHandle(
124
+ () => session.close(),
125
+ (data) => session.send(data),
126
+ (name) => session.signal(name),
127
+ spec.graceMs,
128
+ )
129
+ session.onData = (data: Buffer) => {
130
+ if (!handle.output.destroyed) handle.output.write(data)
131
+ }
132
+ session.onExit = (code: number | null, error?: string) => {
133
+ handle.topLevelExited = true
134
+ handle.output.end()
135
+ handle.resolveExit({ exitCode: error !== undefined ? null : code, signal: null })
136
+ }
137
+ const argv = spec.argv.map(quoteShellArg).join(' ')
138
+ await handle.write(`cd ${quoteShellArg(cwd)} && exec env -i -- ${environment} ${argv}\r`)
139
+ spec.signal?.throwIfAborted()
140
+ return handle
141
+ }