fde2e-bu 1.3.0 → 1.3.2
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/nm/asset.mjs +296 -0
- package/src/nm/host.mjs +25 -8
package/package.json
CHANGED
package/src/nm/asset.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
// 先等 sink 真正 close 再走外层清理——CI 实证(ubuntu/overlayfs):destroy 是异步
|
|
265
|
+
// 语义,rmSync 抢跑会残留 .tmp(main run 39895242723 flake;mac 30 连跑不可复现)
|
|
266
|
+
await new Promise((resolve) => {
|
|
267
|
+
let settled = false;
|
|
268
|
+
const fin = () => { if (!settled) { settled = true; resolve(); } };
|
|
269
|
+
sink.once('close', fin);
|
|
270
|
+
try { sink.destroy(); } catch { fin(); /* 已关闭 */ }
|
|
271
|
+
setTimeout(fin, 1000); // close 事件缺席的兜底(不无限等)
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
fsMod.renameSync(tempPath, finalPath);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
fsMod.rmSync(tempPath, { force: true });
|
|
278
|
+
throw assetError('io', `资产原子就位失败(${safe}):${err?.message ?? err}`);
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
type: 'bu:asset-fetch:ok',
|
|
282
|
+
path: finalPath,
|
|
283
|
+
bytes,
|
|
284
|
+
finalUrl,
|
|
285
|
+
sha256: hash.digest('hex'),
|
|
286
|
+
};
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (tempPath !== null) {
|
|
289
|
+
try { fsMod.rmSync(tempPath, { force: true }); } catch { /* 清残留失败不遮蔽原错误 */ }
|
|
290
|
+
}
|
|
291
|
+
if (err?.name === 'AssetFetchError' && ASSET_ERROR_CODES.has(err.code)) {
|
|
292
|
+
return errorReply(err.code, err?.message ?? String(err));
|
|
293
|
+
}
|
|
294
|
+
return errorReply('io', err?.message ?? String(err)); // 兜底:未映射异常按 io
|
|
295
|
+
}
|
|
296
|
+
}
|
package/src/nm/host.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Native Messaging host(change bu-bind-http-publish A1/A2;bu-online-selfupdate A2
|
|
2
|
-
// 增 bu:update)——Chrome NM 帧协议与
|
|
2
|
+
// 增 bu:update;bu-chrome154-downloads B1 增 bu:asset-fetch)——Chrome NM 帧协议与
|
|
3
|
+
// bu:pair/bu:update/bu:asset-fetch 消息处理。隐蔽子命令
|
|
3
4
|
// `fde2e-bu __nm-host` 直通本模块 runNmHost。
|
|
4
5
|
//
|
|
5
6
|
// 帧协议(Chrome Native Messaging 规范):
|
|
@@ -12,15 +13,21 @@
|
|
|
12
13
|
// {type:'bu:update', feedVersion, url, sha256, size, runningVersion}
|
|
13
14
|
// (bu-online-selfupdate design C4:SW 判定有新版后请 host 定位 unpacked 安装并
|
|
14
15
|
// 下载校验原子替换;url 相对形态按官方 downloads 基址解析,域名硬白名单。)
|
|
16
|
+
// {type:'bu:asset-fetch', url, name, maxBytes, allowHosts}
|
|
17
|
+
// (bu-chrome154-downloads design §A:Chrome 154 chrome.downloads 即断——SW 配图
|
|
18
|
+
// 下载改 host 代取:逐跳白名单流式下载到 ~/fde2e/bu/assets/,回绝对路径+sha256。)
|
|
15
19
|
// 应答(CLI → 扩展):
|
|
16
20
|
// 成功 {type:'bu:pair:ok', cli:'fde2e-bu', version:<pkg>, saved:N}
|
|
17
21
|
// {type:'bu:update:ok', from, to, paths:[…]}(多命中全部应用)
|
|
22
|
+
// {type:'bu:asset-fetch:ok', path, bytes, finalUrl, sha256}
|
|
18
23
|
// 失败 {type:'bu:pair:error', message:<原因>}(未知类型/校验不过/落盘失败)
|
|
19
24
|
// {type:'bu:update:error', code, message, paths?}
|
|
20
25
|
// (code ∈ managed|not-found|verify|bad-url|io|bad-msg——面板按码映射文案)
|
|
26
|
+
// {type:'bu:asset-fetch:error', code, message}
|
|
27
|
+
// (code ∈ bad-msg|bad-url|not-allowed|too-large|net|io——见 nm/asset.mjs)
|
|
21
28
|
// 落盘:pairing.json({version, savedAt, bindings},atomicWrite 0600——复用
|
|
22
29
|
// src/mcp/config.mjs 的原子写底座)。pairing 只含 profileId/role/name/server 等
|
|
23
|
-
// 非敏感标识,绝不含 token/credential
|
|
30
|
+
// 非敏感标识,绝不含 token/credential。资产代取落盘=~/fde2e/bu/assets/(nm/asset.mjs)。
|
|
24
31
|
// 生命周期:stdin EOF → 退出 0;一切日志只走 stderr(stdout 只有协议帧)。
|
|
25
32
|
|
|
26
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -29,6 +36,7 @@ import { join } from 'node:path';
|
|
|
29
36
|
import { atomicWrite } from '../mcp/config.mjs';
|
|
30
37
|
import { resolveConfigDir } from '../configdir.mjs';
|
|
31
38
|
import { locateInstalls, applyUpdate, EXTENSION_ID, resolveNmUrl } from './update.mjs';
|
|
39
|
+
import { assetFetchHost } from './asset.mjs';
|
|
32
40
|
import pkg from '../../package.json' with { type: 'json' };
|
|
33
41
|
|
|
34
42
|
export const NM_HOST_NAME = 'com.fde2e.bu';
|
|
@@ -289,27 +297,36 @@ function errorReply(message) {
|
|
|
289
297
|
|
|
290
298
|
/**
|
|
291
299
|
* 单条 NM 消息 → 应答对象(不碰流;落盘经 deps 注入,纯逻辑可测)。
|
|
292
|
-
* bu:update 走异步 handleUpdateMessage
|
|
293
|
-
*
|
|
300
|
+
* bu:update 走异步 handleUpdateMessage、bu:asset-fetch 走异步 assetFetchHost
|
|
301
|
+
* (返回 Promise——runNmHost 的写队列统一序列化应答帧);其余(bu:pair/未知类型)
|
|
302
|
+
* 同步应答,行为不变。
|
|
294
303
|
* @param {unknown} msg 解析后的 JSON 值
|
|
295
304
|
* @param {{ version?: string, now?: () => string, save?: (pairing:object) => void,
|
|
296
305
|
* home?: string, platform?: string, extId?: string,
|
|
297
|
-
* fetchImpl?: Function, sleepImpl?: Function, env?: object
|
|
306
|
+
* fetchImpl?: Function, sleepImpl?: Function, env?: object,
|
|
307
|
+
* fs?: object, sweepTtlMs?: number }} [deps]
|
|
298
308
|
* save 注入落盘动作;缺省=写 resolveConfigDir() 下 pairing.json(调用时才解析
|
|
299
309
|
* env/家目录——测试可用 BU_CONFIG_DIR 重定向)。home/platform/fetchImpl/
|
|
300
|
-
* sleepImpl/env 属 bu:update
|
|
310
|
+
* sleepImpl/env 属 bu:update 通道(定位与下载注入);home/fs/fetchImpl/now/
|
|
311
|
+
* sweepTtlMs 属 bu:asset-fetch 通道(资产目录与下载注入)。
|
|
301
312
|
* @returns {{type:'bu:pair:ok', cli:string, version:string, saved:number,
|
|
302
313
|
* received:number, truncated:boolean}
|
|
303
314
|
* |{type:'bu:pair:error', message:string}
|
|
304
|
-
* |Promise<{type:'bu:update:ok'|'bu:update:error'
|
|
315
|
+
* |Promise<{type:'bu:update:ok'|'bu:update:error'|'bu:asset-fetch:ok'
|
|
316
|
+
* |'bu:asset-fetch:error', …}>}
|
|
305
317
|
* P1-2:received=收到的原始条数、truncated=扩展侧是否声明截断——截断要如实
|
|
306
318
|
* 回执给 UI 显示「K 条未同步」,不再只报 saved 装作全量。
|
|
307
319
|
*/
|
|
308
320
|
export function handleNmMessage(msg, deps = {}) {
|
|
309
|
-
// bu:update
|
|
321
|
+
// bu:update 分流(异步通道):形状校验/定位/下载全在 handleUpdateMessage 内
|
|
310
322
|
if (msg !== null && typeof msg === 'object' && !Array.isArray(msg) && msg.type === 'bu:update') {
|
|
311
323
|
return handleUpdateMessage(msg, deps);
|
|
312
324
|
}
|
|
325
|
+
// bu:asset-fetch 分流(异步通道,bu-chrome154-downloads B1):校验/逐跳白名单
|
|
326
|
+
// 下载/原子落盘全在 assetFetchHost 内;bu:pair/bu:update 行为零变更
|
|
327
|
+
if (msg !== null && typeof msg === 'object' && !Array.isArray(msg) && msg.type === 'bu:asset-fetch') {
|
|
328
|
+
return assetFetchHost(msg, deps);
|
|
329
|
+
}
|
|
313
330
|
const version = deps.version ?? pkg.version;
|
|
314
331
|
const check = validatePairMessage(msg);
|
|
315
332
|
if (!check.ok) return errorReply(check.message);
|