@tiphareth/dsh-hardssh 0.1.2 → 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.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- 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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
private
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
this.
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
private
|
|
128
|
-
if (this.settled) return
|
|
129
|
-
this.
|
|
130
|
-
if (this.
|
|
131
|
-
this.graceTimer =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
this.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
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.
|
|
188
|
+
if (this.closePromise !== undefined) {
|
|
146
189
|
await terminal.terminate()
|
|
147
190
|
throw new Error('subprocess-ssh: service disposed during terminal setup')
|
|
148
191
|
}
|