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.
@@ -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
+ }