dsh-remote 0.2.0: agent tools to drive slaves from the master

- remote_devices / remote_exec / remote_file / remote_task tools on the master
  (exec: bash -lc with timeout + output clipping; files: read/write/list;
  task: delegate a prompt to the device's own AI, wait for turn end, return
  its answer; optional permission preset, status/cancel)
- only the master may control devices (allowPeerControl to relay between slaves),
  agentTools.enabled=false opts a device out
- FORGE_CREDENTIALS opt-in pseudo-secret: ships the master's tea logins to a
  slave (tea config + git credential store + identity)
- /dsh-remote/debug/inspect + deploy/session-log.mjs for transcripts
- tests for exec/files/task/forge install
This commit is contained in:
lyuksovannyy committed 2026-10-11 10:08:04 +00:00
1 parent 8872e73e76
commit d1e134196d
7 files changed
+625 -8

No files matched your search

+191 -5
View File
@@ -32,12 +32,14 @@ import { Store, randomId, setProfileDir, slug } from './lib/store.mjs'
import { CookieJar } from './lib/auth-cookie.mjs'
import { ownHttp, ownUpgrade, viewHttp, viewUpgrade } from './lib/proxy.mjs'
import * as sync from './lib/sync.mjs'
import * as ops from './lib/agent-ops.mjs'
export const name = 'dsh-remote'
export const inject = {
webServer: { required: true },
connection: { required: true },
credentials: { required: false },
tools: { required: false },
}
/** Optional services, read at call time (they may load after this plugin). */
@@ -49,6 +51,10 @@ const MAX_BODY = 256 * 1024
const PREFIX = '/r/'
const LINK_PATH = '/dsh-remote/link'
const MUX_PATH = '/api/remote.mux'
/** Link methods that act on a device on behalf of an agent. */
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession'])
/** Pseudo-secret name: opting into it ships the master's forge (git) logins. */
const FORGE_SECRET = 'FORGE_CREDENTIALS'
function sendJson(res, status, payload) {
res.statusCode = status
@@ -153,12 +159,44 @@ export function apply(ctx, config = {}) {
return { name, value: r.value }
},
// Forwarded calls: master answers for another device by asking it.
forward: async (p) => {
forward: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const peer = links.get(p.device)
if (!peer) throw httpErr(404, `device ${p.device} offline`)
return peer.mux.call(p.method, p.params || {}, 20000)
// Agent control (exec/files/task) is master → device only, unless explicitly allowed.
if (OPS.has(p.method) && config.agentTools?.allowPeerControl !== true) throw httpErr(403, `${p.method} on other devices is only allowed from the master`)
return peer.mux.call(p.method, p.params || {}, p.timeoutMs || 20000)
},
// ---- agent operations (served by the target device) ----
exec: async (p, mux) => { guardOps(mux); return ops.execCommand(p) },
readFile: async (p, mux) => { guardOps(mux); return ops.readFileOp(p) },
writeFile: async (p, mux) => { guardOps(mux); return ops.writeFileOp(p) },
listDir: async (p, mux) => { guardOps(mux); return ops.listDirOp(p) },
task: async (p, mux) => { guardOps(mux); return ops.runTask(svc(ctx, 'sessionController'), svc(ctx, 'workspaceRegistry'), p, undefined, svc(ctx, 'permissionPresets')) },
taskStatus: async (p, mux) => { guardOps(mux); return ops.taskStatus(svc(ctx, 'sessionController'), p) },
taskCancel: async (p, mux) => { guardOps(mux); return svc(ctx, 'sessionController').cancel({ sessionId: p.sessionId }) },
inspectSession: async (p, mux) => {
guardOps(mux)
const ins = await svc(ctx, 'sessionController').inspect(p.sessionId)
return { sessionId: p.sessionId, events: ins.events.slice(-Math.min(Number(p.last) || 400, 2000)) }
},
// Master → slave: hand over forge logins (only names the slave accepted).
forgeLogins: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const dev = store.devices()[mux.deviceId]
if (!offerSecretsFor(dev).includes(FORGE_SECRET)) throw httpErr(403, `${FORGE_SECRET} not offered to this device`)
return { logins: ops.readForgeLogins() }
},
}
/**
* Agent ops arrive over a link. On a slave, only the master may call them; on the master,
* slaves may not (the master is the controller) unless allowPeerControl is set.
*/
function guardOps(mux) {
if (config.agentTools?.enabled === false) throw httpErr(403, `agent control is disabled on ${self.name}`)
if (role === 'slave' && mux?.deviceId !== 'master') throw httpErr(403, 'agent control only from the master')
if (role === 'master' && mux && config.agentTools?.allowPeerControl !== true) throw httpErr(403, 'slaves may not control the master')
}
function offerSecretsFor(dev) {
@@ -266,12 +304,23 @@ export function apply(ctx, config = {}) {
return { mux: masterLink.mux, meta: { to: device } } // relay through master
}
async function callDevice(device, method, params) {
async function callDevice(device, method, params, timeoutMs = 20000) {
const r = routeTo(device)
if (!r) throw httpErr(404, `device ${device} unreachable`)
if (r.local) return handlers[method](params || {})
if (r.meta.to) return r.mux.call('forward', { device, method, params }, 25000)
return r.mux.call(method, params || {}, 20000)
if (r.meta.to) return r.mux.call('forward', { device, method, params, timeoutMs }, timeoutMs + 5000)
return r.mux.call(method, params || {}, timeoutMs)
}
/** Resolve a device by id or (case-insensitive) name; "self"/"local" = this device. */
function resolveDevice(ref) {
const want = String(ref || '').trim().toLowerCase()
if (!want || want === 'self' || want === 'local' || want === 'this') return self.id
const all = devicesView()
const hit = all.find((d) => d.id === want) || all.find((d) => String(d.name).toLowerCase() === want) || all.find((d) => slug(d.name) === slug(want))
if (!hit) throw httpErr(404, `unknown device "${ref}". Known: ${all.map((d) => `${d.id}${d.online ? '' : ' (offline)'}`).join(', ')}`)
if (!hit.online) throw httpErr(503, `device ${hit.id} is offline`)
return hit.id
}
async function allSessions() {
@@ -483,6 +532,14 @@ export function apply(ctx, config = {}) {
for (const name of manifest.secrets || []) {
if (!(local.secrets || []).includes(name)) continue
try {
if (name === FORGE_SECRET) {
// Not a credential ref: the master's forge logins (tea + git https credentials).
const { logins } = await mux.call('forgeLogins', {}, 15000)
if (!logins?.length) throw new Error('master has no forge logins')
const r = await ops.installForgeLogins(logins)
result.secrets.push(`${name} (${r.installed.join(', ')})`)
continue
}
const { value } = await mux.call('secret', { name }, 15000)
const creds = svc(ctx, 'credentials')
if (!creds) throw new Error('credentials service unavailable')
@@ -627,6 +684,15 @@ export function apply(ctx, config = {}) {
return { role, lastSync, synced: store.data.synced || {} }
})
// Session events of a session on any device (same auth as the UI; used by tooling).
route('/dsh-remote/debug/inspect', ['GET'], async (_req, url) => {
const sessionId = url.searchParams.get('sessionId')
const device = url.searchParams.get('device') || self.id
if (!sessionId) throw httpErr(400, 'sessionId required')
if (device !== self.id) return callDevice(device, 'inspectSession', { sessionId }, 30000)
return handlers.inspectSession({ sessionId })
})
route('/dsh-remote/new-session', ['POST'], async (req) => {
const body = await readJson(req)
const device = String(body.device || self.id)
@@ -723,5 +789,125 @@ export function apply(ctx, config = {}) {
upgradeRoutes.clear()
}, 'dsh-remote: upgrade routes')
// ------------------------------------------------------------------ agent tools
// Registered only where ctx.tools exists (agent compositions). By default they are
// offered on the master; a slave can enable them too (agentTools.enabled: true), in which
// case it can only reach itself and, if the master allows peer control, other devices.
const tools = svc(ctx, 'tools')
const toolsOn = config.agentTools?.tools !== false && (role === 'master' || config.agentTools?.tools === true)
if (tools && typeof tools.register === 'function' && toolsOn) {
const T = (def, fn) => ({
name: def.name,
description: def.description,
parameters: { type: 'object', properties: def.properties, required: def.required ?? [], additionalProperties: false },
output: { schema: {}, render: (_a, v) => [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 1) }] },
timeoutMs: def.timeoutMs,
async execute(args, exec) {
try { return JSON.parse(JSON.stringify((await fn(args ?? {}, exec)) ?? null)) } catch (e) { return { error: String(e?.message ?? e) } }
},
})
const S = (type, description, extra = {}) => ({ type, description, ...extra })
const DEVICE = S('string', 'Device id or name from remote_devices (e.g. "ai-pc"); "self" = this machine.')
const CWD = S('string', 'Working directory on the device (absolute or ~/…; default: the device user\'s home).')
const toolDefs = [
T({
name: 'remote_devices',
description: 'List the DSH devices linked through dsh-remote (this master + paired slaves): id, name, online state, user, home, platform, and optionally their workspaces and recent sessions. Use the ids with remote_exec / remote_file / remote_task.',
properties: { sessions: S('boolean', 'Include each online device\'s workspaces and recent sessions (default false).') },
}, async (args) => {
const devices = devicesView().filter((d) => !d.revoked)
const out = []
for (const d of devices) {
const row = { id: d.id, name: d.name, role: d.role, online: !!d.online, self: !!d.self, platform: d.platform || (d.self ? `${process.platform}-${process.arch}` : undefined) }
if (d.online) {
try {
const info = await callDevice(d.id, 'exec', { command: 'printf "%s\\n%s\\n%s" "$(id -un)" "$HOME" "$(uname -sr)"', timeoutMs: 8000 }, 12000)
const [user, home, kernel] = String(info.stdout || '').split('\n')
Object.assign(row, { user, home, kernel })
} catch (e) { row.execError = e.message }
if (args.sessions) {
try {
const s = await callDevice(d.id, 'sessions', {}, 15000)
row.workspaces = s.workspaces
row.sessions = (s.items || []).slice(0, 15).map(({ sessionId, title, running, cwd, updatedAt }) => ({ sessionId, title, running, cwd, updatedAt: new Date(updatedAt).toISOString() }))
} catch (e) { row.sessionsError = e.message }
}
}
out.push(row)
}
return { devices: out }
}),
T({
name: 'remote_exec',
description: 'Run a bash command on another DSH device (e.g. a slave) and return exit code, stdout and stderr. Runs as that device\'s DSH user, in `bash -lc`, non-interactive (no TTY, GIT_TERMINAL_PROMPT=0). Use for quick deterministic steps: git clone/pull, builds, test runs, inspecting files. For long multi-step work prefer remote_task.',
properties: {
device: DEVICE,
command: S('string', 'The bash command line.'),
cwd: CWD,
timeoutMs: S('integer', 'Kill the command after this many ms (default 120000, max 1800000).'),
stdin: S('string', 'Optional data written to stdin.'),
},
required: ['device', 'command'],
timeoutMs: 31 * 60 * 1000,
}, async (args) => {
const device = resolveDevice(args.device)
const t = Math.min(Math.max(Number(args.timeoutMs) || 120000, 1000), 1800000)
const r = await callDevice(device, 'exec', { command: args.command, cwd: args.cwd, timeoutMs: t, stdin: args.stdin }, t + 15000)
return { device, ...r }
}),
T({
name: 'remote_file',
description: 'Files on another DSH device. action=read (path, offset, limit; binary comes back base64), write (path, content, encoding utf8|base64, append), list (directory entries).',
properties: {
device: DEVICE,
action: S('string', 'read | write | list', { enum: ['read', 'write', 'list'] }),
path: S('string', 'File or directory path (absolute or relative to cwd / home).'),
cwd: CWD,
content: S('string', 'write: file content.'),
encoding: S('string', 'write: utf8 (default) or base64.', { enum: ['utf8', 'base64'] }),
append: S('boolean', 'write: append instead of replace.'),
offset: S('integer', 'read: byte offset.'),
limit: S('integer', 'read: max bytes (default/max 4 MiB).'),
hidden: S('boolean', 'list: include dotfiles.'),
},
required: ['device', 'action', 'path'],
}, async (args) => {
const device = resolveDevice(args.device)
const method = { read: 'readFile', write: 'writeFile', list: 'listDir' }[args.action]
if (!method) throw httpErr(400, 'action must be read, write or list')
return { device, ...(await callDevice(device, method, args, 30000)) }
}),
T({
name: 'remote_task',
description: 'Delegate a task to the AI agent running on another DSH device, so the work executes there with that device\'s own tools and model, and get its final answer. action=run (default): create a session on the device (in cwd, attached to that workspace) or continue sessionId, send prompt, and wait up to timeoutMs for the turn to finish. action=status: collect the latest answer of sessionId. action=cancel: stop it. The session stays visible on that device (Devices panel / /r/<device>/). Write a complete, self-contained prompt — the remote agent has no access to this conversation.',
properties: {
device: DEVICE,
action: S('string', 'run (default) | status | cancel', { enum: ['run', 'status', 'cancel'] }),
prompt: S('string', 'run: the full task for the remote agent.'),
sessionId: S('string', 'Continue / poll / cancel this session on the device.'),
cwd: CWD,
title: S('string', 'run, new session: session title.'),
permission: S('string', 'run: permission preset for the remote session, e.g. "danger-full-access" when the job must run tools outside the workspace sandbox (test suites that spawn processes, git credential writes). Default: the device\'s default preset.'),
wait: S('boolean', 'run: wait for completion (default true). false = start and return the sessionId.'),
timeoutMs: S('integer', 'run: how long to wait (default 1200000 = 20 min, max 2 h). On timeout the task keeps running; poll with action=status.'),
},
required: ['device'],
timeoutMs: 2 * 60 * 60 * 1000 + 60000,
}, async (args, exec) => {
const device = resolveDevice(args.device)
const action = args.action || 'run'
if (action === 'status') return { device, ...(await callDevice(device, 'taskStatus', { sessionId: args.sessionId }, 30000)) }
if (action === 'cancel') return { device, ...(await callDevice(device, 'taskCancel', { sessionId: args.sessionId }, 15000)) }
const t = Math.min(Math.max(Number(args.timeoutMs) || 1200000, 10000), 2 * 60 * 60 * 1000)
const r = await callDevice(device, 'task', {
prompt: args.prompt, sessionId: args.sessionId, cwd: args.cwd, title: args.title, permission: args.permission,
wait: args.wait !== false, timeoutMs: t,
}, t + 30000)
return { device, ...r, url: device === self.id ? `#/session/${r.sessionId}` : `/r/${device}/#/session/${r.sessionId}` }
}),
]
ctx.effect(function* () { for (const t of toolDefs) yield tools.register(t) }, 'dsh-remote: agent tools')
}
log.info?.(`dsh-remote: ${role} "${self.name}" (${self.id}) ready`)
}