dsh-remote 0.1.0: master/slave remote control for DSH
- authenticated WebSocket link (dependency-free RFC6455 + stream mux), pairing codes, per-device keys (hashed), revoke/remove - /r/<device>/ proxy of the full DSH UI (HTTP, Remote WebSocket, SSE) with per-device cookie minting; slave->slave relay through the master - Devices sidebar panel (all devices' sessions, new session on device), Remote devices settings page, finished-session toasts - plugin + config sync master->slave: registry bundles via pluginManager, link: plugins as tarballs, cordis.patch.yml into a managed bundle layer (!!js preserved), allow/deny, opt-in per-secret credential sync - deploy helpers + e2e probes, unit tests
This commit is contained in:
commit
8872e73e76
21 files changed
+2914
No files matched your search
+308
@@ -0,0 +1,308 @@
|
||||
// Plugin + config sync (master → slaves).
|
||||
//
|
||||
// Manifest (built on the master, served over the link):
|
||||
// {
|
||||
// version, builtAt, master,
|
||||
// bundles: [{ name, spec, kind: 'registry'|'local', version, hash }],
|
||||
// patch: [ ...cordis patch entries from the master's cordis.patch.yml selected for sync ],
|
||||
// secrets: [ names of credential refs the master offers for opt-in sync ]
|
||||
// }
|
||||
//
|
||||
// Slave application:
|
||||
// * registry bundles -> ctx.pluginManager.installBundle(`${name}@${version}`) unless already
|
||||
// installed at that version, or excluded by the slave's deny list.
|
||||
// * local (link:) bundles -> fetched as a tarball over the link, unpacked to
|
||||
// <profile>/dsh-remote/plugins/<name>/, installed as `file:` spec.
|
||||
// * patch entries -> written to <profile>/dsh-remote/managed-bundle/cordis.patch.yml,
|
||||
// a bundle the slave enables BEFORE its own cordis.patch.yml, so
|
||||
// anything the slave sets locally keeps precedence.
|
||||
// * secrets -> only the names the slave explicitly opted into are requested;
|
||||
// values travel over the authenticated link and are written with
|
||||
// ctx.credentials.set(ref, value).
|
||||
import crypto from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { profileDir, stateDir } from './store.mjs'
|
||||
|
||||
export const MANAGED_BUNDLE = '@dsh-remote/managed'
|
||||
|
||||
/** Patch entry ids never synced: they are machine-local by nature. */
|
||||
const LOCAL_ONLY_IDS = new Set(['dsh-remote', 'dsh-lanmode', 'host-webserver', 'webserver', 'client-connection'])
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
|
||||
resolve({ code: err ? (err.code ?? 1) : 0, stdout: String(stdout), stderr: String(stderr) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH's own entry-list YAML dialect (JSON schema + `!!js` expressions), loaded from the
|
||||
* running installation so synced patches keep `!!js` nodes. Over the wire a `!!js` node is
|
||||
* `{__jsExpr: "<code>"}`; dumping with this schema writes it back as `!!js`.
|
||||
*/
|
||||
let yamlCache = null
|
||||
async function loadYaml() {
|
||||
if (yamlCache) return yamlCache
|
||||
// Resolve DSH's own copies (not whatever the plugin dir could see): the dsh install's
|
||||
// node_modules, found by following the real path of the running `dsh` bin.
|
||||
const nm = dshNodeModules()
|
||||
if (!nm) throw new Error('cannot locate the DSH installation (node_modules)')
|
||||
const incPkg = JSON.parse(fs.readFileSync(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', 'package.json'), 'utf8'))
|
||||
const incMain = typeof incPkg.exports === 'string' ? incPkg.exports
|
||||
: incPkg.exports?.['.']?.default || incPkg.exports?.['.']?.import || incPkg.exports?.['.'] || incPkg.main || 'lib/index.js'
|
||||
const inc = await import(path.join(nm, '@deepseek-ai', 'cordis-plugin-include', String(incMain)))
|
||||
const yaml = await import(path.join(nm, 'js-yaml', 'dist', 'js-yaml.mjs'))
|
||||
const y = yaml.default || yaml
|
||||
yamlCache = {
|
||||
load: (s) => y.load(s, { schema: inc.entryListSchema }),
|
||||
dump: (v, o = {}) => y.dump(v, { ...o, schema: inc.entryListSchema }),
|
||||
}
|
||||
return yamlCache
|
||||
}
|
||||
|
||||
export { loadYaml }
|
||||
|
||||
function readJson(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
||||
}
|
||||
|
||||
function dirHash(dir) {
|
||||
const h = crypto.createHash('sha256')
|
||||
const walk = (d) => {
|
||||
for (const ent of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (ent.name === 'node_modules' || ent.name === '.git' || ent.name.startsWith('.')) continue
|
||||
const p = path.join(d, ent.name)
|
||||
if (ent.isDirectory()) walk(p)
|
||||
else if (ent.isFile()) { h.update(path.relative(dir, p)); h.update(fs.readFileSync(p)) }
|
||||
}
|
||||
}
|
||||
walk(dir)
|
||||
return h.digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// master side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildManifest({ self, include = {}, offerSecrets = [] }) {
|
||||
const prof = profileDir()
|
||||
const pkg = readJson(path.join(prof, 'package.json')) || {}
|
||||
const deps = pkg.dependencies || {}
|
||||
const enabled = new Set(pkg.dsh?.profile?.bundles || [])
|
||||
const excludeBundles = new Set(include.excludeBundles || [])
|
||||
const bundles = []
|
||||
for (const [name, spec] of Object.entries(deps)) {
|
||||
if (!enabled.has(name) || excludeBundles.has(name) || name === '@local/dsh-remote' || name === MANAGED_BUNDLE) continue
|
||||
const s = String(spec)
|
||||
if (s.startsWith('link:') || s.startsWith('file:')) {
|
||||
const dir = path.resolve(prof, s.replace(/^(link|file):/, ''))
|
||||
const m = readJson(path.join(dir, 'package.json'))
|
||||
if (!m) continue
|
||||
bundles.push({ name, kind: 'local', version: m.version || '0.0.0', hash: dirHash(dir), dir })
|
||||
} else {
|
||||
const installed = readJson(path.join(prof, 'node_modules', name, 'package.json'))
|
||||
bundles.push({ name, kind: 'registry', spec: s, version: installed?.version || s.replace(/^[\^~]/, '') })
|
||||
}
|
||||
}
|
||||
|
||||
// Patch entries: everything in the master's cordis.patch.yml except machine-local ids.
|
||||
let patch = []
|
||||
let patchError = null
|
||||
try {
|
||||
const yaml = await loadYaml()
|
||||
const doc = yaml.load(fs.readFileSync(path.join(prof, 'cordis.patch.yml'), 'utf8')) || []
|
||||
const excludeIds = new Set([...(include.excludePatchIds || []), ...LOCAL_ONLY_IDS])
|
||||
patch = (Array.isArray(doc) ? doc : []).filter((e) => e && typeof e === 'object' && !excludeIds.has(e.id))
|
||||
} catch (e) {
|
||||
patch = []
|
||||
patchError = String(e?.message ?? e)
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
builtAt: Date.now(),
|
||||
master: { id: self.id, name: self.name },
|
||||
bundles: bundles.map(({ dir, ...b }) => b),
|
||||
patch,
|
||||
patchError,
|
||||
secrets: offerSecrets,
|
||||
_dirs: Object.fromEntries(bundles.filter((b) => b.dir).map((b) => [b.name, b.dir])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Pack a local plugin dir into a tgz Buffer (excludes .git, node_modules). */
|
||||
export async function packLocal(dir) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-remote-pack-'))
|
||||
const out = path.join(tmp, 'pkg.tgz')
|
||||
const r = await run('tar', ['--exclude=.git', '--exclude=node_modules', '--exclude=*.bak', '-czf', out, '-C', dir, '.'])
|
||||
if (r.code !== 0) throw new Error(`tar failed: ${r.stderr.trim()}`)
|
||||
const buf = fs.readFileSync(out)
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
return buf
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// slave side
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function syncDir() { return path.join(stateDir(), 'sync') }
|
||||
|
||||
export function managedBundleDir() { return path.join(stateDir(), 'managed-bundle') }
|
||||
|
||||
/** Write the managed bundle (package.json + patch) that carries synced config. */
|
||||
export async function writeManagedBundle(patch, insertedLocalPlugins = []) {
|
||||
const dir = managedBundleDir()
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
const yaml = await loadYaml()
|
||||
const header = '# Managed by dsh-remote: config synced from the master. Do not edit;\n'
|
||||
+ '# your own profile cordis.patch.yml is applied after this layer and wins.\n'
|
||||
const body = patch.length ? yaml.dump(patch, { lineWidth: 200, noRefs: true }) : '[]\n'
|
||||
fs.writeFileSync(path.join(dir, 'cordis.patch.yml'), header + body)
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: MANAGED_BUNDLE,
|
||||
version: `0.0.${Date.now()}`,
|
||||
private: true,
|
||||
description: 'dsh-remote managed layer: plugins/config synced from the master',
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
dsh_remote: { localPlugins: insertedLocalPlugins },
|
||||
}, null, 2))
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/refresh <profile>/node_modules/<name> -> dir. DSH resolves bundle packages
|
||||
* from the profile's node_modules; a `link:` dependency alone is only materialised by a
|
||||
* pnpm install, so make the link ourselves (idempotent, no network).
|
||||
*/
|
||||
export function linkIntoNodeModules(name, dir) {
|
||||
const link = path.join(profileDir(), 'node_modules', ...name.split('/'))
|
||||
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||
try {
|
||||
if (fs.readlinkSync(link) === dir) return false
|
||||
} catch { /* missing or not a link */ }
|
||||
fs.rmSync(link, { recursive: true, force: true })
|
||||
fs.symlinkSync(dir, link)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Ensure the managed bundle is linked and listed last, i.e. right before the user's own patch. */
|
||||
export function ensureManagedBundleOrder() {
|
||||
const prof = profileDir()
|
||||
const file = path.join(prof, 'package.json')
|
||||
const pkg = readJson(file)
|
||||
if (!pkg) return false
|
||||
pkg.dependencies ||= {}
|
||||
const spec = `link:${managedBundleDir()}`
|
||||
let changed = linkIntoNodeModules(MANAGED_BUNDLE, managedBundleDir())
|
||||
let pkgChanged = false
|
||||
if (pkg.dependencies[MANAGED_BUNDLE] !== spec) { pkg.dependencies[MANAGED_BUNDLE] = spec; pkgChanged = true }
|
||||
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||
const idx = bundles.indexOf(MANAGED_BUNDLE)
|
||||
if (idx !== bundles.length - 1) {
|
||||
if (idx !== -1) bundles.splice(idx, 1)
|
||||
bundles.push(MANAGED_BUNDLE)
|
||||
pkgChanged = true
|
||||
}
|
||||
if (pkgChanged) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||
return changed || pkgChanged
|
||||
}
|
||||
|
||||
export function unpackLocal(name, version, tgz) {
|
||||
const safe = name.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
||||
const dir = path.join(syncDir(), 'plugins', safe)
|
||||
const tmp = `${dir}.new`
|
||||
fs.rmSync(tmp, { recursive: true, force: true })
|
||||
fs.mkdirSync(tmp, { recursive: true })
|
||||
const tgzFile = path.join(syncDir(), `${safe}.tgz`)
|
||||
fs.writeFileSync(tgzFile, tgz)
|
||||
return run('tar', ['-xzf', tgzFile, '-C', tmp]).then((r) => {
|
||||
fs.rmSync(tgzFile, { force: true })
|
||||
if (r.code !== 0) throw new Error(`untar ${name}: ${r.stderr.trim()}`)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
fs.renameSync(tmp, dir)
|
||||
linkDshDeps(dir)
|
||||
return dir
|
||||
})
|
||||
}
|
||||
|
||||
/** DSH installation's own node_modules (where @deepseek-ai/* packages live). */
|
||||
export function dshNodeModules() {
|
||||
const bin = process.argv[1] || ''
|
||||
let real = bin
|
||||
try { real = fs.realpathSync(bin) } catch { /* keep argv path */ }
|
||||
const cand = [
|
||||
path.resolve(path.dirname(real), '..', 'node_modules'),
|
||||
path.resolve(path.dirname(bin), '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'),
|
||||
]
|
||||
for (const c of cand) if (fs.existsSync(path.join(c, '@deepseek-ai'))) return c
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Synced local plugins import `@deepseek-ai/*` modules that the master resolved through
|
||||
* a node_modules symlink into its DSH install. Recreate those links against this
|
||||
* machine's DSH so the plugin loads unchanged.
|
||||
*/
|
||||
export function linkDshDeps(pluginDir) {
|
||||
const nm = dshNodeModules()
|
||||
if (!nm) return []
|
||||
const wanted = new Set()
|
||||
const scan = (d, depth = 0) => {
|
||||
if (depth > 3) return
|
||||
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue
|
||||
const p = path.join(d, ent.name)
|
||||
if (ent.isDirectory()) scan(p, depth + 1)
|
||||
else if (/\.(m?js|cjs)$/.test(ent.name)) {
|
||||
const src = fs.readFileSync(p, 'utf8')
|
||||
for (const m of src.matchAll(/(?:from\s+|import\s*\(\s*|require\(\s*)['"](@deepseek-ai\/[a-z0-9-]+)/g)) wanted.add(m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
scan(pluginDir)
|
||||
const linked = []
|
||||
for (const mod of wanted) {
|
||||
const target = path.join(nm, mod)
|
||||
if (!fs.existsSync(target)) continue
|
||||
const link = path.join(pluginDir, 'node_modules', mod)
|
||||
fs.mkdirSync(path.dirname(link), { recursive: true })
|
||||
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
|
||||
fs.symlinkSync(target, link)
|
||||
linked.push(mod)
|
||||
}
|
||||
return linked
|
||||
}
|
||||
|
||||
/** Decide whether a bundle passes the slave's allow/deny lists. */
|
||||
export function allowed(name, { allow = [], deny = [] } = {}) {
|
||||
const match = (pat) => pat === name || (pat.endsWith('*') && name.startsWith(pat.slice(0, -1)))
|
||||
if (deny.some(match)) return false
|
||||
if (allow.length && !allow.some(match)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Link-spec install of a local plugin dir into the profile (package.json + bundles list). */
|
||||
export function linkLocalBundle(name, dir) {
|
||||
const prof = profileDir()
|
||||
const file = path.join(prof, 'package.json')
|
||||
const pkg = readJson(file)
|
||||
if (!pkg) throw new Error('profile package.json unreadable')
|
||||
pkg.dependencies ||= {}
|
||||
const spec = `link:${dir}`
|
||||
let changed = false
|
||||
if (pkg.dependencies[name] !== spec) { pkg.dependencies[name] = spec; changed = true }
|
||||
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
|
||||
if (!bundles.includes(name)) {
|
||||
// Synced plugins go before the managed layer so the master's config for them applies.
|
||||
const at = bundles.indexOf(MANAGED_BUNDLE)
|
||||
if (at === -1) bundles.push(name)
|
||||
else bundles.splice(at, 0, name)
|
||||
changed = true
|
||||
}
|
||||
if (changed) fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n')
|
||||
return linkIntoNodeModules(name, dir) || changed
|
||||
}
|
||||
Reference in new issue
Block a user