vcorp-chrome 1.3.1 → 1.4.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.
Binary file
Binary file
@@ -5,7 +5,7 @@
5
5
  - 用的是你平时登录着的账号、你自己的电脑和网络,平台看到的就是你本人在操作。
6
6
  - 平台的 cookie 和密码**不离开你的电脑**,服务器和它的 AI 都拿不到。
7
7
  - 它只能在你勾选的平台上、在它自己开的标签页里点点填填;你的其它页面它一个都看不到。
8
- - 每个发布任务都要你点「允许」;随时可以暂停、收回、断开。
8
+ - 每个新任务都要你点「允许」,除非你打开了「自动允许新任务」。随时可以暂停、收回、断开。
9
9
 
10
10
  不需要装 Node.js,不需要终端。
11
11
 
@@ -47,7 +47,7 @@
47
47
  ## 四、发布时会发生什么
48
48
 
49
49
  1. 在网页上新建「发布到平台」任务,确认策划后开始发布。
50
- 2. 浏览器弹出通知「maker.vcorp.ai 请求在你的浏览器里发布 → 抖音、小红书」,点 **允许**(也可以在侧栏里点)。5 分钟没点算拒绝。
50
+ 2. 没开「自动允许新任务」时,浏览器弹出通知「maker.vcorp.ai 请求在你的浏览器里发布 → 抖音、小红书」,点 **允许**(也可以在侧栏里点)。5 分钟没点算拒绝。开了这个开关,这一下不再问。
51
51
  3. 浏览器在**后台标签页**里操作,不会抢你正在看的页面。这些标签页带彩色边框,归在一个以「Cloud · maker.vcorp.ai」命名的标签组里,点开就能看它在做什么。
52
52
  4. 发完,任务结束,它开的标签页自动关掉(你正在看的那一页除外)。
53
53
 
@@ -60,7 +60,20 @@
60
60
  - 已经在勾选平台里的不用再批。勾了抖音,再申请 `creator.douyin.com` 不会弹窗。
61
61
  - 一个任务最多加 20 个额外网站。任务结束或被收回后,这些许可一起作废。
62
62
 
63
- 没有对应平台、只是「到某几个网站上办事」的任务也一样:先给你看站点清单和一句用途,你点头之后才能打开;之后每多一个网站再问一次。
63
+ 没有对应平台、只是「到某几个网站上办事」的任务:没开「自动允许」时,先给你看站点清单和一句用途,你点头之后才能打开。开了的话,任务一开始带着的网站和任务一起放行。之后每多一个网站再问一次,这次追加不受「自动允许」影响。
64
+
65
+ ### 读取你电脑上的文件夹
66
+
67
+ 网页上「已连接的浏览器」里有「挂载本地文件夹」。点下去之后,扩展弹出一个小窗口(侧栏里的「添加文件夹」也是这个窗口),你自己选一个文件夹,并给它一个显示名。选文件夹这一下发生在扩展里,网页碰不到你的磁盘。
68
+
69
+ 之后某个任务要读这个文件夹时,浏览器再弹一次通知「想读取你的本地文件夹 → 知识库」,侧栏的任务卡片上同样有允许 / 不允许。「自动允许」管不到这里。
70
+
71
+ - 只读。扩展不会改、不会删你的文件。
72
+ - 只读这些类型:Markdown、文本、PDF、Word(.docx)、CSV、JSON、HTML。隐藏文件、`node_modules`、私钥这类没有这些扩展名的文件不会被列出,也不会被传走。
73
+ - 单个文件最大 32MB,一个任务累计最多 256MB。
74
+ - 服务器只知道显示名,不知道 `D:\...` 这样的路径。
75
+ - 浏览器重启之后,读取权限可能会失效。侧栏或弹出的窗口里点「恢复访问」,再点一次允许即可,不用重新选文件夹。
76
+ - 移除挂载只是忘掉句柄,文件夹还在你的磁盘上。
64
77
 
65
78
  全程 Chrome 要开着、电脑不能睡眠。
66
79
 
@@ -68,7 +81,7 @@
68
81
 
69
82
  | | |
70
83
  |---|---|
71
- | 自动允许新任务 | 不再逐个弹窗确认。只在你信任这个账号、且只勾了要发的平台时打开。额外网站仍要逐个点头 |
84
+ | 自动允许新任务 | 不再逐个弹窗确认。只在你信任这个账号时打开:新任务,以及它一开始就要去的网站,会直接放行。任务开始之后再加的网站,还有本地文件夹,仍然要你逐个点头 |
72
85
  | 暂停 | 立刻断开,云端的命令一条都不接,直到你再关掉 |
