Files
dsh-remote/index.js
T

1568 lines
81 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 crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { acceptUpgrade, connect, rejectUpgrade } from './lib/ws.mjs'
import { Mux } from './lib/mux.mjs'
import { Store, randomId, setProfileDir, slug, stateDir } 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'
import * as ss from './lib/session-sync.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'
/** 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() }
},
// ---- conversation sync (see lib/session-sync.mjs) ----
syncPage: async (p) => syncEngine.servePage(p),
importBegin: async (p) => syncEngine.importBegin(p),
importChunk: async (p) => syncEngine.importChunk(p),
importCommit: async (p) => syncEngine.importCommit(p),
sessionMoved: async (p) => syncEngine.onMoved(p),
sessionDiverged: async (p) => syncEngine.onDiverged(p),
}
/**
* 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: ledger.get(s.sessionId),
})),
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 }
}
// ------------------------------------------------------------------ conversation sync
// Git-like hand-over of a conversation between devices (see lib/session-sync.mjs).
const syncCfg = { mirrorRecent: 10, mirrorIntervalMs: 60000, watchIntervalMs: 20000, ...(config.sessionSync || {}) }
const ledger = new ss.Ledger(store)
const mirrors = new ss.MirrorStore(path.join(stateDir(), 'mirrors'))
const syncEngine = createSyncEngine()
function createSyncEngine() {
const readCache = new Map() // sessionId -> { at, events, cwd }
const staged = new Map() // importId -> { at, meta, events }
let mirroring = false
let delivering = false
const sc = () => {
const c = svc(ctx, 'sessionController')
if (!c) throw httpErr(503, 'session controller unavailable')
return c
}
/** Full local log (cached briefly so paging a big session reads it once). */
async function readLocal(sessionId) {
const hit = readCache.get(sessionId)
if (hit && Date.now() - hit.at < 30000) return hit
let ins
try { ins = await sc().inspect(sessionId) } catch (e) { throw httpErr(404, `session ${sessionId} not found on ${self.name}: ${e.message || e}`) }
const v = { at: Date.now(), events: ins.events || [], cwd: ins.meta?.cwd }
readCache.set(sessionId, v)
for (const [k, x] of readCache) if (Date.now() - x.at > 30000) readCache.delete(k)
return v
}
/** Link method: one page of a local session, optionally continuing a replica the caller holds. */
async function servePage({ sessionId, fromSeq = 0, knownSeq, knownCommit }) {
const { events, cwd } = await readLocal(sessionId)
let from = Number(fromSeq) || 0
let reset = false
if (knownSeq !== undefined && knownSeq >= 0 && ss.commitId(events, knownSeq) !== knownCommit) { reset = true; from = 0 }
const pg = ss.page(events, from)
const tip = ss.tipSeq(events)
return { sessionId, cwd, reset, events: pg.events, nextSeq: pg.nextSeq, done: pg.done, tipSeq: tip, tipCommit: tip >= 0 ? ss.commitId(events, tip) : null }
}
/**
* Pull a remote session (incrementally on top of the local replica, if any) and refresh the
* replica. Falls back to the replica when the device is unreachable.
*/
async function fetchRemote(device, sessionId, info = {}) {
const meta = mirrors.meta(device, sessionId)
let events = meta ? mirrors.events(device, sessionId) : null
try {
if (!routeTo(device)) throw httpErr(503, `device ${device} is offline`)
let from = 0
let known = {}
if (events && events.length && meta?.tipCommit) { from = meta.tipSeq + 1; known = { knownSeq: meta.tipSeq, knownCommit: meta.tipCommit } } else events = []
let cwd = meta?.cwd
for (;;) {
const pg = await callDevice(device, 'syncPage', { sessionId, fromSeq: from, ...known }, 60000)
if (pg.reset) { events = []; known = {} }
events.push(...pg.events)
cwd = pg.cwd ?? cwd
from = pg.nextSeq
if (pg.done) break
known = {}
}
ss.assertDense(events)
const saved = mirrors.save(device, sessionId, events, { ...meta, ...info, cwd, deviceName: info.deviceName || meta?.deviceName })
return { events, cwd, fromMirror: false, syncedAt: saved.syncedAt }
} catch (e) {
if (events && events.length) return { events, cwd: meta.cwd, fromMirror: true, syncedAt: meta.syncedAt, offlineReason: String(e.message || e) }
throw e.status ? e : httpErr(503, String(e.message || e))
}
}
/** Keep replicas of other devices' recent sessions so they can be continued while offline. */
async function refreshMirrors() {
if (mirroring || syncCfg.mirrorRecent <= 0) return
mirroring = true
try {
for (const d of devicesView()) {
if (d.self || !d.online || d.revoked) continue
let list
try { list = await callDevice(d.id, 'sessions', {}, 15000) } catch { continue }
const recent = (list.items || []).slice(0, syncCfg.mirrorRecent)
for (const s of recent) {
const m = mirrors.meta(d.id, s.sessionId)
if (m && m.updatedAt >= s.updatedAt) continue
try {
await fetchRemote(d.id, s.sessionId, { title: s.title, updatedAt: s.updatedAt, cwd: s.cwd, deviceName: d.name })
} catch (e) { log.debug?.(`dsh-remote: mirror ${d.id}/${s.sessionId}: ${e.message}`) }
}
mirrors.prune(d.id, Math.max(syncCfg.mirrorRecent * 2, 20))
}
} finally { mirroring = false }
}
/** Choose where an imported session lives on this device. */
function placeLocally({ workspaceId, cwd }) {
const wr = svc(ctx, 'workspaceRegistry')
const workspaces = wr ? wr.list() : []
if (workspaceId) {
const w = workspaces.find((x) => x.id === workspaceId)
if (!w) throw httpErr(404, `workspace ${workspaceId} not found on ${self.name}`)
return { cwd: w.path, workspaceId: w.id }
}
const exact = cwd && workspaces.find((x) => x.path === cwd)
if (exact) return { cwd: exact.path, workspaceId: exact.id }
const base = cwd ? path.basename(cwd) : ''
const sameName = base && workspaces.find((x) => path.basename(x.path) === base)
if (sameName) return { cwd: sameName.path, workspaceId: sameName.id }
if (cwd && path.isAbsolute(cwd) && fs.existsSync(cwd)) return { cwd }
return { cwd: os.homedir() }
}
/**
* Materialise a conversation prefix on THIS device as a new seeded session — the same log
* shape DSH writes for a fork — then let the session controller adopt it, which composes the
* agent (tools, preset) and attaches it to the workspace exactly like a native session.
*/
async function importLocal({ events, baseSeq, origin, workspaceId, cwd }) {
ss.assertDense(events)
if (!(baseSeq >= 0 && baseSeq < events.length)) throw httpErr(400, 'invalid base seq')
const sp = svc(ctx, 'sessionPersistence')
if (!sp) throw httpErr(503, 'session persistence unavailable on this device')
const where = placeLocally({ workspaceId, cwd })
const sessionId = `session-${crypto.randomUUID()}`
const seed = ss.forkSeed(events, baseSeq)
const header = { version: 4, id: sessionId, createdAt: Date.now(), cwd: where.cwd, isSeeded: true }
const handle = await sp.create(header, { inheritedEventCount: baseSeq + 1 })
try {
await handle.append(seed)
await handle.flush?.()
} finally {
await handle.close()
}
await sc().create(where.workspaceId ? { sessionId, workspaceId: where.workspaceId } : { sessionId, cwd: where.cwd })
ledger.patch(sessionId, { origin: { ...origin, baseSeq, baseCommit: ss.commitId(events, baseSeq), importedAt: Date.now() } })
notify({ type: 'sessions' })
return { sessionId, cwd: where.cwd, workspaceId: where.workspaceId || null }
}
// Link methods for importing onto another device in pages.
function importBegin(meta) {
for (const [k, v] of staged) if (Date.now() - v.at > 10 * 60000) staged.delete(k)
const id = randomId(12)
staged.set(id, { at: Date.now(), meta, events: [] })
return { importId: id }
}
function importChunk({ importId, events }) {
const st = staged.get(importId)
if (!st) throw httpErr(404, 'unknown import (expired?)')
st.events.push(...events)
st.at = Date.now()
return { received: st.events.length }
}
async function importCommit({ importId }) {
const st = staged.get(importId)
if (!st) throw httpErr(404, 'unknown import (expired?)')
staged.delete(importId)
return importLocal({ ...st.meta, events: st.events })
}
async function importOn(device, payload) {
if (device === self.id) return importLocal(payload)
const { events, ...meta } = payload
const { importId } = await callDevice(device, 'importBegin', meta, 20000)
for (let from = 0; from < events.length;) {
const pg = ss.page(events, from)
await callDevice(device, 'importChunk', { importId, events: pg.events }, 60000)
from = pg.nextSeq
}
return callDevice(device, 'importCommit', { importId }, 120000)
}
/** Deliver a notice now, or queue it for the moment the device is reachable again. */
async function send(notice) {
const method = notice.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
if (notice.to === self.id) return { delivered: true, result: await handlers[method](notice) }
if (routeTo(notice.to)) {
try {
const result = await callDevice(notice.to, method, notice, 20000)
return { delivered: true, result }
} catch (e) { log.warn?.(`dsh-remote: ${method} → ${notice.to} failed, queued: ${e.message}`) }
}
ledger.queue(notice)
return { delivered: false }
}
async function deliverOutbox() {
if (delivering) return
delivering = true
try {
for (const n of ledger.pending()) {
if (n.to !== self.id && !routeTo(n.to)) continue
const method = n.kind === 'diverged' ? 'sessionDiverged' : 'sessionMoved'
try {
const result = n.to === self.id ? await handlers[method](n) : await callDevice(n.to, method, n, 20000)
ledger.done(n)
if (n.kind === 'moved') afterMoveAck(n, result)
notify({ type: 'session-sync', delivered: n })
} catch (e) { log.warn?.(`dsh-remote: queued ${method} → ${n.to}: ${e.message}`) }
}
} finally { delivering = false }
}
/** The source answered a move notice; if it had diverged, flag our copy too. */
function afterMoveAck(notice, result) {
if (!result || result.state === 'clean') return
const copy = notice.movedTo
if (copy.device !== self.id) return // the conflict notice goes to that device from the source
ledger.patch(copy.sessionId, { conflict: { kind: result.state, otherTip: result.localTip, atSeq: notice.atSeq, detectedAt: Date.now(), other: { device: notice.to, sessionId: notice.sessionId } } })
notify({ type: 'session-conflict', device: self.id, sessionId: copy.sessionId, title: notice.title, other: { device: notice.to, deviceName: deviceName(notice.to), sessionId: notice.sessionId } })
}
/** Link method on the source device: "your session now continues elsewhere". */
async function onMoved(n) {
let events = []
try { readCache.delete(n.sessionId); events = (await readLocal(n.sessionId)).events } catch { /* deleted here */ }
const state = events.length ? ss.divergence(events, n.atSeq, n.atCommit) : 'clean'
const localTip = ss.tipSeq(events)
ledger.patch(n.sessionId, {
moved: { device: n.movedTo.device, sessionId: n.movedTo.sessionId, atSeq: n.atSeq, atCommit: n.atCommit, at: n.at, by: n.by },
conflict: state === 'clean' ? undefined : { kind: state, localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.movedTo },
})
notify({ type: state === 'clean' ? 'session-moved' : 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.movedTo, deviceName: deviceName(n.movedTo.device) } })
if (state !== 'clean') {
// Tell the device holding the moved copy as well (now or as soon as it is reachable).
await send({ kind: 'diverged', to: n.movedTo.device, sessionId: n.movedTo.sessionId, other: { device: self.id, sessionId: n.sessionId }, state, localTip, atSeq: n.atSeq, title: n.title })
}
return { state, localTip }
}
/** Link method on the copy's device: the other branch got new events after the base. */
function onDiverged(n) {
ledger.patch(n.sessionId, { conflict: { kind: n.state || 'diverged', otherTip: n.localTip, atSeq: n.atSeq, detectedAt: Date.now(), other: n.other } })
notify({ type: 'session-conflict', device: self.id, sessionId: n.sessionId, title: n.title, other: { ...n.other, deviceName: deviceName(n.other.device) } })
return { ok: true }
}
/** A moved-away session that gets new turns here has diverged from its continuation. */
async function watchMoved() {
const entries = Object.entries(ledger.all()).filter(([, l]) => l.moved && !l.conflict)
if (!entries.length) return
let items = []
try { items = (await listLocalSessions()).items } catch { return }
for (const [sessionId, l] of entries) {
const it = items.find((x) => x.sessionId === sessionId)
if (!it || !(it.updatedAt > l.moved.at)) continue
readCache.delete(sessionId)
let events
try { events = (await readLocal(sessionId)).events } catch { continue }
const state = ss.divergence(events, l.moved.atSeq, l.moved.atCommit)
if (state === 'clean') continue
const other = { device: l.moved.device, sessionId: l.moved.sessionId }
ledger.patch(sessionId, { conflict: { kind: state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, detectedAt: Date.now(), other } })
notify({ type: 'session-conflict', device: self.id, sessionId, title: it.title, other: { ...other, deviceName: deviceName(other.device) } })
await send({ kind: 'diverged', to: other.device, sessionId: other.sessionId, other: { device: self.id, sessionId }, state, localTip: ss.tipSeq(events), atSeq: l.moved.atSeq, title: it.title })
}
}
/**
* Continue `sessionId` of `sourceDevice` on `targetDevice`: copy the conversation through its
* last completed turn, then tell the source (now, or queued until it is reachable).
*/
async function continueSession({ sourceDevice, sessionId, targetDevice, workspaceId, title }) {
const src = sourceDevice === self.id
? { ...(await readLocal(sessionId)), fromMirror: false }
: await fetchRemote(sourceDevice, sessionId)
const events = src.events
ss.assertDense(events)
const baseSeq = ss.completedBoundary(events)
if (baseSeq === undefined) throw httpErr(409, 'this conversation has no completed turn yet — wait for the first reply to finish')
const baseCommit = ss.commitId(events, baseSeq)
const prior = sourceDevice === self.id ? ledger.get(sessionId) : null
const name = ss.shortTitle(title || mirrors.meta(sourceDevice, sessionId)?.title)
const imported = await importOn(targetDevice, {
events: events.slice(0, baseSeq + 1), baseSeq, workspaceId, cwd: src.cwd,
origin: { device: sourceDevice, deviceName: deviceName(sourceDevice), sessionId, title: name, fromMirror: !!src.fromMirror },
})
const notice = {
kind: 'moved', to: sourceDevice, sessionId, title: name,
movedTo: { device: targetDevice, sessionId: imported.sessionId },
atSeq: baseSeq, atCommit: baseCommit, at: Date.now(), by: self.id,
}
const sent = await send(notice)
if (sent.delivered) afterMoveAck(notice, sent.result)
notify({ type: 'sessions' })
return {
device: targetDevice, sessionId: imported.sessionId,
url: targetDevice === self.id ? `./#/session/${imported.sessionId}` : `./r/${targetDevice}/#/session/${imported.sessionId}`,
baseSeq, tipSeq: ss.tipSeq(events), partialTurnLeftBehind: ss.tipSeq(events) > baseSeq,
fromMirror: !!src.fromMirror, syncedAt: src.syncedAt || null, offlineReason: src.offlineReason || null,
sourceNotified: sent.delivered, conflict: sent.result?.state && sent.result.state !== 'clean' ? sent.result.state : null,
previouslyMovedTo: prior?.moved || null,
}
}
/** Sessions of offline devices, served from replicas. */
function offlineSessions(device) {
return mirrors.list(device)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.map((m) => ({ sessionId: m.sessionId, title: m.title || null, updatedAt: m.updatedAt || m.syncedAt, cwd: m.cwd, running: false, replica: { syncedAt: m.syncedAt, tipSeq: m.tipSeq } }))
}
const deviceName = (id) => devicesView().find((d) => d.id === id)?.name || mirrors.list(id)[0]?.deviceName || id
// Timers: replicas, divergence watch, and a retry for queued notices.
const tMirror = setInterval(() => { refreshMirrors().catch(() => {}) }, syncCfg.mirrorIntervalMs)
const tWatch = setInterval(() => { watchMoved().catch(() => {}); deliverOutbox().catch(() => {}) }, syncCfg.watchIntervalMs)
const tFirst = setTimeout(() => { refreshMirrors().catch(() => {}) }, 15000)
for (const t of [tMirror, tWatch, tFirst]) t.unref?.()
ctx.effect(() => () => { clearInterval(tMirror); clearInterval(tWatch); clearTimeout(tFirst) }, 'dsh-remote: session sync timers')
return {
servePage, importBegin, importChunk, importCommit, onMoved, onDiverged, continueSession,
offlineSessions, refreshMirrors, deliverOutbox, deviceName,
replicaDevices: () => mirrors.devices(),
lineage: (id) => ledger.get(id),
/** A device (re)appeared: deliver its queued notices right away. */
deviceOnline: () => { setTimeout(() => deliverOutbox().catch(() => {}), 500) },
resolve(sessionId) { ledger.patch(sessionId, { conflict: undefined }); notify({ type: 'sessions' }); return { ok: true } },
state: () => ({ lineage: ledger.all(), outbox: ledger.pending() }),
}
}
// ------------------------------------------------------------------ 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', {})) })))
const live = 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: syncEngine.offlineSessions(devices[i].id), replica: true })
// Offline devices (incl. peers only known from replicas): recent sessions from local replicas,
// so they can still be continued here.
const known = devicesView()
const offline = new Map()
for (const d of known) if (!d.self && !d.revoked && !d.online) offline.set(d.id, d.name)
for (const id of syncEngine.replicaDevices()) if (!known.some((d) => d.id === id && d.online) && id !== self.id && !offline.has(id)) offline.set(id, syncEngine.deviceName(id))
return [...live, ...[...offline].map(([id, name]) => ({ device: id, name, self: false, offline: true, replica: true, items: syncEngine.offlineSessions(id), workspaces: [] }))]
}
// ------------------------------------------------------------------ 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' })
syncEngine.deviceOnline() // a device came back: deliver queued move notices to it
} 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()
syncEngine.deviceOnline() // deliver move notices queued while it was offline, right away
},
}), '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})`)
syncEngine.deviceOnline() // we were offline: deliver queued move notices right away
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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}` }
})
// Continue a conversation on another device (default: this one). Body:
// { sourceDevice, sessionId, targetDevice?, workspaceId?, title? }
route('/dsh-remote/continue-session', ['POST'], async (req) => {
const body = await readJson(req)
const sessionId = String(body.sessionId || '')
if (!sessionId) throw httpErr(400, 'sessionId required')
const sourceDevice = String(body.sourceDevice || self.id)
const targetDevice = body.targetDevice ? resolveDevice(body.targetDevice) : self.id
return syncEngine.continueSession({ sourceDevice, sessionId, targetDevice, workspaceId: body.workspaceId || undefined, title: body.title })
})
// Lineage ledger + queued notices of this device (for the UI and tooling).
route('/dsh-remote/session-sync', ['GET'], async () => syncEngine.state())
// Acknowledge a conflict (both branches stay; the warning goes away).
route('/dsh-remote/session-sync/resolve', ['POST'], async (req) => {
const body = await readJson(req)
return syncEngine.resolve(String(body.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. 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`)
}