vcorp-chrome 1.8.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ // 本机桌面执行器 —— 把 desktop 工具的一次调用变成一次 agent-desktop 子进程调用。
2
+ //
3
+ // agent-desktop 是无状态 CLI:每条命令一个进程、输出一个 JSON 信封(见它的 docs/json-output.md)。
4
+ // 这一层做四件事:找到二进制;每条命令先过 extension/desktop-policy.js;
5
+ // 记住每份快照属于哪个应用、哪个窗口(之后的 ref 命令对着它判);把信封渲染成给模型看的短文本。
6
+ import { spawn } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import {
12
+ checkDesktop, checkObserved, recordOf, filterWindows, checkSteps, snapshotIdOf,
13
+ } from '../../extension/desktop-policy.js';
14
+ import { renderEnvelope } from '../../extension/desktop-render.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ // npm 包 agent-desktop 自带各平台的二进制(bin/agent-desktop-<平台>-<架构>[.exe])。
19
+ // 直接调平台二进制,不经它的 JS 启动器:少起一个 node 进程,每条命令省一两百毫秒。
20
+ export function findBinary() {
21
+ if (process.env.AGENT_DESKTOP_BIN) return process.env.AGENT_DESKTOP_BIN;
22
+ const name = `agent-desktop-${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}${process.platform === 'win32' ? '.exe' : ''}`;
23
+ try {
24
+ const dir = path.dirname(require.resolve('agent-desktop/package.json'));
25
+ const p = path.join(dir, 'bin', name);
26
+ if (fs.existsSync(p)) return p;
27
+ } catch { /* 没装这个依赖,退到 PATH */ }
28
+ return process.platform === 'win32' ? 'agent-desktop.exe' : 'agent-desktop';
29
+ }
30
+
31
+ const OUT_MAX = 8 * 1024 * 1024;
32
+
33
+ // 跑一次,返回信封。不经 shell:参数数组原样交给进程,没有拼接、没有转义问题。
34
+ export function runAgentDesktop(argv, { bin = findBinary(), timeoutMs = 30000 } = {}) {
35
+ return new Promise((resolve) => {
36
+ let out = '';
37
+ let errText = '';
38
+ let done = false;
39
+ const finish = (env) => { if (!done) { done = true; clearTimeout(timer); resolve(env); } };
40
+ let child;
41
+ try {
42
+ child = spawn(bin, argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
43
+ } catch (e) {
44
+ finish(fail('DESKTOP_UNAVAILABLE', `起不来 agent-desktop(${bin}):${e.message}`));
45
+ return;
46
+ }
47
+ const timer = setTimeout(() => {
48
+ child.kill();
49
+ finish(fail('TIMEOUT', `agent-desktop ${Math.round(timeoutMs / 1000)}s 没有回音,结果不明。先 snapshot 看现状,别直接重做。`, 'unknown'));
50
+ }, timeoutMs);
51
+ child.stdout.on('data', (b) => { if (out.length < OUT_MAX) out += b; });
52
+ child.stderr.on('data', (b) => { if (errText.length < 4000) errText += b; });
53
+ child.on('error', (e) => finish(fail('DESKTOP_UNAVAILABLE',
54
+ e.code === 'ENOENT' ? '这台电脑上没有 agent-desktop。运行 npx vcorp-chrome doctor 看怎么装。' : e.message)));
55
+ child.on('close', () => {
56
+ const line = out.trim().split('\n').filter(Boolean).at(-1) || '';
57
+ try { finish(JSON.parse(line)); } catch {
58
+ finish(fail('INTERNAL', `agent-desktop 的输出不是 JSON:${(line || errText).slice(0, 300)}`, 'unknown'));
59
+ }
60
+ });
61
+ });
62
+ }
63
+
64
+ function fail(code, message, delivery = 'not_delivered') {
65
+ return { ok: false, error: { code, message, disposition: { delivery, retry: delivery === 'not_delivered' ? 'safe' : 'unknown' } } };
66
+ }
67
+
68
+ // ---------- 会话 ----------
69
+
70
+ const SNAPS_MAX = 64;
71
+
72
+ // 一个 vcorp 会话(一个 MCP server 进程)一个实例。快照户口只活在内存里:
73
+ // 进程没了户口也没了,那时 ref 命令会被要求重新 snapshot——宁可多拍一次,也不认来路不明的 ref。
74
+ export class DesktopSession {
75
+ constructor({ session, channel = 'local', bin = findBinary(), run = runAgentDesktop } = {}) {
76
+ this.session = String(session || 'vcorp').replace(/[^\w-]/g, '-').slice(0, 64);
77
+ this.channel = channel;
78
+ this.bin = bin;
79
+ this.runner = run;
80
+ this.snaps = new Map();
81
+ this.windows = new Map();
82
+ }
83
+
84
+ remember(rec) {
85
+ this.snaps.delete(rec.id);
86
+ this.snaps.set(rec.id, rec);
87
+ while (this.snaps.size > SNAPS_MAX) this.snaps.delete(this.snaps.keys().next().value);
88
+ }
89
+
90
+ async refreshWindows() {
91
+ const env = await this.runner(['list-windows'], { bin: this.bin, timeoutMs: 15000 });
92
+ if (env?.ok && Array.isArray(env.data)) {
93
+ for (const w of env.data) this.windows.set(w.id, { app: w.app_name, title: w.title || '' });
94
+ }
95
+ return env;
96
+ }
97
+
98
+ ctx() {
99
+ return {
100
+ channel: this.channel,
101
+ session: this.session,
102
+ lookup: (id) => this.snaps.get(id) || null,
103
+ windows: (id) => this.windows.get(id) || null,
104
+ };
105
+ }
106
+
107
+ // 一条命令。返回 { text, image?, env, sensitive }。策略不过直接抛(code 在 e.code 上)。
108
+ async run(params, { timeoutMs = 30000, allowSensitive = true } = {}) {
109
+ if (params?.steps !== undefined) return this.steps(params.steps, { timeoutMs });
110
+ if (params?.window && !this.windows.has(String(params.window))) await this.refreshWindows();
111
+ const chk = checkDesktop(params, this.ctx());
112
+ if (chk.sensitive === 'pay') {
113
+ throw Object.assign(new Error(`「${chk.target?.name}」像是要花钱的按钮。桌面能力不代做付款,请用户亲手点。`), { code: 'PAY_REFUSED' });
114
+ }
115
+ if (chk.sensitive && !allowSensitive) {
116
+ throw Object.assign(new Error(`「${chk.target?.name}」是发送/删除一类的动作,批处理不代做。单独调用一次 desktop ${chk.cmd}。`), { code: 'NEEDS_SINGLE_CALL' });
117
+ }
118
+ let shot = null;
119
+ if (chk.cmd === 'screenshot') {
120
+ shot = path.join(os.tmpdir(), `vcorp-desktop-${process.pid}-${Date.now()}.png`);
121
+ chk.argv.push(shot);
122
+ }
123
+ const env = await this.runner(chk.argv, { bin: this.bin, timeoutMs });
124
+
125
+ if (env?.ok && (chk.cmd === 'snapshot' || chk.cmd === 'find')) {
126
+ const rec = recordOf(env.data);
127
+ if (rec && !rec.app) rec.app = chk.app;
128
+ if (rec && !rec.window && env.data.window) rec.window = env.data.window;
129
+ if (chk.cmd === 'snapshot') checkObserved(rec);
130
+ if (rec) {
131
+ const prev = chk.cmd === 'find' ? this.snaps.get(rec.id) : null;
132
+ this.remember(prev ? { ...prev, refs: { ...prev.refs, ...rec.refs } } : rec);
133
+ }
134
+ }
135
+ if (env?.ok && chk.cmd === 'list-windows') {
136
+ for (const w of env.data || []) this.windows.set(w.id, { app: w.app_name, title: w.title || '' });
137
+ env.data = filterWindows(env.data, this.channel);
138
+ }
139
+ let image = null;
140
+ if (shot) {
141
+ try { image = fs.readFileSync(shot).toString('base64'); } catch { /* 截图失败时信封里有原因 */ }
142
+ fs.rmSync(shot, { force: true });
143
+ }
144
+ return { text: renderEnvelope(chk.cmd, env, { target: chk.target }), image, env, cmd: chk.cmd, app: chk.app, sensitive: chk.sensitive };
145
+ }
146
+
147
+ // 一串步骤:每步过同一套检查,任何一步失败或碰到敏感动作就停,讲清做到哪、为什么停
148
+ async steps(list, { timeoutMs = 30000 } = {}) {
149
+ checkSteps(list);
150
+ const out = [];
151
+ for (let i = 0; i < list.length; i++) {
152
+ const st = list[i];
153
+ let r;
154
+ try {
155
+ r = await this.run(st, { timeoutMs, allowSensitive: false });
156
+ } catch (e) {
157
+ out.push(`⏸ 第 ${i + 1}/${list.length} 步 ${st.cmd}:${e.message}`);
158
+ out.push(`剩下 ${list.length - i - 1} 步没做。`);
159
+ return { text: out.join('\n'), stopped: true };
160
+ }
161
+ out.push(`— ${i + 1}/${list.length} ${st.cmd}\n${r.text}`);
162
+ if (!r.env?.ok) {
163
+ out.push(`⏸ 停在第 ${i + 1} 步,剩下 ${list.length - i - 1} 步没做。`);
164
+ return { text: out.join('\n'), stopped: true };
165
+ }
166
+ }
167
+ return { text: out.join('\n\n') };
168
+ }
169
+ }
170
+
171
+ export { snapshotIdOf };
@@ -0,0 +1,139 @@
1
+ // 原生消息宿主的安装 / 卸载 / 体检(npx vcorp-chrome desktop install|uninstall|status)。
2
+ //
3
+ // Chrome 找宿主靠一份清单:Windows 在注册表 HKCU 下登记清单路径,macOS / Linux 把清单放进
4
+ // 浏览器约定的目录。清单的 path 指向一个启动脚本,脚本用装它时的那个 node 跑 native-host/host.js。
5
+ // allowed_origins 只写本扩展:扩展 id 由 manifest.json 里的 key 算出来,不手抄。
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { spawn, execFileSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { HOME, ensureHome } from './paths.js';
13
+
14
+ export const HOST_NAME = 'ai.vcorp.desktop';
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
16
+ const HOST_JS = path.join(ROOT, 'native-host', 'host.js');
17
+ const DIR = path.join(HOME, 'desktop-host');
18
+ const MANIFEST = path.join(DIR, `${HOST_NAME}.json`);
19
+
20
+ // Chrome 的扩展 id:公钥 DER 的 sha256 前 16 字节,每个十六进制位 0-f 映射成 a-p
21
+ export function extensionId(manifestKey) {
22
+ const hex = crypto.createHash('sha256').update(Buffer.from(manifestKey, 'base64')).digest('hex').slice(0, 32);
23
+ return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
24
+ }
25
+
26
+ function ourExtensionId() {
27
+ const mf = JSON.parse(fs.readFileSync(path.join(ROOT, 'extension', 'manifest.json'), 'utf8'));
28
+ return extensionId(mf.key);
29
+ }
30
+
31
+ const REG_KEYS = [
32
+ `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
33
+ `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}`,
34
+ ];
35
+
36
+ function manifestDirs() {
37
+ const h = os.homedir();
38
+ if (process.platform === 'darwin') {
39
+ const base = path.join(h, 'Library', 'Application Support');
40
+ return ['Google/Chrome', 'Chromium', 'Microsoft Edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(base, b, 'NativeMessagingHosts'));
41
+ }
42
+ return ['google-chrome', 'chromium', 'microsoft-edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(h, '.config', b, 'NativeMessagingHosts'));
43
+ }
44
+
45
+ export function install() {
46
+ ensureHome();
47
+ fs.mkdirSync(DIR, { recursive: true });
48
+ const win = process.platform === 'win32';
49
+ const launcher = path.join(DIR, win ? 'vcorp-desktop-host.cmd' : 'vcorp-desktop-host');
50
+ fs.writeFileSync(launcher, win
51
+ ? `@echo off\r\n"${process.execPath}" "${HOST_JS}" %*\r\n`
52
+ : `#!/bin/sh\nexec "${process.execPath}" "${HOST_JS}" "$@"\n`);
53
+ if (!win) fs.chmodSync(launcher, 0o755);
54
+ const id = ourExtensionId();
55
+ fs.writeFileSync(MANIFEST, JSON.stringify({
56
+ name: HOST_NAME,
57
+ description: 'vcorp-chrome desktop host',
58
+ path: launcher,
59
+ type: 'stdio',
60
+ allowed_origins: [`chrome-extension://${id}/`],
61
+ }, null, 2));
62
+ const placed = [];
63
+ if (win) {
64
+ for (const key of REG_KEYS) {
65
+ execFileSync('reg', ['add', key, '/ve', '/t', 'REG_SZ', '/d', MANIFEST, '/f'], { stdio: 'ignore', windowsHide: true });
66
+ placed.push(key);
67
+ }
68
+ } else {
69
+ for (const dir of manifestDirs()) {
70
+ if (!fs.existsSync(path.dirname(dir))) continue; // 没装这个浏览器
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ fs.copyFileSync(MANIFEST, path.join(dir, `${HOST_NAME}.json`));
73
+ placed.push(dir);
74
+ }
75
+ }
76
+ return { id, launcher, manifest: MANIFEST, placed };
77
+ }
78
+
79
+ export function uninstall() {
80
+ const removed = [];
81
+ if (process.platform === 'win32') {
82
+ for (const key of REG_KEYS) {
83
+ try { execFileSync('reg', ['delete', key, '/f'], { stdio: 'ignore', windowsHide: true }); removed.push(key); } catch { /* 本来就没登记 */ }
84
+ }
85
+ } else {
86
+ for (const dir of manifestDirs()) {
87
+ const f = path.join(dir, `${HOST_NAME}.json`);
88
+ if (fs.existsSync(f)) { fs.rmSync(f); removed.push(f); }
89
+ }
90
+ }
91
+ fs.rmSync(DIR, { recursive: true, force: true });
92
+ return { removed };
93
+ }
94
+
95
+ // 按 Chrome 的方式跟宿主说一句话:起启动脚本、写一条带长度前缀的消息、读一条回包
96
+ export function talk(msg, { launcher, timeoutMs = 20000 } = {}) {
97
+ return new Promise((resolve, reject) => {
98
+ const win = process.platform === 'win32';
99
+ const child = win
100
+ ? spawn('cmd.exe', ['/d', '/c', launcher, `chrome-extension://${ourExtensionId()}/`], { windowsHide: true })
101
+ : spawn(launcher, [`chrome-extension://${ourExtensionId()}/`]);
102
+ const chunks = [];
103
+ const timer = setTimeout(() => { child.kill(); reject(new Error(`宿主 ${timeoutMs / 1000}s 没有回包`)); }, timeoutMs);
104
+ child.stdout.on('data', (b) => chunks.push(b));
105
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
106
+ child.on('close', () => {
107
+ clearTimeout(timer);
108
+ const buf = Buffer.concat(chunks);
109
+ if (buf.length < 4) return reject(new Error('宿主没有回包'));
110
+ resolve(JSON.parse(buf.subarray(4, 4 + buf.readUInt32LE(0)).toString('utf8')));
111
+ });
112
+ const body = Buffer.from(JSON.stringify(msg), 'utf8');
113
+ const len = Buffer.alloc(4);
114
+ len.writeUInt32LE(body.length, 0);
115
+ child.stdin.end(Buffer.concat([len, body]));
116
+ });
117
+ }
118
+
119
+ export async function status() {
120
+ const lines = [];
121
+ let launcher = '';
122
+ if (!fs.existsSync(MANIFEST)) return { installed: false, lines: ['没有安装:运行 npx vcorp-chrome desktop install'] };
123
+ const mf = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
124
+ launcher = mf.path;
125
+ lines.push(`清单 ${MANIFEST}`);
126
+ if (process.platform === 'win32') {
127
+ for (const key of REG_KEYS) {
128
+ let ok = false;
129
+ try { ok = execFileSync('reg', ['query', key, '/ve'], { encoding: 'utf8', windowsHide: true }).includes(MANIFEST); } catch { /* 没登记 */ }
130
+ lines.push(`${ok ? '✅' : '❌'} ${key}`);
131
+ }
132
+ }
133
+ if (!fs.existsSync(HOST_JS)) lines.push(`❌ 启动脚本指向的 ${HOST_JS} 不在了(npx 缓存被清过?):重新运行 desktop install`);
134
+ const hello = await talk({ op: 'hello' }, { launcher });
135
+ lines.push(hello.ready
136
+ ? `✅ 宿主应答 · 设备 ${hello.device.id} · agent-desktop 可用`
137
+ : `❌ 宿主应答了,但 agent-desktop 用不了:[${hello.error?.code}] ${hello.error?.message}`);
138
+ return { installed: true, ready: !!hello.ready, device: hello.device, lines };
139
+ }
package/src/lib/paths.js CHANGED
@@ -11,8 +11,43 @@ export const AUDIT_FILE = path.join(HOME, 'audit.jsonl');
11
11
  export const ALLOWLIST_FILE = path.join(HOME, 'allowlist.json');
12
12
  export const LOG_FILE = path.join(HOME, 'bridge.log');
13
13
 
14
+ export const MACHINE_FILE = path.join(HOME, 'machine.json');
15
+
16
+ // 桌面完全模式的开关。只在这台电脑上用 `vcorp-chrome desktop full on|off` 改,
17
+ // 原生宿主每条命令现读一次——服务器、网页、扩展都写不到这个文件。
18
+ export const FULL_FILE = path.join(HOME, 'desktop-full.json');
19
+
20
+ export function fullMode() {
21
+ try { return JSON.parse(fs.readFileSync(FULL_FILE, 'utf8')).on === true; } catch { return false; }
22
+ }
23
+
24
+ export function setFullMode(on) {
25
+ ensureHome();
26
+ if (!on) { fs.rmSync(FULL_FILE, { force: true }); return false; }
27
+ fs.writeFileSync(FULL_FILE, JSON.stringify({ on: true, at: new Date().toISOString() }, null, 2), { mode: 0o600 });
28
+ return true;
29
+ }
30
+
14
31
  export const DEFAULT_PORT = 8899;
15
32
 
33
+ // 这台电脑(这个系统用户)的身份。本机 MCP 和原生宿主(native-host/)读同一个文件,
34
+ // 所以同一台电脑上的桌面设备在两条通道里是同一个设备号。第一次用时生成,之后不变。
35
+ export function machineId() {
36
+ try {
37
+ const id = JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
38
+ if (/^m_[0-9a-f]{16}$/.test(id)) return id;
39
+ } catch { /* 还没有,下面生成 */ }
40
+ ensureHome();
41
+ const id = `m_${crypto.randomBytes(8).toString('hex')}`;
42
+ const tmp = `${MACHINE_FILE}.${process.pid}.tmp`;
43
+ fs.writeFileSync(tmp, JSON.stringify({ machineId: id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
44
+ try { fs.linkSync(tmp, MACHINE_FILE); } catch { /* 别的进程抢先写好了,用它的 */ }
45
+ fs.rmSync(tmp, { force: true });
46
+ return JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
47
+ }
48
+
49
+ export const desktopDeviceId = (machine) => `desktop-${machine}`;
50
+
16
51
  export function ensureHome() {
17
52
  fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
18
53
  return HOME;
package/src/lib/rpc.js CHANGED
@@ -100,6 +100,7 @@ export class BridgeClient {
100
100
  this.extensionVersion = msg.extensionVersion;
101
101
  this.versionMismatch = !!msg.versionMismatch;
102
102
  this.extensionProtocol = msg.extensionProtocol;
103
+ this.bridgeDevices = !!msg.devices;
103
104
  ws.onmessage = (e2) => this.#dispatch(JSON.parse(e2.data));
104
105
  ws.onclose = () => { this.ws = null; this.#failAll('桥连接已断开'); };
105
106
  ws.onerror = () => {};
@@ -144,7 +145,11 @@ export class BridgeClient {
144
145
  }
145
146
 
146
147
  // 桥侧已有 30s 超时兜底;这里的 35s 只防「桥自己也没了」的情况
147
- async call(cmd, params = {}, { tabId, timeoutMs = 35000 } = {}) {
148
+ // device:指定投给哪一个浏览器设备(devices 列出的 id)。不给就由桥挑有窗口、最近活跃的那个。
149
+ async call(cmd, params = {}, { tabId, timeoutMs = 35000, device } = {}) {
150
+ if ((device || cmd === 'devices') && this.ws && !this.bridgeDevices) {
151
+ throw Object.assign(new Error('在跑的桥是旧版本,不认识设备:指定 device 的命令会被它投给默认的 Chrome。所有会话都退出、桥自动重启之后再用。'), { code: 'BRIDGE_OLD' });
152
+ }
148
153
  if (!this.ws) await this.connect();
149
154
  const id = 'c' + ++this.seq;
150
155
  return new Promise((resolve, reject) => {
@@ -154,7 +159,7 @@ export class BridgeClient {
154
159
  }, timeoutMs);
155
160
  this.waiting.set(id, { resolve, reject, timer });
156
161
  // 桥侧也要知道这条命令能等多久,否则它按默认 30s 就把慢命令判死了
157
- this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000 }));
162
+ this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000, ...(device ? { to: { device } } : {}) }));
158
163
  });
159
164
  }
160
165
  }
