dsh-remote 0.8.70 → 0.8.72
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/audit.js +45 -0
- package/lib/client.js +32 -0
- package/lib/errors.js +24 -1
- package/lib/index.js +3 -11
- package/lib/pool.js +10 -3
- package/lib/telemetry.js +1 -1
- package/package.json +2 -2
package/lib/audit.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// dsh-remote — audit log domain: the pure parts (formatting + reading).
|
|
2
|
+
//
|
|
3
|
+
// Extracted from lib/index.js (issue #82): the god-file keeps only the thin
|
|
4
|
+
// wiring (config gate + file path); everything testable without a mounted
|
|
5
|
+
// plugin lives here. Behaviour is byte-identical to the inlined version —
|
|
6
|
+
// `audit-open-and-no-deploy.test.js` pins it end to end.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build one audit line (without the trailing newline handling of the caller).
|
|
10
|
+
* `cmd` is flattened to a single line and capped at 400 chars.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} p
|
|
13
|
+
* @param {string} p.op - operation name.
|
|
14
|
+
* @param {string} p.cmd - command text or path detail.
|
|
15
|
+
* @param {number|null} p.code - exit code, or null when not applicable.
|
|
16
|
+
* @param {string} [p.user]
|
|
17
|
+
* @param {string} [p.host]
|
|
18
|
+
* @param {number} [p.port]
|
|
19
|
+
* @param {Date} [p.now] - clock injection for tests.
|
|
20
|
+
* @returns {string} the line INCLUDING the trailing newline.
|
|
21
|
+
*/
|
|
22
|
+
export function formatAuditLine({ op, cmd, code, user, host, port, now }) {
|
|
23
|
+
const iso = (now || new Date()).toISOString()
|
|
24
|
+
const flat = String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)
|
|
25
|
+
return [iso, `${user || '?'}@${host || '?'}:${port}`, op, code == null ? '-' : String(code), flat].join(' | ') + '\n'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the last `limit` audit lines from a file (clamped to [1, 500]).
|
|
30
|
+
* A missing/corrupt file degrades to [] — the audit viewer must never throw.
|
|
31
|
+
*
|
|
32
|
+
* @param {(file: string, enc: string) => string} readFile - fs.readFileSync-compatible.
|
|
33
|
+
* @param {string} file
|
|
34
|
+
* @param {number|string} [limit]
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
export function readAuditLines(readFile, file, limit) {
|
|
38
|
+
try {
|
|
39
|
+
const text = readFile(file, 'utf8')
|
|
40
|
+
const lines = text.split('\n').filter(Boolean)
|
|
41
|
+
return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -206,6 +206,7 @@ window.__ModuleLoader__.load({
|
|
|
206
206
|
'settings.checkUpdate': '检查更新',
|
|
207
207
|
'settings.updateNow': '立即更新',
|
|
208
208
|
'settings.updateMode': '更新模式:',
|
|
209
|
+
'settings.explorerAutoOpen': '打开远程工作区时,自动在侧边栏展开「远程文件」(需安装 better-sidebar)',
|
|
209
210
|
'settings.updateManual': '手动',
|
|
210
211
|
'settings.updateAuto': '自动',
|
|
211
212
|
'settings.updateOff': '关闭',
|
|
@@ -510,6 +511,7 @@ window.__ModuleLoader__.load({
|
|
|
510
511
|
'settings.checkUpdate': 'Check for updates',
|
|
511
512
|
'settings.updateNow': 'Update now',
|
|
512
513
|
'settings.updateMode': 'Update mode:',
|
|
514
|
+
'settings.explorerAutoOpen': 'Auto-open the Remote Files sidebar tab for remote workspaces (via better-sidebar)',
|
|
513
515
|
'settings.updateManual': 'Manual',
|
|
514
516
|
'settings.updateAuto': 'Auto',
|
|
515
517
|
'settings.updateOff': 'Off',
|
|
@@ -1134,6 +1136,9 @@ window.__ModuleLoader__.load({
|
|
|
1134
1136
|
// Mirrors the host default (DEFAULT_UPDATE_MODE = 'auto') so the panel
|
|
1135
1137
|
// does not flash "manual" before the first /update-check answers.
|
|
1136
1138
|
const [updMode, setUpdMode] = React.useState('auto')
|
|
1139
|
+
// Issue #78: initial value comes from localStorage, so a user's choice
|
|
1140
|
+
// survives restarts (the bug was: the panel popped again every restart).
|
|
1141
|
+
const [explorerAuto, setExplorerAuto] = React.useState(() => explorerAutoOpenEnabled())
|
|
1137
1142
|
const checkUpdate = (quiet) => {
|
|
1138
1143
|
if (updBusy) return
|
|
1139
1144
|
setUpdBusy(true); setUpdMsg('')
|
|
@@ -1845,6 +1850,19 @@ window.__ModuleLoader__.load({
|
|
|
1845
1850
|
}, m === 'manual' ? tr('settings.modeManual') : m === 'auto' ? tr('settings.modeAuto') : tr('settings.modeOff')),
|
|
1846
1851
|
),
|
|
1847
1852
|
),
|
|
1853
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, fontSize: 12 } },
|
|
1854
|
+
React.createElement('label', { style: { display: 'flex', gap: 6, alignItems: 'center', cursor: 'pointer', opacity: 0.85 } },
|
|
1855
|
+
React.createElement('input', {
|
|
1856
|
+
type: 'checkbox', checked: explorerAuto, 'data-testid': 'explorer-auto-open',
|
|
1857
|
+
onChange: (e) => {
|
|
1858
|
+
const on = !!(e && e.target && e.target.checked)
|
|
1859
|
+
setExplorerAuto(on)
|
|
1860
|
+
try { window.localStorage.setItem(EXPLORER_AUTO_OPEN_KEY, on ? '1' : '0') } catch { /* private mode: choice lasts this session only */ }
|
|
1861
|
+
},
|
|
1862
|
+
}),
|
|
1863
|
+
tr('settings.explorerAutoOpen'),
|
|
1864
|
+
),
|
|
1865
|
+
),
|
|
1848
1866
|
upd && upd.pendingReload
|
|
1849
1867
|
? React.createElement('div', { style: { marginTop: 6, fontSize: 12, color: T.warn } },
|
|
1850
1868
|
tr('settings.updatePendingReload', { disk: upd.disk, loaded: upd.loaded }))
|
|
@@ -2646,6 +2664,19 @@ window.__ModuleLoader__.load({
|
|
|
2646
2664
|
// resource type, never a better-sidebar card (see registerSidebarIntegration).
|
|
2647
2665
|
const SIDEBAR_EXPLORER_ID = 'dsh-remote:explorer'
|
|
2648
2666
|
|
|
2667
|
+
// Issue #78: durable opt-out for the sidebar auto-open. The in-memory
|
|
2668
|
+
// autoOpenedFor set forgetting across restarts is by design; THIS key is
|
|
2669
|
+
// the user's persistent "别再给我弹远程文件面板". Browser-local because the
|
|
2670
|
+
// feature itself is browser-local. Default ON preserves the behaviour
|
|
2671
|
+
// existing users know — only an explicit uncheck turns it off.
|
|
2672
|
+
const EXPLORER_AUTO_OPEN_KEY = 'dsh-remote:explorerAutoOpen'
|
|
2673
|
+
function explorerAutoOpenEnabled() {
|
|
2674
|
+
try {
|
|
2675
|
+
if (typeof window === 'undefined' || !window.localStorage) return true
|
|
2676
|
+
return window.localStorage.getItem(EXPLORER_AUTO_OPEN_KEY) !== '0'
|
|
2677
|
+
} catch { return true }
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2649
2680
|
function fetchRemoteStatus(sessionId) {
|
|
2650
2681
|
return api('GET', withSessionQuery('/dsh-remote/status', sessionId)).catch(() => null)
|
|
2651
2682
|
}
|
|
@@ -3610,6 +3641,7 @@ window.__ModuleLoader__.load({
|
|
|
3610
3641
|
let autoOpenedFor = new Set()
|
|
3611
3642
|
let disposeAuto = null
|
|
3612
3643
|
const tryAutoOpen = () => {
|
|
3644
|
+
if (!explorerAutoOpenEnabled()) return // issue #78: the user said no, persistently
|
|
3613
3645
|
if (typeof bs.subscribeState !== 'function') return
|
|
3614
3646
|
const snap = bs.getSnapshot && bs.getSnapshot()
|
|
3615
3647
|
if (!snap || !snap.sessionId) return
|
package/lib/errors.js
CHANGED
|
@@ -69,6 +69,17 @@ export function classifyError(err, opts = {}) {
|
|
|
69
69
|
raw: msg,
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
// ECONNRESET is the same class of failure as ECONNREFUSED one step later: the
|
|
73
|
+
// peer (or a firewall/middlebox) tore the TCP connection down. Windows surfaces
|
|
74
|
+
// it where POSIX returns ECONNREFUSED (e.g. loopback under load), so leaving it
|
|
75
|
+
// unclassified made a raw OS errno the user-facing message on exactly one OS.
|
|
76
|
+
if (/ECONNRESET|connection reset/i.test(msg)) {
|
|
77
|
+
return {
|
|
78
|
+
category: 'network',
|
|
79
|
+
hint: `连接被重置${where}:对端或中间设备强行断开了 TCP——常见于防火墙/安全组拦截、端口上跑的不是 sshd、或远端刚重启。重试一次;持续出现则确认端口 ${port || 22} 确实指向 sshd 并检查防火墙。`,
|
|
80
|
+
raw: msg,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
72
83
|
if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
|
|
73
84
|
return {
|
|
74
85
|
category: 'sftp',
|
|
@@ -106,8 +117,20 @@ export function classifyError(err, opts = {}) {
|
|
|
106
117
|
*
|
|
107
118
|
* An UNCLASSIFIED failure still gets the host appended: "Connection lost before
|
|
108
119
|
* handshake" or a raw errno on its own leaves the user unable to tell WHICH
|
|
109
|
-
* machine failed, which is the one thing a multi-machine plugin must never hide.
|
|
120
|
+
* machine failed, which is the one thing a multi-machine plugin must never hide.
|
|
121
|
+
*
|
|
122
|
+
* IDEMPOTENT on purpose: the pool already classifies connect-stage errors, and
|
|
123
|
+
* callers (rw_connect, routes) classify again at their own layer. A message
|
|
124
|
+
* starting with a known hint prefix passes through unchanged instead of
|
|
125
|
+
* growing a second "(原始错误:…)" shell. */
|
|
126
|
+
const KNOWN_HINT_PREFIXES = [
|
|
127
|
+
'主机指纹校验失败', '认证失败', 'SSH agent 不可用', '缺少凭据', '主机不可达',
|
|
128
|
+
'SSH 握手未完成', '连接被拒绝', '连接被重置', 'SFTP 操作失败', '连接/操作超时',
|
|
129
|
+
'无法读取私钥文件', '私钥解析失败',
|
|
130
|
+
]
|
|
110
131
|
export function friendlyMessage(err, opts = {}) {
|
|
132
|
+
const incoming = String((err && err.message) || err || '')
|
|
133
|
+
if (KNOWN_HINT_PREFIXES.some((p) => incoming.startsWith(p))) return incoming
|
|
111
134
|
const c = classifyError(err, opts)
|
|
112
135
|
const where = opts.host ? ` (${opts.host}:${opts.port || 22})` : ''
|
|
113
136
|
if (c.category === 'other') {
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessio
|
|
|
50
50
|
import { TaskManager } from './tasks.js'
|
|
51
51
|
import { ForwardManager } from './forwards.js'
|
|
52
52
|
import { WebAttach } from './web-attach.js'
|
|
53
|
+
import { formatAuditLine, readAuditLines } from './audit.js'
|
|
53
54
|
|
|
54
55
|
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
|
|
55
56
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
@@ -1030,8 +1031,7 @@ export async function apply(ctx, config) {
|
|
|
1030
1031
|
const user = (target && target.username) || config.username
|
|
1031
1032
|
const port = (target && target.port) || config.port
|
|
1032
1033
|
try {
|
|
1033
|
-
|
|
1034
|
-
appendFileSync(auditFile(), line, 'utf8')
|
|
1034
|
+
appendFileSync(auditFile(), formatAuditLine({ op, cmd, code, user, host, port }), 'utf8')
|
|
1035
1035
|
} catch {}
|
|
1036
1036
|
}
|
|
1037
1037
|
|
|
@@ -1076,15 +1076,7 @@ export async function apply(ctx, config) {
|
|
|
1076
1076
|
return [fmt('environment', envs), fmt('location', locs), fmt('tags', tags)].filter(Boolean).join('\n')
|
|
1077
1077
|
}
|
|
1078
1078
|
|
|
1079
|
-
const readAudit = (limit) =>
|
|
1080
|
-
try {
|
|
1081
|
-
const text = readFileSync(auditFile(), 'utf8')
|
|
1082
|
-
const lines = text.split('\n').filter(Boolean)
|
|
1083
|
-
return lines.slice(-Math.max(1, Math.min(Number(limit) || 50, 500)))
|
|
1084
|
-
} catch {
|
|
1085
|
-
return []
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1079
|
+
const readAudit = (limit) => readAuditLines(readFileSync, auditFile(), limit)
|
|
1088
1080
|
|
|
1089
1081
|
// ── ignore rules (defaults + user file) ───────────────────────────────────
|
|
1090
1082
|
// NOTE: this matcher is shared by mirror sync (rw_sync/rw_push) and search.
|
package/lib/pool.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|
|
6
6
|
import { truncate } from './paths.js'
|
|
7
7
|
import { createHostKeyGuard } from './hostkey.js'
|
|
8
8
|
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
+
import { friendlyMessage } from './errors.js'
|
|
9
10
|
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
10
11
|
|
|
11
12
|
const { Client } = ssh2
|
|
@@ -283,11 +284,17 @@ export class SshPool {
|
|
|
283
284
|
const guard = createHostKeyGuard(this.config, knownHostsFile)
|
|
284
285
|
const client = new Client()
|
|
285
286
|
let settled = false
|
|
287
|
+
// Every connect-stage failure is classified HERE, at the layer that knows
|
|
288
|
+
// which machine was being dialed: a bare ssh2 errno ("Timed out while
|
|
289
|
+
// waiting for handshake") names no host, and on a multi-machine plugin the
|
|
290
|
+
// user cannot tell WHICH box failed. friendlyMessage is idempotent, so
|
|
291
|
+
// callers that classify again (rw_connect, routes) do not double-wrap.
|
|
292
|
+
const classify = (err) => new Error(friendlyMessage(err, { host: this.config.host, port: this.config.port }))
|
|
286
293
|
const fail = (err) => {
|
|
287
294
|
if (settled) return
|
|
288
295
|
settled = true
|
|
289
296
|
if (isCurrent() && this.client === client) this.client = null
|
|
290
|
-
throw guard.lastError ? new Error(guard.lastError) : err
|
|
297
|
+
throw classify(guard.lastError ? new Error(guard.lastError) : err)
|
|
291
298
|
}
|
|
292
299
|
|
|
293
300
|
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
@@ -345,14 +352,14 @@ export class SshPool {
|
|
|
345
352
|
if (settled) return
|
|
346
353
|
settled = true
|
|
347
354
|
if (isCurrent() && this.client === client) this.client = null
|
|
348
|
-
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
355
|
+
reject(classify(guard.lastError ? new Error(guard.lastError) : err))
|
|
349
356
|
}
|
|
350
357
|
client.on('ready', () => {
|
|
351
358
|
if (settled) return
|
|
352
359
|
settled = true
|
|
353
360
|
if (!isCurrent()) {
|
|
354
361
|
try { client.end() } catch {}
|
|
355
|
-
reject(new Error('ssh target changed during connect'))
|
|
362
|
+
reject(classify(new Error('ssh target changed during connect')))
|
|
356
363
|
return
|
|
357
364
|
}
|
|
358
365
|
this.client = client
|
package/lib/telemetry.js
CHANGED
|
@@ -28,7 +28,7 @@ import path from 'node:path'
|
|
|
28
28
|
* (默认行为不该需要配置),而是:① 测试必须能把它重定向掉,
|
|
29
29
|
* 否则跑一次 npm test 就会往生产统计里灌假装机(发生过);
|
|
30
30
|
* ② 作者换端点/灰度时不必为了改一行字符串发一次版。 */
|
|
31
|
-
const DEFAULT_HEARTBEAT_URL = 'https://
|
|
31
|
+
const DEFAULT_HEARTBEAT_URL = 'https://slippery-iguana-d1fhb1xmd469ee8c-1256429518.ap-shanghai.app.tcloudbase.com/dsh-hb/heartbeat'
|
|
32
32
|
|
|
33
33
|
/** 解析实际使用的端点(env 覆盖优先,仅接受 https,避免被降级到明文)。 */
|
|
34
34
|
export function heartbeatUrl() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.72",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), list hosts from ~/.ssh/config, pick a remote workspace, operate on it with 21 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, remote DSH web UI attach, port forwarding, host grouping, remote sandbox fencing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
@@ -94,6 +94,6 @@
|
|
|
94
94
|
"scripts": {
|
|
95
95
|
"test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=40000 --test-force-exit --test-concurrency=8",
|
|
96
96
|
"test:coverage": "node scripts/coverage-union.mjs",
|
|
97
|
-
"test:coverage:gate": "node scripts/coverage-union.mjs --gate 70"
|
|
97
|
+
"test:coverage:gate": "node scripts/coverage-union.mjs --gate 70 --file-gate 60"
|
|
98
98
|
}
|
|
99
99
|
}
|