netxops/scripts/_desktop-cdp.mjs
oliver 217e1f781a Add Desktop CDP helpers for local plugin enablement.
Provide a minimal remote-debugging client and a one-shot enable script for linked Desktop plugins.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 22:46:38 +08:00

306 lines
11 KiB
JavaScript

/**
* Minimal CDP client for DeepSeek Harness Desktop (--remote-debugging-port).
* Usage:
* node _desktop-cdp.mjs eval 'document.title'
* node _desktop-cdp.mjs install github:hansjone/netxops
*/
import net from 'node:net'
import http from 'node:http'
import crypto from 'node:crypto'
function httpGetJson(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let data = ''
res.on('data', (c) => { data += c })
res.on('end', () => {
try { resolve(JSON.parse(data)) } catch (e) { reject(e) }
})
}).on('error', reject)
})
}
class Cdp {
constructor(wsUrl) {
this.wsUrl = wsUrl
this.id = 0
this.pending = new Map()
this.buf = Buffer.alloc(0)
this.socket = null
}
async connect() {
const u = new URL(this.wsUrl)
const key = crypto.randomBytes(16).toString('base64')
await new Promise((resolve, reject) => {
const socket = net.connect({ host: u.hostname, port: Number(u.port) }, () => {
socket.write(
`GET ${u.pathname}${u.search} HTTP/1.1\r\n` +
`Host: ${u.host}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`,
)
})
socket.on('error', reject)
socket.once('data', (chunk) => {
if (!chunk.toString().includes('101')) {
reject(new Error(`WS upgrade failed: ${chunk.toString('utf8').slice(0, 200)}`))
return
}
this.socket = socket
const rest = chunk.indexOf('\r\n\r\n')
if (rest >= 0 && rest + 4 < chunk.length) this.buf = chunk.subarray(rest + 4)
socket.on('data', (c) => this.onData(c))
resolve()
})
})
}
onData(chunk) {
this.buf = Buffer.concat([this.buf, chunk])
while (true) {
if (this.buf.length < 2) return
const b0 = this.buf[0]
const b1 = this.buf[1]
const masked = (b1 & 0x80) !== 0
let len = b1 & 0x7f
let off = 2
if (len === 126) {
if (this.buf.length < 4) return
len = this.buf.readUInt16BE(2)
off = 4
} else if (len === 127) {
if (this.buf.length < 10) return
len = Number(this.buf.readBigUInt64BE(2))
off = 10
}
const maskOff = masked ? off : -1
if (masked) off += 4
if (this.buf.length < off + len) return
let payload = this.buf.subarray(off, off + len)
if (masked) {
const mask = this.buf.subarray(maskOff, maskOff + 4)
payload = Buffer.from(payload)
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]
}
this.buf = this.buf.subarray(off + len)
const opcode = b0 & 0x0f
if (opcode === 0x1 || opcode === 0x2) {
try {
const msg = JSON.parse(payload.toString('utf8'))
if (msg.id != null && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id)
this.pending.delete(msg.id)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
} catch { /* ignore */ }
} else if (opcode === 0x8) {
this.socket?.end()
} else if (opcode === 0x9) {
this.sendFrame(0xA, payload)
}
}
}
sendFrame(opcode, payload) {
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
const mask = crypto.randomBytes(4)
const masked = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i++) masked[i] = data[i] ^ mask[i % 4]
let header
if (data.length < 126) {
header = Buffer.alloc(6)
header[0] = 0x80 | opcode
header[1] = 0x80 | data.length
mask.copy(header, 2)
} else if (data.length < 65536) {
header = Buffer.alloc(8)
header[0] = 0x80 | opcode
header[1] = 0x80 | 126
header.writeUInt16BE(data.length, 2)
mask.copy(header, 4)
} else {
header = Buffer.alloc(14)
header[0] = 0x80 | opcode
header[1] = 0x80 | 127
header.writeBigUInt64BE(BigInt(data.length), 2)
mask.copy(header, 10)
}
this.socket.write(Buffer.concat([header, masked]))
}
call(method, params = {}, timeoutMs = 30_000) {
const id = ++this.id
const payload = JSON.stringify({ id, method, params })
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.sendFrame(0x1, payload)
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id)
reject(new Error(`CDP timeout: ${method}`))
}
}, timeoutMs)
})
}
async evaluate(expression, timeoutMs = 30_000) {
const result = await this.call('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture: true,
}, timeoutMs)
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails))
}
return result.result?.value
}
close() {
this.socket?.end()
}
}
async function connectPage() {
const tabs = await httpGetJson('http://127.0.0.1:9222/json')
const page = tabs.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
if (!page) throw new Error('no CDP page target')
const cdp = new Cdp(page.webSocketDebuggerUrl)
await cdp.connect()
await cdp.call('Runtime.enable')
return cdp
}
async function probe(cdp) {
return cdp.evaluate(`(() => {
const out = {
title: document.title,
url: location.href,
buttons: [...document.querySelectorAll('button,[role="button"],a')].slice(0, 80).map(el => ({
text: (el.innerText || el.textContent || '').trim().slice(0, 80),
aria: el.getAttribute('aria-label') || '',
testid: el.getAttribute('data-testid') || '',
})).filter(x => x.text || x.aria),
inputs: [...document.querySelectorAll('input,textarea')].slice(0, 40).map(el => ({
type: el.type, placeholder: el.placeholder || '', name: el.name || '', aria: el.getAttribute('aria-label') || '',
})),
hasPluginManager: typeof globalThis !== 'undefined',
}
// Try to find cordis / remote faces on window
const keys = Object.keys(window).filter(k => /plugin|dsh|cordis|remote/i.test(k)).slice(0, 40)
out.windowKeys = keys
return out
})()`)
}
async function clickText(cdp, text) {
return cdp.evaluate(`(() => {
const want = ${JSON.stringify(text)}
const els = [...document.querySelectorAll('button,[role="button"],a,div,span,li')]
const el = els.find(e => {
const t = (e.innerText || e.textContent || '').trim()
return t === want || t.includes(want)
})
if (!el) return { ok: false, reason: 'not-found' }
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
el.click?.()
return { ok: true, text: (el.innerText || '').trim().slice(0, 80) }
})()`)
}
async function ensurePluginsPage(cdp) {
await cdp.evaluate(`(() => {
const el = [...document.querySelectorAll('button')].find(e => e.getAttribute('aria-label') === '插件')
if (el) el.click()
return true
})()`)
await new Promise((r) => setTimeout(r, 800))
}
async function installSpec(cdp, spec) {
await ensurePluginsPage(cdp)
const expression = `(async () => {
const spec = ${JSON.stringify(spec)}
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const open = () => {
const btn = [...document.querySelectorAll('button')].find(e => (e.innerText || '').trim() === '添加插件')
if (btn) btn.click()
}
let input = document.querySelector('input[aria-label="包名或地址"]')
if (!input) { open(); await sleep(600); input = document.querySelector('input[aria-label="包名或地址"]') }
if (!input) return { ok: false, stage: 'no-input', spec }
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
setter.call(input, spec)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
await sleep(250)
const installBtn = [...document.querySelectorAll('button')].find(e => (e.innerText || '').trim() === '安装')
if (!installBtn) return { ok: false, stage: 'no-install-btn', value: input.value, spec }
if (installBtn.disabled) return { ok: false, stage: 'disabled', value: input.value, spec }
installBtn.click()
for (let i = 0; i < 240; i++) {
await sleep(1000)
const still = document.querySelector('input[aria-label="包名或地址"]')
const dlg = document.querySelector('[role="dialog"]')
const text = dlg?.innerText || ''
if (!still) return { ok: true, stage: 'dialog-closed', spec, waitedSec: i + 1 }
// Only inspect dialog body; avoid page-wide false positives.
if (/这个包没有|无法作为插件安装|安装失败|无法安装:|ENOENT|ERR_PNPM|ERR_PACKAGE|404 Not Found|declares no dsh\.bundle/i.test(text)) {
return { ok: false, stage: 'error-text', text: text.slice(0, 800), spec, waitedSec: i + 1 }
}
if (/正在检查|正在安装|正在下载|Installing|Checking/i.test(text)) continue
}
return { ok: false, stage: 'timeout', text: (document.querySelector('[role="dialog"]')?.innerText || '').slice(0, 800), spec }
})()`
return cdp.evaluate(expression, 260_000)
}
async function main() {
const [cmd, ...args] = process.argv.slice(2)
const cdp = await connectPage()
try {
if (cmd === 'eval') {
const v = await cdp.evaluate(args.join(' '), 60_000)
console.log(JSON.stringify(v, null, 2))
return
}
if (cmd === 'probe') {
console.log(JSON.stringify(await probe(cdp), null, 2))
return
}
if (cmd === 'click') {
console.log(JSON.stringify(await clickText(cdp, args.join(' ')), null, 2))
return
}
if (cmd === 'install') {
const spec = args.join(' ')
if (!spec) throw new Error('install requires a package spec')
console.log(JSON.stringify(await installSpec(cdp, spec), null, 2))
return
}
if (cmd === 'list-installed') {
await ensurePluginsPage(cdp)
const v = await cdp.evaluate(`(() => {
const rows = [...document.querySelectorAll('button,[role="button"]')]
.map(el => ({ text: (el.innerText || '').trim().slice(0, 80), aria: el.getAttribute('aria-label') || '' }))
.filter(x => /查看 |启用 |禁用 /.test(x.aria) || /已安装|Installed/.test(x.text))
const body = document.body.innerText
const section = body.includes('已安装') ? body.split('已安装')[1]?.slice(0, 1500) : body.slice(0, 1500)
return { rows: rows.slice(0, 80), section }
})()`)
console.log(JSON.stringify(v, null, 2))
return
}
console.error('usage: eval|probe|click|install|list-installed')
process.exit(2)
} finally {
cdp.close()
}
}
await main()