dsh-remote 0.8.51 → 0.8.53
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 +11 -0
- package/README.md +43 -0
- package/lib/client.js +15 -3
- package/lib/exec-job.js +221 -0
- package/lib/index.js +349 -18
- package/lib/instructions-dynamic.js +336 -0
- package/lib/instructions.js +288 -0
- package/lib/openssh.js +10 -1
- package/lib/pool.js +37 -1
- package/lib/remote-fs.js +26 -1
- package/lib/sftp-surface.js +46 -3
- package/lib/sshconfig.js +13 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -38,6 +38,9 @@ import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshC
|
|
|
38
38
|
import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
|
|
39
39
|
import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
|
|
40
40
|
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
41
|
+
import { createInstructionIndex, formatInstructionIndex } from './instructions.js'
|
|
42
|
+
import { resolveExecTimeout, formatExecResult, startExecJob, execWithOptionalPromotion } from './exec-job.js'
|
|
43
|
+
import { createInjectionTracker, refreshTouched, buildInstructionMessage } from './instructions-dynamic.js'
|
|
41
44
|
import { searchRemote } from './search.js'
|
|
42
45
|
import { installTerminalShell } from './remote-shell.js'
|
|
43
46
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
@@ -1006,6 +1009,26 @@ export async function apply(ctx, config) {
|
|
|
1006
1009
|
appendFileSync(auditFile(), line, 'utf8')
|
|
1007
1010
|
} catch {}
|
|
1008
1011
|
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The core background-job registry, or null when this composition has none.
|
|
1015
|
+
*
|
|
1016
|
+
* Issue #70: `ctx.jobs` is an OPTIONAL service — a minimal embedding, a test
|
|
1017
|
+
* double, or a plugin-only harness may mount no registry at all. Resolving it
|
|
1018
|
+
* lazily (rather than as an injected dependency) keeps the plugin loadable
|
|
1019
|
+
* there and degrades `rw_exec` to foreground-only, which is exactly the
|
|
1020
|
+
* pre-#70 behaviour instead of a hard failure at load time.
|
|
1021
|
+
*
|
|
1022
|
+
* @returns {object|null} the registry, or null.
|
|
1023
|
+
*/
|
|
1024
|
+
const jobRegistry = () => {
|
|
1025
|
+
try {
|
|
1026
|
+
const jobs = ctx.get('jobs')
|
|
1027
|
+
return jobs && typeof jobs.start === 'function' ? jobs : null
|
|
1028
|
+
} catch {
|
|
1029
|
+
return null
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1009
1032
|
const readAudit = (limit) => {
|
|
1010
1033
|
try {
|
|
1011
1034
|
const text = readFileSync(auditFile(), 'utf8')
|
|
@@ -1124,6 +1147,43 @@ export async function apply(ctx, config) {
|
|
|
1124
1147
|
autoPushWatchers.clear()
|
|
1125
1148
|
}, 'dsh-remote.autopush')
|
|
1126
1149
|
|
|
1150
|
+
// ── remote instruction-file index (issue #68) ────────────────────────────
|
|
1151
|
+
// The core AGENTS.md loader only sees the LOCAL mirror, so a remote repo's
|
|
1152
|
+
// instruction files never reach the model's context. This index shallow-
|
|
1153
|
+
// scans a bound workspace (root + ≤2 levels, AGENTS.md / CLAUDE.md only),
|
|
1154
|
+
// mirrors the finds into the local mirror (where the core loader looks),
|
|
1155
|
+
// and lists them in the system-prompt section below. See lib/instructions.js.
|
|
1156
|
+
const instructionIndex = createInstructionIndex()
|
|
1157
|
+
|
|
1158
|
+
// Issue #71: per-session record of which remote instruction files were
|
|
1159
|
+
// already delivered, plus the sweep throttle. See the tools/result hook below.
|
|
1160
|
+
const instructionTracker = createInjectionTracker()
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* The core's user-message factory, when this composition exposes one.
|
|
1164
|
+
*
|
|
1165
|
+
* Issue #71's design leans on the message being a NORMAL user message with a
|
|
1166
|
+
* plugin-owned `source.kind` (consumers fall through unknown kinds; the
|
|
1167
|
+
* core's reconcile only claims `agent-instructions`). The factory lives in
|
|
1168
|
+
* `@deepseek-ai/dsh-llm`, which is not a dependency of this plugin — so it is
|
|
1169
|
+
* resolved opportunistically and a minimal object of the same shape is used
|
|
1170
|
+
* when it is unavailable. The two are interchangeable for every consumer
|
|
1171
|
+
* that matters here (the LLM request builder reads role + content).
|
|
1172
|
+
*
|
|
1173
|
+
* @returns {Function|null} the factory, or null to use the built-in shape.
|
|
1174
|
+
*/
|
|
1175
|
+
const userMessageFactory = () => {
|
|
1176
|
+
try {
|
|
1177
|
+
const llm = ctx.get('llm')
|
|
1178
|
+
const make = llm && typeof llm.createUserMessage === 'function'
|
|
1179
|
+
? llm.createUserMessage
|
|
1180
|
+
: (llm && llm.default && typeof llm.default.createUserMessage === 'function' ? llm.default.createUserMessage : null)
|
|
1181
|
+
return make ? (input) => make.call(llm, input) : null
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1127
1187
|
// Helpers shared by session-bound tools and the machine-scoped web routes take
|
|
1128
1188
|
// the pool explicitly (`p`), defaulting to the active-machine pool so route
|
|
1129
1189
|
// callers — which are machine-scoped by design — stay unchanged.
|
|
@@ -1225,6 +1285,106 @@ export async function apply(ctx, config) {
|
|
|
1225
1285
|
}
|
|
1226
1286
|
return out
|
|
1227
1287
|
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Rebind every mirror recorded for one machine identity to a NEW identity
|
|
1291
|
+
* (issue #67): when a machine's IP/user/port changes, its workspaces must
|
|
1292
|
+
* follow instead of being recreated. The mirror's `.dsh-remote-meta.json` is
|
|
1293
|
+
* the authoritative binding — session routing (bindingFor), pool keying
|
|
1294
|
+
* (poolForMachine) and the sidebar badges all read identity FROM THE META,
|
|
1295
|
+
* not from the directory name — so rewriting the meta re-points every live
|
|
1296
|
+
* session at the new address while the local mirror content stays put.
|
|
1297
|
+
*
|
|
1298
|
+
* The host directory itself is deliberately NOT renamed: live sessions hold
|
|
1299
|
+
* cwd paths inside it, and a rename would dangle them into "session is
|
|
1300
|
+
* LOCAL" refusals (on Windows the rename usually fails outright while a
|
|
1301
|
+
* shell sits inside). The directory name is a display label only; a stale
|
|
1302
|
+
* label after a rebind is the documented cost of keeping sessions alive.
|
|
1303
|
+
*
|
|
1304
|
+
* Matching covers both machine kinds: an ALIAS machine's mirrors are found
|
|
1305
|
+
* through the recorded `alias` (their meta host is the resolved address at
|
|
1306
|
+
* creation time), a literal machine's through host+port+username.
|
|
1307
|
+
*
|
|
1308
|
+
* @param {object} prev - the machine record BEFORE the identity change.
|
|
1309
|
+
* @param {object} next - the machine record AFTER it.
|
|
1310
|
+
* @returns {number} how many mirrors were rebound.
|
|
1311
|
+
*/
|
|
1312
|
+
const rebindMirrors = (prev, next) => {
|
|
1313
|
+
if (!prev || !next) return 0
|
|
1314
|
+
const prevAlias = aliasOf(prev)
|
|
1315
|
+
const prevId = identityOf(prev)
|
|
1316
|
+
const prevRaw = { host: String(prev.host || ''), port: Number(prev.port) || 22, username: String(prev.username || '') }
|
|
1317
|
+
const nextAlias = aliasOf(next)
|
|
1318
|
+
const nextEff = withSshConfig(next) || next
|
|
1319
|
+
const nextId = {
|
|
1320
|
+
host: String(nextEff.host || next.host || ''),
|
|
1321
|
+
port: Number(nextEff.port || next.port) || 22,
|
|
1322
|
+
username: String(nextEff.username || next.username || ''),
|
|
1323
|
+
}
|
|
1324
|
+
const nextRaw = { host: String(next.host || ''), port: Number(next.port) || 22, username: String(next.username || '') }
|
|
1325
|
+
const sameIdentity = (a, b) => a.host === b.host && a.port === b.port && a.username === b.username
|
|
1326
|
+
if (!prevAlias && sameIdentity(prevId, nextId) && sameIdentity(prevRaw, nextRaw)) return 0
|
|
1327
|
+
if (prevAlias && prevAlias === nextAlias) return 0
|
|
1328
|
+
const root = remoteWorkspacesRoot()
|
|
1329
|
+
let rebound = 0
|
|
1330
|
+
let hostDirs = []
|
|
1331
|
+
try { hostDirs = readdirSync(root) } catch { return 0 }
|
|
1332
|
+
for (const hostDir of hostDirs) {
|
|
1333
|
+
const hostPath = path.join(root, hostDir)
|
|
1334
|
+
if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
|
|
1335
|
+
let entries = []
|
|
1336
|
+
try { entries = readdirSync(hostPath) } catch { continue }
|
|
1337
|
+
for (const entry of entries) {
|
|
1338
|
+
const metaPath = path.join(hostPath, entry, '.dsh-remote-meta.json')
|
|
1339
|
+
let meta = null
|
|
1340
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')) } catch { continue }
|
|
1341
|
+
if (!meta || !meta.host) continue
|
|
1342
|
+
const byAlias = prevAlias && String(meta.alias || '') === prevAlias
|
|
1343
|
+
// A meta records the RESOLVED identity at pick time, but that identity
|
|
1344
|
+
// can also have been written raw — match on either form of the old
|
|
1345
|
+
// identity so a rebind still finds mirrors recorded before a later
|
|
1346
|
+
// ssh-config resolution rewrote what "this machine" meant.
|
|
1347
|
+
const metaId = { host: String(meta.host), port: Number(meta.port) || 22, username: String(meta.username || '') }
|
|
1348
|
+
const byIdentity = sameIdentity(metaId, prevId) || sameIdentity(metaId, prevRaw)
|
|
1349
|
+
if (!byAlias && !byIdentity) continue
|
|
1350
|
+
const updated = { ...meta }
|
|
1351
|
+
if (nextAlias) {
|
|
1352
|
+
// Alias machine: the recorded alias is what future resolution keys
|
|
1353
|
+
// on; the resolved address refreshes only when it actually resolved
|
|
1354
|
+
// (an unmatched alias keeps the old literal values rather than
|
|
1355
|
+
// stamping the alias NAME into the address fields).
|
|
1356
|
+
updated.alias = nextAlias
|
|
1357
|
+
if (nextEff.sshConfigMatched || nextEff.sshAlias) {
|
|
1358
|
+
updated.host = nextId.host
|
|
1359
|
+
updated.port = nextId.port
|
|
1360
|
+
updated.username = nextId.username
|
|
1361
|
+
}
|
|
1362
|
+
} else {
|
|
1363
|
+
delete updated.alias
|
|
1364
|
+
updated.host = nextRaw.host
|
|
1365
|
+
updated.port = nextRaw.port
|
|
1366
|
+
updated.username = nextRaw.username
|
|
1367
|
+
}
|
|
1368
|
+
updated.reboundAt = new Date().toISOString()
|
|
1369
|
+
try {
|
|
1370
|
+
writeFileSync(metaPath, JSON.stringify(updated, null, 2))
|
|
1371
|
+
rebound++
|
|
1372
|
+
} catch { /* leave that mirror as-is */ }
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
if (rebound) {
|
|
1376
|
+
// The old identity's pooled connection dials an address the user just
|
|
1377
|
+
// abandoned: drop it so nothing keeps a dead SSH session warm. The new
|
|
1378
|
+
// identity gets its pool lazily on the next tool call.
|
|
1379
|
+
const stale = machinePools.get(poolKey(prevId))
|
|
1380
|
+
if (stale) {
|
|
1381
|
+
machinePools.delete(poolKey(prevId))
|
|
1382
|
+
try { stale.close() } catch { /* already gone */ }
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return rebound
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1228
1388
|
if (config.autoPush && wsPath()) {
|
|
1229
1389
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
1230
1390
|
}
|
|
@@ -1614,10 +1774,10 @@ export async function apply(ctx, config) {
|
|
|
1614
1774
|
defineTool({
|
|
1615
1775
|
name: 'rw_connect',
|
|
1616
1776
|
description:
|
|
1617
|
-
'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
|
|
1777
|
+
'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down. Rebinding (issue #67): pass machineId TOGETHER with a new host/username/port to update that saved machine in place — every workspace mirror bound to its old address is re-pointed (local content kept, no re-sync) instead of forcing new workspaces.',
|
|
1618
1778
|
parameters: {
|
|
1619
1779
|
host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
|
|
1620
|
-
machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
|
|
1780
|
+
machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed — EXCEPT for rebinding (issue #67): combine machineId with a new host/username/port to update the machine in place and re-point its existing workspace mirrors.' },
|
|
1621
1781
|
username: { type: 'string', description: 'SSH user (default from config or root)' },
|
|
1622
1782
|
port: { type: 'integer', description: 'SSH port (default 22)' },
|
|
1623
1783
|
password: { type: 'string', description: 'SSH password (prefer SSH key when possible)' },
|
|
@@ -1634,6 +1794,9 @@ export async function apply(ctx, config) {
|
|
|
1634
1794
|
},
|
|
1635
1795
|
output: textOut,
|
|
1636
1796
|
async execute(args) {
|
|
1797
|
+
// Issue #67: set by the machineId+overrides path, prepended to the
|
|
1798
|
+
// success line so the rebind is visible in the tool result.
|
|
1799
|
+
let reboundNote = ''
|
|
1637
1800
|
const mid = String(args.machineId || '').trim()
|
|
1638
1801
|
if (mid) {
|
|
1639
1802
|
// Connect with an ALREADY SAVED machine (issue #48): the registry
|
|
@@ -1641,6 +1804,25 @@ export async function apply(ctx, config) {
|
|
|
1641
1804
|
// keychain-backed passwords, stored passphrases, proxies, aliases.
|
|
1642
1805
|
const i = machineIndex(mid)
|
|
1643
1806
|
if (i < 0) throw new Error(`rw_connect: no saved machine "${mid}" — list saved machines with rw_machines`)
|
|
1807
|
+
// Issue #67: passing host/username/port ALONGSIDE a machineId rebinds
|
|
1808
|
+
// the saved machine in place — the fix for "the IP changed, do not
|
|
1809
|
+
// make me recreate every workspace". The record keeps its id (and
|
|
1810
|
+
// every credential), and each mirror bound to the old identity is
|
|
1811
|
+
// re-pointed so live sessions keep working against the new address.
|
|
1812
|
+
const prev = machines[i]
|
|
1813
|
+
const overrides = {}
|
|
1814
|
+
if (args.host !== undefined && String(args.host).trim()) overrides.host = String(args.host).trim()
|
|
1815
|
+
if (args.username !== undefined && String(args.username).trim()) overrides.username = String(args.username).trim()
|
|
1816
|
+
if (args.port !== undefined && Number(args.port)) overrides.port = Number(args.port)
|
|
1817
|
+
let rebound = 0
|
|
1818
|
+
if (Object.keys(overrides).length) {
|
|
1819
|
+
const next = { ...prev, ...overrides }
|
|
1820
|
+
try { rebound = rebindMirrors(prev, next) } catch { /* best effort */ }
|
|
1821
|
+
machines[i] = next
|
|
1822
|
+
}
|
|
1823
|
+
if (rebound) {
|
|
1824
|
+
reboundNote = `Rebound ${rebound} workspace mirror(s) from ${prev.username}@${prev.host}:${prev.port} to the new address (local content kept — no re-sync needed).\n`
|
|
1825
|
+
}
|
|
1644
1826
|
store.currentId = machines[i].id
|
|
1645
1827
|
ephemeral = null
|
|
1646
1828
|
saveMachines(machines, store.currentId)
|
|
@@ -1749,7 +1931,7 @@ export async function apply(ctx, config) {
|
|
|
1749
1931
|
const via = active && active.sshAlias
|
|
1750
1932
|
? `\n(ssh-config alias "${active.sshAlias}" → ${effUser}@${effHost}:${effPort}` + (active.sshConfigWarnings && active.sshConfigWarnings.length ? `; ${active.sshConfigWarnings.join(' ')}` : '') + ')'
|
|
1751
1933
|
: ''
|
|
1752
|
-
return { text:
|
|
1934
|
+
return { text: `${reboundNote}Connected to ${effHost} as ${effUser}.${via}\n\npick a workspace with rw_pick_workspace (path=<abs>).` }
|
|
1753
1935
|
} catch (err) {
|
|
1754
1936
|
throw new Error(friendlyMessage(err, { host: effHost, port: effPort }))
|
|
1755
1937
|
}
|
|
@@ -1825,6 +2007,12 @@ export async function apply(ctx, config) {
|
|
|
1825
2007
|
if (!b.bound) persistWorkspace(p)
|
|
1826
2008
|
const local = ensureMirror(p, b.host, b.username, b.port, { alias: b.alias || "" })
|
|
1827
2009
|
startAutoPush(local)
|
|
2010
|
+
// Issue #68: discover the workspace's AGENTS.md / CLAUDE.md NOW — the
|
|
2011
|
+
// first prompt assembly can come before any rw_sync, and the mirror
|
|
2012
|
+
// copy must already be in place for the core instruction loader to
|
|
2013
|
+
// pick it up. Awaited (the scan is a handful of shallow readdirs) but
|
|
2014
|
+
// fully best-effort: a scan failure never fails the pick.
|
|
2015
|
+
try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
|
|
1828
2016
|
// The closing hint depends on where THIS session stands (issue #63):
|
|
1829
2017
|
// in a LOCAL session "Run rw_sync" is a dead instruction — rw_sync
|
|
1830
2018
|
// refuses a local session — so say the mirror must be OPENED first.
|
|
@@ -1878,11 +2066,21 @@ export async function apply(ctx, config) {
|
|
|
1878
2066
|
}
|
|
1879
2067
|
return { text }
|
|
1880
2068
|
}
|
|
2069
|
+
// Issue #68: a real sync can pull in (or obsolete) instruction files —
|
|
2070
|
+
// refresh the prompt index afterwards. Kept inside the task body so a
|
|
2071
|
+
// background sync refreshes when IT finishes, not when it is queued.
|
|
2072
|
+
const runSyncAndIndex = async () => {
|
|
2073
|
+
const r = await runSync()
|
|
2074
|
+
if (!args.dryRun) {
|
|
2075
|
+
try { await instructionIndex.refresh({ pool: b.pool, ws: p, mirrorDir: local, force: true }) } catch { /* best effort */ }
|
|
2076
|
+
}
|
|
2077
|
+
return r
|
|
2078
|
+
}
|
|
1881
2079
|
if (args.async) {
|
|
1882
|
-
const t = tasks.start('sync', `sync ${p}`,
|
|
2080
|
+
const t = tasks.start('sync', `sync ${p}`, runSyncAndIndex)
|
|
1883
2081
|
return { text: `sync started in background: taskId=${t.id} (GET /dsh-remote/task?id=${t.id} for progress)` }
|
|
1884
2082
|
}
|
|
1885
|
-
return
|
|
2083
|
+
return runSyncAndIndex()
|
|
1886
2084
|
},
|
|
1887
2085
|
}),
|
|
1888
2086
|
|
|
@@ -2137,7 +2335,26 @@ export async function apply(ctx, config) {
|
|
|
2137
2335
|
if (st1.size !== st0.size || st1.mtime !== st0.mtime) {
|
|
2138
2336
|
throw new Error(`rw_edit: ${p} changed on the remote while editing (conflict) — re-read and retry`)
|
|
2139
2337
|
}
|
|
2140
|
-
|
|
2338
|
+
// Atomic write: stage to a temp file and rename over the target so a
|
|
2339
|
+
// mid-write failure cannot truncate the original (issue #65). Same
|
|
2340
|
+
// directory keeps the rename on one filesystem.
|
|
2341
|
+
const tmpPath = `${p}.dsh-remote-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
2342
|
+
try {
|
|
2343
|
+
await sftp.writeFile(tmpPath, encodeText(out, args.encoding || config.encoding))
|
|
2344
|
+
// renameOverwrite, not rename: the target EXISTS by definition here,
|
|
2345
|
+
// and plain SSH_FXP_RENAME refuses to replace it (issue #65).
|
|
2346
|
+
await sftp.renameOverwrite(tmpPath, p)
|
|
2347
|
+
// The rename swaps in a FRESH inode, so the original permission bits
|
|
2348
|
+
// (an executable script's +x, a 0600 secret) would silently reset to
|
|
2349
|
+
// the umask default. Restore them; a chmod failure must not fail the
|
|
2350
|
+
// edit itself — the content is already safely in place.
|
|
2351
|
+
if (st0 && typeof st0.mode === 'number') {
|
|
2352
|
+
try { await sftp.setstat(p, { mode: st0.mode & 0o7777 }) } catch { /* best effort */ }
|
|
2353
|
+
}
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
try { await sftp.unlink(tmpPath) } catch { /* best effort */ }
|
|
2356
|
+
throw err
|
|
2357
|
+
}
|
|
2141
2358
|
audit('edit', `edit ${p} (${n} occurrence(s))`, 0, b)
|
|
2142
2359
|
return { ok: true, bytes: Buffer.byteLength(out), text: `edited ${p}: replaced ${n} occurrence(s)` }
|
|
2143
2360
|
},
|
|
@@ -2240,12 +2457,15 @@ export async function apply(ctx, config) {
|
|
|
2240
2457
|
defineTool({
|
|
2241
2458
|
name: 'rw_exec',
|
|
2242
2459
|
description:
|
|
2243
|
-
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables.',
|
|
2460
|
+
'Run a shell command on the remote host. Use for anything that is not reading a file (build, test, grep, etc). Output is capped. Runs in the current remote workspace by default; pass cwd to run elsewhere. pty=true helps interactive commands (sudo prompts, REPLs); env sets environment variables. timeoutMs (1000-600000) overrides the machine-wide commandTimeoutMs for this call only. A command that outlives its timeout is MOVED TO A BACKGROUND JOB instead of being killed (pass promoteOnTimeout=false to kill it instead); run_in_background=true returns immediately with a job id. Background jobs are read with job_output and stopped with job_kill.',
|
|
2244
2461
|
parameters: {
|
|
2245
2462
|
command: { type: 'string', required: true, description: 'Shell command (run on the remote host)' },
|
|
2246
2463
|
cwd: { type: 'string', description: 'Working directory for the command (default: the current remote workspace)' },
|
|
2247
2464
|
pty: { type: 'boolean', description: 'Allocate a pseudo-terminal (interactive commands, default false)' },
|
|
2248
2465
|
env: { type: 'object', additionalProperties: true, description: 'Extra environment variables (string values)' },
|
|
2466
|
+
timeoutMs: { type: 'integer', description: 'Timeout for THIS call in milliseconds (clamped to 1000-600000; default: the commandTimeoutMs config)' },
|
|
2467
|
+
run_in_background: { type: 'boolean', description: 'Return a job id immediately and let the command run in the background (read it with job_output, stop it with job_kill)' },
|
|
2468
|
+
promoteOnTimeout: { type: 'boolean', description: 'When the command outlives its timeout, move it to a background job instead of killing it (default true; needs the core jobs service)' },
|
|
2249
2469
|
},
|
|
2250
2470
|
output: textOut,
|
|
2251
2471
|
async execute(args, exec) {
|
|
@@ -2268,18 +2488,40 @@ export async function apply(ctx, config) {
|
|
|
2268
2488
|
full = `cd ${shq(cwd)} && ${cmd}`
|
|
2269
2489
|
}
|
|
2270
2490
|
}
|
|
2491
|
+
// Issue #70: a per-call override, clamped, and the value we report is
|
|
2492
|
+
// the one we actually applied (never the config value behind an
|
|
2493
|
+
// override's back).
|
|
2494
|
+
const timeoutMs = resolveExecTimeout(args.timeoutMs, config.commandTimeoutMs)
|
|
2495
|
+
const registry = jobRegistry()
|
|
2496
|
+
const owner = exec && exec.agent ? exec.agent.id : undefined
|
|
2497
|
+
const promote = registry !== null && args.promoteOnTimeout !== false
|
|
2271
2498
|
try {
|
|
2272
|
-
|
|
2499
|
+
if (args.run_in_background) {
|
|
2500
|
+
if (!registry) {
|
|
2501
|
+
throw new Error('rw_exec: run_in_background needs the DSH jobs service, which this composition does not provide')
|
|
2502
|
+
}
|
|
2503
|
+
// A background job needs its OWN deadline, or nothing would ever
|
|
2504
|
+
// stop a runaway command. `timeoutMs` becomes that deadline here
|
|
2505
|
+
// (unlike the promote path, where it only bounds the caller's wait).
|
|
2506
|
+
const job = startExecJob(registry, { pool: b.pool, command: full, label: cmd, owner, jobDeadlineMs: timeoutMs, pty: args.pty, env: args.env })
|
|
2507
|
+
audit('exec-background', cmd, 0, b)
|
|
2508
|
+
return {
|
|
2509
|
+
text: `started in the background: jobId=${job.id}\n`
|
|
2510
|
+
+ `Read its output with job_output (job_id="${job.id}"); stop it with job_kill.\n`
|
|
2511
|
+
+ `(the job is killed if it outlives ${timeoutMs}ms)`,
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if (promote) {
|
|
2515
|
+
const r = await execWithOptionalPromotion({
|
|
2516
|
+
registry, pool: b.pool, command: full, label: cmd, owner, timeoutMs,
|
|
2517
|
+
pty: args.pty, env: args.env, signal: exec && exec.signal, formatResult: formatExecResult,
|
|
2518
|
+
})
|
|
2519
|
+
audit('exec', cmd, r.promoted ? 0 : 0, b)
|
|
2520
|
+
return { text: r.text }
|
|
2521
|
+
}
|
|
2522
|
+
const res = await b.pool.exec(full, { timeoutMs, pty: !!args.pty, env: args.env, signal: exec && exec.signal })
|
|
2273
2523
|
audit('exec', cmd, res.code, b)
|
|
2274
|
-
|
|
2275
|
-
if (res.stdout) parts.push(res.stdout.replace(/\s+$/, ''))
|
|
2276
|
-
if (res.stderr) parts.push('-- stderr --\n' + res.stderr.replace(/\s+$/, ''))
|
|
2277
|
-
if (!parts.length) parts.push('(no output)')
|
|
2278
|
-
let text = parts.join('\n')
|
|
2279
|
-
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${config.commandTimeoutMs}ms]`
|
|
2280
|
-
else if (res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
2281
|
-
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
2282
|
-
return { text }
|
|
2524
|
+
return { text: formatExecResult(res, { timeoutMs }) }
|
|
2283
2525
|
} catch (err) {
|
|
2284
2526
|
throw new Error(friendlyMessage(err, { host: b.host, port: b.port }))
|
|
2285
2527
|
}
|
|
@@ -2549,7 +2791,7 @@ export async function apply(ctx, config) {
|
|
|
2549
2791
|
const session = agent && agent.session
|
|
2550
2792
|
const cwd = session && session.header && session.header.cwd
|
|
2551
2793
|
if (!cwd) return '' // no session cwd → nothing to map → stay quiet
|
|
2552
|
-
const { remotePath, machine } = resolveMirrorForLocal(cwd)
|
|
2794
|
+
const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(cwd)
|
|
2553
2795
|
if (!remotePath) return '' // this session is NOT a remote session (issue #13)
|
|
2554
2796
|
// Name the machine the MIRROR records, not the active one: pairing this
|
|
2555
2797
|
// session's remote path with whichever machine happens to be active
|
|
@@ -2562,6 +2804,17 @@ export async function apply(ctx, config) {
|
|
|
2562
2804
|
const fwd = forwards.list().filter((f) => f.active).map((f) => `${f.direction}:127.0.0.1:${f.listenPort}→${f.targetHost}:${f.targetPort}`)
|
|
2563
2805
|
let extra = ''
|
|
2564
2806
|
if (fwd.length) extra = `\nActive port forwards: ${fwd.join(', ')}`
|
|
2807
|
+
// Issue #68: surface the workspace's AGENTS.md / CLAUDE.md. The scan is
|
|
2808
|
+
// lazy (fire-and-forget on first assembly; negative-cached on failure),
|
|
2809
|
+
// so the index appears from the NEXT assembly once known — rw_pick_workspace
|
|
2810
|
+
// scans eagerly, so a picked workspace normally has it from the start.
|
|
2811
|
+
if (machine && mirrorDir) {
|
|
2812
|
+
instructionIndex.note({ pool: poolForMachine(machine), ws: remotePath, mirrorDir })
|
|
2813
|
+
const instr = instructionIndex.get(mirrorDir)
|
|
2814
|
+
if (instr && instr.files && instr.files.length) {
|
|
2815
|
+
extra += `\n${formatInstructionIndex(instr.files, (p) => toDisplayPath(p, platform))}`
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2565
2818
|
// Issue #39: `@` mentions are resolved from the REMOTE tree by
|
|
2566
2819
|
// lib/file-reference.js, so the model must know they are remote-relative
|
|
2567
2820
|
// and that the built-in read tool sees only the local mirror.
|
|
@@ -2610,6 +2863,75 @@ export async function apply(ctx, config) {
|
|
|
2610
2863
|
})
|
|
2611
2864
|
}
|
|
2612
2865
|
|
|
2866
|
+
// ── dynamic remote instruction injection (issue #71) ──────────────────────
|
|
2867
|
+
// #68 delivered the STATIC half (shallow scan + mirror + prompt index). The
|
|
2868
|
+
// dynamic half the core does for local sessions was still missing, because
|
|
2869
|
+
// the core's incremental path is keyed on (a) the tool names read/write/edit
|
|
2870
|
+
// and (b) a `file_path` argument, and it expands scopes using LOCAL paths.
|
|
2871
|
+
// `rw_read_file` is named differently, takes `path`, and its argument is a
|
|
2872
|
+
// REMOTE absolute path — none of the three can ever line up.
|
|
2873
|
+
//
|
|
2874
|
+
// So the plugin does it itself, without touching core: after any successful
|
|
2875
|
+
// rw_* file touch, take the touched path's whole ancestor chain below the
|
|
2876
|
+
// workspace root, stat each level's AGENTS.md / CLAUDE.md, and hand the
|
|
2877
|
+
// current contents to the model as ONE user message carrying our OWN source
|
|
2878
|
+
// kind. Consumers fall through unknown kinds, and the core's reconcile only
|
|
2879
|
+
// claims its own `agent-instructions` kind — which is exactly why the two
|
|
2880
|
+
// injection paths cannot fight over the same inbox slot.
|
|
2881
|
+
//
|
|
2882
|
+
// Everything here is best-effort: the hook never rejects, never blocks the
|
|
2883
|
+
// tool result, and treats an offline machine as "nothing to inject".
|
|
2884
|
+
if (typeof ctx.on === 'function') {
|
|
2885
|
+
ctx.on('tools/result', async (result, hookContext) => {
|
|
2886
|
+
try {
|
|
2887
|
+
const agent = hookContext && (hookContext.agent || (result && result.agent))
|
|
2888
|
+
const name = String((hookContext && hookContext.name) || (result && result.name) || '')
|
|
2889
|
+
// Only the tools that actually reach a remote file. `rw_exec` is
|
|
2890
|
+
// deliberately excluded: a shell command's argument is not a path we
|
|
2891
|
+
// can trust, and scanning arbitrary strings would be a guess.
|
|
2892
|
+
const TOUCH_TOOLS = new Set(['rw_read_file', 'rw_write_file', 'rw_edit', 'rw_list_dir'])
|
|
2893
|
+
if (!TOUCH_TOOLS.has(name)) return
|
|
2894
|
+
if (!agent || !agent.session || !agent.inbox) return
|
|
2895
|
+
const args = (hookContext && hookContext.arguments) || (result && result.arguments) || {}
|
|
2896
|
+
const rawPath = String(args.path || args.file_path || '')
|
|
2897
|
+
if (!rawPath) return
|
|
2898
|
+
const cwd = agent.session.header && agent.session.header.cwd
|
|
2899
|
+
if (!cwd) return
|
|
2900
|
+
const { remotePath, machine, mirrorDir } = resolveMirrorForLocal(String(cwd))
|
|
2901
|
+
if (!remotePath || !machine) return // a LOCAL session: the core owns it
|
|
2902
|
+
const touched = normalizeRemotePath(rawPath)
|
|
2903
|
+
// Reading the workspace root itself adds no chain (the core's baseline
|
|
2904
|
+
// already covers `<root>/AGENTS.md`), so this is the "reading a file
|
|
2905
|
+
// directly in the root triggers no extra scan" boundary from the issue.
|
|
2906
|
+
const sessionId = agent.id || agent.session.id || 'unscoped'
|
|
2907
|
+
if (instructionTracker.throttled(sessionId)) return
|
|
2908
|
+
instructionTracker.swept(sessionId)
|
|
2909
|
+
const pool = poolForMachine(machine)
|
|
2910
|
+
// Never let a slow/offline machine hold the tool result: the sweep runs
|
|
2911
|
+
// against a short-lived SFTP surface and any failure is swallowed.
|
|
2912
|
+
const sftp = await pool.sftp()
|
|
2913
|
+
const { items, removed } = await refreshTouched({
|
|
2914
|
+
sftp, remoteRoot: remotePath, touched, mirrorDir, tracker: instructionTracker, sessionId,
|
|
2915
|
+
})
|
|
2916
|
+
if (!items.length && !removed.length) return
|
|
2917
|
+
const platform = pool.platform
|
|
2918
|
+
const msg = buildInstructionMessage(items, removed, {
|
|
2919
|
+
// Items carry an ABSOLUTE `remote` path; removed entries carry a
|
|
2920
|
+
// workspace-RELATIVE rel. Prefix only the latter — always joining
|
|
2921
|
+
// would double the root prefix (caught by the issue #71 live E2E).
|
|
2922
|
+
display: (p) => {
|
|
2923
|
+
const s = String(p)
|
|
2924
|
+
const abs = s.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(s)
|
|
2925
|
+
return toDisplayPath(abs ? s : joinRemotePath(remotePath, s), platform)
|
|
2926
|
+
},
|
|
2927
|
+
createUserMessage: userMessageFactory(),
|
|
2928
|
+
})
|
|
2929
|
+
if (!msg) return
|
|
2930
|
+
agent.inbox.prepend('next-step', msg)
|
|
2931
|
+
} catch { /* best effort — an injection failure must never break the tool */ }
|
|
2932
|
+
})
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2613
2935
|
// ── slash commands ─────────────────────────────────────────────────────────
|
|
2614
2936
|
const commands = ctx.get('commands')
|
|
2615
2937
|
if (commands !== undefined) {
|
|
@@ -3193,6 +3515,14 @@ export async function apply(ctx, config) {
|
|
|
3193
3515
|
// alone, so a fresh registry stays "no choice yet" (not an
|
|
3194
3516
|
// explicit none) and a previously-cleared registry stays cleared.
|
|
3195
3517
|
saveMachines(machines, store.currentId, false)
|
|
3518
|
+
// Issue #67: an EDITED connection identity (host/user/port) must
|
|
3519
|
+
// carry the machine's existing workspaces over — without this the
|
|
3520
|
+
// mirrors stayed bound to the old address and the user had to
|
|
3521
|
+
// create (and re-sync) a brand-new workspace for the new IP.
|
|
3522
|
+
let reboundMirrors = 0
|
|
3523
|
+
if (i >= 0 && prev) {
|
|
3524
|
+
try { reboundMirrors = rebindMirrors(prev, rec) } catch { /* best effort */ }
|
|
3525
|
+
}
|
|
3196
3526
|
// Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
|
|
3197
3527
|
// Saving a literal machine upserts a marker-tagged Host block,
|
|
3198
3528
|
// so the file and the registry can never drift; the record's
|
|
@@ -3234,6 +3564,7 @@ export async function apply(ctx, config) {
|
|
|
3234
3564
|
machine: sanitizeMachine(rec),
|
|
3235
3565
|
machines: machinesForClient(),
|
|
3236
3566
|
currentId: store.currentId,
|
|
3567
|
+
...(reboundMirrors ? { reboundMirrors } : {}),
|
|
3237
3568
|
...(warning ? { warning, warningDetail } : {}),
|
|
3238
3569
|
...(sshConfigWarning ? { sshConfigWarning } : {}),
|
|
3239
3570
|
})
|