dsh-remote 0.8.34 → 0.8.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  toShellPath, toDisplayPath, remotePathBase, truncate, shortHash,
32
32
  } from './paths.js'
33
33
  import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
34
- import { compileIgnore, DEFAULT_IGNORE } from './ignore.js'
34
+ import { compileIgnore, DEFAULT_IGNORE, DEFAULT_SEARCH_IGNORE } from './ignore.js'
35
35
  import { friendlyMessage } from './errors.js'
36
36
  import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
37
37
  import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
@@ -41,6 +41,12 @@ 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 { 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'
44
50
  import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, 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'
@@ -134,6 +140,14 @@ export const Config = z.object({
134
140
  /** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
135
141
  * partial index answers rather than making the caret wait. */
136
142
  fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
143
+ /** Cooperative budget (ms) for `rw_search`; also declared as the tool's
144
+ * `timeoutMs` so @deepseek-ai/dsh-tool-call-timeout-policy can end a call
145
+ * that overruns. The search itself stops at this budget and returns partial
146
+ * results (issue #44: an unbounded walk over a home directory could never be
147
+ * stopped and left the turn `running` forever). */
148
+ searchTimeoutMs: z.number().step(1).min(1000).default(60000),
149
+ /** Max files `rw_search` will stat/read before returning partial results. */
150
+ searchMaxEntries: z.number().step(1).min(1).default(50000),
137
151
  /** Update mode: `auto` (default) checks on load and periodically, applies a
138
152
  * newer npm release automatically, then hot-swaps the host half (see
139
153
  * `updateAutoReload`); `manual` only checks when the user asks; `off` disables
@@ -152,6 +166,32 @@ export const Config = z.object({
152
166
  * true). With `false` an update only takes effect on the next process start
153
167
  * and `/dsh-remote/update-check` reports `pendingReload: true` meanwhile. */
154
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(''),
155
195
  })
156
196
 
157
197
  // ── shell / path helpers (pure implementations in lib/paths.js) ───────────
@@ -367,6 +407,42 @@ export async function apply(ctx, config) {
367
407
  sshConfigResolved: m.useSshConfig ? resolveMachineSshConfig(m, { text: sshConfigText() }) : null,
368
408
  }))
369
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
+
370
446
  /** The machine the pool is currently bound to: an ephemeral tool connection
371
447
  * (rw_connect save:false) wins, then the stored current, then config default. */
372
448
  let ephemeral = null
@@ -734,6 +810,9 @@ export async function apply(ctx, config) {
734
810
  }
735
811
 
736
812
  // ── ignore rules (defaults + user file) ───────────────────────────────────
