// @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// (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//revoke → master: revoke a device key // POST /dsh-remote/devices//remove → master: delete a device // POST /dsh-remote/devices//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//... → proxy to that device's DSH // Upgrade routes: // /dsh-remote/link → slave → master link (device id + key headers) // /r//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 }) // The inherited title may carry the source machine's name ("ai-pc: …"); keep the clean one. const clean = origin?.title && stripDevicePrefix(origin.title, [origin.device, origin.deviceName]) if (clean) { try { await sc().rename({ sessionId, title: clean }) } catch (e) { log.debug?.(`dsh-remote: rename imported session: ${e.message}`) } } 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(`dsh-remote sign-in

Signed in to ${esc(self.name)}

${!ok ? `

The device that opened this window (${esc(origin || 'unknown')}) is not on this machine's trusted list, so nothing will be sent.

` : send.length ? `

This browser is signed in through the auth proxy. Send that session to the DSH at ${esc(origin)} so it can reach this master?

` : `

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 Settings → Remote devices → Auth proxy → Session cookie.

`} `) }, }), '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// 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 () 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(`offline

Device ${p.device} is offline

Its DSH is not linked right now. Back

`) } 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//). 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`) }