73
86
  | 收回 | 结束某个进行中的任务,关掉它的标签页 |
74
87
  | 操作记录 | 最近 200 条:批准、拒绝、每条命令和结果 |
@@ -95,14 +108,15 @@
95
108
  - `test/cloud-policy.test.js`:每条规矩对应的攻击
96
109
  - `extension/cloud.js`:连接、配对、任务批准、标签页归属、上传
97
110
 
98
- 服务器端实现见 Opus Video Maker 的 `extlink.js`(不在本仓库)。发布任务的信封不用改:agent 在任务进行中直接发 `sites` 命令,参数是 `add`(域名列表)和一行 `reason`。扩展先按原样等你批准这个发布任务,再单独问这些网站。
111
+ 服务器端实现见 Opus Video Maker 的 `extlink.js`(不在本仓库)。发布任务的信封不用改:agent 在任务进行中直接发 `sites` 命令,参数是 `add`(域名列表)和一行 `reason`。没开「自动允许」时,扩展先等你批准这个任务,再单独问这些网站。开了的话,新任务和信封上一开始带着的网站直接放行,任务开始之后再申请的网站仍然单独问。
99
112
 
100
113
  ### 服务器被攻破时,它**能**做什么
101
114
 
102
- 在你**已经批准**的任务里、**你勾选的平台**上,以你的身份在网页上操作:发帖、改资料、删帖。发布任务还可以再申请额外网站,但每个都要你另外点头,点头之前页面打不开。这是这个功能本身的能力,没法去掉,只能收窄:
115
+ 在你**已经批准**的任务里、**你勾选的平台**上,以及这个任务获准的网站上,以你的身份在网页上操作:发帖、改资料、删帖。开了「自动允许」时,新任务和它一开始的网站清单也算批准。任务开始之后再申请的网站,每个都要你另外点头,点头之前页面打不开。这是这个功能本身的能力,没法去掉,只能收窄:
103
116
 
104
117
  - 只勾你要发的平台;不用时暂停或断开。
105
- - 不开「自动允许」,每个任务都看一眼它要发哪些平台。额外网站不受「自动允许」影响。
118
+ - 不开「自动允许」,每个新任务都要你点头:发布任务看一眼平台,通用任务看一眼一开始的网站清单。
119
+ - 开了「自动允许」,这些新任务不再问。任务开始之后再申请的网站,以及本地文件夹,仍然逐个点头。开关开着时,服务器可以在新任务的第一份网站清单里写最多 20 个域名,扩展不再问就放行。不信任这个账号时不要开。
106
120
  - 一个任务的额外网站最多 20 个;IP、localhost、内网名,以及 `com.cn`、`github.io` 这类共享后缀一律申请不了。
107
121
  - 每个任务最多 1500 条命令、每分钟最多 120 条;闲置 3 小时后批准作废。
108
122
  - 提交/发布/删除/支付一类的按钮不能夹在批处理(`act`)里,必须单独点,每一下都进操作记录;支付按钮仍会弹原有的付款确认。
@@ -117,6 +131,7 @@
117
131
  | 看或操作你自己开着的标签页 | 云端只能碰它在本任务里开的页(以及从这些页里点开的新页);参数里的 `tabId` 一律剥掉,以任务表为准 |
118
132
  | 让本机 agent 误接管云端的页 | 云端开页不改动本机的「最近受控标签页」 |
119
133
  | 把你电脑上的文件(如 `~/.ssh/id_rsa`)当素材传到平台上 | `upload` 只收配对服务器 `/api/ext/media/` 下的 URL,不收本机路径、不收字节 |
134
+ | 偷偷读你没交给它的文件夹,或读隐藏文件、私钥 | 本地文件夹要你自己选,并且每个任务再点一次头。路径不能含 `..`,只收文档类扩展名,点开头的文件和 `node_modules` 会跳过。文件字节只能上传到配对服务器的 `/api/ext/pull/`,不能指到别的网址 |
120
135
  | 截到你正在看的页面 | 截图用调试器截指定的标签页;云端不能要求切前台,关了「高保真模式」时只有那一页本来就在前台才截得了 |
121
136
  | 用钓鱼文案骗你 | `ask` 浮条的标题强制带上服务器域名;服务器给的所有文字在侧栏里都以纯文本显示 |