package/src/mcp-server.js CHANGED
@@ -12,8 +12,12 @@ import { resolveHost } from './lib/host.js';
12
12
  import { flatCount } from '../extension/script.js';
13
13
  import { LOADS } from '../extension/loads/index.js';
14
14
  import { getLearnings, saveLearnings, saveDraft, adoptDraft } from './lib/learnings.js';
15
- import { audit } from './lib/paths.js';
15
+ import { audit, machineId, desktopDeviceId } from './lib/paths.js';
16
+ import { DesktopSession, findBinary } from './lib/desktop.js';
17
+ import { redact } from './bridge.js';
18
+ import { describeDevices } from '../extension/device.js';
16
19
  import fs from 'node:fs';
20
+ import os from 'node:os';
17
21
  import path from 'node:path';
18
22
  import { VERSION } from './lib/version.js';
19
23
  import { compat, compatNote } from '../extension/compat.js';
@@ -208,6 +212,7 @@ const TOOLS = [
208
212
  tabId: TAB,
209
213
  label: { type: 'string', description: 'With new/select: what this tab is for ("CRM import — batch 2"). Shown in list, page panel, and to the user.' },
210
214
  focus: { type: 'boolean', description: 'Also bring the tab to the foreground. Interrupts the user — off by default.' },
215
+ device: { type: 'string', description: 'With 2+ Chromes: id from devices. That tabId then sticks to it.' },
211
216
  },