813
+ // NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
814
+ // Do NOT add search-only cache excludes here: widening it would silently stop
815
+ // syncing directories a user may rely on. Search uses searchIgnoreMatcher().
737
816
  const ignoreMatcher = () => {
738
817
  try {
739
818
  const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
@@ -742,6 +821,18 @@ export async function apply(ctx, config) {
742
821
  return compileIgnore(DEFAULT_IGNORE)
743
822
  }
744
823
  }
824
+ /** Search-only matcher: same as ignoreMatcher() plus machine-local cache trees
825
+ * (`~/.npm`, `~/.cache`, …) that are huge, almost never what a human is
826
+ * looking for, and the main cause of the issue #44 hang. Mirror sync is
827
+ * deliberately unaffected. An explicit `path` still searches anywhere. */
828
+ const searchIgnoreMatcher = () => {
829
+ try {
830
+ const fromFile = readFileSync(ignoreFile(), 'utf8').split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
831
+ return compileIgnore(DEFAULT_SEARCH_IGNORE.concat(fromFile))
832
+ } catch {
833
+ return compileIgnore(DEFAULT_SEARCH_IGNORE)
834
+ }
835
+ }
745
836
 
746
837
  // ── task + forward managers ───────────────────────────────────────────────
747
838
  const tasks = new TaskManager()
@@ -749,6 +840,20 @@ export async function apply(ctx, config) {
749
840
  pool.onReady = (client) => forwards.attach(client)
750
841
  pool.onCloseHook = () => forwards.detach()
751
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
+
752
857
  // ── auto-push watcher (config.autoPush, default off) ──────────────────────
753
858
  // Watches the local mirror; local edits are pushed back to the remote after a
754
859
  // 3s debounce, honoring ignore rules + the three-way conflict guard (a remote
@@ -1593,21 +1698,44 @@ export async function apply(ctx, config) {
1593
1698
  defineTool({
1594
1699
  name: 'rw_edit',
1595
1700
  description:
1596
- 'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute.',
1701
+ 'Edit a remote text file by replacing literal text (read-modify-write with an mtime optimistic lock: aborts if the file changed on the remote between read and write). Path is absolute. Accepts the aliases old_string/new_string (and file_path for path), matching the host edit tool, so either spelling works.',
1597
1702
  parameters: {
1598
- path: { type: 'string', required: true, description: 'Absolute remote file path' },
1599
- old: { type: 'string', required: true, description: 'Literal text to replace (must appear exactly once unless count is given)' },
1600
- new: { type: 'string', required: true, description: 'Replacement text' },
1703
+ // `path`/`old`/`new` also accept the host edit tool's spellings
1704
+ // (file_path/old_string/new_string): models habitually use those, and a
1705
+ // schema-level `required` on only one spelling rejects the other before
1706
+ // execute() ever runs. So NONE of them is declared required here — the
1707
+ // resolver below enforces "path + old + new" and reports which spelling
1708
+ // to pass. Keep this in sync: adding `required: true` back to any of
1709
+ // these silently breaks its alias.
1710
+ path: { type: 'string', description: 'Absolute remote file path (alias: file_path)' },
1711
+ old: { type: 'string', description: 'Literal text to replace (must appear exactly once unless count is given). Alias: old_string' },
1712
+ new: { type: 'string', description: 'Replacement text; an empty string deletes the old text. Alias: new_string' },
1713
+ old_string: { type: 'string', description: 'Alias of old (same meaning)' },
1714
+ new_string: { type: 'string', description: 'Alias of new (same meaning)' },
1715
+ file_path: { type: 'string', description: 'Alias of path (same meaning)' },
1601
1716
  count: { type: 'integer', description: 'How many occurrences to replace (default: error if the text appears more than once)' },
1602
1717
  encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
1603
1718
  },
1604
1719
  output: okOut,
1605
1720
  async execute(args, exec) {
1606
1721
  const b = requireMachine(exec, 'rw_edit')
1607
- const p = resolveRemoteArg(b, args.path)
1608
- if (!p || p === '/') throw new Error('rw_edit: a file path is required')
1609
- const oldS = String(args.old ?? '')
1610
- const newS = String(args.new ?? '')
1722
+ // Presence must be checked on the RAW argument, not on the resolved
1723
+ // path: resolveRemoteArg falls back to the workspace root for an empty
1724
+ // string, so `!p` would never fire and a pathless rw_edit would silently
1725
+ // target the workspace root. (That is why `path` cannot simply lose its
1726
+ // schema `required` without this check replacing it.)
1727
+ const rawPath = args.path ?? args.file_path
1728
+ if (rawPath == null || String(rawPath).trim() === '') {
1729
+ throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
1730
+ }
1731
+ const p = resolveRemoteArg(b, rawPath)
1732
+ if (!p || p === '/') throw new Error('rw_edit: a file path is required — pass path (alias: file_path)')
1733
+ const oldRaw = args.old ?? args.old_string
1734
+ const newRaw = args.new ?? args.new_string
1735
+ if (oldRaw == null) throw new Error('rw_edit: old text is required — pass old (alias: old_string)')
1736
+ if (newRaw == null) throw new Error('rw_edit: replacement text is required — pass new (alias: new_string); use an empty string to delete the old text')
1737
+ const oldS = String(oldRaw)
1738
+ const newS = String(newRaw)
1611
1739
  if (oldS === '') throw new Error('rw_edit: old text must not be empty')
1612
1740
  const sftp = await b.pool.sftp()
1613
1741
  const st0 = await sftp.stat(p)
@@ -1772,6 +1900,7 @@ export async function apply(ctx, config) {
1772
1900
  if (!parts.length) parts.push('(no output)')
1773
1901
  let text = parts.join('\n')
1774
1902
  if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
1903
+ else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
1775
1904
  else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
1776
1905
  return { text }
1777
1906
  } catch (err) {
@@ -1783,7 +1912,12 @@ export async function apply(ctx, config) {
1783
1912
  defineTool({
1784
1913
  name: 'rw_search',
1785
1914
  description:
1786
- 'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped.',
1915
+ 'Search remote files for a pattern. POSIX remotes try `rg` then `grep -R` first; Windows and fallbacks use a portable SFTP walk. Honors ignore rules. Returns matching file:line rows; output is capped. Search is bounded (maxEntries/maxDurationMs) and can be cancelled: on a very large tree it returns partial results marked TRUNCATED instead of running indefinitely.',
1916
+ // A cooperative budget for @deepseek-ai/dsh-tool-call-timeout-policy.
1917
+ // Only useful together with the cancellation support below: the policy can
1918
+ // only *ask* a tool to stop, so a tool that ignores exec.signal would keep
1919
+ // the caller waiting (issue #44).
1920
+ timeoutMs: config.searchTimeoutMs,
1787
1921
  parameters: {
1788
1922
  pattern: { type: 'string', required: true, description: 'Pattern to search for (extended regex)' },
1789
1923
  path: { type: 'string', description: 'Directory to search (default: current remote workspace)' },
@@ -1791,6 +1925,8 @@ export async function apply(ctx, config) {
1791
1925
  ignoreCase: { type: 'boolean', description: 'Case-insensitive search (default true)' },
1792
1926
  contextLines: { type: 'integer', description: 'Lines of context around each match (default 0)' },
1793
1927
  maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
1928
+ maxEntries: { type: 'integer', description: 'Stop after scanning this many files (default 50000)' },
1929
+ maxDurationMs: { type: 'integer', description: 'Stop after this many ms and return partial results (default 60000)' },
1794
1930
  },
1795
1931
  output: textOut,
1796
1932
  async execute(args, exec) {
@@ -1807,21 +1943,36 @@ export async function apply(ctx, config) {
1807
1943
  throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
1808
1944
  }
1809
1945
  const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
1810
- const matcher = ignoreMatcher()
1811
- const { matches, scanned, truncated } = await searchRemote(b.pool, dir, {
1946
+ // Budgets. The SFTP walk had NO entry/duration cap before, so a search
1947
+ // over a home directory could run for hours while the turn stayed
1948
+ // `running` and cancel/steer did nothing (issue #44).
1949
+ const maxFiles = Math.min(Math.max(Number(args.maxEntries) || config.searchMaxEntries, 1), 500000)
1950
+ const maxDurationMs = Math.min(Math.max(Number(args.maxDurationMs) || config.searchTimeoutMs, 1000), 600000)
1951
+ const matcher = searchIgnoreMatcher()
1952
+ const { matches, scanned, truncated, cancelled } = await searchRemote(b.pool, dir, {
1812
1953
  pattern,
1813
1954
  regex,
1814
1955
  glob: args.glob,
1815
1956
  ignoreCase: args.ignoreCase !== false,
1816
1957
  contextLines: Math.min(Math.max(Number(args.contextLines) || 0, 0), 10),
1817
1958
  maxMatches,
1959
+ maxFiles,
1960
+ maxDurationMs,
1961
+ signal: exec.signal,
1818
1962
  maxScanBytes: Math.min(config.maxFileBytes || 1024 * 1024, 1024 * 1024),
1819
1963
  isIgnored: (name, isDir) => matcher(name, isDir),
1820
- timeoutMs: config.commandTimeoutMs,
1964
+ timeoutMs: Math.min(config.commandTimeoutMs, maxDurationMs),
1821
1965
  })
1822
- if (!matches.length) return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned)` }
1966
+ const note = cancelled
1967
+ ? `, CANCELLED after ${scanned} files scanned`
1968
+ : truncated
1969
+ ? ', TRUNCATED'
1970
+ : ''
1971
+ if (!matches.length) {
1972
+ return { text: `no matches for /${pattern}/ in ${dir} (${scanned} files scanned${note})` }
1973
+ }
1823
1974
  let text = matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n')
1824
- text += `\n(${matches.length} match(es), ${scanned} files scanned${truncated ? ', TRUNCATED' : ''})`
1975
+ text += `\n(${matches.length} match(es), ${scanned} files scanned${note})`
1825
1976
  return { text: truncate(text, config.maxOutputChars) }
1826
1977
  },
1827
1978
  }),
@@ -1885,6 +2036,64 @@ export async function apply(ctx, config) {
1885
2036
  },
1886
2037
  }),
1887
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
+
1888
2097
  defineTool({
1889
2098
  name: 'rw_forward',
1890
2099
  description:
@@ -2777,6 +2986,237 @@ export async function apply(ctx, config) {
2777
2986
  }
2778
2987
  },
2779
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
+ },
2780
3220
  {
2781
3221
  kind: 'exact',
2782
3222
  path: '/dsh-remote/home',
@@ -2822,13 +3262,21 @@ export async function apply(ctx, config) {
2822
3262
  // a link:/dev install must not be overwritten by an npm publish.
2823
3263
  selfUpdateAllowed: isInstalledCopy(),
2824
3264
  }
2825
- const latest = await fetchLatestVersion()
2826
- if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
3265
+ const probe = await fetchLatestVersion()
3266
+ // 即使查不到最新版,也把**本地状态**照常返回(ok:true),只把 registry
3267
+ // 的失败原因放在 registryError 里。
3268
+ //
3269
+ // 为什么:原先 registry 失败时整条响应 ok:false,客户端遇到 !ok 就只设错误
3270
+ // 文案、不 setUpd,于是面板永远停在「版本信息加载中…」——看起来像卡死,
3271
+ // 而实际上本地版本、更新模式、是否允许自更新都是已知的,完全可以展示。
3272
+ if (probe.error) {
3273
+ return sendJson(res, 200, { ok: true, ...base, latest: null, updateAvailable: false, registryError: probe.error, registryReason: probe.reason })
3274
+ }
2827
3275
  return sendJson(res, 200, {
2828
3276
  ok: true,
2829
3277
  ...base,
2830
- latest,
2831
- updateAvailable: gtVersion(latest, loaded),
3278
+ latest: probe.version,
3279
+ updateAvailable: gtVersion(probe.version, loaded),
2832
3280
  })
2833
3281
  },
2834
3282
  },
@@ -2897,6 +3345,15 @@ export async function apply(ctx, config) {
2897
3345
 
2898
3346
  registerHttpTransports(ctx, routes)
2899
3347
 
3348
+ // ── built-in deployment skill (issue #46 P3) ──────────────────────────────
3349
+ // Registered at runtime so the plugin stays a single self-contained package
3350
+ // (no writable $DSH_HOME/skills, no collision with the user's own skills).
3351
+ // Optional: a composition without `ctx.skills` keeps the deterministic deploy
3352
+ // path and merely loses the AI fallback.
3353
+ ctx.inject(['skills'], (skillCtx) => {
3354
+ registerDeploySkill(skillCtx)
3355
+ })
3356
+
2900
3357
  // ── auto-update: check on load + on an interval; apply silently ───────────
2901
3358
  // Failures are swallowed (never break the plugin). The persisted override
2902
3359
  // (settings UI) wins over the profile-config default, which wins over
@@ -2904,9 +3361,20 @@ export async function apply(ctx, config) {
2904
3361
  const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
2905
3362
  const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE
2906
3363
  if (effectiveUpdateMode === 'auto') {
3364
+ // 限流/网络失败后的退避:命中后跳过接下来若干轮检查,避免在被 npm 限流
3365
+ // (HTTP 429)时反复重试把配额烧得更久。指数增长,上限 24 轮(配合默认
3366
+ // 6h 间隔即最多约 6 天,足以让限流窗口滑过且不会永久停摆)。
3367
+ let backoffRounds = 0
2907
3368
  const checkAndApply = async () => {
2908
- const latest = await fetchLatestVersion()
2909
- if (!latest) return
3369
+ if (backoffRounds > 0) { backoffRounds -= 1; return }
3370
+ const probe = await fetchLatestVersion()
3371
+ if (probe.error) {
3372
+ // 限流与网络故障都要退避;成功一次即重置。
3373
+ backoffRounds = Math.min(backoffRounds === 0 ? 1 : backoffRounds * 2, 24)
3374
+ return
3375
+ }
3376
+ backoffRounds = 0
3377
+ const latest = probe.version
2910
3378
  // The version in flight is whichever of "already on disk" / "already
2911
3379
  // loaded" is newer. Pinning it once outside this closure (as v0.8.23 did)
2912
3380
  // made every later interval re-download and re-apply the same tarball,