dsh-remote 0.8.26 → 0.8.27

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/client.js CHANGED
@@ -143,6 +143,7 @@ window.__ModuleLoader__.load({
143
143
  'settings.updateApplied': '✅ 已更新到 v{to}({from} → {to}),宿主半已在线热切换。',
144
144
  'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
145
145
  'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
146
+ 'settings.selfUpdateBlocked': 'ℹ️ 当前是 link:/开发模式安装(不在 node_modules 下),已禁止自动更新以免覆盖源码——请在你的源码仓库里更新。',
146
147
  'settings.updateFailed': '更新失败',
147
148
  'settings.updateFailedMsg': '更新失败: {msg}',
148
149
  'settings.registryUnreachable': '无法连接 npm registry',
@@ -369,6 +370,7 @@ window.__ModuleLoader__.load({
369
370
  'settings.updateApplied': '✅ Updated to v{to} ({from} → {to}); the host half hot-swapped in place.',
370
371
  'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
371
372
  'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
373
+ 'settings.selfUpdateBlocked': 'ℹ️ This is a link:/dev install (not under node_modules), so self-update is disabled to avoid overwriting source — update it from its source repo.',
372
374
  'settings.updateFailed': 'Update failed',
373
375
  'settings.updateFailedMsg': 'Update failed: {msg}',
374
376
  'settings.registryUnreachable': 'Cannot reach the npm registry',
@@ -820,7 +822,9 @@ window.__ModuleLoader__.load({
820
822
  const [upd, setUpd] = React.useState(null) // { current, latest, updateAvailable, updateMode, updatedMarker }
821
823
  const [updBusy, setUpdBusy] = React.useState(false)
822
824
  const [updMsg, setUpdMsg] = React.useState('')
823
- const [updMode, setUpdMode] = React.useState('manual')
825
+ // Mirrors the host default (DEFAULT_UPDATE_MODE = 'auto') so the panel
826
+ // does not flash "manual" before the first /update-check answers.
827
+ const [updMode, setUpdMode] = React.useState('auto')
824
828
  const checkUpdate = (quiet) => {
825
829
  if (updBusy) return
826
830
  setUpdBusy(true); setUpdMsg('')
@@ -1139,6 +1143,10 @@ window.__ModuleLoader__.load({
1139
1143
  ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1140
1144
  tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
1141
1145
  : null,
1146
+ upd && upd.selfUpdateAllowed === false
1147
+ ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
1148
+ tr('settings.selfUpdateBlocked'))
1149
+ : null,
1142
1150
  updMsg ? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: updMsg.startsWith('✅') ? T.ok : 'inherit', opacity: 0.9 } }, updMsg) : null,
1143
1151
  ),
1144
1152
  React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', fontSize: 11, opacity: 0.65, borderTop: '1px solid ' + T.border, paddingTop: 10, marginTop: 4 } },
package/lib/index.js CHANGED
@@ -41,7 +41,7 @@ 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 } from './update.js'
44
+ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, isInstalledCopy } from './update.js'
45
45
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
46
46
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
47
47
  import { registerHttpTransports } from './http-transport.js'
@@ -51,6 +51,16 @@ import { listDirStructured, removeRemoteTree } from './remote-fs.js'
51
51
 
52
52
  export const name = 'dsh-remote'
53
53
 
54
+ /** Update mode used when neither the persisted `update-mode` file nor the
55
+ * profile config supplies one.
56
+ *
57
+ * Kept as one exported constant on purpose: the value appears in the schema
58
+ * default, in the `/update-check` response and in the auto-update gate, and
59
+ * three hard-coded copies drift (the first version of this change left two
60
+ * `|| 'manual'` fallbacks that quietly disagreed with the new schema default).
61
+ * Changing the default must change exactly one place. */
62
+ export const DEFAULT_UPDATE_MODE = 'auto'
63
+
54
64
  // Web and Desktop share the SSH tools; each optional UI transport is attached
55
65
  // reactively below, so service startup order does not drop the JSON routes.
56
66
  export const inject = ['tools', 'systemPrompt']
@@ -124,11 +134,18 @@ export const Config = z.object({
124
134
  /** Wall-clock budget (ms) for one remote `@` index pass; on expiry the
125
135
  * partial index answers rather than making the caret wait. */
126
136
  fileReferenceTimeoutMs: z.number().step(1).min(200).default(4000),
127
- /** Update mode: `manual` (default) only checks when asked; `auto` checks on
128
- * load and periodically, applying a newer npm release automatically;
129
- * `off` disables version checks entirely. (schemastery 3.18 has no .enum —
130
- * keep string and validate in code.) */
131
- updateMode: z.string().default('manual'),
137
+ /** Update mode: `auto` (default) checks on load and periodically, applies a
138
+ * newer npm release automatically, then hot-swaps the host half (see
139
+ * `updateAutoReload`); `manual` only checks when the user asks; `off` disables
140
+ * version checks entirely. (schemastery 3.18 has no .enum — keep string and
141
+ * validate in code.)
142
+ *
143
+ * `auto` became the default in 0.8.27. It could not have been a safe default
144
+ * before 0.8.24: an update only replaced files on disk and needed a restart,
145
+ * which left users running a new browser half against an old host half with no
146
+ * visible signal. 0.8.24 added the hot swap (and `pendingReload` when it is
147
+ * turned off), so an unattended update now actually takes effect. */
148
+ updateMode: z.string().default(DEFAULT_UPDATE_MODE),
132
149
  /** How often (ms) auto mode checks the npm registry for a newer release. */
133
150
  updateCheckIntervalMs: z.number().step(1).min(60000).default(6 * 3600 * 1000),
134
151
  /** Whether a landed update also hot-swaps the running host half (default
@@ -2789,14 +2806,21 @@ export async function apply(ctx, config) {
2789
2806
  // They differ between an applied update and its hot swap (or a boot).
2790
2807
  const loaded = LOADED_VERSION
2791
2808
  const disk = diskVersion()
2792
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2809
+ // Three-layer precedence: the persisted `update-mode` override (settings
2810
+ // UI) beats the profile config, which beats DEFAULT_UPDATE_MODE. The
2811
+ // fallback must not hard-code a second default here, or this path would
2812
+ // silently disagree with the schema default.
2813
+ const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
2793
2814
  const base = {
2794
2815
  current: loaded,
2795
2816
  loaded,
2796
2817
  disk,
2797
2818
  pendingReload: gtVersion(disk, loaded),
2798
- updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual',
2819
+ updateMode: ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE,
2799
2820
  updatedMarker: existsSync(path.join(selfDir(), '.dsh-remote-updated')),
2821
+ // Exposed so the settings page can explain why `auto` may not act:
2822
+ // a link:/dev install must not be overwritten by an npm publish.
2823
+ selfUpdateAllowed: isInstalledCopy(),
2800
2824
  }
2801
2825
  const latest = await fetchLatestVersion()
2802
2826
  if (latest === null) return sendJson(res, 200, { ok: false, ...base, error: '无法连接 npm registry' })
@@ -2875,9 +2899,10 @@ export async function apply(ctx, config) {
2875
2899
 
2876
2900
  // ── auto-update: check on load + on an interval; apply silently ───────────
2877
2901
  // Failures are swallowed (never break the plugin). The persisted override
2878
- // (settings UI) wins over the profile-config default.
2879
- const rawMode = readUpdateMode() || config.updateMode || 'manual'
2880
- const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : 'manual'
2902
+ // (settings UI) wins over the profile-config default, which wins over
2903
+ // DEFAULT_UPDATE_MODE — the single source of truth for the fallback.
2904
+ const rawMode = readUpdateMode() || config.updateMode || DEFAULT_UPDATE_MODE
2905
+ const effectiveUpdateMode = ['manual', 'auto', 'off'].includes(rawMode) ? rawMode : DEFAULT_UPDATE_MODE
2881
2906
  if (effectiveUpdateMode === 'auto') {
2882
2907
  const checkAndApply = async () => {
2883
2908
  const latest = await fetchLatestVersion()
@@ -2892,6 +2917,9 @@ export async function apply(ctx, config) {
2892
2917
  try {
2893
2918
  await applyUpdate(latest)
2894
2919
  } catch {
2920
+ // 常见且预期的失败:link:/开发模式安装时 applyUpdate 会拒绝
2921
+ // (避免覆盖源码仓库)。这不是异常,静默即可——那类用户在自己的
2922
+ // 仓库里更新。
2895
2923
  return
2896
2924
  }
2897
2925
  // Landed: swap the running host half too, otherwise the code being served
package/lib/update.js CHANGED
@@ -39,6 +39,27 @@ export function selfDir() {
39
39
  }
40
40
  }
41
41
 
42
+ /**
43
+ * 本插件是否装在 `node_modules` 之下(即真正的"安装副本")。
44
+ *
45
+ * ★ 为什么必须有这道判断(实测依据,不是防御性猜测):
46
+ * `selfDir()` 基于 `import.meta.url`,而 **Node 的 ESM 解析会对符号链接做
47
+ * realpath**(已实测:经由 symlink 导入时 `import.meta.url` 报告的是
48
+ * `/real/mod.mjs` 而不是 `/link/mod.mjs`)。于是当插件以 `link:` / 开发模式
49
+ * 安装时,`selfDir()` 指向的是**用户的源码仓库**,而不是安装副本。
50
+ * 一旦 `auto` 更新落地,`applyUpdate` 就会拿 npm 包覆盖那份源码
51
+ * (丢改动、脏工作树)。默认改成 `auto` 之后这个风险从"手点才触发"
52
+ * 变成"自动发生",所以在此拦下:不在 node_modules 下就不自更新。
53
+ *
54
+ * 代价:少数把插件放在非标准路径的部署不会被自动更新——他们仍可手动更新。
55
+ * 这个方向的取舍是正确的:宁可少更新,也不能悄悄毁掉源码。
56
+ * @param dir - 要判定的目录(默认本插件的 selfDir())。
57
+ */
58
+ export function isInstalledCopy(dir = selfDir()) {
59
+ const normalized = String(dir).split(path.sep).join('/').toLowerCase()
60
+ return normalized.includes('/node_modules/')
61
+ }
62
+
42
63
  /** Read the installed package.json version; "0.0.0" when unreadable. */
43
64
  export function readVersion() {
44
65
  try {
@@ -77,6 +98,9 @@ export async function fetchLatestVersion(timeoutMs = 8000) {
77
98
  try {
78
99
  const controller = new AbortController()
79
100
  const timer = setTimeout(() => controller.abort(), timeoutMs)
101
+ // 纯看门狗:调用方并不 await 它,所以必须 unref,否则这一段超时窗口
102
+ // 会拖住事件循环、让本该退出的短命进程(脚本、测试)多挂数秒。
103
+ if (typeof timer.unref === 'function') timer.unref()
80
104
  try {
81
105
  const res = await fetch(REGISTRY_URL, { signal: controller.signal, headers: { accept: 'application/json' } })
82
106
  if (!res.ok) return null
@@ -117,6 +141,13 @@ function parseTar(buf) {
117
141
  * @param targetVersion - exact npm version to install (e.g. "0.8.0").
118
142
  */
119
143
  export async function applyUpdate(targetVersion, options = {}) {
144
+ // 拒绝对非安装副本自更新:`auto` 默认开启后,一次自动更新就会覆盖
145
+ // `selfDir()` 指向的目录,而以 link:/开发模式安装时那正是用户的源码仓库
146
+ // (见 isInstalledCopy 的实测说明)。测试用 options.dir 指向临时目录,
147
+ // 因此不受此限(options.dir 存在即视为显式指定,跳过检查)。
148
+ if (!options.dir && !isInstalledCopy()) {
149
+ throw new Error('refusing to self-update: this copy is not under node_modules (link/dev install) — update it from its source repo instead')
150
+ }
120
151
  const tarballUrl = `https://registry.npmjs.org/${NPM_PACKAGE}/-/${NPM_PACKAGE}-${targetVersion}.tgz`
121
152
  const dir = options.dir || selfDir()
122
153
  const doFetch = options.fetchImpl || fetch
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote",
3
- "version": "0.8.26",
3
+ "version": "0.8.27",
4
4
  "description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
5
5
  "license": "MIT",
6
6
  "author": "flymysql <flyphp@outlook.com>",