212
217
  required: ['action'],
213
218
  },
@@ -500,6 +505,39 @@ const TOOLS = [
500
505
  },
501
506
  },
502
507
  },
508
+ {
509
+ name: 'desktop',
510
+ description:
511
+ 'Native apps and OS dialogs (the file picker a page opens) via the accessibility tree. '
512
+ + 'list-windows → snapshot {app|window} → act on the FULL ref "@s…:eN" → read the effect line. '
513
+ + 'Browser windows are refused (use browser tools). headed:true = real mouse/keys. '
514
+ + 'steps:[{cmd,…}] (≤20) stops on failure or send/delete.',
515
+ inputSchema: {
516
+ type: 'object',
517
+ properties: {
518
+ cmd: {
519
+ type: 'string',
520
+ enum: ['list-apps', 'list-windows', 'snapshot', 'find', 'get', 'screenshot', 'wait', 'click', 'double-click',
521
+ 'right-click', 'type', 'set-value', 'clear', 'focus', 'select', 'toggle', 'check', 'uncheck', 'expand',
522
+ 'collapse', 'scroll', 'scroll-to', 'press', 'launch', 'focus-window', 'clipboard-set', 'clipboard-get',
523
+ 'list-notifications', 'notification-action'],
524
+ },
525
+ app: { type: 'string' }, window: { type: 'string' }, ref: { type: 'string' },
526
+ root: { type: 'string' }, skeleton: { type: 'boolean' }, all: { type: 'boolean' },
527
+ text: { type: 'string' }, value: { type: 'string' }, keys: { type: 'string', description: 'press: "ctrl+s"' },
528
+ role: { type: 'string' }, name: { type: 'string' }, nth: { type: 'number' }, property: { type: 'string' },
529
+ direction: { type: 'string' }, amount: { type: 'number' },
530
+ ms: { type: 'number' }, windowTitle: { type: 'string' }, timeout: { type: 'number' },
531
+ index: { type: 'number' }, action: { type: 'string' }, headed: { type: 'boolean' },
532
+ steps: { type: 'array', items: { type: 'object' } },
533
+ },
534
+ },
535
+ },
536
+ {
537
+ name: 'devices',
538
+ description: 'Devices this session can drive: each connected Chrome and this computer\'s desktop.',
539
+ inputSchema: { type: 'object', properties: {} },
540
+ },
503
541
  {
504
542
  name: 'reload',
505
543
  description:
@@ -552,7 +590,7 @@ warning change target or approach, never repeat the same call.
552
590
 
553
591
  A STEP NEEDS A HUMAN (captcha, QR login, OTP, payment)? Call \`ask\` — never retry or work
554
592
  around. Never let a download put a dialog on their screen: see the \`download\` tool for the
555
- required order. OS file dialogs are beyond any extension.`;
593
+ required order. Native apps and OS file dialogs: \`desktop\`.`;
556
594
 
557
595
  // 页面来的文本全部走这里。边界标记 + 降权说明,both 是给模型看的。
558
596
  function wrapUntrusted(body, meta = '') {
@@ -573,6 +611,9 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
573
611
 
574
612
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
575
613
 
614
+ let desk = null;
615
+ const tabDevice = new Map();
616
+
576
617
  // 宿主身份:--client 只是兜底,真名以 initialize 里宿主自报的 clientInfo 为准
577
618
  // (见 src/lib/host.js)。握手一定先于第一条工具调用,所以在那时认一次就够。
578
619
  let host = null;
@@ -585,7 +626,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
585
626
  return host;
586
627
  };
587
628
 
588
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
629
+ server.setRequestHandler(CallToolRequestSchema, busy(async (req) => {
589
630
  const { name, arguments: args = {} } = req.params;
590
631
  identify();
591
632
  try {
@@ -598,12 +639,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
598
639
  return { content: [{ type: 'text', text }] };
599
640
  }
600
641
 
642
+ // desktop 也不经过桥:本机直接起 agent-desktop 子进程。审计照记,设备号写本机桌面
643
+ if (name === 'desktop') {
644
+ desk ??= new DesktopSession({ session: `vc-${bridge.sessionId}` });
645
+ const t0 = Date.now();
646
+ const id = `desktop:${t0}`;
647
+ const cmd = `desktop.${args.steps !== undefined ? 'steps' : args.cmd}`;
648
+ audit({ ev: 'cmd', id, cmd, client: bridge.client, sid: bridge.sessionId, device: desktopDeviceId(machineId()), params: redact(args) });
649
+ let r;
650
+ try {
651
+ r = await desk.run(args, { timeoutMs: args.steps ? 120000 : Math.min(Math.max((Number(args.timeout) || 0) + 10000, 30000), 600000) });
652
+ } catch (e) {
653
+ audit({ ev: 'res', id, cmd, ok: false, ms: Date.now() - t0, error: e.code || 'INTERNAL' });
654
+ throw e;
655
+ }
656
+ const ok = r.env ? !!r.env.ok : !r.stopped;
657
+ audit({ ev: 'res', id, cmd, ok, ms: Date.now() - t0, error: ok ? undefined : r.env?.error?.code, effect: r.env?.data?.disposition?.delivery });
658
+ if (r.image) return { content: [{ type: 'image', data: r.image, mimeType: 'image/png' }] };
659
+ return { content: [{ type: 'text', text: wrapUntrusted(r.text, 'source="desktop"') }], isError: !ok || undefined };
660
+ }
661
+
662
+ if (name === 'devices') {
663
+ const machine = machineId();
664
+ const list = [{ id: desktopDeviceId(machine), kind: 'desktop', name: `${os.hostname()} 桌面`, machine, online: true,
665
+ caps: { platform: process.platform } }];
666
+ let browserNote = '';
667
+ try {
668
+ if (!bridge.ws) await bridge.connect();
669
+ const d = await bridge.call('devices', {}, { timeoutMs: 8000 });
670
+ list.unshift(...d.devices.map((x) => (x.primary ? { ...x, name: `${x.name}(默认)` } : x)));
671
+ } catch (e) {
672
+ browserNote = `\n浏览器设备查不到:[${e.code || 'INTERNAL'}] ${e.message}`;
673
+ }
674
+ return { content: [{ type: 'text', text: describeDevices(list) + browserNote }] };
675
+ }
676
+
601
677
  if (!bridge.ws) await bridge.connect();
602
678
 
679
+ // 指定设备:tabs 带 device 时记住新开/选中的 tabId 属于哪台,之后同一 tabId 自动跟过去
680
+ const device = args.device || (args.tabId != null ? tabDevice.get(args.tabId) : undefined);
681
+ if (args.device !== undefined) delete args.device;
682
+
603
683
  // 翻页在这一侧循环:审计里 fetch→fetch 相邻 386 次,agent 在一页一个回合地
604
684
  // 手动翻。扩展只管发单次请求,翻页的循环、落盘、停机判定都不该经过模型。
605
685
  if (name === 'fetch' && args.pages && !args.binary) {
606
- return { content: [{ type: 'text', text: await fetchPages(bridge, args) }] };
686
+ return { content: [{ type: 'text', text: await fetchPages(bridge, args, device) }] };
607
687
  }
608
688
 
609
689
  // upload:先把**路径**交给扩展,让它用 CDP 的 DOM.setFileInputFiles 让浏览器
@@ -617,7 +697,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
617
697
  const base = { path: args.path, name: path.basename(args.path), type: mime, bytes: st.size, selector: args.selector, dropSelector: args.dropSelector };
618
698
  // 上传本身可能很慢(本机→浏览器只是投喂,但页面自己的上传要时间),给足预算
619
699
  const timeoutMs = Math.max(60000, Math.round(st.size / (1024 * 1024)) * 1000);
620
- let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs });
700
+ let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs, device });
621
701
  if (!out || out.needBytes) {
622
702
  // 走拖放:这条必须有文件字节。超过 DROP_MAX 就明确报错,不要拿桥去撞——
623
703
  // 桥一断,当前 agent 会话就再也连不回来了,比报错难查得多。
@@ -629,7 +709,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
629
709
  `先压小,或改用页面上真实存在的 file input(给 selector)。`);
