vcorp-chrome 1.7.0 → 1.8.1

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.
@@ -1,390 +1,407 @@
1
- // 云端通道的规矩 —— 远端(maker.vcorp.ai 这类服务)能让这个浏览器做什么,全写在这里。
2
- //
3
- // 这个文件是整条云端链路的安全边界,所以它是纯函数、不碰任何 chrome.* API,
4
- // 能在 Node 里逐条测(test/cloud-policy.test.js)。
5
- //
6
- // 设计前提:**服务器会被攻破**。本机那条桥只有本机进程能连(verifyClient + token),
7
- // 云端通道却是这个浏览器主动连出去的——对面要是被人拿下,下发的每一条命令都是敌意的。
8
- // 所以安全不能靠「服务器不会乱发」,只能靠扩展这一侧拒绝:
9
- //
10
- // 1. 命令白名单:只有操作页面的那一小组。eval / fetch / network / download / reload
11
- // 一律没有——它们能读任意接口、带着 cookie 发请求、往磁盘写东西。
12
- // 2. 域名白名单:云端开的标签页只能停在用户允许过的网站上(侧栏里的名单),
13
- // 以及这个任务里用户点头的网站上,跳出去就屏蔽内容。扩展里没有预设的平台表。
14
- // 3. 标签页隔离:云端只看得见、只碰得到它自己开的标签页,用户自己的页面一个都不行。
15
- // 4. 文件来源:上传只能用配对服务器上的素材 URL,不收本机路径——否则一句
16
- // upload(path:"~/.ssh/id_rsa") 就能把私钥发到公开平台上。
17
- // 5. 每个任务要用户点头,或用户主动开了自动允许。自动允许盖住新任务,以及通用
18
- // 任务信封上一开始带着的网站;盖不住任务开始之后追加的网站和文件夹。有命令数上限。
19
- //
20
- // 站点申请(sites 命令):每个任务(发布也好、「到兼职网站上帮我找个工作」也好)
21
- // 都靠它说明要去哪些网站。没开自动允许时,新任务的第一份网站清单要用户点头;
22
- // 任务开始之后每多一个站点都要再点一次头,自动允许管不到这次追加。
23
- // 用户亲手点「允许」的网站进入允许名单(侧栏可逐个移除),以后的任务用到它们不再问;
24
- // 自动允许只放行这一个任务,不往名单里加。一个任务新批的网站最多 SITES_MAX 个。
25
-
26
- export const DEFAULT_ORIGIN = 'https://maker.vcorp.ai';
27
-
28
- // 远端能调的命令。tabs 的子动作另外管(见 checkCommand)。
29
- export const CLOUD_CMDS = new Set([
30
- 'tabs', 'navigate', 'snapshot', 'click', 'type', 'fill', 'select', 'key', 'scroll',
31
- 'wait', 'read_text', 'query', 'read', 'screenshot', 'upload', 'act', 'ask', 'status', 'sites',
32
- // 本地文件夹:只读、按任务批准、扩展名白名单。读文件在 service worker 里做,
33
- // 选文件夹和重新授权只能发生在扩展自己的页面(见 folders.js 顶部的结论)。
34
- 'folders', 'fs_list', 'fs_read',
35
- ]);
36
-
37
- // ---------- 站点申请 ----------
38
-
39
- export const SITES_MAX = 20;
40
-
41
- // 这些「后缀」底下是千千万万个别人的网站,批准了它等于批准了全部
42
- const SHARED_SUFFIXES = new Set([
43
- 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'ac.cn', 'com.hk', 'com.tw', 'org.tw', 'com.sg',
44
- 'co.uk', 'org.uk', 'ac.uk', 'co.jp', 'ne.jp', 'or.jp', 'co.kr', 'com.au', 'net.au', 'co.nz', 'com.br',
45
- 'github.io', 'gitee.io', 'vercel.app', 'netlify.app', 'pages.dev', 'workers.dev', 'herokuapp.com',
46
- 'blogspot.com', 'appspot.com', 'web.app', 'firebaseapp.com', 'azurewebsites.net', 'cloudfront.net',
47
- ]);
48
-
49
- // 「zhipin.com」「https://www.zhipin.com/web/geek/job」→ 'zhipin.com';不像一个公网站点就 null。
50
- // www. 去掉:批准的是这个站点,含它的子域。IP、localhost、单段名、共享后缀一律不收。
51
- export function normalizeSite(raw) {
52
- let s = String(raw || '').trim().toLowerCase();
53
- if (!s) return null;
54
- if (!/^[a-z][a-z0-9+.-]*:\/\//.test(s)) s = `https://${s}`;
55
- let u;
56
- try { u = new URL(s); } catch { return null; }
57
- if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
58
- if (u.username || u.password) return null;
59
- let h = u.hostname.replace(/\.$/, '');
60
- if (h.startsWith('www.')) h = h.slice(4);
61
- if (h.length > 253 || !h.includes('.')) return null;
62
- if (/^[\d.]+$/.test(h) || h.includes(':') || h.startsWith('[')) return null;
63
- if (!h.split('.').every((l) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return null;
64
- if (/^\d+$/.test(h.split('.').at(-1))) return null;
65
- if (SHARED_SUFFIXES.has(h) || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return null;
66
- return h;
67
- }
68
-
69
- // 一份申请清单 → 去重、合规、限量的站点;不合规的另列出来告诉 agent
70
- export function cleanSites(list) {
71
- const ok = [];
72
- const bad = [];
73
- for (const raw of Array.isArray(list) ? list : []) {
74
- const h = normalizeSite(raw);
75
- if (!h) { bad.push(String(raw).slice(0, 80)); continue; }
76
- if (!ok.includes(h)) ok.push(h);
77
- }
78
- return { sites: ok.slice(0, SITES_MAX), bad };
79
- }
80
-
81
- // act 里允许出现的步骤
82
- const ACT_STEPS = new Set(['click', 'type', 'fill', 'select', 'key', 'scroll', 'wait', 'navigate', 'read', 'assert', 'if', 'repeat']);
83
-
84
- export const TASK_CMD_CAP = 1500; // 一个任务最多这么多条命令,防失控循环把账号刷进风控
85
- export const TASK_IDLE_MS = 3 * 3600e3; // 批准过的任务闲置 3 小时作废,下次要重新点头
86
- export const MEDIA_MAX = 2 * 1024 ** 3; // 素材上限,与 maker 那侧的上传上限一致
87
-
88
- // 本地文件夹。数字和 opus-video-maker/extlink.js 的 PULL_MAX 必须一致:
89
- // 扩展拒绝超限的文件,服务器拒绝超限的上传,两边对不齐就会一截一放。
90
- export const FS_FILE_MAX = 32 * 1024 * 1024; // 单个文件
91
- export const FS_TASK_MAX = 256 * 1024 * 1024; // 一个任务累计读走的字节
92
- export const FS_LIST_MAX = 2000; // 一次列出的条目
93
- export const FS_DEPTH_MAX = 8;
94
- export const FS_FOLDERS_MAX = 8; // 一个浏览器最多挂这么多个;一个任务也最多批准这么多个
95
- export const FS_EXTS = new Set(['.md', '.markdown', '.txt', '.pdf', '.docx', '.csv', '.json', '.html', '.htm']);
96
- const FS_SKIP = new Set(['node_modules', '__pycache__', '.git', '.venv']);
97
-
98
- const err = (code, message) => Object.assign(new Error(message), { code });
99
-
100
- // 配对的服务器地址:只收 https,开发时允许本机 http。路径、账号、端口外的东西一律不认。
101
- export function normalizeOrigin(raw) {
102
- let u;
103
- try { u = new URL(String(raw || '').trim()); } catch { return null; }
104
- const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
105
- if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
106
- if (u.username || u.password) return null;
107
- return u.origin;
108
- }
109
-
110
- export function hostOf(url) {
111
- try { return new URL(url).hostname.toLowerCase(); } catch { return ''; }
112
- }
113
-
114
- export const hostMatches = (host, root) => host === root || host.endsWith('.' + root);
115
-
116
- // 允许名单:用户点过「允许」的根域。读存储时也过一遍,坏数据不进白名单。
117
- export function cleanAllowList(list) {
118
- const out = [];
119
- for (const raw of Array.isArray(list) ? list : []) {
120
- const h = normalizeSite(raw);
121
- if (h && !out.includes(h)) out.push(h);
122
- }
123
- return out;
124
- }
125
-
126
- // 一个任务实际能停的根域:允许名单,加上这个任务里批准过的网站。
127
- export function taskHosts(task, allow = []) {
128
- return [...new Set([...cleanAllowList(allow), ...(task?.sites || [])])];
129
- }
130
-
131
- // 一份申请里,哪些还要问用户。
132
- // covered 是允许名单:申请的站点被某个根域盖住(含子域)就不用问。
133
- // 已经批准或拒绝过的不重复问。本任务新批的网站总数不超过 SITES_MAX,多出来的放 overflow。
134
- export function sitesToAsk(add, { covered = [], sites = [], refused = [] } = {}) {
135
- const have = new Set(sites);
136
- const no = new Set(refused);
137
- const byList = [];
138
- const fresh = [];
139
- const seen = new Set();
140
- for (const h of Array.isArray(add) ? add : []) {
141
- if (typeof h !== 'string' || seen.has(h)) continue;
142
- seen.add(h);
143
- if (covered.some((root) => hostMatches(h, root))) { byList.push(h); continue; }
144
- if (have.has(h) || no.has(h)) continue;
145
- fresh.push(h);
146
- }
147
- const room = Math.max(0, SITES_MAX - sites.length);
148
- return { ask: fresh.slice(0, room), covered: byList, overflow: fresh.slice(room) };
149
- }
150
-
151
- // 一个页面地址在不在白名单里。about:blank 是新开标签页的出生地,放行;
152
- // 其它非 http(s) 一律不行(chrome://、file://、data:、javascript: 都在这里被挡)。
153
- export function urlAllowed(url, hosts) {
154
- if (url === 'about:blank' || url === '') return true;
155
- let u;
156
- try { u = new URL(url); } catch { return false; }
157
- if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
158
- if (u.username || u.password) return false;
159
- const h = u.hostname.toLowerCase();
160
- return hosts.some((root) => hostMatches(h, root));
161
- }
162
-
163
- // 显示名是用户自己起的,不是磁盘路径。不要斜杠,这样它才能当成本地目录名,也才能安全地报给服务器。
164
- export function cleanFolderName(raw) {
165
- const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
166
- if (!s || s.length > 40 || s === '.' || s === '..') return null;
167
- if (/[\\/\x00-\x1f]/.test(s)) return null;
168
- return s;
169
- }
170
-
171
- export function cleanFolderNames(list) {
172
- const ok = [];
173
- const bad = [];
174
- for (const raw of Array.isArray(list) ? list : []) {
175
- const name = cleanFolderName(raw);
176
- if (!name) { bad.push(String(raw).slice(0, 80)); continue; }
177
- if (!ok.includes(name)) ok.push(name);
178
- }
179
- return { names: ok.slice(0, FS_FOLDERS_MAX), bad };
180
- }
181
-
182
- // 已经允许、已经拒绝的不再问。空名单就是「先告诉我挂了哪些」,什么都不问。
183
- export function foldersToAsk(use, { granted = [], refused = [] } = {}) {
184
- const have = new Set(granted);
185
- const no = new Set(refused);
186
- const ask = [];
187
- const already = [];
188
- const denied = [];
189
- for (const name of Array.isArray(use) ? use : []) {
190
- if (typeof name !== 'string') continue;
191
- if (have.has(name)) { if (!already.includes(name)) already.push(name); continue; }
192
- if (no.has(name)) { if (!denied.includes(name)) denied.push(name); continue; }
193
- if (!ask.includes(name)) ask.push(name);
194
- }
195
- return { ask, already, refused: denied };
196
- }
197
-
198
- // 相对路径。'' 是文件夹根;不合法返回 null(和根区分开)。
199
- // 不收绝对路径、反斜杠、.. 和控制字符——服务器被攻破时这是唯一一道路径闸。
200
- export function normalizeRelPath(raw) {
201
- const s = String(raw ?? '');
202
- // 冒号挡掉 Windows 盘符(C:/...)和 NTFS 流。句柄只能按名字往下走,盘符在这里没有意义。
203
- if (s.includes('\\') || s.includes(':') || /[\x00-\x1f]/.test(s) || s.startsWith('/')) return null;
204
- const parts = s.split('/').filter((p) => p && p !== '.');
205
- if (parts.some((p) => p === '..')) return null;
206
- return parts.join('/');
207
- }
208
-
209
- export const skipEntry = (name) => !name || name.startsWith('.') || FS_SKIP.has(name);
210
-
211
- export function fileAllowed(name) {
212
- const base = String(name || '').split('/').pop();
213
- if (!base || base.startsWith('.')) return false;
214
- const dot = base.lastIndexOf('.');
215
- if (dot <= 0) return false;
216
- return FS_EXTS.has(base.slice(dot).toLowerCase());
217
- }
218
-
219
- // 只支持 *(一段里)和 **(跨目录)。不解释 shell,不接收 ..。
220
- export function matchGlob(rel, glob) {
221
- const g = String(glob || '').trim();
222
- if (!g) return true;
223
- let re = '';
224
- const s = g.replace(/\\/g, '/');
225
- for (let i = 0; i < s.length; i++) {
226
- if (s[i] === '*' && s[i + 1] === '*') {
227
- if (s[i + 2] === '/') { re += '(?:.*/)?'; i += 2; }
228
- else { re += '.*'; i += 1; }
229
- } else if (s[i] === '*') re += '[^/]*';
230
- else if (s[i] === '?') re += '[^/]';
231
- else re += s[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
232
- }
233
- return new RegExp(`^${re}$`).test(rel);
234
- }
235
-
236
- // 文件字节只能上传到配对服务器的一次性地址。和 mediaAllowed 同一条理由:
237
- // 不然服务器会让扩展把用户的文件 PUT 到攻击者的主机上。
238
- export function pullUrlAllowed(url, origin) {
239
- let u;
240
- try { u = new URL(url); } catch { return false; }
241
- return u.origin === origin && /^\/api\/ext\/pull\/[A-Za-z0-9_-]{16,64}$/.test(u.pathname)
242
- && !u.username && !u.password && !u.search && !u.hash;
243
- }
244
-
245
- // 素材地址必须指回配对的那台服务器的素材接口——扩展只从那里取文件
246
- export function mediaAllowed(url, origin) {
247
- let u;
248
- try { u = new URL(url); } catch { return false; }
249
- return u.origin === origin && u.pathname.startsWith('/api/ext/media/') && !u.username && !u.password;
250
- }
251
-
252
- // 一条新任务开场时的批准状态。sites 是信封上一开始的网站清单(已清洗),covered 是允许名单。
253
- // 名单里已有的放进 use,只给用户看;名单外的放进 ask,点「允许」才写进名单。
254
- // 有网站的任务没开自动允许就要用户点头,哪怕网站全在名单里:每个任务都要一次同意。
255
- // auto 打开:直接 approved,名单外的网站只给这个任务(写入 sites,不进名单),
256
- // 也不要留在 ask,否则紧接着的 sites 命令会把同一份清单再问一遍。
257
- // auto 盖不住任务开始之后追加的网站(sitesToAsk)和文件夹(foldersToAsk)。
258
- // 没有网站的任务本来就是 approved(只能申请文件夹,文件夹仍另批)。
259
- export function initialTask({ sites = [], covered = [], auto = false } = {}) {
260
- const list = Array.isArray(sites) ? sites.slice() : [];
261
- const use = list.filter((h) => covered.some((root) => hostMatches(h, root)));
262
- const fresh = list.filter((h) => !use.includes(h));
263
- if (!list.length) return { state: 'approved', sites: [], ask: [], use: [] };
264
- return auto
265
- ? { state: 'approved', sites: fresh, ask: [], use }
266
- : { state: 'pending', sites: [], ask: fresh, use };
267
- }
268
-
269
- // act 剧本静态检查:每一步都要是允许的动作,导航目标都要在白名单里。
270
- // 嵌套(if/repeat)一起查——不查的话,危险步骤藏一层就能混过去。
271
- function checkSteps(steps, hosts, depth = 0) {
272
- if (!Array.isArray(steps)) throw err('CLOUD_DENIED', 'act 的 steps 必须是数组');
273
- if (depth > 3) throw err('CLOUD_DENIED', 'act 嵌套太深');
274
- for (const st of steps) {
275
- if (!st || typeof st !== 'object') throw err('CLOUD_DENIED', 'act 的步骤必须是对象');
276
- if (!ACT_STEPS.has(st.do)) throw err('CLOUD_DENIED', `云端任务的 act 里不能用 ${JSON.stringify(st.do)}`);
277
- if (st.do === 'navigate' && st.url !== undefined && !urlAllowed(st.url, hosts)) {
278
- throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(st.url) || st.url}`);
279
- }
280
- if (st.do === 'if') { checkSteps(st.then || [], hosts, depth + 1); checkSteps(st.else || [], hosts, depth + 1); }
281
- if (st.do === 'repeat') checkSteps(st.steps || [], hosts, depth + 1);
282
- }
283
- }
284
-
285
- // 审一条云端命令。返回清洗过的参数(只留安全的字段、补上强制值),不合规直接抛。
286
- // tabId 不在这里管——它由 cloud.js 按「这个任务自己开过哪些页」单独核对。
287
- export function checkCommand(cmd, params, { hosts, origin }) {
288
- if (!CLOUD_CMDS.has(cmd)) throw err('CLOUD_DENIED', `云端任务不能调用 ${cmd}`);
289
- const p = { ...(params && typeof params === 'object' ? params : {}) };
290
- delete p.tabId; // tabId 只能出现在信封上,由 cloud.js 核对归属
291
-
292
- switch (cmd) {
293
- case 'tabs': {
294
- if (!['new', 'list', 'select', 'close'].includes(p.action)) throw err('CLOUD_DENIED', `tabs 不支持 ${p.action}`);
295
- if (p.action === 'new' && p.url !== undefined && !urlAllowed(p.url, hosts)) {
296
- throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(p.url) || p.url}`);
297
- }
298
- p.focus = false; // 后台干活,不抢用户的视线
299
- return p;
300
- }
301
- case 'navigate': {
302
- if (p.url !== undefined) {
303
- if (!urlAllowed(p.url, hosts)) throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(p.url) || p.url}`);
304
- } else if (!['back', 'forward', 'reload'].includes(p.action || 'reload')) {
305
- throw err('CLOUD_DENIED', `navigate 不支持 ${p.action}`);
306
- }
307
- return p;
308
- }
309
- case 'act': {
310
- checkSteps(p.steps, hosts);
311
- // 批处理不代做提交/支付/删除——发布按钮也在其列,必须单独 click 一次,
312
- // 那一下有自己的效果证据,审计日志里也独立可查
313
- p.allowSensitive = false;
314
- return p;
315
- }
316
- case 'upload': {
317
- // 只认 media(服务器上的素材)。path/base64 是本机那条路的参数,云端一个都不收
318
- const m = p.media;
319
- if (!m || typeof m !== 'object' || !mediaAllowed(m.url, origin)) {
320
- throw err('CLOUD_DENIED', 'upload 只能用配对服务器上的素材(media.url)');
321
- }
322
- const size = Number(m.size);
323
- if (!Number.isFinite(size) || size <= 0 || size > MEDIA_MAX) throw err('CLOUD_DENIED', '素材大小不对');
324
- const name = String(m.name || '').replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 120);
325
- if (!name || name.startsWith('.')) throw err('CLOUD_DENIED', '素材文件名不对');
326
- return {
327
- media: { url: m.url, name, type: String(m.type || 'application/octet-stream').slice(0, 80), size },
328
- ...(typeof p.selector === 'string' ? { selector: p.selector } : {}),
329
- ...(typeof p.dropSelector === 'string' ? { dropSelector: p.dropSelector } : {}),
330
- };
331
- }
332
- case 'screenshot':
333
- delete p.focus; // 只走 CDP 截后台页;退回老路要切前台,那会截到用户正在看的东西
334
- return p;
335
- case 'ask':
336
- p.timeout = Math.min(Math.max(Number(p.timeout) || 300000, 5000), 600000);
337
- // 只有对外动作的确认可以被免确认开关跳过。别的值(含没写)一律当成人必须在场:
338
- // 扫码、验证码、滑块不能因为用户开了开关就被静默放行。
339
- if (p.kind !== 'confirm') delete p.kind;
340
- return p;
341
- case 'sites': {
342
- const { sites, bad } = cleanSites(p.add);
343
- if (!sites.length && !bad.length) throw err('CLOUD_DENIED', 'sites 要给 add:要访问的网站,如 ["zhipin.com"]');
344
- return { add: sites, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
345
- }
346
- case 'folders': {
347
- // use 可以是空的:那是在问「这个浏览器挂了哪些」,不是在申请。
348
- const { names, bad } = cleanFolderNames(p.use);
349
- return { use: names, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
350
- }
351
- case 'fs_list': {
352
- const folder = cleanFolderName(p.folder);
353
- if (!folder) throw err('CLOUD_DENIED', 'fs_list 要给 folder:文件夹的显示名');
354
- const rel = normalizeRelPath(p.path ?? '');
355
- if (rel === null) throw err('CLOUD_DENIED', 'path 不合法');
356
- const depth = Math.min(FS_DEPTH_MAX, Math.max(1, Math.floor(Number(p.depth) || 3)));
357
- let glob = '';
358
- if (p.glob !== undefined && p.glob !== '') {
359
- glob = String(p.glob).trim().slice(0, 200);
360
- if (!glob || glob.includes('..') || /[\x00-\x1f\\]/.test(glob)) throw err('CLOUD_DENIED', 'glob 不合法');
361
- }
362
- return { folder, path: rel, depth, ...(glob ? { glob } : {}) };
363
- }
364
- case 'fs_read': {
365
- const folder = cleanFolderName(p.folder);
366
- if (!folder) throw err('CLOUD_DENIED', 'fs_read 要给 folder:文件夹的显示名');
367
- const rel = normalizeRelPath(p.path ?? '');
368
- if (!rel) throw err('CLOUD_DENIED', 'fs_read 要给文件的 path');
369
- const base = rel.split('/').pop();
370
- if (skipEntry(base) || !fileAllowed(base)) throw err('CLOUD_DENIED', `不读取这种文件:${base}`);
371
- if (!pullUrlAllowed(p.upload?.url, origin)) throw err('CLOUD_DENIED', 'fs_read 只能把文件传到配对服务器的 /api/ext/pull/');
372
- return { folder, path: rel, upload: { url: p.upload.url } };
373
- }
374
- default:
375
- return p;
376
- }
377
- }
378
-
379
- // 命令执行完、结果发回服务器之前,标签页若已经跳出白名单(点了个外链、被重定向),
380
- // 结果里那一页的内容不能出去。只留一句说明。
381
- export function redactOffList(url) {
382
- return {
383
- text: `⛔ 标签页跳到了本任务允许范围以外的地址(${hostOf(url) || '非网页地址'}),`
384
- + '内容已屏蔽,标签页已退回。回到允许的网站继续,或用 ask 请用户处理。',
385
- offList: true,
386
- };
387
- }
388
-
389
- // 结果瘦身:截图以外的大块数据不往回传。上限防止一份异常大的快照把通道堵死。
390
- export const RESULT_MAX = 3 * 1024 * 1024;
1
+ // 云端通道的规矩 —— 远端(maker.vcorp.ai 这类服务)能让这个浏览器做什么,全写在这里。
2
+ //
3
+ // 这个文件是整条云端链路的安全边界,所以它是纯函数、不碰任何 chrome.* API,
4
+ // 能在 Node 里逐条测(test/cloud-policy.test.js)。
5
+ //
6
+ // 设计前提:**服务器会被攻破**。本机那条桥只有本机进程能连(verifyClient + token),
7
+ // 云端通道却是这个浏览器主动连出去的——对面要是被人拿下,下发的每一条命令都是敌意的。
8
+ // 所以安全不能靠「服务器不会乱发」,只能靠扩展这一侧拒绝:
9
+ //
10
+ // 1. 命令白名单:只有操作页面的那一小组。eval / fetch / network / download / reload
11
+ // 一律没有——它们能读任意接口、带着 cookie 发请求、往磁盘写东西。
12
+ // 2. 域名白名单:云端开的标签页只能停在用户允许过的网站上(侧栏里的名单),
13
+ // 以及这个任务里用户点头的网站上,跳出去就屏蔽内容。扩展里没有预设的平台表。
14
+ // 3. 标签页隔离:云端只看得见、只碰得到它自己开的标签页,用户自己的页面一个都不行。
15
+ // 4. 文件来源:上传只能用配对服务器上的素材 URL,不收本机路径——否则一句
16
+ // upload(path:"~/.ssh/id_rsa") 就能把私钥发到公开平台上。
17
+ // 5. 每个任务要用户点头,或用户主动开了自动允许。自动允许盖住新任务,以及通用
18
+ // 任务信封上一开始带着的网站;盖不住任务开始之后追加的网站和文件夹。有命令数上限。
19
+ //
20
+ // 站点申请(sites 命令):每个任务(发布也好、「到兼职网站上帮我找个工作」也好)
21
+ // 都靠它说明要去哪些网站。没开自动允许时,新任务的第一份网站清单要用户点头;
22
+ // 任务开始之后每多一个站点都要再点一次头,自动允许管不到这次追加。
23
+ // 用户亲手点「允许」的网站进入允许名单(侧栏可逐个移除),以后的任务用到它们不再问;
24
+ // 自动允许只放行这一个任务,不往名单里加。一个任务新批的网站最多 SITES_MAX 个。
25
+
26
+ import { checkLoadParams, checkCallParams } from './load.js';
27
+
28
+ export const DEFAULT_ORIGIN = 'https://maker.vcorp.ai';
29
+
30
+ // 远端能调的命令。tabs 的子动作另外管(见 checkCommand)。
31
+ export const CLOUD_CMDS = new Set([
32
+ 'tabs', 'navigate', 'snapshot', 'click', 'type', 'fill', 'select', 'key', 'scroll',
33
+ 'wait', 'read_text', 'query', 'read', 'screenshot', 'upload', 'act', 'ask', 'status', 'sites',
34
+ // 本地文件夹:只读、按任务批准、扩展名白名单。读文件在 service worker 里做,
35
+ // 选文件夹和重新授权只能发生在扩展自己的页面(见 folders.js 顶部的结论)。
36
+ 'folders', 'fs_list', 'fs_read',
37
+ // 能力包:只能按名字装扩展包里自带的包、按白名单调 action,payload 是纯数据(见 load.js)。
38
+ // 这是云端拿到「有类型的站点能力」的唯一途径,eval 依旧没有。
39
+ 'load', 'call',
40
+ ]);
41
+
42
+ // ---------- 站点申请 ----------
43
+
44
+ export const SITES_MAX = 20;
45
+
46
+ // 这些「后缀」底下是千千万万个别人的网站,批准了它等于批准了全部
47
+ const SHARED_SUFFIXES = new Set([
48
+ 'com.cn', 'net.cn', 'org.cn', 'gov.cn', 'edu.cn', 'ac.cn', 'com.hk', 'com.tw', 'org.tw', 'com.sg',
49
+ 'co.uk', 'org.uk', 'ac.uk', 'co.jp', 'ne.jp', 'or.jp', 'co.kr', 'com.au', 'net.au', 'co.nz', 'com.br',
50
+ 'github.io', 'gitee.io', 'vercel.app', 'netlify.app', 'pages.dev', 'workers.dev', 'herokuapp.com',
51
+ 'blogspot.com', 'appspot.com', 'web.app', 'firebaseapp.com', 'azurewebsites.net', 'cloudfront.net',
52
+ ]);
53
+
54
+ // 「zhipin.com」「https://www.zhipin.com/web/geek/job」→ 'zhipin.com';不像一个公网站点就 null。
55
+ // www. 去掉:批准的是这个站点,含它的子域。IP、localhost、单段名、共享后缀一律不收。
56
+ export function normalizeSite(raw) {
57
+ let s = String(raw || '').trim().toLowerCase();
58
+ if (!s) return null;
59
+ if (!/^[a-z][a-z0-9+.-]*:\/\//.test(s)) s = `https://${s}`;
60
+ let u;
61
+ try { u = new URL(s); } catch { return null; }
62
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
63
+ if (u.username || u.password) return null;
64
+ let h = u.hostname.replace(/\.$/, '');
65
+ if (h.startsWith('www.')) h = h.slice(4);
66
+ if (h.length > 253 || !h.includes('.')) return null;
67
+ if (/^[\d.]+$/.test(h) || h.includes(':') || h.startsWith('[')) return null;
68
+ if (!h.split('.').every((l) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return null;
69
+ if (/^\d+$/.test(h.split('.').at(-1))) return null;
70
+ if (SHARED_SUFFIXES.has(h) || h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return null;
71
+ return h;
72
+ }
73
+
74
+ // 一份申请清单 → 去重、合规、限量的站点;不合规的另列出来告诉 agent
75
+ export function cleanSites(list) {
76
+ const ok = [];
77
+ const bad = [];
78
+ for (const raw of Array.isArray(list) ? list : []) {
79
+ const h = normalizeSite(raw);
80
+ if (!h) { bad.push(String(raw).slice(0, 80)); continue; }
81
+ if (!ok.includes(h)) ok.push(h);
82
+ }
83
+ return { sites: ok.slice(0, SITES_MAX), bad };
84
+ }
85
+
86
+ // act 里允许出现的步骤
87
+ const ACT_STEPS = new Set(['click', 'type', 'fill', 'select', 'key', 'scroll', 'wait', 'navigate', 'read', 'assert', 'if', 'repeat']);
88
+
89
+ export const TASK_CMD_CAP = 1500; // 一个任务最多这么多条命令,防失控循环把账号刷进风控
90
+ export const TASK_IDLE_MS = 3 * 3600e3; // 批准过的任务闲置 3 小时作废,下次要重新点头
91
+ export const MEDIA_MAX = 2 * 1024 ** 3; // 素材上限,与 maker 那侧的上传上限一致
92
+
93
+ // 本地文件夹。数字和 opus-video-maker/extlink.js 的 PULL_MAX 必须一致:
94
+ // 扩展拒绝超限的文件,服务器拒绝超限的上传,两边对不齐就会一截一放。
95
+ export const FS_FILE_MAX = 32 * 1024 * 1024; // 单个文件
96
+ export const FS_TASK_MAX = 256 * 1024 * 1024; // 一个任务累计读走的字节
97
+ export const FS_LIST_MAX = 2000; // 一次列出的条目
98
+ export const FS_DEPTH_MAX = 8;
99
+ export const FS_FOLDERS_MAX = 8; // 一个浏览器最多挂这么多个;一个任务也最多批准这么多个
100
+ export const FS_EXTS = new Set(['.md', '.markdown', '.txt', '.pdf', '.docx', '.csv', '.json', '.html', '.htm']);
101
+ const FS_SKIP = new Set(['node_modules', '__pycache__', '.git', '.venv']);
102
+
103
+ const err = (code, message) => Object.assign(new Error(message), { code });
104
+
105
+ // 配对的服务器地址:只收 https,开发时允许本机 http。路径、账号、端口外的东西一律不认。
106
+ export function normalizeOrigin(raw) {
107
+ let u;
108
+ try { u = new URL(String(raw || '').trim()); } catch { return null; }
109
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
110
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) return null;
111
+ if (u.username || u.password) return null;
112
+ return u.origin;
113
+ }
114
+
115
+ export function hostOf(url) {
116
+ try { return new URL(url).hostname.toLowerCase(); } catch { return ''; }
117
+ }
118
+
119
+ export const hostMatches = (host, root) => host === root || host.endsWith('.' + root);
120
+
121
+ // 允许名单:用户点过「允许」的根域。读存储时也过一遍,坏数据不进白名单。
122
+ export function cleanAllowList(list) {
123
+ const out = [];
124
+ for (const raw of Array.isArray(list) ? list : []) {
125
+ const h = normalizeSite(raw);
126
+ if (h && !out.includes(h)) out.push(h);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ // 一个任务实际能停的根域:允许名单,加上这个任务里批准过的网站。
132
+ export function taskHosts(task, allow = []) {
133
+ return [...new Set([...cleanAllowList(allow), ...(task?.sites || [])])];
134
+ }
135
+
136
+ // 一份申请里,哪些还要问用户。
137
+ // covered 是允许名单:申请的站点被某个根域盖住(含子域)就不用问。
138
+ // 已经批准或拒绝过的不重复问。本任务新批的网站总数不超过 SITES_MAX,多出来的放 overflow。
139
+ export function sitesToAsk(add, { covered = [], sites = [], refused = [] } = {}) {
140
+ const have = new Set(sites);
141
+ const no = new Set(refused);
142
+ const byList = [];
143
+ const fresh = [];
144
+ const seen = new Set();
145
+ for (const h of Array.isArray(add) ? add : []) {
146
+ if (typeof h !== 'string' || seen.has(h)) continue;
147
+ seen.add(h);
148
+ if (covered.some((root) => hostMatches(h, root))) { byList.push(h); continue; }
149
+ if (have.has(h) || no.has(h)) continue;
150
+ fresh.push(h);
151
+ }
152
+ const room = Math.max(0, SITES_MAX - sites.length);
153
+ return { ask: fresh.slice(0, room), covered: byList, overflow: fresh.slice(room) };
154
+ }
155
+
156
+ // 一个页面地址在不在白名单里。about:blank 是新开标签页的出生地,放行;
157
+ // 其它非 http(s) 一律不行(chrome://、file://、data:、javascript: 都在这里被挡)。
158
+ export function urlAllowed(url, hosts) {
159
+ if (url === 'about:blank' || url === '') return true;
160
+ let u;
161
+ try { u = new URL(url); } catch { return false; }
162
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return false;
163
+ if (u.username || u.password) return false;
164
+ const h = u.hostname.toLowerCase();
165
+ return hosts.some((root) => hostMatches(h, root));
166
+ }
167
+
168
+ // 显示名是用户自己起的,不是磁盘路径。不要斜杠,这样它才能当成本地目录名,也才能安全地报给服务器。
169
+ export function cleanFolderName(raw) {
170
+ const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
171
+ if (!s || s.length > 40 || s === '.' || s === '..') return null;
172
+ if (/[\\/\x00-\x1f]/.test(s)) return null;
173
+ return s;
174
+ }
175
+
176
+ export function cleanFolderNames(list) {
177
+ const ok = [];
178
+ const bad = [];
179
+ for (const raw of Array.isArray(list) ? list : []) {
180
+ const name = cleanFolderName(raw);
181
+ if (!name) { bad.push(String(raw).slice(0, 80)); continue; }
182
+ if (!ok.includes(name)) ok.push(name);
183
+ }
184
+ return { names: ok.slice(0, FS_FOLDERS_MAX), bad };
185
+ }
186
+
187
+ // 已经允许、已经拒绝的不再问。空名单就是「先告诉我挂了哪些」,什么都不问。
188
+ export function foldersToAsk(use, { granted = [], refused = [] } = {}) {
189
+ const have = new Set(granted);
190
+ const no = new Set(refused);
191
+ const ask = [];
192
+ const already = [];
193
+ const denied = [];
194
+ for (const name of Array.isArray(use) ? use : []) {
195
+ if (typeof name !== 'string') continue;
196
+ if (have.has(name)) { if (!already.includes(name)) already.push(name); continue; }
197
+ if (no.has(name)) { if (!denied.includes(name)) denied.push(name); continue; }
198
+ if (!ask.includes(name)) ask.push(name);
199
+ }
200
+ return { ask, already, refused: denied };
201
+ }
202
+
203
+ // 相对路径。'' 是文件夹根;不合法返回 null(和根区分开)。
204
+ // 不收绝对路径、反斜杠、.. 和控制字符——服务器被攻破时这是唯一一道路径闸。
205
+ export function normalizeRelPath(raw) {
206
+ const s = String(raw ?? '');
207
+ // 冒号挡掉 Windows 盘符(C:/...)和 NTFS 流。句柄只能按名字往下走,盘符在这里没有意义。
208
+ if (s.includes('\\') || s.includes(':') || /[\x00-\x1f]/.test(s) || s.startsWith('/')) return null;
209
+ const parts = s.split('/').filter((p) => p && p !== '.');
210
+ if (parts.some((p) => p === '..')) return null;
211
+ return parts.join('/');
212
+ }
213
+
214
+ export const skipEntry = (name) => !name || name.startsWith('.') || FS_SKIP.has(name);
215
+
216
+ export function fileAllowed(name) {
217
+ const base = String(name || '').split('/').pop();
218
+ if (!base || base.startsWith('.')) return false;
219
+ const dot = base.lastIndexOf('.');
220
+ if (dot <= 0) return false;
221
+ return FS_EXTS.has(base.slice(dot).toLowerCase());
222
+ }
223
+
224
+ // 只支持 *(一段里)和 **(跨目录)。不解释 shell,不接收 ..。
225
+ export function matchGlob(rel, glob) {
226
+ const g = String(glob || '').trim();
227
+ if (!g) return true;
228
+ let re = '';
229
+ const s = g.replace(/\\/g, '/');
230
+ for (let i = 0; i < s.length; i++) {
231
+ if (s[i] === '*' && s[i + 1] === '*') {
232
+ if (s[i + 2] === '/') { re += '(?:.*/)?'; i += 2; }
233
+ else { re += '.*'; i += 1; }
234
+ } else if (s[i] === '*') re += '[^/]*';
235
+ else if (s[i] === '?') re += '[^/]';
236
+ else re += s[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
237
+ }
238
+ return new RegExp(`^${re}$`).test(rel);
239
+ }
240
+
241
+ // 文件字节只能上传到配对服务器的一次性地址。和 mediaAllowed 同一条理由:
242
+ // 不然服务器会让扩展把用户的文件 PUT 到攻击者的主机上。
243
+ export function pullUrlAllowed(url, origin) {
244
+ let u;
245
+ try { u = new URL(url); } catch { return false; }
246
+ return u.origin === origin && /^\/api\/ext\/pull\/[A-Za-z0-9_-]{16,64}$/.test(u.pathname)
247
+ && !u.username && !u.password && !u.search && !u.hash;
248
+ }
249
+
250
+ // 素材地址必须指回配对的那台服务器的素材接口——扩展只从那里取文件
251
+ export function mediaAllowed(url, origin) {
252
+ let u;
253
+ try { u = new URL(url); } catch { return false; }
254
+ return u.origin === origin && u.pathname.startsWith('/api/ext/media/') && !u.username && !u.password;
255
+ }
256
+
257
+ // 一条新任务开场时的批准状态。sites 是信封上一开始的网站清单(已清洗),covered 是允许名单。
258
+ // 名单里已有的放进 use,只给用户看;名单外的放进 ask,点「允许」才写进名单。
259
+ // 有网站的任务没开自动允许就要用户点头,哪怕网站全在名单里:每个任务都要一次同意。
260
+ // auto 打开:直接 approved,名单外的网站只给这个任务(写入 sites,不进名单),
261
+ // 也不要留在 ask,否则紧接着的 sites 命令会把同一份清单再问一遍。
262
+ // auto 盖不住任务开始之后追加的网站(sitesToAsk)和文件夹(foldersToAsk)。
263
+ // 没有网站的任务本来就是 approved(只能申请文件夹,文件夹仍另批)。
264
+ export function initialTask({ sites = [], covered = [], auto = false } = {}) {
265
+ const list = Array.isArray(sites) ? sites.slice() : [];
266
+ const use = list.filter((h) => covered.some((root) => hostMatches(h, root)));
267
+ const fresh = list.filter((h) => !use.includes(h));
268
+ if (!list.length) return { state: 'approved', sites: [], ask: [], use: [] };
269
+ return auto
270
+ ? { state: 'approved', sites: fresh, ask: [], use }
271
+ : { state: 'pending', sites: [], ask: fresh, use };
272
+ }
273
+
274
+ // act 剧本静态检查:每一步都要是允许的动作,导航目标都要在白名单里。
275
+ // 嵌套(if/repeat)一起查——不查的话,危险步骤藏一层就能混过去。
276
+ function checkSteps(steps, hosts, depth = 0) {
277
+ if (!Array.isArray(steps)) throw err('CLOUD_DENIED', 'act 的 steps 必须是数组');
278
+ if (depth > 3) throw err('CLOUD_DENIED', 'act 嵌套太深');
279
+ for (const st of steps) {
280
+ if (!st || typeof st !== 'object') throw err('CLOUD_DENIED', 'act 的步骤必须是对象');
281
+ if (!ACT_STEPS.has(st.do)) throw err('CLOUD_DENIED', `云端任务的 act 里不能用 ${JSON.stringify(st.do)}`);
282
+ if (st.do === 'navigate' && st.url !== undefined && !urlAllowed(st.url, hosts)) {
283
+ throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(st.url) || st.url}`);
284
+ }
285
+ if (st.do === 'if') { checkSteps(st.then || [], hosts, depth + 1); checkSteps(st.else || [], hosts, depth + 1); }
286
+ if (st.do === 'repeat') checkSteps(st.steps || [], hosts, depth + 1);
287
+ }
288
+ }
289
+
290
+ // 审一条云端命令。返回清洗过的参数(只留安全的字段、补上强制值),不合规直接抛。
291
+ // tabId 不在这里管——它由 cloud.js 按「这个任务自己开过哪些页」单独核对。
292
+ export function checkCommand(cmd, params, { hosts, origin }) {
293
+ if (!CLOUD_CMDS.has(cmd)) throw err('CLOUD_DENIED', `云端任务不能调用 ${cmd}`);
294
+ const p = { ...(params && typeof params === 'object' ? params : {}) };
295
+ delete p.tabId; // tabId 只能出现在信封上,由 cloud.js 核对归属
296
+
297
+ switch (cmd) {
298
+ case 'tabs': {
299
+ if (!['new', 'list', 'select', 'close'].includes(p.action)) throw err('CLOUD_DENIED', `tabs 不支持 ${p.action}`);
300
+ if (p.action === 'new' && p.url !== undefined && !urlAllowed(p.url, hosts)) {
301
+ throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(p.url) || p.url}`);
302
+ }
303
+ p.focus = false; // 后台干活,不抢用户的视线
304
+ return p;
305
+ }
306
+ case 'navigate': {
307
+ if (p.url !== undefined) {
308
+ if (!urlAllowed(p.url, hosts)) throw err('CLOUD_DENIED', `不在本任务允许的网站上:${hostOf(p.url) || p.url}`);
309
+ } else if (!['back', 'forward', 'reload'].includes(p.action || 'reload')) {
310
+ throw err('CLOUD_DENIED', `navigate 不支持 ${p.action}`);
311
+ }
312
+ return p;
313
+ }
314
+ case 'act': {
315
+ checkSteps(p.steps, hosts);
316
+ // 批处理不代做提交/支付/删除——发布按钮也在其列,必须单独 click 一次,
317
+ // 那一下有自己的效果证据,审计日志里也独立可查
318
+ p.allowSensitive = false;
319
+ return p;
320
+ }
321
+ case 'upload': {
322
+ // 只认 media(服务器上的素材)。path/base64 是本机那条路的参数,云端一个都不收
323
+ const m = p.media;
324
+ if (!m || typeof m !== 'object' || !mediaAllowed(m.url, origin)) {
325
+ throw err('CLOUD_DENIED', 'upload 只能用配对服务器上的素材(media.url)');
326
+ }
327
+ const size = Number(m.size);
328
+ if (!Number.isFinite(size) || size <= 0 || size > MEDIA_MAX) throw err('CLOUD_DENIED', '素材大小不对');
329
+ const name = String(m.name || '').replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 120);
330
+ if (!name || name.startsWith('.')) throw err('CLOUD_DENIED', '素材文件名不对');
331
+ return {
332
+ media: { url: m.url, name, type: String(m.type || 'application/octet-stream').slice(0, 80), size },
333
+ ...(typeof p.selector === 'string' ? { selector: p.selector } : {}),
334
+ ...(typeof p.dropSelector === 'string' ? { dropSelector: p.dropSelector } : {}),
335
+ };
336
+ }
337
+ case 'screenshot':
338
+ delete p.focus; // 只走 CDP 截后台页;退回老路要切前台,那会截到用户正在看的东西
339
+ return p;
340
+ case 'ask':
341
+ p.timeout = Math.min(Math.max(Number(p.timeout) || 300000, 5000), 600000);
342
+ // 只有对外动作的确认可以被免确认开关跳过。别的值(含没写)一律当成人必须在场:
343
+ // 扫码、验证码、滑块不能因为用户开了开关就被静默放行。
344
+ if (p.kind !== 'confirm') delete p.kind;
345
+ return p;
346
+ case 'sites': {
347
+ const { sites, bad } = cleanSites(p.add);
348
+ if (!sites.length && !bad.length) throw err('CLOUD_DENIED', 'sites 要给 add:要访问的网站,如 ["zhipin.com"]');
349
+ return { add: sites, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
350
+ }
351
+ case 'folders': {
352
+ // use 可以是空的:那是在问「这个浏览器挂了哪些」,不是在申请。
353
+ const { names, bad } = cleanFolderNames(p.use);
354
+ return { use: names, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
355
+ }
356
+ case 'fs_list': {
357
+ const folder = cleanFolderName(p.folder);
358
+ if (!folder) throw err('CLOUD_DENIED', 'fs_list 要给 folder:文件夹的显示名');
359
+ const rel = normalizeRelPath(p.path ?? '');
360
+ if (rel === null) throw err('CLOUD_DENIED', 'path 不合法');
361
+ const depth = Math.min(FS_DEPTH_MAX, Math.max(1, Math.floor(Number(p.depth) || 3)));
362
+ let glob = '';
363
+ if (p.glob !== undefined && p.glob !== '') {
364
+ glob = String(p.glob).trim().slice(0, 200);
365
+ if (!glob || glob.includes('..') || /[\x00-\x1f\\]/.test(glob)) throw err('CLOUD_DENIED', 'glob 不合法');
366
+ }
367
+ return { folder, path: rel, depth, ...(glob ? { glob } : {}) };
368
+ }
369
+ case 'fs_read': {
370
+ const folder = cleanFolderName(p.folder);
371
+ if (!folder) throw err('CLOUD_DENIED', 'fs_read 要给 folder:文件夹的显示名');
372
+ const rel = normalizeRelPath(p.path ?? '');
373
+ if (!rel) throw err('CLOUD_DENIED', 'fs_read 要给文件的 path');
374
+ const base = rel.split('/').pop();
375
+ if (skipEntry(base) || !fileAllowed(base)) throw err('CLOUD_DENIED', `不读取这种文件:${base}`);
376
+ if (!pullUrlAllowed(p.upload?.url, origin)) throw err('CLOUD_DENIED', 'fs_read 只能把文件传到配对服务器的 /api/ext/pull/');
377
+ return { folder, path: rel, upload: { url: p.upload.url } };
378
+ }
379
+ case 'load':
380
+ case 'call': {
381
+ // 能力包的 hosts 由 background.js 对着标签页再核一遍;这里管名字、action 和 payload
382
+ try {
383
+ if (cmd === 'load') return { ...checkLoadParams(p), ...(p.reload === true ? { reload: true } : {}) };
384
+ // confirm 由注册表算出来,服务器传的同名字段在这里就被丢掉了
385
+ const { name, action, payload, timeout, confirm } = checkCallParams(p);
386
+ return { name, action, payload, timeout, confirm };
387
+ } catch (e) {
388
+ throw err('CLOUD_DENIED', e.message);
389
+ }
390
+ }
391
+ default:
392
+ return p;
393
+ }
394
+ }
395
+
396
+ // 命令执行完、结果发回服务器之前,标签页若已经跳出白名单(点了个外链、被重定向),
397
+ // 结果里那一页的内容不能出去。只留一句说明。
398
+ export function redactOffList(url) {
399
+ return {
400
+ text: `⛔ 标签页跳到了本任务允许范围以外的地址(${hostOf(url) || '非网页地址'}),`
401
+ + '内容已屏蔽,标签页已退回。回到允许的网站继续,或用 ask 请用户处理。',
402
+ offList: true,
403
+ };
404
+ }
405
+
406
+ // 结果瘦身:截图以外的大块数据不往回传。上限防止一份异常大的快照把通道堵死。
407
+ export const RESULT_MAX = 3 * 1024 * 1024;