vcorp-chrome 1.7.0 → 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/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//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 +143 -2
- package/extension/cloud-policy.js +421 -390
- package/extension/cloud.js +1228 -972
- package/extension/desktop-policy.js +400 -0
- package/extension/desktop-render.js +81 -0
- package/extension/device.js +144 -0
- package/extension/load.js +58 -0
- package/extension/loads/eda/LICENSE +41 -0
- package/extension/loads/eda/NOTICE +38 -0
- package/extension/loads/eda/agent.js +19301 -0
- package/extension/loads/index.js +200 -0
- package/extension/manifest.json +41 -38
- package/extension/mark.js +10 -3
- package/extension/panel.html +10 -0
- package/extension/panel.js +476 -427
- package/native-host/host.js +122 -0
- package/package.json +53 -44
- package/src/bridge.js +31 -2
- package/src/cli.js +35 -1
- package/src/lib/desktop.js +171 -0
- package/src/lib/native-install.js +139 -0
- package/src/lib/paths.js +20 -0
- package/src/lib/rpc.js +7 -2
- package/src/mcp-server.js +167 -10
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// 桌面能力的规矩 —— 纯函数,不碰任何 chrome / node API。
|
|
2
|
+
//
|
|
3
|
+
// 本机 MCP(src/lib/desktop.js)和云端通道(cloud.js → 原生宿主)用同一份。
|
|
4
|
+
// agent-desktop 自己只管系统权限,不管「哪个应用能碰」,所以闸门全在这里,
|
|
5
|
+
// 和页面侧一样:安全判断不放在模型里。
|
|
6
|
+
//
|
|
7
|
+
// 2026-11-02 在 Windows 上实测(agent-desktop 1.0.2)定下的几条:
|
|
8
|
+
// - Chrome 主窗口在 UIA 里整个是敞开的:地址栏、MetaMask / OKX 钱包按钮、密码管理、
|
|
9
|
+
// 扩展按钮,连 vcorp-chrome 侧栏里的「暂停」「断开并忘记这个服务器」「移除」网站、
|
|
10
|
+
// 「高保真模式」开关都有 ref。桌面一侧能点到它们,扩展里的每一道闸就都白设了。
|
|
11
|
+
// 所以浏览器的主窗口一律不碰,只放行浏览器弹出的系统文件对话框。
|
|
12
|
+
// - 系统文件对话框的控件有固定的自动化 id:文件名 1148、打开/保存 1、取消 2。
|
|
13
|
+
// 判断「这是文件对话框」认这组 id,不认标题(标题随语言变,也可能被页面影响)。
|
|
14
|
+
// - 每次调用要起一个进程,list-windows 最少约 730ms,Chrome 快照约 2.4s。
|
|
15
|
+
|
|
16
|
+
const err = (code, message) => Object.assign(new Error(message), { code });
|
|
17
|
+
|
|
18
|
+
// ---------- 应用身份 ----------
|
|
19
|
+
|
|
20
|
+
// 进程名 / 应用名的规范形式:小写、去掉 .exe / .app。「Weixin.exe」和「weixin」是同一个。
|
|
21
|
+
export function appKey(raw) {
|
|
22
|
+
return String(raw ?? '').trim().toLowerCase().replace(/\.(exe|app)$/, '').slice(0, 80);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BROWSERS = new Set([
|
|
26
|
+
'chrome', 'google chrome', 'msedge', 'microsoft edge', 'brave', 'brave browser', 'chromium',
|
|
27
|
+
'firefox', 'mozilla firefox', 'opera', 'vivaldi', 'arc', 'safari', 'yandex', '360se', '360chrome',
|
|
28
|
+
'qqbrowser', 'sogouexplorer', 'doubao',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function isBrowserApp(app) {
|
|
32
|
+
return BROWSERS.has(appKey(app));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 浏览器主窗口的标题以浏览器名收尾(「VCorp Super Agent - Google Chrome」)
|
|
36
|
+
const BROWSER_TITLE = /\s[-–—]\s(Google Chrome|Chromium|Microsoft? Edge|Brave|Mozilla Firefox|Opera|Vivaldi|Arc)$/i;
|
|
37
|
+
|
|
38
|
+
export function isBrowserMainTitle(title) {
|
|
39
|
+
return BROWSER_TITLE.test(String(title ?? ''));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 快照树里有没有系统文件对话框的那组控件
|
|
43
|
+
export function isFileDialogTree(tree) {
|
|
44
|
+
let name = false;
|
|
45
|
+
let ok = false;
|
|
46
|
+
const walk = (n, depth) => {
|
|
47
|
+
if (!n || typeof n !== 'object' || depth > 40 || (name && ok)) return;
|
|
48
|
+
const id = n.native_id?.value;
|
|
49
|
+
if (id === '1148') name = true;
|
|
50
|
+
if (id === '1' && n.role === 'button') ok = true;
|
|
51
|
+
for (const c of Array.isArray(n.children) ? n.children : []) walk(c, depth + 1);
|
|
52
|
+
};
|
|
53
|
+
walk(tree, 0);
|
|
54
|
+
return name && ok;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 两条通道都不碰的:密码管理器(凭据不进上下文)
|
|
58
|
+
const SECRET_APPS = new Set([
|
|
59
|
+
'1password', 'bitwarden', 'keepass', 'keepassxc', 'lastpass', 'dashlane', 'enpass', 'keychain access',
|
|
60
|
+
'keeper', 'nordpass', 'roboform', 'proton pass',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// 云端还不碰的:能拿到整台机器的东西。本机 agent 本来就有 shell,拦它们没有意义
|
|
64
|
+
const CLOUD_DENIED_APPS = new Set([
|
|
65
|
+
'powershell', 'pwsh', 'cmd', 'windowsterminal', 'terminal', 'iterm2', 'conhost', 'wt', 'openconsole',
|
|
66
|
+
'regedit', 'mmc', 'taskmgr', 'sechealthui', 'systemsettings', 'control', 'system preferences',
|
|
67
|
+
'system settings', 'mstsc', 'gnome-terminal', 'konsole', 'xterm', 'alacritty', 'wezterm', 'kitty',
|
|
68
|
+
'cursor', 'code', 'goose', 'claude', 'codex',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
export function deniedApp(app, channel) {
|
|
72
|
+
const k = appKey(app);
|
|
73
|
+
if (!k) return null;
|
|
74
|
+
if (SECRET_APPS.has(k)) return `${app} 是密码管理器,桌面能力不碰它`;
|
|
75
|
+
if (channel === 'cloud' && CLOUD_DENIED_APPS.has(k)) return `云端任务不能操作 ${app}`;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- 敏感动作 ----------
|
|
80
|
+
|
|
81
|
+
export const PAY_NAME = /支付|付款|下单|结算|购买|充值|提现|转账|打赏|续费|红包|pay\s?now|checkout|place\s?order|buy\s?now|purchase|transfer/i;
|
|
82
|
+
export const SENSITIVE_NAME = /发送|发布|提交|删除|移除|清空|卸载|格式化|退出登录|注销|send|submit|publish|delete|remove|erase|uninstall|format|sign\s?out|log\s?out/i;
|
|
83
|
+
|
|
84
|
+
// 'pay':要花钱,必须人点头,没有跳过的办法;'sensitive':对外或不可逆,不自动重试、不进批处理
|
|
85
|
+
export function sensitivity(name) {
|
|
86
|
+
const s = String(name ?? '');
|
|
87
|
+
if (PAY_NAME.test(s)) return 'pay';
|
|
88
|
+
if (SENSITIVE_NAME.test(s)) return 'sensitive';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- 命令表 ----------
|
|
93
|
+
|
|
94
|
+
// kind:read 只看不动,write 会改应用状态。ref:要不要一个快照里的 ref。
|
|
95
|
+
// cloud / local:这条通道能不能用。
|
|
96
|
+
const C = (kind, o = {}) => ({ kind, cloud: true, local: true, ref: false, ...o });
|
|
97
|
+
export const DESKTOP_CMDS = {
|
|
98
|
+
'list-apps': C('read'),
|
|
99
|
+
'list-windows': C('read'),
|
|
100
|
+
snapshot: C('read'),
|
|
101
|
+
find: C('read'),
|
|
102
|
+
get: C('read', { ref: true }),
|
|
103
|
+
screenshot: C('read', { cloud: false }), // 原生消息单条回包 1MB 上限,PNG 常常超
|
|
104
|
+
wait: C('read'),
|
|
105
|
+
click: C('write', { ref: true }),
|
|
106
|
+
'double-click': C('write', { ref: true }),
|
|
107
|
+
'right-click': C('write', { ref: true }),
|
|
108
|
+
type: C('write', { ref: true }),
|
|
109
|
+
'set-value': C('write', { ref: true }),
|
|
110
|
+
clear: C('write', { ref: true }),
|
|
111
|
+
focus: C('write', { ref: true }),
|
|
112
|
+
select: C('write', { ref: true }),
|
|
113
|
+
toggle: C('write', { ref: true }),
|
|
114
|
+
check: C('write', { ref: true }),
|
|
115
|
+
uncheck: C('write', { ref: true }),
|
|
116
|
+
expand: C('write', { ref: true }),
|
|
117
|
+
collapse: C('write', { ref: true }),
|
|
118
|
+
scroll: C('write', { ref: true }),
|
|
119
|
+
'scroll-to': C('write', { ref: true }),
|
|
120
|
+
press: C('write'),
|
|
121
|
+
launch: C('write'),
|
|
122
|
+
'focus-window': C('write', { cloud: false }), // 抢用户的焦点
|
|
123
|
+
'clipboard-set': C('write'),
|
|
124
|
+
'clipboard-get': C('read', { cloud: false }), // 剪贴板里常常是刚复制的密码
|
|
125
|
+
'list-notifications': C('read', { cloud: false }), // 私信、验证码
|
|
126
|
+
'notification-action': C('write', { cloud: false }),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const REF_RE = /^@s[a-z0-9]{4,40}:e\d{1,6}$/;
|
|
130
|
+
const SESSION_RE = /^[\w-]{1,64}$/;
|
|
131
|
+
const WINDOW_RE = /^w-\d{1,20}$/;
|
|
132
|
+
const COMBO_RE = /^[a-z0-9][a-z0-9+_-]{0,39}$/i;
|
|
133
|
+
const ROLE_RE = /^[a-z_-]{1,30}$/;
|
|
134
|
+
|
|
135
|
+
const str = (v, max) => String(v ?? '').replace(/\0/g, '').slice(0, max);
|
|
136
|
+
const int = (v, lo, hi, dflt) => {
|
|
137
|
+
const n = Math.floor(Number(v));
|
|
138
|
+
return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function snapshotIdOf(ref) {
|
|
142
|
+
const m = /^@(s[a-z0-9]+):e\d+$/.exec(String(ref ?? ''));
|
|
143
|
+
return m ? m[1] : '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 一份快照的户口:这些 ref 属于哪个应用、哪个窗口,是不是文件对话框,每个 ref 叫什么。
|
|
147
|
+
// 执行器在快照(和 find)回来之后记下它,之后每条 ref 命令都对着它判。
|
|
148
|
+
export function recordOf(data) {
|
|
149
|
+
if (!data || typeof data !== 'object' || !data.snapshot_id) return null;
|
|
150
|
+
const refs = {};
|
|
151
|
+
let n = 0;
|
|
152
|
+
const walk = (node, depth) => {
|
|
153
|
+
if (!node || typeof node !== 'object' || depth > 60 || n > 4000) return;
|
|
154
|
+
if (node.ref_id) {
|
|
155
|
+
const e = /:(e\d+)$/.exec(node.ref_id)?.[1];
|
|
156
|
+
if (e) { refs[e] = { role: str(node.role, 30), name: str(node.name, 80) }; n += 1; }
|
|
157
|
+
}
|
|
158
|
+
for (const c of Array.isArray(node.children) ? node.children : []) walk(c, depth + 1);
|
|
159
|
+
};
|
|
160
|
+
walk(data.tree, 0);
|
|
161
|
+
if (data.match) walk(data.match, 0);
|
|
162
|
+
for (const m of Array.isArray(data.matches) ? data.matches : []) walk(m, 0);
|
|
163
|
+
return {
|
|
164
|
+
id: String(data.snapshot_id),
|
|
165
|
+
app: str(data.app, 80),
|
|
166
|
+
window: data.window && typeof data.window === 'object'
|
|
167
|
+
? { id: str(data.window.id, 40), title: str(data.window.title, 200) } : null,
|
|
168
|
+
fileDialog: data.tree ? isFileDialogTree(data.tree) : false,
|
|
169
|
+
refs,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 浏览器的窗口只认文件对话框。preflight:对一个非主窗口先拍一张快照,
|
|
174
|
+
// 拍回来由 checkObserved 认它是不是文件对话框——在那之前它的 ref 不进户口。
|
|
175
|
+
function browserGate(app, title, fileDialog, { preflight = false } = {}) {
|
|
176
|
+
if (!isBrowserApp(app) || fileDialog) return null;
|
|
177
|
+
if (preflight && title && !isBrowserMainTitle(title)) return null;
|
|
178
|
+
return `不能操作浏览器窗口(${app}${title ? `「${title.slice(0, 40)}」` : ''}):网页用浏览器工具;`
|
|
179
|
+
+ '浏览器弹出的打开/另存为对话框,先 list-windows 拿到它的 window 再 snapshot。';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 审一条桌面命令,返回 agent-desktop 的参数数组(不含可执行文件本身)。
|
|
183
|
+
// ctx.channel:'local' | 'cloud';ctx.session:快照命名空间;
|
|
184
|
+
// ctx.lookup(snapshotId):取 recordOf 记下的户口;ctx.apps:云端本任务批准的应用(appKey 列表);
|
|
185
|
+
// ctx.windows(windowId):取 list-windows 里那个窗口的 {app, title}(执行器预取)。
|
|
186
|
+
// 返回 { cmd, argv, kind, app, target, sensitive },不合规直接抛。
|
|
187
|
+
export function checkDesktop(params, ctx = {}) {
|
|
188
|
+
const p = params && typeof params === 'object' ? params : {};
|
|
189
|
+
const channel = ctx.channel === 'cloud' ? 'cloud' : 'local';
|
|
190
|
+
const cmd = String(p.cmd || '');
|
|
191
|
+
const spec = DESKTOP_CMDS[cmd];
|
|
192
|
+
if (!spec) throw err('DESKTOP_DENIED', `desktop 不支持 ${cmd || '(没写 cmd)'}`);
|
|
193
|
+
if (!spec[channel]) throw err('DESKTOP_DENIED', `${channel === 'cloud' ? '云端任务' : '本机'}不能用 desktop ${cmd}`);
|
|
194
|
+
if (p.headed && channel === 'cloud') throw err('DESKTOP_DENIED', '云端任务不能用真实鼠标键盘(headed)');
|
|
195
|
+
|
|
196
|
+
const argv = [cmd];
|
|
197
|
+
let app = p.app !== undefined ? str(p.app, 80).trim() : '';
|
|
198
|
+
const win = p.window !== undefined ? str(p.window, 40) : '';
|
|
199
|
+
if (win && !WINDOW_RE.test(win)) throw err('DESKTOP_DENIED', 'window 要用 list-windows 给的 id,如 w-460112');
|
|
200
|
+
let title = '';
|
|
201
|
+
if (win) {
|
|
202
|
+
const w = ctx.windows?.(win);
|
|
203
|
+
if (!w) throw err('DESKTOP_DENIED', `不认识窗口 ${win}:先 list-windows`);
|
|
204
|
+
app = app || w.app;
|
|
205
|
+
title = w.title || '';
|
|
206
|
+
if (appKey(w.app) !== appKey(app)) throw err('DESKTOP_DENIED', `窗口 ${win} 属于 ${w.app},不是 ${app}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let rec = null;
|
|
210
|
+
let target = null;
|
|
211
|
+
if (spec.ref || p.root !== undefined) {
|
|
212
|
+
const ref = str(spec.ref ? p.ref : p.root, 60);
|
|
213
|
+
if (!REF_RE.test(ref)) throw err('DESKTOP_DENIED', 'ref 要用快照里完整的形式,如 @s8f3k2p9:e8');
|
|
214
|
+
rec = ctx.lookup?.(snapshotIdOf(ref)) || null;
|
|
215
|
+
if (!rec) throw err('DESKTOP_DENIED', `不认识 ${ref} 所属的快照:先在这个会话里 snapshot 一次`);
|
|
216
|
+
app = rec.app;
|
|
217
|
+
title = rec.window?.title || '';
|
|
218
|
+
target = rec.refs[/:(e\d+)$/.exec(ref)[1]] || null;
|
|
219
|
+
if (spec.ref) argv.push(ref);
|
|
220
|
+
else argv.push('--root', ref);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const appless = ['list-apps', 'clipboard-set', 'clipboard-get', 'list-notifications', 'notification-action'];
|
|
224
|
+
if (!app && !appless.includes(cmd) && !(cmd === 'wait' && p.ms !== undefined && p.text === undefined)) {
|
|
225
|
+
if (channel === 'cloud' || cmd !== 'list-windows') throw err('DESKTOP_DENIED', `desktop ${cmd} 要给 app 或 window`);
|
|
226
|
+
}
|
|
227
|
+
if (app) {
|
|
228
|
+
const why = deniedApp(app, channel);
|
|
229
|
+
if (why) throw err('DESKTOP_DENIED', why);
|
|
230
|
+
if (channel === 'cloud' && !(ctx.apps || []).includes(appKey(app))) {
|
|
231
|
+
throw err('APP_NOT_APPROVED', `本任务还不能操作 ${app}。先用 apps 申请,用户点头之后再来。`);
|
|
232
|
+
}
|
|
233
|
+
const gate = cmd === 'list-windows' ? null
|
|
234
|
+
: browserGate(app, title, rec?.fileDialog, { preflight: cmd === 'snapshot' && !!win && !rec });
|
|
235
|
+
if (gate) throw err('DESKTOP_DENIED', gate);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 用户给的文字一律不能被当成开关:选项值写成 --opt=值,自由文本放到 -- 之后。
|
|
239
|
+
// 实测 `type @ref --headed` 会被 agent-desktop 当成开关解析——云端的一段文字就能换成真实输入。
|
|
240
|
+
const opt = (name, v) => argv.push(`${name}=${v}`);
|
|
241
|
+
const tail = [];
|
|
242
|
+
const where = () => (win ? opt('--window-id', win) : opt('--app', app));
|
|
243
|
+
switch (cmd) {
|
|
244
|
+
case 'list-windows':
|
|
245
|
+
if (app) opt('--app', app);
|
|
246
|
+
break;
|
|
247
|
+
case 'snapshot':
|
|
248
|
+
if (!rec) where();
|
|
249
|
+
if (!p.all) argv.push('-i');
|
|
250
|
+
argv.push('--compact');
|
|
251
|
+
if (p.skeleton) argv.push('--skeleton');
|
|
252
|
+
if (p.timeout !== undefined) opt('--timeout-ms', int(p.timeout, 500, 20000, 3000));
|
|
253
|
+
break;
|
|
254
|
+
case 'find': {
|
|
255
|
+
if (!rec) where();
|
|
256
|
+
if (p.role === undefined && p.name === undefined && p.text === undefined) throw err('DESKTOP_DENIED', 'find 要给 role / name / text 里至少一个');
|
|
257
|
+
if (p.role !== undefined) {
|
|
258
|
+
const role = str(p.role, 30);
|
|
259
|
+
if (!ROLE_RE.test(role)) throw err('DESKTOP_DENIED', 'role 不合法');
|
|
260
|
+
opt('--role', role);
|
|
261
|
+
}
|
|
262
|
+
if (p.name !== undefined) opt('--name', str(p.name, 200));
|
|
263
|
+
if (p.text !== undefined) opt('--text', str(p.text, 200));
|
|
264
|
+
if (p.nth !== undefined) opt('--nth', int(p.nth, 0, 500, 0));
|
|
265
|
+
else opt('--limit', 20);
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case 'get':
|
|
269
|
+
if (p.property !== undefined) {
|
|
270
|
+
const prop = str(p.property, 10);
|
|
271
|
+
if (!['text', 'value', 'title', 'bounds', 'role', 'states'].includes(prop)) throw err('DESKTOP_DENIED', 'property 不合法');
|
|
272
|
+
opt('--property', prop);
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
case 'screenshot':
|
|
276
|
+
where();
|
|
277
|
+
break;
|
|
278
|
+
case 'wait':
|
|
279
|
+
if (p.text !== undefined) { opt('--text', str(p.text, 200)); opt('--app', app); opt('--timeout', int(p.timeout, 100, 60000, 10000)); }
|
|
280
|
+
else if (p.windowTitle !== undefined) { opt('--window', str(p.windowTitle, 200)); opt('--timeout', int(p.timeout, 100, 60000, 10000)); }
|
|
281
|
+
else argv.push(String(int(p.ms, 0, 30000, 1000)));
|
|
282
|
+
break;
|
|
283
|
+
case 'type':
|
|
284
|
+
if (p.text === undefined) throw err('DESKTOP_DENIED', 'type 要给 text');
|
|
285
|
+
tail.push(str(p.text, 5000));
|
|
286
|
+
break;
|
|
287
|
+
case 'set-value':
|
|
288
|
+
case 'select':
|
|
289
|
+
if (p.value === undefined) throw err('DESKTOP_DENIED', `${cmd} 要给 value`);
|
|
290
|
+
tail.push(str(p.value, 5000));
|
|
291
|
+
break;
|
|
292
|
+
case 'scroll':
|
|
293
|
+
opt('--direction', ['up', 'down', 'left', 'right'].includes(p.direction) ? p.direction : 'down');
|
|
294
|
+
opt('--amount', int(p.amount, 1, 50, 3));
|
|
295
|
+
break;
|
|
296
|
+
case 'press': {
|
|
297
|
+
const combo = str(p.keys, 40);
|
|
298
|
+
if (!COMBO_RE.test(combo)) throw err('DESKTOP_DENIED', 'keys 要写成 return / escape / ctrl+s 这样');
|
|
299
|
+
argv.push(combo);
|
|
300
|
+
where();
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case 'launch':
|
|
304
|
+
tail.push(app);
|
|
305
|
+
break;
|
|
306
|
+
case 'focus-window':
|
|
307
|
+
where();
|
|
308
|
+
break;
|
|
309
|
+
case 'clipboard-set':
|
|
310
|
+
if (p.text === undefined) throw err('DESKTOP_DENIED', 'clipboard-set 要给 text');
|
|
311
|
+
tail.push(str(p.text, 20000));
|
|
312
|
+
break;
|
|
313
|
+
case 'list-notifications':
|
|
314
|
+
if (p.app !== undefined) opt('--app', str(p.app, 80));
|
|
315
|
+
opt('--limit', 20);
|
|
316
|
+
break;
|
|
317
|
+
case 'notification-action': {
|
|
318
|
+
const by = str(p.app, 80);
|
|
319
|
+
if (!by) throw err('DESKTOP_DENIED', 'notification-action 要给 app:通知来自哪个应用');
|
|
320
|
+
if (isBrowserApp(by) || /vcorp/i.test(by)) throw err('DESKTOP_DENIED', '不能替用户点浏览器发出的通知——那里面有任务批准和付款确认');
|
|
321
|
+
opt('--expected-app', by);
|
|
322
|
+
argv.push(String(int(p.index, 1, 100, 1)));
|
|
323
|
+
tail.push(str(p.action, 60));
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (p.headed) argv.push('--headed');
|
|
331
|
+
const session = str(ctx.session, 64);
|
|
332
|
+
if (session) {
|
|
333
|
+
if (!SESSION_RE.test(session)) throw err('DESKTOP_DENIED', 'session 不合法');
|
|
334
|
+
if (cmd !== 'list-apps' && cmd !== 'clipboard-set' && cmd !== 'clipboard-get') opt('--session', session);
|
|
335
|
+
}
|
|
336
|
+
if (tail.length) argv.push('--', ...tail);
|
|
337
|
+
|
|
338
|
+
const sensitive = spec.kind === 'write' ? sensitivity(target?.name) : null;
|
|
339
|
+
return { cmd, argv, kind: spec.kind, app, target, sensitive };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 快照(或 find)回来之后的复核:浏览器的窗口只认文件对话框。
|
|
343
|
+
// snapshot 允许先拍一张浏览器的非主窗口,拍回来不是文件对话框就整份作废,树不出门。
|
|
344
|
+
export function checkObserved(rec) {
|
|
345
|
+
if (!rec) return;
|
|
346
|
+
const gate = browserGate(rec.app, rec.window?.title || '', rec.fileDialog);
|
|
347
|
+
if (gate) throw err('DESKTOP_DENIED', gate);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// list-windows 的结果出门前过一道:浏览器主窗口的标题是用户正在看的网页,云端不给。
|
|
351
|
+
// 本机给,但标上「不可操作」,免得 agent 拿它去 snapshot。
|
|
352
|
+
export function filterWindows(list, channel) {
|
|
353
|
+
const arr = Array.isArray(list) ? list : [];
|
|
354
|
+
return arr.flatMap((w) => {
|
|
355
|
+
const main = isBrowserApp(w.app_name) && isBrowserMainTitle(w.title);
|
|
356
|
+
if (main && channel === 'cloud') return [];
|
|
357
|
+
if (main) return [{ ...w, blocked: '浏览器主窗口:用浏览器工具操作' }];
|
|
358
|
+
if (deniedApp(w.app_name, channel)) return channel === 'cloud' ? [] : [{ ...w, blocked: deniedApp(w.app_name, channel) }];
|
|
359
|
+
return [w];
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 批处理:一步一步过同一套检查。敏感动作不代做,和页面侧的 act 一个规矩。
|
|
364
|
+
export const STEPS_MAX = 20;
|
|
365
|
+
export function checkSteps(steps) {
|
|
366
|
+
if (!Array.isArray(steps) || !steps.length) throw err('DESKTOP_DENIED', 'steps 要是非空数组');
|
|
367
|
+
if (steps.length > STEPS_MAX) throw err('DESKTOP_DENIED', `steps 最多 ${STEPS_MAX} 步`);
|
|
368
|
+
for (const st of steps) {
|
|
369
|
+
if (!st || typeof st !== 'object' || Array.isArray(st)) throw err('DESKTOP_DENIED', 'steps 的每一步都要是对象');
|
|
370
|
+
if (st.steps !== undefined) throw err('DESKTOP_DENIED', 'steps 不能嵌套');
|
|
371
|
+
if (!DESKTOP_CMDS[st.cmd]) throw err('DESKTOP_DENIED', `steps 里不支持 ${st.cmd}`);
|
|
372
|
+
}
|
|
373
|
+
return steps;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------- 云端:应用申请 ----------
|
|
377
|
+
|
|
378
|
+
export const APPS_MAX = 10;
|
|
379
|
+
|
|
380
|
+
export function cleanApps(list) {
|
|
381
|
+
const apps = [];
|
|
382
|
+
const bad = [];
|
|
383
|
+
for (const raw of [].concat(list ?? [])) {
|
|
384
|
+
const k = appKey(raw);
|
|
385
|
+
if (!k || !/^[\p{L}\p{N} ._()-]{1,80}$/u.test(k)) { bad.push(String(raw).slice(0, 40)); continue; }
|
|
386
|
+
if (isBrowserApp(k) || deniedApp(k, 'cloud')) { bad.push(String(raw).slice(0, 40)); continue; }
|
|
387
|
+
if (!apps.includes(k)) apps.push(k);
|
|
388
|
+
}
|
|
389
|
+
return { apps: apps.slice(0, APPS_MAX), bad, overflow: apps.slice(APPS_MAX) };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function appsToAsk(add, { covered = [], apps = [], refused = [] } = {}) {
|
|
393
|
+
const ask = [];
|
|
394
|
+
const already = [];
|
|
395
|
+
for (const a of add) {
|
|
396
|
+
if (covered.includes(a) || apps.includes(a)) already.push(a);
|
|
397
|
+
else if (!refused.includes(a)) ask.push(a);
|
|
398
|
+
}
|
|
399
|
+
return { ask, covered: already };
|
|
400
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// agent-desktop 信封 → 给模型看的短文本。纯函数:本机执行器(src/lib/desktop.js)和
|
|
2
|
+
// 云端通道(cloud.js,经原生宿主)用同一份,两边的 agent 读到的是同一种回执。
|
|
3
|
+
import { DESKTOP_CMDS } from './desktop-policy.js';
|
|
4
|
+
|
|
5
|
+
const LINES_MAX = 400;
|
|
6
|
+
|
|
7
|
+
export function renderTree(tree) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
let cut = 0;
|
|
10
|
+
const walk = (n, depth) => {
|
|
11
|
+
if (!n || typeof n !== 'object') return;
|
|
12
|
+
const named = n.name || n.value;
|
|
13
|
+
const show = n.ref_id || (named && depth < 30);
|
|
14
|
+
if (show) {
|
|
15
|
+
if (lines.length >= LINES_MAX) { cut += 1; } else {
|
|
16
|
+
const bits = [];
|
|
17
|
+
if (n.value !== undefined && n.value !== null && n.value !== n.name) bits.push(`value: ${JSON.stringify(String(n.value).slice(0, 120))}`);
|
|
18
|
+
for (const s of n.states || []) if (s !== 'readonly') bits.push(s);
|
|
19
|
+
if (n.subtree_truncated) bits.push('subtree_truncated');
|
|
20
|
+
if (n.children_count !== undefined && !n.children) bits.push(`${n.children_count} children`);
|
|
21
|
+
const ref = n.ref_id ? `[${n.ref_id}] ` : '';
|
|
22
|
+
lines.push(`${' '.repeat(Math.min(depth, 12))}${ref}${n.role || '?'}${n.name ? ` ${JSON.stringify(String(n.name).slice(0, 80))}` : ''}${bits.length ? ` (${bits.join(', ')})` : ''}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const c of Array.isArray(n.children) ? n.children : []) walk(c, show ? depth + 1 : depth);
|
|
26
|
+
};
|
|
27
|
+
walk(tree, 0);
|
|
28
|
+
if (cut) lines.push(`…还有 ${cut} 个节点没列出:用 root:<ref> 往某一块里钻,或 skeleton:true 先看轮廓`);
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// disposition → 效果行。agent-desktop 的语义:delivered_verified 读回过,
|
|
33
|
+
// delivered_unverified 只是发出去了,retry: unsafe 就是「可能已经生效」。
|
|
34
|
+
export function effectLine(env) {
|
|
35
|
+
const d = env?.data?.disposition || env?.error?.disposition;
|
|
36
|
+
if (env?.ok) {
|
|
37
|
+
if (!d) return '';
|
|
38
|
+
if (d.delivery === 'delivered_verified') return `效果:已读回验证${env.data.details?.verification_scope ? `(${env.data.details.verification_scope})` : ''}`;
|
|
39
|
+
if (d.delivery === 'delivered_unverified') return '效果:已送达,但没有读回证据——重新 snapshot 核对它变成了你要的样子没有';
|
|
40
|
+
if (d.delivery === 'not_delivered') return '效果:本来就是这个状态,什么都没做';
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
if (!d) return '';
|
|
44
|
+
if (d.retry === 'safe') return '没有送达,可以按建议重试';
|
|
45
|
+
return '⚠️ 这一下可能已经生效了。不要直接重试,先 snapshot 看现状';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderData(cmd, data) {
|
|
49
|
+
if (cmd === 'snapshot') {
|
|
50
|
+
const head = `# ${data.app || ''}${data.window?.title ? ` — ${data.window.title}` : ''}${data.window?.id ? ` [${data.window.id}]` : ''} [snapshot ${data.snapshot_id}] ${data.ref_count ?? 0} refs${data.complete === false ? '(不完整:观察时间用完了,可以 timeout 调大或用 root 往下钻)' : ''}`;
|
|
51
|
+
return `${head}\n\n${renderTree(data.tree)}`;
|
|
52
|
+
}
|
|
53
|
+
if (cmd === 'find') {
|
|
54
|
+
const list = data.match ? [data.match] : Array.isArray(data.matches) ? data.matches : [];
|
|
55
|
+
if (data.count !== undefined && !list.length) return `命中 ${data.count} 个`;
|
|
56
|
+
return list.map((m) => `[${m.ref_id}] ${m.role} ${JSON.stringify(m.name || '')}${m.value ? ` value: ${JSON.stringify(String(m.value).slice(0, 80))}` : ''} ← ${(m.path || []).slice(-3).join(' › ')}`).join('\n') || '没有匹配';
|
|
57
|
+
}
|
|
58
|
+
if (cmd === 'list-windows') {
|
|
59
|
+
return (data || []).map((w) => `${w.id} ${w.app_name} ${JSON.stringify(w.title || '')}${w.minimized ? ' (minimized)' : ''}${w.is_focused ? ' (focused)' : ''}${w.blocked ? ` ⛔ ${w.blocked}` : ''}`).join('\n') || '没有窗口';
|
|
60
|
+
}
|
|
61
|
+
if (cmd === 'list-apps') return (data?.apps || []).map((a) => a.name).join('\n');
|
|
62
|
+
if (cmd === 'list-notifications') return JSON.stringify(data, null, 1).slice(0, 6000);
|
|
63
|
+
const keep = { ...data };
|
|
64
|
+
delete keep.disposition;
|
|
65
|
+
delete keep.steps;
|
|
66
|
+
if (keep.details) { delete keep.details.auto_wait; if (!Object.keys(keep.details).length) delete keep.details; }
|
|
67
|
+
const s = JSON.stringify(keep);
|
|
68
|
+
return s === '{}' ? '' : s.slice(0, 4000);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderEnvelope(cmd, env, { target } = {}) {
|
|
72
|
+
const what = `${cmd}${target ? ` ${target.role} ${JSON.stringify(target.name || '')}` : ''}`;
|
|
73
|
+
if (!env?.ok) {
|
|
74
|
+
const e = env?.error || {};
|
|
75
|
+
return [`❌ ${what}:${e.code || 'INTERNAL'} ${e.message || ''}`, e.suggestion ? `建议:${e.suggestion}` : '', effectLine(env)].filter(Boolean).join('\n');
|
|
76
|
+
}
|
|
77
|
+
const body = renderData(cmd, env.data);
|
|
78
|
+
const eff = effectLine(env);
|
|
79
|
+
const head = DESKTOP_CMDS[cmd]?.kind === 'write' ? `✅ ${what}` : '';
|
|
80
|
+
return [head, eff, body].filter(Boolean).join('\n');
|
|
81
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// 设备模型 —— 一个用户、多台设备。纯函数,不碰任何 chrome / node API。
|
|
2
|
+
//
|
|
3
|
+
// 桥(本机)、扩展(云端中继)、服务器(opus-video-maker/extlink.js 抄一份)按同一套规则
|
|
4
|
+
// 回答两个问题:命令要下到哪台设备,结果是从哪台设备来的。协议见 docs/协议.md 第十二节。
|
|
5
|
+
//
|
|
6
|
+
// 四个概念:
|
|
7
|
+
// - 设备 device:能执行命令的独立终端。有自己的 id、类型 kind、能力清单 caps、所在机器 machine。
|
|
8
|
+
// - 链路 link:设备怎么连上来。浏览器自己连;云端的桌面经同机浏览器中继。身份不随链路变。
|
|
9
|
+
// - 机器 machine:同一台电脑上的设备共用一个 machineId。交接只在同机设备之间成立。
|
|
10
|
+
// - 任务绑定:任务开始时把需要的设备钉住,整个任务不换设备。
|
|
11
|
+
|
|
12
|
+
export const PROTOCOL_V = 2;
|
|
13
|
+
|
|
14
|
+
export const KINDS = ['browser', 'desktop', 'mobile'];
|
|
15
|
+
|
|
16
|
+
// 命令属于哪种能力。没写 cap 的老消息一律是浏览器命令——老扩展、老服务器照常工作。
|
|
17
|
+
export const CAPS = ['browser', 'desktop'];
|
|
18
|
+
|
|
19
|
+
export const DEVICE_ERRORS = {
|
|
20
|
+
NO_DEVICE: 'NO_DEVICE',
|
|
21
|
+
DEVICE_OFFLINE: 'DEVICE_OFFLINE',
|
|
22
|
+
DEVICE_NOT_IN_JOB: 'DEVICE_NOT_IN_JOB',
|
|
23
|
+
AMBIGUOUS_DEVICE: 'AMBIGUOUS_DEVICE',
|
|
24
|
+
DEVICE_NOT_HERE: 'DEVICE_NOT_HERE',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ID_RE = /^[\w.:-]{1,80}$/;
|
|
28
|
+
|
|
29
|
+
const err = (code, message, details) => Object.assign(new Error(message), { code, ...(details ? { details } : {}) });
|
|
30
|
+
|
|
31
|
+
export function cleanDeviceId(raw) {
|
|
32
|
+
const s = String(raw ?? '').trim();
|
|
33
|
+
return ID_RE.test(s) ? s : '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function cleanKind(raw) {
|
|
37
|
+
return KINDS.includes(raw) ? raw : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function capOf(msg) {
|
|
41
|
+
const c = msg?.cap;
|
|
42
|
+
return CAPS.includes(c) ? c : 'browser';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 能力 → 能执行它的设备类型。手机以后会认领一部分 browser 命令(内置浏览器)和自己的 mobile 能力,
|
|
46
|
+
// 那时在这里加映射,而不是在每个调用方各写一份。
|
|
47
|
+
export function kindsFor(cap) {
|
|
48
|
+
return cap === 'desktop' ? ['desktop'] : ['browser'];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 设备记录的规范形状。所有一侧上报的、服务器存的、给 agent 看的,都过这一道。
|
|
52
|
+
export function cleanDevice(d) {
|
|
53
|
+
if (!d || typeof d !== 'object') return null;
|
|
54
|
+
const id = cleanDeviceId(d.id);
|
|
55
|
+
const kind = cleanKind(d.kind);
|
|
56
|
+
if (!id || !kind) return null;
|
|
57
|
+
const caps = d.caps && typeof d.caps === 'object' ? d.caps : {};
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
kind,
|
|
61
|
+
name: String(d.name || '').replace(/\s+/g, ' ').trim().slice(0, 60) || kind,
|
|
62
|
+
machine: cleanDeviceId(d.machine) || null,
|
|
63
|
+
parent: cleanDeviceId(d.parent) || null,
|
|
64
|
+
online: d.online !== false,
|
|
65
|
+
caps: {
|
|
66
|
+
platform: String(caps.platform || '').slice(0, 20),
|
|
67
|
+
version: String(caps.version || '').slice(0, 40),
|
|
68
|
+
cmds: Array.isArray(caps.cmds) ? caps.cmds.filter((c) => typeof c === 'string' && c.length <= 40).slice(0, 80) : [],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 命令落到哪台设备。
|
|
74
|
+
// devices:调用方能看到的设备(服务器:本任务绑定的;桥:本机目录)。
|
|
75
|
+
// want:agent 显式指定的 device(可缺省)。cap:命令属于哪种能力。
|
|
76
|
+
// 规则:指定了就必须在名单里、类型对、在线;没指定就找同类,唯一才落,两台以上不猜。
|
|
77
|
+
export function resolveTarget(devices, { cap = 'browser', want = '' } = {}) {
|
|
78
|
+
const list = (Array.isArray(devices) ? devices : []).map(cleanDevice).filter(Boolean);
|
|
79
|
+
const kinds = kindsFor(cap);
|
|
80
|
+
const id = cleanDeviceId(want);
|
|
81
|
+
if (want && !id) throw err(DEVICE_ERRORS.NO_DEVICE, `设备号不合法:${String(want).slice(0, 40)}`);
|
|
82
|
+
if (id) {
|
|
83
|
+
const d = list.find((x) => x.id === id);
|
|
84
|
+
if (!d) throw err(DEVICE_ERRORS.DEVICE_NOT_IN_JOB, `设备 ${id} 不在可用设备里。先用 devices 看看有哪些。`);
|
|
85
|
+
if (!kinds.includes(d.kind)) throw err(DEVICE_ERRORS.NO_DEVICE, `设备 ${id} 是 ${d.kind},不能执行 ${cap} 命令`);
|
|
86
|
+
if (!d.online) throw err(DEVICE_ERRORS.DEVICE_OFFLINE, `设备 ${d.name}(${id})离线`);
|
|
87
|
+
return d;
|
|
88
|
+
}
|
|
89
|
+
const same = list.filter((x) => kinds.includes(x.kind));
|
|
90
|
+
if (!same.length) throw err(DEVICE_ERRORS.NO_DEVICE, `没有可执行 ${cap} 命令的设备`);
|
|
91
|
+
const live = same.filter((x) => x.online);
|
|
92
|
+
if (!live.length) throw err(DEVICE_ERRORS.DEVICE_OFFLINE, `${same.map((x) => x.name).join('、')} 离线`);
|
|
93
|
+
if (live.length > 1) {
|
|
94
|
+
throw err(DEVICE_ERRORS.AMBIGUOUS_DEVICE,
|
|
95
|
+
`有 ${live.length} 台设备都能执行 ${cap} 命令,用 device 指定一台:${live.map((x) => `${x.id}(${x.name})`).join('、')}`,
|
|
96
|
+
{ candidates: live.map((x) => x.id) });
|
|
97
|
+
}
|
|
98
|
+
return live[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 中继:这条链路(self)收到一条命令,判断该自己执行、交给名下子设备,还是拒绝。
|
|
102
|
+
// 不带 to 的老消息归自己。to 指向别人(包括同一用户的其它设备)一律拒绝——
|
|
103
|
+
// 中继只替自己名下的设备转发,服务器想借道去别的设备,这里不答应。
|
|
104
|
+
export function routeHere(msg, { self, children = [] }) {
|
|
105
|
+
const to = cleanDeviceId(msg?.to?.device);
|
|
106
|
+
const cap = capOf(msg);
|
|
107
|
+
if (!to) {
|
|
108
|
+
if (cap !== 'browser') {
|
|
109
|
+
const kid = children.find((c) => kindsFor(cap).includes(c.kind));
|
|
110
|
+
if (kid) return { device: kid, relay: true };
|
|
111
|
+
throw err(DEVICE_ERRORS.NO_DEVICE, `这台设备上没有 ${cap} 能力`);
|
|
112
|
+
}
|
|
113
|
+
return { device: self, relay: false };
|
|
114
|
+
}
|
|
115
|
+
if (to === self.id) {
|
|
116
|
+
if (!kindsFor(cap).includes(self.kind)) throw err(DEVICE_ERRORS.NO_DEVICE, `${self.kind} 设备不能执行 ${cap} 命令`);
|
|
117
|
+
return { device: self, relay: false };
|
|
118
|
+
}
|
|
119
|
+
const kid = children.find((c) => c.id === to);
|
|
120
|
+
if (!kid) throw err(DEVICE_ERRORS.DEVICE_NOT_HERE, `设备 ${to} 不在这条链路上`);
|
|
121
|
+
if (!kindsFor(cap).includes(kid.kind)) throw err(DEVICE_ERRORS.NO_DEVICE, `${kid.kind} 设备不能执行 ${cap} 命令`);
|
|
122
|
+
return { device: kid, relay: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 结果和事件出门前盖上来源。from 只由执行它的一侧盖,信封里原有的 from 一律覆盖——
|
|
126
|
+
// 不能让 payload 里自带一个伪造的来源。
|
|
127
|
+
export function stampFrom(msg, device) {
|
|
128
|
+
const d = cleanDevice(device);
|
|
129
|
+
if (!d) return msg;
|
|
130
|
+
return { ...msg, from: { device: d.id, kind: d.kind, ...(d.machine ? { machine: d.machine } : {}) } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 给 agent 看的一行行设备清单
|
|
134
|
+
export function describeDevices(devices) {
|
|
135
|
+
const list = (Array.isArray(devices) ? devices : []).map(cleanDevice).filter(Boolean);
|
|
136
|
+
if (!list.length) return '(没有可用设备)';
|
|
137
|
+
return list.map((d) => {
|
|
138
|
+
const bits = [d.kind, d.online ? '在线' : '离线'];
|
|
139
|
+
if (d.machine) bits.push(`机器 ${d.machine}`);
|
|
140
|
+
if (d.parent) bits.push(`经 ${d.parent} 中继`);
|
|
141
|
+
if (d.caps.platform) bits.push(d.caps.platform);
|
|
142
|
+
return `${d.id} ${d.name} (${bits.join(' · ')})`;
|
|
143
|
+
}).join('\n');
|
|
144
|
+
}
|