630
710
  }
631
711
  out = await bridge.call('upload', { ...base, base64: fs.readFileSync(args.path).toString('base64') },
632
- { tabId: args.tabId, timeoutMs });
712
+ { tabId: args.tabId, timeoutMs, device });
633
713
  }
634
714
  return { content: [{ type: 'text', text: out.text }] };
635
715
  }
@@ -670,7 +750,11 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
670
750
  // 无人值守(cron、服务器)没有人可问。开关放在 MCP 这一侧读环境变量,
671
751
  // 扩展只管照做——扩展不该知道自己跑在什么场景里。
672
752
  name === 'ask' ? { ...args, disabled: process.env.HUASHU_CHROME_ASK === 'off' } : args,
673
- { tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs) });
753
+ { tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs), device });
754
+ if (device && name === 'tabs') {
755
+ const tid = data?.tabId ?? (args.action === 'select' ? args.tabId : undefined);
756
+ if (tid != null) tabDevice.set(tid, device);
757
+ }
674
758
 
675
759
  // 浏览器只能下到 Downloads 里;下完再挪到 agent 要的位置,对它保持透明
676
760
  if (name === 'download' && args.savePath && data.path) {
@@ -721,7 +805,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
721
805
  } catch (e) {
722
806
  return { content: [{ type: 'text', text: hint(e) + mismatchNote(true) }], isError: true };
723
807
  }
