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