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.
- package/README.md +20 -1
- package/docs//345/215/217/350/256/256.md +53 -0
- package/docs//345/257/271/346/257/224.md +55 -53
- package/docs//346/241/214/351/235/242/345/256/214/345/205/250/346/250/241/345/274/217-/344/272/244/346/216/245.md +128 -0
- package/docs//350/203/275/345/212/233/345/214/205/344/270/216eda-/344/272/244/346/216/245.md +288 -0
- package/extension/background.js +20 -2
- package/extension/cloud-policy.js +23 -0
- package/extension/cloud.js +315 -6
- package/extension/desktop-policy.js +406 -0
- package/extension/desktop-render.js +81 -0
- package/extension/device.js +144 -0
- package/extension/manifest.json +41 -38
- package/extension/panel.html +114 -45
- package/extension/panel.js +469 -427
- package/native-host/full.js +217 -0
- package/native-host/host.js +155 -0
- package/package.json +53 -49
- package/src/bridge.js +31 -2
- package/src/cli.js +50 -1
- package/src/lib/desktop.js +171 -0
- package/src/lib/native-install.js +139 -0
- package/src/lib/paths.js +35 -0
- package/src/lib/rpc.js +7 -2
- package/src/mcp-server.js +135 -10
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// 桌面完全模式 —— 云端任务在这台电脑上拿到和本机 agent(goose 一类)一样的能力:
|
|
2
|
+
// 跑命令、读写任意文件、agent-desktop 的全部命令。
|
|
3
|
+
//
|
|
4
|
+
// 只有用户在本机运行过 `vcorp-chrome desktop full on` 才开(见 paths.js 的 fullMode),
|
|
5
|
+
// 原生宿主每条命令都现读开关。这一层不做「哪个能碰」的判断——那是第二阶段安全方案的事。
|
|
6
|
+
//
|
|
7
|
+
// 原生消息回包上限 1MB:大结果先落到临时文件,扩展再用 page 一段一段取回(见 pageOut / pageRead)。
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const OUT_MAX = 4 * 1024 * 1024; // shell 的 stdout / stderr 各留这么多
|
|
15
|
+
const SHELL_TIMEOUT_MAX = 7 * 60 * 1000; // 服务器那边一条命令等 8 分钟
|
|
16
|
+
export const FILE_READ_MAX = 16 * 1024 * 1024;
|
|
17
|
+
const LIST_MAX = 2000;
|
|
18
|
+
|
|
19
|
+
const fail = (code, message) => ({ ok: false, error: { code, message, disposition: { delivery: 'not_delivered', retry: 'safe' } } });
|
|
20
|
+
|
|
21
|
+
// ~ 和相对路径都从用户主目录算起
|
|
22
|
+
export function resolvePath(p) {
|
|
23
|
+
const s = String(p ?? '').trim();
|
|
24
|
+
if (!s || s.includes('\0')) return '';
|
|
25
|
+
if (s === '~') return os.homedir();
|
|
26
|
+
if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(os.homedir(), s.slice(2));
|
|
27
|
+
return path.resolve(os.homedir(), s);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 用哪个壳。Windows 默认 PowerShell,并把输出编码钉成 UTF-8,中文不乱码。
|
|
31
|
+
export function shellArgv(command, which = '') {
|
|
32
|
+
const win = process.platform === 'win32';
|
|
33
|
+
const sh = String(which || (win ? 'powershell' : 'sh')).toLowerCase();
|
|
34
|
+
if (sh === 'powershell' || sh === 'pwsh') {
|
|
35
|
+
const exe = sh === 'pwsh' ? 'pwsh' : win ? 'powershell.exe' : 'pwsh';
|
|
36
|
+
const pre = '[Console]::OutputEncoding=[Text.Encoding]::UTF8;$OutputEncoding=[Text.Encoding]::UTF8;$ProgressPreference="SilentlyContinue";';
|
|
37
|
+
return [exe, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', pre + command]];
|
|
38
|
+
}
|
|
39
|
+
if (sh === 'cmd') return ['cmd.exe', ['/d', '/s', '/c', `chcp 65001 >nul & ${command}`]];
|
|
40
|
+
if (sh === 'bash') return ['bash', ['-lc', command]];
|
|
41
|
+
if (sh === 'sh') return ['/bin/sh', ['-c', command]];
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function runShell(p = {}) {
|
|
46
|
+
const command = String(p.command ?? '');
|
|
47
|
+
if (!command.trim()) return Promise.resolve(fail('BAD_PARAMS', 'shell 要给 command'));
|
|
48
|
+
const argv = shellArgv(command, p.shell);
|
|
49
|
+
if (!argv) return Promise.resolve(fail('BAD_PARAMS', 'shell 只认 powershell / pwsh / cmd / bash / sh'));
|
|
50
|
+
const cwd = p.cwd ? resolvePath(p.cwd) : os.homedir();
|
|
51
|
+
if (!cwd || !fs.existsSync(cwd)) return Promise.resolve(fail('NOT_FOUND', `没有这个目录:${String(p.cwd).slice(0, 200)}`));
|
|
52
|
+
const timeoutMs = Math.min(Math.max(Number(p.timeout) || 60000, 1000), SHELL_TIMEOUT_MAX);
|
|
53
|
+
const env = { ...process.env, ...(p.env && typeof p.env === 'object' ? Object.fromEntries(Object.entries(p.env).map(([k, v]) => [k, String(v)])) : {}) };
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
const out = { stdout: [], stderr: [], n: { stdout: 0, stderr: 0 }, cut: false };
|
|
58
|
+
let timedOut = false;
|
|
59
|
+
let child;
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(argv[0], argv[1], { cwd, env, windowsHide: true, stdio: [p.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
resolve(fail('SPAWN_FAILED', e.message));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const keep = (which) => (b) => {
|
|
67
|
+
if (out.n[which] >= OUT_MAX) { out.cut = true; return; }
|
|
68
|
+
const room = OUT_MAX - out.n[which];
|
|
69
|
+
const part = b.length > room ? b.subarray(0, room) : b;
|
|
70
|
+
if (part.length < b.length) out.cut = true;
|
|
71
|
+
out[which].push(part);
|
|
72
|
+
out.n[which] += part.length;
|
|
73
|
+
};
|
|
74
|
+
child.stdout.on('data', keep('stdout'));
|
|
75
|
+
child.stderr.on('data', keep('stderr'));
|
|
76
|
+
if (p.stdin !== undefined) { child.stdin.on('error', () => {}); child.stdin.end(String(p.stdin)); }
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
timedOut = true;
|
|
79
|
+
// Windows 上 kill 只杀壳本身,它起的子进程还在;taskkill /t 连树一起收
|
|
80
|
+
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }).on('error', () => {});
|
|
81
|
+
else child.kill('SIGKILL');
|
|
82
|
+
}, timeoutMs);
|
|
83
|
+
child.on('error', (e) => { clearTimeout(timer); resolve(fail(e.code === 'ENOENT' ? 'NOT_FOUND' : 'SPAWN_FAILED', `${argv[0]}:${e.message}`)); });
|
|
84
|
+
child.on('close', (code, signal) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve({
|
|
87
|
+
ok: true,
|
|
88
|
+
data: {
|
|
89
|
+
exitCode: code, signal: signal || null, timedOut, truncated: out.cut, ms: Date.now() - started, cwd,
|
|
90
|
+
stdout: Buffer.concat(out.stdout).toString('utf8'),
|
|
91
|
+
stderr: Buffer.concat(out.stderr).toString('utf8'),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function statOf(abs, st) {
|
|
99
|
+
return {
|
|
100
|
+
path: abs,
|
|
101
|
+
type: st.isDirectory() ? 'dir' : st.isSymbolicLink() ? 'link' : st.isFile() ? 'file' : 'other',
|
|
102
|
+
size: st.size,
|
|
103
|
+
mtime: st.mtime.toISOString(),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function fileOp(p = {}) {
|
|
108
|
+
const action = String(p.action || '');
|
|
109
|
+
const abs = resolvePath(p.path);
|
|
110
|
+
if (!abs) return fail('BAD_PARAMS', 'file 要给 path');
|
|
111
|
+
const fsp = fs.promises;
|
|
112
|
+
try {
|
|
113
|
+
switch (action) {
|
|
114
|
+
case 'stat':
|
|
115
|
+
return { ok: true, data: statOf(abs, await fsp.lstat(abs)) };
|
|
116
|
+
case 'list': {
|
|
117
|
+
const depth = Math.min(Math.max(Math.floor(Number(p.depth) || 1), 1), 6);
|
|
118
|
+
const items = [];
|
|
119
|
+
let more = false;
|
|
120
|
+
const walk = async (dir, d) => {
|
|
121
|
+
let ents;
|
|
122
|
+
try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch (e) { if (dir === abs) throw e; return; }
|
|
123
|
+
for (const e of ents) {
|
|
124
|
+
if (items.length >= LIST_MAX) { more = true; return; }
|
|
125
|
+
const full = path.join(dir, e.name);
|
|
126
|
+
let st = null;
|
|
127
|
+
try { st = await fsp.lstat(full); } catch { /* 刚被删、没权限 */ }
|
|
128
|
+
items.push(st ? statOf(full, st) : { path: full, type: 'unknown' });
|
|
129
|
+
if (e.isDirectory() && d < depth) await walk(full, d + 1);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
await walk(abs, 1);
|
|
133
|
+
return { ok: true, data: { path: abs, items, more } };
|
|
134
|
+
}
|
|
135
|
+
case 'read': {
|
|
136
|
+
const st = await fsp.stat(abs);
|
|
137
|
+
if (st.isDirectory()) return fail('IS_DIR', `${abs} 是目录:用 action:"list"`);
|
|
138
|
+
const offset = Math.max(Math.floor(Number(p.offset) || 0), 0);
|
|
139
|
+
const length = Math.min(Math.max(Math.floor(Number(p.length) || FILE_READ_MAX), 0), FILE_READ_MAX);
|
|
140
|
+
const fh = await fsp.open(abs, 'r');
|
|
141
|
+
try {
|
|
142
|
+
const buf = Buffer.alloc(Math.min(length, Math.max(st.size - offset, 0)));
|
|
143
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
|
|
144
|
+
const body = buf.subarray(0, bytesRead);
|
|
145
|
+
const enc = p.encoding === 'base64' ? 'base64' : 'utf8';
|
|
146
|
+
return { ok: true, data: { path: abs, size: st.size, offset, bytes: bytesRead, more: offset + bytesRead < st.size, encoding: enc, content: body.toString(enc) } };
|
|
147
|
+
} finally { await fh.close(); }
|
|
148
|
+
}
|
|
149
|
+
case 'write': {
|
|
150
|
+
if (p.content === undefined) return fail('BAD_PARAMS', 'write 要给 content');
|
|
151
|
+
const buf = Buffer.from(String(p.content), p.encoding === 'base64' ? 'base64' : 'utf8');
|
|
152
|
+
await fsp.mkdir(path.dirname(abs), { recursive: true });
|
|
153
|
+
if (p.append) await fsp.appendFile(abs, buf); else await fsp.writeFile(abs, buf);
|
|
154
|
+
return { ok: true, data: { path: abs, bytes: buf.length, appended: !!p.append } };
|
|
155
|
+
}
|
|
156
|
+
case 'mkdir':
|
|
157
|
+
await fsp.mkdir(abs, { recursive: true });
|
|
158
|
+
return { ok: true, data: { path: abs } };
|
|
159
|
+
case 'delete':
|
|
160
|
+
await fsp.rm(abs, { recursive: !!p.recursive, force: false });
|
|
161
|
+
return { ok: true, data: { path: abs, deleted: true } };
|
|
162
|
+
case 'move':
|
|
163
|
+
case 'copy': {
|
|
164
|
+
const to = resolvePath(p.to);
|
|
165
|
+
if (!to) return fail('BAD_PARAMS', `${action} 要给 to`);
|
|
166
|
+
await fsp.mkdir(path.dirname(to), { recursive: true });
|
|
167
|
+
if (action === 'move') await fsp.rename(abs, to);
|
|
168
|
+
else await fsp.cp(abs, to, { recursive: true, force: !!p.overwrite, errorOnExist: !p.overwrite });
|
|
169
|
+
return { ok: true, data: { from: abs, to } };
|
|
170
|
+
}
|
|
171
|
+
default:
|
|
172
|
+
return fail('BAD_PARAMS', 'file 的 action 只认 stat / list / read / write / mkdir / delete / move / copy');
|
|
173
|
+
}
|
|
174
|
+
} catch (e) {
|
|
175
|
+
const code = { ENOENT: 'NOT_FOUND', EACCES: 'PERM_DENIED', EPERM: 'PERM_DENIED', EEXIST: 'EXISTS', ENOTEMPTY: 'NOT_EMPTY', EISDIR: 'IS_DIR' }[e.code] || 'FS_ERROR';
|
|
176
|
+
return fail(code, `${e.code || ''} ${e.message}`.trim());
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------- 分页 ----------
|
|
181
|
+
//
|
|
182
|
+
// 宿主 → Chrome 一条最多 1MB。超出的回包整份写进临时目录,先回 { paged },
|
|
183
|
+
// 扩展再一段一段 page 取回、拼好再解析。最后一段取走时删文件;没取完的十分钟后清掉。
|
|
184
|
+
|
|
185
|
+
const PAGE_DIR = path.join(os.tmpdir(), 'vcorp-desktop-pages');
|
|
186
|
+
const PAGE_RAW = 600 * 1024; // 每段原始字节,base64 后约 800KB
|
|
187
|
+
const PAGE_TTL_MS = 10 * 60 * 1000;
|
|
188
|
+
const PAGE_ID_RE = /^[a-f0-9]{24}$/;
|
|
189
|
+
|
|
190
|
+
export function pageOut(body) {
|
|
191
|
+
fs.mkdirSync(PAGE_DIR, { recursive: true });
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
for (const f of fs.readdirSync(PAGE_DIR)) {
|
|
194
|
+
try { if (now - fs.statSync(path.join(PAGE_DIR, f)).mtimeMs > PAGE_TTL_MS) fs.rmSync(path.join(PAGE_DIR, f), { force: true }); } catch { /* 别的进程在删 */ }
|
|
195
|
+
}
|
|
196
|
+
const id = crypto.randomBytes(12).toString('hex');
|
|
197
|
+
fs.writeFileSync(path.join(PAGE_DIR, `${id}.json`), body, { mode: 0o600 });
|
|
198
|
+
return { ok: true, paged: { id, size: body.length, chunk: PAGE_RAW } };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function pageRead(id, offset) {
|
|
202
|
+
if (!PAGE_ID_RE.test(String(id))) return fail('BAD_PARAMS', 'page id 不合法');
|
|
203
|
+
const file = path.join(PAGE_DIR, `${id}.json`);
|
|
204
|
+
let fd;
|
|
205
|
+
try { fd = fs.openSync(file, 'r'); } catch { return fail('NOT_FOUND', '这一页已经过期或取完了'); }
|
|
206
|
+
try {
|
|
207
|
+
const size = fs.fstatSync(fd).size;
|
|
208
|
+
const at = Math.max(Math.floor(Number(offset) || 0), 0);
|
|
209
|
+
const buf = Buffer.alloc(Math.max(Math.min(PAGE_RAW, size - at), 0));
|
|
210
|
+
const n = fs.readSync(fd, buf, 0, buf.length, at);
|
|
211
|
+
const done = at + n >= size;
|
|
212
|
+
fs.closeSync(fd);
|
|
213
|
+
fd = null;
|
|
214
|
+
if (done) fs.rmSync(file, { force: true });
|
|
215
|
+
return { ok: true, page: { offset: at, data: buf.subarray(0, n).toString('base64'), done } };
|
|
216
|
+
} finally { if (fd !== null && fd !== undefined) fs.closeSync(fd); }
|
|
217
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// 原生消息宿主 ai.vcorp.desktop —— 云端通道够到桌面的唯一一段。
|
|
2
|
+
//
|
|
3
|
+
// Chrome 每次 sendNativeMessage 起一个本进程:stdin 进一条消息(4 字节小端长度 + JSON),
|
|
4
|
+
// stdout 回一条,Chrome 随即关管道。本进程只做两件事:
|
|
5
|
+
// hello:报出这台电脑的桌面设备身份(和本机 MCP 用同一个 machineId,所以两条通道里是同一台设备)
|
|
6
|
+
// run:把扩展审过的参数再审一遍,交给 agent-desktop,回它的信封
|
|
7
|
+
// 扩展那一侧已经过了 desktop-policy.js;这里再审是因为扩展本身也可能出错,
|
|
8
|
+
// 而真实输入(--headed)、截图、剪贴板读取这些不该因为一处疏漏就对云端打开。
|
|
9
|
+
//
|
|
10
|
+
// 完全模式(用户在本机 `vcorp-chrome desktop full on`):run 不再查白名单,
|
|
11
|
+
// 另有 shell / file 两种操作(见 full.js);大回包分页,扩展用 page 取回。
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { runAgentDesktop, findBinary } from '../src/lib/desktop.js';
|
|
15
|
+
import { machineId, desktopDeviceId, fullMode } from '../src/lib/paths.js';
|
|
16
|
+
import {
|
|
17
|
+
DESKTOP_CMDS, REF_RE, filterWindows, checkObserved, recordOf, isBrowserMainTitle,
|
|
18
|
+
} from '../extension/desktop-policy.js';
|
|
19
|
+
import { VERSION } from '../src/lib/version.js';
|
|
20
|
+
import { runShell, fileOp, pageOut, pageRead } from './full.js';
|
|
21
|
+
|
|
22
|
+
// Chrome 收宿主回包的上限是 1MB,留出 JSON 外壳的余量
|
|
23
|
+
const REPLY_MAX = 1000 * 1000;
|
|
24
|
+
const IN_MAX = 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
const OPTS = new Set([
|
|
27
|
+
'--app', '--window-id', '--timeout-ms', '--role', '--name', '--text', '--nth', '--limit', '--property',
|
|
28
|
+
'--timeout', '--window', '--direction', '--amount', '--session', '--root', '--expected-app',
|
|
29
|
+
]);
|
|
30
|
+
const FLAGS = new Set(['-i', '--compact', '--skeleton']);
|
|
31
|
+
const COMBO_RE = /^[a-z0-9][a-z0-9+_-]{0,39}$/i;
|
|
32
|
+
const SESSION_RE = /^--session=cloud-[\w-]{1,64}$/;
|
|
33
|
+
|
|
34
|
+
function denied(message) {
|
|
35
|
+
return { ok: false, error: { code: 'DESKTOP_DENIED', message, disposition: { delivery: 'not_delivered', retry: 'safe' } } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 返回 null 表示合规,否则是拒绝原因
|
|
39
|
+
export function recheck(argv) {
|
|
40
|
+
if (!Array.isArray(argv) || !argv.length || argv.length > 40) return 'argv 不合法';
|
|
41
|
+
if (!argv.every((a) => typeof a === 'string' && a.length <= 20000 && !a.includes('\0'))) return 'argv 不合法';
|
|
42
|
+
const spec = DESKTOP_CMDS[argv[0]];
|
|
43
|
+
if (!spec || !spec.cloud) return `云端不能用 ${argv[0]}`;
|
|
44
|
+
const cut = argv.indexOf('--');
|
|
45
|
+
const head = cut < 0 ? argv.slice(1) : argv.slice(1, cut);
|
|
46
|
+
let session = false;
|
|
47
|
+
for (const a of head) {
|
|
48
|
+
if (FLAGS.has(a) || REF_RE.test(a) || /^\d{1,6}$/.test(a)) continue;
|
|
49
|
+
if (argv[0] === 'press' && COMBO_RE.test(a)) continue;
|
|
50
|
+
const eq = a.indexOf('=');
|
|
51
|
+
if (a.startsWith('--') && eq > 2 && OPTS.has(a.slice(0, eq))) {
|
|
52
|
+
if (a.startsWith('--session=')) { if (!SESSION_RE.test(a)) return 'session 不合法'; session = true; }
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
return `参数不在白名单里:${a.slice(0, 40)}`;
|
|
56
|
+
}
|
|
57
|
+
if (!session && !['list-apps', 'clipboard-set'].includes(argv[0])) return '缺少任务的 session';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readMessage() {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
process.stdin.on('data', (b) => {
|
|
66
|
+
chunks.push(b);
|
|
67
|
+
size += b.length;
|
|
68
|
+
if (size > IN_MAX + 4) reject(new Error('消息太大'));
|
|
69
|
+
const buf = Buffer.concat(chunks);
|
|
70
|
+
if (buf.length >= 4 && buf.length >= 4 + buf.readUInt32LE(0)) resolve(JSON.parse(buf.subarray(4, 4 + buf.readUInt32LE(0)).toString('utf8')));
|
|
71
|
+
});
|
|
72
|
+
process.stdin.on('end', () => reject(new Error('没有收到消息')));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 完全模式下 argv 只查形状:是字符串数组、没有 NUL
|
|
77
|
+
export function fullArgvOk(argv) {
|
|
78
|
+
return Array.isArray(argv) && argv.length > 0 && argv.length <= 200
|
|
79
|
+
&& argv.every((a) => typeof a === 'string' && a.length <= 100000 && !a.includes('\0'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reply(obj, { paging = false } = {}) {
|
|
83
|
+
let body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
84
|
+
if (body.length > REPLY_MAX && paging) {
|
|
85
|
+
try { body = Buffer.from(JSON.stringify(pageOut(body)), 'utf8'); } catch { /* 落不了盘就照旧报太大 */ }
|
|
86
|
+
}
|
|
87
|
+
if (body.length > REPLY_MAX) {
|
|
88
|
+
body = Buffer.from(JSON.stringify({
|
|
89
|
+
ok: false,
|
|
90
|
+
error: {
|
|
91
|
+
code: 'TOO_LARGE',
|
|
92
|
+
message: `结果有 ${Math.round(body.length / 1024)}KB,超过原生消息 1MB 的上限`,
|
|
93
|
+
suggestion: 'snapshot 加 skeleton:true 先看轮廓,再用 root 往某一块里钻',
|
|
94
|
+
disposition: { delivery: 'unknown', retry: 'unsafe' },
|
|
95
|
+
},
|
|
96
|
+
}), 'utf8');
|
|
97
|
+
}
|
|
98
|
+
const len = Buffer.alloc(4);
|
|
99
|
+
len.writeUInt32LE(body.length, 0);
|
|
100
|
+
return new Promise((resolve) => process.stdout.write(Buffer.concat([len, body]), resolve));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function main() {
|
|
104
|
+
const msg = await readMessage();
|
|
105
|
+
const full = fullMode();
|
|
106
|
+
if (msg?.op === 'hello') {
|
|
107
|
+
const machine = machineId();
|
|
108
|
+
const probe = await runAgentDesktop(['list-apps'], { timeoutMs: 15000 });
|
|
109
|
+
return reply({
|
|
110
|
+
ok: true,
|
|
111
|
+
device: { id: desktopDeviceId(machine), kind: 'desktop', machine, caps: { platform: process.platform, version: VERSION, full } },
|
|
112
|
+
ready: !!probe.ok,
|
|
113
|
+
full,
|
|
114
|
+
...(probe.ok ? {} : { error: probe.error }),
|
|
115
|
+
binary: findBinary(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// 分页回包的后续段:不管完全模式,取的只是本宿主刚写下的那份
|
|
119
|
+
if (msg?.op === 'page') return reply(pageRead(msg.id, msg.offset));
|
|
120
|
+
// 扩展每条桌面命令前问一次:开关只在这台电脑上,扩展不缓存它
|
|
121
|
+
if (msg?.op === 'mode') return reply({ ok: true, full });
|
|
122
|
+
const offFull = () => reply(denied('这台电脑没有打开桌面完全模式。请用户在这台电脑的终端运行 npx vcorp-chrome desktop full on'));
|
|
123
|
+
if (msg?.op === 'shell') {
|
|
124
|
+
if (!full) return offFull();
|
|
125
|
+
return reply(await runShell(msg.params || {}), { paging: true });
|
|
126
|
+
}
|
|
127
|
+
if (msg?.op === 'file') {
|
|
128
|
+
if (!full) return offFull();
|
|
129
|
+
return reply(await fileOp(msg.params || {}), { paging: true });
|
|
130
|
+
}
|
|
131
|
+
if (msg?.op === 'run' && full) {
|
|
132
|
+
if (!fullArgvOk(msg.argv)) return reply(denied('argv 不合法'));
|
|
133
|
+
const timeoutMs = Math.min(Math.max(Number(msg.timeoutMs) || 30000, 1000), 7 * 60 * 1000);
|
|
134
|
+
return reply(await runAgentDesktop(msg.argv, { timeoutMs }), { paging: true });
|
|
135
|
+
}
|
|
136
|
+
if (msg?.op === 'run') {
|
|
137
|
+
const why = recheck(msg.argv);
|
|
138
|
+
if (why) return reply(denied(why));
|
|
139
|
+
const timeoutMs = Math.min(Math.max(Number(msg.timeoutMs) || 30000, 1000), 90000);
|
|
140
|
+
const env = await runAgentDesktop(msg.argv, { timeoutMs });
|
|
141
|
+
// 浏览器主窗口的标题和树不出这台电脑:扩展那侧也会滤,这里是第二道
|
|
142
|
+
if (env.ok && msg.argv[0] === 'list-windows') env.data = filterWindows(env.data, 'cloud');
|
|
143
|
+
if (env.ok && msg.argv[0] === 'snapshot') {
|
|
144
|
+
const rec = recordOf(env.data);
|
|
145
|
+
if (isBrowserMainTitle(rec?.window?.title)) return reply(denied('浏览器主窗口不出这台电脑'));
|
|
146
|
+
try { checkObserved(rec); } catch (e) { return reply(denied(e.message)); }
|
|
147
|
+
}
|
|
148
|
+
return reply(env);
|
|
149
|
+
}
|
|
150
|
+
return reply(denied(`未知操作 ${String(msg?.op).slice(0, 20)}`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
154
|
+
main().then(() => process.exit(0), async (e) => { await reply(denied(`宿主出错:${e.message}`)); process.exit(0); });
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -1,49 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "vcorp-chrome",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "让任何 AI agent 操控你自己的 Chrome——带着全部登录态。MCP + Chrome 扩展,Claude Code / Codex / Cursor / Gemini CLI 通用。",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"vcorp-chrome": "src/cli.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"src",
|
|
11
|
-
"extension",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"test
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "vcorp-chrome",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "让任何 AI agent 操控你自己的 Chrome——带着全部登录态。MCP + Chrome 扩展,Claude Code / Codex / Cursor / Gemini CLI 通用。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vcorp-chrome": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"extension",
|
|
12
|
+
"native-host",
|
|
13
|
+
"docs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"mcp": "node src/cli.js mcp",
|
|
21
|
+
"bridge": "node src/cli.js bridge --foreground",
|
|
22
|
+
"doctor": "node src/cli.js doctor",
|
|
23
|
+
"build:loads": "node scripts/build-loads.mjs",
|
|
24
|
+
"test": "node --test test/protocol.test.js test/mcp.test.js test/fetch-pages.test.js test/liveness.test.js test/redact.test.js test/audit-redact.test.js test/learnings.test.js test/risk.test.js test/identity.test.js test/host.test.js test/presence.test.js test/act-script.test.js test/multi-instance.test.js test/compat.test.js test/cloud-policy.test.js test/loads.test.js test/desktop-policy.test.js test/desktop-full.test.js",
|
|
25
|
+
"test:live": "node --test test/scenarios.test.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
29
|
+
"ws": "^8.18.3"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"chrome",
|
|
34
|
+
"browser-automation",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"codex",
|
|
37
|
+
"cursor",
|
|
38
|
+
"ai-agent"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"author": "Wizard",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/wizd/huashu-chrome.git"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"esbuild": "^0.24.2",
|
|
48
|
+
"jszip": "^3.10.1"
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"agent-desktop": "1.0.2"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/bridge.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 网页的 Origin 是自己的域名,扩展的是 chrome-extension://<id>。只放后者进来。
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
-
import { DEFAULT_PORT, writeBridgeInfo, newToken, tokenEquals, audit, ensureHome, ALLOWLIST_FILE } from './lib/paths.js';
|
|
11
|
+
import { DEFAULT_PORT, writeBridgeInfo, newToken, tokenEquals, audit, ensureHome, ALLOWLIST_FILE, machineId } from './lib/paths.js';
|
|
12
12
|
import { VERSION } from './lib/version.js';
|
|
13
13
|
// 纯字符串判定、不碰 chrome API,所以桥这边直接复用,不再抄一份
|
|
14
14
|
import { scrubProse } from '../extension/redact.js';
|
|
@@ -112,6 +112,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
112
112
|
return pool.reduce((a, b) => (a.lastRx >= b.lastRx ? a : b));
|
|
113
113
|
}
|
|
114
114
|
const extLabel = (ws) => `Chrome ${ws.chromeVersion || '?'}${ws.headless ? ' · headless' : ''} · 扩展 ${ws.extVersion}`;
|
|
115
|
+
let machineCache = null;
|
|
116
|
+
const machine = () => (machineCache ??= machineId());
|
|
115
117
|
|
|
116
118
|
const wss = new WebSocketServer({
|
|
117
119
|
host: '127.0.0.1',
|
|
@@ -216,6 +218,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
216
218
|
// 老扩展不报 instanceId。退回按扩展 id 认——行为和单槽那版一样
|
|
217
219
|
// (同一份代码的两个实例仍会互相替换),但至少不会跟新扩展混着算。
|
|
218
220
|
ws.instanceId = msg.instanceId || `ext:${msg.extId || '?'}`;
|
|
221
|
+
// 设备号由实例号派生:同一个 Chrome profile 重启、重载扩展都不变(见 extension/device.js)
|
|
222
|
+
ws.deviceId = `browser-${String(ws.instanceId).replace(/[^\w.-]/g, '').slice(0, 64) || 'unknown'}`;
|
|
219
223
|
|
|
220
224
|
// 同一个实例又连了一条:那是断线重连或重载扩展,旧的那条已经是死的,
|
|
221
225
|
// 顶掉它。**只顶同实例的**——顶错了就退回单槽,每秒互踢的老毛病就回来了。
|
|
@@ -241,6 +245,10 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
241
245
|
+ `命令路由到:${extLabel(primary())}`);
|
|
242
246
|
}
|
|
243
247
|
send(ws, { type: 'welcome', bridge: VERSION, v: PROTOCOL });
|
|
248
|
+
// 连上就给一份名单,哪怕是空的。扩展手里那份可能是上一个桥留下的:桥换过之后
|
|
249
|
+
// 旧连接不会再发 close,不推的话死会话要等下一条命令才被冲掉——面板就一直
|
|
250
|
+
// 显示着已经不在的 agent。
|
|
251
|
+
send(ws, { type: 'event', event: 'sessions', live: liveSessions() });
|
|
244
252
|
broadcast({ type: 'event', event: 'extension_online' });
|
|
245
253
|
flushWaiting();
|
|
246
254
|
return;
|
|
@@ -265,6 +273,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
265
273
|
const ext = primary();
|
|
266
274
|
send(ws, {
|
|
267
275
|
type: 'welcome', bridge: VERSION, v: PROTOCOL,
|
|
276
|
+
// 设备目录与按 to.device 投递。老桥没有这一项:它会把指名的命令照样投给默认的那个 Chrome
|
|
277
|
+
devices: true,
|
|
268
278
|
extensionOnline: !!ext,
|
|
269
279
|
extensionVersion: ext?.extVersion,
|
|
270
280
|
// 老 MCP 只认 versionMismatch:只在真需要人处理时置位,补丁号差异不再惊动它
|
|
@@ -286,6 +296,25 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
286
296
|
// agent → extension
|
|
287
297
|
if (msg.type === 'cmd') {
|
|
288
298
|
if (!agents.has(ws)) return;
|
|
299
|
+
// 本机设备目录:桥是唯一知道「有几个 Chrome 连着」的一方,这条由它自己答
|
|
300
|
+
if (msg.cmd === 'devices') {
|
|
301
|
+
const main = primary();
|
|
302
|
+
return send(ws, { type: 'res', id: msg.id, ok: true, data: { devices: liveExtensions().map((e) => ({
|
|
303
|
+
id: e.deviceId, kind: 'browser', name: extLabel(e), machine: machine(), online: true,
|
|
304
|
+
caps: { platform: 'chrome', version: e.extVersion, cmds: [] }, primary: e === main,
|
|
305
|
+
})) } });
|
|
306
|
+
}
|
|
307
|
+
// 指名道姓的命令只投给那一个实例。它不在就直说,不改投别的 Chrome——
|
|
308
|
+
// agent 指定设备正是因为它在意落在哪一台上
|
|
309
|
+
const want = msg.to?.device;
|
|
310
|
+
if (want) {
|
|
311
|
+
const hit = liveExtensions().find((e) => e.deviceId === want);
|
|
312
|
+
if (!hit) {
|
|
313
|
+
return send(ws, { type: 'res', id: msg.id, ok: false, error: { code: 'DEVICE_OFFLINE',
|
|
314
|
+
message: `浏览器设备 ${String(want).slice(0, 80)} 没有连着。用 devices 看看现在有哪些。` } });
|
|
315
|
+
}
|
|
316
|
+
return dispatch(ws, msg, hit);
|
|
317
|
+
}
|
|
289
318
|
const target = primary();
|
|
290
319
|
if (!target) return enqueue(ws, msg);
|
|
291
320
|
return dispatch(ws, msg, target);
|
|
@@ -349,7 +378,7 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
349
378
|
// 只有 281 个不同的 id,最多的一个出现了 1088 次。
|
|
350
379
|
// 路由用的一直是 connId:id,审计却记裸 id——「出事能查」这条承诺
|
|
351
380
|
// 在数据结构层面就不成立。
|
|
352
|
-
audit({ ev: 'cmd', id: key, cmd: msg.cmd, client: ws.client, sid: ws.sid, params: redact(msg.params) });
|
|
381
|
+
audit({ ev: 'cmd', id: key, cmd: msg.cmd, client: ws.client, sid: ws.sid, device: target.deviceId, params: redact(msg.params) });
|
|
353
382
|
// sid 盖章:扩展据此维护每个会话自己的受控 tab 槽(多 agent 并发隔离)。
|
|
354
383
|
// live 是此刻还连着的会话,扩展拿它判断某个标签页「还有没有主」。
|
|
355
384
|
// client / label 是给人看的:页面上的控制标记要写出「Codex CLI」而不是一串 sid。
|
package/src/cli.js
CHANGED
|
@@ -102,6 +102,42 @@ switch (cmd) {
|
|
|
102
102
|
await doctor();
|
|
103
103
|
break;
|
|
104
104
|
|
|
105
|
+
// 云端任务够到桌面要经原生消息宿主;本机 MCP 的 desktop 工具不需要这一步
|
|
106
|
+
case 'desktop': {
|
|
107
|
+
const nat = await import('./lib/native-install.js');
|
|
108
|
+
const sub = process.argv[3];
|
|
109
|
+
if (sub === 'install') {
|
|
110
|
+
const r = nat.install();
|
|
111
|
+
console.log(`已登记原生消息宿主 ${nat.HOST_NAME}(只允许扩展 ${r.id} 调用)`);
|
|
112
|
+
for (const p of r.placed) console.log(` ✅ ${p}`);
|
|
113
|
+
if (!r.placed.length) console.log(' ⚠️ 没找到已安装的 Chrome / Edge / Chromium 配置目录');
|
|
114
|
+
console.log('\n回到扩展侧栏点「启用桌面能力」。');
|
|
115
|
+
for (const l of (await nat.status()).lines) console.log(` ${l}`);
|
|
116
|
+
} else if (sub === 'uninstall') {
|
|
117
|
+
const r = nat.uninstall();
|
|
118
|
+
console.log(r.removed.length ? `已移除:\n ${r.removed.join('\n ')}` : '本来就没有登记。');
|
|
119
|
+
} else if (sub === 'status') {
|
|
120
|
+
const { fullMode } = await import('./lib/paths.js');
|
|
121
|
+
for (const l of (await nat.status()).lines) console.log(` ${l}`);
|
|
122
|
+
console.log(` 完全模式:${fullMode() ? '开(云端任务可以跑命令、读写文件、用全部桌面命令)' : '关'}`);
|
|
123
|
+
} else if (sub === 'full') {
|
|
124
|
+
const { setFullMode, FULL_FILE } = await import('./lib/paths.js');
|
|
125
|
+
const v = process.argv[4];
|
|
126
|
+
if (v !== 'on' && v !== 'off') {
|
|
127
|
+
console.log('用法:vcorp-chrome desktop full on | off');
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
if (setFullMode(v === 'on')) {
|
|
131
|
+
console.log(`已打开桌面完全模式(${FULL_FILE})。`);
|
|
132
|
+
console.log('配对服务器上的任务现在可以在这台电脑上跑任意命令、读写任意文件、操作任何应用。');
|
|
133
|
+
console.log('关掉:npx vcorp-chrome desktop full off');
|
|
134
|
+
} else console.log('已关闭桌面完全模式。');
|
|
135
|
+
} else {
|
|
136
|
+
console.log('用法:vcorp-chrome desktop install | uninstall | status | full on|off');
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
case 'extension':
|
|
106
142
|
printExtension();
|
|
107
143
|
// --reveal:在访达 / 资源管理器里选中扩展文件夹,用户拖进 chrome://extensions 就装好了。
|
|
@@ -133,6 +169,8 @@ switch (cmd) {
|
|
|
133
169
|
vcorp-chrome audit --stats [--days 7] 真实 agent 的用法统计:回合数、哪类调用最多、在哪儿浪费
|
|
134
170
|
vcorp-chrome bridge --foreground 前台跑桥(调试用)
|
|
135
171
|
vcorp-chrome install --dry-run 只看会改哪些配置,不写
|
|
172
|
+
vcorp-chrome desktop install|uninstall|status 让云端任务也能操作桌面应用(登记原生消息宿主)
|
|
173
|
+
vcorp-chrome desktop full on|off 完全模式:云端任务能跑命令、读写文件、用全部桌面命令
|
|
136
174
|
|
|
137
175
|
配置目录 ${HOME}`);
|
|
138
176
|
}
|
|
@@ -204,7 +242,6 @@ async function doctor() {
|
|
|
204
242
|
fs.existsSync(mf)
|
|
205
243
|
? ok(`${path.join(ROOT, 'extension')}${alive ? '' : ''}(这只说明文件在;Chrome 是否从这里加载了,看上面「扩展在线」那行)`)
|
|
206
244
|
: bad('扩展目录缺失', '重装 vcorp-chrome');
|
|
207
|
-
|
|
208
245
|
// bridge.log 里最近一次断连:用户体感的「插件消失」到底是什么时候、断了多久
|
|
209
246
|
try {
|
|
210
247
|
const lines = fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').slice(-400);
|
|
@@ -216,6 +253,18 @@ async function doctor() {
|
|
|
216
253
|
if (lastDown) console.log(` · 最近一次断开 ${lastDown}${lastUp ? `,最近一次连上 ${lastUp}` : ',之后没再连上'}(bridge.log,只有时分秒)`);
|
|
217
254
|
} catch { /* 没日志就没日志 */ }
|
|
218
255
|
|
|
256
|
+
console.log('\n桌面');
|
|
257
|
+
{
|
|
258
|
+
const { findBinary, runAgentDesktop } = await import('./lib/desktop.js');
|
|
259
|
+
const bin = findBinary();
|
|
260
|
+
const t0 = Date.now();
|
|
261
|
+
const env = await runAgentDesktop(['list-apps'], { bin, timeoutMs: 15000 });
|
|
262
|
+
if (env.ok) ok(`agent-desktop 可用 · ${(env.data?.apps || []).length} 个应用 · ${Date.now() - t0}ms(${bin})`);
|
|
263
|
+
else if (env.error?.code === 'PERM_DENIED') bad(`agent-desktop 没有辅助功能权限:${env.error.message}`, env.error.suggestion);
|
|
264
|
+
else bad(`desktop 工具用不了:[${env.error?.code}] ${env.error?.message}`,
|
|
265
|
+
'在 vcorp-chrome 的安装目录里补装 agent-desktop(npm i agent-desktop@1.0.2),或设 AGENT_DESKTOP_BIN 指到它的可执行文件');
|
|
266
|
+
}
|
|
267
|
+
|
|
219
268
|
// 经验库里 agent 记下的「vcorp-chrome 的 X 不生效」——产品 bug 住在经验库里,
|
|
220
269
|
// 没有这一栏就永远回不到开发者手上(screenshot 的 savePath 就是这么躺了一周的)
|
|
221
270
|
try {
|