122
137
  | 网页里的脚本冒充侧栏去批准任务、改配置 | 配对、批准、勾平台、解除这些操作只接受扩展自己的页面发来的消息(`sender.tab` 为空且 URL 是扩展页) |
@@ -14,12 +14,13 @@
14
14
  // 3. 标签页隔离:云端只看得见、只碰得到它自己开的标签页,用户自己的页面一个都不行。
15
15
  // 4. 文件来源:上传只能用配对服务器上的素材 URL,不收本机路径——否则一句
16
16
  // upload(path:"~/.ssh/id_rsa") 就能把私钥发到公开平台上。
17
- // 5. 每个任务要用户点头(或用户主动开了自动允许),且有命令数上限。
17
+ // 5. 每个任务要用户点头,或用户主动开了自动允许。自动允许盖住新任务,以及通用
18
+ // 任务信封上一开始带着的网站;盖不住任务开始之后追加的网站和文件夹。有命令数上限。
18
19
  //
19
20
  // 站点申请(sites 命令):通用任务(「到兼职网站上帮我找个工作」)全靠它;
20
- // 发布任务也能用它临时加网站,比如去查资料再回来发。用户在通知或侧栏里看着这份
21
- // 站点清单点头。之后每多一个站点都要再点一次头,「自动允许」管不到它们——
22
- // 那等于让服务器自己挑网站。已经被勾选平台根域盖住的站点不再问。
21
+ // 发布任务也能用它临时加网站,比如去查资料再回来发。没开自动允许时,新任务的
22
+ // 第一份网站清单要用户点头,点头后这些站随任务生效。任务开始之后每多一个站点
23
+ // 都要再点一次头,自动允许管不到这次追加。已经被勾选平台根域盖住的站点不再问。
23
24
  // 一个任务的额外网站最多 SITES_MAX 个。
24
25
 
25
26
  // 平台 → 允许停留的站点(含子域)。只放创作者后台和它们的登录/作品页需要的根域。