724
- });
808
+ }));
725
809
 
726
810
  // 扩展和本地端版本对不上时在回执里说一次——回执是唯一一定有人读的通道。
727
811
  // 分级见 extension/compat.js:补丁号差异静默;本地端旧了提一次怎么升级;
@@ -738,16 +822,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
738
822
  }
739
823
 
740
824
  await server.connect(new StdioServerTransport());
825
+ watchIdle();
741
826
  return server;
742
827
  }
743
828
 
829
+ // 闲置自退。
830
+ //
831
+ // 本该是宿主的事:会话结束就关掉 stdin,我们读到 EOF 自然就退了(实测成立)。
832
+ // 可 Goose 桌面版每开一个会话起一个 mcp、会话结束却从不关管道——11-2 查到
833
+ // 一个 goose serve 下面挂着 34 个,最老的一周前,合计约 2 GB。宿主不收,只能自己收。
834
+ //
835
+ // 判据是「stdin 上多久没来过任何东西」,且手上没有在跑的工具调用。默认 4 小时:
836
+ // 一个真在用的会话很少静默这么久;万一真是,宿主下一次调用会看到连接已断,
837
+ // 代价是重开一次会话,远小于无限期地攒进程。HUASHU_CHROME_MCP_IDLE_MIN=0 关掉。
838
+ let inflight = 0;
839
+ let lastActive = Date.now();
840
+ const busy = (fn) => async (...a) => {
841
+ inflight++;
842
+ try { return await fn(...a); } finally { inflight--; lastActive = Date.now(); }
843
+ };
844
+
845
+ export function idleLimitMs(env = process.env) {
846
+ const raw = env.HUASHU_CHROME_MCP_IDLE_MIN;
847
+ const min = raw == null || raw === '' ? 240 : Number(raw);
848
+ return Number.isFinite(min) && min > 0 ? min * 60000 : 0;
849
+ }
850
+
851
+ function watchIdle() {
852
+ const limit = idleLimitMs();
853
+ if (!limit) return;
854
+ process.stdin.on('data', () => { lastActive = Date.now(); });
855
+ const tick = setInterval(() => {
856
+ if (inflight || Date.now() - lastActive < limit) return;
857
+ console.error(`[vcorp-chrome] ${Math.round(limit / 60000)} 分钟没有任何请求,宿主大概已经不用这个进程了,自行退出`);
858
+ process.exit(0);
859
+ }, Math.min(60000, Math.max(limit / 4, 50)));
860
+ tick.unref(); // 吊着进程的该是 stdin,不是这个检查
861
+ }
862
+
744
863
  // 自动翻页。页码型:改一个 query 参数递增;游标型:从上一页响应里按路径取下一个游标。
