vcorp-chrome 1.8.1 → 2.0.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.
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
  }