dsh-remote 0.8.35 → 0.8.37

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.
@@ -67,11 +67,103 @@ export function connectionRoute(route) {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Reject a browser-shaped request that is not same-origin ours.
72
+ *
73
+ * This is the FALLBACK fence, used only when the Connection service (which owns
74
+ * DSH's authoritative `requestRejection`) is absent — an old composition that
75
+ * has a `webServer` but no browser-authentication owner. It is deliberately
76
+ * fail-closed on the three browser vectors our own routes would otherwise
77
+ * accept:
78
+ * • `sec-fetch-site: cross-site` — a page on any origin driving our API;
79
+ * • an `Origin` that is not our own authority — the same, with CORS markers;
80
+ * • a non-loopback `Host` — DNS rebinding, where a name the attacker controls
81
+ * resolves to 127.0.0.1 so even `Origin === Host` looks "same-origin".
82
+ * Non-browser callers (curl, scripts) carry no `Sec-Fetch-*`/`Origin` and a
83
+ * loopback `Host`, so they keep working; that is the same shape DSH's own fence
84
+ * implements for `/api`.
85
+ *
86
+ * A request with no HTTP headers at all is not a browser request — it comes
87
+ * from an in-process caller (tests, and the Desktop carrier's IPC path, which
88
+ * authenticates before dispatch) — so there is no browser layer to defend.
89
+ *
90
+ * @param {import('node:http').IncomingMessage} req - the request under test.
91
+ * @returns {number|undefined} 403 to reject, undefined to proceed.
92
+ */
93
+ function fallbackRejection(req) {
94
+ const headers = req && req.headers
95
+ if (!headers || typeof headers !== 'object') return undefined
96
+ const header = (name) => {
97
+ const value = headers[name]
98
+ return typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
99
+ }
100
+ if (header('sec-fetch-site') === 'cross-site') return 403
101
+ const host = header('host')
102
+ const origin = header('origin')
103
+ if (origin !== undefined && host !== undefined) {
104
+ try {
105
+ if (new URL(origin).host !== host) return 403
106
+ } catch {
107
+ return 403
108
+ }
109
+ }
110
+ if (host !== undefined) {
111
+ const hostname = host.replace(/:\d+$/, '').toLowerCase()
112
+ const loopback = hostname === 'localhost' || hostname === '[::1]'
113
+ || /^127(?:\.\d{1,3}){3}$/.test(hostname)
114
+ if (!loopback) return 403
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * Wrap one route so every request passes DSH's browser trust fence first.
121
+ *
122
+ * The legacy `/dsh-remote/*` routes are registered straight on `webServer`, which
123
+ * applies no Host/Origin check and no browser authentication — `requestRejection`
124
+ * lives on the Connection service and is only wired to `/api` and the
125
+ * `/api/remote.mux` upgrade. Without this wrapper our mutating routes were
126
+ * reachable cross-site as "simple requests" (no preflight: a `text/plain` POST
127
+ * needs no CORS approval), so any page the user visited could define a tunnel,
128
+ * rewrite the machine registry, or drive `update-apply`. The model tools were
129
+ * never exposed that way; only these JSON routes were.
130
+ *
131
+ * The lookup is deferred to request time because `webServer` and `connection`
132
+ * arrive independently: the fence must engage as soon as authentication exists,
133
+ * and must not be baked in from whichever service happened to be present when
134
+ * the route was registered.
135
+ *
136
+ * @param {object} route - a webServer route definition.
137
+ * @param {() => object|undefined} connectionOf - resolves the live Connection service.
138
+ * @returns {object} a route whose handler enforces the fence before dispatching.
139
+ */
140
+ export function guardRoute(route, connectionOf) {
141
+ const inner = route.handler
142
+ return {
143
+ ...route,
144
+ handler: async (req, res) => {
145
+ const connection = connectionOf()
146
+ const rejection = typeof connection?.requestRejection === 'function'
147
+ ? connection.requestRejection(req)
148
+ : fallbackRejection(req)
149
+ if (rejection !== undefined) {
150
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
151
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
152
+ return
153
+ }
154
+ return inner(req, res)
155
+ },
156
+ }
157
+ }
158
+
70
159
  export function registerHttpTransports(ctx, routes) {
71
160
  // Reactive injection matters: either service can arrive after the plugin.
72
161
  // Neither optional transport is a hard prerequisite for the SSH tools.
73
162
  ctx.inject(['webServer'], (inner) => {
74
- const disposers = routes.map((route) => inner.get('webServer').register(route))
163
+ // Apply DSH's own browser fence to every legacy route; the Connection
164
+ // service is read per request so a late-arriving authenticator still counts.
165
+ const guarded = routes.map((route) => guardRoute(route, () => ctx.get('connection')))
166
+ const disposers = guarded.map((route) => inner.get('webServer').register(route))
75
167
  inner.effect(() => () => disposers.forEach((dispose) => dispose()), 'dsh-remote.web-routes')
76
168
  })
77
169
  ctx.inject(['connection'], (inner) => {
package/lib/index.js CHANGED
@@ -41,7 +41,13 @@ import { searchRemote } from './search.js'
41
41
  import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
42
42
  import { TaskManager } from './tasks.js'
43
43
  import { ForwardManager } from './forwards.js'
44
- import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, isInstalledCopy } from './update.js'
44
+ import { WebAttach } from './web-attach.js'
45
+ import {
46
+ buildProbeCommand, parseProbe, judgeProbe, buildInstallPlan,
47
+ resolvePrefix, stepResult, DEFAULT_INSTALL_VERSION,
48
+ } from './web-deploy.js'
49
+ import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
50
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
45
51
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
46
52
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
47
53
  import { registerHttpTransports } from './http-transport.js'
@@ -160,6 +166,32 @@ export const Config = z.object({
160
166
  * true). With `false` an update only takes effect on the next process start
161
167
  * and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
162
168
  updateAutoReload: z.boolean().default(true),
169
+ /** First local port tried when attaching a remote DSH Web UI (issue #46).
170
+ * The listener is loopback-only; consecutive ports are tried when this one
171
+ * is taken. */
172
+ webAttachPortStart: z.number().step(1).min(1).max(65535).default(3088),
173
+ /** Remote command that starts a `dsh web` on the attached machine. Only needs
174
+ * changing when the remote's `dsh` is not on the SSH login PATH (e.g. a
175
+ * version installed into a private prefix). */
176
+ webAttachCommand: z.string().default('dsh'),
177
+ /** `DSH_HOME` to export on the remote when attaching. Empty reuses the
178
+ * remote user's own harness home; point it at a scratch directory to keep an
179
+ * attached instance out of the user's real sessions and settings. */
180
+ webAttachDshHome: z.string().default(''),
181
+ /** How long (seconds) to wait for a freshly started remote `dsh web` to print
182
+ * its startup token before giving up. */
183
+ webAttachWaitSeconds: z.number().step(1).min(5).max(300).default(45),
184
+ /** Where an automatic deployment installs dsh on the remote. Empty means
185
+ * `$HOME/.dsh-remote/dsh` (persistent, needs no write access to any system
186
+ * prefix, and never replaces a dsh the user already has). */
187
+ webInstallPrefix: z.string().default(''),
188
+ /** Version an automatic deployment installs. The default is the first release
189
+ * whose node-pty ships Linux prebuilds — older ones cannot boot `dsh web` on
190
+ * Linux at all. */
191
+ webInstallVersion: z.string().default(DEFAULT_INSTALL_VERSION),
192
+ /** npm registry to install from. Empty uses the remote's own npm config, which
193
+ * is what an internal mirror needs to stay in effect. */
194
+ webInstallRegistry: z.string().default(''),
163
195
  })
164
196
 
165
197
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -375,6 +407,42 @@ export async function apply(ctx, config) {
375
407
  sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
376
408
  }))
377
409
 
410
+ /**
411
+ * The `dsh` command to use for a machine's Web UI attach.
412
+ *
413
+ * Resolution order is deliberate: a PER-MACHINE value recorded by a completed
414
+ * deployment wins, then the global config default. Without the per-machine
415
+ * branch, deploying on one host would point every other host at a binary that
416
+ * only exists on that one.
417
+ *
418
+ * @param {object} machine - a registry record (may be undefined).
419
+ * @returns {string} the command to run on that machine.
420
+ */
421
+ const webAttachCommandFor = (machine) => {
422
+ const perMachine = machine && typeof machine.webAttachCommand === 'string' ? machine.webAttachCommand.trim() : ''
423
+ return perMachine || String(config.webAttachCommand || 'dsh')
424
+ }
425
+
426
+ /**
427
+ * Record the dsh command a deployment produced, on that machine.
428
+ *
429
+ * Persisted through the same registry helper every other machine edit uses, so
430
+ * the value survives a restart and appears in the settings UI like any other
431
+ * field. `keepCurrentKey: false` preserves whichever machine is current —
432
+ * deploying must never silently switch the user's active machine.
433
+ *
434
+ * @param {object} machine - the registry record to update.
435
+ * @param {string} command - the working dsh command.
436
+ */
437
+ const persistWebAttachCommand = (machine, command) => {
438
+ try {
439
+ const i = machineIndex(machine.id)
440
+ if (i < 0) return
441
+ machines[i] = { ...machines[i], webAttachCommand: String(command || '') }
442
+ saveMachines(machines, store.currentId, false)
443
+ } catch { /* a failed persist only costs a re-deploy next time */ }
444
+ }
445
+
378
446
  /** The machine the pool is currently bound to: an ephemeral tool connection
379
447
  * (rw_connect save:false) wins, then the stored current, then config default. */
380
448
  let ephemeral = null
@@ -772,6 +840,20 @@ export async function apply(ctx, config) {
772
840
  pool.onReady = (client) => forwards.attach(client)
773
841
  pool.onCloseHook = () => forwards.detach()
774
842
 
843
+ // ── remote DSH Web UI attach sessions (issue #46) ─────────────────────────
844
+ // "Use this DSH as a CLIENT for a DSH on another machine". The remote never
845
+ // opens an inbound port (DSH forbids `--host 0.0.0.0`); we SSH out, start or
846
+ // reuse a loopback-bound `dsh web` there, and carry its socket back over our
847
+ // own SSH connection to a loopback listener here. One session per machine —
848
+ // attaching again to the same machine reuses the live tunnel instead of
849
+ // starting a second remote server. Lifecycle is owned by the plugin fiber, so
850
+ // unloading the plugin tears every tunnel down.
851
+ const webAttaches = new Map() // machineId → WebAttach
852
+ ctx.effect(() => () => {
853
+ for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
854
+ webAttaches.clear()
855
+ }, 'dsh-remote.web-attach')
856
+
775
857
  // ── auto-push watcher (config.autoPush, default off) ──────────────────────
776
858
  // Watches the local mirror; local edits are pushed back to the remote after a
777
859
  // 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
@@ -1954,6 +2036,64 @@ export async function apply(ctx, config) {
1954
2036
  },
1955
2037
  }),
1956
2038
 
2039
+ defineTool({
2040
+ name: 'rw_deploy_probe',
2041
+ description:
2042
+ 'READ-ONLY health check of the remote machine\'s DSH install: platform/arch, node/npm, the existing dsh and its version, whether its native module can load (the usual reason `dsh web` will not start, e.g. a node-pty build with no Linux prebuild), whether it knows --no-open, proxy and npm registry settings, and whether a private install prefix is writable. Writes nothing on the remote. Use it before troubleshooting a failed remote DSH Web UI connection. For an automatic install into a private prefix, use the settings UI ("Deploy & verify") — this tool never installs.',
2043
+ parameters: {},
2044
+ output: textOut,
2045
+ async execute(_args, exec) {
2046
+ // Use the BINDING (this session's own machine), like every other rw_*
2047
+ // tool. Reading the active machine instead would let a probe report on a
2048
+ // host this session never asked about.
2049
+ const b = requireBinding(exec, 'rw_deploy_probe')
2050
+ // The binding carries host/port/user but no id; match on the identity the
2051
+ // binding resolved, so a machine previously deployed to is still found
2052
+ // (its `webAttachCommand` is the per-machine record we want to report).
2053
+ const machine = machines.find((m) => m.host === b.host
2054
+ && Number(m.port || 22) === Number(b.port || 22)
2055
+ && (!b.username || m.username === b.username)) || null
2056
+ // Do NOT pre-resolve the prefix locally: `resolvePrefix({home:''})` falls
2057
+ // back to /tmp, which is not where an install would go. With no explicit
2058
+ // config the REMOTE resolves it from its own $HOME and reports back.
2059
+ const explicitPrefix = String(config.webInstallPrefix || '').trim()
2060
+ const installedCommand = String((machine && machine.webAttachCommand) || '').trim()
2061
+ const out = await b.pool.exec(
2062
+ buildProbeCommand({ prefix: explicitPrefix, installedCommand, defaultPrefixFor: explicitPrefix ? '' : 'home' }),
2063
+ { timeoutMs: 30000 },
2064
+ )
2065
+ const facts = parseProbe(out && out.stdout)
2066
+ const verdict = judgeProbe(facts, { installedCommand })
2067
+ // Rendered as a compact report: the model needs the FACTS and the
2068
+ // VERDICT, not a JSON blob it would have to re-interpret.
2069
+ const lines = [
2070
+ `remote: ${b.host}${b.username ? ' (' + b.username + ')' : ''}`,
2071
+ `verdict: ${verdict.severity}${verdict.ok ? ' (usable)' : ' (blocked)'}`,
2072
+ verdict.useCommand ? `dsh to use: ${verdict.useCommand}` : 'dsh to use: (none usable yet)',
2073
+ `can auto-install: ${verdict.canAutoInstall ? 'yes' : 'no'}`,
2074
+ '',
2075
+ 'facts:',
2076
+ ` platform=${facts.platform || '?'} arch=${facts.arch || '?'} ${facts.windows ? '(windows)' : ''}`,
2077
+ ` node=${facts.node || '(none)'} ${facts.nodeVersion || ''}`,
2078
+ ` npm=${facts.npm || '(none)'}`,
2079
+ ` dsh=${facts.dsh || '(none)'} ${facts.dshVersion || ''}`,
2080
+ ` native-module (node-pty) prebuild: ${facts.ptyPrebuild || 'unknown'}`,
2081
+ ` knows --no-open: ${facts.noOpen || 'unknown'}`,
2082
+ facts.installed ? ` previously deployed command present: ${facts.installed} (pty=${facts.installedPty || '?'} web=${facts.installedWeb || '?'})` : '',
2083
+ ` git bash: ${facts.gitBash || '(none)'}${facts.windows && !facts.gitBash ? ' — required for a Windows remote' : ''}`,
2084
+ ` proxy: ${facts.proxyVar || '(none)'} npm registry: ${facts.registry || '(default)'}`,
2085
+ ` install prefix ${facts.resolvedPrefix || explicitPrefix || '(unresolved)'} writable: ${facts.writable || 'unknown'}`,
2086
+ '',
2087
+ 'findings:',
2088
+ ...verdict.findings.map((f) => ` [${f.severity}] ${f.summary}${f.detail ? ' — ' + f.detail : ''}${f.fix ? '\n fix: ' + f.fix : ''}`),
2089
+ ].filter(Boolean)
2090
+ if (!verdict.ok) {
2091
+ lines.push('', 'This is read-only. To fix it, either use the settings UI card "Remote dsh health check & deploy" (it installs into a private prefix and verifies), or repair it yourself following the dsh-remote-deploy skill.')
2092
+ }
2093
+ return { text: lines.join('\n') }
2094
+ },
2095
+ }),
2096
+
1957
2097
  defineTool({
1958
2098
  name: 'rw_forward',
1959
2099
  description:
@@ -2846,6 +2986,237 @@ export async function apply(ctx, config) {
2846
2986
  }
2847
2987
  },
2848
2988
  },
2989
+ {
2990
+ kind: 'exact',
2991
+ path: '/dsh-remote/web-attach',
2992
+ // Issue #46: open/reuse/close a tunnel to a remote machine's DSH Web UI.
2993
+ // Actions: status (GET) | open | close. `open` either starts a remote
2994
+ // `dsh web` (default) or attaches to one the user already runs by passing
2995
+ // its `http://127.0.0.1:<port>/?token=…` URL.
2996
+ handler: async (req, res) => {
2997
+ try {
2998
+ if (req.method === 'GET') {
2999
+ const q = new URL(req.url, 'http://localhost').searchParams
3000
+ const id = q.get('machineId') || ''
3001
+ const attach = id ? webAttaches.get(id) : undefined
3002
+ return sendJson(res, 200, {
3003
+ ok: true,
3004
+ attach: attach ? attach.describe() : null,
3005
+ attaches: [...webAttaches.values()].map((a) => a.describe()),
3006
+ })
3007
+ }
3008
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
3009
+ const body = parseJsonObject(await readBody(req))
3010
+ const action = String(body.action || '')
3011
+ const machineId = String(body.machineId || store.currentId || '').trim()
3012
+ if (!machineId) return sendJson(res, 400, { ok: false, error: '选择一台机器后再操作(missing machineId)' })
3013
+
3014
+ // ── deploy: probe (read-only) and install (private prefix) ─────────
3015
+ // Issue #46 follow-up. Detection and installation are deterministic, so
3016
+ // they are code rather than a model call: fast, free, unit-testable.
3017
+ // The AI path (a built-in skill) is offered only when this cannot cope.
3018
+ if (action === 'probe' || action === 'install') {
3019
+ const machine = machines.find((m) => m.id === machineId)
3020
+ if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
3021
+ const target = poolForMachine(identityOf(machine))
3022
+ // The command that deployment produced FOR THIS MACHINE wins over any
3023
+ // global default — otherwise a deployment on one host would be
3024
+ // forgotten on the very next probe.
3025
+ const installedCommand = String(machine.webAttachCommand || '').trim()
3026
+ const prefix = resolvePrefix({ home: '' }, { prefix: config.webInstallPrefix || '' })
3027
+
3028
+ if (action === 'probe') {
3029
+ // Strictly read-only: this runs before the user has agreed to any
3030
+ // change, and may be run on a machine they are merely looking at.
3031
+ const out = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3032
+ const facts = parseProbe(out && out.stdout)
3033
+ const verdict = judgeProbe(facts, { installedCommand })
3034
+ // The prefix we judged writability against was a guess (empty
3035
+ // $HOME); now that the probe reported the real home, re-resolve so
3036
+ // the prefix shown matches what an install would actually use.
3037
+ const realPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3038
+ audit('web-deploy', `probe ${machine.host}: ${verdict.severity}`, verdict.ok ? 0 : 1, machine)
3039
+ return sendJson(res, 200, {
3040
+ ok: true,
3041
+ facts,
3042
+ verdict,
3043
+ prefix: realPrefix,
3044
+ command: webAttachCommandFor(machine),
3045
+ installVersion: config.webInstallVersion || DEFAULT_INSTALL_VERSION,
3046
+ registry: config.webInstallRegistry || '',
3047
+ })
3048
+ }
3049
+
3050
+ // install
3051
+ if (body.confirm !== true) {
3052
+ return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
3053
+ }
3054
+ const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
3055
+ const facts = parseProbe(pout && pout.stdout)
3056
+ const verdict = judgeProbe(facts, { installedCommand })
3057
+ if (!verdict.canAutoInstall) {
3058
+ const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
3059
+ audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
3060
+ return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
3061
+ }
3062
+ const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
3063
+ // A registry the user set explicitly wins; otherwise the remote's own
3064
+ // npm config is left alone, which is what an internal mirror needs.
3065
+ // `--registry` is only added when configured — overriding a working
3066
+ // corporate mirror with the public one would be a regression.
3067
+ const registry = String(body.registry || config.webInstallRegistry || '').trim()
3068
+ // Proxy comes from the remote's own environment; the operator may
3069
+ // override it per-install when the inherited one is wrong.
3070
+ const env = body.proxy && typeof body.proxy === 'object'
3071
+ ? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
3072
+ : undefined
3073
+ const plan = buildInstallPlan({
3074
+ facts,
3075
+ prefix: installPrefix,
3076
+ version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
3077
+ registry,
3078
+ env,
3079
+ })
3080
+ const results = []
3081
+ for (const step of plan.steps) {
3082
+ let res2
3083
+ try {
3084
+ res2 = await target.exec(step.command, {
3085
+ timeoutMs: step.timeoutMs || 120000,
3086
+ // Per-step env exists for the proxy override (P4); pool.exec
3087
+ // passes it through to the remote process.
3088
+ ...(step.env ? { env: step.env } : {}),
3089
+ })
3090
+ } catch (err) {
3091
+ results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
3092
+ break
3093
+ }
3094
+ const r = stepResult(step, res2)
3095
+ results.push(r)
3096
+ if (!r.ok) break
3097
+ }
3098
+ const ok = results.length === plan.steps.length && results.every((r) => r.ok)
3099
+ if (ok) {
3100
+ // Persist so the attach flow uses the working build from now on.
3101
+ persistWebAttachCommand(machine, plan.command)
3102
+ audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
3103
+ return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
3104
+ }
3105
+ const failed = results.find((r) => !r.ok)
3106
+ audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
3107
+ // A failure that looks like a network/registry problem gets the AI
3108
+ // escape hatch suggested, since that is precisely the long tail the
3109
+ // deterministic path cannot classify.
3110
+ const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
3111
+ return sendJson(res, 500, {
3112
+ ok: false,
3113
+ error: `安装失败于「${failed ? failed.title : '未知步骤'}」`,
3114
+ steps: results,
3115
+ prefix: installPrefix,
3116
+ suggestInvestigate: true,
3117
+ ...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
3118
+ })
3119
+ }
3120
+
3121
+ if (action === 'open') {
3122
+ // Reuse a live tunnel for this machine: re-opening would start a
3123
+ // second remote DSH and leak the first one's process.
3124
+ const live = webAttaches.get(machineId)
3125
+ if (live && live.active) return sendJson(res, 200, { ok: true, attach: live.describe(), reused: true })
3126
+ // A tunnel that is no longer listening still owns sockets and a log
3127
+ // path; release it before this machine gets a fresh session. Its
3128
+ // remote process is left alone — stopping it stays an explicit act.
3129
+ if (live) { try { await live.close() } catch { /* already torn down */ } }
3130
+ const machine = machines.find((m) => m.id === machineId)
3131
+ if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
3132
+ const attach = new WebAttach({
3133
+ localPortStart: config.webAttachPortStart,
3134
+ })
3135
+ try {
3136
+ await attach.open({
3137
+ pool: poolForMachine(identityOf(machine)),
3138
+ machineId,
3139
+ command: String(body.command || '').trim() || webAttachCommandFor(machine),
3140
+ profile: 'web',
3141
+ existingUrl: String(body.existingUrl || ''),
3142
+ dshHome: config.webAttachDshHome || '',
3143
+ waitSeconds: config.webAttachWaitSeconds,
3144
+ })
3145
+ } catch (err) {
3146
+ try { await attach.close() } catch { /* nothing was listening */ }
3147
+ const msg = String((err && err.message) || err)
3148
+ audit('web-attach', `open ${machine.host} failed: ${msg}`, 1, machine)
3149
+ return sendJson(res, 500, { ok: false, error: msg })
3150
+ }
3151
+ webAttaches.set(machineId, attach)
3152
+ audit('web-attach', `open ${machine.host} → 127.0.0.1:${attach.localPort}`, 0, machine)
3153
+ return sendJson(res, 200, { ok: true, attach: attach.describe() })
3154
+ }
3155
+
3156
+ // ── investigate: hand the problem to an agent with the built-in skill ──
3157
+ // The AI escape hatch. Started ONLY when the user asks for it: it costs
3158
+ // a model call, so it is never automatic — the deterministic probe and
3159
+ // install are the default path.
3160
+ if (action === 'investigate') {
3161
+ const machine = machines.find((m) => m.id === machineId)
3162
+ if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
3163
+ const controller = ctx.get('sessionController')
3164
+ if (!controller || typeof controller.create !== 'function' || typeof controller.prompt !== 'function') {
3165
+ return sendJson(res, 400, {
3166
+ ok: false,
3167
+ error: '当前组合没有会话服务(sessionController),无法让 AI 排查。'
3168
+ + '请改用「部署并验证」,或把排查信息复制到对话里。',
3169
+ })
3170
+ }
3171
+ const sessionId = `dsh-remote-deploy-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
3172
+ try {
3173
+ await controller.create({ sessionId, cwd: undefined })
3174
+ } catch (err) {
3175
+ // An id collision is the only tolerable failure here.
3176
+ if (!/conflict|exist/i.test(String((err && err.message) || err))) {
3177
+ return sendJson(res, 500, { ok: false, error: `创建排查会话失败:${String((err && err.message) || err)}` })
3178
+ }
3179
+ }
3180
+ const prompt = buildInvestigatePrompt({
3181
+ host: machine.host,
3182
+ user: machine.username,
3183
+ prefix: resolvePrefix({ home: '' }, { prefix: config.webInstallPrefix || '' }),
3184
+ findings: Array.isArray(body.findings) ? body.findings : [],
3185
+ facts: body.facts && typeof body.facts === 'object' ? body.facts : undefined,
3186
+ error: String(body.error || ''),
3187
+ })
3188
+ try {
3189
+ await controller.prompt({
3190
+ sessionId,
3191
+ mode: 'queue',
3192
+ content: [{ type: 'text', text: prompt }],
3193
+ })
3194
+ } catch (err) {
3195
+ return sendJson(res, 500, { ok: false, error: `提交排查任务失败:${String((err && err.message) || err)}` })
3196
+ }
3197
+ audit('web-deploy', `investigate ${machine.host} → session ${sessionId}`, 0, machine)
3198
+ return sendJson(res, 200, { ok: true, sessionId, skill: SKILL_NAME })
3199
+ }
3200
+
3201
+ if (action === 'close') {
3202
+ const attach = webAttaches.get(machineId)
3203
+ if (!attach) return sendJson(res, 200, { ok: true, closed: false, error: '该机器没有活动的远程界面' })
3204
+ const stopRemote = body.stopRemote === true
3205
+ // `close()` refuses to signal anything without a recorded PID, so a
3206
+ // tunnel onto the user's own running instance stays hands-off.
3207
+ const exec = (cmd) => poolForMachine(identityOf(machines.find((m) => m.id === machineId) || {})).exec(cmd, { timeoutMs: 10000 })
3208
+ await attach.close({ stopRemote, exec })
3209
+ webAttaches.delete(machineId)
3210
+ audit('web-attach', `close ${attach.cleanUrl}${stopRemote ? ' (remote stopped)' : ''}`, 0)
3211
+ return sendJson(res, 200, { ok: true, closed: true, stoppedRemote: stopRemote && !!attach.remotePid })
3212
+ }
3213
+
3214
+ return sendJson(res, 400, { ok: false, error: 'action must be open | close' })
3215
+ } catch (err) {
3216
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
3217
+ }
3218
+ },
3219
+ },
2849
3220
  {
2850
3221
  kind: 'exact',
2851
3222
  path: '/dsh-remote/home',
@@ -2890,6 +3261,10 @@ export async function apply(ctx, config) {
2890
3261
  // Exposed so the settings page can explain why `auto` may not act:
2891
3262
  // a link:/dev install must not be overwritten by an npm publish.
2892
3263
  selfUpdateAllowed: isInstalledCopy(),
3264
+ // Outcome of the last hot-swap attempt (null if never tried). Without
3265
+ // this the swap could fail forever while the panel kept saying a
3266
+ // reload was merely pending — which is exactly what happened.
3267
+ lastReload: lastSelfReload(),
2893
3268
  }
2894
3269
  const probe = await fetchLatestVersion()
2895
3270
  // 即使查不到最新版,也把**本地状态**照常返回(ok:true),只把 registry
@@ -2974,6 +3349,15 @@ export async function apply(ctx, config) {
2974
3349
 
2975
3350
  registerHttpTransports(ctx, routes)
2976
3351
 
3352
+ // ── built-in deployment skill (issue #46 P3) ──────────────────────────────
3353
+ // Registered at runtime so the plugin stays a single self-contained package
3354
+ // (no writable $DSH_HOME/skills, no collision with the user's own skills).
3355
+ // Optional: a composition without `ctx.skills` keeps the deterministic deploy
3356
+ // path and merely loses the AI fallback.
3357
+ ctx.inject(['skills'], (skillCtx) => {
3358
+ registerDeploySkill(skillCtx)
3359
+ })
3360
+
2977
3361
  // ── auto-update: check on load + on an interval; apply silently ───────────
2978
3362
  // Failures are swallowed (never break the plugin). The persisted override
2979
3363
  // (settings UI) wins over the profile-config default, which wins over
package/lib/pool.js CHANGED
@@ -470,7 +470,15 @@ export class SshPool {
470
470
  timedOut = true
471
471
  reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
472
472
  }, this.config.commandTimeoutMs)
473
- if (typeof attemptTimer.unref === 'function') attemptTimer.unref()
473
+ // ★ 这里**不能 unref**。
474
+ //
475
+ // 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
476
+ // 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
477
+ // Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
478
+ // 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
479
+ // (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
480
+ // 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
481
+ // 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
474
482
  const clearAttempt = () => { clearTimeout(attemptTimer) }
475
483
  try {
476
484
  c.sftp((err, sftp) => {
package/lib/update.js CHANGED
@@ -380,8 +380,24 @@ export async function reloadSelf(loader) {
380
380
  if (!entry) return { ok: false, reason: 'own loader entry not found' }
381
381
  try {
382
382
  const cleared = clearSelfModuleCache(loader)
383
+ // ★ 卸载必须走 entry.fiber.dispose()。
384
+ //
385
+ // 原实现找 `entry._dispose` / `entry.dispose`,但 cordis 的 Entry **两个都没有**
386
+ // (0.2.0 实测方法集只有 constructor/disabledOf/evaluate/_patchContext/refresh/
387
+ // update/_commitVolatile/init/_init),所以这里每次都直接返回
388
+ // "entry exposes no dispose hook" —— 热切换从未真正执行过,而
389
+ // scheduleSelfReload 里的 `.catch(() => {})` 把它彻底吞掉,
390
+ // UI 于是永远停在"已落盘但未切换"(用户报的 0.8.35 -> 0.8.36)。
391
+ //
392
+ // 也不能用 entry.refresh():它是 `if (this.fiber) return; await this.init()`,
393
+ // fiber 还活着时直接返回,等于没重启。真正卸载 fiber 的地方在 Entry.update()
394
+ // 里(`this.fiber?.dispose()`),这里照它做。
395
+ //
396
+ // 保留对 _dispose/dispose 的探测:那是宿主可能提供的扩展钩子,有则优先用,
397
+ // 没有就用 fiber.dispose()——两条路都必须能真正卸载,否则应如实报错。
383
398
  if (typeof entry._dispose === 'function') await entry._dispose()
384
399
  else if (typeof entry.dispose === 'function') await entry.dispose()
400
+ else if (entry.fiber && typeof entry.fiber.dispose === 'function') await entry.fiber.dispose()
385
401
  else return { ok: false, reason: 'entry exposes no dispose hook' }
386
402
  await entry.init()
387
403
  return { ok: true, cleared }
@@ -393,18 +409,38 @@ export async function reloadSelf(loader) {
393
409
  /**
394
410
  * Fire-and-forget {@link reloadSelf} after a short delay, so the caller can
395
411
  * finish answering an HTTP request (or an update check) before its own module is
396
- * torn down. Failures are swallowed: a failed swap leaves the running code in
397
- * place, which is the pre-hot-update behaviour, not a crash.
412
+ * torn down. A failed swap leaves the running code in place, which is the
413
+ * pre-hot-update behaviour, not a crash — so we do not throw.
414
+ *
415
+ * We DO record the outcome, though. Silently discarding it is precisely how the
416
+ * "entry exposes no dispose hook" defect stayed invisible: the panel reported a
417
+ * scheduled swap, the swap never ran, and nothing anywhere said so.
418
+ * {@link lastSelfReload} exposes the result for diagnostics (the settings panel
419
+ * and `/dsh-remote/update-check` surface it), and a non-ok result is logged once.
420
+ *
398
421
  * @param loader - the cordis Loader service.
399
422
  * @param delayMs - grace period before the swap (default 300 ms).
400
423
  * @returns true when the swap was scheduled.
401
424
  */
402
425
  export function scheduleSelfReload(loader, delayMs = 300) {
403
- const timer = setTimeout(() => { void reloadSelf(loader).catch(() => {}) }, Math.max(0, delayMs))
426
+ const timer = setTimeout(() => {
427
+ void reloadSelf(loader).then(
428
+ (res) => { lastReloadResult = res; if (res && res.ok === false) console.warn('[dsh-remote] self-update swap failed:', res.reason) },
429
+ (err) => { lastReloadResult = { ok: false, reason: String((err && err.message) || err) }; console.warn('[dsh-remote] self-update swap threw:', lastReloadResult.reason) },
430
+ )
431
+ }, Math.max(0, delayMs))
404
432
  if (typeof timer.unref === 'function') timer.unref()
405
433
  return true
406
434
  }
407
435
 
436
+ /** Result of the most recent {@link scheduleSelfReload} attempt, for diagnostics. */
437
+ let lastReloadResult = null
438
+
439
+ /** @returns the last self-reload outcome (`{ok,reason?}`, or null if never attempted). */
440
+ export function lastSelfReload() {
441
+ return lastReloadResult
442
+ }
443
+
408
444
  /** Write the persisted update-mode override (settings UI). */
409
445
  export function persistUpdateMode(mode) {
410
446
  if (!['manual', 'auto', 'off'].includes(mode)) return false