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/README.en.md +141 -153
- package/README.md +115 -135
- package/lib/client.js +251 -3
- package/lib/http-transport.js +93 -1
- package/lib/ignore.js +33 -0
- package/lib/index.js +489 -21
- package/lib/pool.js +132 -53
- package/lib/search.js +53 -8
- package/lib/update.js +30 -7
- 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/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
|
|
834
|
-
|
|
835
|
-
|
|
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')),
|
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/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 }`.
|