@geoly-ai/skills-hub 0.2.0 → 0.3.0

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/README.md CHANGED
@@ -6,6 +6,22 @@
6
6
  `geoly-ai` 的 skill 分发中心:一条命令装单个 skill、装矩阵包,
7
7
  并支持外部投稿与过审。
8
8
 
9
+ ## 线上
10
+
11
+ | | |
12
+ |---|---|
13
+ | registry 浏览站 | **https://skills-hub-pearl.vercel.app/** |
14
+ | 埋点摄入端 | `https://skills-hub-telemetry.vercel.app/v1/events` |
15
+
16
+ 🔴 **不是 `skills-hub.vercel.app`** —— `.vercel.app` 子域名全局唯一,项目名撞车时
17
+ Vercel 会自动追加一个随机词(这就是 `-pearl` 的来历)。那个裸域名**不属于本项目**,
18
+ 访问它拿到的是 Vercel 边缘层的 `NOT_FOUND`(`text/plain`,不是站点自己的 404 页)。
19
+
20
+ ⚠️ 这一条踩过:看到裸域名 404 就以为站点坏了,实际站点一直好好的。
21
+ ⚠️ 更值得记的是随之而来的第二个错误 —— 曾经在**那个 404 页面**上
22
+ `grep _vercel/insights` 来判断「站点有没有引 analytics 脚本」。
23
+ **在错误的 URL 上取证,结论就算碰巧对了也是无效的。**
24
+
9
25
  ## 安装
10
26
 
11
27
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geoly-ai/skills-hub",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "geoly-ai 的 skill 分发中心 —— 安装、校验、审计",
5
5
  "type": "module",
