dsh-remote 0.8.53 → 0.8.60
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 +37 -0
- package/README.md +65 -0
- package/lib/client.js +3853 -3730
- package/lib/credential.js +102 -16
- package/lib/errors.js +34 -3
- package/lib/index.js +340 -35
- package/lib/instructions.js +288 -288
- package/lib/pool.js +95 -2
- package/lib/registry.js +202 -4
- package/lib/remote-sandbox.js +183 -0
- package/lib/routes-fs.js +14 -2
- package/lib/routes-term.js +169 -0
- package/lib/terminal.js +205 -0
- package/lib/web-deploy.js +7 -1
- package/package.json +7 -3
package/lib/pool.js
CHANGED
|
@@ -20,6 +20,12 @@ const { Client } = ssh2
|
|
|
20
20
|
* caller can keep using `new SshPool(...)`-shaped code — this factory only has
|
|
21
21
|
* to decide which class to instantiate.
|
|
22
22
|
*/
|
|
23
|
+
/** Hard cap on proxy-chain depth. OpenSSH's ProxyJump has no explicit limit;
|
|
24
|
+
* this exists to turn a config CYCLE into a clear error instead of unbounded
|
|
25
|
+
* recursion. A legitimate chain is 1-3 hops.
|
|
26
|
+
* @type {number} */
|
|
27
|
+
export const MAX_JUMP_HOPS = 8
|
|
28
|
+
|
|
23
29
|
export function createPool(config, opts) {
|
|
24
30
|
return isSystemOpenSshBackend(config)
|
|
25
31
|
? new SystemOpenSshPool(config, opts)
|
|
@@ -168,6 +174,7 @@ export class ActivePool {
|
|
|
168
174
|
connect() { return this.delegate.connect() }
|
|
169
175
|
exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
|
|
170
176
|
sftp() { return this.delegate.sftp() }
|
|
177
|
+
shell(opts) { return this.delegate.shell(opts) }
|
|
171
178
|
detect() { return this.delegate.detect() }
|
|
172
179
|
invalidate() { return this.delegate.invalidate() }
|
|
173
180
|
close() { return this.delegate.close() }
|
|
@@ -284,19 +291,42 @@ export class SshPool {
|
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
294
|
+
//
|
|
295
|
+
// MULTI-HOP (OpenSSH `ProxyJump a,b,c` semantics): each bastion may itself
|
|
296
|
+
// carry a `proxy`, and the chain is built from the FARTHEST hop inward — hop
|
|
297
|
+
// N is dialed through the already-open connection to hop N-1. The recursion
|
|
298
|
+
// IS the mechanism, so there is no depth limit beyond what the config
|
|
299
|
+
// expresses; MAX_JUMP_HOPS is a cycle/typo guard, not a feature limit.
|
|
287
300
|
let sock = null
|
|
288
301
|
const proxyCfg = this.config.proxy
|
|
289
302
|
if (proxyCfg && proxyCfg.host) {
|
|
290
303
|
try {
|
|
304
|
+
const depth = Number(this.config._hopDepth) || 0
|
|
305
|
+
if (depth >= MAX_JUMP_HOPS) {
|
|
306
|
+
throw new Error(`proxy chain is deeper than ${MAX_JUMP_HOPS} hops — check the machine config for a proxy cycle`)
|
|
307
|
+
}
|
|
291
308
|
this.proxyPool = new SshPool({
|
|
292
309
|
...this.config,
|
|
293
310
|
host: proxyCfg.host,
|
|
294
311
|
port: Number(proxyCfg.port) || 22,
|
|
295
312
|
username: proxyCfg.username || this.config.username || 'root',
|
|
313
|
+
// CREDENTIALS DO NOT INHERIT ACROSS HOPS. `...this.config` brings the
|
|
314
|
+
// TARGET's key/passphrase along, which is wrong in two ways: it may
|
|
315
|
+
// not be authorized on the bastion, and presenting the target's key to
|
|
316
|
+
// an intermediate host hands that host a credential it should never
|
|
317
|
+
// see. A hop with no key of its own falls back to the agent/default
|
|
318
|
+
// discovery path, which is what an operator's own ssh setup provides.
|
|
296
319
|
password: proxyCfg.password || '',
|
|
297
320
|
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
298
321
|
passphrase: proxyCfg.passphrase || '',
|
|
299
|
-
|
|
322
|
+
...(proxyCfg.useAgent !== undefined ? { useAgent: !!proxyCfg.useAgent } : {}),
|
|
323
|
+
...(proxyCfg.keyboardInteractive !== undefined ? { keyboardInteractive: !!proxyCfg.keyboardInteractive } : {}),
|
|
324
|
+
...(proxyCfg.hostKeyMode ? { hostKeyMode: proxyCfg.hostKeyMode } : {}),
|
|
325
|
+
// The nested hop keeps its OWN proxy — that is what builds the chain —
|
|
326
|
+
// and carries an incremented depth so a cycle is reported instead of
|
|
327
|
+
// recursing until the process dies.
|
|
328
|
+
proxy: proxyCfg.proxy && proxyCfg.proxy.host ? proxyCfg.proxy : undefined,
|
|
329
|
+
_hopDepth: depth + 1,
|
|
300
330
|
}, { knownHostsFile: this._knownHostsFile })
|
|
301
331
|
const pclient = await this.proxyPool.connect()
|
|
302
332
|
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
@@ -347,9 +377,16 @@ export class SshPool {
|
|
|
347
377
|
hostVerifier: (key) => guard.verifier(key),
|
|
348
378
|
}
|
|
349
379
|
if (sock) opts.sock = sock
|
|
380
|
+
// `useAgent` asks for agent auth, but ssh2 can only offer it when this
|
|
381
|
+
// PROCESS has an agent socket. On Windows there is no agent by default,
|
|
382
|
+
// so the flag alone used to fall through to the generic "no credentials"
|
|
383
|
+
// error — which reads as "the plugin lost my settings" rather than "this
|
|
384
|
+
// machine has no agent". Track the intent so the failure can say which.
|
|
385
|
+
let agentRequested = false
|
|
350
386
|
if (this.config.useAgent) {
|
|
351
387
|
const sockPath = process.env.SSH_AUTH_SOCK
|
|
352
388
|
if (sockPath) opts.agent = sockPath
|
|
389
|
+
else agentRequested = true
|
|
353
390
|
}
|
|
354
391
|
let password = this.config.password || ''
|
|
355
392
|
if (!password && this.passwordResolver) {
|
|
@@ -375,7 +412,9 @@ export class SshPool {
|
|
|
375
412
|
opts.privateKey = key
|
|
376
413
|
opts.passphrase = this.config.passphrase || undefined
|
|
377
414
|
} else if (!password && !opts.agent) {
|
|
378
|
-
throw new Error(
|
|
415
|
+
throw new Error(agentRequested
|
|
416
|
+
? 'no SSH agent available: useAgent is enabled for this machine but SSH_AUTH_SOCK is not set in the DSH host process (Windows has no agent by default) — start an agent (or Pageant) before launching DSH, or save a password / private key for this machine'
|
|
417
|
+
: 'no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
379
418
|
}
|
|
380
419
|
return opts
|
|
381
420
|
}
|
|
@@ -643,6 +682,60 @@ export class SshPool {
|
|
|
643
682
|
)
|
|
644
683
|
}
|
|
645
684
|
|
|
685
|
+
/**
|
|
686
|
+
* Open an INTERACTIVE shell channel on the remote host (PTY-backed).
|
|
687
|
+
*
|
|
688
|
+
* This is what an embedded terminal needs and what `exec` cannot provide:
|
|
689
|
+
* `exec` runs one command and closes, with no PTY and no stdin after the
|
|
690
|
+
* writer returns. A shell channel keeps the session alive across commands, so
|
|
691
|
+
* `vim`, `top`, `sudo` prompts and anything else that talks to a terminal
|
|
692
|
+
* works the way it does in an ssh session.
|
|
693
|
+
*
|
|
694
|
+
* The returned stream is ssh2's channel: write to it for keystrokes, listen
|
|
695
|
+
* for `data` for output, call `setWindow(rows, cols, height, width)` on
|
|
696
|
+
* resize, and `end()`/`close()` to hang up. Columns/rows are negotiated per
|
|
697
|
+
* channel, so two terminals on the same pooled connection stay independent.
|
|
698
|
+
*
|
|
699
|
+
* @param {{cols?: number, rows?: number, term?: string, pty?: boolean}} [opts]
|
|
700
|
+
* @returns {Promise<object>} the ssh2 Channel (interactive stream).
|
|
701
|
+
*/
|
|
702
|
+
shell(opts = {}) {
|
|
703
|
+
const cols = Math.max(2, Math.min(Number(opts.cols) || 80, 1000))
|
|
704
|
+
const rows = Math.max(2, Math.min(Number(opts.rows) || 24, 1000))
|
|
705
|
+
const term = String(opts.term || 'xterm-256color')
|
|
706
|
+
return this.connect().then(
|
|
707
|
+
(client) =>
|
|
708
|
+
new Promise((resolve, reject) => {
|
|
709
|
+
let retried = false
|
|
710
|
+
const runOn = (c) => {
|
|
711
|
+
const wndopts = { cols, rows, height: 0, width: 0, term }
|
|
712
|
+
// A shell on a Windows remote is a cmd/PowerShell via the default
|
|
713
|
+
// sshd shell; the PTY request is identical either way.
|
|
714
|
+
c.shell(wndopts, opts.pty === false ? {} : { pty: wndopts }, (err, stream) => {
|
|
715
|
+
if (err) {
|
|
716
|
+
// Same stale-connection recovery as exec/sftp: a session
|
|
717
|
+
// termination means the pooled client died server-side.
|
|
718
|
+
if (!retried && /channel open failure|open failed|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
719
|
+
retried = true
|
|
720
|
+
this.invalidate()
|
|
721
|
+
return this.connect().then(
|
|
722
|
+
(fresh) => runOn(fresh),
|
|
723
|
+
(e2) => reject(new Error('ssh shell failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
return reject(new Error('ssh shell failed: ' + ((err && err.message) || err)))
|
|
727
|
+
}
|
|
728
|
+
// An idle shell must not be killed by the connection's own idle
|
|
729
|
+
// handling: keepalive is already on, and the channel lives until
|
|
730
|
+
// the client calls end(). Nothing to schedule here.
|
|
731
|
+
resolve(stream)
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
runOn(client)
|
|
735
|
+
}),
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
|
|
646
739
|
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
647
740
|
sftp() {
|
|
648
741
|
return this.connect().then(
|
package/lib/registry.js
CHANGED
|
@@ -58,15 +58,213 @@ 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
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
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
|
+
|
|
184
|
+
/** Machine grouping metadata (host organisation).
|
|
185
|
+
*
|
|
186
|
+
* A flat list does not survive contact with reality: once a user has a dozen
|
|
187
|
+
* machines (prod/staging/GPU boxes/laptops) "pick the right one" becomes the
|
|
188
|
+
* dominant failure mode, and picking the WRONG one on a destructive tool is
|
|
189
|
+
* how accidents happen. These four fields are pure LABELS — they change no
|
|
190
|
+
* connection behaviour — so they live beside it and can be edited freely.
|
|
191
|
+
*
|
|
192
|
+
* Normalization is deliberately conservative: a bad value is dropped (not
|
|
193
|
+
* coerced into something surprising), and every field is optional so existing
|
|
194
|
+
* registries keep loading byte-for-byte.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} raw - a (possibly partial) group patch.
|
|
197
|
+
* @returns {{environment?: string, location?: string, description?: string, tags?: string[]}}
|
|
198
|
+
*/
|
|
199
|
+
export function normalizeMachineGroup(raw) {
|
|
200
|
+
const out = {}
|
|
201
|
+
if (!raw || typeof raw !== 'object') return out
|
|
202
|
+
const str = (v, max) => {
|
|
203
|
+
const s = typeof v === 'string' ? v.trim() : ''
|
|
204
|
+
return s ? s.slice(0, max) : ''
|
|
205
|
+
}
|
|
206
|
+
const env = str(raw.environment, 40)
|
|
207
|
+
if (env) out.environment = env
|
|
208
|
+
const loc = str(raw.location, 40)
|
|
209
|
+
if (loc) out.location = loc
|
|
210
|
+
const desc = str(raw.description, 200)
|
|
211
|
+
if (desc) out.description = desc
|
|
212
|
+
// Tags: accept an array or a comma/space separated string (what a UI text box
|
|
213
|
+
// naturally produces), then de-duplicate case-insensitively while KEEPING the
|
|
214
|
+
// first spelling so the display stays stable.
|
|
215
|
+
let tags = raw.tags
|
|
216
|
+
if (typeof tags === 'string') tags = tags.split(/[,\s]+/)
|
|
217
|
+
if (Array.isArray(tags)) {
|
|
218
|
+
const seen = new Set()
|
|
219
|
+
const kept = []
|
|
220
|
+
for (const t of tags) {
|
|
221
|
+
const s = str(t, 32)
|
|
222
|
+
if (!s) continue
|
|
223
|
+
const k = s.toLowerCase()
|
|
224
|
+
if (seen.has(k)) continue
|
|
225
|
+
seen.add(k)
|
|
226
|
+
kept.push(s)
|
|
227
|
+
if (kept.length >= 12) break // a dozen labels is already too many to scan
|
|
228
|
+
}
|
|
229
|
+
if (kept.length) out.tags = kept
|
|
230
|
+
}
|
|
67
231
|
return out
|
|
68
232
|
}
|
|
69
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Select machines by group metadata — the predicate behind "run this on every
|
|
236
|
+
* staging box". All supplied filters must match (AND); tags are OR within
|
|
237
|
+
* themselves (any requested tag qualifies) which is how people read a tag row.
|
|
238
|
+
*
|
|
239
|
+
* @param {Array<object>} machines
|
|
240
|
+
* @param {{environment?: string, location?: string, tags?: string[]|string, query?: string}} filter
|
|
241
|
+
* @returns {Array<object>}
|
|
242
|
+
*/
|
|
243
|
+
export function filterMachinesByGroup(machines, filter = {}) {
|
|
244
|
+
const list = Array.isArray(machines) ? machines : []
|
|
245
|
+
const env = typeof filter.environment === 'string' ? filter.environment.trim().toLowerCase() : ''
|
|
246
|
+
const loc = typeof filter.location === 'string' ? filter.location.trim().toLowerCase() : ''
|
|
247
|
+
let wantTags = filter.tags
|
|
248
|
+
if (typeof wantTags === 'string') wantTags = wantTags.split(/[,\s]+/)
|
|
249
|
+
wantTags = (Array.isArray(wantTags) ? wantTags : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
250
|
+
const query = typeof filter.query === 'string' ? filter.query.trim().toLowerCase() : ''
|
|
251
|
+
return list.filter((m) => {
|
|
252
|
+
if (!m) return false
|
|
253
|
+
if (env && String(m.environment || '').toLowerCase() !== env) return false
|
|
254
|
+
if (loc && String(m.location || '').toLowerCase() !== loc) return false
|
|
255
|
+
if (wantTags.length) {
|
|
256
|
+
const have = (Array.isArray(m.tags) ? m.tags : []).map((t) => String(t).toLowerCase())
|
|
257
|
+
if (!wantTags.some((t) => have.includes(t))) return false
|
|
258
|
+
}
|
|
259
|
+
if (query) {
|
|
260
|
+
const hay = [m.name, m.host, m.username, m.environment, m.location, m.description, ...(Array.isArray(m.tags) ? m.tags : [])]
|
|
261
|
+
.filter(Boolean).join(' ').toLowerCase()
|
|
262
|
+
if (!hay.includes(query)) return false
|
|
263
|
+
}
|
|
264
|
+
return true
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
70
268
|
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
71
269
|
export function applyMachine(config, m) {
|
|
72
270
|
if (!m) return
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// dsh-remote — remote sandbox semantics (issue: parity with local sandbox).
|
|
2
|
+
//
|
|
3
|
+
// The gap this closes: a local agent session runs under `ctx.sandboxPolicy`
|
|
4
|
+
// (read-only / workspace-write / danger-full-access), but every rw_* tool ran
|
|
5
|
+
// remote commands with NO policy at all. A confined session could therefore
|
|
6
|
+
// reach a remote host and mutate anything — the sandbox was not bypassed by a
|
|
7
|
+
// flaw in its enforcement, it simply had no say over a different machine.
|
|
8
|
+
//
|
|
9
|
+
// Why this is a TOOL-LAYER fence rather than `ctx.sandbox.confine()`: the core
|
|
10
|
+
// sandbox service wraps argv for a LOCAL spawn (`confine(argv, policy)` returns
|
|
11
|
+
// argv to spawn on THIS host). A remote host has no sandbox runner, so wrapping
|
|
12
|
+
// local argv cannot constrain a process on the other end of an SSH channel. The
|
|
13
|
+
// core's own docs say exactly this — "Containers, microVMs, and remote
|
|
14
|
+
// execution replace the surrounding capability seam instead". So the honest
|
|
15
|
+
// enforcement point is here: decide, per operation, whether this session's mode
|
|
16
|
+
// permits that remote path/command, and refuse with the OFFICIAL denial shape
|
|
17
|
+
// so the model's escalation affordances keep working.
|
|
18
|
+
//
|
|
19
|
+
// Threat model (stated plainly, matching the official dsh-fs-sandbox header):
|
|
20
|
+
// this is CONTAINMENT, not a security boundary. Paths are compared lexically, so
|
|
21
|
+
// symlinks and TOCTOU races inside the remote host can escape; a remote command
|
|
22
|
+
// can `cd` anywhere once bash runs with the user's own privileges. What it does
|
|
23
|
+
// buy, and what the issue asked for: a confined session can no longer mutate a
|
|
24
|
+
// remote tree outside its workspace BY ACCIDENT through the file tools, and
|
|
25
|
+
// `rw_exec` refuses outright under read-only instead of silently running
|
|
26
|
+
// anything. Deployments needing a hard boundary must confine on the REMOTE side
|
|
27
|
+
// (containers, per-user accounts, filesystem ACLs).
|
|
28
|
+
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { normalizeRemotePath, relPathUnder } from './paths.js'
|
|
31
|
+
|
|
32
|
+
/** The three file-effect modes, mirroring `@deepseek-ai/dsh-sandbox`. */
|
|
33
|
+
export const REMOTE_SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access']
|
|
34
|
+
|
|
35
|
+
/** Denial marker prefix, matching the official `sandboxDenialMarker` shape. */
|
|
36
|
+
const DENIAL_PREFIX = '[sandbox: '
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether `target` lies inside `root` using lexical, separator-aware
|
|
40
|
+
* comparison. Mirrors the official `isPathInsideWorkspace`: both sides are
|
|
41
|
+
* normalized so `..` cannot escape, and `/tmp/foo` does not contain
|
|
42
|
+
* `/tmp/foobar`.
|
|
43
|
+
*
|
|
44
|
+
* A root of `/` contains everything (the whole filesystem is the workspace),
|
|
45
|
+
* which is what makes "workspace = filesystem root" a legal, permissive setup
|
|
46
|
+
* (issue #60) rather than a denial of everything.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} target - the remote path being touched.
|
|
49
|
+
* @param {string} root - the remote workspace root.
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isInsideRemote(target, root) {
|
|
53
|
+
if (typeof target !== 'string' || typeof root !== 'string') return false
|
|
54
|
+
// POSIX-absolute only. `normalizeRemotePath` deliberately coerces a relative
|
|
55
|
+
// input to an absolute one (`tmp/foo` → `/tmp/foo`, `../foo` → `/foo`), which
|
|
56
|
+
// is right for a user-supplied path but WRONG for a fence: it would turn an
|
|
57
|
+
// unprovable root into a confident one. A root that is not absolute proves
|
|
58
|
+
// nothing, so refuse before normalizing.
|
|
59
|
+
if (!isPosixAbsolute(root)) return false
|
|
60
|
+
const t = normalizeRemotePath(target)
|
|
61
|
+
let r = normalizeRemotePath(root)
|
|
62
|
+
if (r === '/' || r === '') return true
|
|
63
|
+
r = r.replace(/\/+$/, '')
|
|
64
|
+
if (t === r) return true
|
|
65
|
+
return t.startsWith(r + '/')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a path is an absolute POSIX or Windows-Drive path, WITHOUT the
|
|
70
|
+
* coercing normalization `normalizeRemotePath` performs.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} p
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function isPosixAbsolute(p) {
|
|
76
|
+
const s = String(p || '')
|
|
77
|
+
if (!s) return false
|
|
78
|
+
if (s.startsWith('/')) return true // POSIX
|
|
79
|
+
if (/^[a-zA-Z]:[\\/]/.test(s)) return true // Windows drive
|
|
80
|
+
if (/^\\\\[^\\]+/.test(s)) return true // Windows UNC
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decide whether one remote operation is permitted under a mode.
|
|
86
|
+
*
|
|
87
|
+
* `operation` is the coarse kind the tools act with:
|
|
88
|
+
* - `read` — reading remote data (never denied: read-only still reads)
|
|
89
|
+
* - `write` — creating/replacing/removing a REMOTE PATH (path fence)
|
|
90
|
+
* - `exec` — running an arbitrary remote command
|
|
91
|
+
* - `tunnel` — opening a port forward
|
|
92
|
+
* - `connect`— dialing/authenticating a machine (never denied: without it
|
|
93
|
+
* nothing else could work, and it changes no remote state)
|
|
94
|
+
*
|
|
95
|
+
* @param {object} arg
|
|
96
|
+
* @param {string} arg.mode - the resolved sandbox mode.
|
|
97
|
+
* @param {string} arg.operation - see above.
|
|
98
|
+
* @param {string} [arg.path] - the remote path a `write` targets.
|
|
99
|
+
* @param {string} arg.workspaceRoot - the remote workspace root.
|
|
100
|
+
* @returns {{allowed: boolean, reason?: string, mode: string, subject?: string}}
|
|
101
|
+
*/
|
|
102
|
+
export function checkRemoteSandbox({ mode, operation, path: target, workspaceRoot }) {
|
|
103
|
+
const m = REMOTE_SANDBOX_MODES.includes(mode) ? mode : 'read-only'
|
|
104
|
+
// Fail closed on an unknown mode, exactly like the official renderPolicyContext.
|
|
105
|
+
const effective = m
|
|
106
|
+
|
|
107
|
+
if (operation === 'read' || operation === 'connect') return { allowed: true, mode: effective }
|
|
108
|
+
|
|
109
|
+
if (effective === 'danger-full-access') return { allowed: true, mode: effective }
|
|
110
|
+
|
|
111
|
+
if (effective === 'read-only') {
|
|
112
|
+
// read-only refuses every state-changing remote operation. This is the
|
|
113
|
+
// strict reading of the official mode ("`read-only` permits only required
|
|
114
|
+
// sinks such as /dev/null") and the one that keeps a confined session
|
|
115
|
+
// honest on a machine whose filesystem it cannot police.
|
|
116
|
+
return {
|
|
117
|
+
allowed: false,
|
|
118
|
+
mode: effective,
|
|
119
|
+
subject: operation === 'exec' ? 'command' : operation === 'tunnel' ? 'tunnel' : 'operation',
|
|
120
|
+
reason:
|
|
121
|
+
operation === 'exec'
|
|
122
|
+
? 'read-only sessions may not run commands on a remote host'
|
|
123
|
+
: operation === 'tunnel'
|
|
124
|
+
? 'read-only sessions may not open remote port forwards'
|
|
125
|
+
: 'read-only sessions may not modify a remote host',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// workspace-write: writes must stay inside the workspace; exec and tunnel are
|
|
130
|
+
// allowed because a command's effects cannot be bounded lexically (and the
|
|
131
|
+
// official bash tool under workspace-write DOES run commands, confining them
|
|
132
|
+
// via the OS). Refusing exec here would make workspace-write useless for the
|
|
133
|
+
// primary remote use case (builds, tests), while pretending to confine it
|
|
134
|
+
// would be worse than saying we do not.
|
|
135
|
+
if (operation === 'exec' || operation === 'tunnel') return { allowed: true, mode: effective }
|
|
136
|
+
|
|
137
|
+
// A write with no resolvable workspace root cannot be proven inside it.
|
|
138
|
+
if (!workspaceRoot) {
|
|
139
|
+
return {
|
|
140
|
+
allowed: false,
|
|
141
|
+
mode: effective,
|
|
142
|
+
subject: 'operation',
|
|
143
|
+
reason: 'workspace-write needs a remote workspace root to fence writes; none is set',
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (isInsideRemote(target, workspaceRoot)) return { allowed: true, mode: effective }
|
|
147
|
+
return {
|
|
148
|
+
allowed: false,
|
|
149
|
+
mode: effective,
|
|
150
|
+
subject: 'operation',
|
|
151
|
+
reason: `workspace-write only permits changes inside ${workspaceRoot}`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render a denial into the official error shape so the model sees the same
|
|
157
|
+
* thing it would from a local sandbox denial (and can use the same escalation
|
|
158
|
+
* affordances when the composition advertises them).
|
|
159
|
+
*
|
|
160
|
+
* @param {{mode: string, reason?: string, subject?: string}} denial
|
|
161
|
+
* @returns {Error}
|
|
162
|
+
*/
|
|
163
|
+
export function remoteSandboxDenial(denial) {
|
|
164
|
+
const mode = (denial && denial.mode) || 'read-only'
|
|
165
|
+
const subject = (denial && denial.subject) || 'operation'
|
|
166
|
+
const hint = `[escalation: raise sandbox mode to retry the ${subject} once]`
|
|
167
|
+
const err = new Error(`${DENIAL_PREFIX}${mode}]\n${denial && denial.reason ? denial.reason + '\n' : ''}${hint}`)
|
|
168
|
+
err.code = 'FS_SANDBOX_DENIED'
|
|
169
|
+
err.remoteSandbox = { mode, subject }
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Relative path of a remote target under the workspace root, for diagnostics.
|
|
175
|
+
* Returns null when the target is outside it.
|
|
176
|
+
* @param {string} target
|
|
177
|
+
* @param {string} root
|
|
178
|
+
* @returns {string|null}
|
|
179
|
+
*/
|
|
180
|
+
export function remoteRelUnder(target, root) {
|
|
181
|
+
const rel = relPathUnder(root, target)
|
|
182
|
+
return rel === null ? null : rel
|
|
183
|
+
}
|
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 === '/') {
|