dsh-remote 0.8.35 → 0.8.36
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.en.md +70 -1
- package/README.md +59 -1
- package/lib/client.js +238 -0
- package/lib/http-transport.js +93 -1
- package/lib/index.js +380 -0
- package/lib/pool.js +9 -1
- package/lib/web-attach.js +467 -0
- package/lib/web-deploy-skill.js +205 -0
- package/lib/web-deploy.js +652 -0
- package/package.json +1 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// dsh-remote — "remote DSH web UI" attach sessions (issue #46).
|
|
2
|
+
//
|
|
3
|
+
// The user's intent behind that issue is "use the local DSH as a CLIENT for a
|
|
4
|
+
// DSH running on another machine": they do NOT want the remote to expose an
|
|
5
|
+
// inbound listener. DSH makes that impossible anyway — `dsh-web-app` hard-
|
|
6
|
+
// rejects `--host 0.0.0.0` (startup.js) because it would put remote code
|
|
7
|
+
// execution on the network, and `dsh-host-webserver` only accepts the
|
|
8
|
+
// 127.0.0.1/0.0.0.0 literals.
|
|
9
|
+
//
|
|
10
|
+
// So this module inverts the direction, exactly like the rest of the plugin:
|
|
11
|
+
// we SSH OUT, start (or reuse) a loopback-bound `dsh web` on the remote, and
|
|
12
|
+
// carry its socket back over our own SSH connection with a local TCP forward.
|
|
13
|
+
// The browser then talks to 127.0.0.1:<localPort> on this machine.
|
|
14
|
+
//
|
|
15
|
+
// Verified end-to-end on a real Linux host (dsh 0.1.5-rc.2): a bare TCP forward
|
|
16
|
+
// is sufficient — the SPA, its assets, AND the `/api/remote.mux` WebSocket
|
|
17
|
+
// upgrade all transit it (HTTP/1.1 101), with no reverse proxy and no host
|
|
18
|
+
// rewriting. That works because DSH binds its auth cookie to the request
|
|
19
|
+
// authority, so a cookie minted against `127.0.0.1:<localPort>` is exactly what
|
|
20
|
+
// the browser then sends back to that same authority.
|
|
21
|
+
//
|
|
22
|
+
// Three details drive the design and are worth stating once:
|
|
23
|
+
//
|
|
24
|
+
// 1. The launch token CANNOT be read from anywhere but stdout. It is a
|
|
25
|
+
// `randomBytes` value held in a process-local WeakMap and printed on one
|
|
26
|
+
// line (`dsh web: http://127.0.0.1:<port>/?token=…`). No file, no env var.
|
|
27
|
+
// Therefore the token is captured by redirecting the remote process's
|
|
28
|
+
// output to a log file and parsing that line out of it.
|
|
29
|
+
// 2. `--port 0` lets the remote OS pick a free port, and the same printed line
|
|
30
|
+
// reports which port it chose — so we never have to guess or probe for a
|
|
31
|
+
// free port on the remote.
|
|
32
|
+
// 3. The pairing that must survive is (token, port) from ONE launch. Reading
|
|
33
|
+
// them from one line is what keeps them consistent.
|
|
34
|
+
//
|
|
35
|
+
// Everything here is read-only with respect to the remote filesystem except
|
|
36
|
+
// the log file it creates under the remote `$HOME`.
|
|
37
|
+
import net from 'node:net'
|
|
38
|
+
|
|
39
|
+
/** Remote log file per attach session; `$HOME` is expanded by the remote shell. */
|
|
40
|
+
const LOG_NAME = '.dsh-remote-web'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the `dsh web:` startup line out of a remote log.
|
|
44
|
+
*
|
|
45
|
+
* The line looks like:
|
|
46
|
+
* dsh web: http://127.0.0.1:43129/?token=Iy7FKuPSI5qJKJUFDyah4B6i8Ul6_hvspiJhiWufYt8
|
|
47
|
+
* and may be followed by a LAN suffix when the remote bound 0.0.0.0 (it never
|
|
48
|
+
* will here — we never pass `--host` — but tolerate it rather than break).
|
|
49
|
+
*
|
|
50
|
+
* @param {string} text - the remote log contents.
|
|
51
|
+
* @returns {{url: string, port: number, token: string}|null} parsed facts, or
|
|
52
|
+
* null when the line has not appeared yet (still booting) or is malformed.
|
|
53
|
+
*/
|
|
54
|
+
export function parseWebStart(text) {
|
|
55
|
+
if (typeof text !== 'string' || text === '') return null
|
|
56
|
+
// Take the LAST match: a reused log file may hold an earlier session's line,
|
|
57
|
+
// and the newest one is the one this launch produced.
|
|
58
|
+
const matches = [...text.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
59
|
+
if (matches.length === 0) return null
|
|
60
|
+
const url = matches[matches.length - 1][1]
|
|
61
|
+
let parsed
|
|
62
|
+
try {
|
|
63
|
+
parsed = new URL(url)
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const port = Number(parsed.port)
|
|
68
|
+
const token = parsed.searchParams.get('token') || ''
|
|
69
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
70
|
+
if (!token) return null
|
|
71
|
+
return { url, port, token }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when the log proves the remote process died instead of booting. */
|
|
75
|
+
export function launchFailed(text) {
|
|
76
|
+
return typeof text === 'string' && text.includes(DEAD_MARKER)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Marker the remote launcher echoes when the process exits before printing. */
|
|
80
|
+
const DEAD_MARKER = '__DSH_REMOTE_DIED__'
|
|
81
|
+
/** Marker separating the parsed line from the raw log tail for diagnostics. */
|
|
82
|
+
export const TAIL_MARKER = '__DSH_TAIL__'
|
|
83
|
+
/** Marker carrying the remote PID so the session can stop its own process. */
|
|
84
|
+
export const PID_MARKER = '__DSH_PID__='
|
|
85
|
+
/** Random ASCII tag so two attach sessions never share a remote log file. */
|
|
86
|
+
function randomTag() {
|
|
87
|
+
return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the one-shot remote command that launches `dsh web` detached and waits
|
|
92
|
+
* for its startup line.
|
|
93
|
+
*
|
|
94
|
+
* `setsid` + `nohup` + redirected stdio are all required: the SSH channel closes
|
|
95
|
+
* as soon as this shell exits, and without detaching the SIGHUP would kill the
|
|
96
|
+
* server we just started. The loop is bounded so a wedged remote cannot hang the
|
|
97
|
+
* call past the caller's own timeout, and it reports either the parsed line or
|
|
98
|
+
* the log tail — never silence.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} spec - launch parameters.
|
|
101
|
+
* @param {boolean} [spec.omitNoOpen] - drop `--no-open`. Older DSH (measured on
|
|
102
|
+
* 0.1.0-rc.6) has no such flag and exits with "unknown option '--no-open'";
|
|
103
|
+
* the caller retries with this set rather than assuming a flag exists.
|
|
104
|
+
* @returns {string} a POSIX shell command line (no newlines, ASCII only).
|
|
105
|
+
*/
|
|
106
|
+
export function buildLaunchCommand({ command, profile, logPath, remotePort = 0, dshHome = '', waitSeconds = 45, omitNoOpen = false }) {
|
|
107
|
+
const env = dshHome ? `DSH_HOME="${dshHome}" ` : ''
|
|
108
|
+
const noOpen = omitNoOpen ? '' : ' --no-open'
|
|
109
|
+
const args = `--profile ${profile} --port ${Number(remotePort) || 0}${noOpen}`
|
|
110
|
+
const limit = Number(waitSeconds) || 45
|
|
111
|
+
// The loop is ONE shell element: `do` is a keyword and `do;` is a syntax error
|
|
112
|
+
// in POSIX sh, so it must be glued to the first command of the body rather
|
|
113
|
+
// than separated like the other statements.
|
|
114
|
+
const loop = `while [ $i -lt ${limit} ]; do grep -q 'token=' "$LOG" 2>/dev/null && break`
|
|
115
|
+
+ `; kill -0 $P 2>/dev/null || { echo ${DEAD_MARKER}; break; }`
|
|
116
|
+
+ `; sleep 1; i=$((i+1)); done`
|
|
117
|
+
return [
|
|
118
|
+
`LOG="$HOME/${logPath}"`,
|
|
119
|
+
`: > "$LOG"`,
|
|
120
|
+
// `command -v setsid` guards hosts without util-linux (macOS); plain nohup
|
|
121
|
+
// still detaches from the channel's HUP there.
|
|
122
|
+
`command -v setsid >/dev/null 2>&1 && SET=setsid || SET=`,
|
|
123
|
+
`${env}$SET nohup ${command} ${args} >"$LOG" 2>&1 </dev/null & P=$!`,
|
|
124
|
+
`i=0`,
|
|
125
|
+
loop,
|
|
126
|
+
`echo "${PID_MARKER}$P"`,
|
|
127
|
+
`grep -m1 'dsh web:' "$LOG" 2>/dev/null`,
|
|
128
|
+
`echo ${TAIL_MARKER}`,
|
|
129
|
+
`tail -20 "$LOG" 2>/dev/null`,
|
|
130
|
+
].join('; ')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extract the remote PID from launcher output (0 when absent). */
|
|
134
|
+
export function parseRemotePid(output) {
|
|
135
|
+
const m = /__DSH_PID__=(\d+)/.exec(String(output || ''))
|
|
136
|
+
return m ? Number(m[1]) : 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Recognize a known remote-side reason the web surface could not start, and
|
|
141
|
+
* return an actionable sentence for it.
|
|
142
|
+
*
|
|
143
|
+
* The point is to keep an operator from reading "the remote DSH did not report a
|
|
144
|
+
* startup token" and concluding the *plugin* is broken. Measured cause: dsh
|
|
145
|
+
* ≤ 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published tarball ships
|
|
146
|
+
* darwin/win32 prebuilds but **no `linux-x64`**, so `dsh web` cannot boot on
|
|
147
|
+
* Linux at all — it dies loading the native module. 0.1.5-rc.2 moved to
|
|
148
|
+
* `node-pty@1.2.0-beta.15`, which does ship Linux builds.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} output - the remote log tail.
|
|
151
|
+
* @returns {string} a hint, or '' when nothing is recognized.
|
|
152
|
+
*/
|
|
153
|
+
export function explainLaunchFailure(output) {
|
|
154
|
+
const text = String(output || '')
|
|
155
|
+
if (/pty\.node|Failed to load native module/i.test(text)) {
|
|
156
|
+
return ' The remote could not load node-pty\'s native module. Known cause: dsh <= 0.1.0-rc.6'
|
|
157
|
+
+ ' depends on node-pty@1.1.0, which publishes no linux-x64 prebuild, so `dsh web` cannot start'
|
|
158
|
+
+ ' on Linux at all. Upgrade the remote dsh to 0.1.5-rc.2 or newer (node-pty@1.2.0-beta.15 ships'
|
|
159
|
+
+ ' the Linux build), or point `webAttachCommand` at a newer dsh installed elsewhere.'
|
|
160
|
+
}
|
|
161
|
+
if (/is intentionally not supported|--host 0\.0\.0\.0/i.test(text)) {
|
|
162
|
+
return ' The remote dsh refused an all-interfaces bind. Attach deliberately never passes `--host`;'
|
|
163
|
+
+ ' check `webAttachCommand` does not add one.'
|
|
164
|
+
}
|
|
165
|
+
if (/command not found|No such file or directory|not found/i.test(text)) {
|
|
166
|
+
return ' The remote shell could not run the dsh command. If the remote dsh is not on the SSH login'
|
|
167
|
+
+ ' PATH (a version manager or private prefix), set `webAttachCommand` to its absolute path.'
|
|
168
|
+
}
|
|
169
|
+
return ''
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the command that stops the remote process THIS session started.
|
|
174
|
+
*
|
|
175
|
+
* Two things make this less obvious than it looks, both found by running it
|
|
176
|
+
* against a real host:
|
|
177
|
+
* 1. The log path cannot identify the process — it appears only as a shell
|
|
178
|
+
* redirect, so it is absent from argv and `pkill -f <logfile>` matches
|
|
179
|
+
* nothing (the process survived, verified live).
|
|
180
|
+
* 2. The PORT cannot identify it either, because we deliberately launch with
|
|
181
|
+
* `--port 0` (the remote picks a free port, and the chosen number is only
|
|
182
|
+
* known afterwards). A `--port 0` substring match therefore never fires.
|
|
183
|
+
*
|
|
184
|
+
* So the recorded PID is the handle, and the kill targets its process GROUP
|
|
185
|
+
* (`kill -- -PID`): the launch goes through `setsid`, making the child a
|
|
186
|
+
* session/group leader whose PGID equals its PID, so the group contains exactly
|
|
187
|
+
* the process tree we started — never a sibling attach session and never the
|
|
188
|
+
* user's own DSH. Before signalling, the command re-verifies that the PID still
|
|
189
|
+
* exists and still looks like a `dsh` process, so a recycled PID belonging to an
|
|
190
|
+
* unrelated program is not killed.
|
|
191
|
+
*
|
|
192
|
+
* @param {number} pid - the PID reported by the launch.
|
|
193
|
+
* @param {string} [logPath] - this session's log file, removed after the kill so
|
|
194
|
+
* repeated attaches do not accumulate files in the remote home.
|
|
195
|
+
* @returns {string} a POSIX shell command (always exits 0, never throws).
|
|
196
|
+
*/
|
|
197
|
+
export function buildStopCommand(pid, logPath = '') {
|
|
198
|
+
const p = Number(pid) || 0
|
|
199
|
+
const cleanup = logPath ? `; rm -f "$HOME/${logPath}" 2>/dev/null` : ''
|
|
200
|
+
if (p < 1) return `true${cleanup}`
|
|
201
|
+
return `P=${p}`
|
|
202
|
+
+ `; if [ -d "/proc/$P" ]; then`
|
|
203
|
+
+ ` C=$(tr '\\0' ' ' < "/proc/$P/cmdline" 2>/dev/null);`
|
|
204
|
+
+ ` case "$C" in *dsh*) kill -- -"$P" 2>/dev/null || kill "$P" 2>/dev/null;; esac;`
|
|
205
|
+
+ ` fi${cleanup}; true`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* One attached remote DSH Web UI: a local listener whose sockets are carried
|
|
210
|
+
* over SSH to the remote's loopback web port.
|
|
211
|
+
*
|
|
212
|
+
* The listener is a plain `net.Server` + `client.forwardOut` pair — the same
|
|
213
|
+
* mechanism `ForwardManager` uses for user-defined forwards. It is implemented
|
|
214
|
+
* here rather than through that manager because this tunnel is bound to a
|
|
215
|
+
* CHOSEN pool (the machine the user picked), whereas `ForwardManager` is wired
|
|
216
|
+
* to the single "active machine" pool; routing an attach through it would send
|
|
217
|
+
* the tunnel to whichever machine happened to be active.
|
|
218
|
+
*/
|
|
219
|
+
export class WebAttach {
|
|
220
|
+
/**
|
|
221
|
+
* @param {object} opts - injected collaborators (all optional in production).
|
|
222
|
+
* @param {object} [opts.net] - a `node:net`-compatible module (tests).
|
|
223
|
+
* @param {number} [opts.localPortStart] - first local port to try.
|
|
224
|
+
* @param {number} [opts.localPortMax] - how many ports to try before failing.
|
|
225
|
+
*/
|
|
226
|
+
constructor(opts = {}) {
|
|
227
|
+
this.net = opts.net || net
|
|
228
|
+
this.localPortStart = opts.localPortStart || 3088
|
|
229
|
+
this.localPortMax = opts.localPortMax || 50
|
|
230
|
+
/** @type {import('node:net').Server|null} */ this.server = null
|
|
231
|
+
/** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
|
|
232
|
+
/** @type {object|null} */ this.client = null
|
|
233
|
+
this.localPort = 0
|
|
234
|
+
this.remotePort = 0
|
|
235
|
+
this.token = ''
|
|
236
|
+
this.tag = ''
|
|
237
|
+
this.logPath = ''
|
|
238
|
+
this.remotePid = 0
|
|
239
|
+
this.machineId = ''
|
|
240
|
+
this.command = ''
|
|
241
|
+
this.startedAt = 0
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Whether the tunnel is currently listening. */
|
|
245
|
+
get active() {
|
|
246
|
+
return this.server !== null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Attach to a remote DSH Web UI.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} spec - the attach request.
|
|
253
|
+
* @param {object} spec.pool - the SshPool for the chosen machine.
|
|
254
|
+
* @param {string} spec.machineId - machine id, for bookkeeping.
|
|
255
|
+
* @param {string} [spec.command] - remote dsh executable (default `dsh`).
|
|
256
|
+
* @param {string} [spec.profile] - DSH profile to boot (default `web`).
|
|
257
|
+
* @param {string} [spec.existingUrl] - attach to an ALREADY running remote
|
|
258
|
+
* web UI instead of starting one: its `http://127.0.0.1:<port>/?token=…`.
|
|
259
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string, code: number}>} [spec.exec]
|
|
260
|
+
* - remote exec, defaulting to `pool.exec`.
|
|
261
|
+
* @returns {Promise<{url: string, localPort: number, remotePort: number}>}
|
|
262
|
+
*/
|
|
263
|
+
async open(spec) {
|
|
264
|
+
const {
|
|
265
|
+
pool, machineId = '', command = 'dsh', profile = 'web',
|
|
266
|
+
existingUrl = '', dshHome = '', waitSeconds = 45,
|
|
267
|
+
} = spec || {}
|
|
268
|
+
if (!pool) throw new Error('web-attach: a machine pool is required')
|
|
269
|
+
this.machineId = String(machineId || '')
|
|
270
|
+
this.command = command
|
|
271
|
+
|
|
272
|
+
const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
|
|
273
|
+
// Connecting first also surfaces credential/host-key problems BEFORE we
|
|
274
|
+
// start a remote process we would then have to clean up.
|
|
275
|
+
this.client = await pool.connect()
|
|
276
|
+
|
|
277
|
+
if (existingUrl) {
|
|
278
|
+
const parsed = parseWebStart(`dsh web: ${existingUrl}`)
|
|
279
|
+
if (!parsed) {
|
|
280
|
+
throw new Error('web-attach: existing URL must look like http://127.0.0.1:<port>/?token=<token>')
|
|
281
|
+
}
|
|
282
|
+
this.remotePort = parsed.port
|
|
283
|
+
this.token = parsed.token
|
|
284
|
+
} else {
|
|
285
|
+
this.tag = randomTag()
|
|
286
|
+
const logPath = `${LOG_NAME}-${this.tag}.log`
|
|
287
|
+
this.logPath = logPath
|
|
288
|
+
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
289
|
+
let out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, false))
|
|
290
|
+
// Older DSH (0.1.0-rc.6) has no `--no-open` and exits on the unknown flag
|
|
291
|
+
// before doing anything. Retry once without it: `--no-open` is a nicety
|
|
292
|
+
// (it stops the REMOTE from opening its own browser), not a requirement,
|
|
293
|
+
// and a bounded single retry is safe because the first attempt provably
|
|
294
|
+
// never started a server.
|
|
295
|
+
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
296
|
+
out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, true))
|
|
297
|
+
this.omittedNoOpen = true
|
|
298
|
+
}
|
|
299
|
+
this.remotePid = parseRemotePid(out)
|
|
300
|
+
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
301
|
+
// Both failure shapes carry the same diagnostics: the process either died
|
|
302
|
+
// (its output is the whole story) or is still booting after the deadline.
|
|
303
|
+
if (launchFailed(out)) {
|
|
304
|
+
throw new Error('web-attach: the remote DSH exited during startup.'
|
|
305
|
+
+ explainLaunchFailure(tail || out)
|
|
306
|
+
+ ` ${tail || 'no output'}`)
|
|
307
|
+
}
|
|
308
|
+
const parsed = parseWebStart(out)
|
|
309
|
+
if (!parsed) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
|
|
312
|
+
+ ` Check that \`${command}\` exists on the remote and can boot a web profile.`
|
|
313
|
+
+ explainLaunchFailure(tail || out)
|
|
314
|
+
+ (tail ? ` Remote output: ${tail}` : ''),
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
this.remotePort = parsed.port
|
|
318
|
+
this.token = parsed.token
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await this._listen()
|
|
322
|
+
this.startedAt = Date.now()
|
|
323
|
+
return this.describe()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Run one launch attempt and return its raw output.
|
|
328
|
+
*
|
|
329
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
330
|
+
* @param {object} d - launch parameters plus the exec timeout.
|
|
331
|
+
* @param {boolean} omitNoOpen - drop `--no-open` for old DSH.
|
|
332
|
+
* @returns {Promise<string>} the launcher's stdout.
|
|
333
|
+
*/
|
|
334
|
+
async _launch(exec, d, omitNoOpen) {
|
|
335
|
+
const cmd = buildLaunchCommand({ ...d, omitNoOpen })
|
|
336
|
+
const res = await exec(cmd, { timeoutMs: d.timeoutMs })
|
|
337
|
+
return String(res && res.stdout ? res.stdout : '')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Bind the local listener, advancing past ports already in use. */
|
|
341
|
+
async _listen() {
|
|
342
|
+
let lastError = null
|
|
343
|
+
for (let i = 0; i < this.localPortMax; i++) {
|
|
344
|
+
const port = this.localPortStart + i
|
|
345
|
+
try {
|
|
346
|
+
await this._listenOn(port)
|
|
347
|
+
this.localPort = port
|
|
348
|
+
return
|
|
349
|
+
} catch (err) {
|
|
350
|
+
lastError = err
|
|
351
|
+
// Only a taken port is worth retrying; anything else (permissions,
|
|
352
|
+
// an unusable interface) will not be fixed by the next number.
|
|
353
|
+
if (err && err.code !== 'EADDRINUSE') break
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
throw new Error(
|
|
357
|
+
`web-attach: no free local port in ${this.localPortStart}..${this.localPortStart + this.localPortMax - 1}`
|
|
358
|
+
+ (lastError ? ` (${lastError.code || lastError.message})` : ''),
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_listenOn(port) {
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const server = this.net.createServer((socket) => this._pipe(socket))
|
|
365
|
+
const onError = (err) => {
|
|
366
|
+
server.removeListener('listening', onListening)
|
|
367
|
+
try { server.close() } catch { /* never listened */ }
|
|
368
|
+
reject(err)
|
|
369
|
+
}
|
|
370
|
+
const onListening = () => {
|
|
371
|
+
server.removeListener('error', onError)
|
|
372
|
+
// A post-bind error must not crash the host process: report and drop
|
|
373
|
+
// the tunnel so the UI can show "disconnected" instead of dying.
|
|
374
|
+
server.on('error', (err) => {
|
|
375
|
+
this.server = null
|
|
376
|
+
this.lastError = String((err && err.message) || err)
|
|
377
|
+
})
|
|
378
|
+
this.server = server
|
|
379
|
+
resolve()
|
|
380
|
+
}
|
|
381
|
+
server.once('error', onError)
|
|
382
|
+
server.once('listening', onListening)
|
|
383
|
+
// Loopback only: this endpoint reaches a full remote harness, so it must
|
|
384
|
+
// never be reachable from the network even on a LAN-facing machine.
|
|
385
|
+
server.listen(port, '127.0.0.1')
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Carry one local socket to the remote web port over SSH. */
|
|
390
|
+
_pipe(socket) {
|
|
391
|
+
if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
392
|
+
this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
|
|
393
|
+
if (err) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
394
|
+
this.sockets.add(socket)
|
|
395
|
+
socket.pipe(channel)
|
|
396
|
+
channel.pipe(socket)
|
|
397
|
+
const kill = () => {
|
|
398
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
399
|
+
try { channel.close() } catch { /* already closed */ }
|
|
400
|
+
this.sockets.delete(socket)
|
|
401
|
+
}
|
|
402
|
+
socket.on('error', kill)
|
|
403
|
+
channel.on('error', kill)
|
|
404
|
+
socket.on('close', kill)
|
|
405
|
+
channel.on('close', kill)
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The URL to open, carrying the one-time launch token.
|
|
411
|
+
*
|
|
412
|
+
* The token is a credential, so it stays on loopback and is never sent
|
|
413
|
+
* anywhere; DSH exchanges it for a 30-day signed cookie on the first request
|
|
414
|
+
* and redirects to a clean `/`.
|
|
415
|
+
*/
|
|
416
|
+
get url() {
|
|
417
|
+
return `http://127.0.0.1:${this.localPort}/?token=${this.token}`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The clean URL (no token) — what the address bar shows after the exchange. */
|
|
421
|
+
get cleanUrl() {
|
|
422
|
+
return `http://127.0.0.1:${this.localPort}/`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Serializable status for the settings UI. */
|
|
426
|
+
describe() {
|
|
427
|
+
return {
|
|
428
|
+
machineId: this.machineId,
|
|
429
|
+
url: this.url,
|
|
430
|
+
cleanUrl: this.cleanUrl,
|
|
431
|
+
token: this.token,
|
|
432
|
+
localPort: this.localPort,
|
|
433
|
+
remotePort: this.remotePort,
|
|
434
|
+
active: this.active,
|
|
435
|
+
startedAt: this.startedAt,
|
|
436
|
+
// Only a process we launched can be stopped by us; an attach onto the
|
|
437
|
+
// user's own running instance is deliberately hands-off.
|
|
438
|
+
managed: this.remotePid > 0,
|
|
439
|
+
error: this.lastError || '',
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Tear the tunnel down. The remote process is left running by default: the
|
|
445
|
+
* user may be using that DSH elsewhere, and silently killing a process we did
|
|
446
|
+
* not start is worse than a leftover one. `stopRemote` opts in.
|
|
447
|
+
*
|
|
448
|
+
* @param {object} [opts] - close options.
|
|
449
|
+
* @param {boolean} [opts.stopRemote] - also terminate the remote process.
|
|
450
|
+
* @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
|
|
451
|
+
*/
|
|
452
|
+
async close({ stopRemote = false, exec } = {}) {
|
|
453
|
+
for (const socket of [...this.sockets]) {
|
|
454
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
455
|
+
}
|
|
456
|
+
this.sockets.clear()
|
|
457
|
+
const server = this.server
|
|
458
|
+
this.server = null
|
|
459
|
+
if (server) await new Promise((resolve) => server.close(() => resolve()))
|
|
460
|
+
// Only a process we started has a recorded PID; an attach to the user's own
|
|
461
|
+
// running instance must never signal anything.
|
|
462
|
+
if (stopRemote && exec && this.remotePid) {
|
|
463
|
+
await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
|
|
464
|
+
}
|
|
465
|
+
this.client = null
|
|
466
|
+
}
|
|
467
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// dsh-remote — built-in agent skill for remote DSH deployment (issue #46, P3).
|
|
2
|
+
//
|
|
3
|
+
// Layer 1 (`lib/web-deploy.js`) handles the deterministic path: probe the
|
|
4
|
+
// remote, install to a private prefix, verify, attach. This module is the
|
|
5
|
+
// ESCAPE HATCH for everything that path cannot express — a missing package
|
|
6
|
+
// manager, a proxy, sudo-only systems, an internal mirror, a Windows remote, or
|
|
7
|
+
// a failure whose message does not match any known signature.
|
|
8
|
+
//
|
|
9
|
+
// It is a skill rather than more code because the value here is JUDGEMENT over
|
|
10
|
+
// arbitrary output, and because there is no closed set of remote environments to
|
|
11
|
+
// enumerate. The skill body is therefore written as a diagnosis-first playbook:
|
|
12
|
+
// the symptoms actually measured while building issue #46 come first, because
|
|
13
|
+
// those are the ones that waste the most time.
|
|
14
|
+
//
|
|
15
|
+
// Two deliberate properties:
|
|
16
|
+
//
|
|
17
|
+
// 1. **The skill is registered at runtime, not shipped as a file.** The plugin
|
|
18
|
+
// is a single npm package; a filesystem skill would need a writable
|
|
19
|
+
// `$DSH_HOME/skills` and would collide with whatever the user keeps there.
|
|
20
|
+
// `ctx.skills.register()` keeps it self-contained and namespaced.
|
|
21
|
+
// 2. **It is user-invocable and model-invocable.** The settings button starts a
|
|
22
|
+
// session that already has the skill selected; the model may also reach for
|
|
23
|
+
// it on its own when an `rw_*` call fails with a deployment-shaped error.
|
|
24
|
+
import { DEFAULT_INSTALL_VERSION, DEFAULT_PREFIX_SUFFIX } from './web-deploy.js'
|
|
25
|
+
|
|
26
|
+
/** Stable skill name; kebab-case is required by the registry. */
|
|
27
|
+
export const SKILL_NAME = 'dsh-remote-deploy'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The skill body.
|
|
31
|
+
*
|
|
32
|
+
* Written for an agent that already has this plugin's `rw_*` tools. It is
|
|
33
|
+
* intentionally explicit about the two failure signatures measured on a real
|
|
34
|
+
* host, because a generic "install it" instruction would have been useless for
|
|
35
|
+
* both of them.
|
|
36
|
+
*/
|
|
37
|
+
export const SKILL_BODY = `# 在远端机器部署并验证 DSH
|
|
38
|
+
|
|
39
|
+
用途:用户想连一台机器的 DSH 网页界面(\`dsh web\`),但连不上。你负责把环境修好并**证明**
|
|
40
|
+
它真的能用。确定性流程(探测/私有前缀安装/校验)已经由插件的「部署并验证」按钮实现;
|
|
41
|
+
你只在它做不到或失败时才被叫来。
|
|
42
|
+
|
|
43
|
+
## 先用工具,不要一上来就手工
|
|
44
|
+
|
|
45
|
+
**第一件事:调 \`rw_deploy_probe\`。** 它是只读的,给出结构化体检:平台/架构、node/npm、
|
|
46
|
+
现有 dsh 及版本、**原生模块能否启动**、是否认识 \`--no-open\`、Git Bash、代理与 npm 源、
|
|
47
|
+
安装前缀可写性,以及判定结论(blocker/warn/info)与修复建议。先读它的结论,不要自己拼命令猜。
|
|
48
|
+
|
|
49
|
+
它**只读、不安装**(刻意的)。要装就用设置页的「部署并验证」卡片——它装到私有前缀并逐步校验。
|
|
50
|
+
你在这里的价值是处理那条路径覆盖不到的情况(见下文的长尾)。真需要自己安装时照本文流程做,
|
|
51
|
+
并在结论里说明为什么自动路径不适用。
|
|
52
|
+
|
|
53
|
+
## 两个已实测的失败签名(最重要)
|
|
54
|
+
|
|
55
|
+
### 1. \`Failed to load native module: pty.node\`
|
|
56
|
+
|
|
57
|
+
**根因**:dsh 依赖 node-pty,而某些版本发布的包里**没有当前平台的预编译产物**。
|
|
58
|
+
实测:\`node-pty@1.1.0\`(dsh 0.1.0-rc.6 依赖它)只带 darwin/win32 的 prebuild,
|
|
59
|
+
**没有 linux-x64**,所以 \`dsh web\` 在 Linux 上**根本起不来**。
|
|
60
|
+
\`node-pty@1.2.0-beta.15\`(dsh 0.1.5-rc.2 起)带 Linux 产物。
|
|
61
|
+
|
|
62
|
+
**判断**:\`ls <dsh根>/node_modules/node-pty/prebuilds/\` —— 少了 \`linux-x64\` 就是这个病。
|
|
63
|
+
**修复**:装 \`@deepseek-ai/dsh@${DEFAULT_INSTALL_VERSION}\` 或更新到私有前缀(见下)。
|
|
64
|
+
|
|
65
|
+
### 2. \`error: unknown option '--no-open'\`
|
|
66
|
+
|
|
67
|
+
**根因**:\`--no-open\` 是较新版本的 flag;老版本(实测 0.1.0-rc.6)不认识它,直接退出。
|
|
68
|
+
**修复**:插件会自动去掉该 flag 重试一次,通常**不需要你处理**。只有当你手工拼命令时才注意。
|
|
69
|
+
|
|
70
|
+
### 不是部署问题的报错(别修错方向)
|
|
71
|
+
|
|
72
|
+
- \`NO_ADAPTER: no adapter registered for provider ...\` / \`no API key for provider route\`
|
|
73
|
+
⇒ 是**宿主模型/provider 配置**问题,与远端部署无关。
|
|
74
|
+
- \`NO_COOKIE\` / 401 ⇒ 登录态问题。
|
|
75
|
+
- \`QUOTA\` / \`Insufficient Balance\` ⇒ 额度问题。
|
|
76
|
+
|
|
77
|
+
## 标准部署流程(私有前缀,绝不污染用户环境)
|
|
78
|
+
|
|
79
|
+
**原则**:默认**不要** \`npm i -g\`。全局安装需要系统目录写权限、改用户 PATH、
|
|
80
|
+
还可能覆盖用户正在用的版本。用私有前缀:
|
|
81
|
+
|
|
82
|
+
\`\`\`bash
|
|
83
|
+
PREFIX="$HOME/${DEFAULT_PREFIX_SUFFIX}"
|
|
84
|
+
mkdir -p "$PREFIX"
|
|
85
|
+
printf '%s\\n' '{"name":"dsh-remote-install","private":true,"version":"0.0.0"}' > "$PREFIX/package.json"
|
|
86
|
+
cd "$PREFIX" && npm install --prefix "$PREFIX" --no-audit --no-fund \\
|
|
87
|
+
'@deepseek-ai/dsh@${DEFAULT_INSTALL_VERSION}'
|
|
88
|
+
"$PREFIX/node_modules/.bin/dsh" --version
|
|
89
|
+
\`\`\`
|
|
90
|
+
|
|
91
|
+
(写一个私有 \`package.json\` 是为了防止 npm 向上找到无关项目、改掉它的依赖树。)
|
|
92
|
+
|
|
93
|
+
**校验**(三步都要过,缺一步不算成功):
|
|
94
|
+
\`\`\`bash
|
|
95
|
+
BIN="$PREFIX/node_modules/.bin/dsh"
|
|
96
|
+
test -x "$BIN" && "$BIN" --version # 1. 二进制存在
|
|
97
|
+
ls "$PREFIX/node_modules/node-pty/prebuilds/" # 2. 有当前平台产物
|
|
98
|
+
"$BIN" web --help | grep -q -- '--port' # 3. web 子命令在
|
|
99
|
+
\`\`\`
|
|
100
|
+
|
|
101
|
+
⚠️ **常见误判**:\`.bin/dsh\` 是符号链接。\`realpath\` 后 \`dirname(dirname(...))\` 会落在
|
|
102
|
+
\`<prefix>/node_modules/@deepseek-ai/dsh\`(包**内部**),那里**没有**兄弟 \`node-pty\`,
|
|
103
|
+
于是好安装被判成「缺 pty」。正确做法是**向上找到第一个含 \`node_modules\` 目录的祖先**。
|
|
104
|
+
|
|
105
|
+
**最后必须端到端证明**(否则「装好了」没有意义):
|
|
106
|
+
\`\`\`bash
|
|
107
|
+
# 起一个只监听 127.0.0.1 的 web,抓到它打印的 token 行
|
|
108
|
+
DSH_HOME=/tmp/dshweb-check setsid nohup "$BIN" --profile web --port 0 --no-open \\
|
|
109
|
+
>/tmp/dshweb-check.log 2>&1 </dev/null &
|
|
110
|
+
sleep 8; grep -m1 'dsh web:' /tmp/dshweb-check.log
|
|
111
|
+
\`\`\`
|
|
112
|
+
拿到 \`http://127.0.0.1:<port>/?token=…\` 即成功。**用完要清理**(kill 掉并删 log)。
|
|
113
|
+
|
|
114
|
+
## 长尾情况
|
|
115
|
+
|
|
116
|
+
- **没有 node/npm**:先装 Node(nvm 最不侵入)。装不了就问用户要权限。
|
|
117
|
+
- **公司内网/镜像源**:用 \`npm install --registry <url>\`,或让用户指路。**不要**擅自改
|
|
118
|
+
用户全局 npm 配置。
|
|
119
|
+
- **Windows 远端**:启动命令是 POSIX shell(依赖 \`setsid\`/\`nohup\`)。远端有 Git Bash
|
|
120
|
+
时可工作(插件会经 \`bash -s\` 执行);没有则如实说明暂不支持,不要硬试。
|
|
121
|
+
- **代理**:\`HTTP_PROXY\`/\`HTTPS_PROXY\` 对 npm 生效;先探测是否需要。
|
|
122
|
+
- **需要 sudo**:**停下来问用户**,不要自作主张提权。
|
|
123
|
+
|
|
124
|
+
## 纪律
|
|
125
|
+
|
|
126
|
+
1. **只读优先**:先诊断,再动手。诊断阶段不要写任何东西。
|
|
127
|
+
2. **不改用户环境**:不 \`npm i -g\`(除非用户明确要求)、不改 PATH、不动 \`~/.dsh\`。
|
|
128
|
+
3. **可回退**:安装只落在私有前缀,删掉那个目录就完全恢复。告诉用户路径。
|
|
129
|
+
4. **收尾清理**:你起的进程、写的临时 log、用的临时 DSH_HOME 都要清掉。
|
|
130
|
+
5. **报结论要带证据**:贴出关键命令的实际输出,不要只说「已修复」。
|
|
131
|
+
6. **不确定就问**:涉及提权、改全局配置、删文件时先确认。
|
|
132
|
+
`
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Register the deployment skill with the session skill registry.
|
|
136
|
+
*
|
|
137
|
+
* Optional by design: a composition without `ctx.skills` simply has no skill,
|
|
138
|
+
* and the deterministic deploy path keeps working. Failures are contained —
|
|
139
|
+
* a skill that fails to register must never take down the plugin.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} ctx - plugin context.
|
|
142
|
+
* @returns {boolean} whether the skill was registered.
|
|
143
|
+
*/
|
|
144
|
+
export function registerDeploySkill(ctx) {
|
|
145
|
+
try {
|
|
146
|
+
const skills = ctx.get('skills')
|
|
147
|
+
if (!skills || typeof skills.register !== 'function') return false
|
|
148
|
+
skills.register({
|
|
149
|
+
name: SKILL_NAME,
|
|
150
|
+
description: '在远端机器部署并验证 DSH 网页界面:诊断「连不上」的原因、'
|
|
151
|
+
+ '装到私有前缀、校验原生模块与 web 子命令,并端到端证明可用。'
|
|
152
|
+
+ '当远端 dsh 缺失、版本过旧、或 dsh web 起不来时使用。',
|
|
153
|
+
whenToUse: '远端 DSH 界面连不上;远端没有 dsh 或版本过旧;'
|
|
154
|
+
+ '出现 Failed to load native module: pty.node;需要判断是部署问题还是宿主问题。',
|
|
155
|
+
content: SKILL_BODY,
|
|
156
|
+
source: 'dsh-remote',
|
|
157
|
+
// Both entry points are wanted: the settings button starts a session with
|
|
158
|
+
// it pre-selected, and the model may reach for it when an rw_* call fails
|
|
159
|
+
// in a deployment-shaped way.
|
|
160
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
161
|
+
})
|
|
162
|
+
return true
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// A missing skill only costs the AI fallback; never break the plugin.
|
|
165
|
+
try { ctx.logger?.warn?.(new Error('dsh-remote: deploy skill not registered: ' + String((err && err.message) || err))) } catch { /* no logger */ }
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the prompt handed to the AI when the user asks it to investigate.
|
|
172
|
+
*
|
|
173
|
+
* The failure context is included verbatim because the whole point is that the
|
|
174
|
+
* deterministic path could not classify it — paraphrasing would throw away the
|
|
175
|
+
* only evidence there is.
|
|
176
|
+
*
|
|
177
|
+
* @param {object} context - what the deterministic path learned.
|
|
178
|
+
* @returns {string} the prompt text.
|
|
179
|
+
*/
|
|
180
|
+
export function buildInvestigatePrompt(context = {}) {
|
|
181
|
+
const c = context || {}
|
|
182
|
+
const lines = [
|
|
183
|
+
`请用 ${SKILL_NAME} 技能,帮我修好这台远端机器的 DSH 网页界面问题。`,
|
|
184
|
+
'',
|
|
185
|
+
`远端:${c.host || '(未知主机)'}${c.user ? ' (' + c.user + ')' : ''}`,
|
|
186
|
+
]
|
|
187
|
+
if (c.prefix) lines.push(`建议安装位置:${c.prefix}`)
|
|
188
|
+
if (c.findings && c.findings.length) {
|
|
189
|
+
lines.push('', '自动体检已发现:')
|
|
190
|
+
for (const f of c.findings) {
|
|
191
|
+
lines.push(`- [${f.severity}] ${f.summary}${f.detail ? ' —— ' + f.detail : ''}`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (c.error) lines.push('', '自动部署/连接失败信息:', '```', String(c.error).slice(0, 2000), '```')
|
|
195
|
+
if (c.facts && Object.keys(c.facts).length) {
|
|
196
|
+
lines.push('', '体检原始事实:', '```json', JSON.stringify(c.facts, null, 2).slice(0, 2000), '```')
|
|
197
|
+
}
|
|
198
|
+
lines.push(
|
|
199
|
+
'',
|
|
200
|
+
'要求:先诊断、只读优先;装到私有前缀而不是全局;装完要**端到端证明**能起 web',
|
|
201
|
+
'(拿到 `dsh web: http://127.0.0.1:<port>/?token=…`);收尾清理你起的进程和临时文件;',
|
|
202
|
+
'最后用证据说明结论。涉及提权或改全局配置时先问我。',
|
|
203
|
+
)
|
|
204
|
+
return lines.join('\n')
|
|
205
|
+
}
|