@geoly-ai/skills-hub 0.1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/skills-hub.mjs +26 -0
- package/package.json +44 -0
- package/src/adapters/index.mjs +832 -0
- package/src/artifact.mjs +376 -0
- package/src/atomic-fs.mjs +166 -0
- package/src/attestation.mjs +136 -0
- package/src/canonical-json.mjs +147 -0
- package/src/cli.mjs +208 -0
- package/src/commands/check.mjs +295 -0
- package/src/commands/context.mjs +235 -0
- package/src/commands/install.mjs +430 -0
- package/src/commands/locks.mjs +197 -0
- package/src/commands/output.mjs +127 -0
- package/src/commands/query.mjs +266 -0
- package/src/commands/recover.mjs +438 -0
- package/src/commands/registry.mjs +123 -0
- package/src/commands/resolve.mjs +171 -0
- package/src/commands/snapshot-access.mjs +91 -0
- package/src/commands/sync-lock.mjs +189 -0
- package/src/crc32c.mjs +27 -0
- package/src/exit-codes.mjs +265 -0
- package/src/fault-inject.mjs +379 -0
- package/src/install.mjs +732 -0
- package/src/journal.mjs +435 -0
- package/src/ledger.mjs +671 -0
- package/src/lock.mjs +98 -0
- package/src/lockfile.mjs +0 -0
- package/src/pack.mjs +792 -0
- package/src/packer.mjs +351 -0
- package/src/plan.mjs +519 -0
- package/src/recover.mjs +1345 -0
- package/src/safe-fs.mjs +252 -0
- package/src/sigstore.mjs +480 -0
- package/src/snapshot.mjs +528 -0
- package/src/stats.mjs +59 -0
- package/src/target.mjs +738 -0
- package/src/telemetry.mjs +393 -0
- package/src/tree-digest.mjs +103 -0
- package/src/trust-roots/README.md +31 -0
- package/src/trust-roots/sigstore-public-good.json +126 -0
- package/src/trust.mjs +563 -0
- package/src/untar.mjs +570 -0
- package/src/upload.mjs +268 -0
- package/src/vendor.mjs +465 -0
package/src/vendor.mjs
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// vendor —— 03-packs.md §6 的**物化器**。
|
|
2
|
+
//
|
|
3
|
+
// pack 是引用不是容器(§1),单独取 pack 得不到完整的目录树。
|
|
4
|
+
// 08-matrix-migration.md 要把主题仓的 vendored 目录改为「从 hub 取」,因此需要
|
|
5
|
+
// 一个把 pack + 全部成员摊平成目录树的东西。
|
|
6
|
+
//
|
|
7
|
+
// npx <hub CLI> vendor pack:geoly/plaud-theme-matrix@0.3.6 \
|
|
8
|
+
// --out .github/codex/plaud-theme-matrix --layout flat
|
|
9
|
+
// (包名以 package.json 为准,本模块不写死 —— ERRATA 里正有一条在改它)
|
|
10
|
+
//
|
|
11
|
+
// 🔴 **本模块只做库,不做命令面。** 参数解析、下载、进度输出都在 CLI 那一侧。
|
|
12
|
+
// 本模块拿到的是「已经在内存里的字节 + 对应的快照 record」。
|
|
13
|
+
//
|
|
14
|
+
// 🔴 **`vendor` 不走安装账本** —— 它写的是用户仓库里的目录,不是 client skills 目录。
|
|
15
|
+
// 所以这里没有 generation、没有 attic、没有 refcount;但**整目录替换的事务纪律
|
|
16
|
+
// 与安装同样严格**:先在同一文件系统上把新树建完整,再一次 rename 换上去。
|
|
17
|
+
import { mkdtempSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, isAbsolute, basename } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { withVerifiedArtifact, writeEntries } from './artifact.mjs';
|
|
21
|
+
import { renameDirFsync, rmtreeFsync, fsyncDir, sameDevice, writeAtomic } from './atomic-fs.mjs';
|
|
22
|
+
import { treeDigest } from './tree-digest.mjs';
|
|
23
|
+
import { stringify, parseStrict } from './canonical-json.mjs';
|
|
24
|
+
import { collectTree, PackError } from './packer.mjs';
|
|
25
|
+
import { validatePackManifest, parseArtifactId } from './pack.mjs';
|
|
26
|
+
|
|
27
|
+
export const VENDORED_SCHEMA = 'geoly.skills.vendored/1';
|
|
28
|
+
export const VENDORED_FILE = 'VENDORED.json';
|
|
29
|
+
export const VENDOR_INTENT_SCHEMA = 'geoly.skills.vendor-intent/1';
|
|
30
|
+
|
|
31
|
+
const bad = (code, msg) => { throw new PackError(code, msg); };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 🔴 **「整目录替换」在纯 Node 里做不成真原子的 —— 这里如实说明,不假装。**
|
|
35
|
+
*
|
|
36
|
+
* §6 说 vendor 与安装「同样的事务纪律」。安装靠的是 tx 目录 + journal + generation
|
|
37
|
+
* 水位;vendor 不走账本,所以只能自己带一份**最小意图文件**。
|
|
38
|
+
*
|
|
39
|
+
* 我们能给的承诺,逐条:
|
|
40
|
+
* ✅ `out` **永远不会是半新半旧的混合树** —— 新树在 staging 上建完整、验过摘要,
|
|
41
|
+
* 才开始换;换的过程只有两次 rename,没有逐文件覆盖。
|
|
42
|
+
* ❌ **不承诺**「任一时刻 `out` 都存在」。两次 rename 之间崩溃会留下 `out` 缺席。
|
|
43
|
+
* 这不是可以掩盖的窗口:`renameat2(RENAME_EXCHANGE)` 在 Node 里拿不到,
|
|
44
|
+
* 而「先删旧再换新」的空窗更长、「逐文件覆盖」直接产生混合树。
|
|
45
|
+
* ✅ 那个窗口**可恢复**:换之前先落 `.geoly-vendor-intent.json`(原子写 + fsync),
|
|
46
|
+
* 记下 staging / out / retired 三个路径;`recoverVendor(parent)` 按它把状态收敛到
|
|
47
|
+
* 「新树就位」或「旧树复原」,二选一,不留中间态。
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ 意图文件里没有摘要以外的秘密:它只是**指路**,恢复时仍然重新验树摘要。
|
|
50
|
+
*/
|
|
51
|
+
function intentPath(parent) { return join(parent, '.geoly-vendor-intent.json'); }
|
|
52
|
+
|
|
53
|
+
export const STAGING_PREFIX = '.geoly-vendor-';
|
|
54
|
+
export const RETIRED_PREFIX = '.geoly-vendor-old-';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 🔴 `existsSync` 是 fail-open 的:悬空 symlink、EACCES 都返回 false,
|
|
58
|
+
* 于是「看不见」被当成「不存在」。凡是要据此**删除或覆盖**的判断都必须用这个。
|
|
59
|
+
*/
|
|
60
|
+
function lstatOrNull(p) {
|
|
61
|
+
try { return lstatSync(p); } catch (e) {
|
|
62
|
+
if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR') return null;
|
|
63
|
+
throw e; // EACCES 之类:看不见就不能声称它不存在
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** §6 目前只定义了 `flat`。**别的值不给「合理默认」**,直接拒。 */
|
|
68
|
+
export const LAYOUTS = Object.freeze(['flat']);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `<out>/VENDORED.json` —— 供 CI 复核与后续重取(§6)。
|
|
72
|
+
*
|
|
73
|
+
* 🔴 记的是 **pack id、snapshot、每个成员的 id 与 `tree_digest`**。
|
|
74
|
+
* 不记 asset.sha256:那是**制品字节**的身份,而物化出来的是**目录树**,
|
|
75
|
+
* 树摘要才是能对着磁盘复算的那一个。记一个复算不了的值,CI 只能选择信任它。
|
|
76
|
+
*/
|
|
77
|
+
export function buildVendoredManifest({ packId, snapshot, layout, members, skipped = [] }) {
|
|
78
|
+
const a = parseArtifactId(packId, 'pack id');
|
|
79
|
+
if (a.kind !== 'pack') bad('E_VENDOR_INPUT', `vendor 的对象必须是 pack:,得到 ${packId}`);
|
|
80
|
+
if (!Number.isSafeInteger(snapshot) || snapshot < 0) bad('E_VENDOR_INPUT', 'snapshot 必须是非负整数');
|
|
81
|
+
if (!LAYOUTS.includes(layout)) bad('E_VENDOR_LAYOUT', `--layout 只支持 ${LAYOUTS.join(' / ')},得到 ${JSON.stringify(layout)}`);
|
|
82
|
+
return {
|
|
83
|
+
schema: VENDORED_SCHEMA,
|
|
84
|
+
pack: packId,
|
|
85
|
+
snapshot,
|
|
86
|
+
layout,
|
|
87
|
+
members: [...members]
|
|
88
|
+
.map(m => ({ dir: m.dir, id: m.id, role: m.role, tree_digest: m.tree_digest }))
|
|
89
|
+
.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0)),
|
|
90
|
+
skipped: [...skipped].sort(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 物化 pack + 成员到 `out`,**整目录替换**。
|
|
96
|
+
*
|
|
97
|
+
* 布局(`flat`,= 先例 vendored 目录的契约):
|
|
98
|
+
* ```
|
|
99
|
+
* <out>/VENDORED.json
|
|
100
|
+
* <out>/<成员 name>/… 每个成员的载荷
|
|
101
|
+
* <out>/<pack 自己的载荷文件> MATRIX.md / AGENTS.md / README.md / pack.json …
|
|
102
|
+
* ```
|
|
103
|
+
*
|
|
104
|
+
* 🔴 pack 自己的载荷放在 `<out>` 根上(08-matrix-migration.md §4:
|
|
105
|
+
* `MATRIX.md` / `AGENTS.md` / `README.md` / `CHANGELOG.md` 作为 pack 载荷的说明文档带进来)。
|
|
106
|
+
* 若某个 pack 载荷文件的**首段**与某个成员目录同名 → 拒绝,不做「谁覆盖谁」的默认。
|
|
107
|
+
*
|
|
108
|
+
* @param {object} a
|
|
109
|
+
* @param {{bytes:Buffer, record:object}} a.pack pack 制品本身
|
|
110
|
+
* @param {Array<{bytes:Buffer, record:object, role:string}>} a.members 已按 role 分好的成员
|
|
111
|
+
* @param {string} a.out 绝对路径。**整目录被替换**
|
|
112
|
+
* @param {number} a.snapshot 解析所用快照号
|
|
113
|
+
* @param {string} [a.layout]
|
|
114
|
+
* @returns {{out:string, tree_digest:string, members:Array, skipped:string[]}}
|
|
115
|
+
*/
|
|
116
|
+
export function materializeVendor({ pack, members, out, snapshot, layout = 'flat', skipped = [] }) {
|
|
117
|
+
if (!LAYOUTS.includes(layout)) bad('E_VENDOR_LAYOUT', `--layout 只支持 ${LAYOUTS.join(' / ')},得到 ${JSON.stringify(layout)}`);
|
|
118
|
+
if (typeof out !== 'string' || !isAbsolute(out)) bad('E_VENDOR_INPUT', `--out 必须是绝对路径,得到 ${JSON.stringify(out)}`);
|
|
119
|
+
const parent = dirname(out);
|
|
120
|
+
if (lstatOrNull(parent) === null) bad('E_VENDOR_INPUT', `--out 的父目录不存在:${parent}`);
|
|
121
|
+
// 🔴 out 的类型判定必须**排在最前面**,尤其排在任何会 stat(跟随)的检查之前。
|
|
122
|
+
// 悬空 symlink 上 `sameDevice()` 里的 statSync 会先抛 ENOENT,
|
|
123
|
+
// 于是报出来的是「文件不存在」而不是「这是个 symlink,拒绝」——
|
|
124
|
+
// 诊断指向完全错的方向,而 symlink 那道门其实根本没跑到。
|
|
125
|
+
const outSt = lstatOrNull(out);
|
|
126
|
+
if (outSt !== null) {
|
|
127
|
+
if (outSt.isSymbolicLink()) bad('E_VENDOR_TARGET', `${out} 是 symlink,拒绝替换(不跟随)`);
|
|
128
|
+
if (!outSt.isDirectory()) bad('E_VENDOR_TARGET', `${out} 不是目录`);
|
|
129
|
+
// 🔴 staging 必须与 out 同一个文件系统 —— rename 跨设备会失败(EXDEV),
|
|
130
|
+
// 而「先复制再删」不是原子的。所以 staging 建在 out 的**父目录**里,不在 /tmp。
|
|
131
|
+
if (!sameDevice(out, parent)) {
|
|
132
|
+
bad('E_VENDOR_XDEV', `${out} 与其父目录不在同一文件系统上,无法原子替换`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 早报:上一次没收尾就别开始新的。放在这里是为了**不做无用功** ——
|
|
137
|
+
// 把全部成员解包完再报「上一次没收尾」,白跑几秒还留一堆临时目录。
|
|
138
|
+
// ⚠️ 这是预检,不是保证(R-3);真正的判定在交换点前**再查一次**。
|
|
139
|
+
if (lstatOrNull(intentPath(parent)) !== null) {
|
|
140
|
+
bad('E_VENDOR_INTENT_PENDING',
|
|
141
|
+
`${intentPath(parent)} 还在:上一次 vendor 没收尾。先跑 recoverVendor(),不要覆盖它`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const staging = mkdtempSync(join(parent, STAGING_PREFIX));
|
|
145
|
+
let retired = null;
|
|
146
|
+
try {
|
|
147
|
+
const memberInfo = [];
|
|
148
|
+
const usedDirs = new Map();
|
|
149
|
+
|
|
150
|
+
for (const m of members) {
|
|
151
|
+
// 🔴 嵌套 pack 成员:`flat` 布局对它**没有定义** —— 是把它自己的成员也摊到
|
|
152
|
+
// 同一层(可能与外层成员撞名),还是给它一层目录(那就不是 flat 了)?
|
|
153
|
+
// 规范没写,所以拒绝,不给「合理默认」。
|
|
154
|
+
if (m.record.kind === 'pack') {
|
|
155
|
+
bad('E_VENDOR_NESTED_PACK',
|
|
156
|
+
`成员 ${m.record.id} 是 pack。§6 的 flat 布局没有定义嵌套 pack 的物化方式,拒绝`);
|
|
157
|
+
}
|
|
158
|
+
if (m.record.kind !== 'skill') bad('E_VENDOR_INPUT', `成员 ${m.record.id} 的 kind 不合法`);
|
|
159
|
+
// 目录名 = name(四端 skills 目录是平铺的,vendored 目录沿用同一契约)
|
|
160
|
+
const dir = m.record.name;
|
|
161
|
+
if (usedDirs.has(dir)) {
|
|
162
|
+
bad('E_VENDOR_DIR_COLLIDE', `两个成员都要物化到 ${dir}/:${usedDirs.get(dir)} 与 ${m.record.id}`);
|
|
163
|
+
}
|
|
164
|
+
usedDirs.set(dir, m.record.id);
|
|
165
|
+
// 🔴 验签由调用方在拿 record 时做;这里做的是**验摘要 + 隔离解包**,
|
|
166
|
+
// 并且用 withVerifiedArtifact —— 它结构上不可能忘记清理临时目录。
|
|
167
|
+
withVerifiedArtifact({ bytes: m.bytes, record: m.record }, (art) => {
|
|
168
|
+
const prefixed = art.entries.map(e => ({ ...e, path: `${dir}/${e.path}` }));
|
|
169
|
+
writeEntries(staging, prefixed);
|
|
170
|
+
memberInfo.push({ dir, id: m.record.id, role: m.role, tree_digest: art.treeDigest });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 🔴 大小写折叠冲突:macOS 上 `Foo/` 与 `foo/` 是同一个目录。
|
|
175
|
+
const fold = new Map();
|
|
176
|
+
for (const [d, id] of usedDirs) {
|
|
177
|
+
const f = d.toLowerCase();
|
|
178
|
+
if (fold.has(f)) bad('E_VENDOR_DIR_COLLIDE', `成员目录 ${d} 与 ${fold.get(f)} 大小写折叠后相同(macOS 上会互相覆盖)`);
|
|
179
|
+
fold.set(f, d);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// pack 自己的载荷落在根上
|
|
183
|
+
let packManifest = null;
|
|
184
|
+
withVerifiedArtifact({ bytes: pack.bytes, record: pack.record }, (art) => {
|
|
185
|
+
packManifest = validatePackManifest(JSON.parse(
|
|
186
|
+
art.entries.find(e => e.path === 'pack.json').data.toString('utf8')));
|
|
187
|
+
for (const e of art.entries) {
|
|
188
|
+
const head = e.path.split('/')[0];
|
|
189
|
+
if (usedDirs.has(head)) {
|
|
190
|
+
bad('E_VENDOR_DIR_COLLIDE',
|
|
191
|
+
`pack 载荷里的 ${e.path} 与成员目录 ${head}/ 冲突(§6 的 flat 布局把两者放在同一层)`);
|
|
192
|
+
}
|
|
193
|
+
if (e.path === VENDORED_FILE) {
|
|
194
|
+
bad('E_VENDOR_DIR_COLLIDE', `pack 载荷里出现 ${VENDORED_FILE},会与物化器自己写的那份冲突`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
writeEntries(staging, art.entries);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// 🔴 **交上来的成员必须正好是 pack.json 锁定的那一组。**
|
|
201
|
+
// 不查的话,调用方可以少给一个(物化出一棵缺东西的树,而 VENDORED.json 照样
|
|
202
|
+
// 自洽)或多给一个(往 vendored 目录里塞不属于这个 pack 的 skill)——
|
|
203
|
+
// pack 是引用,物化器是它唯一一次把引用兑现成字节的地方,兑现得对不对
|
|
204
|
+
// 只有在这里能判。判据是**摘要也要对上**,不只是 id 对上。
|
|
205
|
+
const declared = new Map([...packManifest.members, ...packManifest.bundled]
|
|
206
|
+
.map(m => [m.id, m.tree_digest]));
|
|
207
|
+
const given = new Set(memberInfo.map(m => m.id));
|
|
208
|
+
const skippedSet = new Set(skipped);
|
|
209
|
+
for (const [id, td] of declared) {
|
|
210
|
+
if (given.has(id)) {
|
|
211
|
+
const got = memberInfo.find(m => m.id === id).tree_digest;
|
|
212
|
+
if (got !== td) {
|
|
213
|
+
bad('E_VENDOR_MEMBER_DIGEST',
|
|
214
|
+
`完整性事件:成员 ${id} 物化出来的树摘要是 ${got},pack.json 锁的是 ${td}`);
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!skippedSet.has(id)) {
|
|
219
|
+
bad('E_VENDOR_MEMBER_MISSING', `pack 锁定了成员 ${id},但没有交上来,也没有列进 skipped`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const id of given) {
|
|
223
|
+
if (!declared.has(id)) bad('E_VENDOR_MEMBER_EXTRA', `交上来的 ${id} 不是 ${packManifest.id} 的成员`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const manifest = buildVendoredManifest({
|
|
227
|
+
packId: pack.record.id, snapshot, layout, members: memberInfo, skipped,
|
|
228
|
+
});
|
|
229
|
+
writeEntries(staging, [{
|
|
230
|
+
path: VENDORED_FILE, mode: 0o644, data: Buffer.from(stringify(manifest), 'utf8'),
|
|
231
|
+
}]);
|
|
232
|
+
fsyncDir(staging);
|
|
233
|
+
|
|
234
|
+
const digest = treeDigest(staging);
|
|
235
|
+
|
|
236
|
+
// ── 整目录替换(带恢复意图,见文件头的承诺清单) ──────────────────────
|
|
237
|
+
// 🔴 判「out 在不在」不能用 existsSync:它对**悬空 symlink** 返回 false
|
|
238
|
+
// (fail-open)。于是 `ln -s /does/not/exist out` 会被当成「目标不存在」,
|
|
239
|
+
// 直接被 rename 覆盖掉。判据必须是 lstat 的 errno。
|
|
240
|
+
// ⚠️ 这里是**复验**:函数入口已经判过一次类型,但那是几百毫秒之前的快照
|
|
241
|
+
// (中间跑完了全部成员的解包)。同 R-3:预检不保证世界不会变,
|
|
242
|
+
// 真正的动作点必须自己复验它依赖的那几项。
|
|
243
|
+
const nowSt = lstatOrNull(out);
|
|
244
|
+
if (nowSt !== null) {
|
|
245
|
+
if (nowSt.isSymbolicLink()) bad('E_VENDOR_TARGET', `${out} 在物化期间被换成了 symlink,拒绝`);
|
|
246
|
+
if (!nowSt.isDirectory()) bad('E_VENDOR_TARGET', `${out} 在物化期间被换成了非目录,拒绝`);
|
|
247
|
+
}
|
|
248
|
+
const willRetire = nowSt !== null;
|
|
249
|
+
const intent = intentPath(parent);
|
|
250
|
+
const retiredPath = join(parent, `${RETIRED_PREFIX}${basename(staging)}`);
|
|
251
|
+
if (lstatOrNull(intent) !== null) {
|
|
252
|
+
bad('E_VENDOR_INTENT_PENDING',
|
|
253
|
+
`${intent} 还在:上一次 vendor 没收尾。先跑 recoverVendor(),不要覆盖它`);
|
|
254
|
+
}
|
|
255
|
+
writeAtomic(intent, Buffer.from(stringify({
|
|
256
|
+
schema: VENDOR_INTENT_SCHEMA,
|
|
257
|
+
out, staging, retired: willRetire ? retiredPath : null, tree_digest: digest,
|
|
258
|
+
}), 'utf8'));
|
|
259
|
+
fsyncDir(parent);
|
|
260
|
+
|
|
261
|
+
if (willRetire) { renameDirFsync(out, retiredPath); retired = retiredPath; }
|
|
262
|
+
renameDirFsync(staging, out);
|
|
263
|
+
// 🔴 **先删 retired,最后才删 intent。** 反过来(先删 intent 再删 retired)
|
|
264
|
+
// 会多出一个无人认领的状态:intent 已经没了,retired 还在,
|
|
265
|
+
// recoverVendor 返回 none,那棵旧树变成永远回收不掉的 orphan(Codex 第二轮 #1)。
|
|
266
|
+
// intent 是「还没收尾」的唯一标记,所以它必须是**最后**一个消失的东西。
|
|
267
|
+
if (retired !== null) { rmtreeFsync(retired); retired = null; }
|
|
268
|
+
rmSync(intent, { force: true });
|
|
269
|
+
fsyncDir(parent);
|
|
270
|
+
|
|
271
|
+
return { out, tree_digest: digest, pack: packManifest.id, members: memberInfo, skipped };
|
|
272
|
+
} finally {
|
|
273
|
+
// staging 若还在(中途失败),清掉
|
|
274
|
+
try { if (lstatOrNull(staging) !== null) rmtreeFsync(staging); } catch { /* 尽力而为 */ }
|
|
275
|
+
// 🔴 **retired 只放回去,绝不在这里删。**
|
|
276
|
+
// Codex 第二轮给的反例:① 第一次 rename 把旧 out 移到 retired;
|
|
277
|
+
// ② 外部进程抢先建了一个新的 out;③ 第二次 rename 失败;
|
|
278
|
+
// ④ 旧版 finally 看到 out 存在,就把 retired 删了 —— 结果是**外部那个目录留下、
|
|
279
|
+
// 真正的旧树被删掉**,而且 intent 也一并没了,人工都恢复不了。
|
|
280
|
+
// 补偿动作比不补偿更糟,这是最坏的一种。
|
|
281
|
+
// 现在的规矩:out 缺席才放回去;否则原样留着 retired 与 intent,交给
|
|
282
|
+
// recoverVendor / 人工判断。**留下证据永远好过替人做决定。**
|
|
283
|
+
if (retired !== null) {
|
|
284
|
+
try {
|
|
285
|
+
if (lstatOrNull(out) === null) {
|
|
286
|
+
renameDirFsync(retired, out);
|
|
287
|
+
rmSync(intentPath(parent), { force: true });
|
|
288
|
+
fsyncDir(parent);
|
|
289
|
+
}
|
|
290
|
+
} catch { /* 尽力而为;剩下的交给 recoverVendor */ }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 🔴 意图文件是**磁盘上的普通文件**,谁都能改它 —— 而 recover 会照着它
|
|
297
|
+
* **删目录**。所以它必须像任何 wire 输入一样被严格校验,不能只看 schema。
|
|
298
|
+
*
|
|
299
|
+
* Codex 第二轮给的恶意样例:把 staging / retired 指向 `/tmp/delete-me`,
|
|
300
|
+
* recover 就会替攻击者删掉那两个目录。
|
|
301
|
+
*
|
|
302
|
+
* 约束(缺一不可):
|
|
303
|
+
* · 三个路径都是绝对路径;
|
|
304
|
+
* · `staging` / `retired` **必须就在 `parent` 下**,且带我们自己的前缀 ——
|
|
305
|
+
* recover 只允许删自己建的东西;
|
|
306
|
+
* · `out` 也必须在 `parent` 下(materializeVendor 的 staging 就建在 out 的父目录里);
|
|
307
|
+
* · `retired` 要么是 null,要么是字符串(缺字段会让 existsSync(undefined) 抛 TypeError);
|
|
308
|
+
* · `tree_digest` 形状正确。
|
|
309
|
+
*/
|
|
310
|
+
function assertIntentShape(doc, parent, where) {
|
|
311
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) bad('E_VENDOR_INTENT', `${where} 不是对象`);
|
|
312
|
+
if (doc.schema !== VENDOR_INTENT_SCHEMA) bad('E_VENDOR_INTENT', `${where} 的 schema 不认识:${JSON.stringify(doc.schema)}`);
|
|
313
|
+
const keys = Object.keys(doc).sort().join(',');
|
|
314
|
+
if (keys !== 'out,retired,schema,staging,tree_digest') bad('E_VENDOR_INTENT', `${where} 的键集不对:${keys}`);
|
|
315
|
+
const { out, staging, retired, tree_digest } = doc;
|
|
316
|
+
for (const [k, v] of [['out', out], ['staging', staging]]) {
|
|
317
|
+
if (typeof v !== 'string' || !isAbsolute(v)) bad('E_VENDOR_INTENT', `${where}.${k} 必须是绝对路径`);
|
|
318
|
+
if (dirname(v) !== parent) bad('E_VENDOR_INTENT', `${where}.${k} 不在 ${parent} 下:${v}`);
|
|
319
|
+
}
|
|
320
|
+
if (!basename(staging).startsWith(STAGING_PREFIX)) {
|
|
321
|
+
bad('E_VENDOR_INTENT', `${where}.staging 不带 ${STAGING_PREFIX} 前缀 —— recover 只删自己建的目录:${staging}`);
|
|
322
|
+
}
|
|
323
|
+
if (retired !== null) {
|
|
324
|
+
if (typeof retired !== 'string' || !isAbsolute(retired)) bad('E_VENDOR_INTENT', `${where}.retired 必须是 null 或绝对路径`);
|
|
325
|
+
if (dirname(retired) !== parent) bad('E_VENDOR_INTENT', `${where}.retired 不在 ${parent} 下:${retired}`);
|
|
326
|
+
if (!basename(retired).startsWith(RETIRED_PREFIX)) {
|
|
327
|
+
bad('E_VENDOR_INTENT', `${where}.retired 不带 ${RETIRED_PREFIX} 前缀:${retired}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (typeof tree_digest !== 'string' || !/^geoly-tree-v1:sha256:[0-9a-f]{64}$/.test(tree_digest)) {
|
|
331
|
+
bad('E_VENDOR_INTENT', `${where}.tree_digest 形状不对:${JSON.stringify(tree_digest)}`);
|
|
332
|
+
}
|
|
333
|
+
return { out, staging, retired, tree_digest };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 把一次被打断的 `materializeVendor()` 收敛到二选一的终态。
|
|
338
|
+
*
|
|
339
|
+
* 逐崩溃点(意图文件在,说明换到一半):
|
|
340
|
+
* · `staging` 还在、`out` 也在 → 换还没开始。删 staging,保留 out(**回到旧树**)。
|
|
341
|
+
* · `staging` 还在、`out` 不在 → 第一次 rename 之后崩的(旧树在 retired)。
|
|
342
|
+
* 继续做第二次 rename(**前滚到新树**),再删 retired。
|
|
343
|
+
* · `staging` 不在、`out` 在 → 两次 rename 都做完了,只是没删 intent / retired。删掉即可。
|
|
344
|
+
* · `staging` 不在、`out` 不在 → 只可能是 retired 还在(新树没建成)。把 retired 换回来。
|
|
345
|
+
*
|
|
346
|
+
* 🔴 前滚之前**重新算一次树摘要并与意图里那份比对** —— 意图文件只指路,不作数。
|
|
347
|
+
*/
|
|
348
|
+
export function recoverVendor(parent) {
|
|
349
|
+
const intent = intentPath(parent);
|
|
350
|
+
if (lstatOrNull(intent) === null) return { action: 'none' };
|
|
351
|
+
let doc;
|
|
352
|
+
try {
|
|
353
|
+
doc = parseStrict(readFileSync(intent, 'utf8'));
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// 截断 / 非法 JSON:**不删它**。删掉等于把「有一次没收尾」这个事实也抹掉。
|
|
356
|
+
bad('E_VENDOR_INTENT', `${intent} 解析失败(截断或被改坏),需人工处置:${e.message}`);
|
|
357
|
+
}
|
|
358
|
+
const { out, staging, retired, tree_digest } = assertIntentShape(doc, parent, intent);
|
|
359
|
+
|
|
360
|
+
const stStaging = lstatOrNull(staging);
|
|
361
|
+
const stOut = lstatOrNull(out);
|
|
362
|
+
const stRetired = retired === null ? null : lstatOrNull(retired);
|
|
363
|
+
// 🔴 三个路径都不许是 symlink:recover 会**删除**或 **rename** 它们,
|
|
364
|
+
// 跟随一次就等于把删除动作转嫁到别人的目录上(Codex 第二轮 #1)。
|
|
365
|
+
for (const [label, st, p] of [['staging', stStaging, staging], ['out', stOut, out], ['retired', stRetired, retired]]) {
|
|
366
|
+
if (st !== null && st.isSymbolicLink()) bad('E_VENDOR_RECOVER', `${label} (${p}) 是 symlink,拒绝在恢复流程里碰它`);
|
|
367
|
+
if (st !== null && !st.isDirectory()) bad('E_VENDOR_RECOVER', `${label} (${p}) 不是目录,拒绝`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let action;
|
|
371
|
+
if (stStaging !== null && stOut !== null) {
|
|
372
|
+
rmtreeFsync(staging);
|
|
373
|
+
action = 'rolled-back';
|
|
374
|
+
} else if (stStaging !== null) {
|
|
375
|
+
const got = treeDigest(staging);
|
|
376
|
+
if (got !== tree_digest) {
|
|
377
|
+
bad('E_VENDOR_RECOVER', `staging 的树摘要是 ${got},意图里记的是 ${tree_digest} —— 拒绝前滚`);
|
|
378
|
+
}
|
|
379
|
+
renameDirFsync(staging, out);
|
|
380
|
+
action = 'rolled-forward';
|
|
381
|
+
} else if (stOut !== null) {
|
|
382
|
+
action = 'already-done';
|
|
383
|
+
} else if (stRetired !== null) {
|
|
384
|
+
renameDirFsync(retired, out);
|
|
385
|
+
action = 'restored-old';
|
|
386
|
+
} else {
|
|
387
|
+
bad('E_VENDOR_RECOVER', `既没有 staging、也没有 ${out}、也没有 retired —— 状态无法收敛,需人工处置`);
|
|
388
|
+
}
|
|
389
|
+
if (retired !== null && lstatOrNull(retired) !== null && lstatOrNull(out) !== null) rmtreeFsync(retired);
|
|
390
|
+
rmSync(intent, { force: true });
|
|
391
|
+
fsyncDir(parent);
|
|
392
|
+
return { action, out };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── 05-lifecycle.md §6.1 / 08-matrix-migration.md §3.1 的双摘要 ─────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `scripts/verify-vendored.mjs` 那道 CI 门的**判定核心**(脚本面由别人接线)。
|
|
399
|
+
*
|
|
400
|
+
* 校验:hub 制品的载荷**去掉 `added_files` 白名单里的文件之后**,逐字节等于上游那棵树,
|
|
401
|
+
* 且上游那棵树的摘要 == `provenance.origin_tree_digest`。
|
|
402
|
+
* `added_files` 只允许 `["skill.json"]`;**任何其他新增、任何修改、任何删除都让门失败**。
|
|
403
|
+
*
|
|
404
|
+
* 🔴 判据是**逐字节**(path + mode + 内容),不是「文件都在」——
|
|
405
|
+
* 「文件在不在」从来不是判据。
|
|
406
|
+
*
|
|
407
|
+
* ⚠️ **书写形式的冲突,本函数不替调用方吞掉**:
|
|
408
|
+
* §6.1 规定摘要值自带算法前缀(`geoly-tree-v1:sha256:…`),而 `snapshot.mjs`
|
|
409
|
+
* 对 `provenance.origin_tree_digest` 用的是 `assertAssetDigest`,只接受 `sha256:…`。
|
|
410
|
+
* 两者不兼容。本函数**要求传进来的期望值带 `geoly-tree-v1:` 前缀**(§6.1 的形式),
|
|
411
|
+
* 不做静默截断 —— 静默转换正是 E-3 要消灭的「一个逻辑值多种书写」。
|
|
412
|
+
*
|
|
413
|
+
* @param {object} a
|
|
414
|
+
* @param {string} a.hubPayloadDir hub 制品解出来的载荷目录
|
|
415
|
+
* @param {string} a.originDir 上游 origin_commit 下 origin_subpath 的原始文件
|
|
416
|
+
* @param {string[]} a.addedFiles provenance.added_files
|
|
417
|
+
* @param {string} a.expectedOriginTreeDigest
|
|
418
|
+
* @param {string[]} [a.allowedAddedFiles] 白名单,默认 ['skill.json']
|
|
419
|
+
*/
|
|
420
|
+
export function verifyVendoredPayload({
|
|
421
|
+
hubPayloadDir, originDir, addedFiles, expectedOriginTreeDigest,
|
|
422
|
+
allowedAddedFiles = ['skill.json'],
|
|
423
|
+
}) {
|
|
424
|
+
if (!Array.isArray(addedFiles)) bad('E_VENDORED_ADDED', 'provenance.added_files 必填且必须是数组(ERRATA E-1)');
|
|
425
|
+
const allow = new Set(allowedAddedFiles);
|
|
426
|
+
for (const f of addedFiles) {
|
|
427
|
+
if (!allow.has(f)) {
|
|
428
|
+
bad('E_VENDORED_ADDED',
|
|
429
|
+
`added_files 里的 ${JSON.stringify(f)} 不在白名单 [${[...allow].join(', ')}] 内`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (new Set(addedFiles).size !== addedFiles.length) bad('E_VENDORED_ADDED', 'added_files 有重复项');
|
|
433
|
+
|
|
434
|
+
const hub = new Map(collectTree(hubPayloadDir).map(e => [e.path, e]));
|
|
435
|
+
const origin = new Map(collectTree(originDir).map(e => [e.path, e]));
|
|
436
|
+
|
|
437
|
+
const added = new Set(addedFiles);
|
|
438
|
+
// 🔴 声明为新增的文件必须**真的是新增**:上游已有同名文件时,把它列进 added_files
|
|
439
|
+
// 就等于用白名单掩盖一次修改。
|
|
440
|
+
for (const f of added) {
|
|
441
|
+
if (!hub.has(f)) bad('E_VENDORED_ADDED', `added_files 声明了 ${f},但 hub 载荷里没有它`);
|
|
442
|
+
if (origin.has(f)) bad('E_VENDORED_ADDED', `added_files 声明 ${f} 是新增,但上游已经有这个文件(那是修改,不是新增)`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const extra = [...hub.keys()].filter(p => !added.has(p) && !origin.has(p)).sort();
|
|
446
|
+
if (extra.length) bad('E_VENDORED_EXTRA', `hub 载荷多出未声明的文件:${extra.join(', ')}`);
|
|
447
|
+
const missing = [...origin.keys()].filter(p => !hub.has(p)).sort();
|
|
448
|
+
if (missing.length) bad('E_VENDORED_MISSING', `hub 载荷缺少上游的文件(不允许删除):${missing.join(', ')}`);
|
|
449
|
+
|
|
450
|
+
const modified = [];
|
|
451
|
+
for (const [p, o] of origin) {
|
|
452
|
+
const h = hub.get(p);
|
|
453
|
+
if (h.mode !== o.mode || !h.data.equals(o.data)) modified.push(p);
|
|
454
|
+
}
|
|
455
|
+
if (modified.length) bad('E_VENDORED_MODIFIED', `hub 载荷与上游逐字节不符:${modified.sort().join(', ')}`);
|
|
456
|
+
|
|
457
|
+
const got = treeDigest(originDir);
|
|
458
|
+
if (got !== expectedOriginTreeDigest) {
|
|
459
|
+
bad('E_VENDORED_ORIGIN_DIGEST',
|
|
460
|
+
`上游树摘要是 ${got},provenance.origin_tree_digest 说是 ${expectedOriginTreeDigest}`);
|
|
461
|
+
}
|
|
462
|
+
return { origin_tree_digest: got, added_files: [...added].sort(), files: origin.size };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export { PackError };
|