6
6
  "bin": {
@@ -222,6 +222,13 @@ export function makeContext(globals, deps = {}) {
222
222
  verifier: deps.verifier ?? null,
223
223
  /** 注入点:测试用内存 registry,生产用 `registry.mjs` 的缓存适配器 */
224
224
  registryFactory: deps.registryFactory ?? null,
225
+ /**
226
+ * 注入点:出网。生产走内建 `fetch`(`src/download.mjs` 的默认值)。
227
+ * 🔴 与 `verifier` 一样**没有逃生口**:不给就是真出网,
228
+ * 不存在「测试忘了注入所以静默不出网」这种状态 ——
229
+ * 那会让「--offline 有没有被绕过」这个问题看起来被回答了,其实没有。
230
+ */
231
+ fetchImpl: deps.fetchImpl ?? null,
225
232
  /** 注入点:埋点。生产是 `telemetry.record` */
226
233
  record: deps.record ?? null,
227
234
  /**
@@ -30,6 +30,7 @@ import { parseSpec, resolveSpec } from './resolve.mjs';
30
30
  import { annotations, annotationSuffix } from './output.mjs';
31
31
  import { UsageError, ConflictError, UnsupportedError, EXIT, classify } from '../exit-codes.mjs';
32
32
  import { resolveSnapshotForCommand } from './snapshot-access.mjs';
33
+ import { preheatForInstall, preheatAssetsFor } from './preheat-run.mjs';
33
34
  import { makeLockfileHook } from './sync-lock.mjs';
34
35
 
35
36
  /**
@@ -494,6 +495,12 @@ export async function cmdInstall(ctx, argv, out) {
494
495
  }
495
496
 
496
497
  // ── 解析当前快照(metadata 锁在 trust 内核里起落,本层不碰它) ───────────
498
+ // ── 联网刷新 metadata(🔴 必须在解析快照**之前**)────────────────────────
499
+ // 出网只发生在这里;`resolveSnapshotForCommand` 之后的一切都是同步的、
500
+ // 只读本地缓存。`--offline` 时这一步直接跳过 —— 走的是同一条码,
501
+ // 不是「另一条不出网的实现」。
502
+ await preheatForInstall(ctx, out);
503
+
497
504
  const { snapshot: snap, stale, floor, pinned, verifier } = await resolveSnapshotForCommand(ctx);
498
505
  if (stale) out.warn('timestamp 已过期:本次输出全部按 stale 处理(--allow-stale 已给)');
499
506
 
@@ -515,6 +522,10 @@ export async function cmdInstall(ctx, argv, out) {
515
522
  // 完全同一个**的兼容性检查 —— 一处判定、一种文案。
516
523
  // 因此这里 `client: null`:让 resolvePackInstall 只管成员,不重复判 client。
517
524
  const byId = new Map(snap.artifacts.map((r) => [r.id, r]));
525
+ // 🔴 `fetchAsset` 是**同步**的(内核要求),所以字节必须在这之前就到本地。
526
+ // 分两波是因为 pack 本体要先下回来、验过、读出 manifest,
527
+ // 才知道它的成员是哪些 —— 成员的资产在下面第二波。
528
+ await preheatAssetsFor(ctx, records.filter((r) => r.kind === 'pack'), snap);
518
529
  const packInfos = records.filter((r) => r.kind === 'pack').map((record) => {
519
530
  const bytes = ctx.registry.fetchAsset(record);
520
531
  const manifest = withPackErrors(() => withVerifiedArtifact(
@@ -563,6 +574,12 @@ export async function cmdInstall(ctx, argv, out) {
563
574
  }
564
575
  }
565
576
 
577
+ // ── 第二波:单元资产(🔴 也必须在进事务之前)────────────────────────────
578
+ // `installOneTarget` 是**同步**的 —— 它在锁与事务里面,不能 await。
579
+ // 所以真正会落盘的那些字节必须在这里就到本地。
580
+ // ⚠️ 各 client 的单元可能不同,所以取的是**并集**;按摘要落盘,重复的不会重下。
581
+ await preheatAssetsFor(ctx, [...perClient.values()].flatMap((v) => v.units.map((u) => u.record)), snap);
582
+
566
583
  if (isAll) {
567
584
  // 🔴 **名单为空的 client 整个跳过,不要走一个空事务。**
568
585
  // 实测(2026-08-30 探针):不跳的话它照样 bootstrap 出 `.geoly`、烧掉一代
@@ -0,0 +1,105 @@
1
+ // preheat 的**编排** —— 把「出网取字节」与「同步验签」缝在一起。
2
+ //
3
+ // 🔴 顺序是这个模块存在的全部理由:
4
+ //
5
+ // ① preheatMetadata 出网,只往 staging 写 (async)
6
+ // ② resolveCurrent 验签/新鲜度/floor,读 staging (**同步、原样未改**)
7
+ // ③ promoteMetadata 持 metadata 锁,原子提升 (同步)
8
+ //
9
+ // ②**不能**接受 Promise —— 内核就是同步的,这也是 preheat 必须存在的原因。
10
+ // 所以 ① 与 ③ 之间那一段里,「已下载但还没验」的字节**只在 staging**。
11
+ //
12
+ // ⚠️ ② 内部会取 metadata 锁(`advanceTrustFloor` 自己 acquire/release),
13
+ // 而 ③ 也要取同一把。`src/lock.mjs` **禁止重入**,所以 ③ 必须在 ② **返回之后**
14
+ // 调用,不能把 ② 包进 ③ 的临界区里。
15
+
16
+ import { existsSync } from 'node:fs';
17
+ import { resolveCurrent } from '../snapshot.mjs';
18
+ import { readTrustFloor, resolveStateDir } from '../trust.mjs';
19
+ import { preheatAssets, preheatMetadata, promoteMetadata, discard, newBudget } from '../preheat.mjs';
20
+ import { createCacheRegistry } from './registry.mjs';
21
+
22
+ /**
23
+ * 联网刷新一次 metadata(timestamp + 当前快照)到本地缓存。
24
+ *
25
+ * @returns {{refreshed:boolean, n:number|null, reason:string}}
26
+ */
27
+ export async function preheatOnce({
28
+ cacheDir, stateDir, verifier, cliVersion, now, fetchImpl, timeoutMs,
29
+ budget = newBudget(),
30
+ }) {
31
+ const { stagingDir, n } = await preheatMetadata({ cacheDir, fetchImpl, timeoutMs, budget });
32
+ try {
33
+ // 🔴 registry 指向 **staging**,不是 cache —— 验的必须是刚下回来的那份。
34
+ // 指向 cache 的话,验的是上一轮的旧字节,而新字节从没被验过就被提升了。
35
+ const staged = createCacheRegistry({ cacheDir: stagingDir, offline: false });
36
+ resolveCurrent({
37
+ stateDir,
38
+ fetchTimestamp: staged.fetchTimestamp,
39
+ fetchSnapshot: staged.fetchSnapshot,
40
+ verifier, cliVersion, now,
41
+ offline: false,
42
+ });
43
+ // 到这里 floor 已经被 ② 推进过了;③ 在锁内重读并比对它。
44
+ const floor = readTrustFloor(resolveStateDir(stateDir));
45
+ promoteMetadata({ cacheDir, stateDir, stagingDir, n, expectedFloor: floor });
46
+ return { refreshed: true, n, reason: 'ok' };
47
+ } catch (e) {
48
+ // 🔴 验不过就整个丢掉 staging —— 缓存里那份**旧的、验过的**原样保留。
49
+ // 这正是「未验证字节不进缓存」那条的落点:攻击者投毒一次,
50
+ // 也只是让这一次刷新失败,而不是把本地永久毒成验不过。
51
+ discard(stagingDir);
52
+ throw e;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * install 之前的刷新。**失败要不要致命,取决于本地有没有可用的缓存。**
58
+ *
59
+ * 🔴 有缓存时网络失败**不该**中断安装:离线可用是这个 CLI 的核心属性之一。
60
+ * 但**验签失败**永远致命 —— 那不是「网络不好」,那是有人在改字节。
61
+ * 两者报的错必须分得开,否则「网络抖了一下」会被写进 issue 说成「你们被入侵了」,
62
+ * 反过来更糟:真被投毒时被当成网络问题重试掉。
63
+ */
64
+ export async function preheatForInstall(ctx, out) {
65
+ if (ctx.offline) return { refreshed: false, reason: 'offline' };
66
+ // 🔴 注入了自定义 registry 时不 preheat:那种情况下字节**根本不从缓存来**,
67
+ // 下载只是在给一个没人会读的目录塞东西。
68
+ // ⚠️ 这不是「测试专用后门」—— 判据是「字节的来源是不是本地缓存」,
69
+ // 生产入口 `bin/skills-hub.mjs` 一个 dep 都不传,永远走不到这条。
70
+ if (ctx.registryFactory) return { refreshed: false, reason: 'custom-registry' };
71
+ const haveCache = existsSync(`${ctx.cacheDir}/timestamp.json`);
72
+ try {
73
+ const r = await preheatOnce({
74
+ cacheDir: ctx.cacheDir, stateDir: ctx.stateDir,
75
+ verifier: ctx.verifier, cliVersion: ctx.cliVersion, now: ctx.now,
76
+ fetchImpl: ctx.fetchImpl,
77
+ });
78
+ if (r.refreshed) out?.note?.(`已刷新到快照 ${r.n}`);
79
+ return r;
80
+ } catch (e) {
81
+ // 完整性/验签问题一律上抛 —— 它们**不是**「网络不好」。
82
+ if (e.name === 'IntegrityError' || e.name === 'WireError' || e.code?.startsWith?.('E_')) throw e;
83
+ if (!haveCache) throw e; // 没缓存又取不到 = 真的装不了
84
+ out?.note?.(`联网刷新失败(${e.message})—— 改用本地缓存继续`);
85
+ return { refreshed: false, reason: 'network-failed-cache-used' };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 下载一批记录的资产。`--offline` 时直接跳过 —— 缓存未命中会在
91
+ * `registry.fetchAsset()` 那里报「离线未命中」,那是**它**该报的错。
92
+ *
93
+ * 🔴 `records` 必须来自**已验签的快照**。调用方传进来的对象里
94
+ * `asset.sha256` / `size` / `file` 决定了下载什么、校验什么 ——
95
+ * 如果它们不是从验过的快照里来的,「校验通过」就只是在和自己对暗号。
96
+ */
97
+ export async function preheatAssetsFor(ctx, records, snap) {
98
+ if (ctx.offline || ctx.registryFactory || records.length === 0) return;
99
+ await preheatAssets({
100
+ cacheDir: ctx.cacheDir,
101
+ n: snap.snapshot,
102
+ records,
103
+ fetchImpl: ctx.fetchImpl ?? undefined,
104
+ });
105
+ }
@@ -0,0 +1,191 @@
1
+ // 取字节的**唯一**出网口 —— preheat 用它,别的地方不许自己开 fetch。
2
+ //
3
+ // 🔴 这一层**一个校验都不做**(除了下面这几条纯粹的传输上限)。
4
+ // 签名、摘要、新鲜度、trust floor 全在 `snapshot.resolveCurrent()` 与
5
+ // `artifact.*` 里。理由与 `commands/registry.mjs` 顶部那条相同:把校验
6
+ // 放进取字节层,会诱使人写「这次是我自己下的、可以信」。
7
+ // **下载器不认识「可信」这个概念。**
8
+ //
9
+ // 出网规矩与 `upload.mjs` 保持一套(那边已经踩过一遍):
10
+ // · https-only,且用 URL 解析判断而不是 `startsWith`
11
+ // · 禁止重定向
12
+ // · 超时必须覆盖到**读完 body**,不能 fetch 一返回就 clear
13
+ //
14
+ // ⚠️ 与 upload 不同的一条:那边是**发**数据,这边是**收**。
15
+ // 收的一侧多一个上限问题 —— 见 `readCapped` 的注释。
16
+
17
+ import { NetworkError } from './exit-codes.mjs';
18
+
19
+ /** 单次下载的硬上限。资产另有更大的上限,由调用方按快照记录的 size 传入。 */
20
+ export const MAX_DOWNLOAD_BYTES = 8 * 1024 * 1024;
21
+
22
+ /**
23
+ * 默认超时。🔴 覆盖全程:DNS、连接、TLS、响应头、**整条重定向链**、读完 body。
24
+ *
25
+ * ⚠️ 这一行原本是单行注释,里面的 `**整条重定向链**` 紧跟一个斜杠 ——
26
+ * `*` + `/` 当场把块注释关掉了,报的却是「Unexpected identifier 'body'」。
27
+ * 在块注释里写 Markdown 粗体时,**别让星号紧挨着斜杠**。
28
+ */
29
+ export const DEFAULT_TIMEOUT_MS = 30_000;
30
+
31
+ /**
32
+ * 允许被重定向到的 host。
33
+ *
34
+ * 🔴 **不能只允许 0 次重定向** —— 2026-09-03 实测,GitHub Release 资产必然 302:
35
+ *
36
+ * github.com/<repo>/releases/download/<tag>/<file>
37
+ * → 302 → release-assets.githubusercontent.com/…?sp=r&sig=…&jwt=…
38
+ *
39
+ * 所以「一次都不跟随」等于一个字节都下不来。我第一版正是这么写的。
40
+ *
41
+ * ⚠️ 注意跳转目标**带查询串**(那是它的签名参数)——
42
+ * 因此「禁止 query/fragment」只能管**初始 URL**,不能一并套到跳转目标上,
43
+ * 否则同样是全部下载失败。
44
+ */
45
+ export const REDIRECT_HOSTS = Object.freeze(['release-assets.githubusercontent.com']);
46
+
47
+ /** 重定向跳数上限。实测只需 1 跳;给 3 是留余量,不是没有上限。 */
48
+ export const MAX_REDIRECTS = 3;
49
+
50
+ /**
51
+ * 校验下载地址。
52
+ *
53
+ * 🔴 host 必须由调用方钉死,**不接受来自被下载内容的地址**。
54
+ * locator 契约(02-registry.md §4.0)要求推导链上不出现未验签输入 ——
55
+ * 一个「服务端告诉你去哪儿取下一段」的下载器,正好破坏那条契约。
56
+ */
57
+ export function assertDownloadUrl(raw, expectHost, { isRedirect = false } = {}) {
58
+ if (!expectHost) {
59
+ // 🔴 不给 host 就不许下载。默认放行等于把这道闸留在「注释里」——
60
+ // 我第一版正是只写了注释、没写代码(见下面那条实测)。
61
+ throw new NetworkError('assertDownloadUrl 必须传 expectHost —— 下载地址的 host 由调用方钉死');
62
+ }
63
+ const allowed = Array.isArray(expectHost) ? expectHost : [expectHost];
64
+ let u;
65
+ try { u = new URL(raw); } catch { throw new NetworkError(`下载地址不是合法 URL:${raw}`); }
66
+ if (u.protocol !== 'https:') throw new NetworkError(`下载地址必须是 https:${raw}`);
67
+ if (u.username || u.password) throw new NetworkError('下载地址不得内嵌凭据');
68
+ // 🔴 **真正拦住改道的是 host 这一条,不是上面的 protocol 检查。**
69
+ // 实测:`https:/\evil.test/a`(一条反斜杠)被 WHATWG URL 规范化成
70
+ // `https://evil.test/a` —— protocol 是 https、凭据也没有,
71
+ // 上面两条**全部放行**,字节从 evil.test 来。
72
+ // ⚠️ 所以「用 URL 解析而不是 startsWith」只解决了协议混淆,
73
+ // 没解决 host 混淆;host 必须单独比,且比的是解析后的 `u.host`。
74
+ if (!allowed.includes(u.host)) {
75
+ throw new NetworkError(
76
+ `下载地址的 host 是 ${u.host},不在允许列表 [${allowed.join(', ')}] 里:${raw}`,
77
+ );
78
+ }
79
+ // 初始 URL 由 locator 契约推导,**必须是干净路径**:带 query/fragment 说明
80
+ // 它不是我们自己拼出来的。跳转目标反过来——GitHub 的签名参数就在 query 里,
81
+ // 对它套同一条会让所有下载失败(实测)。
82
+ if (!isRedirect && (u.search || u.hash)) {
83
+ throw new NetworkError(`初始下载地址不得带查询串或片段:${raw}`);
84
+ }
85
+ return u;
86
+ }
87
+
88
+ /**
89
+ * 读 body,并在**读的过程中**记账。
90
+ *
91
+ * 🔴 不能只信 `Content-Length`:它是服务端自己说的。声称 1 KiB 却发 10 GiB
92
+ * 是一行代码的事,而 `res.arrayBuffer()` 会**先把它全收下来**再让你发现超限 ——
93
+ * 那时候内存已经没了。所以:Content-Length 先做一次早筛(能省则省),
94
+ * 真正的上限在流式读取里逐块累计,超了当场断开。
95
+ */
96
+ async function readCapped(res, cap, what) {
97
+ const declared = Number(res.headers?.get?.('content-length'));
98
+ if (Number.isFinite(declared) && declared > cap) {
99
+ throw new NetworkError(`${what} 声称 ${declared} 字节,超过上限 ${cap}`);
100
+ }
101
+ if (!res.body?.getReader) {
102
+ // 测试替身可能只给 arrayBuffer()。生产路径(undici)一定有 body。
103
+ const buf = Buffer.from(await res.arrayBuffer());
104
+ if (buf.length > cap) throw new NetworkError(`${what} 有 ${buf.length} 字节,超过上限 ${cap}`);
105
+ return buf;
106
+ }
107
+ const reader = res.body.getReader();
108
+ const chunks = [];
109
+ let total = 0;
110
+ for (;;) {
111
+ const { done, value } = await reader.read();
112
+ if (done) break;
113
+ total += value.length;
114
+ if (total > cap) {
115
+ await reader.cancel().catch(() => {});
116
+ throw new NetworkError(`${what} 超过上限 ${cap} 字节(读到 ${total} 就断开了)`);
117
+ }
118
+ chunks.push(value);
119
+ }
120
+ return Buffer.concat(chunks, total);
121
+ }
122
+
123
+ /**
124
+ * 下载一个 URL,返回 Buffer。
125
+ *
126
+ * @param {string} url
127
+ * @param {object} [o]
128
+ * @param {number} [o.cap] 字节上限,默认 MAX_DOWNLOAD_BYTES
129
+ * @param {function} [o.fetchImpl] 注入用
130
+ * @param {number} [o.timeoutMs]
131
+ * @param {string} [o.what] 出错信息里怎么称呼它
132
+ */
133
+ export async function download(url, {
134
+ host,
135
+ redirectHosts = REDIRECT_HOSTS,
136
+ maxRedirects = MAX_REDIRECTS,
137
+ cap = MAX_DOWNLOAD_BYTES,
138
+ fetchImpl = globalThis.fetch,
139
+ timeoutMs = DEFAULT_TIMEOUT_MS,
140
+ what = url,
141
+ } = {}) {
142
+ assertDownloadUrl(url, host);
143
+ if (typeof fetchImpl !== 'function') {
144
+ throw new NetworkError('当前 Node 没有内建 fetch,且调用方没有注入 fetchImpl');
145
+ }
146
+ const ac = new AbortController();
147
+ // 🔴 计时器覆盖**整条重定向链 + 读完 body**,只在最后 clear。
148
+ // 在 fetch 返回时就 clear 的话,一个慢慢滴字节的服务端可以让这次下载
149
+ // 永远挂着(upload.mjs 同款教训);每跳各起一个计时器的话,
150
+ // N 跳就等于把超时放大 N 倍。
151
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
152
+ try {
153
+ let current = url;
154
+ for (let hop = 0; ; hop += 1) {
155
+ let res;
156
+ try {
157
+ res = await fetchImpl(current, {
158
+ method: 'GET',
159
+ // 🔴 'manual' 而不是 'follow':跳到哪儿由**我们**决定,
160
+ // 每一跳都重新过一遍 https + host allowlist。
161
+ // 交给 fetch 的 follow 就没有这道复核了。
162
+ redirect: 'manual',
163
+ signal: ac.signal,
164
+ });
165
+ } catch (e) {
166
+ if (ac.signal.aborted) throw new NetworkError(`${what} 下载超时(${timeoutMs} ms)`);
167
+ throw new NetworkError(`${what} 下载失败:${e.message}`);
168
+ }
169
+
170
+ if (res.status >= 300 && res.status < 400) {
171
+ const loc = res.headers?.get?.('location');
172
+ if (!loc) throw new NetworkError(`${what} 回了 HTTP ${res.status} 却没给 location`);
173
+ if (hop >= maxRedirects) {
174
+ throw new NetworkError(`${what} 重定向超过 ${maxRedirects} 跳,放弃`);
175
+ }
176
+ // 相对 location 也要能处理;解析基准是当前这一跳的地址
177
+ const next = new URL(loc, current).toString();
178
+ // 🔴 每一跳都复核。跳转目标允许带 query(GitHub 的签名参数在那里),
179
+ // 但 https 与 host allowlist 一步都不能少。
180
+ assertDownloadUrl(next, redirectHosts, { isRedirect: true });
181
+ current = next;
182
+ continue;
183
+ }
184
+
185
+ if (!res.ok) throw new NetworkError(`${what} 下载失败:HTTP ${res.status}`);
186
+ return await readCapped(res, cap, what);
187
+ }
188
+ } finally {
189
+ clearTimeout(timer);
190
+ }
191
+ }
@@ -0,0 +1,416 @@
1
+ // preheat —— 把「取字节」这件事**整个挪到 resolveCurrent() 之前**。
2
+ //
3
+ // 为什么必须是这个形状:`snapshot.resolveCurrent()` 是**同步**函数,它要求
4
+ // `fetchTimestamp()` / `fetchSnapshot(n)` 同步返回。内建 fetch 返回 Promise,
5
+ // 接不进去;把内核改成 async 是内核 API 变更。于是:
6
+ //
7
+ // preheat(async,出网,只往 staging 写)
8
+ // → resolveCurrent(同步,原样未改,只读缓存/staging)
9
+ // → 提升(持 metadata 锁,原子 rename)
10
+ //
11
+ // 🔴 **缓存里只放验过的字节。** timestamp.json 与 snapshots/<N> **不是内容寻址**
12
+ // (文件名不含摘要),未验证就写进去的话,攻击者投毒一次就能让之后每一次
13
+ // `--offline` 都验签失败 —— 那是**持久 DoS**,而且看起来像「我们的签名坏了」。
14
+ // staging 让这件事在构造上不可能。
15
+ //
16
+ // ⚠️ **可证明的承诺只有这两条**(Codex 2026-09-03 纠正了我原来的说法):
17
+ // · 提升**之前**失败:旧的可达缓存不变。
18
+ // · 提升**过程中**失败:只可能留下**已验证、且不被新 timestamp 引用**的孤儿文件;
19
+ // **不会留下坏指针**。
20
+ // 我原本写的是「任何失败 cache 一个字节都没被碰过」—— 那是**假的**:
21
+ // 提升是多次 rename,第一次成、第二次挂,缓存就已经变了。多文件 rename
22
+ // 不是事务,说它是事务只会让后来的人依赖一个不存在的保证。
23
+
24
+ import { existsSync, linkSync, readFileSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { download } from './download.mjs';
28
+ import { unwrapTimestamp } from './timestamp-envelope.mjs';
29
+ import { acquire } from './lock.mjs';
30
+ import { NetworkError } from './exit-codes.mjs';
31
+ import {
32
+ IntegrityError, METADATA_LOCK, assertFloorUnchanged, resolveStateDir, sha256Of,
33
+ } from './trust.mjs';
34
+ import { fsyncDir, fsyncParentAfter, mkdirChainFsync, writeAtomic } from './atomic-fs.mjs';
35
+
36
+ /** 内建 host。🔴 只能来自这里 —— 不接受 timestamp/snapshot/用户输入里的地址。 */
37
+ export const REGISTRY_HOST = 'github.com';
38
+ export const REGISTRY_BASE = `https://${REGISTRY_HOST}/geoly-ai/skills-hub`;
39
+
40
+ /** 单份 JSON 的上限,与 11-wire-contract.md §2 一致。 */
41
+ export const MAX_JSON_BYTES = 8 * 1024 * 1024;
42
+
43
+ /**
44
+ * 一次 preheat 的总量闸。
45
+ *
46
+ * 🔴 单文件上限拦不住「很多个小文件」:`install --all` 的记录集来自快照,
47
+ * 而快照是验过签的 —— 但**先下载后验签**的顺序意味着,在验签之前
48
+ * 我们已经按它说的条数发了那么多请求。总量闸是这一段的兜底。
49
+ */
50
+ export const MAX_TOTAL_BYTES = 512 * 1024 * 1024;
51
+ export const MAX_REQUESTS = 512;
52
+
53
+ /** 整个 preheat 的总 deadline(单次下载另有自己的超时)。 */
54
+ export const MAX_TOTAL_MS = 10 * 60 * 1000;
55
+
56
+ // ── locator(02-registry.md §4.0)────────────────────────────────────────────
57
+ //
58
+ // 🔴 推导链上不出现任何未验签的输入:`<N>` 来自**已验签**的 timestamp,
59
+ // `<file>` 由**已验签**快照里那条记录的字段重算,`<host>` 是内建常量。
60
+
61
+ export const timestampUrl = () => `${REGISTRY_BASE}/releases/download/timestamp/timestamp.json`;
62
+ export const snapshotUrl = (n) => `${REGISTRY_BASE}/releases/download/hub-v${n}/hub-${n}.json`;
63
+ export const snapshotBundleUrl = (n) => `${REGISTRY_BASE}/releases/download/hub-v${n}/hub-${n}.json.sigstore.json`;
64
+ export const assetUrl = (n, file) => `${REGISTRY_BASE}/releases/download/hub-v${n}/${file}`;
65
+
66
+ /**
67
+ * 快照号必须是**非负安全整数**,且原样可往回写成同一个字符串。
68
+ *
69
+ * 🔴 拒绝浮点、指数、前导零、负数、超过 2^53-1。
70
+ * 理由不是「不好看」:这个值要拼进 URL,也要拼进本地文件名。
71
+ * `Number('1e3')` 是 1000、`Number('007')` 是 7 —— 两个不同的字符串
72
+ * 映到同一个 N,等于给缓存投毒开了一扇门(同一个文件名两种来源)。
73
+ */
74
+ export function assertSnapshotNumber(v, where = 'latest_snapshot') {
75
+ if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 0) {
76
+ throw new IntegrityError('E_SNAPSHOT_N', `${where} 必须是非负安全整数,得到 ${JSON.stringify(v)}`);
77
+ }
78
+ return v;
79
+ }
80
+
81
+ /**
82
+ * 资产文件名的安全校验 —— **不是黑名单,是重算后逐字节比**。
83
+ *
84
+ * 🔴 黑名单(拒 `/` `\` `..` query fragment 百分号编码…)永远漏得掉一种写法。
85
+ * 而发布端的命名是**确定性**的(`scripts/build-snapshot.mjs` 的
86
+ * `assetFileName()`:`<kind>_<ns>_<name>_<version>.tar.gz`),
87
+ * 所以客户端可以用记录**自己的字段**重算一遍再比 —— 对不上就拒。
88
+ * 这样「什么字符算安全」这个问题根本不需要回答。
89
+ *
90
+ * ⚠️ 这也堵住了一类更隐蔽的:`asset.file` 指向**另一条记录**的资产。
91
+ * 黑名单查不出这种(它完全是个合法文件名)。
92
+ */
93
+ export function assertAssetFile(rec) {
94
+ const expect = `${rec.kind}_${rec.namespace}_${rec.name}_${rec.version}.tar.gz`;
95
+ if (rec.asset?.file !== expect) {
96
+ throw new IntegrityError(
97
+ 'E_ASSET_FILE',
98
+ `记录 ${rec.id} 的 asset.file 是 ${JSON.stringify(rec.asset?.file)},`
99
+ + `但按它自己的字段应为 ${JSON.stringify(expect)}`,
100
+ );
101
+ }
102
+ return expect;
103
+ }
104
+
105
+ // ── staging ────────────────────────────────────────────────────────────────
106
+
107
+ function makeStaging(cacheDir) {
108
+ // 🔴 必须在 cacheDir **内部**:跨设备的 rename 不是原子的,
109
+ // 而 /tmp 与用户 home 经常不在一个设备上。
110
+ const dir = join(cacheDir, '.staging', randomBytes(8).toString('hex'));
111
+ mkdirChainFsync(dir);
112
+ mkdirChainFsync(join(dir, 'snapshots'));
113
+ return dir;
114
+ }
115
+
116
+ /**
117
+ * 落一个文件到 staging。
118
+ *
119
+ * 🔴 走 `writeAtomic` 而不是裸 `writeFileSync` —— 后者**不 fsync 文件本身**,
120
+ * 断电后可能留下一个「存在但内容是零」的文件。对内容寻址的资产尤其要命:
121
+ * 文件名是摘要,看起来就像验过了(Codex 2026-09-03 P1)。
122
+ * `writeAtomic` 还接着项目的掉电影子模型,绕过它等于这条路径不受故障注入覆盖。
123
+ *
124
+ * 这里仍然**一个校验都不做** —— 只保证「字节确实持久到盘上了」。
125
+ */
126
+ function stage(dir, rel, bytes) {
127
+ const p = join(dir, rel);
128
+ writeAtomic(p, bytes);
129
+ return p;
130
+ }
131
+
132
+ /**
133
+ * 提升一个文件。
134
+ *
135
+ * 🔴 **滚动的东西不能用 no-replace。**
136
+ * `timestamp.json` 每次都是新的:no-replace 会让第二次 preheat 必然
137
+ * 抛 E_CACHE_CONFLICT,而 floor 此前**已经推进** —— 缓存就永久卡在
138
+ * 旧 timestamp,之后每一次 install 都失败。
139
+ * ⚠️ 这个 bug 我自己没看出来,是 Codex 2026-09-03 指出的;
140
+ * 更糟的是我那条「幂等」测试只覆盖了**同内容**的情形,
141
+ * 等于把错的契约钉死了 —— 断言了错误的契约,所以永远绿。
142
+ *
143
+ * 判据:**内容寻址或不可变的用 no-replace,滚动的用替换。**
144
+ * · snapshots/<N>.json / .sigstore.json —— N 定了内容就定了,不可变
145
+ * · assets/<hex> —— 名字就是摘要,不可变
146
+ * · timestamp.json —— 滚动
147
+ */
148
+ function promote(from, to, { what, rolling = false }) {
149
+ const srcDir = dirname(from);
150
+ if (rolling) {
151
+ renameSync(from, to);
152
+ fsyncParentAfter(to);
153
+ fsyncDir(srcDir);
154
+ return true;
155
+ }
156
+ // 🔴 no-replace 用 `link()` 的 EEXIST,**不是** `existsSync()` + `rename()`。
157
+ // 后者中间有一条缝:查的时候不在,rename 的时候已经被别人放进去了 ——
158
+ // 于是我们悄悄覆盖了别人刚写好的字节(TOCTOU,Codex 指出)。
159
+ // `link` 在内核里是原子的:要么建成,要么 EEXIST。
160
+ try {
161
+ linkSync(from, to);
162
+ } catch (e) {
163
+ if (e.code !== 'EEXIST') throw e;
164
+ const a = readFileSync(to);
165
+ const b = readFileSync(from);
166
+ if (!a.equals(b)) {
167
+ throw new IntegrityError(
168
+ 'E_CACHE_CONFLICT',
169
+ `${what} 在缓存里已存在,且内容与刚下载的不一致 —— 拒绝覆盖。`
170
+ + `\n 这两份字节都自称是同一个名字,其中至少一份是错的。`,
171
+ );
172
+ }
173
+ unlinkSync(from);
174
+ return false;
175
+ }
176
+ fsyncParentAfter(to);
177
+ unlinkSync(from);
178
+ fsyncDir(srcDir);
179
+ return true;
180
+ }
181
+
182
+ // ── 主流程 ─────────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * 取回 metadata(timestamp + 当前快照),放进 staging。
186
+ *
187
+ * 返回 `{ stagingDir, n }`。**一次校验都不做** —— 调用方拿它喂给
188
+ * `resolveCurrent()`,由后者验签、验新鲜度、推进 floor。
189
+ *
190
+ * 🔴 第 2 步的「未验签窥探 N」只决定**下载哪个文件**。若 N 是伪造的,
191
+ * 第 3 方(resolveCurrent)验不过,staging 整个丢掉,缓存没被碰过。
192
+ */
193
+ export async function preheatMetadata({
194
+ cacheDir, fetchImpl, timeoutMs, budget = newBudget(),
195
+ }) {
196
+ const stagingDir = makeStaging(cacheDir);
197
+ try {
198
+ const tsBytes = await spend(budget, (left) => download(timestampUrl(), {
199
+ host: REGISTRY_HOST, cap: MAX_JSON_BYTES, fetchImpl,
200
+ timeoutMs: Math.min(timeoutMs ?? Infinity, left), what: 'timestamp.json',
201
+ }));
202
+ stage(stagingDir, 'timestamp.json', tsBytes);
203
+
204
+ // 🔴 窥探:**只**为了知道下载哪个 N。用严格解析 + 严格整数校验,
205
+ // 且**只把数值** N 拼进 URL,不把原始 JSON 字符串拼进去。
206
+ const n = peekLatestSnapshot(tsBytes);
207
+
208
+ const [snapBytes, bundleBytes] = [
209
+ await spend(budget, (left) => download(snapshotUrl(n), {
210
+ host: REGISTRY_HOST, cap: MAX_JSON_BYTES, fetchImpl,
211
+ timeoutMs: Math.min(timeoutMs ?? Infinity, left), what: `快照 ${n}`,
212
+ })),
213
+ await spend(budget, (left) => download(snapshotBundleUrl(n), {
214
+ host: REGISTRY_HOST, cap: MAX_JSON_BYTES, fetchImpl,
215
+ timeoutMs: Math.min(timeoutMs ?? Infinity, left), what: `快照 ${n} 的 bundle`,
216
+ })),
217
+ ];
218
+ stage(stagingDir, join('snapshots', `${n}.json`), snapBytes);
219
+ stage(stagingDir, join('snapshots', `${n}.sigstore.json`), bundleBytes);
220
+ return { stagingDir, n };
221
+ } catch (e) {
222
+ discard(stagingDir);
223
+ throw e;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 从**未验签**的 timestamp 字节里取出 latest_snapshot。
229
+ *
230
+ * ⚠️ 名字里的 `peek` 是认真的:这一步的输出**只能**用来决定下载哪个文件。
231
+ * 任何拿它做判断(「比本地新就…」)的用法都是在信未验签的数据。
232
+ */
233
+ export function peekLatestSnapshot(tsBytes) {
234
+ // 🔴 用**同一个** `unwrapTimestamp()` 拆信封,不另写一个解析器。
235
+ // 我第一版自己写了一份(判断 payload 是字符串就 base64 解)——
236
+ // 虽然结论碰巧一样,但**两个解析器对同一份字节给出不同答案**本身就是一类洞:
237
+ // 窥探这一步认了 A,验签那一步认了 B,中间就有一条缝。
238
+ // `unwrapTimestamp` 明说它只做形状检查、不做信任判断,正是这里该用的。
239
+ let inner;
240
+ try {
241
+ inner = JSON.parse(unwrapTimestamp(tsBytes).bytes.toString('utf8'));
242
+ } catch (e) {
243
+ // 取到一页 HTML(登录墙、错误页、404 页)是**网络/端点**问题。
244
+ // 报成完整性问题会让人去查签名 —— 方向全错。
245
+ throw new NetworkError(
246
+ `timestamp.json 解析不了,取到的字节不像 registry 的响应:${e.message}`,
247
+ );
248
+ }
249
+ return assertSnapshotNumber(inner?.latest_snapshot);
250
+ }
251
+
252
+ /**
253
+ * 下载并校验一批资产,落进 `assets/<sha256hex>`。
254
+ *
255
+ * 🔴 `records` **必须来自已验签的快照**,不能是调用方随手拼的对象 ——
256
+ * 否则 `asset.sha256` / `size` / `file` 全是攻击者说了算,
257
+ * 「校验通过」就只是在和自己对暗号。
258
+ *
259
+ * ⚠️ 这里**允许留下孤儿**:单个资产验过就落盘(按摘要命名)。
260
+ * 整套矩阵下到一半断网时,已经下好的那些下次直接命中 ——
261
+ * 「全部下完再统一提升」会让每次断网都从头再来。
262
+ * 孤儿是安全的:文件名就是摘要,读回来还要再验一次。
263
+ */
264
+ export async function preheatAssets({
265
+ cacheDir, n, records, fetchImpl, timeoutMs, budget = newBudget(),
266
+ }) {
267
+ // 🔴 每个把 n 拼进路径或 URL 的入口都要自己校验一次。
268
+ // 只在 peekLatestSnapshot 里校验是不够的 —— 调用方可以直接调这个函数,
269
+ // 而一个字符串 n(比如 "../x")会把路径带出 snapshots/。
270
+ assertSnapshotNumber(n, 'preheatAssets 的 n');
271
+ const assetsDir = join(cacheDir, 'assets');
272
+ mkdirChainFsync(assetsDir);
273
+ const staging = makeStaging(cacheDir);
274
+ const got = [];
275
+ try {
276
+ for (const rec of records) {
277
+ // 🔴 **校验在缓存命中之前。** 我原本把 assertAssetFile 放在 existsSync 之后 ——
278
+ // 于是「已经有同摘要文件」时,一条 asset.file 写错的记录会被**静默放行**
279
+ // (Codex 2026-09-03)。闸放在快路径后面 = 快路径上没有闸。
280
+ assertAssetFile(rec);
281
+ const hex = String(rec.asset.sha256).replace(/^sha256:/, '');
282
+ const dest = join(assetsDir, hex);
283
+ // 🔴 去重**就是**这一句缓存命中,不需要另设一个 seen 集合。
284
+ // 我原本两样都写了,变异测试当场指出「去掉 seen 没有任何测试变红」——
285
+ // 因为第一条记录落盘之后,第二条本来就会命中这里。
286
+ // 而且 seen 那版更差:它 `continue` 时不往 got 里推,报告会少一条记录。
287
+ if (existsSync(dest)) {
288
+ // 🔴 缓存命中**不等于**那份字节还是对的。名字是摘要,所以一个
289
+ // 崩溃留下的截断文件**看起来就像验过了**(Codex 2026-09-03)。
290
+ // 这里只查大小 —— 完整的摘要复验在 `artifact.assertAssetBytes()`
291
+ // 里每次安装都会做,在这儿再算一遍大资产的 sha256 是白花钱。
292
+ // 大小对不上就删掉重下,而不是报错:**这是我们自己的缓存坏了,
293
+ // 不是分发被投毒** —— 让用户去查签名是把人引向错误的方向。
294
+ if (statSync(dest).size === rec.asset.size) {
295
+ got.push({ id: rec.id, cached: true });
296
+ continue;
297
+ }
298
+ unlinkSync(dest);
299
+ }
300
+
301
+ const file = rec.asset.file;
302
+ // 资产上限按**这一条记录自己声明的 size**,不是一个固定的 8 MiB ——
303
+ // 固定值会拒掉规格允许的合法资产(Codex 指出)。
304
+ const bytes = await spend(budget, (left) => download(assetUrl(n, file), {
305
+ host: REGISTRY_HOST, cap: rec.asset.size, fetchImpl,
306
+ timeoutMs: Math.min(timeoutMs ?? Infinity, left),
307
+ what: `资产 ${rec.id}(${file})`,
308
+ }), rec.asset.size);
309
+
310
+ // ⚠️ 字节数不同 → 摘要必然不同,所以这一条**抓不到摘要抓不到的东西**。
311
+ // 留着是为了**错误信息**:说「少了 3 个字节」比说「摘要对不上」
312
+ // 更快指向真正的原因(截断的下载 / 代理插了一段)。
313
+ // 顺序必须在摘要之前,否则这条永远轮不到。
314
+ if (bytes.length !== rec.asset.size) {
315
+ throw new IntegrityError('E_ASSET_SIZE',
316
+ `资产 ${rec.id} 下回来是 ${bytes.length} 字节,快照说应为 ${rec.asset.size}`);
317
+ }
318
+ const actual = sha256Of(bytes);
319
+ if (actual !== `sha256:${hex}`) {
320
+ throw new IntegrityError('E_ASSET_DIGEST',
321
+ `资产 ${rec.id} 的摘要是 ${actual},快照说应为 sha256:${hex}`);
322
+ }
323
+ const tmp = stage(staging, hex, bytes);
324
+ promote(tmp, dest, { what: `资产 ${rec.id}` });
325
+ got.push({ id: rec.id, cached: false });
326
+ }
327
+ return got;
328
+ } finally {
329
+ discard(staging);
330
+ }
331
+ }
332
+
333
+ /**
334
+ * 把 staging 里的 metadata 提升进缓存。
335
+ *
336
+ * 🔴 必须在**自己持有 metadata 锁**的临界区内做,并在同一段临界区里
337
+ * 重读 floor 比对(`assertFloorUnchanged` 的文档明说它自己不是屏障)。
338
+ *
339
+ * 不这么做会出这个竞态(Codex 2026-09-03):
340
+ * ① preheat A 验过 v2、推进 floor 到 v2
341
+ * ② preheat B 推进到 v3 并落盘
342
+ * ③ A 这时才把自己的 v2 提升进 cache
343
+ * 结果 `cache/timestamp.json = v2` 而 floor 已是 v3 ——
344
+ * **之后每一次 install 都必然失败**,且看不出为什么。
345
+ *
346
+ * 🔴 提升顺序:snapshots/<N>.json → .sigstore.json → timestamp.json **最后**。
347
+ * 断电时最多留下「没人引用的孤儿快照」,而不是「timestamp 指向一个
348
+ * 不存在的 N」—— 后者会让客户端每次都取不到那份快照,
349
+ * 而它手上的 timestamp 是验过签的,于是像是分发被投毒了。
350
+ */
351
+ export function promoteMetadata({ cacheDir, stateDir, stagingDir, n, expectedFloor }) {
352
+ assertSnapshotNumber(n, 'promoteMetadata 的 n');
353
+ const dir = resolveStateDir(stateDir);
354
+ const release = acquire(join(dir, METADATA_LOCK), { cli: 'skills-hub preheat' });
355
+ try {
356
+ assertFloorUnchanged(dir, expectedFloor);
357
+ mkdirChainFsync(join(cacheDir, 'snapshots'));
358
+ promote(join(stagingDir, 'snapshots', `${n}.json`),
359
+ join(cacheDir, 'snapshots', `${n}.json`), { what: `快照 ${n}` });
360
+ promote(join(stagingDir, 'snapshots', `${n}.sigstore.json`),
361
+ join(cacheDir, 'snapshots', `${n}.sigstore.json`), { what: `快照 ${n} 的 bundle` });
362
+ // 🔴 最后这一个。它是**指针**,指向的东西必须先就位。
363
+ // 而且它是**滚动**的 —— 用替换语义,不是 no-replace(见 promote 顶部)。
364
+ promote(join(stagingDir, 'timestamp.json'),
365
+ join(cacheDir, 'timestamp.json'), { what: 'timestamp.json', rolling: true });
366
+ } finally {
367
+ release();
368
+ discard(stagingDir);
369
+ }
370
+ }
371
+
372
+ // ── 预算 ───────────────────────────────────────────────────────────────────
373
+
374
+ export function newBudget({
375
+ maxBytes = MAX_TOTAL_BYTES, maxRequests = MAX_REQUESTS, maxMs = MAX_TOTAL_MS, now = Date.now,
376
+ } = {}) {
377
+ return { bytes: 0, requests: 0, maxBytes, maxRequests, maxMs, deadline: now() + maxMs, now };
378
+ }
379
+
380
+ /**
381
+ * 跑一次下载并记账。
382
+ *
383
+ * 🔴 **字节要事前预留,不能事后记账**(Codex 2026-09-03 指出)。
384
+ * 事后检查的话,最后一个资产可以任意大 —— 它已经下完了才发现超限,
385
+ * 内存和磁盘早就被吃掉了。总量闸的意义正在于「不让它下下来」。
386
+ * 资产的 size 在快照里写着,所以预留是做得到的。
387
+ *
388
+ * 🔴 单次超时也要取**剩余**总时限。否则最后一个请求可以整整越过总 deadline
389
+ * 再跑满自己的 30 秒。
390
+ *
391
+ * @param {number} [reserve] 这次预计的字节数(已知时传,如资产的 asset.size)
392
+ */
393
+ async function spend(b, fn, reserve = 0) {
394
+ if (b.requests >= b.maxRequests) {
395
+ throw new NetworkError(`本次 preheat 的请求数超过上限 ${b.maxRequests}`);
396
+ }
397
+ const left = b.deadline - b.now();
398
+ if (left <= 0) throw new NetworkError(`本次 preheat 超过总时限 ${b.maxMs} ms`);
399
+ if (b.bytes + reserve > b.maxBytes) {
400
+ throw new NetworkError(
401
+ `本次 preheat 的总字节数会超过上限 ${b.maxBytes}(已用 ${b.bytes},这一份还要 ${reserve})`,
402
+ );
403
+ }
404
+ b.requests += 1;
405
+ const out = await fn(left);
406
+ b.bytes += out.length;
407
+ // 事前预留之后这一条只可能在 reserve=0(未知大小)的路径上触发
408
+ if (b.bytes > b.maxBytes) {
409
+ throw new NetworkError(`本次 preheat 的总字节数超过上限 ${b.maxBytes}`);
410
+ }
411
+ return out;
412
+ }
413
+
414
+ export function discard(stagingDir) {
415
+ try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
416
+ }