@@ -64,6 +65,9 @@ export function judgeProbe(probe, { url = '', text = '' } = {}) {
64
65
  export const CLOUD_CMDS = new Set([
65
66
  'tabs', 'navigate', 'snapshot', 'click', 'type', 'fill', 'select', 'key', 'scroll',
66
67
  'wait', 'read_text', 'query', 'read', 'screenshot', 'upload', 'act', 'ask', 'status', 'sites',
68
+ // 本地文件夹:只读、按任务批准、扩展名白名单。读文件在 service worker 里做,
69
+ // 选文件夹和重新授权只能发生在扩展自己的页面(见 folders.js 顶部的结论)。
70
+ 'folders', 'fs_list', 'fs_read',
67
71
  ]);
68
72
 
69
73
  // ---------- 站点申请(通用任务,以及发布任务的额外网站) ----------
@@ -117,6 +121,16 @@ export const TASK_CMD_CAP = 1500; // 一个任务最多这么多条命令
117
121
  export const TASK_IDLE_MS = 3 * 3600e3; // 批准过的任务闲置 3 小时作废,下次要重新点头
118
122
  export const MEDIA_MAX = 2 * 1024 ** 3; // 素材上限,与 maker 那侧的上传上限一致
119
123
 
124
+ // 本地文件夹。数字和 opus-video-maker/extlink.js 的 PULL_MAX 必须一致:
125
+ // 扩展拒绝超限的文件,服务器拒绝超限的上传,两边对不齐就会一截一放。
126
+ export const FS_FILE_MAX = 32 * 1024 * 1024; // 单个文件
127
+ export const FS_TASK_MAX = 256 * 1024 * 1024; // 一个任务累计读走的字节
128
+ export const FS_LIST_MAX = 2000; // 一次列出的条目
129
+ export const FS_DEPTH_MAX = 8;
130
+ export const FS_FOLDERS_MAX = 8; // 一个浏览器最多挂这么多个;一个任务也最多批准这么多个
131
+ export const FS_EXTS = new Set(['.md', '.markdown', '.txt', '.pdf', '.docx', '.csv', '.json', '.html', '.htm']);
132
+ const FS_SKIP = new Set(['node_modules', '__pycache__', '.git', '.venv']);
133
+
120
134
  const err = (code, message) => Object.assign(new Error(message), { code });
121
135
 
122
136
  // 配对的服务器地址:只收 https,开发时允许本机 http。路径、账号、端口外的东西一律不认。
@@ -180,6 +194,88 @@ export function urlAllowed(url, hosts) {
180
194
  return hosts.some((root) => hostMatches(h, root));
181
195
  }
182
196
 
197
+ // 显示名是用户自己起的,不是磁盘路径。不要斜杠,这样它才能当成本地目录名,也才能安全地报给服务器。
198
+ export function cleanFolderName(raw) {
199
+ const s = String(raw ?? '').replace(/\s+/g, ' ').trim();
200
+ if (!s || s.length > 40 || s === '.' || s === '..') return null;
201
+ if (/[\\/\x00-\x1f]/.test(s)) return null;
202
+ return s;
203
+ }
204
+
205
+ export function cleanFolderNames(list) {
206
+ const ok = [];
207
+ const bad = [];
208
+ for (const raw of Array.isArray(list) ? list : []) {
209
+ const name = cleanFolderName(raw);
210
+ if (!name) { bad.push(String(raw).slice(0, 80)); continue; }
211
+ if (!ok.includes(name)) ok.push(name);
212
+ }
213
+ return { names: ok.slice(0, FS_FOLDERS_MAX), bad };
214
+ }
215
+
216
+ // 已经允许、已经拒绝的不再问。空名单就是「先告诉我挂了哪些」,什么都不问。
217
+ export function foldersToAsk(use, { granted = [], refused = [] } = {}) {
218
+ const have = new Set(granted);
219
+ const no = new Set(refused);
220
+ const ask = [];
221
+ const already = [];
222
+ const denied = [];
223
+ for (const name of Array.isArray(use) ? use : []) {
224
+ if (typeof name !== 'string') continue;
225
+ if (have.has(name)) { if (!already.includes(name)) already.push(name); continue; }
226
+ if (no.has(name)) { if (!denied.includes(name)) denied.push(name); continue; }
227
+ if (!ask.includes(name)) ask.push(name);
228
+ }
229
+ return { ask, already, refused: denied };
230
+ }
231
+
232
+ // 相对路径。'' 是文件夹根;不合法返回 null(和根区分开)。
233
+ // 不收绝对路径、反斜杠、.. 和控制字符——服务器被攻破时这是唯一一道路径闸。
234
+ export function normalizeRelPath(raw) {
235
+ const s = String(raw ?? '');
236
+ // 冒号挡掉 Windows 盘符(C:/...)和 NTFS 流。句柄只能按名字往下走,盘符在这里没有意义。
237
+ if (s.includes('\\') || s.includes(':') || /[\x00-\x1f]/.test(s) || s.startsWith('/')) return null;
238
+ const parts = s.split('/').filter((p) => p && p !== '.');
239
+ if (parts.some((p) => p === '..')) return null;
240
+ return parts.join('/');
241
+ }
242
+
243
+ export const skipEntry = (name) => !name || name.startsWith('.') || FS_SKIP.has(name);
244
+
245
+ export function fileAllowed(name) {
246
+ const base = String(name || '').split('/').pop();
247
+ if (!base || base.startsWith('.')) return false;
248
+ const dot = base.lastIndexOf('.');
249
+ if (dot <= 0) return false;
250
+ return FS_EXTS.has(base.slice(dot).toLowerCase());
251
+ }
252
+
253
+ // 只支持 *(一段里)和 **(跨目录)。不解释 shell,不接收 ..。
254
+ export function matchGlob(rel, glob) {
255
+ const g = String(glob || '').trim();
256
+ if (!g) return true;
257
+ let re = '';
258
+ const s = g.replace(/\\/g, '/');
259
+ for (let i = 0; i < s.length; i++) {
260
+ if (s[i] === '*' && s[i + 1] === '*') {
261
+ if (s[i + 2] === '/') { re += '(?:.*/)?'; i += 2; }
262
+ else { re += '.*'; i += 1; }
263
+ } else if (s[i] === '*') re += '[^/]*';
264
+ else if (s[i] === '?') re += '[^/]';
265
+ else re += s[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
266
+ }
267
+ return new RegExp(`^${re}$`).test(rel);
268
+ }
269
+
270
+ // 文件字节只能上传到配对服务器的一次性地址。和 mediaAllowed 同一条理由:
271
+ // 不然服务器会让扩展把用户的文件 PUT 到攻击者的主机上。
272
+ export function pullUrlAllowed(url, origin) {
273
+ let u;
274
+ try { u = new URL(url); } catch { return false; }
275
+ return u.origin === origin && /^\/api\/ext\/pull\/[A-Za-z0-9_-]{16,64}$/.test(u.pathname)
276
+ && !u.username && !u.password && !u.search && !u.hash;
277
+ }
278
+
183
279
  // 素材地址必须指回配对的那台服务器的素材接口——扩展只从那里取文件
184
280
  export function mediaAllowed(url, origin) {
185
281
  let u;
@@ -193,6 +289,22 @@ export function grantedPlatforms(requested, enabled) {
193
289
  return [...new Set(requested || [])].filter((id) => PLATFORMS[id] && on.has(id));
194
290
  }
195
291
 
292
+ // 一条新任务开场时的批准状态。platforms 是已经求过交集的生效平台。
293
+ // auto 打开:发布任务(有生效平台)和通用任务都直接 approved。通用任务信封上
294
+ // 已经带着的网站写入 sites,不要留在 ask,否则紧接着的 sites 命令会把同一份清单再问一遍。
295
+ // auto 盖不住任务开始之后追加的网站(sitesToAsk)和文件夹(foldersToAsk)。
296
+ // 发布任务一个生效平台都没有:denied。没有网站的通用任务本来就是 approved(文件夹仍另批)。
297
+ export function initialTask({ browse = false, sites = [], platforms = [], auto = false } = {}) {
298
+ const list = Array.isArray(sites) ? sites.slice() : [];
299
+ if (browse) {
300
+ return auto
301
+ ? { state: 'approved', sites: list, ask: [] }
302
+ : { state: list.length ? 'pending' : 'approved', sites: [], ask: list };
303
+ }
304
+ if (!(Array.isArray(platforms) ? platforms : []).length) return { state: 'denied', sites: [], ask: [] };
305
+ return { state: auto ? 'approved' : 'pending', sites: [], ask: [] };
306
+ }
307
+
196
308
  // act 剧本静态检查:每一步都要是允许的动作,导航目标都要在白名单里。
197
309
  // 嵌套(if/repeat)一起查——不查的话,危险步骤藏一层就能混过去。
198
310
  function checkSteps(steps, hosts, depth = 0) {
@@ -267,6 +379,34 @@ export function checkCommand(cmd, params, { hosts, origin }) {
267
379
  if (!sites.length && !bad.length) throw err('CLOUD_DENIED', 'sites 要给 add:要访问的网站,如 ["zhipin.com"]');
268
380
  return { add: sites, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
269
381
  }
382
+ case 'folders': {
383
+ // use 可以是空的:那是在问「这个浏览器挂了哪些」,不是在申请。
384
+ const { names, bad } = cleanFolderNames(p.use);
385
+ return { use: names, bad, reason: String(p.reason || '').replace(/\s+/g, ' ').trim().slice(0, 200) };
386
+ }
387
+ case 'fs_list': {
388
+ const folder = cleanFolderName(p.folder);
389
+ if (!folder) throw err('CLOUD_DENIED', 'fs_list 要给 folder:文件夹的显示名');
390
+ const rel = normalizeRelPath(p.path ?? '');
391
+ if (rel === null) throw err('CLOUD_DENIED', 'path 不合法');
392
+ const depth = Math.min(FS_DEPTH_MAX, Math.max(1, Math.floor(Number(p.depth) || 3)));
393
+ let glob = '';
394
+ if (p.glob !== undefined && p.glob !== '') {
395
+ glob = String(p.glob).trim().slice(0, 200);
396
+ if (!glob || glob.includes('..') || /[\x00-\x1f\\]/.test(glob)) throw err('CLOUD_DENIED', 'glob 不合法');
397
+ }
398
+ return { folder, path: rel, depth, ...(glob ? { glob } : {}) };
399
+ }
400
+ case 'fs_read': {
401
+ const folder = cleanFolderName(p.folder);
402
+ if (!folder) throw err('CLOUD_DENIED', 'fs_read 要给 folder:文件夹的显示名');
403
+ const rel = normalizeRelPath(p.path ?? '');
404
+ if (!rel) throw err('CLOUD_DENIED', 'fs_read 要给文件的 path');
405
+ const base = rel.split('/').pop();
406
+ if (skipEntry(base) || !fileAllowed(base)) throw err('CLOUD_DENIED', `不读取这种文件:${base}`);
407
+ if (!pullUrlAllowed(p.upload?.url, origin)) throw err('CLOUD_DENIED', 'fs_read 只能把文件传到配对服务器的 /api/ext/pull/');
408
+ return { folder, path: rel, upload: { url: p.upload.url } };
409
+ }
270
410
  default:
271
411
  return p;
272
412
  }