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:
lyuksovannyy committed 2026-10-11 08:23:19 +00:00
commit 8872e73e76
21 files changed
+2914

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=DeepSeek Harness (dsh web) for this user on 127.0.0.1:3081
After=network-online.target
[Service]
Type=simple
WorkingDirectory=%h
Environment=NODE_ENV=production
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.local/bin/dsh web --host 127.0.0.1 --port 3081 --no-open
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Tiny admin CLI for dsh-remote that talks to the LOCAL DSH over loopback, authenticating
// with DSH's own browser-session secret from $DSH_HOME/.credentials.yaml (owner-only file).
//
// node dshr-cli.mjs GET state
// node dshr-cli.mjs POST pairing '{}'
// node dshr-cli.mjs POST pair '{"masterUrl":"http://192.168.0.111:3080","code":"ABCD-..."}'
// node dshr-cli.mjs GET /r/<device>/ (raw path, prints status + first bytes)
//
// Env: DSH_PORT (default 3081), DSH_HOME (default ~/.dsh)
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { mintCookie } from '../lib/auth-cookie.mjs'
const [method = 'GET', target = 'state', body] = process.argv.slice(2)
const port = process.env.DSH_PORT || '3081'
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const creds = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8')
const m = creds.match(/client-connection\/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)
if (!m) { console.error('browser-session secret not found'); process.exit(2) }
const authority = `127.0.0.1:${port}`
const cookie = mintCookie(authority, m[1], 1)
const url = target.startsWith('/') ? `http://${authority}${target}` : `http://${authority}/dsh-remote/${target}`
const res = await fetch(url, {
method,
headers: { cookie, ...(body ? { 'content-type': 'application/json' } : {}) },
body: body || undefined,
redirect: 'manual',
})
const text = await res.text()
if (target.startsWith('/')) {
console.log(res.status, res.headers.get('content-type') || '', res.headers.get('location') || '')
console.log(text.slice(0, Number(process.env.BYTES || 400)))
} else {
console.log(res.status)
try { console.log(JSON.stringify(JSON.parse(text), null, 2)) } catch { console.log(text) }
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
// E2E: listen to the local /dsh-remote/stream (SSE) for N seconds and print events.
// node deploy/e2e-events.mjs [seconds] [port]
import fs from 'node:fs'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import { mintCookie } from '../lib/auth-cookie.mjs'
const secs = Number(process.argv[2] || 30)
const port = process.argv[3] || process.env.DSH_PORT || '3081'
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const authority = `127.0.0.1:${port}`
const req = http.get({ host: '127.0.0.1', port, path: '/dsh-remote/stream', headers: { cookie: mintCookie(authority, secret, 1), host: authority } }, (res) => {
console.log('stream', res.statusCode)
res.setEncoding('utf8')
res.on('data', (d) => { for (const line of d.split('\n')) if (line.startsWith('data:')) console.log(new Date().toISOString().slice(11, 19), line.slice(5).trim()) })
})
setTimeout(() => { req.destroy(); process.exit(0) }, secs * 1000)
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Load one URL N times in headless Chromium and count uncaught exceptions per load.
// node deploy/e2e-exc.mjs <chrome> <url> <cookieAuthority> [loads] [settleMs]
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
const [chrome, url, authority, loads = '3', settle = '8000'] = process.argv.slice(2)
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-exc-'))
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
const wsUrl = await new Promise((resolve, reject) => {
let buf = ''
proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) })
setTimeout(() => reject(new Error('chrome did not start')), 15000)
})
const page = (await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()).find((t) => t.type === 'page')
const ws = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((r) => ws.addEventListener('open', r))
let id = 0
const pending = new Map()
let exc = []
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
if (m.method === 'Runtime.exceptionThrown') exc.push((m.params.exceptionDetails?.exception?.description || '').split('\n')[0].slice(0, 120))
})
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
await send('Page.enable'); await send('Runtime.enable'); await send('Network.enable')
const host = authority.split(':')[0]
await send('Network.setCookie', { name: cookieName(authority), value: mintCookie(authority, secret, 1).split('=').slice(1).join('='), domain: host, path: '/', httpOnly: true })
for (let i = 0; i < Number(loads); i++) {
exc = []
await send('Page.navigate', { url: `${url}${url.includes('?') ? '&' : '?'}n=${i}` })
await new Promise((r) => setTimeout(r, Number(settle)))
console.log(`load ${i}: ${exc.length} exceptions ${exc.join(' || ')}`)
}
ws.close(); proc.kill('SIGKILL')
try { fs.rmSync(prof, { recursive: true, force: true }) } catch { /* chrome may still hold files */ }
process.exit(0)
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
// E2E UI check with headless Chromium over the DevTools protocol (no playwright dep).
// node deploy/e2e-ui.mjs <chrome-binary> <out-dir> [port]
// Loads the local DSH (cookie minted from the browser-session secret), opens the Devices
// panel, screenshots it, then opens /r/<first remote device>/ and screenshots that.
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { cookieName, mintCookie } from '../lib/auth-cookie.mjs'
const [chrome, outDir, port = '3081'] = process.argv.slice(2)
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const authority = `127.0.0.1:${port}`
const cookieVal = mintCookie(authority, secret, 1).split('=').slice(1).join('=')
fs.mkdirSync(outDir, { recursive: true })
const prof = fs.mkdtempSync(path.join(os.tmpdir(), 'dshr-chrome-'))
const proc = spawn(chrome, ['--headless=new', '--no-sandbox', '--disable-gpu', `--user-data-dir=${prof}`, '--remote-debugging-port=0', '--window-size=1500,950', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] })
const wsUrl = await new Promise((resolve, reject) => {
let buf = ''
proc.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) resolve(m[1]) })
setTimeout(() => reject(new Error('chrome did not start')), 15000)
})
const list = await (await fetch(wsUrl.replace(/^ws/, 'http').replace(/\/devtools\/browser\/.*/, '/json/list'))).json()
const page = list.find((t) => t.type === 'page')
const ws = new WebSocket(page.webSocketDebuggerUrl)
await new Promise((r) => ws.addEventListener('open', r))
let id = 0
const pending = new Map()
const logs = []
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
if (m.method === 'Runtime.consoleAPICalled') logs.push(`${m.params.type}: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`.slice(0, 300))
if (m.method === 'Runtime.exceptionThrown') logs.push(`EXC: ${m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text}`.slice(0, Number(process.env.EXC_LEN || 400)))
})
const send = (method, params = {}) => new Promise((resolve) => { const i = ++id; pending.set(i, resolve); ws.send(JSON.stringify({ id: i, method, params })) })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value
const shot = async (name) => {
const r = await send('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(path.join(outDir, name), Buffer.from(r.result.data, 'base64'))
console.log('screenshot', path.join(outDir, name))
}
await send('Page.enable'); await send('Runtime.enable')
await send('Network.enable')
await send('Network.setCookie', { name: cookieName(authority), value: cookieVal, domain: '127.0.0.1', path: '/', httpOnly: true })
await send('Page.navigate', { url: `http://${authority}/` })
await sleep(9000)
// Click the Devices panel entry (its icon carries data-dsh-panel-entry).
const clicked = await evalJs(`(() => { const svg = document.querySelector('[data-dsh-panel-entry="dsh-remote-devices"]'); if (!svg) return 'no-entry'; (svg.closest('button,a,[role=button]') || svg).click(); return 'clicked' })()`)
console.log('devices entry:', clicked)
await sleep(3500)
console.log('panel text:', String(await evalJs(`(document.querySelector('[data-dsh-plugin="dsh-remote"]')?.innerText || 'NO PANEL').slice(0, 600)`)).replace(/\n+/g, ' | '))
await shot('devices-panel.png')
const localErrors = logs.filter((l) => /EXC/.test(l)).length
console.log('local page exceptions:', localErrors, logs.filter((l) => /EXC/.test(l)).map((l) => l.slice(0, 90)).join(' || '))
logs.length = 0
const remote = process.env.REMOTE_DEVICE || 'ai-pc'
const remoteSession = process.env.REMOTE_SESSION || ''
await send('Page.navigate', { url: `http://${authority}/r/${remote}/${remoteSession ? `#/session/${remoteSession}` : ''}` })
await sleep(remoteSession ? 14000 : 10000)
console.log('remote title/banner:', await evalJs(`document.title + ' :: ' + (document.querySelector('a[title*="another device"]')?.innerText || 'no banner') + ' :: hash=' + location.hash`))
if (remoteSession) {
console.log('remote session view:', String(await evalJs(`document.body.innerText.slice(0, 500)`)).replace(/\n+/g, ' | '))
}
await shot('remote-ui.png')
console.log('console:', logs.filter((l) => /error|EXC|warn/i.test(l)).slice(0, 12).join('\n '))
ws.close(); proc.kill('SIGKILL'); fs.rmSync(prof, { recursive: true, force: true })
process.exit(0)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
// E2E probe: open the DSH Remote stream WebSocket of a device THROUGH the local
// /r/<device>/api/remote.mux proxy and check that the remote DSH answers.
// node deploy/e2e-ws.mjs <device> [port]
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { connect } from '../lib/ws.mjs'
import { mintCookie } from '../lib/auth-cookie.mjs'
const device = process.argv[2] || 'ai-pc'
const port = process.argv[3] || process.env.DSH_PORT || '3081'
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const secret = fs.readFileSync(path.join(home, '.credentials.yaml'), 'utf8').match(/browser-session:[\s\S]*?secret:\s*([A-Za-z0-9_-]{43})/)[1]
const authority = `127.0.0.1:${port}`
const cookie = mintCookie(authority, secret, 1)
const t0 = Date.now()
const ws = await connect(`ws://${authority}/r/${device}/api/remote.mux`, { headers: { cookie, origin: `http://${authority}` } })
console.log(`ws open through /r/${device}/ in ${Date.now() - t0} ms`)
const got = await new Promise((resolve) => {
const timer = setTimeout(() => resolve('(no frame within 4s: connection stays open, server waits for client frames)'), 4000)
ws.on('message', (m) => { clearTimeout(timer); resolve(`first frame: ${String(m).slice(0, 160)}`) })
ws.on('close', (c, r) => { clearTimeout(timer); resolve(`closed ${c} ${r}`) })
})
console.log(got)
console.log(`still open: ${ws.open}`)
ws.close()
process.exit(0)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Register a dsh-remote checkout as a `link:` bundle in the current user's DSH web profile
// (no pnpm/network needed) and optionally set its config.
//
// node deploy/install-local.mjs [--role slave|master|auto] [--name "My PC"] [--profile web]
//
// Effects (idempotent):
// <profile>/package.json dependencies["@local/dsh-remote"] = "link:<this dir>"
// dsh.profile.bundles += "@local/dsh-remote"
// <profile>/node_modules/@local/dsh-remote -> <this dir>
// <profile>/cordis.patch.yml appends a `- id: dsh-remote` config block if missing
// Restart `dsh web` afterwards (host code is loaded at boot).
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const args = process.argv.slice(2)
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 ? args[i + 1] : d }
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
const prof = path.join(home, 'profiles', opt('profile', 'web'))
const NAME = '@local/dsh-remote'
const pkgFile = path.join(prof, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'))
pkg.dependencies ||= {}
pkg.dependencies[NAME] = `link:${pluginDir}`
const bundles = (((pkg.dsh ||= {}).profile ||= {}).bundles ||= [])
if (!bundles.includes(NAME)) bundles.push(NAME)
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n')
const linkDir = path.join(prof, 'node_modules', '@local')
fs.mkdirSync(linkDir, { recursive: true })
const link = path.join(linkDir, 'dsh-remote')
try { fs.rmSync(link, { recursive: true, force: true }) } catch { /* ignore */ }
fs.symlinkSync(pluginDir, link)
const patchFile = path.join(prof, 'cordis.patch.yml')
let patch = ''
try { patch = fs.readFileSync(patchFile, 'utf8') } catch { patch = '' }
if (!/^- id: dsh-remote\b/m.test(patch)) {
const role = opt('role', 'auto')
const name = opt('name', '')
const block = [
'# dsh-remote (installed by deploy/install-local.mjs)',
'- id: dsh-remote',
' config:',
` role: ${role}`,
` deviceName: ${JSON.stringify(name)}`,
' sync:',
' auto: true',
' allow: []',
' deny: []',
' secrets: []',
'',
].join('\n')
// An empty/comment-only patch file fails DSH boot, so only append real entries.
fs.writeFileSync(patchFile, (patch.trim() && !/^\s*\[\]\s*$/m.test(patch.trim()) ? patch.replace(/\n*$/, '\n') : '') + block)
}
console.log(`dsh-remote linked into ${prof}; restart dsh web to load it.`)
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Dev helper: copy this checkout to a slave's ~/.dsh/plugins/dsh-remote and restart its dsh.
# deploy/push-slave.sh agent@192.168.0.110
set -euo pipefail
HOST="${1:?usage: push-slave.sh user@host}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
O=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
tar --exclude=.git --exclude=node_modules -C "$DIR" -czf - . | timeout 20 ssh "${O[@]}" "$HOST" \
'mkdir -p ~/.dsh/plugins/dsh-remote && tar -xzf - -C ~/.dsh/plugins/dsh-remote'
timeout 45 ssh "${O[@]}" "$HOST" 'export PATH=$HOME/.local/bin:$PATH XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user restart dsh
for i in $(seq 1 40); do curl -s -m 2 -o /dev/null http://127.0.0.1:3081/ && break; sleep 0.5; done
echo "slave dsh: $(systemctl --user is-active dsh)"'