745
864
  // 停机判据都是确定性的:非 2xx、空体、和上一页一模一样、游标为空或没变、跑满 max。
746
865
  // 落盘是每页一行 JSON(url/status/body),body 能解析成 JSON 就存解析后的。
747
866
  const pathGet = (obj, p) => String(p || '').split('.').filter(Boolean).reduce((o, k) => (o == null ? undefined : o[k]), obj);
748
867
  const EMPTY_BODY = /^\s*(\[\s*\]|\{\s*\}|null)?\s*$/;
749
868
 
750
- export async function fetchPages(bridge, args) {
869
+ export async function fetchPages(bridge, args, device) {
751
870
  const pg = args.pages || {};
752
871
  if (!pg.param && !pg.cursorParam) throw Object.assign(new Error('pages 需要 param(页码参数名)或 cursorParam(游标参数名)'), { code: 'INTERNAL' });
753
872
  const max = Math.min(Math.max(Number(pg.max) || 10, 1), 50);
@@ -762,7 +881,7 @@ export async function fetchPages(bridge, args) {
762
881
  const url = new URL(base);
763
882
  if (pg.param) url.searchParams.set(pg.param, String(n));
764
883
  if (pg.cursorParam && cursor) url.searchParams.set(pg.cursorParam, cursor);
765
- const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId });
884
+ const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId, device });
766
885
  const m = /^(\d+)\n\n([\s\S]*)$/.exec(data?.text || '');
