1433 lines
69 KiB
JavaScript
1433 lines
69 KiB
JavaScript
// @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, sha256 } 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 or session sync. */
|
||
const OPS = new Set(['exec', 'readFile', 'writeFile', 'listDir', 'task', 'taskStatus', 'taskCancel', 'inspectSession', 'exportSession', 'importSession', 'recordSessionMoved'])
|
||
/** Pseudo-secret name: opting into it ships the master's forge (git) logins. */
|
||
const FORGE_SECRET = 'FORGE_CREDENTIALS'
|
||
/** Session titles must not carry the machine name ("ai-pc: fix tests" → "fix tests"): the device is shown separately. */
|
||
const stripDevicePrefix = (title, names) => {
|
||
let t = String(title ?? '')
|
||
for (const n of names.filter(Boolean)) {
|
||
const esc = String(n).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
// "[name] x" / "(name) x" (brackets close the prefix) or "name: x" / "name - x" / "name | x"
|
||
const re = new RegExp(`^\\s*(?:[\\[(]${esc}[\\])]\\s*[:·|>–—-]?|${esc}\\s*[:·|>–—-])\\s*`, 'i')
|
||
t = t.replace(re, '')
|
||
}
|
||
return t.trim()
|
||
}
|
||
|
||
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()
|
||
}
|
||
// Node type: Settings → Remote devices (stored in state) beats the config file; "auto"
|
||
// (nothing chosen) = slave once paired with a master, else master.
|
||
const uiSettings = store.settings()
|
||
const configuredRole = ['master', 'slave'].includes(uiSettings.nodeType) ? uiSettings.nodeType : config.role
|
||
const role = configuredRole === 'master' ? 'master' : configuredRole === '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() }
|
||
},
|
||
// ---- seamless conversation sync & version control ----
|
||
exportSession: async (p, mux) => {
|
||
const sc = svc(ctx, 'sessionController')
|
||
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||
let ins = null
|
||
try {
|
||
ins = await sc.inspect(p.sessionId)
|
||
} catch (err) {
|
||
throw httpErr(404, `session "${p.sessionId}" not found: ${err.message}`)
|
||
}
|
||
const events = ins.events || []
|
||
const lastSeq = events.at(-1)?.seq ?? -1
|
||
const versionToken = store.sessionLineage()?.[p.sessionId]?.versionToken ||
|
||
sha256(`${p.sessionId}:${lastSeq}:${events.length}`)
|
||
return {
|
||
sessionId: p.sessionId,
|
||
meta: ins.meta || {},
|
||
events,
|
||
lastSeq,
|
||
versionToken,
|
||
title: stripDevicePrefix(ins.meta?.title ?? ins.meta?.projections?.values?.title, [self.id, self.name]) || ins.meta?.title || 'Session',
|
||
cwd: ins.meta?.cwd,
|
||
workspaceId: ins.meta?.workspaceId,
|
||
}
|
||
},
|
||
importSession: async (p, mux) => {
|
||
const sc = svc(ctx, 'sessionController')
|
||
const sp = svc(ctx, 'sessionPersistence')
|
||
if (!sc) throw httpErr(503, 'session controller unavailable')
|
||
|
||
const targetSessionId = `session-${randomId(12)}`
|
||
const title = p.title || p.meta?.title || 'Synced session'
|
||
const cwd = p.cwd || p.meta?.cwd
|
||
const workspaceId = p.workspaceId || p.meta?.workspaceId
|
||
|
||
let createdViaSp = false
|
||
if (sp && p.events && p.events.length > 0) {
|
||
try {
|
||
const header = {
|
||
id: targetSessionId,
|
||
createdAt: Date.now(),
|
||
cwd: cwd || process.cwd(),
|
||
workspaceId,
|
||
title,
|
||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||
originSessionId: p.sessionId,
|
||
}
|
||
const handle = await sp.create(header)
|
||
await handle.append(p.events)
|
||
await handle.flush()
|
||
await handle.close()
|
||
createdViaSp = true
|
||
} catch (e) {
|
||
log.warn?.(`dsh-remote: sp.create import failed: ${e.message}, falling back to sc.create`)
|
||
}
|
||
}
|
||
|
||
if (!createdViaSp) {
|
||
const res = await sc.create({ cwd, workspaceId })
|
||
const createdId = res.sessionId
|
||
try {
|
||
await sc.rename({ sessionId: createdId, title })
|
||
} catch {}
|
||
store.recordLineage(createdId, {
|
||
originSessionId: p.sessionId,
|
||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||
baseSeq: p.lastSeq ?? 0,
|
||
versionToken: p.versionToken,
|
||
syncedAt: Date.now(),
|
||
status: 'active',
|
||
})
|
||
return { sessionId: createdId, ok: true }
|
||
}
|
||
|
||
store.recordLineage(targetSessionId, {
|
||
originSessionId: p.sessionId,
|
||
originDevice: p.sourceDevice || mux?.deviceId || 'remote',
|
||
baseSeq: p.lastSeq ?? 0,
|
||
versionToken: p.versionToken,
|
||
syncedAt: Date.now(),
|
||
status: 'active',
|
||
})
|
||
|
||
return { sessionId: targetSessionId, ok: true }
|
||
},
|
||
recordSessionMoved: async (p, mux) => {
|
||
const sc = svc(ctx, 'sessionController')
|
||
let localLastSeq = -1
|
||
let conflict = false
|
||
if (sc) {
|
||
try {
|
||
const ins = await sc.inspect(p.sessionId)
|
||
const events = ins.events || []
|
||
localLastSeq = events.at(-1)?.seq ?? -1
|
||
if (localLastSeq > (p.atSeq ?? -1)) {
|
||
conflict = true
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
store.recordLineage(p.sessionId, {
|
||
movedTo: p.movedTo,
|
||
targetSessionId: p.targetSessionId,
|
||
atSeq: p.atSeq,
|
||
localLastSeq,
|
||
versionToken: p.versionToken,
|
||
conflict,
|
||
status: conflict ? 'conflict' : 'moved',
|
||
})
|
||
|
||
if (conflict) {
|
||
notify({
|
||
type: 'session-conflict',
|
||
sessionId: p.sessionId,
|
||
movedTo: p.movedTo,
|
||
atSeq: p.atSeq,
|
||
localLastSeq,
|
||
title: `Sync conflict on ${p.sessionId}: moved to ${p.movedTo} at seq ${p.atSeq}, modified locally up to seq ${localLastSeq}`,
|
||
})
|
||
} else {
|
||
notify({
|
||
type: 'session-moved',
|
||
sessionId: p.sessionId,
|
||
movedTo: p.movedTo,
|
||
title: `Session ${p.sessionId} moved to ${p.movedTo} (synced to prevent conflicts)`,
|
||
})
|
||
}
|
||
|
||
return { ok: true, conflict, localLastSeq }
|
||
},
|
||
}
|
||
|
||
/**
|
||
* 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: stripDevicePrefix(s.projections?.values?.title, [self.id, self.name]) || (s.projections?.values?.title ?? null),
|
||
lineage: store.sessionLineage()[s.sessionId] || 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}`))
|
||
} else if (name === 'session-moved' || name === 'session-conflict') {
|
||
notify(data)
|
||
if (role === 'master') {
|
||
for (const [id, l] of links) {
|
||
if (id !== fromDevice) l.mux.event(name, data)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
// Notify offline remote ASAP of any session moves that happened while it was disconnected
|
||
try {
|
||
const pending = store.popPendingMovesForDevice(id)
|
||
if (pending && pending.length > 0) {
|
||
for (const move of pending) {
|
||
entry.mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||
log.warn?.(`dsh-remote: failed to notify ${id} of session move: ${err.message}`)
|
||
})
|
||
}
|
||
}
|
||
} catch {}
|
||
},
|
||
}), 'dsh-remote: link endpoint')
|
||
}
|
||
|
||
// ------------------------------------------------------------------ slave: keep link up
|
||
let stopped = false
|
||
let backoff = 1000
|
||
let connecting = null
|
||
/** One connect attempt at a time (retry-link, the backoff timer and edge changes can race). */
|
||
function connectMaster() {
|
||
if (connecting) return connecting
|
||
connecting = connectMasterOnce().finally(() => { connecting = null })
|
||
return connecting
|
||
}
|
||
async function connectMasterOnce() {
|
||
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'
|
||
const link = { mux, info: null, peers: [] }
|
||
masterLink = link
|
||
backoff = 1000
|
||
mux.on('event', (n, d) => onLinkEvent(n, d, 'master'))
|
||
mux.on('close', (code, reason) => {
|
||
if (masterLink === link) 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 })
|
||
link.info = { id: info.id, name: info.name }
|
||
// Only devices that are online on the master are reachable through its relay.
|
||
link.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
|
||
authRequired = null
|
||
log.info?.(`dsh-remote: linked to master ${info.name} (${m.url})`)
|
||
// Notify master of any session moves queued while offline
|
||
try {
|
||
const pending = store.popPendingMovesForDevice('master')
|
||
if (pending && pending.length > 0) {
|
||
for (const move of pending) {
|
||
mux.call('recordSessionMoved', move, 15000).catch((err) => {
|
||
log.warn?.(`dsh-remote: failed to notify master of session move: ${err.message}`)
|
||
})
|
||
}
|
||
}
|
||
} catch {}
|
||
if (config.sync?.auto !== false) runSlaveSync().catch((e) => log.warn(`dsh-remote: sync failed: ${e.message}`))
|
||
} catch (e) {
|
||
if (masterLink && !masterLink.info) masterLink = null // never clear a link that finished its handshake
|
||
// 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}); sign in through the auth proxy (Settings → Remote devices)`
|
||
: `auth proxy in front of ${url.host} rejected the edgeAuth credentials (${e.status})`
|
||
edgeFor(m).reset()
|
||
backoff = Math.max(backoff, 15000)
|
||
raiseAuthRequired(m, { status: e.status, location: e.location, reason: lastLinkError })
|
||
} else if (e.status === 200 || /text\/html/i.test(e.contentType || '')) {
|
||
// Upgrade answered with a normal page instead of 101: a login page served inline.
|
||
lastLinkError = `auth proxy in front of ${url.host} answered the link with a web page; sign in through the auth proxy (Settings → Remote devices)`
|
||
raiseAuthRequired(m, { status: e.status, reason: lastLinkError })
|
||
} else lastLinkError = e.message
|
||
log.warn(`dsh-remote: link to master failed: ${lastLinkError}`)
|
||
schedule()
|
||
}
|
||
}
|
||
|
||
// The master sits behind an auth middleware that wants an interactive sign-in. Surfaced in
|
||
// /dsh-remote/state so the UI can open the sign-in popup when "auth middleware" is on.
|
||
let authRequired = null
|
||
function raiseAuthRequired(m, { status, location, reason }) {
|
||
if (store.settings().authMiddleware === false) return
|
||
const loginUrl = (() => {
|
||
try { return new URL(location || '', m.url).href } catch { return null }
|
||
})()
|
||
authRequired = { at: Date.now(), status, reason, loginUrl, masterUrl: m.url, signInUrl: `${m.url}/dsh-remote/enroll` }
|
||
notify({ type: 'auth-required', ...authRequired })
|
||
}
|
||
|
||
// 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, { persistCookie: (c) => persistProxyCookie(cfg, c) }) }
|
||
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]
|
||
}
|
||
/** Cookie mode: write the (possibly rotated) proxy session cookie back to its credential ref. */
|
||
async function persistProxyCookie(cfg, cookieHeader) {
|
||
const creds = svc(ctx, 'credentials')
|
||
if (creds && cfg?.cookieRef) await creds.set(cfg.cookieRef, cookieHeader)
|
||
}
|
||
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?.()
|
||
}
|
||
|
||
// Restart this DSH from the UI (node type changes only take effect at boot). Relies on the
|
||
// service manager (systemd Restart=always / LaunchAgent KeepAlive), like restartOnSync.
|
||
const isManaged = () => !!(process.env.INVOCATION_ID || process.env.XPC_SERVICE_NAME || process.env.JOURNAL_STREAM)
|
||
|
||
// ------------------------------------------------------------------ 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, authRequired } : null,
|
||
node: nodeView(false),
|
||
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 === 'cookie') {
|
||
// Session cookie pasted from a browser that is already signed in through the proxy.
|
||
out.cookieRef = await put(S(spec.cookieRef) || 'DSH_REMOTE_EDGE_COOKIE', spec.cookie)
|
||
} 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 }
|
||
})
|
||
|
||
/**
|
||
* Node settings from Settings → Remote devices:
|
||
* nodeType master | slave (restart needed when it changes: it decides which
|
||
* endpoints/links this instance starts)
|
||
* masterOrigin slave: origin of the master (https://dsh.example.com), pairs with it
|
||
* authMiddleware true: an auth proxy sits in front of the master; when the link meets a
|
||
* non-DSH answer the UI opens a sign-in popup, then calls retry-link
|
||
* false: behave as before (no popup)
|
||
*/
|
||
route('/dsh-remote/node', ['GET', 'POST'], async (req) => {
|
||
if (req.method === 'POST') {
|
||
const body = await readJson(req)
|
||
const patch = {}
|
||
if (body.nodeType !== undefined) {
|
||
if (!['master', 'slave'].includes(body.nodeType)) throw httpErr(400, 'nodeType must be master or slave')
|
||
patch.nodeType = body.nodeType
|
||
}
|
||
if (body.authMiddleware !== undefined) patch.authMiddleware = !!body.authMiddleware
|
||
const before = store.settings()
|
||
store.setSettings(patch)
|
||
if (patch.authMiddleware === false) authRequired = null
|
||
if (body.masterOrigin !== undefined && store.master) {
|
||
const origin = originOf(body.masterOrigin)
|
||
if (origin !== store.master.url) throw httpErr(400, 'to change the master, re-pair (Pair with a master)')
|
||
}
|
||
return nodeView(before.nodeType !== patch.nodeType && patch.nodeType !== undefined)
|
||
}
|
||
return nodeView(false)
|
||
})
|
||
|
||
const originOf = (u) => {
|
||
const url = new URL(String(u || '').trim())
|
||
if (!/^https?:$/.test(url.protocol)) throw httpErr(400, 'origin must start with http:// or https://')
|
||
return url.origin + url.pathname.replace(/\/+$/, '')
|
||
}
|
||
|
||
function nodeView(restartRequired) {
|
||
const s = store.settings()
|
||
return {
|
||
nodeType: s.nodeType || null, // what the user chose (null = auto)
|
||
effectiveNodeType: role, // what this process is running as
|
||
restartRequired: !!restartRequired || (!!s.nodeType && s.nodeType !== role),
|
||
// master: the origin slaves should use (own origin as the browser sees it is shown by the UI);
|
||
// slave: the paired master's origin (read-only once paired).
|
||
masterOrigin: store.master?.url || null,
|
||
authMiddleware: s.authMiddleware === true,
|
||
authRequired,
|
||
}
|
||
}
|
||
|
||
// Retry the master link now (after the user signed in through the popup).
|
||
route('/dsh-remote/retry-link', ['POST'], async () => {
|
||
if (role !== 'slave' || !store.master) throw httpErr(400, 'not a paired slave')
|
||
authRequired = null
|
||
backoff = 1000
|
||
edgeFor(store.master).reset()
|
||
clearTimeout(timer)
|
||
await connectMaster()
|
||
return { linked: !!masterLink?.mux, error: masterLink ? null : lastLinkError, authRequired }
|
||
})
|
||
|
||
route('/dsh-remote/restart', ['POST'], async () => {
|
||
if (!isManaged()) throw httpErr(409, 'this DSH is not running under a service manager (systemd / launchd); restart it yourself')
|
||
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 800).unref?.()
|
||
return { ok: true, restarting: true }
|
||
})
|
||
|
||
// ---- sign-in hand-over page (served by the MASTER, reachable only through its auth proxy)
|
||
// A slave UI opens this in a popup. The browser signs in to the auth proxy (Authentik, …),
|
||
// lands back here, and — after an explicit click — this page postMessages the proxy
|
||
// session cookie it received to the slave UI that opened it, so the slave's DSH can replay
|
||
// it on the link. No DSH auth is required (the proxy is the gate), and it only ever returns
|
||
// the caller's own cookies to an origin the user approves; origins are limited to
|
||
// loopback / private-network hosts or `signInOrigins` in the config.
|
||
const isPrivateHost = (h) => /^(localhost|\[::1\]|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|[^.]+\.local)$/i.test(h)
|
||
const originAllowed = (o) => {
|
||
try {
|
||
const u = new URL(o)
|
||
return isPrivateHost(u.hostname) || (config.signInOrigins || []).includes(u.origin)
|
||
} catch { return false }
|
||
}
|
||
ctx.effect(() => ctx.webServer.register({
|
||
kind: 'exact',
|
||
path: '/dsh-remote/signin',
|
||
handler: (req, res) => {
|
||
const url = new URL(req.url || '/', 'http://x')
|
||
const origin = url.searchParams.get('origin') || ''
|
||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||
const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim()).filter((s) => s && !/^dsh-auth-/.test(s))
|
||
const proxyCookies = cookies.filter((c) => /^(authentik_proxy|_oauth2_proxy|cf_authorization|authelia_session|keycloak|kc-|_forward_auth)/i.test(c))
|
||
const send = proxyCookies.length ? proxyCookies : cookies
|
||
const ok = originAllowed(origin)
|
||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'" })
|
||
res.end(`<!doctype html><meta charset=utf-8><title>dsh-remote sign-in</title>
|
||
<body style="font:15px system-ui;max-width:34em;margin:3em auto;padding:0 1em;background:#111;color:#ddd">
|
||
<h2>Signed in to ${esc(self.name)}</h2>
|
||
${!ok ? `<p>The device that opened this window (<code>${esc(origin || 'unknown')}</code>) is not on this machine's trusted list, so nothing will be sent.</p>`
|
||
: send.length ? `<p>This browser is signed in through the auth proxy. Send that session to the DSH at <code>${esc(origin)}</code> so it can reach this master?</p>
|
||
<p><button id=go style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:0;background:#2f81f7;color:#fff;cursor:pointer">Send session</button> <button onclick="window.close()" style="font:inherit;padding:.5em 1.2em;border-radius:8px;border:1px solid #555;background:none;color:#ddd;cursor:pointer">Cancel</button></p>
|
||
<p id=msg style="opacity:.7"></p>
|
||
<script>
|
||
const cookie=${JSON.stringify(send.join('; ')).replace(/</g, '\\u003c')};
|
||
document.getElementById('go').onclick=()=>{
|
||
if(!window.opener){document.getElementById('msg').textContent='No opener window: close this and use the Sign in button again.';return}
|
||
window.opener.postMessage({type:'dsh-remote-signin',cookie,master:location.origin},${JSON.stringify(origin)});
|
||
document.getElementById('msg').textContent='Sent. You can close this window.';setTimeout(()=>window.close(),700)}
|
||
</script>`
|
||
: `<p>You are signed in, but the auth proxy did not forward its session cookie to DSH, so it cannot be handed over automatically. Copy it from your browser (DevTools → Application → Cookies → this site) and paste it into <b>Settings → Remote devices → Auth proxy → Session cookie</b>.</p>`}
|
||
</body>`)
|
||
},
|
||
}), 'dsh-remote: sign-in page')
|
||
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}` }
|
||
})
|
||
|
||
route('/dsh-remote/continue-session', ['POST'], async (req) => {
|
||
const body = await readJson(req)
|
||
const sourceDevice = String(body.sourceDevice || body.device || self.id)
|
||
const targetDevice = String(body.targetDevice || self.id)
|
||
const sessionId = String(body.sessionId || '')
|
||
if (!sessionId) throw httpErr(400, 'sessionId required')
|
||
|
||
let exportData
|
||
if (sourceDevice === self.id) {
|
||
exportData = await handlers.exportSession({ sessionId })
|
||
} else {
|
||
try {
|
||
exportData = await callDevice(sourceDevice, 'exportSession', { sessionId }, 30000)
|
||
} catch (err) {
|
||
const lin = store.sessionLineage()[sessionId]
|
||
exportData = {
|
||
sessionId,
|
||
meta: { title: lin?.title || sessionId, cwd: lin?.cwd },
|
||
events: [],
|
||
lastSeq: lin?.baseSeq ?? 0,
|
||
versionToken: lin?.versionToken || sha256(sessionId),
|
||
title: lin?.title || sessionId,
|
||
cwd: lin?.cwd,
|
||
offlineSource: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
let importRes
|
||
if (targetDevice === self.id) {
|
||
importRes = await handlers.importSession(exportData)
|
||
} else {
|
||
importRes = await callDevice(targetDevice, 'importSession', exportData, 30000)
|
||
}
|
||
|
||
const newSessionId = importRes.sessionId
|
||
const notice = {
|
||
sessionId,
|
||
fromDevice: sourceDevice,
|
||
toDevice: targetDevice,
|
||
targetSessionId: newSessionId,
|
||
atSeq: exportData.lastSeq ?? 0,
|
||
versionToken: exportData.versionToken,
|
||
timestamp: Date.now(),
|
||
}
|
||
|
||
if (sourceDevice === self.id) {
|
||
await handlers.recordSessionMoved(notice)
|
||
} else {
|
||
try {
|
||
await callDevice(sourceDevice, 'recordSessionMoved', notice, 10000)
|
||
} catch {
|
||
store.queueMoveNotice(notice)
|
||
}
|
||
}
|
||
|
||
return {
|
||
sourceDevice,
|
||
targetDevice,
|
||
originalSessionId: sessionId,
|
||
sessionId: newSessionId,
|
||
url: targetDevice === self.id ? `./#/session/${newSessionId}` : `./r/${targetDevice}/#/session/${newSessionId}`,
|
||
conflict: importRes.conflict || false,
|
||
versionToken: exportData.versionToken,
|
||
}
|
||
})
|
||
|
||
route('/dsh-remote/session-lineage', ['GET'], async () => store.sessionLineage())
|
||
|
||
// 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. Describe the task only; do not include the device name (it is shown next to the session already).'),
|
||
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: stripDevicePrefix(args.title, [device, args.device, devicesView().find((d) => d.id === device)?.name]) || undefined, 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`)
|
||
}
|