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/client.js CHANGED
@@ -105,6 +105,36 @@ window.__ModuleLoader__.load({
105
105
  'settings.audit': '最近执行的远程命令(审计日志)',
106
106
  'settings.auditEmpty': '暂无记录(在远端执行的命令会记录到 $DSH_HOME/remote-workspaces/audit.log)',
107
107
  'settings.auditRefresh': '刷新日志',
108
+ 'settings.webAttach': '远程 DSH 界面',
109
+ 'settings.webAttachHint': '在本机浏览器里打开远程机器上的 DSH 网页界面。不需要在远端开放任何端口:插件会主动 SSH 连出去,在远端启一个只监听 127.0.0.1 的 dsh web,再把它的端口通过 SSH 隧道搬回本机。',
110
+ 'settings.webAttachMachine': '机器',
111
+ 'settings.webAttachOpen': '连接并打开',
112
+ 'settings.webAttachOpening': '正在连接…',
113
+ 'settings.webAttachReopen': '打开',
114
+ 'settings.webAttachClose': '断开',
115
+ 'settings.webAttachCloseStop': '断开并停止远端',
116
+ 'settings.webAttachReconnect': '重连',
117
+ 'settings.webAttachDropped': '连接已断开',
118
+ 'settings.webAttachNone': '尚未连接任何远程界面',
119
+ 'settings.webAttachExisting': '或粘贴远端已在运行的界面地址(http://127.0.0.1:<端口>/?token=…)',
120
+ 'settings.webAttachExistingGo': '连接已有实例',
121
+ 'settings.webAttachTokenWarn': '地址含登录令牌,仅供本机使用,请勿外发。',
122
+ 'settings.webAttachFailed': '连接失败:',
123
+ 'settings.deploy': '远端 dsh 体检与部署',
124
+ 'settings.deployHint': '连接失败大多不是插件的问题,而是远端 dsh 版本或环境不对(例如旧版在 Linux 上无法启动 web)。这里可以只读体检,也可以一键装到远端私有目录(不影响用户在用的版本)。',
125
+ 'settings.deployProbe': '体检(只读)',
126
+ 'settings.deployProbing': '正在体检…',
127
+ 'settings.deployInstall': '部署并验证',
128
+ 'settings.deployInstalling': '正在部署…',
129
+ 'settings.deployConfirm': '将在远端私有目录安装 dsh(不写系统目录、不改 PATH、不覆盖用户在用的版本)。继续?',
130
+ 'settings.deployInvestigate': '让 AI 排查',
131
+ 'settings.deployInvestigating': '正在创建排查会话…',
132
+ 'settings.deployOk': '远端环境可用',
133
+ 'settings.deployBlocked': '远端环境有问题',
134
+ 'settings.deployUseCommand': '将使用的 dsh',
135
+ 'settings.deploySteps': '安装步骤',
136
+ 'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
137
+ 'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
108
138
  'settings.badgePwd': '密码',
109
139
  'settings.badgeKeychain': '钥匙串',
110
140
  'settings.badgeProxy': '跳板',
@@ -144,6 +174,7 @@ window.__ModuleLoader__.load({
144
174
  'settings.updateAppliedNoReload': '✅ 已更新到 v{to}({from} → {to})。重启 Harness 生效。',
145
175
  'settings.updatePendingReload': '⚠️ 磁盘已是 v{disk},运行中的仍是 v{loaded};点「立即更新」或重启 Harness 完成切换。',
146
176
  'settings.selfUpdateBlocked': 'ℹ️ 当前是 link:/开发模式安装(不在 node_modules 下),已禁止自动更新以免覆盖源码——请在你的源码仓库里更新。',
177
+ 'settings.updateRegistryError': 'npm registry 查询失败:{msg}',
147
178
  'settings.updateFailed': '更新失败',
148
179
  'settings.updateFailedMsg': '更新失败: {msg}',
149
180
  'settings.registryUnreachable': '无法连接 npm registry',
@@ -332,6 +363,36 @@ window.__ModuleLoader__.load({
332
363
  'settings.audit': 'Recent remote commands (audit log)',
333
364
  'settings.auditEmpty': 'No records yet (commands run remotely are logged to $DSH_HOME/remote-workspaces/audit.log)',
334
365
  'settings.auditRefresh': 'Refresh log',
366
+ 'settings.webAttach': 'Remote DSH Web UI',
367
+ 'settings.webAttachHint': 'Open the DSH web interface of a remote machine in this browser. The remote never opens an inbound port: this plugin SSHes out, starts a loopback-only dsh web there, and carries its port back over the SSH connection.',
368
+ 'settings.webAttachMachine': 'Machine',
369
+ 'settings.webAttachOpen': 'Connect & open',
370
+ 'settings.webAttachOpening': 'Connecting…',
371
+ 'settings.webAttachReopen': 'Open',
372
+ 'settings.webAttachClose': 'Disconnect',
373
+ 'settings.webAttachCloseStop': 'Disconnect & stop remote',
374
+ 'settings.webAttachReconnect': 'Reconnect',
375
+ 'settings.webAttachDropped': 'Disconnected',
376
+ 'settings.webAttachNone': 'No remote UI attached',
377
+ 'settings.webAttachExisting': 'Or paste the URL of an interface already running there (http://127.0.0.1:<port>/?token=…)',
378
+ 'settings.webAttachExistingGo': 'Attach to running instance',
379
+ 'settings.webAttachTokenWarn': 'The URL carries a login token. It is for this machine only — do not share it.',
380
+ 'settings.webAttachFailed': 'Could not attach: ',
381
+ 'settings.deploy': 'Remote dsh health check & deploy',
382
+ 'settings.deployHint': 'A failed connect is usually not the plugin\'s fault: the remote dsh version or environment is wrong (an old one cannot start the web surface on Linux at all). Check read-only, or install into a private directory on the remote that leaves the user\'s own version untouched.',
383
+ 'settings.deployProbe': 'Check (read-only)',
384
+ 'settings.deployProbing': 'Checking…',
385
+ 'settings.deployInstall': 'Deploy & verify',
386
+ 'settings.deployInstalling': 'Deploying…',
387
+ 'settings.deployConfirm': 'Install dsh into a private directory on the remote (no system directory, no PATH change, no replacing the version in use). Continue?',
388
+ 'settings.deployInvestigate': 'Let the AI investigate',
389
+ 'settings.deployInvestigating': 'Creating a session…',
390
+ 'settings.deployOk': 'Remote environment is usable',
391
+ 'settings.deployBlocked': 'Remote environment has a problem',
392
+ 'settings.deployUseCommand': 'dsh that will be used',
393
+ 'settings.deploySteps': 'Install steps',
394
+ 'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
395
+ 'settings.deployInvestigateStarted': 'A session was created — follow up there.',
335
396
  'settings.badgePwd': 'password',
336
397
  'settings.badgeKeychain': 'keychain',
337
398
  'settings.badgeProxy': 'jump',
@@ -371,6 +432,7 @@ window.__ModuleLoader__.load({
371
432
  'settings.updateAppliedNoReload': '✅ Updated to v{to} ({from} → {to}). Restart Harness to apply.',
372
433
  'settings.updatePendingReload': '⚠️ v{disk} is on disk but v{loaded} is still running; update again or restart Harness to switch.',
373
434
  '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.',
435
+ 'settings.updateRegistryError': 'npm registry query failed: {msg}',
374
436
  'settings.updateFailed': 'Update failed',
375
437
  'settings.updateFailedMsg': 'Update failed: {msg}',
376
438
  'settings.registryUnreachable': 'Cannot reach the npm registry',
@@ -783,6 +845,21 @@ window.__ModuleLoader__.load({
783
845
  const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
784
846
  const [fwdOpen, setFwdOpen] = React.useState(true)
785
847
  const [auditOpen, setAuditOpen] = React.useState(false)
848
+ // Issue #46: attach a REMOTE machine's DSH Web UI as a local page. One
849
+ // session per machine; `webAttach` holds the machineId currently opening.
850
+ const [webAttaches, setWebAttaches] = React.useState([])
851
+ const [webAttachMachine, setWebAttachMachine] = React.useState('')
852
+ const [webAttachOpen, setWebAttachOpen] = React.useState(false)
853
+ const [webAttachBusy, setWebAttachBusy] = React.useState('')
854
+ const [webAttachErr, setWebAttachErr] = React.useState('')
855
+ const [webAttachExisting, setWebAttachExisting] = React.useState('')
856
+ // Issue #46 P1–P4: deterministic remote deployment + the AI escape hatch.
857
+ // `deploy` holds the last probe/install outcome for the selected machine;
858
+ // `deployBusy` is the running step id, so the UI can show progress.
859
+ const [deploy, setDeploy] = React.useState(null)
860
+ const [deployBusy, setDeployBusy] = React.useState('')
861
+ const [deployErr, setDeployErr] = React.useState('')
862
+ const [deployOpen, setDeployOpen] = React.useState(false)
786
863
  React.useEffect(() => { ensureChromeCss() }, [])
787
864
 
788
865
  const testConn = () => {
@@ -830,9 +907,17 @@ window.__ModuleLoader__.load({
830
907
  setUpdBusy(true); setUpdMsg('')
831
908
  api('GET', '/dsh-remote/update-check')
832
909
  .then((r) => {
833
- if (!r || !r.ok) { setUpdMsg((r && r.error) || tr('settings.updateFail')); return }
834
- setUpd(r)
835
- if (r.updateMode) setUpdMode(r.updateMode)
910
+ if (!r) { setUpdMsg(tr('settings.updateFail')); return }
911
+ // 即使 registry 查不到(限流/离线/超时),也先用本地状态填充面板,
912
+ // 否则 latest 永远为 null,界面会一直停在「版本信息加载中…」——
913
+ // 看起来像卡死,而本地版本/更新模式其实都是已知的。
914
+ if (r.ok) { setUpd(r); if (r.updateMode) setUpdMode(r.updateMode) }
915
+ if (r.registryError) {
916
+ // 把真实原因说出来:限流是"稍后自动恢复",与"网络坏了"处置不同。
917
+ setUpdMsg(tr('settings.updateRegistryError', { msg: r.registryError }))
918
+ return
919
+ }
920
+ if (!r.ok) { setUpdMsg(r.error || tr('settings.updateFail')); return }
836
921
  if (!quiet) setUpdMsg(r.updateAvailable ? tr('settings.updateAvailable', { version: r.latest }) : tr('settings.updateLatest', { version: r.current }))
837
922
  })
838
923
  .catch((e) => setUpdMsg(String((e && e.message) || e)))
@@ -864,6 +949,43 @@ window.__ModuleLoader__.load({
864
949
  const refreshForwards = () => api('GET', '/dsh-remote/forwards').then((r) => setForwards(r.forwards || [])).catch(() => {})
865
950
  React.useEffect(() => { refreshForwards(); api('GET', '/dsh-remote/audit?limit=30').then((r) => setAuditLines(r.lines || [])).catch(() => {}) }, [])
866
951
 
952
+ // ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
953
+ // The remote never opens an inbound port; we SSH out and carry the remote
954
+ // loopback web UI back to a local port. One tunnel per machine.
955
+ const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
956
+ .then((r) => setWebAttaches(r.attaches || []))
957
+ .catch(() => {})
958
+ React.useEffect(() => { refreshWebAttaches(); const t = setInterval(refreshWebAttaches, 15000); return () => clearInterval(t) }, [])
959
+ const webAttachAction = (action, machineId, extra) => {
960
+ setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
961
+ return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
962
+ .then((r) => { refreshWebAttaches(); return r })
963
+ .catch((e) => { setWebAttachErr(String((e && e.message) || e)); return null })
964
+ .then((r) => { setWebAttachBusy(''); return r })
965
+ }
966
+
967
+ // ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
968
+ // The probe is read-only, so it is safe to run before the user agrees to
969
+ // anything; the install requires an explicit second click (confirm: true).
970
+ const deployAction = (action, machineId, extra) => {
971
+ setDeployBusy(action); setDeployErr('')
972
+ return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
973
+ .then((r) => {
974
+ if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
975
+ if (action === 'install') setDeploy({ kind: 'install', ...r, at: Date.now() })
976
+ if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
977
+ return r
978
+ })
979
+ .catch((e) => {
980
+ const msg = String((e && e.message) || e)
981
+ setDeployErr(msg)
982
+ // Keep the failing step list so the operator sees WHERE it stopped.
983
+ setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
984
+ return null
985
+ })
986
+ .then((r) => { setDeployBusy(''); refreshWebAttaches(); return r })
987
+ }
988
+
867
989
  const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
868
990
  const setFb = (k) => (ev) => setForm({ ...form, [k]: ev.target.checked })
869
991
  const startEdit = (m) => {
@@ -1082,6 +1204,132 @@ window.__ModuleLoader__.load({
1082
1204
  workspaces.map((w, i) => React.createElement('div', { key: w + i, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
1083
1205
  React.createElement('code', { style: { flex: 1, fontSize: 12, fontFamily: 'monospace' } }, w),
1084
1206
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' }, onClick: () => setWorkspaceNow(w) }, tr('settings.switch'))))) : null,
1207
+ // 远程 DSH 界面(issue #46)
1208
+ React.createElement('div', { style: boxS },
1209
+ sectionToggle(webAttachOpen, setWebAttachOpen, tr('settings.webAttach')),
1210
+ webAttachOpen ? React.createElement(React.Fragment, null,
1211
+ React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.webAttachHint')),
1212
+ // The URL below carries a one-time login token. It stays on loopback,
1213
+ // but a user could paste it into a chat — say so where the URL is.
1214
+ React.createElement('div', { style: { color: T.warn, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.webAttachTokenWarn')),
1215
+ // Live tunnels, one row per machine.
1216
+ webAttaches.length ? webAttaches.map((a) => React.createElement('div', { key: a.machineId || a.localPort, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', fontSize: 12, flexWrap: 'wrap' } },
1217
+ React.createElement('span', { style: { color: a.active ? T.ok : T.muted } }, a.active ? '●' : '○'),
1218
+ React.createElement('code', { style: { fontFamily: 'monospace' } }, a.cleanUrl || ''),
1219
+ React.createElement('span', { style: { opacity: 0.6, fontFamily: 'monospace' } }, '→ :' + a.remotePort),
1220
+ a.active ? React.createElement('a', {
1221
+ className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, textDecoration: 'none' },
1222
+ // The token rides on this loopback URL only; DSH exchanges it
1223
+ // for a signed cookie and redirects to a clean '/'.
1224
+ href: a.url, target: '_blank', rel: 'noopener noreferrer',
1225
+ }, tr('settings.webAttachReopen')) : null,
1226
+ // A tunnel whose listener died (remote restart, sshd drop) keeps
1227
+ // its row so the user can see what happened and reconnect. The
1228
+ // remote process is NOT auto-restarted: that would silently
1229
+ // resurrect a server on a machine the user may be done with.
1230
+ !a.active ? React.createElement('button', {
1231
+ type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1232
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('open', a.machineId),
1233
+ }, tr('settings.webAttachReconnect')) : null,
1234
+ !a.active ? React.createElement('span', { style: { color: T.muted, fontSize: 11 } }, a.error || tr('settings.webAttachDropped')) : null,
1235
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1236
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('close', a.machineId) }, tr('settings.webAttachClose')),
1237
+ // Only offered for a process we started: an attach onto the user's
1238
+ // own running DSH must never signal it.
1239
+ a.managed
1240
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1241
+ disabled: !!webAttachBusy, onClick: () => webAttachAction('close', a.machineId, { stopRemote: true }) }, tr('settings.webAttachCloseStop'))
1242
+ : null)) : React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.webAttachNone')),
1243
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' } },
1244
+ React.createElement('select', { className: 'dsh-rw-input', value: webAttachMachine, onChange: (e) => setWebAttachMachine(e.target.value), style: { ...inputS, maxWidth: 200 } },
1245
+ React.createElement('option', { value: '' }, tr('settings.webAttachMachine') + '…'),
1246
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`))),
1247
+ React.createElement('button', {
1248
+ type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS,
1249
+ disabled: !!webAttachBusy || !webAttachMachine,
1250
+ // The machine is named explicitly in the request, so attaching
1251
+ // never has to move the user's "current machine" selection.
1252
+ onClick: () => webAttachAction('open', webAttachMachine),
1253
+ }, webAttachBusy.endsWith(':open') ? tr('settings.webAttachOpening') : tr('settings.webAttachOpen'))),
1254
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' } },
1255
+ React.createElement('input', { className: 'dsh-rw-input', value: webAttachExisting, onChange: (e) => setWebAttachExisting(e.target.value), placeholder: tr('settings.webAttachExisting'), style: { ...inputS, flex: 1, minWidth: 260 } }),
1256
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px' },
1257
+ disabled: !!webAttachBusy || !webAttachMachine || !webAttachExisting.trim(),
1258
+ onClick: () => webAttachAction('open', webAttachMachine, { existingUrl: webAttachExisting.trim() }).then(() => setWebAttachExisting('')) }, tr('settings.webAttachExistingGo'))),
1259
+ webAttachErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, tr('settings.webAttachFailed') + webAttachErr) : null,
1260
+ ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, String(webAttaches.length)),
1261
+ ),
1262
+ // 远端 dsh 体检与部署(issue #46 P1–P4):确定性的体检/安装,失败再交给 AI。
1263
+ React.createElement('div', { style: boxS },
1264
+ sectionToggle(deployOpen, setDeployOpen, tr('settings.deploy')),
1265
+ deployOpen ? React.createElement(React.Fragment, null,
1266
+ React.createElement('div', { style: { opacity: 0.65, fontSize: 11, lineHeight: 1.5, marginBottom: 6 } }, tr('settings.deployHint')),
1267
+ React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6, flexWrap: 'wrap' } },
1268
+ React.createElement('select', { className: 'dsh-rw-input', value: webAttachMachine, onChange: (e) => setWebAttachMachine(e.target.value), style: { ...inputS, maxWidth: 200 } },
1269
+ React.createElement('option', { value: '' }, tr('settings.webAttachMachine') + '…'),
1270
+ machines.map((m) => React.createElement('option', { key: m.id, value: m.id }, `${m.name || m.host} (${m.username}@${m.host})`))),
1271
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
1272
+ disabled: !!deployBusy || !webAttachMachine,
1273
+ onClick: () => deployAction('probe', webAttachMachine) },
1274
+ deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
1275
+ // Verdict from the last probe/install, rendered as findings with the
1276
+ // severity the backend assigned — the UI never re-classifies them.
1277
+ deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
1278
+ deploy.kind === 'probe' && deploy.verdict
1279
+ ? React.createElement('div', { style: { fontWeight: 600, color: deploy.verdict.ok ? T.ok : T.danger, marginBottom: 4 } },
1280
+ deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked'))
1281
+ : null,
1282
+ (deploy.verdict && deploy.verdict.findings || []).map((f, i) => React.createElement('div', { key: f.code + i, style: { display: 'flex', gap: 6, alignItems: 'flex-start', padding: '2px 0' } },
1283
+ React.createElement('span', { style: { color: f.severity === 'blocker' ? T.danger : f.severity === 'warn' ? T.warn : f.severity === 'ok' ? T.ok : T.muted } },
1284
+ f.severity === 'blocker' ? '●' : f.severity === 'ok' ? '●' : '○'),
1285
+ React.createElement('span', null,
1286
+ React.createElement('span', null, f.summary),
1287
+ f.detail ? React.createElement('span', { style: { opacity: 0.7 } }, ' —— ' + f.detail) : null,
1288
+ f.fix ? React.createElement('div', { style: { opacity: 0.8, fontSize: 11 } }, '→ ' + f.fix) : null))),
1289
+ deploy.command || (deploy.verdict && deploy.verdict.useCommand)
1290
+ ? React.createElement('div', { style: { marginTop: 4, fontFamily: 'monospace', fontSize: 11, opacity: 0.8, wordBreak: 'break-all' } },
1291
+ tr('settings.deployUseCommand') + ':' + (deploy.command || deploy.verdict.useCommand))
1292
+ : null,
1293
+ // Per-step progress: a failure names the step it stopped at, which
1294
+ // is the whole reason the plan is split into separate commands.
1295
+ Array.isArray(deploy.steps) && deploy.steps.length
1296
+ ? React.createElement('div', { style: { marginTop: 6 } },
1297
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: 2 } }, tr('settings.deploySteps')),
1298
+ deploy.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
1299
+ React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
1300
+ React.createElement('span', null, s.title),
1301
+ !s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
1302
+ : null,
1303
+ deploy.ok === true && deploy.steps
1304
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployDone'))
1305
+ : null,
1306
+ deploy.kind === 'investigate' && deploy.sessionId
1307
+ ? React.createElement('div', { style: { color: T.ok, marginTop: 4 } }, tr('settings.deployInvestigateStarted') + ' (' + deploy.sessionId + ')')
1308
+ : null,
1309
+ ) : null,
1310
+ // The install button only appears when a probe says it is both needed
1311
+ // and possible; clicking it is the explicit consent (confirm: true).
1312
+ deploy && deploy.verdict && deploy.verdict.canAutoInstall && !deploy.verdict.ok
1313
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
1314
+ disabled: !!deployBusy,
1315
+ onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
1316
+ deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall'))
1317
+ : null,
1318
+ // The AI escape hatch: offered after a failure, and ONLY on click —
1319
+ // it costs a model call, so it is never automatic.
1320
+ (deployErr || (deploy && deploy.ok === false))
1321
+ ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', marginTop: 6 },
1322
+ disabled: !!deployBusy,
1323
+ onClick: () => deployAction('investigate', webAttachMachine, {
1324
+ findings: (deploy && deploy.verdict && deploy.verdict.findings) || [],
1325
+ facts: deploy && deploy.facts,
1326
+ error: deployErr || (deploy && deploy.error) || '',
1327
+ }) },
1328
+ deployBusy === 'investigate' ? tr('settings.deployInvestigating') : tr('settings.deployInvestigate'))
1329
+ : null,
1330
+ deployErr ? React.createElement('div', { style: { color: T.danger, fontSize: 12, marginTop: 6, wordBreak: 'break-all' } }, deployErr) : null,
1331
+ ) : React.createElement('div', { style: { fontSize: 11, opacity: 0.55 } }, deploy && deploy.verdict ? (deploy.verdict.ok ? tr('settings.deployOk') : tr('settings.deployBlocked')) : ''),
1332
+ ),
1085
1333
  // 端口转发面板
1086
1334
  React.createElement('div', { style: boxS },
1087
1335
  sectionToggle(fwdOpen, setFwdOpen, tr('settings.forwards')),
@@ -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/ignore.js CHANGED
@@ -106,6 +106,39 @@ export const DEFAULT_IGNORE = [
106
106
  '.dsh-remote-sync-state.json',
107
107
  ]
108
108
 
109
+ /**
110
+ * Extra directories skipped by `rw_search` (but NOT by mirror sync — a user may
111
+ * legitimately want their cache tree mirrored, and silently excluding it from
112
+ * sync would lose data).
113
+ *
114
+ * These are machine-local package/tool caches: enormous numbers of tiny files
115
+ * that essentially never contain what a human is searching for, and that are
116
+ * the main way a "search my home directory" turns into a multi-hour walk
117
+ * (issue #44 reports `~/.npm/_cacache` specifically). Searching them is
118
+ * available via an explicit `path`, which bypasses these excludes.
119
+ */
120
+ export const DEFAULT_SEARCH_IGNORE = [
121
+ ...DEFAULT_IGNORE,
122
+ '.npm/',
123
+ '.cache/',
124
+ '.local/',
125
+ '.cargo/',
126
+ '.rustup/',
127
+ '.gradle/',
128
+ '.m2/',
129
+ '.pnpm-store/',
130
+ '.yarn/',
131
+ '.conda/',
132
+ '.docker/',
133
+ '.nvm/',
134
+ '.pyenv/',
135
+ '.Trash/',
136
+ '.Trash-1000/',
137
+ 'snap/',
138
+ 'AppData/',
139
+ 'Library/Caches/',
140
+ ]
141
+
109
142
  /**
110
143
  * Load the user ignore file (gitignore syntax) and merge with defaults.
111
144
  * Returns `{ patterns, fromFile }`.