@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.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- 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
|
+
}
|