767
886
  const status = Number(m?.[1] || 0), body = m?.[2] ?? '';
768
887
  if (!(status >= 200 && status < 300)) { stop = `第 ${i + 1} 页返回 ${status},停下`; break; }
@@ -822,6 +941,12 @@ function hint(e) {
822
941
  + '在「高保真模式」那一栏点「开启」——只需一次。',
823
942
  L2_BUSY: '真实输入事件用不了(多半是用户自己开着 DevTools,一个标签页只允许一个调试器)。'
824
943
  + '已经用普通事件完成了;如果结果不对,让用户关掉 DevTools 再试。',
944
+ DEVICE_OFFLINE: '指定的设备不在线。devices 看现在有哪些,换一台或请用户把它打开。',
945
+ AMBIGUOUS_DEVICE: '有不止一台设备能做这件事。从候选里挑一个,用 device 指定。',
946
+ DESKTOP_DENIED: '这条桌面命令被安全规则拦下(原因见上)。浏览器窗口用浏览器工具;别换个写法绕。',
947
+ DESKTOP_UNAVAILABLE: '这台电脑上的桌面能力没装好。运行 npx vcorp-chrome doctor 看缺什么。',
948
+ PAY_REFUSED: '付款类按钮只能由用户亲手点。用 ask 把要付的东西说清楚,请用户自己完成。',
949
+ NEEDS_SINGLE_CALL: '发送/删除一类的动作不在批处理里做。先确认目标没错,再单独调用一次。',
825
950
  };
826
951
  return `[${e.code || 'INTERNAL'}] ${e.message}` + (map[e.code] ? `\n→ ${map[e.code]}` : '');
827
952
  }