Files
dsh-remote/index.js
T
lyuksovannyy 78fce8e1ce dsh-remote 0.4.0: edge auth – slaves log in to an auth proxy in front of the master
- lib/edge-auth.mjs: basic / bearer / oauth2 client_credentials (Authentik M2M) /
  custom headers (Cloudflare Access), proxy session cookie jar, retry with fresh
  token, clear edge-refusal vs DSH-refusal errors
- link + enroll go through the edge; master.edgeAuth stored at pair time,
  secrets as DSH credential refs only; config edgeAuth override
- /dsh-remote/edge-auth: show/change/probe; Settings UI section + pair form
- pair.mjs / install-mac.sh: --edge … flags, secrets prompted via /dev/tty
- tests: fake Authentik-like proxy for all modes + WebSocket through the edge
2026-10-11 12:19:36 +00:00

1040 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @local/dsh-remote — Host side.
//
// Master/slave remote control for DSH (Claude-Desktop-remote style):
// * slaves keep one authenticated WebSocket link to the master (outbound, NAT-friendly);
// * every device's DSH is reachable from every other device at /r/<device>/ (HTTP +
// WebSocket tunnelled over the link; sessions execute on the owning machine);
// slave→slave goes through the master's relay;
// * the master aggregates device + session lists for the Devices UI;
// * plugins and config are synced master → slave (managed bundle layer, allow/deny,
// opt-in per-secret credential sync).
//
// HTTP routes (all behind DSH's own browser auth via ctx.connection.requestRejection):
// GET /dsh-remote/state → role, self, link status, devices (+ online)
// GET /dsh-remote/sessions → sessions of every reachable device
// POST /dsh-remote/pairing → master: create a one-time pairing code
// POST /dsh-remote/pair → slave: pair with {masterUrl, code}
// POST /dsh-remote/unpair → slave: forget the master
// POST /dsh-remote/devices/<id>/revoke → master: revoke a device key
// POST /dsh-remote/devices/<id>/remove → master: delete a device
// POST /dsh-remote/devices/<id>/sync → master: set {enabled, allow, deny, secrets}
// POST /dsh-remote/sync → slave: pull + apply now
// GET /dsh-remote/sync/status → slave: last sync result / master: manifest summary
// POST /dsh-remote/new-session → {device, cwd?} create a session on a device
// * /r/<device>/... → proxy to that device's DSH
// Upgrade routes:
// /dsh-remote/link → slave → master link (device id + key headers)
// /r/<device>/api/remote.mux → DSH Remote stream WebSocket of another device
import os from 'node:os'
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
import { Mux } from './lib/mux.mjs'
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'
import { createEdgeAuth } from './lib/edge-auth.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). */
function svc(ctx, key) {
try { return ctx.get(key) } catch { return undefined }
}
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
res.setHeader('content-type', 'application/json; charset=utf-8')
res.setHeader('cache-control', 'no-store')
res.end(JSON.stringify(payload))
}
async function readJson(req) {
const chunks = []
let size = 0
for await (const c of req) {
size += c.length
if (size > MAX_BODY) throw Object.assign(new Error('body too large'), { status: 413 })
chunks.push(c)
}
if (!size) return {}
const v = JSON.parse(Buffer.concat(chunks).toString('utf8'))
if (!v || typeof v !== 'object' || Array.isArray(v)) throw Object.assign(new Error('body must be an object'), { status: 400 })
return v
}
const httpErr = (status, message) => Object.assign(new Error(message), { status })
export function apply(ctx, config = {}) {
const prof = svc(ctx, 'profileContext')
if (prof?.dir) setProfileDir(prof.dir)
const store = new Store()
const self = store.identity(config.deviceName || os.hostname())
if (config.deviceName && config.deviceName !== self.name) {
self.name = config.deviceName
store.save()
}
const role = config.role === 'master' ? 'master' : config.role === 'slave' ? 'slave' : (store.master ? 'slave' : 'master')
const log = ctx.logger ?? console
const cookies = new CookieJar(ctx)
const localTarget = () => ({ host: '127.0.0.1', port: ctx.webServer.port })
// device id -> { mux, info, connectedAt } (master: slaves; slave: { master })
const links = new Map()
const listeners = new Set()
const notify = (event) => { for (const l of listeners) { try { l(event) } catch { /* ignore */ } } }
let lastSync = store.data.lastSync || null
let masterLink = null // slave only
// ------------------------------------------------------------------ stream routing
// A stream's meta.to names the device that must serve it. "self" streams hit local DSH.
// The master relays streams whose target is another slave.
function serveStream(stream) {
const to = stream.meta.to
if (to && to !== self.id) {
if (role !== 'master') return stream.fail(`cannot relay to ${to}`)
const peer = links.get(to)
if (!peer) return stream.fail(`device ${to} offline`)
return relay(stream, peer.mux)
}
if (stream.kind === 'http') return ownHttp(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
if (stream.kind === 'ws') return ownUpgrade(stream, { target: localTarget(), cookie: (a) => cookies.get(a) })
if (stream.kind === 'blob') return serveBlob(stream)
return stream.fail(`unknown stream kind ${stream.kind}`)
}
/** Splice an incoming stream onto a new stream to another link (master relay). */
function relay(inbound, mux) {
const out = mux.open(inbound.kind, inbound.meta)
const pipe = (a, b) => {
a.on('head', (m) => b.head(m))
a.on('data', (d) => b.write(d))
a.on('end', () => b.end())
a.on('error', (e) => b.fail(e.message))
a.on('remote-pause', () => b.mux.sendControl({ t: 'pause', id: b.id }))
a.on('remote-resume', () => b.mux.sendControl({ t: 'resume', id: b.id }))
}
pipe(inbound, out)
pipe(out, inbound)
inbound.start()
}
// ------------------------------------------------------------------ control handlers
const handlers = {
hello: async (p) => ({ id: self.id, name: self.name, role, version: config.version || '1' }),
sessions: async () => listLocalSessions(),
newSession: async (p) => createLocalSession(p),
devices: async (p, mux) => devicesView(),
route: async () => ({ ok: true }),
manifest: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const dev = store.devices()[mux.deviceId]
if (dev?.sync?.enabled === false) return { version: 1, bundles: [], patch: [], secrets: [], disabled: true }
const m = await buildManifestCached()
return { ...m, _dirs: undefined, secrets: offerSecretsFor(dev) }
},
secret: async (p, mux) => {
if (role !== 'master') throw httpErr(400, 'not a master')
const dev = store.devices()[mux.deviceId]
const name = String(p.name || '')
if (!offerSecretsFor(dev).includes(name)) throw httpErr(403, `secret ${name} not offered to this device`)
const c = svc(ctx, 'credentials')
if (!c) throw httpErr(503, 'credentials service unavailable')
const r = await c.resolve(name)
if (!r?.value) throw httpErr(404, `secret ${name} not set on master`)
return { name, value: r.value }
},
// Forwarded calls: master answers for another device by asking it.
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`)
// 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) {
const offered = new Set(config.sync?.offerSecrets || [])
return (dev?.sync?.secrets || []).filter((n) => offered.has(n))
}
let manifestCache = null
async function buildManifestCached() {
if (manifestCache && Date.now() - manifestCache.at < 15000) return manifestCache.value
const value = await sync.buildManifest({
self,
include: config.sync || {},
offerSecrets: config.sync?.offerSecrets || [],
})
manifestCache = { at: Date.now(), value }
return value
}
async function serveBlob(stream) {
// Only the master serves blobs: packed local plugins named in its manifest.
try {
if (role !== 'master') throw new Error('not a master')
const m = await buildManifestCached()
const dir = m._dirs?.[stream.meta.name]
if (!dir) throw new Error(`no local plugin ${stream.meta.name}`)
const dev = store.devices()[stream.mux.deviceId]
if (dev && !sync.allowed(stream.meta.name, dev.sync || {})) throw new Error('denied for this device')
const buf = await sync.packLocal(dir)
stream.head({ status: 200, size: buf.length })
stream.write(buf)
stream.end()
} catch (e) {
stream.fail(e.message)
}
}
// ------------------------------------------------------------------ sessions
async function listLocalSessions() {
const sc = svc(ctx, 'sessionController')
if (!sc) return { items: [], workspaces: [] }
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 10000)
try {
const { items } = await sc.list({}, ac.signal)
const wr = svc(ctx, 'workspaceRegistry')
const workspaces = wr ? wr.list().map((w) => ({ id: w.id, title: w.title, path: w.path })) : []
return {
items: items
.filter((s) => !s.parentSessionId && !s.blank)
.slice(0, config.maxSessions || 200)
.map((s) => ({
sessionId: s.sessionId,
updatedAt: s.updatedAt,
running: s.running,
cwd: s.cwd,
title: s.projections?.values?.title ?? null,
})),
workspaces,
}
} finally {
clearTimeout(t)
}
}
async function createLocalSession({ cwd, workspaceId } = {}) {
const sc = svc(ctx, 'sessionController')
if (!sc) throw httpErr(503, 'session controller unavailable')
const req = {}
if (workspaceId) req.workspaceId = workspaceId
else if (cwd) req.cwd = cwd
const r = await sc.create(req)
return { sessionId: r.sessionId }
}
// ------------------------------------------------------------------ device views
function devicesView() {
const out = [{ id: self.id, name: self.name, role, self: true, online: true }]
if (role === 'master') {
for (const d of Object.values(store.devices())) {
const l = links.get(d.id)
out.push({
id: d.id, name: d.name, platform: d.platform, role: 'slave', revoked: !!d.revoked,
online: !!l, connectedAt: l?.connectedAt || null, lastSeenAt: d.lastSeenAt || null,
sync: d.sync,
})
}
} else if (masterLink?.info) {
out.push({ ...masterLink.info, role: 'master', online: true })
// Peers in the master's broadcast are by definition online right now.
for (const d of masterLink.peers || []) if (d.id !== self.id && d.role !== 'master') out.push({ ...d, online: true, via: 'master' })
}
return out
}
/** Mux that reaches `device` from here (null = local). */
function routeTo(device) {
if (!device || device === self.id) return { local: true }
if (role === 'master') {
const l = links.get(device)
return l ? { mux: l.mux, meta: {} } : null
}
if (!masterLink?.mux || masterLink.mux.closed) return null
if (masterLink.info?.id === device) return { mux: masterLink.mux, meta: {} }
return { mux: masterLink.mux, meta: { to: device } } // relay through master
}
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, 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() {
const devices = devicesView().filter((d) => d.online && !d.revoked)
const results = await Promise.allSettled(devices.map(async (d) => ({ device: d, ...(await callDevice(d.id, 'sessions', {})) })))
return results.map((r, i) => r.status === 'fulfilled'
? { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, items: r.value.items, workspaces: r.value.workspaces }
: { device: devices[i].id, name: devices[i].name, self: !!devices[i].self, error: String(r.reason?.message ?? r.reason), items: [] })
}
// ------------------------------------------------------------------ session activity watch
// Poll local running states; emit "finished" events (for notifications) to all viewers.
const lastRunning = new Map()
async function pollActivity() {
try {
const { items } = await listLocalSessions()
for (const s of items) {
const was = lastRunning.get(s.sessionId)
if (was === true && !s.running) {
const ev = { type: 'session-finished', device: self.id, deviceName: self.name, sessionId: s.sessionId, title: s.title, at: Date.now() }
notify(ev)
broadcast('activity', ev)
}
lastRunning.set(s.sessionId, s.running)
}
} catch { /* ignore */ }
}
const activityTimer = setInterval(pollActivity, config.activityPollMs || 4000)
activityTimer.unref?.()
ctx.effect(() => () => clearInterval(activityTimer), 'dsh-remote: activity poll')
function broadcast(name, data) {
if (role === 'master') for (const l of links.values()) l.mux.event(name, data)
else masterLink?.mux?.event(name, data)
}
function onLinkEvent(name, data, fromDevice) {
if (name === 'activity') {
notify(data)
// Master fans slave activity out to the other slaves.
if (role === 'master') for (const [id, l] of links) if (id !== fromDevice) l.mux.event(name, data)
} else if (name === 'peers' && role === 'slave' && masterLink) {
masterLink.peers = data
notify({ type: 'devices' })
} else if (name === 'sync-available' && role === 'slave') {
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
}
}
function pushPeers() {
if (role !== 'master') return
const peers = devicesView().filter((d) => d.online).map(({ id, name, role: r, platform }) => ({ id, name, role: r, platform }))
for (const l of links.values()) l.mux.event('peers', peers)
notify({ type: 'devices' })
}
// ------------------------------------------------------------------ master: accept links
if (role === 'master') {
ctx.effect(() => ctx.webServer.registerUpgrade({
path: LINK_PATH,
handler: (req, socket, head) => {
const id = String(req.headers['x-dsh-remote-device'] || '')
const key = String(req.headers['x-dsh-remote-key'] || '')
const dev = store.verifyDevice(id, key)
if (!dev) return rejectUpgrade(socket, 401, 'unknown or revoked device')
const ws = acceptUpgrade(req, socket, head)
if (!ws) return
const prev = links.get(id)
if (prev) prev.mux.close(4000, 'replaced by new connection')
const mux = new Mux(ws, { odd: false, handlers, onStream: serveStream })
mux.deviceId = id
const entry = { mux, connectedAt: Date.now(), info: { id, name: dev.name } }
links.set(id, entry)
dev.lastSeenAt = Date.now()
dev.platform = String(req.headers['x-dsh-remote-platform'] || dev.platform || '')
store.save()
mux.on('event', (n, d) => onLinkEvent(n, d, id))
mux.on('close', () => {
if (links.get(id) === entry) links.delete(id)
const d = store.devices()[id]
if (d) { d.lastSeenAt = Date.now(); store.save() }
log.info?.(`dsh-remote: ${id} disconnected`)
pushPeers()
})
log.info?.(`dsh-remote: ${id} connected`)
pushPeers()
},
}), 'dsh-remote: link endpoint')
}
// ------------------------------------------------------------------ slave: keep link up
let stopped = false
let backoff = 1000
async function connectMaster() {
const m = store.master
if (!m || stopped) return
const url = new URL(m.url)
url.protocol = url.protocol === 'https:' ? 'wss:' : url.protocol === 'http:' ? 'ws:' : url.protocol
url.pathname = url.pathname.replace(/\/+$/, '') + LINK_PATH
try {
const edge = edgeFor(m)
const ws = await connect(url.href, {
headers: {
...(await edge.headers()),
'x-dsh-remote-device': m.deviceId,
'x-dsh-remote-key': m.key,
'x-dsh-remote-platform': `${process.platform}-${process.arch}`,
},
onHeaders: (h) => edge.absorb(h),
})
const mux = new Mux(ws, { odd: true, handlers, onStream: serveStream })
mux.deviceId = 'master'
masterLink = { mux, info: null, peers: [] }
backoff = 1000
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
mux.on('close', (code, reason) => {
masterLink = null
notify({ type: 'devices' })
if (code === 4401) {
lastLinkError = `master rejected this device (${reason || 'revoked'}); re-pair it`
log.warn(`dsh-remote: ${lastLinkError}`)
return
}
lastLinkError = `link closed (${code}${reason ? ' ' + reason : ''}); reconnecting`
if (!stopped) schedule()
})
const info = await mux.call('hello', { id: self.id, name: self.name })
masterLink.info = { id: info.id, name: info.name }
// Only devices that are online on the master are reachable through its relay.
masterLink.peers = (await mux.call('devices', {})).filter((d) => d.online && !d.revoked).map(({ id, name, role: r }) => ({ id, name, role: r }))
notify({ type: 'devices' })
lastLinkError = null
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
} catch (e) {
masterLink = null
// DSH itself refuses an unknown/revoked key with 401 + this exact text; anything else
// that is a redirect/401/403 came from an auth proxy in front of the master.
const deviceRefused = e.status === 401 && /unknown or revoked device/.test(e.body || '')
if (deviceRefused) { log.warn(`dsh-remote: master refused key (${e.message}); re-pair this device`); lastLinkError = e.message; return }
const edgeRefused = (e.status >= 300 && e.status < 400) || e.status === 401 || e.status === 403
if (edgeRefused) {
const mode = edgeFor(m).mode
lastLinkError = mode === 'none'
? `auth proxy in front of ${url.host} blocked the link (${e.status}); configure edgeAuth for this master (Settings → Remote devices)`
: `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
edgeFor(m).reset()
backoff = Math.max(backoff, 15000)
} else lastLinkError = e.message
log.warn(`dsh-remote: link to master failed: ${lastLinkError}`)
schedule()
}
}
// Edge auth (auth proxy in front of the master). Config: master.edgeAuth stored at pair
// time, overridden by `edgeAuth` in this plugin's config. Secrets resolve via credentials.
let edgeCache = null
function edgeConfigFor(m) {
return config.edgeAuth?.mode ? config.edgeAuth : (m?.edgeAuth || { mode: 'none' })
}
function edgeFor(m) {
const cfg = edgeConfigFor(m)
const key = JSON.stringify(cfg)
if (edgeCache?.key !== key) edgeCache = { key, client: createEdgeAuth(cfg, resolveSecretRef) }
return edgeCache.client
}
async function resolveSecretRef(ref) {
const creds = svc(ctx, 'credentials')
if (creds) {
try { const r = await creds.resolve(ref); if (r?.value) return r.value } catch { /* fall through */ }
}
return process.env[ref]
}
let lastLinkError = null
let timer = null
function schedule() {
clearTimeout(timer)
timer = setTimeout(connectMaster, backoff)
timer.unref?.()
backoff = Math.min(backoff * 2, 30000)
}
if (role === 'slave') connectMaster()
ctx.effect(() => () => {
stopped = true
clearTimeout(timer)
masterLink?.mux?.close(1001, 'shutdown')
for (const l of links.values()) l.mux.close(1001, 'shutdown')
}, 'dsh-remote: links')
// ------------------------------------------------------------------ slave: sync
let syncing = null
async function runSlaveSync() {
if (role !== 'slave') throw httpErr(400, 'only slaves sync')
if (syncing) return syncing
syncing = (async () => {
const mux = masterLink?.mux
if (!mux) throw httpErr(503, 'not linked to a master')
const local = config.sync || {}
const manifest = await mux.call('manifest', {}, 30000)
const result = { at: Date.now(), installed: [], updated: [], skipped: [], failed: [], secrets: [], patchEntries: manifest.patch?.length || 0 }
if (manifest.disabled) { result.skipped.push('sync disabled for this device on master'); return result }
const pm = svc(ctx, 'pluginManager')
const prev = store.data.synced || {}
const next = {}
let profileChanged = false
for (const b of manifest.bundles || []) {
if (!sync.allowed(b.name, local)) { result.skipped.push(`${b.name} (local deny/allow)`); continue }
try {
if (b.kind === 'local') {
if (prev[b.name]?.hash === b.hash && sync.isLinked(b.name, prev[b.name].dir)) { next[b.name] = prev[b.name]; continue }
const stream = mux.open('blob', { name: b.name })
const buf = await new Promise((resolve, reject) => {
const parts = []
stream.on('data', (d) => parts.push(d))
stream.on('end', () => { resolve(Buffer.concat(parts)); stream.end() })
stream.on('error', reject)
})
const dir = await sync.unpackLocal(b.name, b.version, buf)
profileChanged = sync.linkLocalBundle(b.name, dir) || profileChanged
next[b.name] = { kind: 'local', hash: b.hash, version: b.version, dir }
;(prev[b.name] ? result.updated : result.installed).push(`${b.name}@${b.version}`)
} else {
if (!pm) throw new Error('plugin manager unavailable')
const have = (await pm.listBundles()).find((x) => x.name === b.name)
if (have?.installed && have.version === b.version) {
if (!have.enabled) await pm.setBundleEnabled(b.name, true)
next[b.name] = { kind: 'registry', version: b.version }
continue
}
const r = await pm.installBundle(`${b.name}@${b.version}`, { enabled: true })
if (r.application === 'failed') throw new Error(r.error?.message || 'install failed')
next[b.name] = { kind: 'registry', version: b.version }
;(have ? result.updated : result.installed).push(`${b.name}@${b.version}`)
}
} catch (e) {
result.failed.push(`${b.name}: ${e.message}`)
if (prev[b.name]) next[b.name] = prev[b.name]
}
}
// Config layer.
await sync.writeManagedBundle(manifest.patch || [], Object.keys(next))
profileChanged = sync.ensureManagedBundleOrder() || profileChanged
// Opt-in secrets.
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')
await creds.set(name, value)
result.secrets.push(name)
} catch (e) {
result.failed.push(`secret ${name}: ${e.message}`)
}
}
store.data.synced = next
store.data.lastSync = result
store.save()
lastSync = result
result.restartRequired = profileChanged
notify({ type: 'sync', result })
// Unattended slaves (LaunchAgent KeepAlive / systemd Restart=always): restart DSH so
// newly synced plugins load. Only when no session is running, and at most once per
// 10 minutes so a broken plugin can't cause a restart loop.
if (profileChanged && config.sync?.restartOnSync) scheduleSelfRestart('plugins changed by sync')
return result
})()
try { return await syncing } finally { syncing = null }
}
function scheduleSelfRestart(reason) {
const last = Number(store.data.lastSelfRestart || 0)
if (Date.now() - last < 10 * 60 * 1000) { log.warn(`dsh-remote: restart needed (${reason}) but skipped: restarted <10 min ago`); return }
const attempt = async (tries) => {
let busy = false
try { busy = (await listLocalSessions()).items.some((s) => s.running) } catch { busy = false }
if (busy && tries < 60) { setTimeout(() => attempt(tries + 1), 30000).unref?.(); return }
store.data.lastSelfRestart = Date.now()
store.save()
log.warn(`dsh-remote: restarting DSH (${reason}); the service manager brings it back`)
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 1500).unref?.()
}
setTimeout(() => attempt(0), 3000).unref?.()
}
// ------------------------------------------------------------------ HTTP API
const route = (path, methods, handler) => ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path,
handler: async (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
if (!methods.includes(req.method)) { res.statusCode = 405; res.setHeader('allow', methods.join(', ')); return res.end() }
try {
sendJson(res, 200, await handler(req, new URL(req.url || '/', 'http://x')))
} catch (e) {
sendJson(res, e.status && e.status < 600 ? e.status : 500, { error: String(e.message || e) })
}
},
}), `dsh-remote: ${path}`)
route('/dsh-remote/state', ['GET'], async () => ({
role,
self,
master: role === 'slave' ? { url: store.master?.url || null, linked: !!masterLink?.mux, info: masterLink?.info || null, error: masterLink ? null : lastLinkError, edgeAuth: store.master ? edgeFor(store.master).describe() : null } : null,
devices: devicesView(),
publicUrl: config.publicUrl || null,
lanUrl: config.lanUrl || null,
lastSync,
sync: { auto: config.sync?.auto !== false, allow: config.sync?.allow || [], deny: config.sync?.deny || [], secrets: config.sync?.secrets || [], offerSecrets: config.sync?.offerSecrets || [] },
}))
route('/dsh-remote/sessions', ['GET'], async () => ({ devices: await allSessions() }))
route('/dsh-remote/pairing', ['POST'], async (req) => {
if (role !== 'master') throw httpErr(400, 'pairing codes are created on the master')
const body = await readJson(req)
return store.createPairingCode({ label: String(body.label || '') })
})
// Pair endpoint ON THE MASTER, called by a slave's DSH (not by a browser): exchanges a
// pairing code for a device key. Not behind browser auth (the code is the credential).
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: '/dsh-remote/enroll',
handler: async (req, res) => {
if (req.method !== 'POST') { res.statusCode = 405; return res.end() }
try {
if (role !== 'master') throw httpErr(400, 'not a master')
const body = await readJson(req)
const rec = store.consumePairingCode(body.code)
if (!rec) throw httpErr(403, 'invalid or expired pairing code')
const dev = store.addDevice({ id: body.id, name: body.name, platform: body.platform })
sendJson(res, 200, { deviceId: dev.id, key: dev.secret, master: { id: self.id, name: self.name } })
} catch (e) {
sendJson(res, e.status || 500, { error: String(e.message || e) })
}
},
}), 'dsh-remote: enroll')
/**
* Normalise an edgeAuth spec from the UI/API. Secret values arrive inline once and are
* written to DSH credentials under a ref; only the ref names are kept in state.
*/
async function storeEdgeAuth(spec) {
if (!spec || !spec.mode || spec.mode === 'none') return { mode: 'none' }
const creds = svc(ctx, 'credentials')
const put = async (ref, value) => {
if (value === undefined || value === null || value === '') return ref
if (!creds) throw httpErr(503, 'credentials service unavailable to store the edge secret')
await creds.set(ref, String(value))
return ref
}
const S = (s) => (s ? String(s).trim() : undefined)
const out = { mode: spec.mode }
if (spec.mode === 'basic') {
out.username = S(spec.username) || ''
out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
} else if (spec.mode === 'bearer') {
out.tokenRef = await put(S(spec.tokenRef) || 'DSH_REMOTE_EDGE_TOKEN', spec.token)
} else if (spec.mode === 'oauth2') {
Object.assign(out, { tokenUrl: S(spec.tokenUrl), clientId: S(spec.clientId), username: S(spec.username), scope: S(spec.scope) })
if (!out.tokenUrl || !out.clientId) throw httpErr(400, 'oauth2 edgeAuth needs tokenUrl and clientId')
if (spec.password || spec.passwordRef) out.passwordRef = await put(S(spec.passwordRef) || 'DSH_REMOTE_EDGE_PASSWORD', spec.password)
if (spec.clientSecret || spec.clientSecretRef) out.clientSecretRef = await put(S(spec.clientSecretRef) || 'DSH_REMOTE_EDGE_CLIENT_SECRET', spec.clientSecret)
} else if (spec.mode === 'headers') {
out.headers = {}
let i = 0
for (const [k, v] of Object.entries(spec.headers || {})) {
if (!/^[A-Za-z0-9-]+$/.test(k)) throw httpErr(400, `bad header name ${k}`)
const val = String(v ?? '')
// Values are treated as secrets unless already a ref.
out.headers[k] = val.startsWith('ref:') ? val : `ref:${await put(`DSH_REMOTE_EDGE_HEADER_${++i}`, val)}`
}
} else throw httpErr(400, `unknown edgeAuth mode ${spec.mode}`)
return out
}
route('/dsh-remote/pair', ['POST'], async (req) => {
const body = await readJson(req)
const masterUrl = String(body.masterUrl || '').trim().replace(/\/+$/, '')
if (!/^https?:\/\//.test(masterUrl)) throw httpErr(400, 'masterUrl must start with http:// or https://')
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
const edge = createEdgeAuth(config.edgeAuth?.mode ? config.edgeAuth : edgeAuth, resolveSecretRef)
let r
try {
r = await edge.edgeFetch(`${masterUrl}/dsh-remote/enroll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: body.code, id: self.id, name: self.name, platform: `${process.platform}-${process.arch}` }),
})
} catch (e) {
throw httpErr(e.status && e.status < 600 ? (e.status >= 300 && e.status < 400 ? 502 : e.status) : 502, `${e.message}${e.hint ? ` – ${e.hint}` : ''}`)
}
const j = await r.json().catch(() => ({}))
if (!r.ok) throw httpErr(r.status, j.error || `enroll failed (HTTP ${r.status})`)
store.setMaster({ url: masterUrl, deviceId: j.deviceId, key: j.key, master: j.master, edgeAuth, pairedAt: Date.now() })
edgeCache = null
return { ok: true, deviceId: j.deviceId, master: j.master, edgeAuth: edge.describe(), restartRequired: role !== 'slave' }
})
// Change / test edge auth of an already paired slave without re-pairing.
route('/dsh-remote/edge-auth', ['GET', 'POST'], async (req) => {
if (!store.master) throw httpErr(400, 'not paired with a master')
if (req.method === 'POST') {
const body = await readJson(req)
const edgeAuth = await storeEdgeAuth(body.edgeAuth)
store.setMaster({ ...store.master, edgeAuth })
edgeCache = null
masterLink?.mux?.close(1000, 'edge auth changed')
backoff = 1000
if (!masterLink) { clearTimeout(timer); connectMaster() }
}
// Probe: an unauthenticated GET of the link path through the edge must reach DSH, which
// answers a plain GET on an upgrade-only path with 404/426 (not a login redirect).
const edge = edgeFor(store.master)
let probe
try {
const r = await edge.edgeFetch(`${store.master.url}/dsh-remote/enroll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
probe = { reachedDsh: true, status: r.status }
} catch (e) {
probe = { reachedDsh: false, error: e.message, hint: e.hint }
}
return { master: store.master.url, override: !!config.edgeAuth?.mode, edgeAuth: edge.describe(), probe, linked: !!masterLink?.mux, linkError: lastLinkError }
})
route('/dsh-remote/unpair', ['POST'], async () => {
store.setMaster(null)
masterLink?.mux?.close(1000, 'unpaired')
return { ok: true }
})
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/dsh-remote/devices',
handler: async (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) return sendJson(res, rejection, { error: 'rejected' })
try {
if (role !== 'master') throw httpErr(400, 'devices are managed on the master')
if (req.method !== 'POST') throw httpErr(405, 'POST only')
const m = new URL(req.url || '/', 'http://x').pathname.match(/^\/dsh-remote\/devices\/([a-z0-9-]+)\/(revoke|remove|sync)$/)
if (!m) throw httpErr(404, 'not found')
const [, id, action] = m
if (action === 'revoke') { store.revokeDevice(id); links.get(id)?.mux.close(4401, 'revoked') }
else if (action === 'remove') { store.removeDevice(id); links.get(id)?.mux.close(4401, 'removed') }
else {
const dev = store.devices()[id]
if (!dev) throw httpErr(404, 'unknown device')
const body = await readJson(req)
const asList = (v) => (Array.isArray(v) ? v.map(String).filter(Boolean) : [])
dev.sync = { enabled: body.enabled !== false, allow: asList(body.allow), deny: asList(body.deny), secrets: asList(body.secrets) }
store.save()
links.get(id)?.mux.event('sync-available', {})
}
pushPeers()
sendJson(res, 200, { ok: true, devices: devicesView() })
} catch (e) {
sendJson(res, e.status || 500, { error: String(e.message || e) })
}
},
}), 'dsh-remote: devices')
route('/dsh-remote/sync', ['POST'], async () => {
if (role === 'master') {
manifestCache = null
for (const l of links.values()) l.mux.event('sync-available', {})
return { ok: true, pushed: links.size }
}
return runSlaveSync()
})
route('/dsh-remote/sync/status', ['GET'], async () => {
if (role === 'master') {
const m = await buildManifestCached()
return { role, bundles: m.bundles, patchIds: m.patch.map((e) => e.id || (e.insert ? 'insert' : '?')), patchError: m.patchError || null, offerSecrets: m.secrets }
}
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)
const r = await callDevice(device, 'newSession', { cwd: body.cwd, workspaceId: body.workspaceId })
return { device, sessionId: r.sessionId, url: device === self.id ? `./#/session/${r.sessionId}` : `./r/${device}/#/session/${r.sessionId}` }
})
// Server-sent events for the client: devices changes, activity, sync results.
route('/dsh-remote/events', ['GET'], async () => ({}))
ctx.effect(() => {
const dispose = ctx.webServer.register({
kind: 'exact',
path: '/dsh-remote/stream',
handler: (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) { res.statusCode = rejection; return res.end() }
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' })
res.write(': ok\n\n')
const l = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`)
listeners.add(l)
const ka = setInterval(() => res.write(': ka\n\n'), 20000)
req.on('close', () => { listeners.delete(l); clearInterval(ka) })
},
})
return dispose
}, 'dsh-remote: event stream')
// ------------------------------------------------------------------ /r/<device>/ proxy
function parseRemote(urlPath) {
const m = urlPath.match(/^\/r\/([a-z0-9-]+)(\/.*)?$/)
if (!m) return null
return { device: m[1], rest: m[2] || '/' }
}
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/r',
handler: (req, res) => {
const rejection = ctx.connection.requestRejection(req)
if (rejection !== undefined) {
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
return res.end(rejection === 401 ? 'dsh-remote: open this DSH first (authentication required)\n' : 'forbidden\n')
}
const url = new URL(req.url || '/', 'http://x')
const p = parseRemote(url.pathname)
if (!p) { res.statusCode = 404; return res.end() }
// "/r/ai-pc" → "/r/ai-pc/" so relative URLs (<base href="./">) resolve under the mount.
if (url.pathname === `/r/${p.device}`) {
res.writeHead(301, { location: `/r/${p.device}/${url.search}${url.hash}` })
return res.end()
}
if (p.device === self.id) {
res.writeHead(302, { location: `/${url.search}` })
return res.end()
}
const r = routeTo(p.device)
if (!r) {
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
return res.end(`<!doctype html><meta charset=utf-8><title>offline</title><body style="font:15px system-ui;padding:3em;background:#111;color:#ddd">
<h2>Device <code>${p.device}</code> is offline</h2><p>Its DSH is not linked right now. <a style="color:#8af" href="/">Back</a></p>`)
}
viewHttp(req, res, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: p.rest + url.search, basePath: `/r/${p.device}` })
},
}), 'dsh-remote: /r proxy')
// Upgrades: DSH's webserver matches upgrade routes exactly, so register the Remote mux
// path for each known device id (and re-register as devices appear).
const upgradeRoutes = new Map()
function ensureUpgradeRoute(device) {
if (device === self.id || upgradeRoutes.has(device)) return
const path = `/r/${device}${MUX_PATH}`
try {
const dispose = ctx.webServer.registerUpgrade({
path,
handler: (req, socket, head) => {
if (ctx.connection.requestRejection(req) !== undefined) return rejectUpgrade(socket, 401, 'auth required')
const r = routeTo(device)
if (!r) return rejectUpgrade(socket, 502, 'device offline')
const url = new URL(req.url || '/', 'http://x')
viewUpgrade(req, socket, head, (kind, meta) => r.mux.open(kind, { ...meta, ...r.meta }), { rest: MUX_PATH + url.search })
},
})
upgradeRoutes.set(device, dispose)
} catch (e) {
log.warn(`dsh-remote: upgrade route ${path}: ${e.message}`)
}
}
const upgradeSync = setInterval(() => { for (const d of devicesView()) ensureUpgradeRoute(d.id) }, 2000)
upgradeSync.unref?.()
for (const d of Object.keys(store.devices())) ensureUpgradeRoute(d)
ctx.effect(() => () => {
clearInterval(upgradeSync)
for (const dispose of upgradeRoutes.values()) { try { dispose() } catch { /* ignore */ } }
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`)
}