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/index.js
CHANGED
|
@@ -41,6 +41,9 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
|
|
|
41
41
|
import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
|
|
42
42
|
import { resolveExecTimeout, formatExecResult, startExecJob, execWithOptionalPromotion } from './exec-job.js'
|
|
43
43
|
import { createInjectionTracker, refreshTouched, buildInstructionMessage } from './instructions-dynamic.js'
|
|
44
|
+
import { REMOTE_SANDBOX_MODES, checkRemoteSandbox, remoteSandboxDenial } from './remote-sandbox.js'
|
|
45
|
+
import { createTerminalManager } from './terminal.js'
|
|
46
|
+
import { createTerminalRoutes } from './routes-term.js'
|
|
44
47
|
import { searchRemote } from './search.js'
|
|
45
48
|
import { installTerminalShell } from './remote-shell.js'
|
|
46
49
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
@@ -55,7 +58,7 @@ import {
|
|
|
55
58
|
import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
|
|
56
59
|
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
|
|
57
60
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
58
|
-
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
61
|
+
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup, resolveProbeCredentials } from './registry.js'
|
|
59
62
|
import { registerHttpTransports } from './http-transport.js'
|
|
60
63
|
import { SshPool, ActivePool, createPool } from './pool.js'
|
|
61
64
|
import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
|
|
@@ -277,6 +280,24 @@ export const Config = z.object({
|
|
|
277
280
|
* inside a mirror) always sees all tools, and an invalid value falls back
|
|
278
281
|
* to `bootstrap`. (schemastery 3.18 has no .enum — validated in code.) */
|
|
279
282
|
localTools: z.string().default('bootstrap'),
|
|
283
|
+
/** Remote sandbox fence (parity with the local session sandbox).
|
|
284
|
+
*
|
|
285
|
+
* `inherit` (default) reads the SAME `ctx.sandboxPolicy` the local tools use
|
|
286
|
+
* and applies it to remote operations: read-only refuses remote writes and
|
|
287
|
+
* commands, workspace-write fences remote writes to the workspace root,
|
|
288
|
+
* danger-full-access allows everything. `off` restores the pre-0.8.54
|
|
289
|
+
* behaviour (no fence) for deployments whose remotes enforce their own
|
|
290
|
+
* boundary. A per-call `sandbox_permissions: 'danger-full-access'` also
|
|
291
|
+
* works, but only when the composition advertises escalation. */
|
|
292
|
+
remoteSandbox: z.string().default('inherit'),
|
|
293
|
+
/** What to do when the OS secure store refuses a password.
|
|
294
|
+
*
|
|
295
|
+
* `refuse` (default): never write a password to machines.json. The machine is
|
|
296
|
+
* saved without a credential and the UI reports the store failure plus the
|
|
297
|
+
* repair hint -- you asked for encryption, so silently downgrading to
|
|
298
|
+
* plaintext is the one outcome you did not ask for.
|
|
299
|
+
* `plain`: the pre-0.8.54 fallback (write it and warn). */
|
|
300
|
+
credentialFallback: z.string().default('refuse'),
|
|
280
301
|
})
|
|
281
302
|
|
|
282
303
|
// ── shell / path helpers (pure implementations in lib/paths.js) ───────────
|
|
@@ -1029,6 +1050,28 @@ export async function apply(ctx, config) {
|
|
|
1029
1050
|
return null
|
|
1030
1051
|
}
|
|
1031
1052
|
}
|
|
1053
|
+
/**
|
|
1054
|
+
* One-line-per-group inventory of the saved machines: which environments,
|
|
1055
|
+
* locations and tags exist. This is what makes the filters discoverable — a
|
|
1056
|
+
* user who has never set a label sees nothing to filter by, so the empty
|
|
1057
|
+
* result reports what IS there.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {Array<object>} list
|
|
1060
|
+
* @returns {string} '' when no machine carries any label.
|
|
1061
|
+
*/
|
|
1062
|
+
const groupSummary = (list) => {
|
|
1063
|
+
const envs = new Map(); const locs = new Map(); const tags = new Map()
|
|
1064
|
+
const bump = (map, key) => { if (key) map.set(key, (map.get(key) || 0) + 1) }
|
|
1065
|
+
for (const m of Array.isArray(list) ? list : []) {
|
|
1066
|
+
if (!m) continue
|
|
1067
|
+
bump(envs, m.environment)
|
|
1068
|
+
bump(locs, m.location)
|
|
1069
|
+
for (const t of (Array.isArray(m.tags) ? m.tags : [])) bump(tags, t)
|
|
1070
|
+
}
|
|
1071
|
+
const fmt = (label, map) => (map.size ? ` ${label}: ` + [...map.entries()].map(([k, n]) => `${k} (${n})`).join(', ') : '')
|
|
1072
|
+
return [fmt('environment', envs), fmt('location', locs), fmt('tags', tags)].filter(Boolean).join('\n')
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1032
1075
|
const readAudit = (limit) => {
|
|
1033
1076
|
try {
|
|
1034
1077
|
const text = readFileSync(auditFile(), 'utf8')
|
|
@@ -1159,6 +1202,12 @@ export async function apply(ctx, config) {
|
|
|
1159
1202
|
// already delivered, plus the sweep throttle. See the tools/result hook below.
|
|
1160
1203
|
const instructionTracker = createInjectionTracker()
|
|
1161
1204
|
|
|
1205
|
+
// Embedded interactive terminal sessions (P1). Host-owned PTY channels; the
|
|
1206
|
+
// browser polls for output. Disposed with the plugin fiber so an unload hangs
|
|
1207
|
+
// up every remote shell.
|
|
1208
|
+
const terminals = createTerminalManager()
|
|
1209
|
+
ctx.effect(() => () => { try { terminals.dispose() } catch { /* already gone */ } }, 'dsh-remote.terminals')
|
|
1210
|
+
|
|
1162
1211
|
/**
|
|
1163
1212
|
* The core's user-message factory, when this composition exposes one.
|
|
1164
1213
|
*
|
|
@@ -1446,6 +1495,29 @@ export async function apply(ctx, config) {
|
|
|
1446
1495
|
err.httpStatus = 403
|
|
1447
1496
|
throw err
|
|
1448
1497
|
}
|
|
1498
|
+
// No session context: fall back to the ACTIVE machine. Boot restore runs on
|
|
1499
|
+
// `setImmediate`, so a request that arrives first would see `config.host`
|
|
1500
|
+
// still empty even though the registry already names a current machine —
|
|
1501
|
+
// apply it here instead of answering "no machine" to a user who HAS one.
|
|
1502
|
+
if (!String(config.host || '').trim()) {
|
|
1503
|
+
const m = activeMachine()
|
|
1504
|
+
if (m && m.host) {
|
|
1505
|
+
try { await applyActiveMachine() } catch { /* reported below */ }
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
// Still nothing: answer with the REAL reason. Falling through used to hand the
|
|
1509
|
+
// caller the un-configured pool, whose first command failed with
|
|
1510
|
+
// "no credentials: set a password, a privateKeyPath…" — i.e. a missing
|
|
1511
|
+
// *selection* was reported as a missing *credential*, which sends people to the
|
|
1512
|
+
// wrong settings page.
|
|
1513
|
+
if (!String(config.host || '').trim()) {
|
|
1514
|
+
const err = new Error(
|
|
1515
|
+
'当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。'
|
|
1516
|
+
+ '(保存的机器只是备用连接,不会自动成为当前机器。)',
|
|
1517
|
+
)
|
|
1518
|
+
err.httpStatus = 409
|
|
1519
|
+
throw err
|
|
1520
|
+
}
|
|
1449
1521
|
return {
|
|
1450
1522
|
pool,
|
|
1451
1523
|
ws: wsPath(),
|
|
@@ -1542,6 +1614,69 @@ export async function apply(ctx, config) {
|
|
|
1542
1614
|
return b
|
|
1543
1615
|
}
|
|
1544
1616
|
|
|
1617
|
+
// ── remote sandbox semantics ──────────────────────────────────────────────
|
|
1618
|
+
// A local session is confined by ctx.sandboxPolicy; a REMOTE command used to
|
|
1619
|
+
// run under no policy at all, so a read-only session could mutate any file on
|
|
1620
|
+
// any reachable host. `ctx.sandbox.confine()` cannot help here — it wraps argv
|
|
1621
|
+
// for a spawn on THIS machine, while the process we start lives on the other
|
|
1622
|
+
// end of an SSH channel. So the fence is applied at the tool boundary, using
|
|
1623
|
+
// the SAME resolved policy the local tools consume (see lib/remote-sandbox.js
|
|
1624
|
+
// for the threat model: containment, not a security boundary).
|
|
1625
|
+
//
|
|
1626
|
+
// Optional by design: a composition without ctx.sandboxPolicy (a minimal
|
|
1627
|
+
// embedding, a test double, an agentless call) keeps the pre-existing
|
|
1628
|
+
// unconfined behaviour instead of failing to load, and `remoteSandbox` config
|
|
1629
|
+
// can switch the fence off for deployments whose remotes enforce their own.
|
|
1630
|
+
const remoteSandboxEnabled = () => config.remoteSandbox !== 'off'
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Resolve the sandbox mode for one tool call.
|
|
1634
|
+
*
|
|
1635
|
+
* Reads the core policy service when present; otherwise honours the plugin's
|
|
1636
|
+
* own `remoteSandbox` fallback (`inherit` ⇒ danger-full-access, i.e. the old
|
|
1637
|
+
* unconfined behaviour). Any resolution failure is treated as read-only — fail
|
|
1638
|
+
* closed, never open.
|
|
1639
|
+
*
|
|
1640
|
+
* @param {object} [exec] - the tool run context.
|
|
1641
|
+
* @returns {string} the mode name.
|
|
1642
|
+
*/
|
|
1643
|
+
const sandboxModeFor = (exec) => {
|
|
1644
|
+
if (!remoteSandboxEnabled()) return 'danger-full-access'
|
|
1645
|
+
try {
|
|
1646
|
+
const svc = ctx.get('sandboxPolicy')
|
|
1647
|
+
if (svc && typeof svc.resolve === 'function') {
|
|
1648
|
+
const session = exec && exec.agent ? exec.agent.session : undefined
|
|
1649
|
+
const resolved = svc.resolve(session ? { session } : {})
|
|
1650
|
+
if (resolved && REMOTE_SANDBOX_MODES.includes(resolved.mode)) return resolved.mode
|
|
1651
|
+
}
|
|
1652
|
+
} catch { /* fall through to the configured default */ }
|
|
1653
|
+
const fallback = String(config.remoteSandbox || 'inherit')
|
|
1654
|
+
return REMOTE_SANDBOX_MODES.includes(fallback) ? fallback : 'danger-full-access'
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Assert that one remote operation is permitted, throwing the official denial
|
|
1659
|
+
* shape when it is not.
|
|
1660
|
+
*
|
|
1661
|
+
* @param {object} exec - the tool run context.
|
|
1662
|
+
* @param {'read'|'write'|'exec'|'tunnel'|'connect'} operation
|
|
1663
|
+
* @param {object} [opts]
|
|
1664
|
+
* @param {string} [opts.path] - the remote path a `write` targets.
|
|
1665
|
+
* @param {string} [opts.tool] - tool name for the message.
|
|
1666
|
+
*/
|
|
1667
|
+
const requireRemoteSandbox = (exec, operation, opts = {}) => {
|
|
1668
|
+
if (!remoteSandboxEnabled()) return
|
|
1669
|
+
const mode = sandboxModeFor(exec)
|
|
1670
|
+
if (mode === 'danger-full-access') return
|
|
1671
|
+
const b = (() => { try { return bindingFor(exec) } catch { return null } })()
|
|
1672
|
+
const workspaceRoot = (b && b.ws) || ''
|
|
1673
|
+
const verdict = checkRemoteSandbox({ mode, operation, path: opts.path, workspaceRoot })
|
|
1674
|
+
if (verdict.allowed) return
|
|
1675
|
+
const err = remoteSandboxDenial(verdict)
|
|
1676
|
+
if (opts.tool) err.message = `${opts.tool}: ${err.message}`
|
|
1677
|
+
throw err
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1545
1680
|
/**
|
|
1546
1681
|
* Binding for a tool that takes an explicit absolute remote path: it needs a
|
|
1547
1682
|
* resolvable MACHINE but not a workspace root. A local session is refused for
|
|
@@ -1849,6 +1984,9 @@ export async function apply(ctx, config) {
|
|
|
1849
1984
|
if (args.passphrase !== undefined) rec.passphrase = String(args.passphrase)
|
|
1850
1985
|
if (args.useAgent !== undefined) rec.useAgent = !!args.useAgent
|
|
1851
1986
|
if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
|
|
1987
|
+
// Group labels (host organisation): merge-only, so a partial call
|
|
1988
|
+
// never wipes existing labels.
|
|
1989
|
+
{ const g = normalizeMachineGroup(args); for (const k of Object.keys(g)) rec[k] = g[k] }
|
|
1852
1990
|
if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
|
|
1853
1991
|
if (args.name) rec.name = String(args.name)
|
|
1854
1992
|
// Issue #51: pick the transport backend. `openssh` is what reaches
|
|
@@ -1941,14 +2079,30 @@ export async function apply(ctx, config) {
|
|
|
1941
2079
|
defineTool({
|
|
1942
2080
|
name: 'rw_machines',
|
|
1943
2081
|
description:
|
|
1944
|
-
'List the machines saved in the registry, secrets stripped: id, name, host/port/username, auth method flags (
|
|
1945
|
-
parameters: {
|
|
2082
|
+
'List the machines saved in the registry, secrets stripped: id, name, host/port/username, auth method flags, ssh-config alias resolution, workspace, grouping labels (environment / tags / location), and which one is current. Optional filters (environment / tags / location / query) narrow the list - useful once you have many machines. Pass rw_connect(machineId=<id>) to connect with the full stored settings of a machine (issue #48).',
|
|
2083
|
+
parameters: {
|
|
2084
|
+
environment: { type: 'string', description: 'Only machines in this environment (e.g. prod, staging)' },
|
|
2085
|
+
location: { type: 'string', description: 'Only machines in this location (e.g. shanghai, gpu-lab)' },
|
|
2086
|
+
tags: { type: 'string', description: 'Only machines carrying ANY of these tags (comma or space separated)' },
|
|
2087
|
+
query: { type: 'string', description: 'Free-text match over name/host/user/environment/location/description/tags' },
|
|
2088
|
+
},
|
|
1946
2089
|
output: textOut,
|
|
1947
|
-
async execute() {
|
|
2090
|
+
async execute(args = {}) {
|
|
1948
2091
|
if (!machines.length) {
|
|
1949
2092
|
return { text: 'No saved machines yet — add one in Settings → 远程工作区, or connect with rw_connect(host=..., save defaults to true).' }
|
|
1950
2093
|
}
|
|
1951
|
-
|
|
2094
|
+
// Group filters (host organisation): a flat list stops being usable once
|
|
2095
|
+
// a user has a dozen machines, and picking the wrong one on a destructive
|
|
2096
|
+
// tool is how accidents happen.
|
|
2097
|
+
const filtered = filterMachinesByGroup(machines, {
|
|
2098
|
+
environment: args.environment, location: args.location, tags: args.tags, query: args.query,
|
|
2099
|
+
})
|
|
2100
|
+
const anyFilter = !!(args.environment || args.location || args.tags || args.query)
|
|
2101
|
+
if (anyFilter && !filtered.length) {
|
|
2102
|
+
const groups = groupSummary(machines)
|
|
2103
|
+
return { text: 'No machine matches that filter.' + (groups ? '\n\nAvailable groups:\n' + groups : '') }
|
|
2104
|
+
}
|
|
2105
|
+
const rows = filtered.map((m) => {
|
|
1952
2106
|
const cur = store.currentId === m.id ? ' [CURRENT]' : ''
|
|
1953
2107
|
const auth = []
|
|
1954
2108
|
if (m.password || (m.credentialBackend && m.credentialBackend !== 'plain')) auth.push('password')
|
|
@@ -1966,9 +2120,19 @@ export async function apply(ctx, config) {
|
|
|
1966
2120
|
via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
|
|
1967
2121
|
}
|
|
1968
2122
|
const ws = m.workspace ? ` workspace=${m.workspace}` : ''
|
|
1969
|
-
|
|
2123
|
+
// Grouping labels, shown inline so a wrong-host pick is visible.
|
|
2124
|
+
const meta = []
|
|
2125
|
+
if (m.environment) meta.push('env=' + m.environment)
|
|
2126
|
+
if (m.location) meta.push('loc=' + m.location)
|
|
2127
|
+
if (Array.isArray(m.tags) && m.tags.length) meta.push('tags=' + m.tags.join(','))
|
|
2128
|
+
if (m.description) meta.push(m.description)
|
|
2129
|
+
const group = meta.length ? '\n ' + meta.join(' ') : ''
|
|
2130
|
+
return `- ${m.id} "${m.name || m.host}" [${backend}] ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}${group}`
|
|
1970
2131
|
})
|
|
1971
|
-
|
|
2132
|
+
const suffix = anyFilter
|
|
2133
|
+
? `\n\n${rows.length} of ${machines.length} machine(s) match. connect with rw_connect(machineId=<id>).`
|
|
2134
|
+
: '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.'
|
|
2135
|
+
return { text: rows.join('\n') + suffix }
|
|
1972
2136
|
},
|
|
1973
2137
|
}),
|
|
1974
2138
|
|
|
@@ -2257,6 +2421,8 @@ export async function apply(ctx, config) {
|
|
|
2257
2421
|
output: okOut,
|
|
2258
2422
|
async execute(args, exec) {
|
|
2259
2423
|
const b = requireMachine(exec, 'rw_write_file')
|
|
2424
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2425
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_write_file', path: String(args.path || '') })
|
|
2260
2426
|
const p = resolveRemoteArg(b, args.path)
|
|
2261
2427
|
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
2262
2428
|
const content = String(args.content == null ? '' : args.content)
|
|
@@ -2294,6 +2460,8 @@ export async function apply(ctx, config) {
|
|
|
2294
2460
|
output: okOut,
|
|
2295
2461
|
async execute(args, exec) {
|
|
2296
2462
|
const b = requireMachine(exec, 'rw_edit')
|
|
2463
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2464
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_edit', path: String(args.path || args.file_path || '') })
|
|
2297
2465
|
// Presence must be checked on the RAW argument, not on the resolved
|
|
2298
2466
|
// path: resolveRemoteArg falls back to the workspace root for an empty
|
|
2299
2467
|
// string, so `!p` would never fire and a pathless rw_edit would silently
|
|
@@ -2372,6 +2540,8 @@ export async function apply(ctx, config) {
|
|
|
2372
2540
|
output: okOut,
|
|
2373
2541
|
async execute(args, exec) {
|
|
2374
2542
|
const b = requireMachine(exec, 'rw_append')
|
|
2543
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2544
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_append', path: String(args.path || '') })
|
|
2375
2545
|
const p = resolveRemoteArg(b, args.path)
|
|
2376
2546
|
if (!p || p === '/') throw new Error('rw_append: a file path is required')
|
|
2377
2547
|
const sftp = await b.pool.sftp()
|
|
@@ -2395,6 +2565,8 @@ export async function apply(ctx, config) {
|
|
|
2395
2565
|
output: textOut,
|
|
2396
2566
|
async execute(args, exec) {
|
|
2397
2567
|
const b = requireMachine(exec, 'rw_mkdir')
|
|
2568
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2569
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_mkdir', path: String(args.path || '') })
|
|
2398
2570
|
const p = resolveRemoteArg(b, args.path)
|
|
2399
2571
|
if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
|
|
2400
2572
|
const sftp = await b.pool.sftp()
|
|
@@ -2415,6 +2587,8 @@ export async function apply(ctx, config) {
|
|
|
2415
2587
|
output: okOut,
|
|
2416
2588
|
async execute(args, exec) {
|
|
2417
2589
|
const b = requireMachine(exec, 'rw_remove')
|
|
2590
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2591
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_remove', path: String(args.path || '') })
|
|
2418
2592
|
const p = resolveRemoteArg(b, args.path)
|
|
2419
2593
|
if (!p || p === '/') throw new Error('rw_remove: a path is required')
|
|
2420
2594
|
const sftp = await b.pool.sftp()
|
|
@@ -2443,6 +2617,8 @@ export async function apply(ctx, config) {
|
|
|
2443
2617
|
output: textOut,
|
|
2444
2618
|
async execute(args, exec) {
|
|
2445
2619
|
const b = requireMachine(exec, 'rw_move')
|
|
2620
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2621
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_move', path: String(args.source || args.path || '') })
|
|
2446
2622
|
const p = resolveRemoteArg(b, args.path)
|
|
2447
2623
|
const d = resolveRemoteArg(b, args.dest)
|
|
2448
2624
|
if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
|
|
@@ -2470,6 +2646,8 @@ export async function apply(ctx, config) {
|
|
|
2470
2646
|
output: textOut,
|
|
2471
2647
|
async execute(args, exec) {
|
|
2472
2648
|
const b = requireBinding(exec, 'rw_exec')
|
|
2649
|
+
// Remote sandbox fence: commands change remote state.
|
|
2650
|
+
requireRemoteSandbox(exec, 'exec', { tool: 'rw_exec' })
|
|
2473
2651
|
const cmd = String(args.command || '')
|
|
2474
2652
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
2475
2653
|
const ws = b.ws
|
|
@@ -2642,6 +2820,8 @@ export async function apply(ctx, config) {
|
|
|
2642
2820
|
output: okOut,
|
|
2643
2821
|
async execute(args, exec) {
|
|
2644
2822
|
const b = requireMachine(exec, 'rw_upload')
|
|
2823
|
+
// Remote sandbox fence: a write must be permitted by this session's resolved mode.
|
|
2824
|
+
requireRemoteSandbox(exec, 'write', { tool: 'rw_upload', path: String(args.path || '') })
|
|
2645
2825
|
const rp = resolveRemoteArg(b, args.path)
|
|
2646
2826
|
const lp = String(args.localPath || '')
|
|
2647
2827
|
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
@@ -2726,9 +2906,11 @@ export async function apply(ctx, config) {
|
|
|
2726
2906
|
remove: { type: 'boolean', description: 'Remove an existing forward by listenPort (default false)' },
|
|
2727
2907
|
},
|
|
2728
2908
|
output: textOut,
|
|
2729
|
-
async execute(args) {
|
|
2909
|
+
async execute(args, exec) {
|
|
2730
2910
|
const port = Number(args.listenPort)
|
|
2731
2911
|
if (!port || port < 1 || port > 65535) throw new Error('rw_forward: a valid listenPort is required')
|
|
2912
|
+
// Remote sandbox fence: a forward opens a network path into the remote.
|
|
2913
|
+
requireRemoteSandbox(exec, 'tunnel', { tool: 'rw_forward' })
|
|
2732
2914
|
const dir = args.direction === 'reverse' ? 'reverse' : 'local'
|
|
2733
2915
|
const existing = forwards.list().find((f) => Number(f.listenPort) === port && f.direction === dir)
|
|
2734
2916
|
if (args.remove) {
|
|
@@ -3040,6 +3222,13 @@ export async function apply(ctx, config) {
|
|
|
3040
3222
|
})
|
|
3041
3223
|
const pickLocalNative = async () => {
|
|
3042
3224
|
const platform = process.platform
|
|
3225
|
+
// Escape hatch for non-interactive hosts and for the test suite: opening a
|
|
3226
|
+
// real OS folder dialog from a request handler means the REQUEST waits for a
|
|
3227
|
+
// human. In a headless host (or CI) that is a hang nobody can see or cancel —
|
|
3228
|
+
// the route then falls back to its browse capability / "type the path" hint.
|
|
3229
|
+
if (String(process.env.DSH_REMOTE_NO_NATIVE_DIALOG || '') && process.env.DSH_REMOTE_NO_NATIVE_DIALOG !== '0') {
|
|
3230
|
+
throw new Error('已禁用本机目录选择器对话框(DSH_REMOTE_NO_NATIVE_DIALOG)—— 请直接在输入框填本地路径')
|
|
3231
|
+
}
|
|
3043
3232
|
if (platform === 'darwin') {
|
|
3044
3233
|
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
3045
3234
|
}
|
|
@@ -3278,6 +3467,15 @@ export async function apply(ctx, config) {
|
|
|
3278
3467
|
sendJson, readBody, resolveRequestBinding, decodeBuf, encodeText, audit, config, mirrorDirFor,
|
|
3279
3468
|
}),
|
|
3280
3469
|
|
|
3470
|
+
// Embedded terminal (P1): one host-owned PTY session per machine, polled.
|
|
3471
|
+
...createTerminalRoutes({
|
|
3472
|
+
sendJson, readBody, resolveRequestBinding, terminals,
|
|
3473
|
+
// The machine fence: a session is identified by the mirror-recorded
|
|
3474
|
+
// origin, so a terminal opened on host A can never be driven from a
|
|
3475
|
+
// request bound to host B.
|
|
3476
|
+
machineKey: (b) => poolKey({ host: b.host, port: b.port, username: b.username }),
|
|
3477
|
+
}),
|
|
3478
|
+
|
|
3281
3479
|
{
|
|
3282
3480
|
kind: 'exact',
|
|
3283
3481
|
path: '/dsh-remote/workspace',
|
|
@@ -3454,8 +3652,20 @@ export async function apply(ctx, config) {
|
|
|
3454
3652
|
port: Number(body.port) || 22,
|
|
3455
3653
|
username: String(body.username || '').trim() || 'root',
|
|
3456
3654
|
password: '',
|
|
3457
|
-
|
|
3458
|
-
|
|
3655
|
+
// An OMITTED `privateKeyPath` keeps the stored one; an explicit
|
|
3656
|
+
// empty string clears it (that is what the form sends when the user
|
|
3657
|
+
// empties the box). A partial update from an API/agent caller used
|
|
3658
|
+
// to silently wipe the key path, leaving a machine that can only
|
|
3659
|
+
// fail to authenticate — the same data-loss class as the passphrase.
|
|
3660
|
+
privateKeyPath: body.privateKeyPath === undefined
|
|
3661
|
+
? String((prev && prev.privateKeyPath) || '')
|
|
3662
|
+
: String(body.privateKeyPath || '').trim(),
|
|
3663
|
+
// An EMPTY passphrase from the form means "unchanged", not
|
|
3664
|
+
// "delete it": the value is never echoed back to the browser, so
|
|
3665
|
+
// an edit that touched any other field used to silently wipe a
|
|
3666
|
+
// saved private-key passphrase (and the next connect then failed
|
|
3667
|
+
// with "no passphrase given" for a key that had been working).
|
|
3668
|
+
passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
|
|
3459
3669
|
workspace: String(body.workspace || '').trim(),
|
|
3460
3670
|
hostKeyMode: body.hostKeyMode || '',
|
|
3461
3671
|
useAgent: !!body.useAgent,
|
|
@@ -3473,13 +3683,28 @@ export async function apply(ctx, config) {
|
|
|
3473
3683
|
webAttachCommand: body.webAttachCommand === undefined
|
|
3474
3684
|
? String((prev && prev.webAttachCommand) || '')
|
|
3475
3685
|
: String(body.webAttachCommand || '').trim(),
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3686
|
+
// Group labels (host organisation). Merge-only: a request that
|
|
3687
|
+
// omits them keeps the stored values instead of wiping them.
|
|
3688
|
+
...normalizeMachineGroup({
|
|
3689
|
+
environment: body.environment === undefined ? (prev && prev.environment) : body.environment,
|
|
3690
|
+
location: body.location === undefined ? (prev && prev.location) : body.location,
|
|
3691
|
+
description: body.description === undefined ? (prev && prev.description) : body.description,
|
|
3692
|
+
tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
|
|
3693
|
+
}),
|
|
3694
|
+
// A jump host the form did not mention is KEPT (omitted ≠
|
|
3695
|
+
// removed), and a jump PASSWORD the form did not re-supply is
|
|
3696
|
+
// kept too: the settings page never receives it back, so an edit
|
|
3697
|
+
// used to strip the bastion credential and the machine could
|
|
3698
|
+
// only fail at connect time.
|
|
3699
|
+
proxy: body.proxy === undefined
|
|
3700
|
+
? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
|
|
3701
|
+
: (body.proxy && body.proxy.host ? {
|
|
3702
|
+
host: String(body.proxy.host),
|
|
3703
|
+
port: Number(body.proxy.port) || 22,
|
|
3704
|
+
username: String(body.proxy.username || '').trim(),
|
|
3705
|
+
password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
|
|
3706
|
+
privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
|
|
3707
|
+
} : undefined),
|
|
3483
3708
|
credentialBackend: credBackend,
|
|
3484
3709
|
recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
|
|
3485
3710
|
lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
|
|
@@ -3497,15 +3722,37 @@ export async function apply(ctx, config) {
|
|
|
3497
3722
|
machineId: id,
|
|
3498
3723
|
password: body.password,
|
|
3499
3724
|
secretsDir: secretsDir(),
|
|
3725
|
+
onStoreFailure: config.credentialFallback === 'plain' ? 'plain' : 'refuse',
|
|
3500
3726
|
})
|
|
3501
3727
|
rec.credentialBackend = persisted.credentialBackend
|
|
3502
3728
|
rec.password = persisted.password
|
|
3729
|
+
// Record WHERE the secret ended up, so the settings page can say
|
|
3730
|
+
// "已保存" for a keychain machine too (its record carries an
|
|
3731
|
+
// empty `password` by design).
|
|
3732
|
+
rec.secretStored = persisted.stored === 'secret'
|
|
3503
3733
|
if (persisted.warning) {
|
|
3504
3734
|
warning = persisted.warning
|
|
3505
3735
|
warningDetail = persisted.error
|
|
3506
3736
|
}
|
|
3507
|
-
} else if (prev
|
|
3508
|
-
|
|
3737
|
+
} else if (prev) {
|
|
3738
|
+
// No password typed: keep whatever the machine already had —
|
|
3739
|
+
// the plaintext copy AND the keychain flag. Turning the
|
|
3740
|
+
// "加密保存密码" checkbox OFF is handled next: it must actually
|
|
3741
|
+
// MOVE the secret, or the checkbox is a lie.
|
|
3742
|
+
rec.password = prev.password || ''
|
|
3743
|
+
rec.secretStored = prev.secretStored
|
|
3744
|
+
const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
|
|
3745
|
+
if (credBackend === 'plain' && prevKeychain) {
|
|
3746
|
+
try {
|
|
3747
|
+
const existing = await machinePassword(prev)
|
|
3748
|
+
if (existing) {
|
|
3749
|
+
rec.password = existing
|
|
3750
|
+
rec.secretStored = false
|
|
3751
|
+
rec.credentialBackend = 'plain'
|
|
3752
|
+
await deleteSecret(id, secretsDir()).catch(() => {})
|
|
3753
|
+
}
|
|
3754
|
+
} catch { /* keep the keychain copy; the next connect still works */ }
|
|
3755
|
+
}
|
|
3509
3756
|
}
|
|
3510
3757
|
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
3511
3758
|
// Issue #13: saving a machine must NOT make it the active
|
|
@@ -3611,17 +3858,46 @@ export async function apply(ctx, config) {
|
|
|
3611
3858
|
let body = {}
|
|
3612
3859
|
try {
|
|
3613
3860
|
body = parseJsonObject(await readBody(req))
|
|
3861
|
+
// ── The probe must dial with the credentials a REAL connect would use.
|
|
3862
|
+
//
|
|
3863
|
+
// Stored secrets never travel back to the browser (see
|
|
3864
|
+
// sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
|
|
3865
|
+
// EMPTY password while the machine itself connected fine through the
|
|
3866
|
+
// pool's keychain/plaintext resolver. Every such probe died with
|
|
3867
|
+
// `no credentials: set a password, a privateKeyPath, or enable
|
|
3868
|
+
// useAgent to connect` — the error said "you configured nothing" about
|
|
3869
|
+
// a machine that was visibly working.
|
|
3870
|
+
//
|
|
3871
|
+
// So: resolve the saved record (by id when the form is editing one,
|
|
3872
|
+
// else by identity) and merge — form values override, blanks fall back
|
|
3873
|
+
// to the stored record. The resolved password is then ALSO exposed as a
|
|
3874
|
+
// lazy resolver so an OS-keychain machine behaves exactly like a real
|
|
3875
|
+
// connect (read on demand, never logged).
|
|
3876
|
+
const savedRec = (() => {
|
|
3877
|
+
const byId = body.id ? machines[machineIndex(String(body.id))] : null
|
|
3878
|
+
if (byId) return byId
|
|
3879
|
+
// No id (a fresh form that happens to match a saved machine): match on
|
|
3880
|
+
// the identity the form is about to dial.
|
|
3881
|
+
const hit = machineRecordFor({
|
|
3882
|
+
host: String(body.host || ''),
|
|
3883
|
+
port: Number(body.port) || 22,
|
|
3884
|
+
username: String(body.username || ''),
|
|
3885
|
+
})
|
|
3886
|
+
return hit || null
|
|
3887
|
+
})()
|
|
3888
|
+
const savedPassword = savedRec ? await machinePassword(savedRec) : ''
|
|
3889
|
+
const cred = resolveProbeCredentials(body, savedRec, savedPassword)
|
|
3614
3890
|
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
3615
3891
|
// the form holds the alias name, not the host it points at.
|
|
3616
|
-
const probeBackend = String(
|
|
3892
|
+
const probeBackend = String(cred.backend || config.backend || 'native')
|
|
3617
3893
|
const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
|
|
3618
3894
|
const aliasTarget = body.useSshConfig
|
|
3619
3895
|
? resolveMachineSshConfig({
|
|
3620
3896
|
host: String(body.host || ''),
|
|
3621
3897
|
port: Number(body.port) || 22,
|
|
3622
3898
|
username: String(body.username || ''),
|
|
3623
|
-
privateKeyPath: String(
|
|
3624
|
-
proxy:
|
|
3899
|
+
privateKeyPath: String(cred.privateKeyPath || ''),
|
|
3900
|
+
proxy: cred.proxy,
|
|
3625
3901
|
useSshConfig: true,
|
|
3626
3902
|
}, { text: sshConfigText() })
|
|
3627
3903
|
: null
|
|
@@ -3641,19 +3917,24 @@ export async function apply(ctx, config) {
|
|
|
3641
3917
|
host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
|
|
3642
3918
|
port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
|
|
3643
3919
|
username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
|
|
3644
|
-
password:
|
|
3645
|
-
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(
|
|
3646
|
-
passphrase:
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
} :
|
|
3920
|
+
password: cred.password,
|
|
3921
|
+
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
|
|
3922
|
+
passphrase: cred.passphrase,
|
|
3923
|
+
// Auth METHOD flags come from the merge too: a machine that
|
|
3924
|
+
// authenticates through an agent or keyboard-interactive used to be
|
|
3925
|
+
// probed with `useAgent: false`, i.e. with no auth method at all.
|
|
3926
|
+
useAgent: cred.useAgent,
|
|
3927
|
+
keyboardInteractive: cred.keyboardInteractive,
|
|
3928
|
+
...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
|
|
3929
|
+
...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
|
|
3930
|
+
proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
|
|
3654
3931
|
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
3655
3932
|
commandTimeoutMs: 10000,
|
|
3656
3933
|
}, { knownHostsFile })
|
|
3934
|
+
// Same lazy resolution the active pool uses: if the merged password was
|
|
3935
|
+
// empty because the secret lives in the OS keychain, the pool asks again
|
|
3936
|
+
// at dial time instead of failing with "no credentials".
|
|
3937
|
+
probe.passwordResolver = async () => machinePassword(savedRec || {})
|
|
3657
3938
|
const started = Date.now()
|
|
3658
3939
|
await probe.connect()
|
|
3659
3940
|
await probe.exec('true', { timeoutMs: 10000 })
|
|
@@ -3676,6 +3957,9 @@ export async function apply(ctx, config) {
|
|
|
3676
3957
|
platform: probe.platform,
|
|
3677
3958
|
shell: probe.shellMode,
|
|
3678
3959
|
gitBash: probe.gitBashPath || '',
|
|
3960
|
+
// Tell the UI whether the probe leaned on the SAVED credential: with
|
|
3961
|
+
// an empty password box, "测试成功" otherwise looks impossible.
|
|
3962
|
+
usedSavedCredential: cred.usedSavedCredential,
|
|
3679
3963
|
// Issue #38: tell the settings page where an alias resolved to (and
|
|
3680
3964
|
// anything the plugin could not honour, e.g. a multi-hop ProxyJump).
|
|
3681
3965
|
alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
|
|
@@ -3726,11 +4010,23 @@ export async function apply(ctx, config) {
|
|
|
3726
4010
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
3727
4011
|
const action = String(body.action || '')
|
|
3728
4012
|
if (action === 'define') {
|
|
4013
|
+
// Validate the port HERE, not at start time: `listen(0)` would silently
|
|
4014
|
+
// pick an ephemeral port and the user would be looking at a forward
|
|
4015
|
+
// that listens nowhere they asked for. The settings UI cannot produce
|
|
4016
|
+
// this, but the HTTP API (and therefore an agent) can.
|
|
4017
|
+
const listenPort = Number(body.listenPort)
|
|
4018
|
+
if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) {
|
|
4019
|
+
return sendJson(res, 400, { ok: false, error: 'listenPort 必须是 1–65535 的整数' })
|
|
4020
|
+
}
|
|
4021
|
+
const targetPort = Number(body.targetPort) || listenPort
|
|
4022
|
+
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
4023
|
+
return sendJson(res, 400, { ok: false, error: 'targetPort 必须是 1–65535 的整数' })
|
|
4024
|
+
}
|
|
3729
4025
|
const d = forwards.define({
|
|
3730
4026
|
direction: body.direction === 'reverse' ? 'reverse' : 'local',
|
|
3731
|
-
listenPort
|
|
4027
|
+
listenPort,
|
|
3732
4028
|
targetHost: body.targetHost || '127.0.0.1',
|
|
3733
|
-
targetPort
|
|
4029
|
+
targetPort,
|
|
3734
4030
|
autoStart: !!body.autoStart,
|
|
3735
4031
|
machineId: store.currentId,
|
|
3736
4032
|
})
|
|
@@ -4109,7 +4405,16 @@ export async function apply(ctx, config) {
|
|
|
4109
4405
|
if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
|
|
4110
4406
|
return sendJson(res, 200, { ok: true, home })
|
|
4111
4407
|
} catch (err) {
|
|
4112
|
-
|
|
4408
|
+
// `ok:false` — reporting `ok:true` WITH an `error` is a contradiction:
|
|
4409
|
+
// any caller that trusts `ok` treats "could not resolve the home
|
|
4410
|
+
// directory" as success. The picker only reads `home`, so it still falls
|
|
4411
|
+
// back to `hint`/`error` exactly as before.
|
|
4412
|
+
return sendJson(res, 200, {
|
|
4413
|
+
ok: false,
|
|
4414
|
+
home: null,
|
|
4415
|
+
hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径',
|
|
4416
|
+
error: String((err && err.message) || err),
|
|
4417
|
+
})
|
|
4113
4418
|
}
|
|
4114
4419
|
},
|
|
4115
4420
|
},
|