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,972 +1,1005 @@
1
- // 云端通道 —— 让一个配对过的服务(默认 maker.vcorp.ai)在这个浏览器里替用户办事(发布、查资料、填表)。
2
- //
3
- // 和本机那条桥是两回事:桥只有本机进程能连,agent 拿到的是全套工具;
4
- // 云端通道是**这个浏览器主动连出去**的一条 WebSocket,对面是公网上的服务器。
5
- // 所以这里的每一条命令都按「对面可能已经被攻破」来处理,规矩全在 cloud-policy.js:
6
- // 只有页面操作那一小组命令、只能停在允许名单(以及本任务里批准的网站)上、只能碰自己开的标签页、
7
- // 上传只能用服务器上的素材。新任务要用户点头;开了自动允许则新任务和它一开始的网站直接放行。
8
- // 任务开始之后追加的网站、本地文件夹,仍要逐个点头。带 kind=confirm 的 ask 是对外动作的确认:
9
- // 用户开了免确认,或在这个任务里点过「本任务内都同意」,就直接放行。扫码和验证码不带 kind,照旧等人。
10
- // 没有预设的平台表:要去哪个网站,任务自己申请。
11
- //
12
- // 身份:配对时在这里生成一对 ECDSA P-256 密钥,私钥以不可导出的 CryptoKey 存在
13
- // IndexedDB,服务器只拿到公钥。每次连接对服务器给的随机数签名,签名里带上服务器
14
- // 地址——换一台服务器重放这个签名没有用。服务器那边没有可被偷走后冒充本机的口令。
15
- //
16
- // socket 放在 SW 里,不放 offscreen:Chrome 116+ 上 WebSocket 收发消息会续 SW 的命,
17
- // 20 秒一次心跳足够;SW 真被回收了,30 秒的 alarm 会把它连回来(见 background.js)。
18
-
19
- import * as policy from './cloud-policy.js';
20
- import * as folders from './folders.js';
21
-
22
- const CFG = 'cloud'; // storage.local:配对信息和用户的选择
23
- const TASKS = 'cloudTasks'; // storage.local:任务(批准状态、自己开的标签页)
24
- const LOG = 'cloudLog'; // storage.local:审计日志,侧栏可看
25
- const STATUS = 'cloudStatus'; // storage.session:连接状态,侧栏订阅
26
- const CLAIM = 'cloudClaim'; // storage.session:配对第一步拿到的账号,等用户确认
27
- const LOG_CAP = 200;
28
- const PING_MS = 20000;
29
- const DEAD_MS = 65000;
30
- const APPROVE_MS = 5 * 60 * 1000;
31
- const PER_MINUTE = 120;
32
- const DROP_MAX = 48 * 1024 * 1024; // SW 读字节再交给页面的那条路的上限(同本机 upload)
33
-
34
- let deps = null; // background 注入:run(cmd, params, tabId, sid, label)、setCloudLive(sids)
35
- let ws = null;
36
- let pingTimer = null;
37
- let retryTimer = null;
38
- let retryDelay = 0;
39
- let lastRx = 0;
40
- let connecting = false;
41
- const waiters = new Map(); // jobId → Set<fn(allow)>,等用户批准的命令
42
- const recent = []; // 最近一分钟的命令时间戳
43
- let lock = Promise.resolve();
44
- let mountWin = null;
45
-
46
- const err = (code, message) => Object.assign(new Error(message), { code });
47
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
-
49
- // 任务表的读改写串行化:同一任务的几条命令可能并发到达,读改写交错会丢标签页
50
- function serial(fn) {
51
- const run = lock.then(fn, fn);
52
- lock = run.catch(() => {});
53
- return run;
54
- }
55
-
56
- // ---------- 密钥 ----------
57
-
58
- function idb() {
59
- return new Promise((resolve, reject) => {
60
- const r = indexedDB.open('hc-cloud', 1);
61
- r.onupgradeneeded = () => r.result.createObjectStore('keys');
62
- r.onsuccess = () => resolve(r.result);
63
- r.onerror = () => reject(r.error);
64
- });
65
- }
66
- async function idbOp(mode, fn) {
67
- const db = await idb();
68
- try {
69
- return await new Promise((resolve, reject) => {
70
- const tx = db.transaction('keys', mode);
71
- const req = fn(tx.objectStore('keys'));
72
- tx.oncomplete = () => resolve(req?.result);
73
- tx.onerror = () => reject(tx.error);
74
- });
75
- } finally { db.close(); }
76
- }
77
- const keyGet = () => idbOp('readonly', (s) => s.get('device'));
78
- const keySet = (v) => idbOp('readwrite', (s) => s.put(v, 'device'));
79
- const keyDel = () => idbOp('readwrite', (s) => s.delete('device'));
80
-
81
- async function newKey() {
82
- // 私钥 extractable:false —— 连扩展自己的代码也导不出原始私钥
83
- const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
84
- const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
85
- return { privateKey: pair.privateKey, publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } };
86
- }
87
-
88
- const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
89
-
90
- async function sign(text) {
91
- const k = await keyGet();
92
- if (!k?.privateKey) throw err('CLOUD_UNPAIRED', '本机没有配对密钥');
93
- return b64url(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, k.privateKey, new TextEncoder().encode(text)));
94
- }
95
-
96
- // 签名内容的格式,和服务器那侧(opus-video-maker/extlink.js)逐字一致
97
- export const helloText = (origin, deviceId, nonce) => `vcorp-cloud-v1\n${origin}\n${deviceId}\n${nonce}`;
98
-
99
- // ---------- 配置 ----------
100
-
101
- const getCfg = async () => (await chrome.storage.local.get(CFG))[CFG] || null;
102
- const setCfg = (v) => chrome.storage.local.set({ [CFG]: v });
103
- const setStatus = (state, message = '') => chrome.storage.session.set({ [STATUS]: { state, message, at: Date.now() } }).catch(() => {});
104
-
105
- async function log(entry) {
106
- try {
107
- const { [LOG]: list = [] } = await chrome.storage.local.get(LOG);
108
- list.push({ t: Date.now(), ...entry });
109
- await chrome.storage.local.set({ [LOG]: list.slice(-LOG_CAP) });
110
- } catch { /* 日志写不进不影响干活 */ }
111
- }
112
-
113
- function deviceName() {
114
- const plat = navigator.userAgentData?.platform || (/Windows/.test(navigator.userAgent) ? 'Windows' : /Mac/.test(navigator.userAgent) ? 'macOS' : 'Linux');
115
- const ver = (navigator.userAgent.match(/Chrome\/(\d+)/) || [])[1] || '';
116
- return `Chrome ${ver} · ${plat}`.trim();
117
- }
118
-
119
- // ---------- 配对 ----------
120
- //
121
- // 两步:先 claim 拿到「这个码属于哪个账号」,给用户看一眼,确认了才 confirm 生成密钥。
122
- // 多这一步是为了防「把这个码填进你的扩展」式的钓鱼——填别人给的码,等于把自己的
123
- // 浏览器里的登录交给别人的 maker 账号去用。确认框里写着账号名,看一眼就知道不对。
124
-
125
- async function post(origin, path, body) {
126
- const res = await fetch(origin + path, {
127
- method: 'POST', credentials: 'omit', cache: 'no-store',
128
- headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
129
- });
130
- let data = null;
131
- try { data = await res.json(); } catch { /* 下面按状态码报 */ }
132
- if (!res.ok) throw err('CLOUD_PAIR', data?.error || `服务器返回 ${res.status}`);
133
- return data;
134
- }
135
-
136
- async function claim(rawOrigin, rawCode) {
137
- const origin = policy.normalizeOrigin(rawOrigin || policy.DEFAULT_ORIGIN);
138
- if (!origin) throw err('CLOUD_PAIR', '服务器地址必须是 https://(本机调试可用 http://localhost)');
139
- const code = String(rawCode || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
140
- if (code.length !== 8) throw err('CLOUD_PAIR', '配对码是 8 位字母和数字');
141
- const r = await post(origin, '/api/ext/pair/claim', { code });
142
- const pending = { origin, code, account: String(r.account || ''), expiresAt: r.expiresAt || Date.now() + 600000 };
143
- await chrome.storage.session.set({ [CLAIM]: pending });
144
- return pending;
145
- }
146
-
147
- async function confirmClaim() {
148
- const { [CLAIM]: c } = await chrome.storage.session.get(CLAIM);
149
- if (!c) throw err('CLOUD_PAIR', '没有待确认的配对,重新输入配对码');
150
- const key = await newKey();
151
- const r = await post(c.origin, '/api/ext/pair/confirm', { code: c.code, publicKey: key.publicJwk, name: deviceName() });
152
- if (!r?.deviceId) throw err('CLOUD_PAIR', '服务器没有返回设备号');
153
- await keySet({ privateKey: key.privateKey, deviceId: r.deviceId, origin: c.origin });
154
- const old = await getCfg();
155
- await setCfg({
156
- origin: c.origin, deviceId: r.deviceId, account: String(r.account || c.account), pairedAt: Date.now(),
157
- // 换绑时沿用之前允许的网站;第一次配对名单是空的,等任务申请、用户点允许
158
- sites: policy.cleanAllowList(old?.sites), auto: false, autoConfirm: false, paused: false,
159
- });
160
- await chrome.storage.session.remove(CLAIM);
161
- await log({ ev: 'paired', account: r.account || c.account, origin: c.origin });
162
- clearTimeout(retryTimer); retryTimer = null;
163
- retryDelay = 0;
164
- void connect();
165
- return getCfg();
166
- }
167
-
168
- // 解除配对:先告诉服务器作废这个设备,再把本机的密钥、任务、云端开的标签页全部清掉。
169
- // 服务器那一步失败也照样清本机——用户点了「断开」,本机就不能再听那边的话。
170
- async function unpair() {
171
- const cfg = await getCfg();
172
- if (ws?.readyState === 1) { try { ws.send(JSON.stringify({ type: 'unpair' })); } catch { /* 下面照样清 */ } }
173
- await sleep(200);
174
- disconnect();
175
- const { [TASKS]: tasks = {} } = await chrome.storage.local.get(TASKS);
176
- for (const t of Object.values(tasks)) await closeTaskTabs(t, { force: true });
177
- await keyDel().catch(() => {});
178
- await chrome.storage.local.remove([CFG, TASKS]);
179
- deps?.setCloudLive([]);
180
- await setStatus('off');
181
- await log({ ev: 'unpaired', origin: cfg?.origin || '' });
182
- }
183
-
184
- // ---------- 连接 ----------
185
-
186
- function disconnect() {
187
- clearInterval(pingTimer); pingTimer = null;
188
- clearTimeout(retryTimer); retryTimer = null;
189
- if (ws) { try { ws.onclose = null; ws.close(); } catch { /* 已经废了 */ } }
190
- ws = null;
191
- }
192
-
193
- function scheduleReconnect(minDelay = 0) {
194
- if (retryTimer) return;
195
- const grown = retryDelay ? Math.min(retryDelay * 2, 60000) : 1000;
196
- retryDelay = Math.max(grown, minDelay);
197
- retryTimer = setTimeout(() => { retryTimer = null; void connect(); }, retryDelay);
198
- }
199
-
200
- function socketUrl(origin) {
201
- const u = new URL(origin);
202
- u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
203
- u.pathname = '/api/ext/ws';
204
- u.search = '';
205
- u.hash = '';
206
- return u.href;
207
- }
208
-
209
- export async function connect() {
210
- // readyState 2 是 CLOSING。只挡 0 和 1 的话,旧连接还在关、新连接已经握上,
211
- // 代理会把叠在一起的升级请求回 503。
212
- if (connecting || retryTimer || (ws && ws.readyState !== 3)) return;
213
- const cfg = await getCfg();
214
- if (!cfg?.deviceId) return setStatus('off');
215
- if (cfg.paused) return setStatus('paused', '已暂停:云端命令一律不接');
216
- connecting = true;
217
- let sock;
218
- let opened = false;
219
- try {
220
- await setStatus('connecting');
221
- // 地址不合法时构造函数会抛,接住后重连。握手被拒(代理 503 等)不会抛:
222
- // Chrome 把 HTTP 状态写进自己的网络日志,脚本取消不了那一行。
223
- // 这种失败走下面的 onclose,并且把重试间隔拉到至少 15 秒,避免每秒一条。
224
- try { sock = new WebSocket(socketUrl(cfg.origin)); }
225
- catch { scheduleReconnect(); return; }
226
- ws = sock;
227
- lastRx = Date.now();
228
- sock.onopen = () => { opened = true; };
229
- sock.onerror = (ev) => { ev?.preventDefault?.(); };
230
- sock.onmessage = (ev) => {
231
- void (async () => {
232
- try {
233
- lastRx = Date.now();
234
- let m;
235
- try { m = JSON.parse(ev.data); } catch { return; }
236
- if (!m || typeof m !== 'object') return;
237
- if (m.type === 'ping') { if (sock.readyState === 1) sock.send(JSON.stringify({ type: 'pong' })); return; }
238
- if (m.type === 'pong') return;
239
- if (m.type === 'challenge') {
240
- try {
241
- const sig = await sign(helloText(cfg.origin, cfg.deviceId, String(m.nonce || '')));
242
- const mounted = await folders.names().catch(() => []);
243
- sock.send(JSON.stringify({
244
- type: 'hello', v: 1, deviceId: cfg.deviceId, sig,
245
- version: chrome.runtime.getManifest().version, sites: policy.cleanAllowList(cfg.sites), auto: !!cfg.auto, autoConfirm: !!cfg.autoConfirm,
246
- folders: mounted,
247
- }));
248
- } catch { try { sock.close(); } catch { /* 已经废了 */ } }
249
- return;
250
- }
251
- if (m.type === 'welcome') {
252
- retryDelay = 0;
253
- await setStatus('online', String(m.account || cfg.account || ''));
254
- return;
255
- }
256
- // 服务器说这个设备已经被作废(用户在网页上删了它):本机跟着解除,不再重连
257
- if (m.type === 'revoked') { await unpair(); await setStatus('off', '这个浏览器已在网页上被移除'); return; }
258
- if (m.type === 'cmd') { await handle(m); return; }
259
- // 服务器的「验证」:一来一回量延迟,什么都不开。老版本这里还会探平台登录,已经去掉。
260
- if (m.type === 'probe') { send({ type: 'res', id: m.id, ok: true, data: {} }); return; }
261
- if (m.type === 'mount_request') { await openMount(); return; }
262
- if (m.type === 'job_end') { await endTask(String(m.jobId || ''), 'server'); }
263
- } catch { /* 一条消息处理失败不能变成未捕获异常 */ }
264
- })();
265
- };
266
- sock.onclose = (ev) => {
267
- if (ws !== sock) return;
268
- ws = null;
269
- clearInterval(pingTimer); pingTimer = null;
270
- if (ev.code === 4401) { void setStatus('error', '服务器不认这个设备的签名,请重新配对'); return; }
271
- void setStatus('connecting');
272
- scheduleReconnect(opened ? 0 : 15000);
273
- };
274
- clearInterval(pingTimer);
275
- pingTimer = setInterval(() => {
276
- try {
277
- if (sock.readyState !== 1) return;
278
- if (Date.now() - lastRx > DEAD_MS) { sock.close(); return; }
279
- sock.send(JSON.stringify({ type: 'ping' }));
280
- } catch { /* 发送时已经断开 */ }
281
- }, PING_MS);
282
- } catch {
283
- await setStatus('connecting');
284
- scheduleReconnect();
285
- } finally {
286
- connecting = false;
287
- }
288
- }
289
-
290
- // alarm 每 30 秒叫一次:配了对、没暂停、却没连着,就连
291
- export async function ensureCloud() {
292
- if (retryTimer || (ws && ws.readyState !== 3)) return;
293
- const cfg = await getCfg();
294
- if (cfg?.deviceId && !cfg.paused) await connect();
295
- }
296
-
297
- const send = (m) => { try { if (ws?.readyState === 1) ws.send(JSON.stringify(m)); } catch { /* 发送时已经断开 */ } };
298
-
299
- // ---------- 任务 ----------
300
-
301
- const getTasks = async () => (await chrome.storage.local.get(TASKS))[TASKS] || {};
302
- const setTasks = (t) => chrome.storage.local.set({ [TASKS]: t });
303
- const sidOf = (jobId) => `cloud-${jobId}`;
304
- // 带大写字母,identity.js 才会原样当显示名用(不然会被当 slug 机械美化)。
305
- // sid 里没有冒号,页面标记上的短码就是任务号末 6 位,用户能和网页上的任务对上
306
- const labelOf = (origin) => `Cloud · ${policy.hostOf(origin)}`;
307
-
308
- async function pushLive() {
309
- const tasks = await getTasks();
310
- deps?.setCloudLive(Object.values(tasks).filter((t) => t.state === 'approved').map((t) => sidOf(t.jobId)));
311
- }
312
-
313
- const allowOf = (cfg) => policy.cleanAllowList(cfg?.sites);
314
- const hostsOf = (t, cfg) => policy.taskHosts(t, allowOf(cfg));
315
- // 任务表的格式版本。老版本按平台建的任务没有它,启动时清掉,下一条命令重新问。
316
- const TASK_V = 2;
317
-
318
- // 新任务由用户点头,或由用户打开的自动允许直接放行(含信封上一开始的网站)。
319
- // 任务开始之后再申请的网站、本地文件夹,自动允许管不到。服务器只能申请。
320
- // reason:第一次申请站点时 agent 写的用途,放进批准框里给用户看。
321
- async function taskFor(job, cfg, reason = '', { allowEmpty = false } = {}) {
322
- const jobId = String(job?.id || '');
323
- if (!/^[\w-]{1,64}$/.test(jobId)) throw err('CLOUD_DENIED', '命令缺少有效的任务号');
324
- const title = String(job?.title || '').slice(0, 80);
325
- let freshAuto = false;
326
-
327
- const t = await serial(async () => {
328
- const tasks = await getTasks();
329
- let task = tasks[jobId];
330
- if (task?.state === 'approved' && Date.now() - task.lastAt > policy.TASK_IDLE_MS) task = null; // 闲置太久,重新问
331
- if (task && task.v === TASK_V) return task;
332
- const { sites } = policy.cleanSites(job?.sites);
333
- // 只读本地文件夹的任务可以还没有网站。文件夹本身仍要单独点头,这里不替用户答应。
334
- if (!sites.length && !allowEmpty) return { state: 'nosites' };
335
- const opening = policy.initialTask({ sites, covered: allowOf(cfg), auto: !!cfg.auto });
336
- freshAuto = !!cfg.auto && opening.state === 'approved' && sites.length > 0;
337
- task = {
338
- v: TASK_V, jobId, title, sites: opening.sites, use: opening.use, ask: opening.ask, refused: [], purpose: reason,
339
- folders: [], folderRefused: [], folderAsk: [], folderBytes: 0,
340
- tabs: [], cur: null, count: 0, state: opening.state, createdAt: Date.now(), lastAt: Date.now(),
341
- };
342
- tasks[jobId] = task;
343
- await setTasks(tasks);
344
- return task;
345
- });
346
-
347
- if (freshAuto) {
348
- await log({ ev: 'approved', job: jobId, msg: '自动放行' });
349
- await pushLive();
350
- }
351
- if (t.state === 'nosites') throw err('NO_SITES', '先用 sites 申请要访问的网站(如 sites {"add":["zhipin.com"],"reason":"找兼职"}),或用 folders 申请本地文件夹。用户批准后才能继续。');
352
- if (t.state === 'denied') throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
353
- if (t.state === 'approved') return t;
354
-
355
- // 这条任务还挂着。开关若已经打开(包括刚打开、存储里已经是开的),按允许处理,不再弹窗。
356
- const live = await getCfg();
357
- if (cfg.auto || live?.auto) {
358
- await decide(jobId, true, { auto: true });
359
- const granted = (await getTasks())[jobId];
360
- if (granted?.state === 'approved') return granted;
361
- }
362
- const now = (await getTasks())[jobId];
363
- if (!now) throw err('CLOUD_DENIED', '这个任务已经结束');
364
- if (now.state === 'denied') throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
365
- if (now.state === 'approved') return now;
366
-
367
- // 等用户点头。通知是把人叫回来的唯一办法;侧栏里同样有一张卡片
368
- notifyPending(now, live || cfg);
369
- const allow = await waitFor(jobId);
370
- if (allow === null) throw err('CLOUD_TIMEOUT', '用户 5 分钟内没有批准这个任务。告诉用户打开浏览器扩展侧栏批准后再继续。');
371
- if (!allow) throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
372
- return (await getTasks())[jobId];
373
- }
374
-
375
- // 用户的决定:true 允许、false 拒绝、null 5 分钟没理
376
- function waitFor(key) {
377
- return new Promise((resolve) => {
378
- const set = waiters.get(key) || new Set();
379
- const fn = (v) => { clearTimeout(timer); set.delete(fn); resolve(v); };
380
- const timer = setTimeout(() => fn(null), APPROVE_MS);
381
- set.add(fn);
382
- waiters.set(key, set);
383
- });
384
- }
385
-
386
- function notifyPending(t, cfg) {
387
- const names = [...(t.ask || []), ...(t.use || [])].join('、');
388
- chrome.notifications?.create(`hc-cloud:${t.jobId}`, {
389
- type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
390
- title: `${policy.hostOf(cfg.origin)} 请求在你的浏览器里操作这些网站`,
391
- message: `${t.title ? `「${t.title}」` : '一个任务'} → ${names}${t.purpose ? `\n用途:${t.purpose}` : ''}`,
392
- buttons: [{ title: '允许' }, { title: '拒绝' }],
393
- }, () => void chrome.runtime.lastError);
394
- }
395
-
396
- // 进行中的任务又要新站点:单独问一次,只问这几个。
397
- function notifySites(t, cfg) {
398
- chrome.notifications?.create(`hc-cloud-sites:${t.jobId}`, {
399
- type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
400
- title: `${policy.hostOf(cfg.origin)} 的任务还想访问新的网站`,
401
- message: `${t.title ? `「${t.title}」` : '任务'} → ${(t.ask || []).join('、')}${t.askWhy ? `\n用途:${t.askWhy}` : ''}`,
402
- buttons: [{ title: '允许' }, { title: '拒绝' }],
403
- }, () => void chrome.runtime.lastError);
404
- }
405
-
406
- // 任务申请网站。允许名单已经盖住的不再问;用户拒绝过的同一任务不再问。
407
- // 「自动允许」管不到这里。用户点了允许,这些网站也进允许名单。
408
- // 本任务新批的网站超过 SITES_MAX 的部分这次不询问。
409
- async function requestSites(task, p, cfg) {
410
- const split = policy.sitesToAsk(p.add, {
411
- covered: allowOf((await getCfg()) || cfg),
412
- sites: task.sites || [],
413
- refused: task.refused || [],
414
- });
415
- if (split.ask.length) {
416
- const t = await updateTask(task.jobId, (x) => {
417
- if (!Array.isArray(x.sites)) x.sites = [];
418
- if (!Array.isArray(x.refused)) x.refused = [];
419
- x.ask = split.ask;
420
- x.askWhy = p.reason;
421
- });
422
- if (!t) throw err('CLOUD_DENIED', '这个任务已经结束');
423
- notifySites(t, cfg);
424
- const allow = await waitFor(`sites:${task.jobId}`);
425
- await updateTask(task.jobId, (x) => {
426
- if (!Array.isArray(x.sites)) x.sites = [];
427
- if (!Array.isArray(x.refused)) x.refused = [];
428
- if (allow) for (const h of split.ask) if (!x.sites.includes(h)) x.sites.push(h);
429
- else if (allow === false) for (const h of split.ask) if (!x.refused.includes(h)) x.refused.push(h);
430
- x.ask = [];
431
- x.askWhy = '';
432
- });
433
- chrome.notifications?.clear(`hc-cloud-sites:${task.jobId}`, () => void chrome.runtime.lastError);
434
- if (allow) await allowSites(split.ask);
435
- await log({ ev: allow ? 'sites_on' : allow === false ? 'sites_off' : 'sites_timeout', job: task.jobId, msg: split.ask.join(',') });
436
- }
437
- const now = (await getTasks())[task.jobId] || task;
438
- const allowList = allowOf(await getCfg());
439
- const sites = now.sites || [];
440
- const refused = now.refused || [];
441
- const lines = [`允许访问:${policy.taskHosts(now, allowList).join('、') || '(无)'}`];
442
- if (split.covered.length) lines.push(`已在允许名单里:${split.covered.join('、')}`);
443
- const no = p.add.filter((h) => refused.includes(h) && !split.covered.includes(h));
444
- const later = split.ask.filter((h) => !sites.includes(h) && !refused.includes(h));
445
- if (no.length) lines.push(`用户不同意:${no.join('、')}。不要再申请,换别的办法或在报告里说明。`);
446
- if (later.length) lines.push(`用户 5 分钟内没有回应:${later.join('、')}。可以稍后再申请一次。`);
447
- if (split.overflow.length) lines.push(`超出本任务额外网站上限(最多 ${policy.SITES_MAX} 个),这次没有询问:${split.overflow.join('、')}`);
448
- if (p.bad.length) lines.push(`不是可以申请的网站(要写域名,如 zhipin.com):${p.bad.join('、')}`);
449
- return { text: lines.join('\n'), sites };
450
- }
451
-
452
- function notifyFolders(t, cfg) {
453
- chrome.notifications?.create(`hc-cloud-folders:${t.jobId}`, {
454
- type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
455
- title: `${policy.hostOf(cfg.origin)} 的任务想读取你的本地文件夹`,
456
- message: `${t.title ? `「${t.title}」` : '任务'} → ${(t.folderAsk || []).join('、')}${t.folderAskWhy ? `\n用途:${t.folderAskWhy}` : ''}`,
457
- buttons: [{ title: '允许' }, { title: '拒绝' }],
458
- }, () => void chrome.runtime.lastError);
459
- }
460
-
461
- // 和额外网站一样:「自动允许」管不到文件夹。没挂载的名字不弹窗,直接告诉 agent。
462
- async function requestFolders(task, p, cfg) {
463
- const mounted = await folders.names().catch(() => []);
464
- const have = new Set(mounted.map((f) => f.name));
465
- const known = p.use.filter((name) => have.has(name));
466
- const unknown = p.use.filter((name) => !have.has(name));
467
- const split = policy.foldersToAsk(known, { granted: task.folders || [], refused: task.folderRefused || [] });
468
- if (split.ask.length) {
469
- const t = await updateTask(task.jobId, (x) => {
470
- if (!Array.isArray(x.folders)) x.folders = [];
471
- if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
472
- x.folderAsk = split.ask;
473
- x.folderAskWhy = p.reason;
474
- });
475
- if (!t) throw err('CLOUD_DENIED', '这个任务已经结束');
476
- notifyFolders(t, cfg);
477
- const allow = await waitFor(`folders:${task.jobId}`);
478
- await updateTask(task.jobId, (x) => {
479
- if (!Array.isArray(x.folders)) x.folders = [];
480
- if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
481
- if (allow) for (const name of split.ask) if (!x.folders.includes(name) && x.folders.length < policy.FS_FOLDERS_MAX) x.folders.push(name);
482
- else if (allow === false) for (const name of split.ask) if (!x.folderRefused.includes(name)) x.folderRefused.push(name);
483
- x.folderAsk = [];
484
- x.folderAskWhy = '';
485
- });
486
- chrome.notifications?.clear(`hc-cloud-folders:${task.jobId}`, () => void chrome.runtime.lastError);
487
- await log({ ev: allow ? 'folders_on' : allow === false ? 'folders_off' : 'folders_timeout', job: task.jobId, msg: split.ask.join(',') });
488
- }
489
- const now = (await getTasks())[task.jobId] || task;
490
- const granted = now.folders || [];
491
- const refused = now.folderRefused || [];
492
- const lines = [`已挂载:${mounted.map((f) => f.name).join('、') || '(无)'}`];
493
- lines.push(`本任务可以读:${granted.join('、') || '(无)'}`);
494
- const no = known.filter((name) => refused.includes(name));
495
- const later = split.ask.filter((name) => !granted.includes(name) && !refused.includes(name));
496
- if (no.length) lines.push(`用户不同意:${no.join('、')}。不要再申请。`);
497
- if (later.length) lines.push(`用户 5 分钟内没有回应:${later.join('、')}。可以稍后再申请一次。`);
498
- if (unknown.length) lines.push(`这个浏览器没有挂载:${unknown.join('、')}。请用户在网页上点「挂载本地文件夹」。`);
499
- if (p.bad.length) lines.push(`不是合法的文件夹名:${p.bad.join('、')}`);
500
- return { text: lines.join('\n'), folders: granted, audit: `folders ${p.use.join(',') || '(list)'}` };
501
- }
502
-
503
- function decideFolders(jobId, allow) {
504
- chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
505
- for (const fn of waiters.get(`folders:${jobId}`) || []) fn(!!allow);
506
- }
507
-
508
- async function notifyLocked(entry) {
509
- chrome.notifications?.create(`hc-folder:${entry.id}`, {
510
- type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
511
- title: '本地文件夹需要重新允许',
512
- message: `「${entry.name}」的读取权限失效了。点这里打开窗口,再点「恢复访问」。`,
513
- }, () => void chrome.runtime.lastError);
514
- await openMount(entry.id).catch(() => {});
515
- }
516
-
517
- async function fsCmd(cmd, p, task) {
518
- const granted = task.folders || [];
519
- if (!granted.includes(p.folder)) {
520
- throw err('CLOUD_DENIED', `本任务还不能读「${p.folder}」。先用 folders 申请,用户点头之后再读。`);
521
- }
522
- const entry = await folders.getByName(p.folder);
523
- if (!entry?.handle) throw err('CLOUD_DENIED', `这个浏览器上没有「${p.folder}」。请用户在网页上重新挂载。`);
524
- try {
525
- if (cmd === 'fs_list') return await folders.listTree(entry.handle, { path: p.path, depth: p.depth, glob: p.glob, folder: p.folder });
526
- const file = await folders.readFile(entry.handle, p.path);
527
- let reserved = false;
528
- const held = await updateTask(task.jobId, (x) => {
529
- const used = x.folderBytes || 0;
530
- if (used + file.size > policy.FS_TASK_MAX) return;
531
- x.folderBytes = used + file.size;
532
- reserved = true;
533
- });
534
- if (!held) throw err('CLOUD_DENIED', '这个任务已经结束');
535
- if (!reserved) throw err('CLOUD_DENIED', '这个任务读取本地文件已到 256MB 上限');
536
- try {
537
- const up = await folders.uploadFile(file, p.upload.url);
538
- return { text: `已读取 ${p.folder}/${p.path}(${up.bytes} 字节),可以拉取了。`, bytes: up.bytes, name: up.name, audit: `read ${p.folder}/${p.path} ${up.bytes}` };
539
- } catch (e) {
540
- await updateTask(task.jobId, (x) => { x.folderBytes = Math.max(0, (x.folderBytes || 0) - file.size); });
541
- throw e;
542
- }
543
- } catch (e) {
544
- if (e.code === 'FOLDER_LOCKED') {
545
- await notifyLocked(entry);
546
- throw err('FOLDER_LOCKED', `文件夹「${entry.name}」需要你重新允许一次。已打开窗口,点「恢复访问」后再试。`);
547
- }
548
- throw e;
549
- }
550
- }
551
-
552
- async function openMount(folderId = '') {
553
- const url = chrome.runtime.getURL('mount.html') + (folderId ? `?id=${encodeURIComponent(folderId)}` : '');
554
- if (mountWin != null) {
555
- const existing = await chrome.windows.get(mountWin).catch(() => null);
556
- if (existing) {
557
- await chrome.windows.update(mountWin, { focused: true, drawAttention: true }).catch(() => {});
558
- const [tab] = await chrome.tabs.query({ windowId: mountWin });
559
- if (tab) await chrome.tabs.update(tab.id, { url }).catch(() => {});
560
- return;
561
- }
562
- mountWin = null;
563
- }
564
- const created = await chrome.windows.create({ url, type: 'popup', width: 440, height: 560, focused: true });
565
- mountWin = created?.id ?? null;
566
- }
567
-
568
- async function reportFolders() {
569
- const mounted = await folders.names().catch(() => []);
570
- const cfg = await getCfg();
571
- if (cfg && ws?.readyState === 1) send({ type: 'config', sites: allowOf(cfg), auto: !!cfg.auto, autoConfirm: !!cfg.autoConfirm, folders: mounted });
572
- await chrome.storage.session.set({ folderTick: Date.now() }).catch(() => {});
573
- }
574
-
575
- function decideSites(jobId, allow) {
576
- chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
577
- for (const fn of waiters.get(`sites:${jobId}`) || []) fn(!!allow);
578
- }
579
-
580
- // 用户亲手允许的网站进允许名单。自动允许不走这里。
581
- async function allowSites(hosts) {
582
- if (!hosts?.length) return;
583
- const cfg = await getCfg();
584
- if (!cfg) return;
585
- const list = allowOf(cfg);
586
- const add = policy.cleanAllowList(hosts).filter((h) => !list.includes(h));
587
- if (!add.length) return;
588
- await patchCfg({ sites: [...list, ...add] });
589
- await log({ ev: 'site_on', msg: add.join(',') });
590
- }
591
-
592
- // 自动允许(auto)只放行这个任务:一开始的网站给这个任务用,不进允许名单。
593
- // 用户点的允许:这些网站同时进允许名单。
594
- async function decide(jobId, allow, { auto = false } = {}) {
595
- let granted = [];
596
- const changed = await serial(async () => {
597
- const tasks = await getTasks();
598
- const t = tasks[jobId];
599
- if (!t || t.state !== 'pending') return false;
600
- t.state = allow ? 'approved' : 'denied';
601
- t.lastAt = Date.now();
602
- if (allow) { granted = t.ask || []; t.sites = [...new Set([...(t.sites || []), ...granted])]; }
603
- t.ask = [];
604
- await setTasks(tasks);
605
- return true;
606
- });
607
- chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
608
- if (changed && allow && !auto) await allowSites(granted);
609
- if (changed) {
610
- await log({ ev: allow ? 'approved' : 'denied', job: jobId, ...(auto && allow ? { msg: '自动放行' } : {}) });
611
- await pushLive();
612
- }
613
- for (const fn of waiters.get(jobId) || []) fn(!!allow);
614
- }
615
-
616
- // 任务结束(服务器说完了,或用户在侧栏里收回):从此不再接它的命令,关掉它开的标签页。
617
- // 用户正盯着的那一页不关——他可能正在核对刚发出去的作品。
618
- async function endTask(jobId, by) {
619
- const t = await serial(async () => {
620
- const tasks = await getTasks();
621
- const task = tasks[jobId];
622
- if (!task) return null;
623
- delete tasks[jobId];
624
- await setTasks(tasks);
625
- return task;
626
- });
627
- if (!t) return;
628
- for (const fn of waiters.get(jobId) || []) fn(false);
629
- for (const fn of waiters.get(`sites:${jobId}`) || []) fn(false);
630
- for (const fn of waiters.get(`folders:${jobId}`) || []) fn(false);
631
- chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
632
- chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
633
- chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
634
- await closeTaskTabs(t, { force: by === 'user' });
635
- await pushLive();
636
- await log({ ev: 'ended', job: jobId, by });
637
- }
638
-
639
- async function closeTaskTabs(t, { force = false } = {}) {
640
- for (const id of t.tabs || []) {
641
- const tab = await chrome.tabs.get(id).catch(() => null);
642
- if (!tab) continue;
643
- if (!force && tab.active) continue;
644
- await chrome.tabs.remove(id).catch(() => {});
645
- }
646
- }
647
-
648
- const updateTask = (jobId, fn) => serial(async () => {
649
- const tasks = await getTasks();
650
- if (!tasks[jobId]) return null;
651
- fn(tasks[jobId]);
652
- await setTasks(tasks);
653
- return tasks[jobId];
654
- });
655
-
656
- // ---------- 命令 ----------
657
-
658
- async function handle(m) {
659
- const started = Date.now();
660
- const jobId = String(m.job?.id || '');
661
- try {
662
- const data = await exec(m);
663
- let out = data;
664
- const audit = out && typeof out === 'object' ? out.audit : '';
665
- if (out && typeof out === 'object') delete out.audit;
666
- if (JSON.stringify(out ?? null).length > policy.RESULT_MAX) {
667
- out = { text: String(data?.text || '').slice(0, 200000) + '\n…(结果太大,已截断)' };
668
- }
669
- send({ type: 'res', id: m.id, ok: true, data: out });
670
- await log({ job: jobId, cmd: m.cmd, ok: true, ms: Date.now() - started, ...(audit ? { msg: String(audit).slice(0, 160) } : {}) });
671
- } catch (e) {
672
- send({ type: 'res', id: m.id, ok: false, error: { code: e.code || 'INTERNAL', message: e.message || String(e) } });
673
- await log({ job: jobId, cmd: m.cmd, ok: false, code: e.code || 'INTERNAL', msg: String(e.message || e).slice(0, 120) });
674
- }
675
- }
676
-
677
- // 地址:正在加载的那个(pendingUrl)也要算——导航提交之前 url 还是旧的
678
- const urlsOf = (tab) => [tab.url || '', tab.pendingUrl || ''].filter(Boolean);
679
- const tabAllowed = (tab, hosts) => urlsOf(tab).every((u) => policy.urlAllowed(u, hosts));
680
-
681
- // 跳出白名单的页先退回;退不回就清空成空白页。内容一律不出去。
682
- async function bounce(tabId, hosts) {
683
- await chrome.tabs.goBack(tabId).catch(() => {});
684
- await sleep(1500);
685
- const tab = await chrome.tabs.get(tabId).catch(() => null);
686
- if (tab && !tabAllowed(tab, hosts)) await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {});
687
- }
688
-
689
- async function exec(m) {
690
- const cfg = await getCfg();
691
- if (!cfg?.deviceId) throw err('CLOUD_UNPAIRED', '这个浏览器没有配对');
692
- if (cfg.paused) throw err('CLOUD_PAUSED', '用户暂停了云端控制。告诉用户在扩展侧栏里恢复后再继续。');
693
- const now = Date.now();
694
- while (recent.length && now - recent[0] > 60000) recent.shift();
695
- if (recent.length >= PER_MINUTE) throw err('CLOUD_RATE', '命令太密(每分钟最多 120 条),放慢一点');
696
- recent.push(now);
697
-
698
- const reason = m.cmd === 'sites' || m.cmd === 'folders' ? String(m.params?.reason || '').slice(0, 200) : '';
699
- const task = await taskFor(m.job, cfg, reason, { allowEmpty: m.cmd === 'folders' });
700
- const jobId = task.jobId;
701
- const updated = await updateTask(jobId, (t) => { t.count += 1; t.lastAt = Date.now(); });
702
- if (!updated) throw err('CLOUD_DENIED', '这个任务已经结束');
703
- if (updated.count > policy.TASK_CMD_CAP) throw err('CLOUD_DENIED', `一个任务最多 ${policy.TASK_CMD_CAP} 条命令,已经用完`);
704
-
705
- const hosts = hostsOf(updated, cfg);
706
- const p = policy.checkCommand(m.cmd, m.params, { hosts, origin: cfg.origin });
707
- if (m.cmd === 'sites') return requestSites(updated, p, cfg);
708
- if (m.cmd === 'folders') return requestFolders(updated, p, cfg);
709
- if (m.cmd === 'fs_list' || m.cmd === 'fs_read') return fsCmd(m.cmd, p, updated);
710
- const sid = sidOf(jobId);
711
- const label = labelOf(cfg.origin);
712
- const own = new Set(updated.tabs);
713
-
714
- if (m.cmd === 'tabs') return tabsCmd(p, m, updated, { sid, label, hosts, own });
715
-
716
- const tid = Number(m.tabId ?? m.params?.tabId ?? updated.cur);
717
- if (!tid || !own.has(tid)) {
718
- throw err('NO_TAB', updated.tabs.length
719
- ? `只能操作本任务自己开的标签页:${updated.tabs.join('、')}`
720
- : '本任务还没有标签页——先 tabs(action:"new", url:…) 开一个');
721
- }
722
- const tab = await chrome.tabs.get(tid).catch(() => null);
723
- if (!tab) throw err('NO_TAB', `标签页 ${tid} 已经关了`);
724
- if (!tabAllowed(tab, hosts)) {
725
- await bounce(tid, hosts);
726
- return policy.redactOffList(tab.pendingUrl || tab.url);
727
- }
728
-
729
- // 对外动作的确认:全局开关或本任务里点过「都同意」,就不弹浮条。
730
- // 不带 kind 的 ask(扫码、验证码)不满足条件,继续往下弹窗。
731
- if (m.cmd === 'ask' && p.kind === 'confirm' && (cfg.autoConfirm || updated.confirmAll)) {
732
- await log({ ev: 'ask_auto', job: jobId, msg: String(p.prompt || '').slice(0, 160) });
733
- return { outcome: 'continued', auto: true, text: '✅ 用户已设置对外动作免确认,直接继续。' };
734
- }
735
-
736
- // 浮条上写明是谁在问:云端发来的文字可能是钓鱼,用户得知道这不是本机 agent 说的
737
- if (m.cmd === 'ask') p.title = `☁ ${policy.hostOf(cfg.origin)}:${String(p.title || '需要你搭把手').slice(0, 60)}`;
738
- const data = m.cmd === 'upload'
739
- ? await uploadMedia(p, tid, sid, label)
740
- : await deps.run(m.cmd, p, tid, sid, label);
741
-
742
- // 「本任务内都同意」只记在这个任务上。任务结束时整条记录删掉,不会带到下一个任务。
743
- // always 是扩展内部的记号,不回给服务器。
744
- if (m.cmd === 'ask' && data?.always) {
745
- await updateTask(jobId, (t) => { t.confirmAll = true; });
746
- await log({ ev: 'confirm_task', job: jobId, msg: String(p.prompt || '').slice(0, 160) });
747
- delete data.always;
748
- }
749
-
750
- // 结果出门前再查一遍本任务所有标签页:有一页跳出了白名单,整份结果都不能出去
751
- const after = (await getTasks())[jobId]?.tabs || [];
752
- for (const id of after) {
753
- const t = await chrome.tabs.get(id).catch(() => null);
754
- if (t && !tabAllowed(t, hosts)) {
755
- await bounce(id, hosts);
756
- return policy.redactOffList(t.pendingUrl || t.url);
757
- }
758
- }
759
- // 点击开出了新标签页,后台已经跟过去了:下一条缺省命令落在新页上
760
- const next = Number(data?.tabId) && after.includes(Number(data.tabId)) ? Number(data.tabId) : tid;
761
- await updateTask(jobId, (t) => { t.cur = next; });
762
- return data;
763
- }
764
-
765
- async function tabsCmd(p, m, task, { sid, label, hosts, own }) {
766
- if (p.action === 'list') {
767
- const lines = [];
768
- for (const id of task.tabs) {
769
- const t = await chrome.tabs.get(id).catch(() => null);
770
- if (!t) continue;
771
- const ok = tabAllowed(t, hosts);
772
- lines.push(`[${id}]${id === task.cur ? ' *' : ' '} ${ok ? `${t.title || '(无标题)'} — ${t.url}` : '(已跳出允许范围,内容屏蔽)'}`);
773
- }
774
- return { text: lines.length ? `本任务的标签页(* = 缺省):\n${lines.join('\n')}` : '本任务还没有标签页' };
775
- }
776
- if (p.action === 'new') {
777
- // 不动全局的「最近受控标签页」:本机 agent 不该继承到云端任务开的页
778
- const { activeTabId } = await chrome.storage.local.get('activeTabId');
779
- const r = await deps.run('tabs', p, undefined, sid, label);
780
- if (activeTabId) await chrome.storage.local.set({ activeTabId });
781
- else await chrome.storage.local.remove('activeTabId');
782
- const id = Number(r?.tabId);
783
- if (id) await updateTask(task.jobId, (t) => { if (!t.tabs.includes(id)) t.tabs.push(id); t.cur = id; });
784
- return r;
785
- }
786
- const id = Number(m.params?.tabId ?? m.tabId);
787
- if (!own.has(id)) throw err('NO_TAB', `只能操作本任务自己开的标签页:${task.tabs.join('、') || '(还没有)'}`);
788
- if (p.action === 'select') {
789
- await updateTask(task.jobId, (t) => { t.cur = id; });
790
- return { text: `受控标签页切到 [${id}]` };
791
- }
792
- await chrome.tabs.remove(id).catch(() => {});
793
- await updateTask(task.jobId, (t) => { t.tabs = t.tabs.filter((x) => x !== id); if (t.cur === id) t.cur = t.tabs.at(-1) || null; });
794
- return { text: `已关闭标签页 ${id}` };
795
- }
796
-
797
- // 上传:素材从配对服务器取,本机磁盘一个字节都不碰。
798
- // ① 页面里的内容脚本自己去取(服务器的素材接口带 CORS),在页面里造 File 投进去——
799
- // 不经过消息通道,几百 MB 的视频也不用转 base64
800
- // ② 取不到(极少数页面拦得住)就由 SW 取、转 base64 交给页面,限 48MB
801
- async function uploadMedia(p, tid, sid, label) {
802
- const { media } = p;
803
- const base = { name: media.name, type: media.type, bytes: media.size, selector: p.selector, dropSelector: p.dropSelector };
804
- let why = '';
805
- try {
806
- return await deps.run('upload', { ...base, url: media.url }, tid, sid, label);
807
- } catch (e) {
808
- if (e.code !== 'FETCH_FAILED') throw e;
809
- why = e.message;
810
- }
811
- if (media.size > DROP_MAX) {
812
- throw err('INTERNAL', `页面里取不到素材(${why}),而素材 ${(media.size / 1048576).toFixed(0)}MB 超过 48MB,没法改由扩展转交。`);
813
- }
814
- const res = await fetch(media.url, { credentials: 'omit', cache: 'no-store' });
815
- if (!res.ok) throw err('INTERNAL', `取素材失败:${res.status}`);
816
- const bytes = new Uint8Array(await res.arrayBuffer());
817
- if (bytes.length !== media.size) throw err('INTERNAL', `素材大小不对:应为 ${media.size},实际 ${bytes.length}`);
818
- let s = '';
819
- for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
820
- return deps.run('upload', { ...base, base64: btoa(s) }, tid, sid, label);
821
- }
822
-
823
- // ---------- 侧栏 ----------
824
-
825
- // 只认扩展自己的页面(侧栏)发来的。内容脚本跑在网页里,页面被攻破时它也可能被利用,
826
- // 配对、批准、解除这些动作不能从那里发起。
827
- const fromPanel = (sender) => !sender.tab && typeof sender.url === 'string' && sender.url.startsWith(chrome.runtime.getURL(''));
828
-
829
- async function panelState() {
830
- const [cfg, { [STATUS]: status }, { [CLAIM]: claimed }, tasks, { [LOG]: list = [] }] = await Promise.all([
831
- getCfg(), chrome.storage.session.get(STATUS), chrome.storage.session.get(CLAIM), getTasks(), chrome.storage.local.get(LOG),
832
- ]);
833
- return {
834
- cfg: cfg && { origin: cfg.origin, account: cfg.account, sites: allowOf(cfg), auto: cfg.auto, autoConfirm: !!cfg.autoConfirm, paused: cfg.paused, pairedAt: cfg.pairedAt },
835
- status: status || { state: 'off' },
836
- claim: claimed && claimed.expiresAt > Date.now() ? { origin: claimed.origin, account: claimed.account } : null,
837
- tasks: Object.values(tasks).sort((a, b) => b.lastAt - a.lastAt),
838
- folders: await folders.listFolders().catch(() => []),
839
- log: list.slice(-40).reverse(),
840
- defaultOrigin: policy.DEFAULT_ORIGIN,
841
- };
842
- }
843
-
844
- async function patchCfg(patch) {
845
- const cfg = await getCfg();
846
- if (!cfg) throw err('CLOUD_UNPAIRED', '还没有配对');
847
- const { platforms: _old, ...rest } = cfg; // 老版本存的平台勾选,不再有用
848
- const next = { ...rest, ...patch };
849
- await setCfg(next);
850
- const mounted = await folders.names().catch(() => []);
851
- send({ type: 'config', sites: allowOf(next), auto: !!next.auto, autoConfirm: !!next.autoConfirm, folders: mounted });
852
- return next;
853
- }
854
-
855
- async function onPanel(m) {
856
- switch (m.op) {
857
- case 'state': return panelState();
858
- case 'claim': await claim(m.origin, m.code); return panelState();
859
- case 'cancelClaim': await chrome.storage.session.remove(CLAIM); return panelState();
860
- case 'confirm': await confirmClaim(); return panelState();
861
- case 'unpair': await unpair(); return panelState();
862
- case 'removeSite': {
863
- const host = String(m.host || '');
864
- const cfg = await getCfg();
865
- if (!cfg) throw err('CLOUD_UNPAIRED', '还没有配对');
866
- await patchCfg({ sites: allowOf(cfg).filter((h) => h !== host) });
867
- // 收回的网站立刻从进行中的任务里摘掉
868
- await serial(async () => {
869
- const tasks = await getTasks();
870
- for (const t of Object.values(tasks)) t.sites = (t.sites || []).filter((h) => h !== host);
871
- await setTasks(tasks);
872
- });
873
- await log({ ev: 'site_off', msg: host });
874
- return panelState();
875
- }
876
- case 'setAutoConfirm': {
877
- await patchCfg({ autoConfirm: !!m.autoConfirm });
878
- await log({ ev: m.autoConfirm ? 'confirm_on' : 'confirm_off' });
879
- return panelState();
880
- }
881
- case 'setAuto': {
882
- await patchCfg({ auto: !!m.auto });
883
- await log({ ev: m.auto ? 'auto_on' : 'auto_off' });
884
- // 已经挂着「允许」的任务跟着放行。追加网站和文件夹不在 pending 上,这里碰不到。
885
- if (m.auto) {
886
- const pending = Object.values(await getTasks()).filter((t) => t.state === 'pending');
887
- for (const t of pending) await decide(t.jobId, true, { auto: true });
888
- }
889
- return panelState();
890
- }
891
- case 'setPaused': {
892
- await patchCfg({ paused: !!m.paused });
893
- await log({ ev: m.paused ? 'paused' : 'resumed' });
894
- if (m.paused) { disconnect(); await setStatus('paused', '已暂停:云端命令一律不接'); } else void connect();
895
- return panelState();
896
- }
897
- case 'decide': await decide(String(m.jobId), !!m.allow); return panelState();
898
- case 'decideSites': decideSites(String(m.jobId), !!m.allow); return panelState();
899
- case 'decideFolders': decideFolders(String(m.jobId), !!m.allow); return panelState();
900
- case 'openMount': await openMount(m.folderId ? String(m.folderId) : ''); return panelState();
901
- case 'removeFolder': await folders.removeFolder(String(m.id || '')); await reportFolders(); return panelState();
902
- case 'foldersChanged': await reportFolders(); return panelState();
903
- case 'endTask': await endTask(String(m.jobId), 'user'); return panelState();
904
- case 'reconnect': disconnect(); retryDelay = 0; await connect(); return panelState();
905
- default: throw err('INTERNAL', `未知操作 ${m.op}`);
906
- }
907
- }
908
-
909
- // ---------- 装配 ----------
910
-
911
- export function initCloud(d) {
912
- deps = d;
913
-
914
- chrome.runtime.onMessage.addListener((m, sender, sendResponse) => {
915
- if (!m?.__hcCloud) return false;
916
- if (!fromPanel(sender)) { sendResponse({ ok: false, error: '只接受扩展侧栏发来的操作' }); return false; }
917
- onPanel({ ...m, op: m.__hcCloud }).then(
918
- (state) => sendResponse({ ok: true, state }),
919
- (e) => sendResponse({ ok: false, error: e.message || String(e) }),
920
- );
921
- return true;
922
- });
923
-
924
- chrome.notifications?.onButtonClicked.addListener((id, idx) => {
925
- if (id.startsWith('hc-cloud-folders:')) decideFolders(id.slice('hc-cloud-folders:'.length), idx === 0);
926
- else if (id.startsWith('hc-cloud-sites:')) decideSites(id.slice('hc-cloud-sites:'.length), idx === 0);
927
- else if (id.startsWith('hc-cloud:')) void decide(id.slice('hc-cloud:'.length), idx === 0);
928
- else if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
929
- });
930
- chrome.notifications?.onClicked.addListener((id) => {
931
- if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
932
- });
933
- chrome.windows?.onRemoved.addListener((id) => { if (id === mountWin) mountWin = null; });
934
-
935
- // 从本任务的页面里点开的新标签页(「去发布」常在新页打开)归同一个任务,
936
- // 白名单照样管它:下一条命令前、结果出门前都会核对地址
937
- chrome.tabs.onCreated.addListener((tab) => {
938
- if (!tab.openerTabId) return;
939
- void serial(async () => {
940
- const tasks = await getTasks();
941
- const t = Object.values(tasks).find((x) => x.state === 'approved' && x.tabs.includes(tab.openerTabId));
942
- if (!t || t.tabs.includes(tab.id)) return;
943
- t.tabs.push(tab.id);
944
- await setTasks(tasks);
945
- });
946
- });
947
- chrome.tabs.onRemoved.addListener((tabId) => {
948
- void serial(async () => {
949
- const tasks = await getTasks();
950
- let hit = false;
951
- for (const t of Object.values(tasks)) {
952
- if (!t.tabs.includes(tabId)) continue;
953
- t.tabs = t.tabs.filter((x) => x !== tabId);
954
- if (t.cur === tabId) t.cur = t.tabs.at(-1) || null;
955
- hit = true;
956
- }
957
- if (hit) await setTasks(tasks);
958
- });
959
- });
960
-
961
- // 闲置太久的批准作废;结束了的任务、老格式的任务不留在侧栏里
962
- void serial(async () => {
963
- const tasks = await getTasks();
964
- let dirty = false;
965
- for (const [id, t] of Object.entries(tasks)) {
966
- if (Date.now() - t.lastAt > policy.TASK_IDLE_MS || t.v !== TASK_V) { delete tasks[id]; dirty = true; }
967
- }
968
- if (dirty) await setTasks(tasks);
969
- }).then(pushLive);
970
-
971
- void ensureCloud();
972
- }
1
+ // 云端通道 —— 让一个配对过的服务(默认 maker.vcorp.ai)在这个浏览器里替用户办事(发布、查资料、填表)。
2
+ //
3
+ // 和本机那条桥是两回事:桥只有本机进程能连,agent 拿到的是全套工具;
4
+ // 云端通道是**这个浏览器主动连出去**的一条 WebSocket,对面是公网上的服务器。
5
+ // 所以这里的每一条命令都按「对面可能已经被攻破」来处理,规矩全在 cloud-policy.js:
6
+ // 只有页面操作那一小组命令、只能停在允许名单(以及本任务里批准的网站)上、只能碰自己开的标签页、
7
+ // 上传只能用服务器上的素材。新任务要用户点头;开了自动允许则新任务和它一开始的网站直接放行。
8
+ // 任务开始之后追加的网站、本地文件夹,仍要逐个点头。带 kind=confirm 的 ask 是对外动作的确认:
9
+ // 用户开了免确认,或在这个任务里点过「本任务内都同意」,就直接放行。扫码和验证码不带 kind,照旧等人。
10
+ // 没有预设的平台表:要去哪个网站,任务自己申请。
11
+ //
12
+ // 身份:配对时在这里生成一对 ECDSA P-256 密钥,私钥以不可导出的 CryptoKey 存在
13
+ // IndexedDB,服务器只拿到公钥。每次连接对服务器给的随机数签名,签名里带上服务器
14
+ // 地址——换一台服务器重放这个签名没有用。服务器那边没有可被偷走后冒充本机的口令。
15
+ //
16
+ // socket 放在 SW 里,不放 offscreen:Chrome 116+ 上 WebSocket 收发消息会续 SW 的命,
17
+ // 20 秒一次心跳足够;SW 真被回收了,30 秒的 alarm 会把它连回来(见 background.js)。
18
+
19
+ import * as policy from './cloud-policy.js';
20
+ import * as folders from './folders.js';
21
+
22
+ const CFG = 'cloud'; // storage.local:配对信息和用户的选择
23
+ const TASKS = 'cloudTasks'; // storage.local:任务(批准状态、自己开的标签页)
24
+ const LOG = 'cloudLog'; // storage.local:审计日志,侧栏可看
25
+ const STATUS = 'cloudStatus'; // storage.session:连接状态,侧栏订阅
26
+ const CLAIM = 'cloudClaim'; // storage.session:配对第一步拿到的账号,等用户确认
27
+ const LOG_CAP = 200;
28
+ const PING_MS = 20000;
29
+ const DEAD_MS = 65000;
30
+ const APPROVE_MS = 5 * 60 * 1000;
31
+ const PER_MINUTE = 120;
32
+ const DROP_MAX = 48 * 1024 * 1024; // SW 读字节再交给页面的那条路的上限(同本机 upload)
33
+
34
+ let deps = null; // background 注入:run(cmd, params, tabId, sid, label)、setCloudLive(sids)
35
+ let ws = null;
36
+ let pingTimer = null;
37
+ let retryTimer = null;
38
+ let retryDelay = 0;
39
+ let lastRx = 0;
40
+ let connecting = false;
41
+ const waiters = new Map(); // jobId → Set<fn(allow)>,等用户批准的命令
42
+ const recent = []; // 最近一分钟的命令时间戳
43
+ let lock = Promise.resolve();
44
+ let mountWin = null;
45
+
46
+ const err = (code, message) => Object.assign(new Error(message), { code });
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+
49
+ // 任务表的读改写串行化:同一任务的几条命令可能并发到达,读改写交错会丢标签页
50
+ function serial(fn) {
51
+ const run = lock.then(fn, fn);
52
+ lock = run.catch(() => {});
53
+ return run;
54
+ }
55
+
56
+ // ---------- 密钥 ----------
57
+
58
+ function idb() {
59
+ return new Promise((resolve, reject) => {
60
+ const r = indexedDB.open('hc-cloud', 1);
61
+ r.onupgradeneeded = () => r.result.createObjectStore('keys');
62
+ r.onsuccess = () => resolve(r.result);
63
+ r.onerror = () => reject(r.error);
64
+ });
65
+ }
66
+ async function idbOp(mode, fn) {
67
+ const db = await idb();
68
+ try {
69
+ return await new Promise((resolve, reject) => {
70
+ const tx = db.transaction('keys', mode);
71
+ const req = fn(tx.objectStore('keys'));
72
+ tx.oncomplete = () => resolve(req?.result);
73
+ tx.onerror = () => reject(tx.error);
74
+ });
75
+ } finally { db.close(); }
76
+ }
77
+ const keyGet = () => idbOp('readonly', (s) => s.get('device'));
78
+ const keySet = (v) => idbOp('readwrite', (s) => s.put(v, 'device'));
79
+ const keyDel = () => idbOp('readwrite', (s) => s.delete('device'));
80
+
81
+ async function newKey() {
82
+ // 私钥 extractable:false —— 连扩展自己的代码也导不出原始私钥
83
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
84
+ const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
85
+ return { privateKey: pair.privateKey, publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } };
86
+ }
87
+
88
+ const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
89
+
90
+ async function sign(text) {
91
+ const k = await keyGet();
92
+ if (!k?.privateKey) throw err('CLOUD_UNPAIRED', '本机没有配对密钥');
93
+ return b64url(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, k.privateKey, new TextEncoder().encode(text)));
94
+ }
95
+
96
+ // 签名内容的格式,和服务器那侧(opus-video-maker/extlink.js)逐字一致
97
+ export const helloText = (origin, deviceId, nonce) => `vcorp-cloud-v1\n${origin}\n${deviceId}\n${nonce}`;
98
+
99
+ // ---------- 配置 ----------
100
+
101
+ const getCfg = async () => (await chrome.storage.local.get(CFG))[CFG] || null;
102
+ const setCfg = (v) => chrome.storage.local.set({ [CFG]: v });
103
+ const setStatus = (state, message = '') => chrome.storage.session.set({ [STATUS]: { state, message, at: Date.now() } }).catch(() => {});
104
+
105
+ async function log(entry) {
106
+ try {
107
+ const { [LOG]: list = [] } = await chrome.storage.local.get(LOG);
108
+ list.push({ t: Date.now(), ...entry });
109
+ await chrome.storage.local.set({ [LOG]: list.slice(-LOG_CAP) });
110
+ } catch { /* 日志写不进不影响干活 */ }
111
+ }
112
+
113
+ function deviceName() {
114
+ const plat = navigator.userAgentData?.platform || (/Windows/.test(navigator.userAgent) ? 'Windows' : /Mac/.test(navigator.userAgent) ? 'macOS' : 'Linux');
115
+ const ver = (navigator.userAgent.match(/Chrome\/(\d+)/) || [])[1] || '';
116
+ return `Chrome ${ver} · ${plat}`.trim();
117
+ }
118
+
119
+ // ---------- 配对 ----------
120
+ //
121
+ // 两步:先 claim 拿到「这个码属于哪个账号」,给用户看一眼,确认了才 confirm 生成密钥。
122
+ // 多这一步是为了防「把这个码填进你的扩展」式的钓鱼——填别人给的码,等于把自己的
123
+ // 浏览器里的登录交给别人的 maker 账号去用。确认框里写着账号名,看一眼就知道不对。
124
+
125
+ async function post(origin, path, body) {
126
+ const res = await fetch(origin + path, {
127
+ method: 'POST', credentials: 'omit', cache: 'no-store',
128
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
129
+ });
130
+ let data = null;
131
+ try { data = await res.json(); } catch { /* 下面按状态码报 */ }
132
+ if (!res.ok) throw err('CLOUD_PAIR', data?.error || `服务器返回 ${res.status}`);
133
+ return data;
134
+ }
135
+
136
+ async function claim(rawOrigin, rawCode) {
137
+ const origin = policy.normalizeOrigin(rawOrigin || policy.DEFAULT_ORIGIN);
138
+ if (!origin) throw err('CLOUD_PAIR', '服务器地址必须是 https://(本机调试可用 http://localhost)');
139
+ const code = String(rawCode || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
140
+ if (code.length !== 8) throw err('CLOUD_PAIR', '配对码是 8 位字母和数字');
141
+ const r = await post(origin, '/api/ext/pair/claim', { code });
142
+ const pending = { origin, code, account: String(r.account || ''), expiresAt: r.expiresAt || Date.now() + 600000 };
143
+ await chrome.storage.session.set({ [CLAIM]: pending });
144
+ return pending;
145
+ }
146
+
147
+ async function confirmClaim() {
148
+ const { [CLAIM]: c } = await chrome.storage.session.get(CLAIM);
149
+ if (!c) throw err('CLOUD_PAIR', '没有待确认的配对,重新输入配对码');
150
+ const key = await newKey();
151
+ const r = await post(c.origin, '/api/ext/pair/confirm', { code: c.code, publicKey: key.publicJwk, name: deviceName() });
152
+ if (!r?.deviceId) throw err('CLOUD_PAIR', '服务器没有返回设备号');
153
+ await keySet({ privateKey: key.privateKey, deviceId: r.deviceId, origin: c.origin });
154
+ const old = await getCfg();
155
+ await setCfg({
156
+ origin: c.origin, deviceId: r.deviceId, account: String(r.account || c.account), pairedAt: Date.now(),
157
+ // 换绑时沿用之前允许的网站;第一次配对名单是空的,等任务申请、用户点允许
158
+ sites: policy.cleanAllowList(old?.sites), auto: false, autoConfirm: false, paused: false,
159
+ });
160
+ await chrome.storage.session.remove(CLAIM);
161
+ await log({ ev: 'paired', account: r.account || c.account, origin: c.origin });
162
+ clearTimeout(retryTimer); retryTimer = null;
163
+ retryDelay = 0;
164
+ void connect();
165
+ return getCfg();
166
+ }
167
+
168
+ // 解除配对:先告诉服务器作废这个设备,再把本机的密钥、任务、云端开的标签页全部清掉。
169
+ // 服务器那一步失败也照样清本机——用户点了「断开」,本机就不能再听那边的话。
170
+ async function unpair() {
171
+ const cfg = await getCfg();
172
+ if (ws?.readyState === 1) { try { ws.send(JSON.stringify({ type: 'unpair' })); } catch { /* 下面照样清 */ } }
173
+ await sleep(200);
174
+ disconnect();
175
+ const { [TASKS]: tasks = {} } = await chrome.storage.local.get(TASKS);
176
+ for (const t of Object.values(tasks)) await closeTaskTabs(t, { force: true });
177
+ await keyDel().catch(() => {});
178
+ await chrome.storage.local.remove([CFG, TASKS]);
179
+ deps?.setCloudLive([]);
180
+ await setStatus('off');
181
+ await log({ ev: 'unpaired', origin: cfg?.origin || '' });
182
+ }
183
+
184
+ // ---------- 连接 ----------
185
+
186
+ function disconnect() {
187
+ clearInterval(pingTimer); pingTimer = null;
188
+ clearTimeout(retryTimer); retryTimer = null;
189
+ if (ws) { try { ws.onclose = null; ws.close(); } catch { /* 已经废了 */ } }
190
+ ws = null;
191
+ }
192
+
193
+ function scheduleReconnect(minDelay = 0) {
194
+ if (retryTimer) return;
195
+ const grown = retryDelay ? Math.min(retryDelay * 2, 60000) : 1000;
196
+ retryDelay = Math.max(grown, minDelay);
197
+ retryTimer = setTimeout(() => { retryTimer = null; void connect(); }, retryDelay);
198
+ }
199
+
200
+ function socketUrl(origin) {
201
+ const u = new URL(origin);
202
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
203
+ u.pathname = '/api/ext/ws';
204
+ u.search = '';
205
+ u.hash = '';
206
+ return u.href;
207
+ }
208
+
209
+ export async function connect() {
210
+ // readyState 2 是 CLOSING。只挡 0 和 1 的话,旧连接还在关、新连接已经握上,
211
+ // 代理会把叠在一起的升级请求回 503。
212
+ if (connecting || retryTimer || (ws && ws.readyState !== 3)) return;
213
+ const cfg = await getCfg();
214
+ if (!cfg?.deviceId) return setStatus('off');
215
+ if (cfg.paused) return setStatus('paused', '已暂停:云端命令一律不接');
216
+ connecting = true;
217
+ let sock;
218
+ let opened = false;
219
+ try {
220
+ await setStatus('connecting');
221
+ // 地址不合法时构造函数会抛,接住后重连。握手被拒(代理 503 等)不会抛:
222
+ // Chrome 把 HTTP 状态写进自己的网络日志,脚本取消不了那一行。
223
+ // 这种失败走下面的 onclose,并且把重试间隔拉到至少 15 秒,避免每秒一条。
224
+ try { sock = new WebSocket(socketUrl(cfg.origin)); }
225
+ catch { scheduleReconnect(); return; }
226
+ ws = sock;
227
+ lastRx = Date.now();
228
+ sock.onopen = () => { opened = true; };
229
+ sock.onerror = (ev) => { ev?.preventDefault?.(); };
230
+ sock.onmessage = (ev) => {
231
+ void (async () => {
232
+ try {
233
+ lastRx = Date.now();
234
+ let m;
235
+ try { m = JSON.parse(ev.data); } catch { return; }
236
+ if (!m || typeof m !== 'object') return;
237
+ if (m.type === 'ping') { if (sock.readyState === 1) sock.send(JSON.stringify({ type: 'pong' })); return; }
238
+ if (m.type === 'pong') return;
239
+ if (m.type === 'challenge') {
240
+ try {
241
+ const sig = await sign(helloText(cfg.origin, cfg.deviceId, String(m.nonce || '')));
242
+ const mounted = await folders.names().catch(() => []);
243
+ sock.send(JSON.stringify({
244
+ type: 'hello', v: 1, deviceId: cfg.deviceId, sig,
245
+ version: chrome.runtime.getManifest().version, sites: policy.cleanAllowList(cfg.sites), auto: !!cfg.auto, autoConfirm: !!cfg.autoConfirm,
246
+ folders: mounted,
247
+ }));
248
+ } catch { try { sock.close(); } catch { /* 已经废了 */ } }
249
+ return;
250
+ }
251
+ if (m.type === 'welcome') {
252
+ retryDelay = 0;
253
+ await setStatus('online', String(m.account || cfg.account || ''));
254
+ return;
255
+ }
256
+ // 服务器说这个设备已经被作废(用户在网页上删了它):本机跟着解除,不再重连
257
+ if (m.type === 'revoked') { await unpair(); await setStatus('off', '这个浏览器已在网页上被移除'); return; }
258
+ if (m.type === 'cmd') { await handle(m); return; }
259
+ // 服务器的「验证」:一来一回量延迟,什么都不开。老版本这里还会探平台登录,已经去掉。
260
+ if (m.type === 'probe') { send({ type: 'res', id: m.id, ok: true, data: {} }); return; }
261
+ if (m.type === 'mount_request') { await openMount(); return; }
262
+ if (m.type === 'job_end') { await endTask(String(m.jobId || ''), 'server'); }
263
+ } catch { /* 一条消息处理失败不能变成未捕获异常 */ }
264
+ })();
265
+ };
266
+ sock.onclose = (ev) => {
267
+ if (ws !== sock) return;
268
+ ws = null;
269
+ clearInterval(pingTimer); pingTimer = null;
270
+ if (ev.code === 4401) { void setStatus('error', '服务器不认这个设备的签名,请重新配对'); return; }
271
+ void setStatus('connecting');
272
+ scheduleReconnect(opened ? 0 : 15000);
273
+ };
274
+ clearInterval(pingTimer);
275
+ pingTimer = setInterval(() => {
276
+ try {
277
+ if (sock.readyState !== 1) return;
278
+ if (Date.now() - lastRx > DEAD_MS) { sock.close(); return; }
279
+ sock.send(JSON.stringify({ type: 'ping' }));
280
+ } catch { /* 发送时已经断开 */ }
281
+ }, PING_MS);
282
+ } catch {
283
+ await setStatus('connecting');
284
+ scheduleReconnect();
285
+ } finally {
286
+ connecting = false;
287
+ }
288
+ }
289
+
290
+ // alarm 每 30 秒叫一次:配了对、没暂停、却没连着,就连
291
+ export async function ensureCloud() {
292
+ if (retryTimer || (ws && ws.readyState !== 3)) return;
293
+ const cfg = await getCfg();
294
+ if (cfg?.deviceId && !cfg.paused) await connect();
295
+ }
296
+
297
+ const send = (m) => { try { if (ws?.readyState === 1) ws.send(JSON.stringify(m)); } catch { /* 发送时已经断开 */ } };
298
+
299
+ // ---------- 任务 ----------
300
+
301
+ const getTasks = async () => (await chrome.storage.local.get(TASKS))[TASKS] || {};
302
+ const setTasks = (t) => chrome.storage.local.set({ [TASKS]: t });
303
+ const sidOf = (jobId) => `cloud-${jobId}`;
304
+ // 带大写字母,identity.js 才会原样当显示名用(不然会被当 slug 机械美化)。
305
+ // sid 里没有冒号,页面标记上的短码就是任务号末 6 位,用户能和网页上的任务对上
306
+ const labelOf = (origin) => `Cloud · ${policy.hostOf(origin)}`;
307
+
308
+ async function pushLive() {
309
+ const tasks = await getTasks();
310
+ deps?.setCloudLive(Object.values(tasks).filter((t) => t.state === 'approved').map((t) => sidOf(t.jobId)));
311
+ }
312
+
313
+ const allowOf = (cfg) => policy.cleanAllowList(cfg?.sites);
314
+ const hostsOf = (t, cfg) => policy.taskHosts(t, allowOf(cfg));
315
+ // 任务表的格式版本。老版本按平台建的任务没有它,启动时清掉,下一条命令重新问。
316
+ const TASK_V = 2;
317
+
318
+ // 新任务由用户点头,或由用户打开的自动允许直接放行(含信封上一开始的网站)。
319
+ // 任务开始之后再申请的网站、本地文件夹,自动允许管不到。服务器只能申请。
320
+ // reason:第一次申请站点时 agent 写的用途,放进批准框里给用户看。
321
+ async function taskFor(job, cfg, reason = '', { allowEmpty = false } = {}) {
322
+ const jobId = String(job?.id || '');
323
+ if (!/^[\w-]{1,64}$/.test(jobId)) throw err('CLOUD_DENIED', '命令缺少有效的任务号');
324
+ const title = String(job?.title || '').slice(0, 80);
325
+ let freshAuto = false;
326
+
327
+ const t = await serial(async () => {
328
+ const tasks = await getTasks();
329
+ let task = tasks[jobId];
330
+ if (task?.state === 'approved' && Date.now() - task.lastAt > policy.TASK_IDLE_MS) task = null; // 闲置太久,重新问
331
+ if (task && task.v === TASK_V) return task;
332
+ const { sites } = policy.cleanSites(job?.sites);
333
+ // 只读本地文件夹的任务可以还没有网站。文件夹本身仍要单独点头,这里不替用户答应。
334
+ if (!sites.length && !allowEmpty) return { state: 'nosites' };
335
+ const opening = policy.initialTask({ sites, covered: allowOf(cfg), auto: !!cfg.auto });
336
+ freshAuto = !!cfg.auto && opening.state === 'approved' && sites.length > 0;
337
+ task = {
338
+ v: TASK_V, jobId, title, sites: opening.sites, use: opening.use, ask: opening.ask, refused: [], purpose: reason,
339
+ folders: [], folderRefused: [], folderAsk: [], folderBytes: 0,
340
+ tabs: [], cur: null, count: 0, state: opening.state, createdAt: Date.now(), lastAt: Date.now(),
341
+ };
342
+ tasks[jobId] = task;
343
+ await setTasks(tasks);
344
+ return task;
345
+ });
346
+
347
+ if (freshAuto) {
348
+ await log({ ev: 'approved', job: jobId, msg: '自动放行' });
349
+ await pushLive();
350
+ }
351
+ if (t.state === 'nosites') throw err('NO_SITES', '先用 sites 申请要访问的网站(如 sites {"add":["zhipin.com"],"reason":"找兼职"}),或用 folders 申请本地文件夹。用户批准后才能继续。');
352
+ if (t.state === 'denied') throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
353
+ if (t.state === 'approved') return t;
354
+
355
+ // 这条任务还挂着。开关若已经打开(包括刚打开、存储里已经是开的),按允许处理,不再弹窗。
356
+ const live = await getCfg();
357
+ if (cfg.auto || live?.auto) {
358
+ await decide(jobId, true, { auto: true });
359
+ const granted = (await getTasks())[jobId];
360
+ if (granted?.state === 'approved') return granted;
361
+ }
362
+ const now = (await getTasks())[jobId];
363
+ if (!now) throw err('CLOUD_DENIED', '这个任务已经结束');
364
+ if (now.state === 'denied') throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
365
+ if (now.state === 'approved') return now;
366
+
367
+ // 等用户点头。通知是把人叫回来的唯一办法;侧栏里同样有一张卡片
368
+ notifyPending(now, live || cfg);
369
+ const allow = await waitFor(jobId);
370
+ if (allow === null) throw err('CLOUD_TIMEOUT', '用户 5 分钟内没有批准这个任务。告诉用户打开浏览器扩展侧栏批准后再继续。');
371
+ if (!allow) throw err('CLOUD_DENIED', '用户拒绝了这个任务。不要重试。');
372
+ return (await getTasks())[jobId];
373
+ }
374
+
375
+ // 用户的决定:true 允许、false 拒绝、null 5 分钟没理
376
+ function waitFor(key) {
377
+ return new Promise((resolve) => {
378
+ const set = waiters.get(key) || new Set();
379
+ const fn = (v) => { clearTimeout(timer); set.delete(fn); resolve(v); };
380
+ const timer = setTimeout(() => fn(null), APPROVE_MS);
381
+ set.add(fn);
382
+ waiters.set(key, set);
383
+ });
384
+ }
385
+
386
+ function notifyPending(t, cfg) {
387
+ const names = [...(t.ask || []), ...(t.use || [])].join('、');
388
+ chrome.notifications?.create(`hc-cloud:${t.jobId}`, {
389
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
390
+ title: `${policy.hostOf(cfg.origin)} 请求在你的浏览器里操作这些网站`,
391
+ message: `${t.title ? `「${t.title}」` : '一个任务'} → ${names}${t.purpose ? `\n用途:${t.purpose}` : ''}`,
392
+ buttons: [{ title: '允许' }, { title: '拒绝' }],
393
+ }, () => void chrome.runtime.lastError);
394
+ }
395
+
396
+ // 进行中的任务又要新站点:单独问一次,只问这几个。
397
+ function notifySites(t, cfg) {
398
+ chrome.notifications?.create(`hc-cloud-sites:${t.jobId}`, {
399
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
400
+ title: `${policy.hostOf(cfg.origin)} 的任务还想访问新的网站`,
401
+ message: `${t.title ? `「${t.title}」` : '任务'} → ${(t.ask || []).join('、')}${t.askWhy ? `\n用途:${t.askWhy}` : ''}`,
402
+ buttons: [{ title: '允许' }, { title: '拒绝' }],
403
+ }, () => void chrome.runtime.lastError);
404
+ }
405
+
406
+ // 任务申请网站。允许名单已经盖住的不再问;用户拒绝过的同一任务不再问。
407
+ // 「自动允许」管不到这里。用户点了允许,这些网站也进允许名单。
408
+ // 本任务新批的网站超过 SITES_MAX 的部分这次不询问。
409
+ async function requestSites(task, p, cfg) {
410
+ const split = policy.sitesToAsk(p.add, {
411
+ covered: allowOf((await getCfg()) || cfg),
412
+ sites: task.sites || [],
413
+ refused: task.refused || [],
414
+ });
415
+ if (split.ask.length) {
416
+ const t = await updateTask(task.jobId, (x) => {
417
+ if (!Array.isArray(x.sites)) x.sites = [];
418
+ if (!Array.isArray(x.refused)) x.refused = [];
419
+ x.ask = split.ask;
420
+ x.askWhy = p.reason;
421
+ });
422
+ if (!t) throw err('CLOUD_DENIED', '这个任务已经结束');
423
+ notifySites(t, cfg);
424
+ const allow = await waitFor(`sites:${task.jobId}`);
425
+ await updateTask(task.jobId, (x) => {
426
+ if (!Array.isArray(x.sites)) x.sites = [];
427
+ if (!Array.isArray(x.refused)) x.refused = [];
428
+ if (allow) for (const h of split.ask) if (!x.sites.includes(h)) x.sites.push(h);
429
+ else if (allow === false) for (const h of split.ask) if (!x.refused.includes(h)) x.refused.push(h);
430
+ x.ask = [];
431
+ x.askWhy = '';
432
+ });
433
+ chrome.notifications?.clear(`hc-cloud-sites:${task.jobId}`, () => void chrome.runtime.lastError);
434
+ if (allow) await allowSites(split.ask);
435
+ await log({ ev: allow ? 'sites_on' : allow === false ? 'sites_off' : 'sites_timeout', job: task.jobId, msg: split.ask.join(',') });
436
+ }
437
+ const now = (await getTasks())[task.jobId] || task;
438
+ const allowList = allowOf(await getCfg());
439
+ const sites = now.sites || [];
440
+ const refused = now.refused || [];
441
+ const lines = [`允许访问:${policy.taskHosts(now, allowList).join('、') || '(无)'}`];
442
+ if (split.covered.length) lines.push(`已在允许名单里:${split.covered.join('、')}`);
443
+ const no = p.add.filter((h) => refused.includes(h) && !split.covered.includes(h));
444
+ const later = split.ask.filter((h) => !sites.includes(h) && !refused.includes(h));
445
+ if (no.length) lines.push(`用户不同意:${no.join('、')}。不要再申请,换别的办法或在报告里说明。`);
446
+ if (later.length) lines.push(`用户 5 分钟内没有回应:${later.join('、')}。可以稍后再申请一次。`);
447
+ if (split.overflow.length) lines.push(`超出本任务额外网站上限(最多 ${policy.SITES_MAX} 个),这次没有询问:${split.overflow.join('、')}`);
448
+ if (p.bad.length) lines.push(`不是可以申请的网站(要写域名,如 zhipin.com):${p.bad.join('、')}`);
449
+ return { text: lines.join('\n'), sites };
450
+ }
451
+
452
+ function notifyFolders(t, cfg) {
453
+ chrome.notifications?.create(`hc-cloud-folders:${t.jobId}`, {
454
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
455
+ title: `${policy.hostOf(cfg.origin)} 的任务想读取你的本地文件夹`,
456
+ message: `${t.title ? `「${t.title}」` : '任务'} → ${(t.folderAsk || []).join('、')}${t.folderAskWhy ? `\n用途:${t.folderAskWhy}` : ''}`,
457
+ buttons: [{ title: '允许' }, { title: '拒绝' }],
458
+ }, () => void chrome.runtime.lastError);
459
+ }
460
+
461
+ // 和额外网站一样:「自动允许」管不到文件夹。没挂载的名字不弹窗,直接告诉 agent。
462
+ async function requestFolders(task, p, cfg) {
463
+ const mounted = await folders.names().catch(() => []);
464
+ const have = new Set(mounted.map((f) => f.name));
465
+ const known = p.use.filter((name) => have.has(name));
466
+ const unknown = p.use.filter((name) => !have.has(name));
467
+ const split = policy.foldersToAsk(known, { granted: task.folders || [], refused: task.folderRefused || [] });
468
+ if (split.ask.length) {
469
+ const t = await updateTask(task.jobId, (x) => {
470
+ if (!Array.isArray(x.folders)) x.folders = [];
471
+ if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
472
+ x.folderAsk = split.ask;
473
+ x.folderAskWhy = p.reason;
474
+ });
475
+ if (!t) throw err('CLOUD_DENIED', '这个任务已经结束');
476
+ notifyFolders(t, cfg);
477
+ const allow = await waitFor(`folders:${task.jobId}`);
478
+ await updateTask(task.jobId, (x) => {
479
+ if (!Array.isArray(x.folders)) x.folders = [];
480
+ if (!Array.isArray(x.folderRefused)) x.folderRefused = [];
481
+ if (allow) for (const name of split.ask) if (!x.folders.includes(name) && x.folders.length < policy.FS_FOLDERS_MAX) x.folders.push(name);
482
+ else if (allow === false) for (const name of split.ask) if (!x.folderRefused.includes(name)) x.folderRefused.push(name);
483
+ x.folderAsk = [];
484
+ x.folderAskWhy = '';
485
+ });
486
+ chrome.notifications?.clear(`hc-cloud-folders:${task.jobId}`, () => void chrome.runtime.lastError);
487
+ await log({ ev: allow ? 'folders_on' : allow === false ? 'folders_off' : 'folders_timeout', job: task.jobId, msg: split.ask.join(',') });
488
+ }
489
+ const now = (await getTasks())[task.jobId] || task;
490
+ const granted = now.folders || [];
491
+ const refused = now.folderRefused || [];
492
+ const lines = [`已挂载:${mounted.map((f) => f.name).join('、') || '(无)'}`];
493
+ lines.push(`本任务可以读:${granted.join('、') || '(无)'}`);
494
+ const no = known.filter((name) => refused.includes(name));
495
+ const later = split.ask.filter((name) => !granted.includes(name) && !refused.includes(name));
496
+ if (no.length) lines.push(`用户不同意:${no.join('、')}。不要再申请。`);
497
+ if (later.length) lines.push(`用户 5 分钟内没有回应:${later.join('、')}。可以稍后再申请一次。`);
498
+ if (unknown.length) lines.push(`这个浏览器没有挂载:${unknown.join('、')}。请用户在网页上点「挂载本地文件夹」。`);
499
+ if (p.bad.length) lines.push(`不是合法的文件夹名:${p.bad.join('、')}`);
500
+ return { text: lines.join('\n'), folders: granted, audit: `folders ${p.use.join(',') || '(list)'}` };
501
+ }
502
+
503
+ function decideFolders(jobId, allow) {
504
+ chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
505
+ for (const fn of waiters.get(`folders:${jobId}`) || []) fn(!!allow);
506
+ }
507
+
508
+ async function notifyLocked(entry) {
509
+ chrome.notifications?.create(`hc-folder:${entry.id}`, {
510
+ type: 'basic', iconUrl: 'icons/icon128.png', priority: 2, requireInteraction: true,
511
+ title: '本地文件夹需要重新允许',
512
+ message: `「${entry.name}」的读取权限失效了。点这里打开窗口,再点「恢复访问」。`,
513
+ }, () => void chrome.runtime.lastError);
514
+ await openMount(entry.id).catch(() => {});
515
+ }
516
+
517
+ async function fsCmd(cmd, p, task) {
518
+ const granted = task.folders || [];
519
+ if (!granted.includes(p.folder)) {
520
+ throw err('CLOUD_DENIED', `本任务还不能读「${p.folder}」。先用 folders 申请,用户点头之后再读。`);
521
+ }
522
+ const entry = await folders.getByName(p.folder);
523
+ if (!entry?.handle) throw err('CLOUD_DENIED', `这个浏览器上没有「${p.folder}」。请用户在网页上重新挂载。`);
524
+ try {
525
+ if (cmd === 'fs_list') return await folders.listTree(entry.handle, { path: p.path, depth: p.depth, glob: p.glob, folder: p.folder });
526
+ const file = await folders.readFile(entry.handle, p.path);
527
+ let reserved = false;
528
+ const held = await updateTask(task.jobId, (x) => {
529
+ const used = x.folderBytes || 0;
530
+ if (used + file.size > policy.FS_TASK_MAX) return;
531
+ x.folderBytes = used + file.size;
532
+ reserved = true;
533
+ });
534
+ if (!held) throw err('CLOUD_DENIED', '这个任务已经结束');
535
+ if (!reserved) throw err('CLOUD_DENIED', '这个任务读取本地文件已到 256MB 上限');
536
+ try {
537
+ const up = await folders.uploadFile(file, p.upload.url);
538
+ return { text: `已读取 ${p.folder}/${p.path}(${up.bytes} 字节),可以拉取了。`, bytes: up.bytes, name: up.name, audit: `read ${p.folder}/${p.path} ${up.bytes}` };
539
+ } catch (e) {
540
+ await updateTask(task.jobId, (x) => { x.folderBytes = Math.max(0, (x.folderBytes || 0) - file.size); });
541
+ throw e;
542
+ }
543
+ } catch (e) {
544
+ if (e.code === 'FOLDER_LOCKED') {
545
+ await notifyLocked(entry);
546
+ throw err('FOLDER_LOCKED', `文件夹「${entry.name}」需要你重新允许一次。已打开窗口,点「恢复访问」后再试。`);
547
+ }
548
+ throw e;
549
+ }
550
+ }
551
+
552
+ async function openMount(folderId = '') {
553
+ const url = chrome.runtime.getURL('mount.html') + (folderId ? `?id=${encodeURIComponent(folderId)}` : '');
554
+ if (mountWin != null) {
555
+ const existing = await chrome.windows.get(mountWin).catch(() => null);
556
+ if (existing) {
557
+ await chrome.windows.update(mountWin, { focused: true, drawAttention: true }).catch(() => {});
558
+ const [tab] = await chrome.tabs.query({ windowId: mountWin });
559
+ if (tab) await chrome.tabs.update(tab.id, { url }).catch(() => {});
560
+ return;
561
+ }
562
+ mountWin = null;
563
+ }
564
+ const created = await chrome.windows.create({ url, type: 'popup', width: 440, height: 560, focused: true });
565
+ mountWin = created?.id ?? null;
566
+ }
567
+
568
+ async function reportFolders() {
569
+ const mounted = await folders.names().catch(() => []);
570
+ const cfg = await getCfg();
571
+ if (cfg && ws?.readyState === 1) send({ type: 'config', sites: allowOf(cfg), auto: !!cfg.auto, autoConfirm: !!cfg.autoConfirm, folders: mounted });
572
+ await chrome.storage.session.set({ folderTick: Date.now() }).catch(() => {});
573
+ }
574
+
575
+ function decideSites(jobId, allow) {
576
+ chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
577
+ for (const fn of waiters.get(`sites:${jobId}`) || []) fn(!!allow);
578
+ }
579
+
580
+ // 用户亲手允许的网站进允许名单。自动允许不走这里。
581
+ async function allowSites(hosts) {
582
+ if (!hosts?.length) return;
583
+ const cfg = await getCfg();
584
+ if (!cfg) return;
585
+ const list = allowOf(cfg);
586
+ const add = policy.cleanAllowList(hosts).filter((h) => !list.includes(h));
587
+ if (!add.length) return;
588
+ await patchCfg({ sites: [...list, ...add] });
589
+ await log({ ev: 'site_on', msg: add.join(',') });
590
+ }
591
+
592
+ // 自动允许(auto)只放行这个任务:一开始的网站给这个任务用,不进允许名单。
593
+ // 用户点的允许:这些网站同时进允许名单。
594
+ async function decide(jobId, allow, { auto = false } = {}) {
595
+ let granted = [];
596
+ const changed = await serial(async () => {
597
+ const tasks = await getTasks();
598
+ const t = tasks[jobId];
599
+ if (!t || t.state !== 'pending') return false;
600
+ t.state = allow ? 'approved' : 'denied';
601
+ t.lastAt = Date.now();
602
+ if (allow) { granted = t.ask || []; t.sites = [...new Set([...(t.sites || []), ...granted])]; }
603
+ t.ask = [];
604
+ await setTasks(tasks);
605
+ return true;
606
+ });
607
+ chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
608
+ if (changed && allow && !auto) await allowSites(granted);
609
+ if (changed) {
610
+ await log({ ev: allow ? 'approved' : 'denied', job: jobId, ...(auto && allow ? { msg: '自动放行' } : {}) });
611
+ await pushLive();
612
+ }
613
+ for (const fn of waiters.get(jobId) || []) fn(!!allow);
614
+ }
615
+
616
+ // 任务结束(服务器说完了,或用户在侧栏里收回):从此不再接它的命令,关掉它开的标签页。
617
+ // 用户正盯着的那一页不关——他可能正在核对刚发出去的作品。
618
+ async function endTask(jobId, by) {
619
+ const t = await serial(async () => {
620
+ const tasks = await getTasks();
621
+ const task = tasks[jobId];
622
+ if (!task) return null;
623
+ delete tasks[jobId];
624
+ await setTasks(tasks);
625
+ return task;
626
+ });
627
+ if (!t) return;
628
+ for (const fn of waiters.get(jobId) || []) fn(false);
629
+ for (const fn of waiters.get(`sites:${jobId}`) || []) fn(false);
630
+ for (const fn of waiters.get(`folders:${jobId}`) || []) fn(false);
631
+ chrome.notifications?.clear(`hc-cloud:${jobId}`, () => void chrome.runtime.lastError);
632
+ chrome.notifications?.clear(`hc-cloud-sites:${jobId}`, () => void chrome.runtime.lastError);
633
+ chrome.notifications?.clear(`hc-cloud-folders:${jobId}`, () => void chrome.runtime.lastError);
634
+ await closeTaskTabs(t, { force: by === 'user' });
635
+ await pushLive();
636
+ await log({ ev: 'ended', job: jobId, by });
637
+ }
638
+
639
+ async function closeTaskTabs(t, { force = false } = {}) {
640
+ for (const id of t.tabs || []) {
641
+ const tab = await chrome.tabs.get(id).catch(() => null);
642
+ if (!tab) continue;
643
+ if (!force && tab.active) continue;
644
+ await chrome.tabs.remove(id).catch(() => {});
645
+ }
646
+ }
647
+
648
+ const updateTask = (jobId, fn) => serial(async () => {
649
+ const tasks = await getTasks();
650
+ if (!tasks[jobId]) return null;
651
+ fn(tasks[jobId]);
652
+ await setTasks(tasks);
653
+ return tasks[jobId];
654
+ });
655
+
656
+ // ---------- 命令 ----------
657
+
658
+ async function handle(m) {
659
+ const started = Date.now();
660
+ const jobId = String(m.job?.id || '');
661
+ try {
662
+ const data = await exec(m);
663
+ let out = data;
664
+ const audit = out && typeof out === 'object' ? out.audit : '';
665
+ if (out && typeof out === 'object') delete out.audit;
666
+ if (JSON.stringify(out ?? null).length > policy.RESULT_MAX) {
667
+ out = { text: String(data?.text || '').slice(0, 200000) + '\n…(结果太大,已截断)' };
668
+ }
669
+ send({ type: 'res', id: m.id, ok: true, data: out });
670
+ await log({ job: jobId, cmd: m.cmd, ok: true, ms: Date.now() - started, ...(audit ? { msg: String(audit).slice(0, 160) } : {}) });
671
+ } catch (e) {
672
+ send({ type: 'res', id: m.id, ok: false, error: { code: e.code || 'INTERNAL', message: e.message || String(e) } });
673
+ await log({ job: jobId, cmd: m.cmd, ok: false, code: e.code || 'INTERNAL', msg: String(e.message || e).slice(0, 120) });
674
+ }
675
+ }
676
+
677
+ // 地址:正在加载的那个(pendingUrl)也要算——导航提交之前 url 还是旧的
678
+ const urlsOf = (tab) => [tab.url || '', tab.pendingUrl || ''].filter(Boolean);
679
+ const tabAllowed = (tab, hosts) => urlsOf(tab).every((u) => policy.urlAllowed(u, hosts));
680
+
681
+ // 跳出白名单的页先退回;退不回就清空成空白页。内容一律不出去。
682
+ async function bounce(tabId, hosts) {
683
+ await chrome.tabs.goBack(tabId).catch(() => {});
684
+ await sleep(1500);
685
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
686
+ if (tab && !tabAllowed(tab, hosts)) await chrome.tabs.update(tabId, { url: 'about:blank' }).catch(() => {});
687
+ }
688
+
689
+ async function exec(m) {
690
+ const cfg = await getCfg();
691
+ if (!cfg?.deviceId) throw err('CLOUD_UNPAIRED', '这个浏览器没有配对');
692
+ if (cfg.paused) throw err('CLOUD_PAUSED', '用户暂停了云端控制。告诉用户在扩展侧栏里恢复后再继续。');
693
+ const now = Date.now();
694
+ while (recent.length && now - recent[0] > 60000) recent.shift();
695
+ if (recent.length >= PER_MINUTE) throw err('CLOUD_RATE', '命令太密(每分钟最多 120 条),放慢一点');
696
+ recent.push(now);
697
+
698
+ const reason = m.cmd === 'sites' || m.cmd === 'folders' ? String(m.params?.reason || '').slice(0, 200) : '';
699
+ const task = await taskFor(m.job, cfg, reason, { allowEmpty: m.cmd === 'folders' });
700
+ const jobId = task.jobId;
701
+ const updated = await updateTask(jobId, (t) => { t.count += 1; t.lastAt = Date.now(); });
702
+ if (!updated) throw err('CLOUD_DENIED', '这个任务已经结束');
703
+ if (updated.count > policy.TASK_CMD_CAP) throw err('CLOUD_DENIED', `一个任务最多 ${policy.TASK_CMD_CAP} 条命令,已经用完`);
704
+
705
+ const hosts = hostsOf(updated, cfg);
706
+ const p = policy.checkCommand(m.cmd, m.params, { hosts, origin: cfg.origin });
707
+ if (m.cmd === 'sites') return requestSites(updated, p, cfg);
708
+ if (m.cmd === 'folders') return requestFolders(updated, p, cfg);
709
+ if (m.cmd === 'fs_list' || m.cmd === 'fs_read') return fsCmd(m.cmd, p, updated);
710
+ const sid = sidOf(jobId);
711
+ const label = labelOf(cfg.origin);
712
+ const own = new Set(updated.tabs);
713
+
714
+ if (m.cmd === 'tabs') return tabsCmd(p, m, updated, { sid, label, hosts, own });
715
+
716
+ const tid = Number(m.tabId ?? m.params?.tabId ?? updated.cur);
717
+ if (!tid || !own.has(tid)) {
718
+ throw err('NO_TAB', updated.tabs.length
719
+ ? `只能操作本任务自己开的标签页:${updated.tabs.join('、')}`
720
+ : '本任务还没有标签页——先 tabs(action:"new", url:…) 开一个');
721
+ }
722
+ const tab = await chrome.tabs.get(tid).catch(() => null);
723
+ if (!tab) throw err('NO_TAB', `标签页 ${tid} 已经关了`);
724
+ if (!tabAllowed(tab, hosts)) {
725
+ await bounce(tid, hosts);
726
+ return policy.redactOffList(tab.pendingUrl || tab.url);
727
+ }
728
+
729
+ // 对外动作的确认:全局开关或本任务里点过「都同意」,就不弹浮条。
730
+ // 不带 kind 的 ask(扫码、验证码)不满足条件,继续往下弹窗。
731
+ if (m.cmd === 'ask' && p.kind === 'confirm' && (cfg.autoConfirm || updated.confirmAll)) {
732
+ await log({ ev: 'ask_auto', job: jobId, msg: String(p.prompt || '').slice(0, 160) });
733
+ return { outcome: 'continued', auto: true, text: '✅ 用户已设置对外动作免确认,直接继续。' };
734
+ }
735
+
736
+ // 浮条上写明是谁在问:云端发来的文字可能是钓鱼,用户得知道这不是本机 agent 说的
737
+ if (m.cmd === 'ask') p.title = `☁ ${policy.hostOf(cfg.origin)}:${String(p.title || '需要你搭把手').slice(0, 60)}`;
738
+ // 能力包里会删东西或整片重做的 action(load.json 的 confirm):扩展自己弹确认,不靠 agent 记得先 ask。
739
+ // 和对外动作的确认是同一套:全局免确认、本任务内都同意都能盖住它。说明文字来自扩展包,不来自服务器。
740
+ if (m.cmd === 'call') {
741
+ const what = p.confirm;
742
+ delete p.confirm;
743
+ if (what) await confirmCall(what, p, updated, cfg, { tid, sid, label });
744
+ }
745
+
746
+ const data = m.cmd === 'upload'
747
+ ? await uploadMedia(p, tid, sid, label)
748
+ : await deps.run(m.cmd, p, tid, sid, label);
749
+
750
+ // 「本任务内都同意」只记在这个任务上。任务结束时整条记录删掉,不会带到下一个任务。
751
+ // always 是扩展内部的记号,不回给服务器。
752
+ if (m.cmd === 'ask' && data?.always) {
753
+ await updateTask(jobId, (t) => { t.confirmAll = true; });
754
+ await log({ ev: 'confirm_task', job: jobId, msg: String(p.prompt || '').slice(0, 160) });
755
+ delete data.always;
756
+ }
757
+
758
+ // 结果出门前再查一遍本任务所有标签页:有一页跳出了白名单,整份结果都不能出去
759
+ const after = (await getTasks())[jobId]?.tabs || [];
760
+ for (const id of after) {
761
+ const t = await chrome.tabs.get(id).catch(() => null);
762
+ if (t && !tabAllowed(t, hosts)) {
763
+ await bounce(id, hosts);
764
+ return policy.redactOffList(t.pendingUrl || t.url);
765
+ }
766
+ }
767
+ // 点击开出了新标签页,后台已经跟过去了:下一条缺省命令落在新页上
768
+ const next = Number(data?.tabId) && after.includes(Number(data.tabId)) ? Number(data.tabId) : tid;
769
+ await updateTask(jobId, (t) => { t.cur = next; });
770
+ return data;
771
+ }
772
+
773
+ async function confirmCall(what, p, task, cfg, { tid, sid, label }) {
774
+ const jobId = task.jobId;
775
+ const msg = `${p.name}.${p.action}`;
776
+ if (cfg.autoConfirm || task.confirmAll) {
777
+ await log({ ev: 'ask_auto', job: jobId, msg });
778
+ return;
779
+ }
780
+ const r = await deps.run('ask', {
781
+ kind: 'confirm',
782
+ title: `☁ ${policy.hostOf(cfg.origin)}:要${what}`,
783
+ prompt: `云端任务要在这个页面上${what}(${msg})。同意后才会执行。`,
784
+ timeout: 300000,
785
+ wantNote: false,
786
+ }, tid, sid, label);
787
+ if (r?.always) {
788
+ await updateTask(jobId, (t) => { t.confirmAll = true; });
789
+ await log({ ev: 'confirm_task', job: jobId, msg });
790
+ }
791
+ if (r?.outcome !== 'continued') {
792
+ throw err('CONFIRM_DECLINED', r?.outcome === 'timed_out'
793
+ ? `用户 5 分钟内没有确认「${what}」,没有执行。在报告里写明,不要重试。`
794
+ : `用户没有同意「${what}」,没有执行。这是明确的「别做」,不要换个方式重试。`);
795
+ }
796
+ }
797
+
798
+ async function tabsCmd(p, m, task, { sid, label, hosts, own }) {
799
+ if (p.action === 'list') {
800
+ const lines = [];
801
+ for (const id of task.tabs) {
802
+ const t = await chrome.tabs.get(id).catch(() => null);
803
+ if (!t) continue;
804
+ const ok = tabAllowed(t, hosts);
805
+ lines.push(`[${id}]${id === task.cur ? ' *' : ' '} ${ok ? `${t.title || '(无标题)'} — ${t.url}` : '(已跳出允许范围,内容屏蔽)'}`);
806
+ }
807
+ return { text: lines.length ? `本任务的标签页(* = 缺省):\n${lines.join('\n')}` : '本任务还没有标签页' };
808
+ }
809
+ if (p.action === 'new') {
810
+ // 不动全局的「最近受控标签页」:本机 agent 不该继承到云端任务开的页
811
+ const { activeTabId } = await chrome.storage.local.get('activeTabId');
812
+ const r = await deps.run('tabs', p, undefined, sid, label);
813
+ if (activeTabId) await chrome.storage.local.set({ activeTabId });
814
+ else await chrome.storage.local.remove('activeTabId');
815
+ const id = Number(r?.tabId);
816
+ if (id) await updateTask(task.jobId, (t) => { if (!t.tabs.includes(id)) t.tabs.push(id); t.cur = id; });
817
+ return r;
818
+ }
819
+ const id = Number(m.params?.tabId ?? m.tabId);
820
+ if (!own.has(id)) throw err('NO_TAB', `只能操作本任务自己开的标签页:${task.tabs.join('、') || '(还没有)'}`);
821
+ if (p.action === 'select') {
822
+ await updateTask(task.jobId, (t) => { t.cur = id; });
823
+ return { text: `受控标签页切到 [${id}]` };
824
+ }
825
+ await chrome.tabs.remove(id).catch(() => {});
826
+ await updateTask(task.jobId, (t) => { t.tabs = t.tabs.filter((x) => x !== id); if (t.cur === id) t.cur = t.tabs.at(-1) || null; });
827
+ return { text: `已关闭标签页 ${id}` };
828
+ }
829
+
830
+ // 上传:素材从配对服务器取,本机磁盘一个字节都不碰。
831
+ // ① 页面里的内容脚本自己去取(服务器的素材接口带 CORS),在页面里造 File 投进去——
832
+ // 不经过消息通道,几百 MB 的视频也不用转 base64
833
+ // ② 取不到(极少数页面拦得住)就由 SW 取、转 base64 交给页面,限 48MB
834
+ async function uploadMedia(p, tid, sid, label) {
835
+ const { media } = p;
836
+ const base = { name: media.name, type: media.type, bytes: media.size, selector: p.selector, dropSelector: p.dropSelector };
837
+ let why = '';
838
+ try {
839
+ return await deps.run('upload', { ...base, url: media.url }, tid, sid, label);
840
+ } catch (e) {
841
+ if (e.code !== 'FETCH_FAILED') throw e;
842
+ why = e.message;
843
+ }
844
+ if (media.size > DROP_MAX) {
845
+ throw err('INTERNAL', `页面里取不到素材(${why}),而素材 ${(media.size / 1048576).toFixed(0)}MB 超过 48MB,没法改由扩展转交。`);
846
+ }
847
+ const res = await fetch(media.url, { credentials: 'omit', cache: 'no-store' });
848
+ if (!res.ok) throw err('INTERNAL', `取素材失败:${res.status}`);
849
+ const bytes = new Uint8Array(await res.arrayBuffer());
850
+ if (bytes.length !== media.size) throw err('INTERNAL', `素材大小不对:应为 ${media.size},实际 ${bytes.length}`);
851
+ let s = '';
852
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
853
+ return deps.run('upload', { ...base, base64: btoa(s) }, tid, sid, label);
854
+ }
855
+
856
+ // ---------- 侧栏 ----------
857
+
858
+ // 只认扩展自己的页面(侧栏)发来的。内容脚本跑在网页里,页面被攻破时它也可能被利用,
859
+ // 配对、批准、解除这些动作不能从那里发起。
860
+ const fromPanel = (sender) => !sender.tab && typeof sender.url === 'string' && sender.url.startsWith(chrome.runtime.getURL(''));
861
+
862
+ async function panelState() {
863
+ const [cfg, { [STATUS]: status }, { [CLAIM]: claimed }, tasks, { [LOG]: list = [] }] = await Promise.all([
864
+ getCfg(), chrome.storage.session.get(STATUS), chrome.storage.session.get(CLAIM), getTasks(), chrome.storage.local.get(LOG),
865
+ ]);
866
+ return {
867
+ cfg: cfg && { origin: cfg.origin, account: cfg.account, sites: allowOf(cfg), auto: cfg.auto, autoConfirm: !!cfg.autoConfirm, paused: cfg.paused, pairedAt: cfg.pairedAt },
868
+ status: status || { state: 'off' },
869
+ claim: claimed && claimed.expiresAt > Date.now() ? { origin: claimed.origin, account: claimed.account } : null,
870
+ tasks: Object.values(tasks).sort((a, b) => b.lastAt - a.lastAt),
871
+ folders: await folders.listFolders().catch(() => []),
872
+ log: list.slice(-40).reverse(),
873
+ defaultOrigin: policy.DEFAULT_ORIGIN,
874
+ };
875
+ }
876
+
877
+ async function patchCfg(patch) {
878
+ const cfg = await getCfg();
879
+ if (!cfg) throw err('CLOUD_UNPAIRED', '还没有配对');
880
+ const { platforms: _old, ...rest } = cfg; // 老版本存的平台勾选,不再有用
881
+ const next = { ...rest, ...patch };
882
+ await setCfg(next);
883
+ const mounted = await folders.names().catch(() => []);
884
+ send({ type: 'config', sites: allowOf(next), auto: !!next.auto, autoConfirm: !!next.autoConfirm, folders: mounted });
885
+ return next;
886
+ }
887
+
888
+ async function onPanel(m) {
889
+ switch (m.op) {
890
+ case 'state': return panelState();
891
+ case 'claim': await claim(m.origin, m.code); return panelState();
892
+ case 'cancelClaim': await chrome.storage.session.remove(CLAIM); return panelState();
893
+ case 'confirm': await confirmClaim(); return panelState();
894
+ case 'unpair': await unpair(); return panelState();
895
+ case 'removeSite': {
896
+ const host = String(m.host || '');
897
+ const cfg = await getCfg();
898
+ if (!cfg) throw err('CLOUD_UNPAIRED', '还没有配对');
899
+ await patchCfg({ sites: allowOf(cfg).filter((h) => h !== host) });
900
+ // 收回的网站立刻从进行中的任务里摘掉
901
+ await serial(async () => {
902
+ const tasks = await getTasks();
903
+ for (const t of Object.values(tasks)) t.sites = (t.sites || []).filter((h) => h !== host);
904
+ await setTasks(tasks);
905
+ });
906
+ await log({ ev: 'site_off', msg: host });
907
+ return panelState();
908
+ }
909
+ case 'setAutoConfirm': {
910
+ await patchCfg({ autoConfirm: !!m.autoConfirm });
911
+ await log({ ev: m.autoConfirm ? 'confirm_on' : 'confirm_off' });
912
+ return panelState();
913
+ }
914
+ case 'setAuto': {
915
+ await patchCfg({ auto: !!m.auto });
916
+ await log({ ev: m.auto ? 'auto_on' : 'auto_off' });
917
+ // 已经挂着「允许」的任务跟着放行。追加网站和文件夹不在 pending 上,这里碰不到。
918
+ if (m.auto) {
919
+ const pending = Object.values(await getTasks()).filter((t) => t.state === 'pending');
920
+ for (const t of pending) await decide(t.jobId, true, { auto: true });
921
+ }
922
+ return panelState();
923
+ }
924
+ case 'setPaused': {
925
+ await patchCfg({ paused: !!m.paused });
926
+ await log({ ev: m.paused ? 'paused' : 'resumed' });
927
+ if (m.paused) { disconnect(); await setStatus('paused', '已暂停:云端命令一律不接'); } else void connect();
928
+ return panelState();
929
+ }
930
+ case 'decide': await decide(String(m.jobId), !!m.allow); return panelState();
931
+ case 'decideSites': decideSites(String(m.jobId), !!m.allow); return panelState();
932
+ case 'decideFolders': decideFolders(String(m.jobId), !!m.allow); return panelState();
933
+ case 'openMount': await openMount(m.folderId ? String(m.folderId) : ''); return panelState();
934
+ case 'removeFolder': await folders.removeFolder(String(m.id || '')); await reportFolders(); return panelState();
935
+ case 'foldersChanged': await reportFolders(); return panelState();
936
+ case 'endTask': await endTask(String(m.jobId), 'user'); return panelState();
937
+ case 'reconnect': disconnect(); retryDelay = 0; await connect(); return panelState();
938
+ default: throw err('INTERNAL', `未知操作 ${m.op}`);
939
+ }
940
+ }
941
+
942
+ // ---------- 装配 ----------
943
+
944
+ export function initCloud(d) {
945
+ deps = d;
946
+
947
+ chrome.runtime.onMessage.addListener((m, sender, sendResponse) => {
948
+ if (!m?.__hcCloud) return false;
949
+ if (!fromPanel(sender)) { sendResponse({ ok: false, error: '只接受扩展侧栏发来的操作' }); return false; }
950
+ onPanel({ ...m, op: m.__hcCloud }).then(
951
+ (state) => sendResponse({ ok: true, state }),
952
+ (e) => sendResponse({ ok: false, error: e.message || String(e) }),
953
+ );
954
+ return true;
955
+ });
956
+
957
+ chrome.notifications?.onButtonClicked.addListener((id, idx) => {
958
+ if (id.startsWith('hc-cloud-folders:')) decideFolders(id.slice('hc-cloud-folders:'.length), idx === 0);
959
+ else if (id.startsWith('hc-cloud-sites:')) decideSites(id.slice('hc-cloud-sites:'.length), idx === 0);
960
+ else if (id.startsWith('hc-cloud:')) void decide(id.slice('hc-cloud:'.length), idx === 0);
961
+ else if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
962
+ });
963
+ chrome.notifications?.onClicked.addListener((id) => {
964
+ if (id.startsWith('hc-folder:')) void openMount(id.slice('hc-folder:'.length));
965
+ });
966
+ chrome.windows?.onRemoved.addListener((id) => { if (id === mountWin) mountWin = null; });
967
+
968
+ // 从本任务的页面里点开的新标签页(「去发布」常在新页打开)归同一个任务,
969
+ // 白名单照样管它:下一条命令前、结果出门前都会核对地址
970
+ chrome.tabs.onCreated.addListener((tab) => {
971
+ if (!tab.openerTabId) return;
972
+ void serial(async () => {
973
+ const tasks = await getTasks();
974
+ const t = Object.values(tasks).find((x) => x.state === 'approved' && x.tabs.includes(tab.openerTabId));
975
+ if (!t || t.tabs.includes(tab.id)) return;
976
+ t.tabs.push(tab.id);
977
+ await setTasks(tasks);
978
+ });
979
+ });
980
+ chrome.tabs.onRemoved.addListener((tabId) => {
981
+ void serial(async () => {
982
+ const tasks = await getTasks();
983
+ let hit = false;
984
+ for (const t of Object.values(tasks)) {
985
+ if (!t.tabs.includes(tabId)) continue;
986
+ t.tabs = t.tabs.filter((x) => x !== tabId);
987
+ if (t.cur === tabId) t.cur = t.tabs.at(-1) || null;
988
+ hit = true;
989
+ }
990
+ if (hit) await setTasks(tasks);
991
+ });
992
+ });
993
+
994
+ // 闲置太久的批准作废;结束了的任务、老格式的任务不留在侧栏里
995
+ void serial(async () => {
996
+ const tasks = await getTasks();
997
+ let dirty = false;
998
+ for (const [id, t] of Object.entries(tasks)) {
999
+ if (Date.now() - t.lastAt > policy.TASK_IDLE_MS || t.v !== TASK_V) { delete tasks[id]; dirty = true; }
1000
+ }
1001
+ if (dirty) await setTasks(tasks);
1002
+ }).then(pushLive);
1003
+
1004
+ void ensureCloud();
1005
+ }