fde2e-bu 1.2.0 → 1.3.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.
- package/package.json +1 -1
- package/src/cli.mjs +58 -9
- package/src/misc.mjs +2 -14
- package/src/nm/asset.mjs +288 -0
- package/src/nm/host.mjs +168 -16
- package/src/nm/update.mjs +727 -0
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -14,8 +14,9 @@ import { runServe } from './mcp/serve.mjs';
|
|
|
14
14
|
import { runHttpServe, DEFAULT_HTTP_PORT } from './mcp/http.mjs';
|
|
15
15
|
import { runNmHost } from './nm/host.mjs';
|
|
16
16
|
import { cmdBindInstall, cmdBindStatus, cmdBindRemove, assertBindPlatform } from './nm/bind.mjs';
|
|
17
|
+
import { cmdSelfUpdate, DEFAULT_FEED_URL } from './nm/update.mjs';
|
|
17
18
|
import { resolveConfigDir } from './configdir.mjs';
|
|
18
|
-
import { cmdStatus, cmdDoctor
|
|
19
|
+
import { cmdStatus, cmdDoctor } from './misc.mjs';
|
|
19
20
|
import { readFileSync } from 'node:fs';
|
|
20
21
|
import { fileURLToPath } from 'node:url';
|
|
21
22
|
import { dirname, join } from 'node:path';
|
|
@@ -35,7 +36,8 @@ const MAIN_HELP = `fde2e-bu —— FDE2E Browser Use 命令行(零运行时依
|
|
|
35
36
|
serve(stdio JSON-RPC 2.0;--http=仅回环 token 端点)| add | list | remove
|
|
36
37
|
status 桥服务(omni /healthz)健康探测,离线降级提示;顶部显示已配对执行器
|
|
37
38
|
doctor 自检:node 版本 / 配置路径 / 环境变量 / 扩展 zip / NM manifest / pairing
|
|
38
|
-
update
|
|
39
|
+
update 扩展自更新:发现 unpacked 安装 → feed 检查 → 下载校验原子替换
|
|
40
|
+
[--ext-dir <dir>] [--feed <url>] [--dry-run]
|
|
39
41
|
bind 扩展↔CLI 一次性配对(Chrome Native Messaging)
|
|
40
42
|
install | status | remove
|
|
41
43
|
|
|
@@ -112,6 +114,29 @@ flag 面按子命令收窄(C1):不适用 flag 与多余位置参数一律
|
|
|
112
114
|
(如 mcp list --profile-id x 不再被静默忽略);--port/--token 仅 --http 模式可用。
|
|
113
115
|
--home/--dir 显式重定向配置根与工作区根(测试用,不碰真实家目录)。`;
|
|
114
116
|
|
|
117
|
+
const UPDATE_HELP = `fde2e-bu update —— 扩展自更新(unpacked 独立安装)
|
|
118
|
+
|
|
119
|
+
用法:
|
|
120
|
+
fde2e-bu update [--ext-dir <dir>] [--feed <url>] [--dry-run]
|
|
121
|
+
发现本机 unpacked 安装(Chrome 系浏览器 Preferences/Secure Preferences 的
|
|
122
|
+
extensions.settings[meojillndgamkbfjijfehjaefolnfopn].path,多浏览器多
|
|
123
|
+
profile 全扫)→ fetch feed(缺省 ${DEFAULT_FEED_URL})→ semver 只认 > →
|
|
124
|
+
下载(https 强制)→ sha256/字节数双校验 → staging 解压(zip-slip/条目数/
|
|
125
|
+
根 manifest/总量四重防护)→ rm+rename 原子替换(Windows 瞬态占用退避)。
|
|
126
|
+
多处安装全部应用;任一失败不回滚其它。受 FDE2E Omni 桌面版管理的安装
|
|
127
|
+
(~/fde2e/bu/ 内或目录旁 .browser-extension-state.json)自动跳过——随 App 更新。
|
|
128
|
+
--ext-dir <dir> 显式指定安装目录(复核 manifest.json 在场即可,免扫描)
|
|
129
|
+
--feed <url> 覆写 feed 地址(测试/私有部署;仍强制 https)
|
|
130
|
+
--dry-run 只报发现与目标版本,不写盘
|
|
131
|
+
0 命中 → exit 1+指引(浏览器未开过本插件/装在非常规处时用 --ext-dir)。
|
|
132
|
+
CLI 本体的更新仍走 npm:npm i -g fde2e-bu@latest。
|
|
133
|
+
|
|
134
|
+
环境变量(NM host 路径也生效):
|
|
135
|
+
BU_UPDATE_BASE 下载基址/白名单重定向(测试假 feed server/私有部署;
|
|
136
|
+
信任重定向到指定基址,不是放宽为任意域)
|
|
137
|
+
|
|
138
|
+
设计:openspec/changes/bu-online-selfupdate(proposal/design C)。`;
|
|
139
|
+
|
|
115
140
|
const BIND_HELP = `fde2e-bu bind —— 扩展↔CLI 一次性配对(Chrome Native Messaging)
|
|
116
141
|
|
|
117
142
|
平台:支持 macOS 与 Linux(NM 清单按 Chrome 规范路径文件落盘);Windows 需注册表
|
|
@@ -296,18 +321,42 @@ export async function main(argv) {
|
|
|
296
321
|
return cmdDoctor({ home: values.home, dir: values.dir, json: values.json, configDir: values['config-dir'], manifestsDir: values['manifests-dir'] });
|
|
297
322
|
}
|
|
298
323
|
case 'update': {
|
|
299
|
-
//
|
|
300
|
-
|
|
324
|
+
// A3(bu-online-selfupdate):--ext-dir/--feed/--dry-run;不适用 flag 与多余
|
|
325
|
+
// 位置参数一律 exit 2(cli-tooling 规格);--feed 在参数面即强制 https。
|
|
326
|
+
assertFlags(rest, ['ext-dir', 'feed', 'dry-run', 'help'], 'update', { help: UPDATE_HELP });
|
|
301
327
|
const { positionals, values } = parse(rest, {
|
|
302
|
-
options: {
|
|
303
|
-
|
|
328
|
+
options: {
|
|
329
|
+
'ext-dir': { type: 'string' },
|
|
330
|
+
feed: { type: 'string' },
|
|
331
|
+
'dry-run': { type: 'boolean' },
|
|
332
|
+
help: { type: 'boolean', short: 'h' },
|
|
333
|
+
},
|
|
334
|
+
help: UPDATE_HELP,
|
|
304
335
|
});
|
|
305
336
|
if (values.help) {
|
|
306
|
-
console.log(
|
|
337
|
+
console.log(UPDATE_HELP);
|
|
307
338
|
return 0;
|
|
308
339
|
}
|
|
309
|
-
assertNoPositionals(positionals, 'update', { help:
|
|
310
|
-
|
|
340
|
+
assertNoPositionals(positionals, 'update', { help: UPDATE_HELP });
|
|
341
|
+
if (values['ext-dir'] !== undefined && values['ext-dir'] === '') {
|
|
342
|
+
throw new UsageError('--ext-dir 不能为空', { help: UPDATE_HELP });
|
|
343
|
+
}
|
|
344
|
+
if (values.feed !== undefined) {
|
|
345
|
+
let u;
|
|
346
|
+
try {
|
|
347
|
+
u = new URL(values.feed);
|
|
348
|
+
} catch {
|
|
349
|
+
throw new UsageError(`--feed url 无法解析:${values.feed}`, { help: UPDATE_HELP });
|
|
350
|
+
}
|
|
351
|
+
if (u.protocol !== 'https:') {
|
|
352
|
+
throw new UsageError(`--feed 必须 https(收到 ${u.protocol}//${u.host})`, { help: UPDATE_HELP });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return cmdSelfUpdate({
|
|
356
|
+
...(values['ext-dir'] !== undefined ? { extDir: values['ext-dir'] } : {}),
|
|
357
|
+
...(values.feed !== undefined ? { feed: values.feed } : {}),
|
|
358
|
+
dryRun: Boolean(values['dry-run']),
|
|
359
|
+
});
|
|
311
360
|
}
|
|
312
361
|
case 'bind': {
|
|
313
362
|
// A3:bind install/status/remove;--help/-h → BIND_HELP exit 0;裸 bind → 帮助 exit 2
|
package/src/misc.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// status / doctor
|
|
1
|
+
// status / doctor(tasks 1.2 / 1.3;C2 P2-7;A5=pairing 消费)。
|
|
2
|
+
// (update 组自 bu-online-selfupdate A3 起改走 src/nm/update.mjs 的 cmdSelfUpdate。)
|
|
2
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
3
4
|
import { join, dirname, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
@@ -8,7 +9,6 @@ import { findSkillsWorkspace } from './skills/skills.mjs';
|
|
|
8
9
|
import { resolveConfigDir } from './configdir.mjs';
|
|
9
10
|
import { readPairing, defaultProfileId } from './nm/host.mjs';
|
|
10
11
|
import { manifestTargets, readManifest, hostScriptExecutable } from './nm/bind.mjs';
|
|
11
|
-
import pkg from '../package.json' with { type: 'json' }; // P1-3:update 文案版本真值单源
|
|
12
12
|
|
|
13
13
|
// ---------- status(task 1.2 + C2):/healthz 探活+绑定配置面+六落点注册清单 ----------
|
|
14
14
|
|
|
@@ -227,15 +227,3 @@ export async function cmdDoctor({ home, dir, json, fetchImpl, healthzTimeoutMs,
|
|
|
227
227
|
}
|
|
228
228
|
return counts.fail > 0 ? 1 : 0;
|
|
229
229
|
}
|
|
230
|
-
|
|
231
|
-
// ---------- update(task 4.2):尊重 npm,不自作下载器 ----------
|
|
232
|
-
|
|
233
|
-
/** P1-3:版本真值取 package.json(不再写死 0.1.0/「尚未发布」——发布与否是
|
|
234
|
-
* registry 侧事实,CLI 只如实报当前版本与更新命令,404 语义交给用户对照)。 */
|
|
235
|
-
export function cmdUpdate() {
|
|
236
|
-
console.log(`当前 fde2e-bu@${pkg.version};更新走 npm(不自作下载器):`);
|
|
237
|
-
console.log(' npm i -g fde2e-bu@latest # 全局安装形态');
|
|
238
|
-
console.log(' npx -y fde2e-bu@latest … # 免装直跑(生产建议 pin 版本)');
|
|
239
|
-
console.log('若 npm registry 报 404(E404/PACKAGE_VERSION_NOT_FOUND),说明该版本尚未发布——属正常现象,等发布即可。');
|
|
240
|
-
return 0;
|
|
241
|
-
}
|
package/src/nm/asset.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// NM 资产代取(change bu-chrome154-downloads B1,design §A)——SW 发起
|
|
2
|
+
// `bu:asset-fetch`,host 按白名单校验后流式下载到生态资产目录
|
|
3
|
+
// `~/fde2e/bu/assets/<safe-name>`,回绝对路径(CDP DOM.setFileInputFiles 消费)。
|
|
4
|
+
// 背景:Chrome 154 上 chrome.downloads 对插件全量即断——配图上传链改走本通道,
|
|
5
|
+
// 浏览器下载仅作保底回退(SW 侧 cdp.js downloadTo)。
|
|
6
|
+
//
|
|
7
|
+
// 契约(帧协议不变,host.mjs 写队列串行应答):
|
|
8
|
+
// → {type:'bu:asset-fetch', url, name, maxBytes, allowHosts:[…]}
|
|
9
|
+
// ← {type:'bu:asset-fetch:ok', path, bytes, finalUrl, sha256}
|
|
10
|
+
// ← {type:'bu:asset-fetch:error', code, message}
|
|
11
|
+
// code ∈ bad-msg|bad-url|not-allowed|too-large|net|io
|
|
12
|
+
// (bad-msg/bad-url/not-allowed/too-large=确定性拒绝——SW 侧不回退直接抛;
|
|
13
|
+
// net/io=环境性失败——SW 无 CLI 时可回退 chrome.downloads)
|
|
14
|
+
//
|
|
15
|
+
// 安全边界(design §A「白名单双闸」的 host 侧):
|
|
16
|
+
// - host **不自带**业务白名单(allowHosts 由 SW 从 constraints 传入),但强制
|
|
17
|
+
// 协议与域校验——https-only(http 仅本机回环 dev)+ allowHosts 成员匹配;
|
|
18
|
+
// - 重定向**逐跳**白名单(manual redirect 循环,任何一跳越权即断,最多 5 跳);
|
|
19
|
+
// - maxBytes 双闸:content-length 预检 + 流式累计(超限即断,不做无界落盘);
|
|
20
|
+
// - 落盘 temp+rename 原子写;safe-name 同 cdp.js 规则(/ 与 \ 替换 _,另拒
|
|
21
|
+
// '.'/'..' 防目录逃逸);写前清扫目录内 mtime>24h 文件(即取即用语义,防
|
|
22
|
+
// 无界增长)。
|
|
23
|
+
// 依赖全注入可测(home/fs/fetchImpl/now/sweepTtlMs)。
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import * as realFs from 'node:fs';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { Readable } from 'node:stream';
|
|
28
|
+
import { pipeline } from 'node:stream/promises';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
/** 消息类型(SW cdp.js assetFetch 客户端的入口消息)。 */
|
|
32
|
+
export const ASSET_FETCH_MSG_TYPE = 'bu:asset-fetch';
|
|
33
|
+
/** 重定向逐跳上限(design §A:redirect 逐跳白名单,5 跳预算)。 */
|
|
34
|
+
export const MAX_REDIRECT_HOPS = 5;
|
|
35
|
+
/** 资产目录内文件清扫 TTL(24h——上传即取即用,裕量覆盖重试)。 */
|
|
36
|
+
export const ASSET_SWEEP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
/** http 放行的本机回环 host(URL.hostname 口径——IPv6 带方括号)。 */
|
|
38
|
+
export const ASSET_LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
|
|
39
|
+
/** 单次 maxBytes 声明上限(防畸形巨值把资产目录当无界落盘面;业务上限在 SW 侧)。 */
|
|
40
|
+
export const MAX_BYTES_CEILING = 1024 * 1024 * 1024;
|
|
41
|
+
/** name 长度上限(文件名级;safe 化后仍适用)。 */
|
|
42
|
+
export const NAME_MAX = 255;
|
|
43
|
+
/** allowHosts 条数上限(防滥用;constraints 两侧白名单均 ≤4 条)。 */
|
|
44
|
+
export const ALLOW_HOSTS_MAX = 64;
|
|
45
|
+
|
|
46
|
+
/** 资产代取错误(code ∈ bad-msg|bad-url|not-allowed|too-large|net|io)。 */
|
|
47
|
+
export function assetError(code, message) {
|
|
48
|
+
const err = new Error(message ?? code);
|
|
49
|
+
err.name = 'AssetFetchError';
|
|
50
|
+
err.code = code;
|
|
51
|
+
return err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 本模块错误码封闭集(区分 Node 内建 err.code 如 ENOENT/EPIPE——那些按 io 兜底)。 */
|
|
55
|
+
const ASSET_ERROR_CODES = new Set(['bad-msg', 'bad-url', 'not-allowed', 'too-large', 'net', 'io']);
|
|
56
|
+
|
|
57
|
+
function errorReply(code, message) {
|
|
58
|
+
return { type: 'bu:asset-fetch:error', code, message };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 单跳校验(纯函数,供测试与逐跳循环共用):
|
|
63
|
+
* 1. 协议必须 http(s)(其它协议 ⇒ bad-url);
|
|
64
|
+
* 2. http 仅本机回环(⇒ not-allowed);
|
|
65
|
+
* 3. hostname 必须是 allowHosts 成员(⇒ not-allowed)。
|
|
66
|
+
* @param {URL} u
|
|
67
|
+
* @param {string[]} allowHosts
|
|
68
|
+
* @returns {{code:string, message:string}|null}
|
|
69
|
+
*/
|
|
70
|
+
export function checkAssetHop(u, allowHosts) {
|
|
71
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
72
|
+
return { code: 'bad-url', message: `资产 url 协议必须 http(s)(收到 ${u.protocol}//${u.hostname})` };
|
|
73
|
+
}
|
|
74
|
+
if (u.protocol === 'http:' && !ASSET_LOOPBACK_HOSTS.includes(u.hostname)) {
|
|
75
|
+
return { code: 'not-allowed', message: `http 资产仅允许本机(${ASSET_LOOPBACK_HOSTS.join('/')}),收到 http://${u.hostname}` };
|
|
76
|
+
}
|
|
77
|
+
if (!allowHosts.includes(u.hostname)) {
|
|
78
|
+
return { code: 'not-allowed', message: `资产域名不在 allowHosts(只允许 ${allowHosts.join('/')},收到 ${u.hostname})` };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** safe 化文件名(与 cdp.js downloadTo 同规则:/ 与 \ 替换 _;'.'/'..' 拒绝)。 */
|
|
84
|
+
export function safeAssetName(name) {
|
|
85
|
+
// 路径分隔符 + Windows 保留字符一并替换(对抗审查 P3.3——跨平台优雅降级为可写名)
|
|
86
|
+
const safe = String(name).replace(/[/\\:*?"<>|\x00-\x1f]/g, '_');
|
|
87
|
+
if (safe === '.' || safe === '..') {
|
|
88
|
+
throw assetError('bad-msg', `资产 name 非法(${JSON.stringify(name)}——safe 化后仍是目录引用)`);
|
|
89
|
+
}
|
|
90
|
+
return safe;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 资产目录清扫(best-effort):删除 mtime 早于 now−ttl 的**文件**(目录不动);
|
|
95
|
+
* 单个 stat/unlink 失败忽略(清扫不阻断下载)。
|
|
96
|
+
*/
|
|
97
|
+
export function sweepAssetDir(dir, { ttlMs = ASSET_SWEEP_TTL_MS, now = Date.now(), fs = realFs } = {}) {
|
|
98
|
+
let names;
|
|
99
|
+
try {
|
|
100
|
+
names = fs.readdirSync(dir);
|
|
101
|
+
} catch {
|
|
102
|
+
return; // 目录不存在/不可读 → 无可清扫
|
|
103
|
+
}
|
|
104
|
+
for (const n of names) {
|
|
105
|
+
const p = join(dir, n);
|
|
106
|
+
try {
|
|
107
|
+
const st = fs.statSync(p);
|
|
108
|
+
if (!st.isFile()) continue;
|
|
109
|
+
if (now - st.mtimeMs > ttlMs) fs.rmSync(p, { force: true });
|
|
110
|
+
} catch { /* best-effort */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 丢弃响应体(redirect/manual 路径释放连接;无 body/不可取消则忽略)。 */
|
|
115
|
+
async function discardBody(res) {
|
|
116
|
+
try {
|
|
117
|
+
if (res?.body?.cancel) await res.body.cancel();
|
|
118
|
+
else if (res?.body?.destroy) res.body.destroy();
|
|
119
|
+
} catch { /* 丢弃失败不阻断 */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 逐跳白名单下载循环(design §A:redirect:'manual',任何一跳越权即断)。
|
|
124
|
+
* @returns {Promise<{res: object, finalUrl: string}>} 终跳 2xx 响应(body 未消费)
|
|
125
|
+
*/
|
|
126
|
+
async function fetchWithHopAllowlist(url, allowHosts, fetchImpl) {
|
|
127
|
+
let current = url;
|
|
128
|
+
for (let hop = 0; ; hop += 1) {
|
|
129
|
+
const deny = checkAssetHop(current, allowHosts);
|
|
130
|
+
if (deny) throw assetError(deny.code, deny.message);
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await fetchImpl(current.href, { redirect: 'manual' });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw assetError('net', `资产下载失败(${current.host}):${err?.message ?? err}`);
|
|
136
|
+
}
|
|
137
|
+
if (res && [301, 302, 303, 307, 308].includes(res.status)) {
|
|
138
|
+
const loc = res.headers?.get?.('location');
|
|
139
|
+
await discardBody(res);
|
|
140
|
+
if (typeof loc !== 'string' || loc === '') {
|
|
141
|
+
throw assetError('net', `重定向缺 Location(${current.host} → HTTP ${res.status})`);
|
|
142
|
+
}
|
|
143
|
+
if (hop >= MAX_REDIRECT_HOPS) {
|
|
144
|
+
throw assetError('net', `重定向超过 ${MAX_REDIRECT_HOPS} 跳(终点 ${current.href})`);
|
|
145
|
+
}
|
|
146
|
+
let next;
|
|
147
|
+
try {
|
|
148
|
+
next = new URL(loc, current.href); // 相对 Location 按当前跳解析
|
|
149
|
+
} catch {
|
|
150
|
+
throw assetError('net', `重定向 Location 无法解析:${loc}`);
|
|
151
|
+
}
|
|
152
|
+
current = next;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!res || typeof res.ok !== 'boolean' || !res.ok) {
|
|
156
|
+
await discardBody(res);
|
|
157
|
+
throw assetError('net', `资产下载失败(${current.host}):HTTP ${res?.status ?? '???'}`);
|
|
158
|
+
}
|
|
159
|
+
// manual 模式下 res.url=本跳请求 URL;缺省(桩环境)回退 current.href
|
|
160
|
+
const finalUrl = typeof res.url === 'string' && res.url !== '' ? res.url : current.href;
|
|
161
|
+
return { res, finalUrl };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 处理 bu:asset-fetch(async——下载/落盘是异步动作)。永不 reject:一切失败映射
|
|
167
|
+
* 错误帧(code 语义见文件头)。
|
|
168
|
+
* @param {unknown} msg
|
|
169
|
+
* @param {{ home?: string, fs?: object, fetchImpl?: Function, now?: () => number,
|
|
170
|
+
* sweepTtlMs?: number }} [deps]
|
|
171
|
+
* @returns {Promise<{type:'bu:asset-fetch:ok', path:string, bytes:number, finalUrl:string, sha256:string}
|
|
172
|
+
* |{type:'bu:asset-fetch:error', code:string, message:string}>}
|
|
173
|
+
*/
|
|
174
|
+
export async function assetFetchHost(msg, deps = {}) {
|
|
175
|
+
// 1. 形状校验(结构性畸形 ⇒ bad-msg——确定性拒绝,SW 不回退)
|
|
176
|
+
if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
177
|
+
return errorReply('bad-msg', '消息须为 JSON 对象');
|
|
178
|
+
}
|
|
179
|
+
if (msg.type !== ASSET_FETCH_MSG_TYPE) {
|
|
180
|
+
return errorReply('bad-msg', `未知消息类型:${JSON.stringify(msg.type ?? null)}`);
|
|
181
|
+
}
|
|
182
|
+
if (typeof msg.url !== 'string' || msg.url === '') {
|
|
183
|
+
return errorReply('bad-msg', `url 须为非空字符串(收到 ${JSON.stringify(msg.url ?? null)})`);
|
|
184
|
+
}
|
|
185
|
+
if (typeof msg.name !== 'string' || msg.name === '' || msg.name.length > NAME_MAX) {
|
|
186
|
+
return errorReply('bad-msg', `name 须为 1~${NAME_MAX} 字符的字符串(收到 ${JSON.stringify(msg.name ?? null)})`);
|
|
187
|
+
}
|
|
188
|
+
if (!Number.isSafeInteger(msg.maxBytes) || msg.maxBytes <= 0 || msg.maxBytes > MAX_BYTES_CEILING) {
|
|
189
|
+
return errorReply('bad-msg', `maxBytes 须为 (0, ${MAX_BYTES_CEILING}] 的整数(收到 ${JSON.stringify(msg.maxBytes ?? null)})`);
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(msg.allowHosts) || msg.allowHosts.length === 0 || msg.allowHosts.length > ALLOW_HOSTS_MAX
|
|
192
|
+
|| msg.allowHosts.some((h) => typeof h !== 'string' || h === '' || h.length > 253)) {
|
|
193
|
+
return errorReply('bad-msg', `allowHosts 须为 1~${ALLOW_HOSTS_MAX} 条非空字符串数组(收到 ${JSON.stringify(msg.allowHosts ?? null)})`);
|
|
194
|
+
}
|
|
195
|
+
let safe;
|
|
196
|
+
try {
|
|
197
|
+
safe = safeAssetName(msg.name);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return errorReply(err.code, err.message);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const fsMod = deps.fs ?? realFs;
|
|
203
|
+
const fetchFn = deps.fetchImpl ?? globalThis.fetch;
|
|
204
|
+
if (typeof fetchFn !== 'function') {
|
|
205
|
+
return errorReply('net', '无可用 fetch(Node >= 18)');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 失败路径的 temp 残留清理(成功路径 rename 已把 temp 就位为终名)。 */
|
|
209
|
+
let tempPath = null;
|
|
210
|
+
try {
|
|
211
|
+
// 2. 首跳 URL 解析(字符串已到、不可解析 ⇒ bad-url)
|
|
212
|
+
let u;
|
|
213
|
+
try {
|
|
214
|
+
u = new URL(msg.url);
|
|
215
|
+
} catch {
|
|
216
|
+
throw assetError('bad-url', `资产 url 无法解析:${msg.url}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 3. 逐跳白名单下载(含首跳协议/域校验)
|
|
220
|
+
const { res, finalUrl } = await fetchWithHopAllowlist(u, msg.allowHosts, fetchFn);
|
|
221
|
+
if (!res.body) throw assetError('net', '资产下载响应无 body');
|
|
222
|
+
|
|
223
|
+
// 4. content-length 预检(maxBytes 第一闸;无声明则只靠流式第二闸)
|
|
224
|
+
const clRaw = res.headers?.get?.('content-length');
|
|
225
|
+
if (typeof clRaw === 'string' && /^\d+$/.test(clRaw) && Number(clRaw) > msg.maxBytes) {
|
|
226
|
+
await discardBody(res);
|
|
227
|
+
throw assetError('too-large', `资产超限(content-length=${clRaw} > maxBytes=${msg.maxBytes})`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 5. 落盘准备:目录 mkdir -p + 写前清扫(mtime>ttl 的文件 best-effort 删)
|
|
231
|
+
const dir = join(deps.home ?? homedir(), 'fde2e', 'bu', 'assets');
|
|
232
|
+
fsMod.mkdirSync(dir, { recursive: true });
|
|
233
|
+
sweepAssetDir(dir, {
|
|
234
|
+
...(deps.sweepTtlMs !== undefined ? { ttlMs: deps.sweepTtlMs } : {}),
|
|
235
|
+
// 专用 nowMs(epoch 毫秒)——不复用 host 共享 deps.now(那是 ISO 字符串,串线即
|
|
236
|
+
// 清扫静默失效的哑弹;对抗审查 P3.1)
|
|
237
|
+
...(deps.nowMs !== undefined ? { now: deps.nowMs() } : {}),
|
|
238
|
+
fs: fsMod,
|
|
239
|
+
});
|
|
240
|
+
const finalPath = join(dir, safe);
|
|
241
|
+
tempPath = join(dir, `.bu-asset-${process.pid}-${safe}.tmp`);
|
|
242
|
+
fsMod.rmSync(tempPath, { force: true });
|
|
243
|
+
|
|
244
|
+
// 6. 流式累计(maxBytes 第二闸)+ sha256 + temp 写入 → rename 原子就位
|
|
245
|
+
const source = res.body.getReader ? Readable.fromWeb(res.body) : Readable.from(res.body);
|
|
246
|
+
const hash = createHash('sha256');
|
|
247
|
+
const sink = fsMod.createWriteStream(tempPath);
|
|
248
|
+
let bytes = 0;
|
|
249
|
+
try {
|
|
250
|
+
await pipeline(source, async function* countAndPass(chunks) {
|
|
251
|
+
for await (const chunk of chunks) {
|
|
252
|
+
bytes += chunk.length;
|
|
253
|
+
if (bytes > msg.maxBytes) {
|
|
254
|
+
throw assetError('too-large', `资产超限(流式累计 ${bytes} > maxBytes=${msg.maxBytes})`);
|
|
255
|
+
}
|
|
256
|
+
hash.update(chunk);
|
|
257
|
+
yield chunk;
|
|
258
|
+
}
|
|
259
|
+
}, sink);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (err?.name === 'AssetFetchError' && ASSET_ERROR_CODES.has(err.code)) throw err; // too-large 等本模块错误原样透传
|
|
262
|
+
throw assetError('io', `资产落盘中断:${err?.message ?? err}`);
|
|
263
|
+
} finally {
|
|
264
|
+
try { sink.destroy(); } catch { /* 已关闭 */ }
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
fsMod.renameSync(tempPath, finalPath);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
fsMod.rmSync(tempPath, { force: true });
|
|
270
|
+
throw assetError('io', `资产原子就位失败(${safe}):${err?.message ?? err}`);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
type: 'bu:asset-fetch:ok',
|
|
274
|
+
path: finalPath,
|
|
275
|
+
bytes,
|
|
276
|
+
finalUrl,
|
|
277
|
+
sha256: hash.digest('hex'),
|
|
278
|
+
};
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (tempPath !== null) {
|
|
281
|
+
try { fsMod.rmSync(tempPath, { force: true }); } catch { /* 清残留失败不遮蔽原错误 */ }
|
|
282
|
+
}
|
|
283
|
+
if (err?.name === 'AssetFetchError' && ASSET_ERROR_CODES.has(err.code)) {
|
|
284
|
+
return errorReply(err.code, err?.message ?? String(err));
|
|
285
|
+
}
|
|
286
|
+
return errorReply('io', err?.message ?? String(err)); // 兜底:未映射异常按 io
|
|
287
|
+
}
|
|
288
|
+
}
|