dsh-remote 0.8.54 → 0.8.61
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 +19 -3
- package/README.md +18 -4
- package/lib/client.js +360 -79
- package/lib/credential.js +52 -4
- package/lib/errors.js +34 -3
- package/lib/index.js +229 -30
- package/lib/instructions.js +288 -288
- package/lib/pool.js +10 -1
- package/lib/registry.js +118 -4
- package/lib/routes-fs.js +14 -2
- package/lib/web-deploy.js +7 -1
- package/package.json +7 -3
package/lib/registry.js
CHANGED
|
@@ -58,15 +58,129 @@ export function saveMachines(file, list, currentId, keepCurrentKey = true) {
|
|
|
58
58
|
writeFileSync(file, JSON.stringify(payload, null, 2))
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Does this record actually HAVE a password the plugin can use?
|
|
63
|
+
*
|
|
64
|
+
* `passwordSet` used to be `!!m.password`, i.e. "a PLAINTEXT password exists in
|
|
65
|
+
* machines.json". A machine whose password lives in the OS keychain stores
|
|
66
|
+
* `password: ''` (by design — issue #30), so it reported `passwordSet: false`
|
|
67
|
+
* and the settings page showed an empty password box with no indication that a
|
|
68
|
+
* credential was saved. That is the difference between "the plugin forgot my
|
|
69
|
+
* password" and "the plugin has it and will not echo it".
|
|
70
|
+
*
|
|
71
|
+
* `secretStored` is written by the save route from the keychain result, so new
|
|
72
|
+
* records carry the truth. Records saved before that flag existed only carry the
|
|
73
|
+
* backend name, which is the best evidence available for them (a non-plain
|
|
74
|
+
* backend means a secret was meant to live in the OS store).
|
|
75
|
+
*
|
|
76
|
+
* @param {object} m - a registry record.
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function hasStoredPassword(m) {
|
|
80
|
+
if (!m) return false
|
|
81
|
+
if (m.password) return true
|
|
82
|
+
if (m.secretStored === true) return true
|
|
83
|
+
if (m.secretStored === undefined && m.credentialBackend && m.credentialBackend !== 'plain') return true
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Strip secrets before sending a machine to the client.
|
|
88
|
+
*
|
|
89
|
+
* Secrets never round-trip to the browser: a password is replaced by
|
|
90
|
+
* `passwordSet`, and the private-key `passphrase` — which IS stored in the
|
|
91
|
+
* record — is replaced by `passphraseSet`. Both flags exist so the settings form
|
|
92
|
+
* can say "已保存(••••••••),留空 = 不修改" instead of showing an empty box that
|
|
93
|
+
* looks like the plugin lost the value. */
|
|
62
94
|
export function sanitizeMachine(m) {
|
|
63
95
|
if (!m) return m
|
|
64
|
-
const { password, proxy, ...rest } = m
|
|
65
|
-
const out = {
|
|
66
|
-
|
|
96
|
+
const { password, passphrase, proxy, ...rest } = m
|
|
97
|
+
const out = {
|
|
98
|
+
...rest,
|
|
99
|
+
passwordSet: hasStoredPassword(m),
|
|
100
|
+
passphraseSet: !!(passphrase && String(passphrase).length),
|
|
101
|
+
}
|
|
102
|
+
if (proxy) {
|
|
103
|
+
out.proxy = {
|
|
104
|
+
...proxy,
|
|
105
|
+
password: proxy.password ? '' : undefined,
|
|
106
|
+
passwordSet: !!(proxy.password && proxy.password.length),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
67
109
|
return out
|
|
68
110
|
}
|
|
69
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the credentials a TEST probe must dial with (issue: "编辑已连接机器 → 测试
|
|
114
|
+
* 连接" always failed with `no credentials`).
|
|
115
|
+
*
|
|
116
|
+
* The settings form never receives stored secrets, so an edit-then-test posted an
|
|
117
|
+
* EMPTY password while a real connect (which resolves the keychain/plain secret
|
|
118
|
+
* through the pool's `passwordResolver`) worked fine. The probe therefore has to
|
|
119
|
+
* be a MERGE: the form is an override layer, and anything it leaves blank falls
|
|
120
|
+
* back to what is stored for that machine.
|
|
121
|
+
*
|
|
122
|
+
* Rules, deliberately one-way (a form value never gets discarded):
|
|
123
|
+
* · a non-empty form string wins;
|
|
124
|
+
* · an empty/absent form string keeps the stored value;
|
|
125
|
+
* · booleans (`useAgent`, `keyboardInteractive`) are taken from the form when
|
|
126
|
+
* the form actually supplied a boolean, otherwise from the record — the
|
|
127
|
+
* probe must exercise the SAME auth method the machine will use;
|
|
128
|
+
* · a jump-host (`proxy`) password the form did not re-supply is carried over
|
|
129
|
+
* from the stored proxy, otherwise a bastion-protected machine "fails to
|
|
130
|
+
* test" for a reason that has nothing to do with the target.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} body - the probe request body (form values).
|
|
133
|
+
* @param {object|null} rec - the saved machine record, if any.
|
|
134
|
+
* @param {string} [storedPassword] - the record's resolvable password (may come
|
|
135
|
+
* from the OS keychain, hence the separate argument).
|
|
136
|
+
* @returns {{password:string,passphrase:string,privateKeyPath:string,useAgent:boolean,
|
|
137
|
+
* keyboardInteractive:boolean,hostKeyMode:string,sshBinary:string,backend:string,
|
|
138
|
+
* proxy:object|undefined,usedSavedCredential:boolean}}
|
|
139
|
+
*/
|
|
140
|
+
export function resolveProbeCredentials(body = {}, rec = null, storedPassword = '') {
|
|
141
|
+
const form = body || {}
|
|
142
|
+
const saved = rec || {}
|
|
143
|
+
const str = (v) => (v === undefined || v === null ? '' : String(v))
|
|
144
|
+
const pick = (formVal, storedVal) => {
|
|
145
|
+
const f = str(formVal).trim()
|
|
146
|
+
return f !== '' ? str(formVal) : str(storedVal)
|
|
147
|
+
}
|
|
148
|
+
const pickBool = (formVal, storedVal) => (typeof formVal === 'boolean' ? formVal : !!storedVal)
|
|
149
|
+
|
|
150
|
+
const formPassword = str(form.password)
|
|
151
|
+
const password = formPassword !== '' ? formPassword : str(storedPassword)
|
|
152
|
+
const formProxy = form.proxy && form.proxy.host ? form.proxy : null
|
|
153
|
+
const savedProxy = saved.proxy && saved.proxy.host ? saved.proxy : null
|
|
154
|
+
const baseProxy = formProxy || savedProxy
|
|
155
|
+
const proxy = baseProxy
|
|
156
|
+
? {
|
|
157
|
+
host: String(baseProxy.host),
|
|
158
|
+
port: Number(baseProxy.port) || 22,
|
|
159
|
+
username: str(baseProxy.username),
|
|
160
|
+
password: formProxy && str(formProxy.password) !== ''
|
|
161
|
+
? str(formProxy.password)
|
|
162
|
+
: str(savedProxy && savedProxy.password),
|
|
163
|
+
privateKeyPath: str(baseProxy.privateKeyPath),
|
|
164
|
+
}
|
|
165
|
+
: undefined
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
password,
|
|
169
|
+
passphrase: pick(form.passphrase, saved.passphrase),
|
|
170
|
+
privateKeyPath: pick(form.privateKeyPath, saved.privateKeyPath),
|
|
171
|
+
useAgent: pickBool(form.useAgent, saved.useAgent),
|
|
172
|
+
keyboardInteractive: pickBool(form.keyboardInteractive, saved.keyboardInteractive),
|
|
173
|
+
hostKeyMode: pick(form.hostKeyMode, saved.hostKeyMode),
|
|
174
|
+
sshBinary: pick(form.sshBinary, saved.sshBinary),
|
|
175
|
+
backend: pick(form.backend, saved.backend) || 'native',
|
|
176
|
+
proxy,
|
|
177
|
+
// True when the form could not supply the credential and the stored one had
|
|
178
|
+
// to be used — the UI says so, because "测试成功" with an empty password box
|
|
179
|
+
// is otherwise indistinguishable from a form-only probe.
|
|
180
|
+
usedSavedCredential: formPassword === '' && password !== '',
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
70
184
|
/** Machine grouping metadata (host organisation).
|
|
71
185
|
*
|
|
72
186
|
* A flat list does not survive contact with reality: once a user has a dozen
|
package/lib/routes-fs.js
CHANGED
|
@@ -37,7 +37,17 @@ export function createFsRoutes({
|
|
|
37
37
|
path: '/dsh-remote/ls',
|
|
38
38
|
handler: async (req, res) => {
|
|
39
39
|
try {
|
|
40
|
-
|
|
40
|
+
// A POST body counts. This route used to read the session hint from the
|
|
41
|
+
// QUERY STRING only, so `POST /ls {sessionId}` silently listed the ACTIVE
|
|
42
|
+
// machine instead of the caller's — the wrong-host hazard issue #25 is
|
|
43
|
+
// about, reachable through the API even though the browser always uses
|
|
44
|
+
// GET. `/read`, `/write` and `/fs` already honoured the body; now this one
|
|
45
|
+
// does too, and `path` may come from either place.
|
|
46
|
+
let body = {}
|
|
47
|
+
if (req.method === 'POST') {
|
|
48
|
+
try { body = JSON.parse((await readBody(req)) || '{}') } catch { body = {} }
|
|
49
|
+
}
|
|
50
|
+
const hint = requestSessionHint(req, body)
|
|
41
51
|
const b = await bindOrReply(req, res, hint)
|
|
42
52
|
if (!b) return
|
|
43
53
|
// Identity echo (external-switch defense): name the machine this
|
|
@@ -53,7 +63,9 @@ export function createFsRoutes({
|
|
|
53
63
|
}
|
|
54
64
|
await b.pool.detect()
|
|
55
65
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
56
|
-
const raw = q.get('path')
|
|
66
|
+
const raw = q.get('path')
|
|
67
|
+
? decodeURIComponent(q.get('path'))
|
|
68
|
+
: (body.path ? String(body.path) : (b.ws || ''))
|
|
57
69
|
const canon = normalizeRemotePath(raw)
|
|
58
70
|
const win = b.pool.platform === 'windows'
|
|
59
71
|
if (canon === '/' || raw === '' || raw === '/') {
|
package/lib/web-deploy.js
CHANGED
|
@@ -595,7 +595,13 @@ function q(s) {
|
|
|
595
595
|
*/
|
|
596
596
|
export function buildInstallPlan(spec) {
|
|
597
597
|
const facts = spec.facts || {}
|
|
598
|
-
const prefix = String(spec.prefix || '').replace(/\/+$/, '')
|
|
598
|
+
const prefix = String(spec.prefix || '').trim().replace(/\/+$/, '')
|
|
599
|
+
// An empty prefix emits `mkdir -p ''` / `cd ''` / `--prefix ''`: commands that
|
|
600
|
+
// either fail confusingly or (worse) touch the filesystem root. Refuse to build
|
|
601
|
+
// such a plan at all — the caller must resolve a prefix first.
|
|
602
|
+
if (!prefix || prefix === '/') {
|
|
603
|
+
throw new Error('buildInstallPlan: refusing to build a plan without a private install prefix')
|
|
604
|
+
}
|
|
599
605
|
const version = String(spec.version || DEFAULT_INSTALL_VERSION)
|
|
600
606
|
const registry = String(spec.registry || '')
|
|
601
607
|
const env = spec.env && typeof spec.env === 'object' ? spec.env : undefined
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive
|
|
3
|
+
"version": "0.8.61",
|
|
4
|
+
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive, multi-hop ProxyJump), pick a remote workspace, operate on it with 22 rw_* tools, conflict-aware mirror sync, an embedded PTY terminal, one-click remote dsh deploy + 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>",
|
|
7
7
|
"readme": "README.md",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"keywords": [
|
|
18
18
|
"deepseek-harness",
|
|
19
19
|
"dsh",
|
|
20
|
+
"sandbox",
|
|
21
|
+
"terminal",
|
|
20
22
|
"dsh-plugin",
|
|
21
23
|
"remote",
|
|
22
24
|
"ssh",
|
|
@@ -90,6 +92,8 @@
|
|
|
90
92
|
"semver": "^7.8.5"
|
|
91
93
|
},
|
|
92
94
|
"scripts": {
|
|
93
|
-
"test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=
|
|
95
|
+
"test": "node --import ./test/setup-telemetry-off.mjs --test --test-timeout=40000 --test-force-exit",
|
|
96
|
+
"test:coverage": "node scripts/coverage-union.mjs",
|
|
97
|
+
"test:coverage:gate": "node scripts/coverage-union.mjs --gate 70"
|
|
94
98
|
}
|
|
95
99
|
}
|