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.
- package/README.en.md +76 -13
- package/README.md +65 -13
- package/lib/client.js +238 -0
- package/lib/http-transport.js +93 -1
- package/lib/index.js +385 -1
- package/lib/pool.js +9 -1
- package/lib/update.js +39 -3
- 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
package/lib/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
397
|
-
*
|
|
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(() => {
|
|
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
|