dsh-remote 0.8.10 → 0.8.12
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/cordis.patch.yml +53 -53
- package/lib/binding.js +97 -0
- package/lib/client.js +1986 -1496
- package/lib/index.js +395 -127
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ import { importableEntries, sshConfigPath, readSshConfigText } from './sshconfig
|
|
|
39
39
|
import { saveSecret, getSecret, deleteSecret, platformBackend } from './credential.js'
|
|
40
40
|
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
41
41
|
import { searchTree } from './search.js'
|
|
42
|
+
import { resolveMirror, poolKey } from './binding.js'
|
|
42
43
|
import { TaskManager } from './tasks.js'
|
|
43
44
|
import { ForwardManager } from './forwards.js'
|
|
44
45
|
import { selfDir, readVersion, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode } from './update.js'
|
|
@@ -859,6 +860,106 @@ export async function apply(ctx, config) {
|
|
|
859
860
|
})
|
|
860
861
|
}
|
|
861
862
|
|
|
863
|
+
// ── per-machine SSH pools (session-bound) ─────────────────────────────────
|
|
864
|
+
// The `pool` above stays the ACTIVE-machine pool: it backs the settings page,
|
|
865
|
+
// the workspace picker routes, and `rw_connect`, whose semantics are "switch
|
|
866
|
+
// the active machine". Session-bound tool calls must not go through it —
|
|
867
|
+
// `setTarget()` rewrites the shared `config` identity and closes the live
|
|
868
|
+
// connection, so two sessions on different machines would take the pool from
|
|
869
|
+
// each other and commands would land on the wrong host (issue #25).
|
|
870
|
+
//
|
|
871
|
+
// Instead each machine gets its own pool, keyed by its stable identity. A
|
|
872
|
+
// session resolves its machine from its own workspace mirror, so concurrent
|
|
873
|
+
// sessions on different hosts never share a connection. Sessions on the SAME
|
|
874
|
+
// machine still share one, preserving the single-connection + keepalive model.
|
|
875
|
+
|
|
876
|
+
/** Deployment-varying tunables shared by every pool, owned by the live plugin
|
|
877
|
+
* Config so a settings change reaches existing pools. Identity, credential,
|
|
878
|
+
* and per-machine policy fields (`hostKeyMode`, `shell`, `proxy`) are
|
|
879
|
+
* deliberately excluded — those are pinned per pool from the machine record,
|
|
880
|
+
* and re-copying them from the shared config is exactly the bug this
|
|
881
|
+
* registry fixes. */
|
|
882
|
+
const TUNABLE_KEYS = [
|
|
883
|
+
'connectTimeoutMs', 'commandTimeoutMs', 'maxOutputChars', 'maxFileBytes', 'encoding',
|
|
884
|
+
]
|
|
885
|
+
const syncTunables = (target) => {
|
|
886
|
+
for (const k of TUNABLE_KEYS) {
|
|
887
|
+
if (Object.prototype.hasOwnProperty.call(config, k)) target[k] = config[k]
|
|
888
|
+
}
|
|
889
|
+
return target
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const machinePools = new Map()
|
|
893
|
+
ctx.effect(() => () => {
|
|
894
|
+
for (const p of machinePools.values()) {
|
|
895
|
+
try { p.close() } catch { /* a pool already torn down by its own error path */ }
|
|
896
|
+
}
|
|
897
|
+
machinePools.clear()
|
|
898
|
+
}, 'dsh-remote.machine-pools')
|
|
899
|
+
|
|
900
|
+
/** Registry record for a resolved mirror origin, so a session-bound pool gets
|
|
901
|
+
* the machine's real credentials (key path, passphrase, agent, proxy) rather
|
|
902
|
+
* than only the host/user/port the mirror meta records.
|
|
903
|
+
*
|
|
904
|
+
* An EPHEMERAL connection (rw_connect save:false) is not in the registry but
|
|
905
|
+
* is still a legitimate credential source for a pool the same process later
|
|
906
|
+
* resolves for that identity: without it a mirror picked on a temporary
|
|
907
|
+
* connection would resolve to an empty-credential pool and fail to connect. */
|
|
908
|
+
const machineRecordFor = ({ host, port, username }) =>
|
|
909
|
+
(ephemeral && ephemeral.host === host && Number(ephemeral.port) === Number(port) && (ephemeral.username || '') === (username || '')
|
|
910
|
+
? ephemeral
|
|
911
|
+
: null)
|
|
912
|
+
|| machines.find((m) => m.host === host && Number(m.port) === Number(port) && (m.username || '') === (username || ''))
|
|
913
|
+
|| null
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Get (or lazily create) the dedicated pool for one remote identity.
|
|
917
|
+
* @param {{host: string, port: number, username: string}} target - the mirror-recorded origin.
|
|
918
|
+
* @returns {SshPool} the pool pinned to that identity; never the active-machine pool.
|
|
919
|
+
*/
|
|
920
|
+
const poolForMachine = (target) => {
|
|
921
|
+
const key = poolKey(target)
|
|
922
|
+
const rec = machineRecordFor(target)
|
|
923
|
+
const existing = machinePools.get(key)
|
|
924
|
+
if (existing) {
|
|
925
|
+
syncTunables(existing.config)
|
|
926
|
+
// Re-read credentials and per-machine policy from the registry: editing a
|
|
927
|
+
// machine in the settings page must reach a pool created earlier, and a
|
|
928
|
+
// pool holding a stale password would keep failing to reconnect.
|
|
929
|
+
if (rec) {
|
|
930
|
+
existing.config.password = rec.password || ''
|
|
931
|
+
existing.config.privateKeyPath = rec.privateKeyPath || ''
|
|
932
|
+
existing.config.passphrase = rec.passphrase || ''
|
|
933
|
+
existing.config.useAgent = !!rec.useAgent
|
|
934
|
+
existing.config.keyboardInteractive = !!rec.keyboardInteractive
|
|
935
|
+
existing.config.proxy = rec.proxy || undefined
|
|
936
|
+
existing.config.hostKeyMode = rec.hostKeyMode || config.hostKeyMode
|
|
937
|
+
}
|
|
938
|
+
return existing
|
|
939
|
+
}
|
|
940
|
+
const poolConfig = syncTunables({
|
|
941
|
+
...config,
|
|
942
|
+
host: target.host,
|
|
943
|
+
port: Number(target.port) || 22,
|
|
944
|
+
username: target.username || (rec && rec.username) || 'root',
|
|
945
|
+
password: (rec && rec.password) || '',
|
|
946
|
+
privateKeyPath: (rec && rec.privateKeyPath) || '',
|
|
947
|
+
passphrase: (rec && rec.passphrase) || '',
|
|
948
|
+
useAgent: rec ? !!rec.useAgent : !!config.useAgent,
|
|
949
|
+
keyboardInteractive: rec ? !!rec.keyboardInteractive : !!config.keyboardInteractive,
|
|
950
|
+
proxy: (rec && rec.proxy) || undefined,
|
|
951
|
+
hostKeyMode: (rec && rec.hostKeyMode) || config.hostKeyMode,
|
|
952
|
+
// Identity-pinned pools never carry the shared active workspace: a
|
|
953
|
+
// session passes its own mirror-resolved remote path explicitly.
|
|
954
|
+
workspace: '',
|
|
955
|
+
})
|
|
956
|
+
const created = new SshPool(poolConfig)
|
|
957
|
+
// Keychain-backed passwords resolve against THIS pool's machine record.
|
|
958
|
+
created.passwordResolver = async () => machinePassword(rec || { id: '', password: poolConfig.password })
|
|
959
|
+
machinePools.set(key, created)
|
|
960
|
+
return created
|
|
961
|
+
}
|
|
962
|
+
|
|
862
963
|
const setCurrent = async (id) => {
|
|
863
964
|
if (!id) {
|
|
864
965
|
// Explicit "active remote = none": saved machines stay in the registry
|
|
@@ -955,10 +1056,24 @@ export async function apply(ctx, config) {
|
|
|
955
1056
|
}
|
|
956
1057
|
|
|
957
1058
|
// ── audit log ─────────────────────────────────────────────────────────────
|
|
958
|
-
|
|
1059
|
+
/**
|
|
1060
|
+
* Append one audit line. `target` names the machine the operation really ran
|
|
1061
|
+
* on and must be passed by every session-bound caller: the shared `config`
|
|
1062
|
+
* identity belongs to the ACTIVE machine, which can be a different host than
|
|
1063
|
+
* the session that issued the command, so defaulting to it would
|
|
1064
|
+
* misattribute the operation.
|
|
1065
|
+
* @param {string} op - operation name.
|
|
1066
|
+
* @param {string} cmd - command text or path detail.
|
|
1067
|
+
* @param {number|null} code - exit code, or null when not applicable.
|
|
1068
|
+
* @param {{host?: string, username?: string, port?: number}} [target] - machine acted on; defaults to the active machine.
|
|
1069
|
+
*/
|
|
1070
|
+
const audit = (op, cmd, code, target) => {
|
|
959
1071
|
if (!config.auditLog) return
|
|
1072
|
+
const host = (target && target.host) || config.host
|
|
1073
|
+
const user = (target && target.username) || config.username
|
|
1074
|
+
const port = (target && target.port) || config.port
|
|
960
1075
|
try {
|
|
961
|
-
const line = [new Date().toISOString(), `${
|
|
1076
|
+
const line = [new Date().toISOString(), `${user || '?'}@${host || '?'}:${port}`, op, code == null ? '-' : String(code), String(cmd || '').replace(/\s+/g, ' ').slice(0, 400)].join(' | ') + '\n'
|
|
962
1077
|
appendFileSync(auditFile(), line, 'utf8')
|
|
963
1078
|
} catch {}
|
|
964
1079
|
}
|
|
@@ -998,22 +1113,27 @@ export async function apply(ctx, config) {
|
|
|
998
1113
|
if (!entry || !entry.pending.size) return
|
|
999
1114
|
const rels = [...entry.pending]
|
|
1000
1115
|
entry.pending.clear()
|
|
1001
|
-
|
|
1002
|
-
|
|
1116
|
+
// Resolve the destination from the WATCHED MIRROR, not from the active
|
|
1117
|
+
// machine: a watcher outlives any one session, so gating on the active
|
|
1118
|
+
// machine made it silently stop pushing (and, before per-machine pools,
|
|
1119
|
+
// risked pushing one host's mirror through another host's connection).
|
|
1120
|
+
const { remotePath: ws, machine } = resolveMirrorForLocal(localDir)
|
|
1121
|
+
if (!ws || !machine) return
|
|
1122
|
+
const target = poolForMachine(machine)
|
|
1003
1123
|
let sftp
|
|
1004
|
-
try { sftp = await
|
|
1124
|
+
try { sftp = await target.sftp() } catch { return }
|
|
1005
1125
|
const matcher = ignoreMatcher()
|
|
1006
1126
|
const state = loadSyncState(localDir)
|
|
1007
1127
|
const next = { ...state }
|
|
1008
1128
|
if (rels.includes('*')) {
|
|
1009
1129
|
const r = await pushTree(sftp, localDir, ws, { maxFiles: 500, maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
|
|
1010
1130
|
Object.assign(next, r.nextState)
|
|
1011
|
-
for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1)
|
|
1131
|
+
for (const c of r.stats.conflicts) audit('auto-push-conflict', `push ${c.path}`, 1, machine)
|
|
1012
1132
|
} else {
|
|
1013
1133
|
for (const rel of rels) {
|
|
1014
1134
|
const r = await pushOneFile(sftp, localDir, ws, rel, { maxFileBytes: config.maxFileBytes, isIgnored: matcher, state: next })
|
|
1015
1135
|
if (r.status === 'pushed' && r.state) Object.assign(next, r.state)
|
|
1016
|
-
else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1)
|
|
1136
|
+
else if (r.status === 'conflict') audit('auto-push-conflict', `push ${rel}`, 1, machine)
|
|
1017
1137
|
}
|
|
1018
1138
|
}
|
|
1019
1139
|
saveSyncState(localDir, next)
|
|
@@ -1047,8 +1167,11 @@ export async function apply(ctx, config) {
|
|
|
1047
1167
|
autoPushWatchers.clear()
|
|
1048
1168
|
}, 'dsh-remote.autopush')
|
|
1049
1169
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1170
|
+
// Helpers shared by session-bound tools and the machine-scoped web routes take
|
|
1171
|
+
// the pool explicitly (`p`), defaulting to the active-machine pool so route
|
|
1172
|
+
// callers — which are machine-scoped by design — stay unchanged.
|
|
1173
|
+
const run = async (cmd, opts = {}, p = pool) => {
|
|
1174
|
+
const res = await p.exec(cmd, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {})
|
|
1052
1175
|
const parts = []
|
|
1053
1176
|
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
1054
1177
|
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
@@ -1061,11 +1184,11 @@ export async function apply(ctx, config) {
|
|
|
1061
1184
|
|
|
1062
1185
|
/** Structured listing: name + type + size + mtime + mode (SFTP protocol-level,
|
|
1063
1186
|
* works on any remote: POSIX / cmd.exe / PowerShell). */
|
|
1064
|
-
const listDirStructured = async (p) => {
|
|
1187
|
+
const listDirStructured = async (p, sshPool = pool) => {
|
|
1065
1188
|
const target = normalizeRemotePath(p || '/')
|
|
1066
1189
|
let sftp
|
|
1067
1190
|
try {
|
|
1068
|
-
sftp = await
|
|
1191
|
+
sftp = await sshPool.sftp()
|
|
1069
1192
|
} catch (err) {
|
|
1070
1193
|
throw new Error('browse failed: ' + ((err && err.message) || err))
|
|
1071
1194
|
}
|
|
@@ -1141,10 +1264,10 @@ export async function apply(ctx, config) {
|
|
|
1141
1264
|
return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
|
|
1142
1265
|
}
|
|
1143
1266
|
|
|
1144
|
-
const isRemoteDir = async (p) => {
|
|
1267
|
+
const isRemoteDir = async (p, sshPool = pool) => {
|
|
1145
1268
|
const target = normalizeRemotePath(p)
|
|
1146
1269
|
try {
|
|
1147
|
-
const sftp = await
|
|
1270
|
+
const sftp = await sshPool.sftp()
|
|
1148
1271
|
const st = await sftp.stat(target)
|
|
1149
1272
|
return !!(st && st.isDirectory && st.isDirectory())
|
|
1150
1273
|
} catch {
|
|
@@ -1180,44 +1303,26 @@ export async function apply(ctx, config) {
|
|
|
1180
1303
|
const wsPath = () => (config.workspace || '').trim()
|
|
1181
1304
|
/** Map a LOCAL path to the remote path its mirror represents ('' when the
|
|
1182
1305
|
* path is not inside any dsh-remote mirror). Shared by the resolve-mirror
|
|
1183
|
-
* route, the session-aware system-prompt injection, and status().
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
for (const hostDir of readdirSync(root)) {
|
|
1192
|
-
const hostPath = path.join(root, hostDir)
|
|
1193
|
-
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1194
|
-
for (const mirrorDir of readdirSync(hostPath)) {
|
|
1195
|
-
const metaPath = path.join(hostPath, mirrorDir, '.dsh-remote-meta.json')
|
|
1196
|
-
if (!existsSync(metaPath)) continue
|
|
1197
|
-
try {
|
|
1198
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
1199
|
-
const mirrorAbs = norm(path.join(hostPath, mirrorDir))
|
|
1200
|
-
if (mirrorAbs === base || base.startsWith(mirrorAbs + path.sep) || base.startsWith(mirrorAbs + '/')) {
|
|
1201
|
-
if (mirrorAbs.length > matched.length) {
|
|
1202
|
-
matched = mirrorAbs
|
|
1203
|
-
matchedRemote = String(meta.remotePath || '')
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
} catch { /* skip unparsable meta */ }
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
return { mirrorDir: matched || null, remotePath: matchedRemote || '' }
|
|
1211
|
-
}
|
|
1306
|
+
* route, the session-aware system-prompt injection, and status().
|
|
1307
|
+
*
|
|
1308
|
+
* `machine` carries the mirror's recorded origin (`host`/`port`/`username`
|
|
1309
|
+
* from `.dsh-remote-meta.json`), or null when the path is not inside a
|
|
1310
|
+
* mirror. That origin is what makes a session's remote target authoritative
|
|
1311
|
+
* without consulting the mutable active-machine state, so a concurrent
|
|
1312
|
+
* session switching machines cannot redirect this session's commands. */
|
|
1313
|
+
const resolveMirrorForLocal = (local) => resolveMirror(local, remoteWorkspacesRoot())
|
|
1212
1314
|
if (config.autoPush && wsPath()) {
|
|
1213
1315
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
1214
1316
|
}
|
|
1215
|
-
/** The remote path
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1317
|
+
/** The remote path one agent session is bound to, or '' when that session is
|
|
1318
|
+
* a plain local one. Walks the mirror registry (host side copy of the
|
|
1319
|
+
* resolve-mirror logic) so status / prompt / sidebar agree.
|
|
1320
|
+
*
|
|
1321
|
+
* `sessionId` narrows to one session. WITHOUT it the first live session's cwd
|
|
1322
|
+
* is used, which is only meaningful for the machine-scoped settings view:
|
|
1323
|
+
* with several live sessions the "first" one is arbitrary and may belong to
|
|
1324
|
+
* another machine. Tool calls must never route through this — they resolve
|
|
1325
|
+
* their own session through `bindingFor()`, which is authoritative. */
|
|
1221
1326
|
const sessionRemotePath = (sessionId) => {
|
|
1222
1327
|
let cwd = ''
|
|
1223
1328
|
try {
|
|
@@ -1233,6 +1338,111 @@ export async function apply(ctx, config) {
|
|
|
1233
1338
|
if (!cwd) return ''
|
|
1234
1339
|
return resolveMirrorForLocal(cwd).remotePath
|
|
1235
1340
|
}
|
|
1341
|
+
|
|
1342
|
+
// ── session-bound remote binding (the authoritative tool path) ─────────────
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Resolve the remote binding a tool call must act on, from the calling
|
|
1346
|
+
* session's own workspace cwd.
|
|
1347
|
+
*
|
|
1348
|
+
* This is what keeps concurrent sessions independent: the machine and the
|
|
1349
|
+
* workspace root both come from the session's mirror `.dsh-remote-meta.json`,
|
|
1350
|
+
* never from the mutable active-machine state, so another session switching
|
|
1351
|
+
* machines cannot redirect this call. Sessions on the same machine resolve to
|
|
1352
|
+
* the same key and share one pool.
|
|
1353
|
+
*
|
|
1354
|
+
* @param {object} [exec] - the tool's `ToolRunContext`; `exec.agent.session.header.cwd` identifies the caller.
|
|
1355
|
+
* @returns {{pool: SshPool, ws: string, host: string, username: string, port: number, mirrorDir: string|null, bound: boolean, local: boolean}}
|
|
1356
|
+
* `bound` is true only for a session whose cwd resolves to a mirror. A
|
|
1357
|
+
* caller with a cwd that is NOT a mirror is a local session: `local` is
|
|
1358
|
+
* true and the active-machine values are returned for machine-scoped
|
|
1359
|
+
* reporting (`rw_info`) — session-scoped tools must refuse it instead,
|
|
1360
|
+
* which is what {@link requireBinding} enforces.
|
|
1361
|
+
*/
|
|
1362
|
+
const bindingFor = (exec) => {
|
|
1363
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
1364
|
+
if (cwd) {
|
|
1365
|
+
const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(String(cwd))
|
|
1366
|
+
if (remotePath && machine) {
|
|
1367
|
+
return {
|
|
1368
|
+
pool: poolForMachine(machine),
|
|
1369
|
+
ws: remotePath,
|
|
1370
|
+
host: machine.host,
|
|
1371
|
+
username: machine.username,
|
|
1372
|
+
port: machine.port,
|
|
1373
|
+
mirrorDir,
|
|
1374
|
+
bound: true,
|
|
1375
|
+
local: false,
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
pool,
|
|
1381
|
+
ws: wsPath(),
|
|
1382
|
+
host: config.host,
|
|
1383
|
+
username: config.username,
|
|
1384
|
+
port: config.port,
|
|
1385
|
+
mirrorDir: wsPath() ? mirrorDirFor(wsPath(), config.host, config.username, config.port) : null,
|
|
1386
|
+
bound: false,
|
|
1387
|
+
// A caller that identified itself with a cwd, yet resolved to no mirror,
|
|
1388
|
+
// is definitively a LOCAL session. Distinguish it from "no caller
|
|
1389
|
+
// context at all", where the active machine is still the right answer.
|
|
1390
|
+
local: !!cwd,
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Binding for a tool that requires a remote workspace, with a diagnostic
|
|
1396
|
+
* refusal when none is resolvable. Failing loud here is the point: silently
|
|
1397
|
+
* falling back to the active machine is how a command reached the wrong host.
|
|
1398
|
+
* @param {object} exec - the tool's `ToolRunContext`.
|
|
1399
|
+
* @param {string} tool - tool name, used in the error message.
|
|
1400
|
+
* @returns {ReturnType<typeof bindingFor>} a binding with `bound: true`.
|
|
1401
|
+
*/
|
|
1402
|
+
const requireBinding = (exec, tool) => {
|
|
1403
|
+
const b = bindingFor(exec)
|
|
1404
|
+
// A LOCAL session must be refused even when an active machine happens to
|
|
1405
|
+
// have a workspace: inheriting it is precisely how a command reaches a host
|
|
1406
|
+
// the caller never asked for. Checking only `!b.ws` missed this, because
|
|
1407
|
+
// the active machine's workspace is usually non-empty.
|
|
1408
|
+
if (b.local) {
|
|
1409
|
+
throw new Error(
|
|
1410
|
+
`${tool}: this session is LOCAL — its workspace is not a remote mirror, `
|
|
1411
|
+
+ 'so there is no remote host to act on. Refusing rather than falling back to the active machine '
|
|
1412
|
+
+ `(${config.username || '?'}@${config.host || '?'}), which belongs to a different session. `
|
|
1413
|
+
+ 'Use rw_connect + rw_pick_workspace to bind a remote workspace, then open a session in its local mirror.',
|
|
1414
|
+
)
|
|
1415
|
+
}
|
|
1416
|
+
if (!b.ws) {
|
|
1417
|
+
throw new Error(
|
|
1418
|
+
`${tool}: no remote workspace set — call rw_connect then rw_pick_workspace first.`,
|
|
1419
|
+
)
|
|
1420
|
+
}
|
|
1421
|
+
if (!b.host) throw new Error(`${tool}: no remote host resolved for this session — run rw_connect first.`)
|
|
1422
|
+
return b
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Binding for a tool that takes an explicit absolute remote path: it needs a
|
|
1427
|
+
* resolvable MACHINE but not a workspace root. A local session is refused for
|
|
1428
|
+
* the same reason as {@link requireBinding} — the active machine belongs to
|
|
1429
|
+
* whoever set it, not to this caller.
|
|
1430
|
+
* @param {object} exec - the tool's `ToolRunContext`.
|
|
1431
|
+
* @param {string} tool - tool name, used in the error message.
|
|
1432
|
+
* @returns {ReturnType<typeof bindingFor>} a binding whose `host` is non-empty.
|
|
1433
|
+
*/
|
|
1434
|
+
const requireMachine = (exec, tool) => {
|
|
1435
|
+
const b = bindingFor(exec)
|
|
1436
|
+
if (b.local) {
|
|
1437
|
+
throw new Error(
|
|
1438
|
+
`${tool}: this session is LOCAL — its workspace is not a remote mirror, `
|
|
1439
|
+
+ 'so there is no remote host to act on. Refusing rather than falling back to the active machine '
|
|
1440
|
+
+ `(${config.username || '?'}@${config.host || '?'}), which belongs to a different session.`,
|
|
1441
|
+
)
|
|
1442
|
+
}
|
|
1443
|
+
if (!b.host) throw new Error(`${tool}: no remote host for this session — run rw_connect first`)
|
|
1444
|
+
return b
|
|
1445
|
+
}
|
|
1236
1446
|
const status = () => ({
|
|
1237
1447
|
host: config.host,
|
|
1238
1448
|
port: config.port,
|
|
@@ -1276,27 +1486,44 @@ export async function apply(ctx, config) {
|
|
|
1276
1486
|
'Show the remote environment: host/user/port, connection health, current remote workspace path, active port forwards. Call this first to orient, or when an rw_* call fails to check connectivity. Note: remote context is session-scoped — a saved machine only becomes active when you explicitly rw_connect to it (issue #13).',
|
|
1277
1487
|
parameters: {},
|
|
1278
1488
|
output: textOut,
|
|
1279
|
-
async execute() {
|
|
1489
|
+
async execute(_args, exec) {
|
|
1490
|
+
// Report THIS session's binding, not the globally active machine: the
|
|
1491
|
+
// two differ whenever another session is working on another host, and
|
|
1492
|
+
// reporting the active machine is what made this tool contradict itself.
|
|
1493
|
+
const b = bindingFor(exec)
|
|
1494
|
+
// rw_info is the orientation tool, so resolve the real platform rather
|
|
1495
|
+
// than reporting "detecting…" for a pool this session has not used yet.
|
|
1496
|
+
if (b.host) {
|
|
1497
|
+
try { await b.pool.detect() } catch { /* unreachable host → the ping below reports it */ }
|
|
1498
|
+
}
|
|
1280
1499
|
const s = status()
|
|
1281
|
-
const lines =
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1500
|
+
const lines = b.bound
|
|
1501
|
+
? [
|
|
1502
|
+
`Remote host: ${b.username || '<user>'}@${b.host}:${b.port} (bound to this session's workspace)`,
|
|
1503
|
+
`Current remote workspace: ${toDisplayPath(b.ws, b.pool.platform)}`,
|
|
1504
|
+
`Local mirror: ${b.mirrorDir || '(none)'}`,
|
|
1505
|
+
`Connected: ${b.pool.client ? 'yes' : 'no'}`,
|
|
1506
|
+
`Remote shell: ${b.pool.platform === 'windows' ? (b.pool.gitBashPath ? 'Git Bash (' + b.pool.gitBashPath + ')' : 'Windows (Git Bash not found — set config.shell to a bash.exe path)') : (b.pool.platform === 'posix' ? 'native POSIX' : 'detecting…')}`,
|
|
1507
|
+
`Active forwards: ${s.forwards.filter((f) => f.active).length} / ${s.forwards.length} (forwards are machine-scoped)`,
|
|
1508
|
+
`Host key: ${isHostKeyKnown(b.host, b.port) ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
1509
|
+
'',
|
|
1510
|
+
]
|
|
1511
|
+
: [
|
|
1512
|
+
`Remote host: ${s.username || '<user>'}@${s.host || '<host>'}:${s.port}${s.activeSource !== 'machine' ? ` (source: ${s.activeSource})` : ''}`,
|
|
1513
|
+
'Session remote context: (this session is LOCAL — no remote workspace bound)',
|
|
1514
|
+
`Active machine workspace: ${s.workspace || '(none — call rw_connect then rw_pick_workspace to set one)'}`,
|
|
1515
|
+
`Connected: ${s.connected ? 'yes' : 'no'}`,
|
|
1516
|
+
`Host key: ${s.hostKeyKnown ? 'trusted' : 'not yet trusted'} (mode=${s.hostKeyMode})`,
|
|
1517
|
+
'',
|
|
1518
|
+
]
|
|
1519
|
+
if (b.host && b.ws) {
|
|
1293
1520
|
try {
|
|
1294
|
-
const res = await pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1521
|
+
const res = await b.pool.exec('echo ok', { timeoutMs: Math.min(config.commandTimeoutMs, 8000) })
|
|
1295
1522
|
if (res.signal === 'TIMEOUT') lines.push('Ping: timeout')
|
|
1296
1523
|
else if (res.code === 0) lines.push('Ping: OK — ' + res.stdout.replace(/\s+/g, ' ').trim())
|
|
1297
1524
|
else lines.push('Ping: FAILED — ' + (res.stderr || res.stdout || `exit ${res.code}`).trim())
|
|
1298
1525
|
} catch (err) {
|
|
1299
|
-
lines.push('Ping: FAILED — ' + friendlyMessage(err,
|
|
1526
|
+
lines.push('Ping: FAILED — ' + friendlyMessage(err, { host: b.host, port: b.port }))
|
|
1300
1527
|
}
|
|
1301
1528
|
} else {
|
|
1302
1529
|
lines.push('No host + workspace configured — call rw_connect with a host to get started.')
|
|
@@ -1376,17 +1603,28 @@ export async function apply(ctx, config) {
|
|
|
1376
1603
|
path: { type: 'string', required: true, description: 'Absolute remote directory path, e.g. /home/dev/code/project or C:\\Users\\dev\\project' },
|
|
1377
1604
|
},
|
|
1378
1605
|
output: textOut,
|
|
1379
|
-
async execute(args) {
|
|
1606
|
+
async execute(args, exec) {
|
|
1380
1607
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1381
1608
|
if (!p || p === '/') throw new Error('rw_pick_workspace: path must be an absolute directory')
|
|
1382
|
-
|
|
1383
|
-
|
|
1609
|
+
// Picking a workspace targets the machine this session is already bound
|
|
1610
|
+
// to when it has one. A LOCAL session deliberately falls back to the
|
|
1611
|
+
// active machine here: this is the setup flow that runs right after
|
|
1612
|
+
// rw_connect, before any mirror exists to bind to. It creates a mirror
|
|
1613
|
+
// rather than reading or writing remote data, so the refusal that
|
|
1614
|
+
// guards the other tools would make binding a first workspace
|
|
1615
|
+
// impossible.
|
|
1616
|
+
const b = bindingFor(exec)
|
|
1617
|
+
if (!b.host) throw new Error('rw_pick_workspace: no remote host — call rw_connect first')
|
|
1618
|
+
const ok = await isRemoteDir(p, b.pool)
|
|
1619
|
+
const shown = toDisplayPath(p, b.pool.platform)
|
|
1384
1620
|
if (!ok) return { text: `not a directory (or missing) on ${shown}` }
|
|
1385
|
-
|
|
1386
|
-
|
|
1621
|
+
// A bound session's workspace lives in its own mirror meta, so the
|
|
1622
|
+
// shared active-machine workspace is only updated for the unbound flow.
|
|
1623
|
+
if (!b.bound) persistWorkspace(p)
|
|
1624
|
+
const local = ensureMirror(p, b.host, b.username, b.port)
|
|
1387
1625
|
startAutoPush(local)
|
|
1388
1626
|
return {
|
|
1389
|
-
text: `Remote workspace set to ${shown} on ${
|
|
1627
|
+
text: `Remote workspace set to ${shown} on ${b.username}@${b.host}.\nLocal mirror (native workspace path): ${local}\n\nRun rw_sync to download its files into the local mirror.`,
|
|
1390
1628
|
}
|
|
1391
1629
|
},
|
|
1392
1630
|
}),
|
|
@@ -1403,10 +1641,10 @@ export async function apply(ctx, config) {
|
|
|
1403
1641
|
async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
|
|
1404
1642
|
},
|
|
1405
1643
|
output: textOut,
|
|
1406
|
-
async execute(args) {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
const local = mirrorDirFor(p,
|
|
1644
|
+
async execute(args, exec) {
|
|
1645
|
+
const b = requireBinding(exec, 'rw_sync')
|
|
1646
|
+
const p = b.ws
|
|
1647
|
+
const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
|
|
1410
1648
|
mkdirSync(local, { recursive: true })
|
|
1411
1649
|
const depth = Math.min(Math.max(Number(args.depth) || 5, 1), 8)
|
|
1412
1650
|
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
@@ -1415,7 +1653,7 @@ export async function apply(ctx, config) {
|
|
|
1415
1653
|
const runSync = async () => {
|
|
1416
1654
|
let sftp
|
|
1417
1655
|
try {
|
|
1418
|
-
sftp = await pool.sftp()
|
|
1656
|
+
sftp = await b.pool.sftp()
|
|
1419
1657
|
} catch (err) {
|
|
1420
1658
|
throw new Error('sftp unavailable: ' + ((err && err.message) || err))
|
|
1421
1659
|
}
|
|
@@ -1453,10 +1691,10 @@ export async function apply(ctx, config) {
|
|
|
1453
1691
|
async: { type: 'boolean', description: 'Run in the background and return a task id (default false)' },
|
|
1454
1692
|
},
|
|
1455
1693
|
output: textOut,
|
|
1456
|
-
async execute(args) {
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
const local = mirrorDirFor(p,
|
|
1694
|
+
async execute(args, exec) {
|
|
1695
|
+
const b = requireBinding(exec, 'rw_push')
|
|
1696
|
+
const p = b.ws
|
|
1697
|
+
const local = b.mirrorDir || mirrorDirFor(p, b.host, b.username, b.port)
|
|
1460
1698
|
if (!existsSync(local)) throw new Error(`rw_push: local mirror does not exist — run rw_sync first (${local})`)
|
|
1461
1699
|
const maxFiles = Math.min(Math.max(Number(args.maxFiles) || 500, 1), 2000)
|
|
1462
1700
|
const isIgnored = ignoreMatcher()
|
|
@@ -1464,7 +1702,7 @@ export async function apply(ctx, config) {
|
|
|
1464
1702
|
const runPush = async () => {
|
|
1465
1703
|
let sftp
|
|
1466
1704
|
try {
|
|
1467
|
-
sftp = await pool.sftp()
|
|
1705
|
+
sftp = await b.pool.sftp()
|
|
1468
1706
|
} catch (err) {
|
|
1469
1707
|
throw new Error('sftp unavailable: ' + ((err && err.message) || err))
|
|
1470
1708
|
}
|
|
@@ -1499,12 +1737,13 @@ export async function apply(ctx, config) {
|
|
|
1499
1737
|
path: { type: 'string', description: 'Absolute remote path (default: current remote workspace)' },
|
|
1500
1738
|
},
|
|
1501
1739
|
output: textOut,
|
|
1502
|
-
async execute(args) {
|
|
1503
|
-
const
|
|
1740
|
+
async execute(args, exec) {
|
|
1741
|
+
const b = requireBinding(exec, 'rw_list_dir')
|
|
1742
|
+
const p = args.path ? normalizeRemotePath(String(args.path)) : b.ws
|
|
1504
1743
|
if (!p) throw new Error('rw_list_dir: no path and no remote workspace set')
|
|
1505
1744
|
let list
|
|
1506
1745
|
try {
|
|
1507
|
-
const sftp = await pool.sftp()
|
|
1746
|
+
const sftp = await b.pool.sftp()
|
|
1508
1747
|
list = await sftp.readdir(p)
|
|
1509
1748
|
} catch (err) {
|
|
1510
1749
|
throw new Error('rw_list_dir: ' + ((err && err.message) || err))
|
|
@@ -1535,10 +1774,11 @@ export async function apply(ctx, config) {
|
|
|
1535
1774
|
path: { type: 'string', required: true, description: 'Absolute remote path' },
|
|
1536
1775
|
},
|
|
1537
1776
|
output: textOut,
|
|
1538
|
-
async execute(args) {
|
|
1777
|
+
async execute(args, exec) {
|
|
1778
|
+
const b = requireMachine(exec, 'rw_stat')
|
|
1539
1779
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1540
1780
|
if (!p) throw new Error('rw_stat: path is required')
|
|
1541
|
-
const sftp = await pool.sftp()
|
|
1781
|
+
const sftp = await b.pool.sftp()
|
|
1542
1782
|
let st
|
|
1543
1783
|
try {
|
|
1544
1784
|
st = await sftp.stat(p)
|
|
@@ -1569,14 +1809,15 @@ export async function apply(ctx, config) {
|
|
|
1569
1809
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1570
1810
|
},
|
|
1571
1811
|
output: textOut,
|
|
1572
|
-
async execute(args) {
|
|
1812
|
+
async execute(args, exec) {
|
|
1813
|
+
const b = requireMachine(exec, 'rw_read_file')
|
|
1573
1814
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1574
1815
|
if (!p) throw new Error('rw_read_file: path is required')
|
|
1575
1816
|
const maxLines = Math.min(Math.max(Number(args.maxLines) || 2000, 1), 10000)
|
|
1576
1817
|
let from = Math.max(Number(args.startLine) || 1, 1)
|
|
1577
1818
|
let to = Number(args.endLine) || 0
|
|
1578
1819
|
if (!to || to - from + 1 > maxLines) to = from + maxLines - 1
|
|
1579
|
-
const sftp = await pool.sftp()
|
|
1820
|
+
const sftp = await b.pool.sftp()
|
|
1580
1821
|
let st
|
|
1581
1822
|
try { st = await sftp.stat(p) } catch (err) { throw new Error('rw_read_file: ' + ((err && err.message) || err)) }
|
|
1582
1823
|
if (config.maxFileBytes > 0 && st.size > config.maxFileBytes) {
|
|
@@ -1609,16 +1850,17 @@ export async function apply(ctx, config) {
|
|
|
1609
1850
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1610
1851
|
},
|
|
1611
1852
|
output: okOut,
|
|
1612
|
-
async execute(args) {
|
|
1853
|
+
async execute(args, exec) {
|
|
1854
|
+
const b = requireMachine(exec, 'rw_write_file')
|
|
1613
1855
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1614
1856
|
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
1615
1857
|
const content = String(args.content == null ? '' : args.content)
|
|
1616
|
-
const sftp = await pool.sftp()
|
|
1858
|
+
const sftp = await b.pool.sftp()
|
|
1617
1859
|
if (args.mkdir !== false) await mkdirRemoteDirs(sftp, remoteDirname(p))
|
|
1618
1860
|
const buf = encodeText(content, args.encoding || config.encoding)
|
|
1619
1861
|
await sftp.writeFile(p, buf)
|
|
1620
1862
|
const bytes = buf.byteLength
|
|
1621
|
-
audit('write_file', `write ${p} (${bytes}B)`, 0)
|
|
1863
|
+
audit('write_file', `write ${p} (${bytes}B)`, 0, b)
|
|
1622
1864
|
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
1623
1865
|
},
|
|
1624
1866
|
}),
|
|
@@ -1635,13 +1877,14 @@ export async function apply(ctx, config) {
|
|
|
1635
1877
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1636
1878
|
},
|
|
1637
1879
|
output: okOut,
|
|
1638
|
-
async execute(args) {
|
|
1880
|
+
async execute(args, exec) {
|
|
1881
|
+
const b = requireMachine(exec, 'rw_edit')
|
|
1639
1882
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1640
1883
|
if (!p || p === '/') throw new Error('rw_edit: a file path is required')
|
|
1641
1884
|
const oldS = String(args.old ?? '')
|
|
1642
1885
|
const newS = String(args.new ?? '')
|
|
1643
1886
|
if (oldS === '') throw new Error('rw_edit: old text must not be empty')
|
|
1644
|
-
const sftp = await pool.sftp()
|
|
1887
|
+
const sftp = await b.pool.sftp()
|
|
1645
1888
|
const st0 = await sftp.stat(p)
|
|
1646
1889
|
const buf = await sftp.readFile(p)
|
|
1647
1890
|
const content = decodeBuf(buf, args.encoding || config.encoding)
|
|
@@ -1665,7 +1908,7 @@ export async function apply(ctx, config) {
|
|
|
1665
1908
|
throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
|
|
1666
1909
|
}
|
|
1667
1910
|
await sftp.writeFile(p, encodeText(out, args.encoding || config.encoding))
|
|
1668
|
-
audit('edit', `edit ${p} (${n} occurrence(s))`, 0)
|
|
1911
|
+
audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
|
|
1669
1912
|
return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
|
|
1670
1913
|
},
|
|
1671
1914
|
}),
|
|
@@ -1680,16 +1923,17 @@ export async function apply(ctx, config) {
|
|
|
1680
1923
|
encoding: { type: 'string', description: 'Text encoding, e.g. utf-8 (default) or gbk' },
|
|
1681
1924
|
},
|
|
1682
1925
|
output: okOut,
|
|
1683
|
-
async execute(args) {
|
|
1926
|
+
async execute(args, exec) {
|
|
1927
|
+
const b = requireMachine(exec, 'rw_append')
|
|
1684
1928
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1685
1929
|
if (!p || p === '/') throw new Error('rw_append: a file path is required')
|
|
1686
|
-
const sftp = await pool.sftp()
|
|
1930
|
+
const sftp = await b.pool.sftp()
|
|
1687
1931
|
let existing = ''
|
|
1688
1932
|
try { existing = decodeBuf(await sftp.readFile(p), args.encoding || config.encoding) } catch { /* new file */ }
|
|
1689
1933
|
const content = existing + String(args.content ?? '')
|
|
1690
1934
|
await sftp.writeFile(p, encodeText(content, args.encoding || config.encoding))
|
|
1691
1935
|
const bytes = Buffer.byteLength(content)
|
|
1692
|
-
audit('append', `append ${p}`, 0)
|
|
1936
|
+
audit('append', `append ${p}`, 0, b)
|
|
1693
1937
|
return { ok: true, bytes, text: `appended to ${p} (now ${bytes} bytes)` }
|
|
1694
1938
|
},
|
|
1695
1939
|
}),
|
|
@@ -1702,12 +1946,13 @@ export async function apply(ctx, config) {
|
|
|
1702
1946
|
path: { type: 'string', required: true, description: 'Absolute remote directory path' },
|
|
1703
1947
|
},
|
|
1704
1948
|
output: textOut,
|
|
1705
|
-
async execute(args) {
|
|
1949
|
+
async execute(args, exec) {
|
|
1950
|
+
const b = requireMachine(exec, 'rw_mkdir')
|
|
1706
1951
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1707
1952
|
if (!p || p === '/') throw new Error('rw_mkdir: a directory path is required')
|
|
1708
|
-
const sftp = await pool.sftp()
|
|
1953
|
+
const sftp = await b.pool.sftp()
|
|
1709
1954
|
await mkdirRemoteDirs(sftp, p)
|
|
1710
|
-
audit('mkdir', `mkdir ${p}`, 0)
|
|
1955
|
+
audit('mkdir', `mkdir ${p}`, 0, b)
|
|
1711
1956
|
return { text: `created ${p}` }
|
|
1712
1957
|
},
|
|
1713
1958
|
}),
|
|
@@ -1721,20 +1966,21 @@ export async function apply(ctx, config) {
|
|
|
1721
1966
|
recursive: { type: 'boolean', description: 'Recursively delete a directory (default false)' },
|
|
1722
1967
|
},
|
|
1723
1968
|
output: okOut,
|
|
1724
|
-
async execute(args) {
|
|
1969
|
+
async execute(args, exec) {
|
|
1970
|
+
const b = requireMachine(exec, 'rw_remove')
|
|
1725
1971
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1726
1972
|
if (!p || p === '/') throw new Error('rw_remove: a path is required')
|
|
1727
|
-
const sftp = await pool.sftp()
|
|
1973
|
+
const sftp = await b.pool.sftp()
|
|
1728
1974
|
const st = await sftp.stat(p).catch(() => null)
|
|
1729
1975
|
if (!st) return { ok: false, text: `not found: ${p}` }
|
|
1730
1976
|
if (st.isDirectory && st.isDirectory()) {
|
|
1731
1977
|
if (!args.recursive) throw new Error(`rw_remove: ${p} is a directory — pass recursive=true to delete its tree`)
|
|
1732
1978
|
const removed = await removeRemoteTree(sftp, p)
|
|
1733
|
-
audit('remove', `remove -r ${p}`, 0)
|
|
1979
|
+
audit('remove', `remove -r ${p}`, 0, b)
|
|
1734
1980
|
return { ok: true, text: `removed ${removed} entries under ${p}` }
|
|
1735
1981
|
}
|
|
1736
1982
|
await sftp.unlink(p)
|
|
1737
|
-
audit('remove', `remove ${p}`, 0)
|
|
1983
|
+
audit('remove', `remove ${p}`, 0, b)
|
|
1738
1984
|
return { ok: true, text: `removed ${p}` }
|
|
1739
1985
|
},
|
|
1740
1986
|
}),
|
|
@@ -1748,14 +1994,15 @@ export async function apply(ctx, config) {
|
|
|
1748
1994
|
dest: { type: 'string', required: true, description: 'Destination absolute remote path' },
|
|
1749
1995
|
},
|
|
1750
1996
|
output: textOut,
|
|
1751
|
-
async execute(args) {
|
|
1997
|
+
async execute(args, exec) {
|
|
1998
|
+
const b = requireMachine(exec, 'rw_move')
|
|
1752
1999
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1753
2000
|
const d = normalizeRemotePath(String(args.dest || ''))
|
|
1754
2001
|
if (!p || !d || p === '/') throw new Error('rw_move: both path and dest are required')
|
|
1755
|
-
const sftp = await pool.sftp()
|
|
2002
|
+
const sftp = await b.pool.sftp()
|
|
1756
2003
|
await mkdirRemoteDirs(sftp, remoteDirname(d))
|
|
1757
2004
|
await sftp.rename(p, d)
|
|
1758
|
-
audit('move', `move ${p} → ${d}`, 0)
|
|
2005
|
+
audit('move', `move ${p} → ${d}`, 0, b)
|
|
1759
2006
|
return { text: `moved ${p} → ${d}` }
|
|
1760
2007
|
},
|
|
1761
2008
|
}),
|
|
@@ -1771,14 +2018,20 @@ export async function apply(ctx, config) {
|
|
|
1771
2018
|
env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
|
|
1772
2019
|
},
|
|
1773
2020
|
output: textOut,
|
|
1774
|
-
async execute(args) {
|
|
2021
|
+
async execute(args, exec) {
|
|
2022
|
+
const b = requireBinding(exec, 'rw_exec')
|
|
1775
2023
|
const cmd = String(args.command || '')
|
|
1776
2024
|
if (!cmd) throw new Error('rw_exec: command is required')
|
|
1777
|
-
const ws =
|
|
2025
|
+
const ws = b.ws
|
|
1778
2026
|
const cwd = args.cwd ? normalizeRemotePath(String(args.cwd)) : (ws || '')
|
|
1779
2027
|
let full = cmd
|
|
1780
2028
|
if (cwd) {
|
|
1781
|
-
|
|
2029
|
+
// Detect before choosing the cwd form: a per-machine pool starts
|
|
2030
|
+
// undetected, and reading `platform` first would skip the Git Bash
|
|
2031
|
+
// mount-path rewrite on the first command sent to a Windows host.
|
|
2032
|
+
// detect() is cached and idempotent, so this costs one probe per pool.
|
|
2033
|
+
try { await b.pool.detect() } catch { /* probe failed → fall through to the POSIX form */ }
|
|
2034
|
+
if (b.pool.platform === 'windows' && b.pool.gitBashPath) {
|
|
1782
2035
|
// Git Bash terminal: cd in the /c/Users/… mount form
|
|
1783
2036
|
full = `cd ${shq(toShellPath(cwd))} && ${cmd}`
|
|
1784
2037
|
} else if (!cwd.includes('\\')) {
|
|
@@ -1786,8 +2039,8 @@ export async function apply(ctx, config) {
|
|
|
1786
2039
|
}
|
|
1787
2040
|
}
|
|
1788
2041
|
try {
|
|
1789
|
-
const res = await pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
|
|
1790
|
-
audit('exec', cmd, res.code)
|
|
2042
|
+
const res = await b.pool.exec(full, { timeoutMs: config.commandTimeoutMs, pty: !!args.pty, env: args.env })
|
|
2043
|
+
audit('exec', cmd, res.code, b)
|
|
1791
2044
|
const parts = []
|
|
1792
2045
|
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
1793
2046
|
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
@@ -1797,7 +2050,7 @@ export async function apply(ctx, config) {
|
|
|
1797
2050
|
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
1798
2051
|
return { text }
|
|
1799
2052
|
} catch (err) {
|
|
1800
|
-
throw new Error(friendlyMessage(err, { host:
|
|
2053
|
+
throw new Error(friendlyMessage(err, { host: b.host, port: b.port }))
|
|
1801
2054
|
}
|
|
1802
2055
|
},
|
|
1803
2056
|
}),
|
|
@@ -1815,10 +2068,11 @@ export async function apply(ctx, config) {
|
|
|
1815
2068
|
maxMatches: { type: 'integer', description: 'Max matches to return (default 500)' },
|
|
1816
2069
|
},
|
|
1817
2070
|
output: textOut,
|
|
1818
|
-
async execute(args) {
|
|
2071
|
+
async execute(args, exec) {
|
|
2072
|
+
const b = requireBinding(exec, 'rw_search')
|
|
1819
2073
|
const pattern = String(args.pattern || '')
|
|
1820
2074
|
if (!pattern) throw new Error('rw_search: pattern is required')
|
|
1821
|
-
const ws =
|
|
2075
|
+
const ws = b.ws
|
|
1822
2076
|
const dir = args.path ? normalizeRemotePath(String(args.path)) : (ws || '')
|
|
1823
2077
|
if (!dir) throw new Error('rw_search: no path and no remote workspace set')
|
|
1824
2078
|
let regex
|
|
@@ -1827,7 +2081,7 @@ export async function apply(ctx, config) {
|
|
|
1827
2081
|
} catch (err) {
|
|
1828
2082
|
throw new Error('rw_search: bad pattern: ' + ((err && err.message) || err))
|
|
1829
2083
|
}
|
|
1830
|
-
const sftp = await pool.sftp()
|
|
2084
|
+
const sftp = await b.pool.sftp()
|
|
1831
2085
|
const maxMatches = Math.min(Math.max(Number(args.maxMatches) || 500, 1), 2000)
|
|
1832
2086
|
const matcher = ignoreMatcher()
|
|
1833
2087
|
const { matches, scanned, truncated } = await searchTree(sftp, dir, {
|
|
@@ -1854,17 +2108,18 @@ export async function apply(ctx, config) {
|
|
|
1854
2108
|
localPath: { type: 'string', description: 'Local destination (default: the workspace mirror, preserving the relative path)' },
|
|
1855
2109
|
},
|
|
1856
2110
|
output: okOut,
|
|
1857
|
-
async execute(args) {
|
|
2111
|
+
async execute(args, exec) {
|
|
2112
|
+
const b = requireMachine(exec, 'rw_download')
|
|
1858
2113
|
const p = normalizeRemotePath(String(args.path || ''))
|
|
1859
2114
|
if (!p || p === '/') throw new Error('rw_download: a remote file path is required')
|
|
1860
|
-
const sftp = await pool.sftp()
|
|
2115
|
+
const sftp = await b.pool.sftp()
|
|
1861
2116
|
let local
|
|
1862
2117
|
if (args.localPath) {
|
|
1863
2118
|
local = path.resolve(String(args.localPath))
|
|
1864
2119
|
} else {
|
|
1865
|
-
const ws =
|
|
2120
|
+
const ws = b.ws
|
|
1866
2121
|
if (!ws) throw new Error('rw_download: no remote workspace set — pass localPath explicitly')
|
|
1867
|
-
const base = mirrorDirFor(ws,
|
|
2122
|
+
const base = mirrorDirFor(ws, b.host, b.username, b.port)
|
|
1868
2123
|
const rel = p.startsWith(ws) ? p.slice(ws.length).replace(/^\/+/, '') : p.slice(1)
|
|
1869
2124
|
local = path.join(base, rel)
|
|
1870
2125
|
}
|
|
@@ -1888,16 +2143,17 @@ export async function apply(ctx, config) {
|
|
|
1888
2143
|
path: { type: 'string', required: true, description: 'Absolute remote destination path' },
|
|
1889
2144
|
},
|
|
1890
2145
|
output: okOut,
|
|
1891
|
-
async execute(args) {
|
|
2146
|
+
async execute(args, exec) {
|
|
2147
|
+
const b = requireMachine(exec, 'rw_upload')
|
|
1892
2148
|
const rp = normalizeRemotePath(String(args.path || ''))
|
|
1893
2149
|
const lp = String(args.localPath || '')
|
|
1894
2150
|
if (!rp || rp === '/' || !lp) throw new Error('rw_upload: both localPath and a remote path are required')
|
|
1895
2151
|
if (!existsSync(lp)) throw new Error(`rw_upload: local file not found: ${lp}`)
|
|
1896
|
-
const sftp = await pool.sftp()
|
|
2152
|
+
const sftp = await b.pool.sftp()
|
|
1897
2153
|
await mkdirRemoteDirs(sftp, remoteDirname(rp))
|
|
1898
2154
|
const st = statSync(lp)
|
|
1899
2155
|
await sftp.fastPut(lp, rp)
|
|
1900
|
-
audit('upload', `upload ${lp} → ${rp}`, 0)
|
|
2156
|
+
audit('upload', `upload ${lp} → ${rp}`, 0, b)
|
|
1901
2157
|
return { ok: true, bytes: st.size, text: `uploaded ${st.size} bytes from ${lp} → ${rp}` }
|
|
1902
2158
|
},
|
|
1903
2159
|
}),
|
|
@@ -1944,12 +2200,17 @@ export async function apply(ctx, config) {
|
|
|
1944
2200
|
defineTool({
|
|
1945
2201
|
name: 'rw_disconnect',
|
|
1946
2202
|
description:
|
|
1947
|
-
|
|
2203
|
+
"Close this session's SSH connection to its remote host, releasing the persistent pool. Useful to rotate a connection or after a long idle. Sessions bound to other machines keep their own connections.",
|
|
1948
2204
|
parameters: {},
|
|
1949
2205
|
output: okOut,
|
|
1950
|
-
async execute() {
|
|
1951
|
-
|
|
1952
|
-
|
|
2206
|
+
async execute(_args, exec) {
|
|
2207
|
+
// Close the connection THIS session actually uses: closing the
|
|
2208
|
+
// active-machine pool instead would look like a no-op to a bound
|
|
2209
|
+
// session while disconnecting whichever machine happened to be active.
|
|
2210
|
+
const b = bindingFor(exec)
|
|
2211
|
+
b.pool.close()
|
|
2212
|
+
if (!b.bound) return { ok: true, text: 'disconnected the active machine (forwards stopped)' }
|
|
2213
|
+
return { ok: true, text: `disconnected ${b.username}@${b.host}:${b.port} for this session` }
|
|
1953
2214
|
},
|
|
1954
2215
|
}),
|
|
1955
2216
|
]
|
|
@@ -1974,14 +2235,21 @@ export async function apply(ctx, config) {
|
|
|
1974
2235
|
const session = agent && agent.session
|
|
1975
2236
|
const cwd = session && session.header && session.header.cwd
|
|
1976
2237
|
if (!cwd) return '' // no session cwd → nothing to map → stay quiet
|
|
1977
|
-
const { remotePath } = resolveMirrorForLocal(cwd)
|
|
2238
|
+
const { remotePath, machine } = resolveMirrorForLocal(cwd)
|
|
1978
2239
|
if (!remotePath) return '' // this session is NOT a remote session (issue #13)
|
|
2240
|
+
// Name the machine the MIRROR records, not the active one: pairing this
|
|
2241
|
+
// session's remote path with whichever machine happens to be active
|
|
2242
|
+
// states a host/path combination that may not exist (issue #25).
|
|
2243
|
+
const who = machine
|
|
2244
|
+
? `${machine.username || 'user'}@${machine.host}`
|
|
2245
|
+
: `${config.username || 'user'}@${config.host}`
|
|
2246
|
+
const platform = machine ? poolForMachine(machine).platform : pool.platform
|
|
1979
2247
|
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
1980
2248
|
let extra = ''
|
|
1981
2249
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
1982
2250
|
return (
|
|
1983
2251
|
'## Remote workspace\n' +
|
|
1984
|
-
`Current remote workspace: ${
|
|
2252
|
+
`Current remote workspace: ${who}:${toDisplayPath(remotePath, platform)}\n` +
|
|
1985
2253
|
'Use the rw_* tools (rw_list_dir / rw_read_file / rw_write_file / rw_edit / rw_exec / rw_search / rw_sync / rw_push) to inspect and act on files on the remote host. Treat this directory as the working root for this task.' +
|
|
1986
2254
|
extra
|
|
1987
2255
|
)
|