Skip to content

Commit bd17c27

Browse files
committed
feat: index.json protocol adapter
1 parent 87c3799 commit bd17c27

9 files changed

Lines changed: 134 additions & 31 deletions

File tree

packages/cli/postinstall.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* package and overwrites the local directory, ensuring data is in place the first time the user runs
66
* `bl advisor recommend`.
77
*
8-
* Flow (unified skill publishing protocol: skills/index.json + one skill.tar.br per skill):
8+
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill):
99
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
10-
* 2. Download skills/bailian-docs-llm-wiki/skill.tar.br (brotli q6, ~2.3MB)
10+
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
11+
* legacy fallback to skill.tar.br when the entry has no valid object field
1112
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
1213
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
1314
* 5. Write ~/.bailian/wiki-sync-state.json
@@ -41,7 +42,10 @@ const CONFIG_DIR_NAME = ".bailian";
4142
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
4243
const STATE_FILE_NAME = "wiki-sync-state.json";
4344
const INDEX_KEY = "index.json";
44-
const ASSET_NAME = "skill.tar.br";
45+
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
46+
const LEGACY_ASSET_NAME = "skill.tar.br";
47+
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
48+
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
4549

4650
const INDEX_TIMEOUT_MS = 3000;
4751
const DOWNLOAD_TIMEOUT_MS = 30000;
@@ -153,8 +157,10 @@ async function main() {
153157
if (!entry?.contentHash)
154158
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
155159

156-
// 2. Download skill.tar.br (per-entry path safety check during extraction)
157-
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${ASSET_NAME}`);
160+
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
161+
const assetName =
162+
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
163+
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
158164

159165
// 3. Extract to same-volume temp dir + atomic swap
160166
const catalogDir = getCatalogDir();

packages/core/src/advisor/sync.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
* upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly)
1212
*
1313
* Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is
14-
* skills/index.json, one skill.tar.br per skill (brotli q6).
14+
* skills/index.json, one content-addressed object per skill (sha256-<hex>.tar.br, brotli q6;
15+
* legacy fallback skill.tar.br).
1516
*
1617
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction,
1718
* validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric
@@ -45,7 +46,6 @@ interface SyncState {
4546
}
4647

4748
interface SkillsIndex {
48-
version: number;
4949
updatedAt?: string;
5050
skills: Record<string, SkillIndexEntry>;
5151
}
@@ -121,7 +121,7 @@ async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
121121
});
122122
if (!res.ok) return null;
123123
const index = (await res.json()) as SkillsIndex;
124-
if (typeof index?.version !== "number" || !index.skills) return null;
124+
if (!index?.skills || typeof index.skills !== "object") return null;
125125
return index.skills[WIKI_SKILL_NAME] ?? null;
126126
} catch {
127127
return null;

packages/core/src/skills/extract.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
* Symmetric with the publisher (FC skills-publish.mjs: tar.pack + brotli); uses only Node built-in
44
* zlib + tar-stream, no extra decompression dependencies.
55
*/
6-
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
6+
import {
7+
createWriteStream,
8+
existsSync,
9+
mkdirSync,
10+
readdirSync,
11+
readFileSync,
12+
renameSync,
13+
rmSync,
14+
} from "node:fs";
15+
import { createHash } from "node:crypto";
716
import { dirname, join } from "node:path";
817
import { Readable } from "node:stream";
918
import { pipeline } from "node:stream/promises";
@@ -46,6 +55,31 @@ export async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promis
4655
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
4756
}
4857

58+
/**
59+
* Recompute the publisher's deterministic content hash over an extracted directory:
60+
* regular files sorted by "/"-separated relative path (code-unit order, same as the
61+
* publisher's byte-order sort for ASCII paths), sha256 accumulating relPath + bytes.
62+
* Symmetric with computeContentHash in FC skills-publish.mjs.
63+
*/
64+
export function computeDirContentHash(dir: string): string {
65+
const relPaths: string[] = [];
66+
const walk = (sub: string): void => {
67+
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
68+
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
69+
if (dirent.isDirectory()) walk(rel);
70+
else if (dirent.isFile()) relPaths.push(rel);
71+
}
72+
};
73+
walk("");
74+
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
75+
const hash = createHash("sha256");
76+
for (const rel of relPaths) {
77+
hash.update(rel);
78+
hash.update(readFileSync(join(dir, rel)));
79+
}
80+
return `sha256:${hash.digest("hex")}`;
81+
}
82+
4983
/**
5084
* Atomic swap: replace destDir with the extracted content from tmpDir.
5185
* tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic.

packages/core/src/skills/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ export type {
77
SkillStatus,
88
SkillStatusRow,
99
} from "./types.ts";
10-
export { getSkillRegistryBaseUrl, fetchSkillsIndex, downloadSkillAsset } from "./registry.ts";
10+
export {
11+
getSkillRegistryBaseUrl,
12+
fetchSkillsIndex,
13+
downloadSkillAsset,
14+
resolveAssetFileName,
15+
} from "./registry.ts";
1116
export {
1217
getSkillsDir,
1318
getSkillLockPath,
@@ -18,7 +23,7 @@ export {
1823
} from "./lock.ts";
1924
export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts";
2025
export { validateSkillDir, type SkillMeta } from "./validate.ts";
21-
export { extractTarBr, atomicSwap, isSafeEntryName } from "./extract.ts";
26+
export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts";
2227
export {
2328
getAgentTargets,
2429
detectInstalledAgents,

packages/core/src/skills/installer.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
22
import { join } from "node:path";
33
import { BailianError } from "../errors/base.ts";
44
import { ExitCode } from "../errors/codes.ts";
5-
import { atomicSwap, extractTarBr } from "./extract.ts";
5+
import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts";
66
import { getSkillsDir } from "./lock.ts";
77
import { downloadSkillAsset } from "./registry.ts";
88
import { isSafeSkillName } from "./sanitize.ts";
@@ -34,6 +34,7 @@ function assertSafeName(name: string): void {
3434
export async function installSkillFromBuffer(
3535
name: string,
3636
tarBrBuffer: Buffer,
37+
expectedContentHash?: string,
3738
): Promise<InstalledSkill> {
3839
assertSafeName(name);
3940
const skillsDir = getSkillsDir();
@@ -43,6 +44,18 @@ export async function installSkillFromBuffer(
4344
try {
4445
mkdirSync(tmpDir, { recursive: true });
4546
await extractTarBr(tarBrBuffer, tmpDir);
47+
// Integrity check before touching canonical: recompute the publisher fingerprint over
48+
// the extracted files; on mismatch the current installation is left untouched
49+
if (expectedContentHash?.startsWith("sha256:")) {
50+
const actualContentHash = computeDirContentHash(tmpDir);
51+
if (actualContentHash !== expectedContentHash) {
52+
throw new BailianError(
53+
`Skill ${name} failed integrity check: index says ${expectedContentHash}, archive is ${actualContentHash}`,
54+
ExitCode.GENERAL,
55+
"Downloaded archive does not match the index fingerprint (registry may be mid-publish); retry later",
56+
);
57+
}
58+
}
4659
const meta = validateSkillDir(tmpDir, name);
4760
atomicSwap(tmpDir, dest);
4861
return { name, path: dest, meta };
@@ -60,8 +73,8 @@ export async function installSkill(name: string, entry: SkillIndexEntry): Promis
6073
"Upgrade bailian-cli to the latest version and retry",
6174
);
6275
}
63-
const buffer = await downloadSkillAsset(name);
64-
return installSkillFromBuffer(name, buffer);
76+
const buffer = await downloadSkillAsset(name, entry);
77+
return installSkillFromBuffer(name, buffer, entry.contentHash);
6578
}
6679

6780
/** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */

packages/core/src/skills/registry.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import { BailianError } from "../errors/base.ts";
22
import { ExitCode } from "../errors/codes.ts";
3-
import type { SkillsIndex } from "./types.ts";
3+
import type { SkillIndexEntry, SkillsIndex } from "./types.ts";
44

55
/**
66
* Skill registry client: public-read OSS, pure HTTPS GET, zero credentials (usable with auth: "none").
77
* Defaults to the skills/ prefix of the bailian-wiki bucket; override with BAILIAN_SKILL_REGISTRY_URL
88
* for canary/private mirror scenarios.
99
*/
1010
const DEFAULT_REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
11-
/** index.json protocol version supported by this client */
12-
const SUPPORTED_INDEX_VERSION = 1;
1311

1412
const INDEX_TIMEOUT_MS = 10_000;
1513
const ASSET_TIMEOUT_MS = 120_000;
@@ -69,19 +67,24 @@ export async function fetchSkillsIndex(): Promise<SkillsIndex> {
6967
"Retry later or contact the publisher",
7068
);
7169
}
72-
if (index.version !== SUPPORTED_INDEX_VERSION) {
73-
throw new BailianError(
74-
`Skill index protocol version ${index.version} is not supported by this CLI`,
75-
ExitCode.GENERAL,
76-
"Upgrade bailian-cli to the latest version and retry",
77-
);
78-
}
7970
return index;
8071
}
8172

73+
/**
74+
* Strict shape check for entry.object (defense against a hostile/corrupted index —
75+
* anything not matching falls back to the legacy fixed key, never into the URL path).
76+
*/
77+
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
78+
79+
/** Resolve which file to download for a skill: content-addressed object, else legacy fixed key */
80+
export function resolveAssetFileName(entry?: SkillIndexEntry): string {
81+
const object = entry?.object;
82+
return object && OBJECT_FILE_RE.test(object) ? object : "skill.tar.br";
83+
}
84+
8285
/** Download the tar.br archive for a single skill (one skill = one GET) */
83-
export async function downloadSkillAsset(name: string): Promise<Buffer> {
84-
const url = `${getSkillRegistryBaseUrl()}/${name}/skill.tar.br`;
86+
export async function downloadSkillAsset(name: string, entry?: SkillIndexEntry): Promise<Buffer> {
87+
const url = `${getSkillRegistryBaseUrl()}/${name}/${resolveAssetFileName(entry)}`;
8588
let res: Response;
8689
try {
8790
res = await fetch(url, { signal: AbortSignal.timeout(ASSET_TIMEOUT_MS) });

packages/core/src/skills/types.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
* Data structures for the unified skill publishing protocol (symmetric with FC publisher skills-publish.mjs).
33
*
44
* Remote layout (public-read OSS, the sole data source for `bl skill`):
5-
* <registry>/index.json — skill catalog (SkillsIndex)
6-
* <registry>/<name>/skill.tar.br — one object per skill (tar + brotli, atomic publish)
5+
* <registry>/index.json — skill catalog (SkillsIndex)
6+
* <registry>/<name>/sha256-<hex>.tar.br — content-addressed skill object (tar + brotli);
7+
* entry.object names the exact file, so index.json is the single atomic commit point.
8+
* Legacy fallback: <registry>/<name>/skill.tar.br (entries without object)
79
*
810
* Local layout:
911
* ~/.bailian/skills/<name>/ — canonical install directory
@@ -22,11 +24,14 @@ export interface SkillIndexEntry {
2224
contentHash?: string;
2325
/** Compression format identifier, currently always "tar.br" */
2426
compression?: string;
27+
/**
28+
* Content-addressed object file name under <registry>/<name>/, e.g. "sha256-<hex>.tar.br".
29+
* Absent on legacy entries — client falls back to the fixed key "skill.tar.br".
30+
*/
31+
object?: string;
2532
}
2633

2734
export interface SkillsIndex {
28-
/** Protocol schema version, currently 1; client should error and prompt upgrade on unrecognized versions */
29-
version: number;
3035
updatedAt?: string;
3136
/** key = skill name (i.e. OSS directory name, download path, local install dir name) */
3237
skills: Record<string, SkillIndexEntry>;

packages/core/tests/skills-installer.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
2+
import { createHash } from "crypto";
23
import { tmpdir } from "os";
34
import { join } from "path";
45
import { brotliCompressSync } from "zlib";
@@ -98,3 +99,40 @@ test("installer: invalid skill name rejected outright", async () => {
9899
await expect(installSkillFromBuffer("../escape", buf)).rejects.toThrow(/Invalid skill name/);
99100
});
100101
});
102+
103+
/** Same accumulation as publisher computeContentHash: sorted rel path + bytes */
104+
function expectedHashOf(files: Record<string, string>): string {
105+
const hash = createHash("sha256");
106+
for (const rel of Object.keys(files).sort()) {
107+
hash.update(rel);
108+
hash.update(Buffer.from(files[rel]));
109+
}
110+
return `sha256:${hash.digest("hex")}`;
111+
}
112+
113+
test("installer: matching contentHash passes integrity check", async () => {
114+
await inTempConfigDir(async () => {
115+
const files = { "SKILL.md": VALID_SKILL_MD, "references/usage.md": "# usage\n" };
116+
const installed = await installSkillFromBuffer(
117+
"demo",
118+
await buildTarBr(files),
119+
expectedHashOf(files),
120+
);
121+
expect(installed.name).toBe("demo");
122+
expect(existsSync(join(getSkillsDir(), "demo", "SKILL.md"))).toBe(true);
123+
});
124+
});
125+
126+
test("installer: contentHash mismatch → rejected, previous install preserved", async () => {
127+
await inTempConfigDir(async () => {
128+
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD }));
129+
const tampered = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "extra.md": "tampered\n" });
130+
await expect(
131+
installSkillFromBuffer("demo", tampered, expectedHashOf({ "SKILL.md": VALID_SKILL_MD })),
132+
).rejects.toThrow(/integrity check/);
133+
// Old version untouched, temp dir cleaned up
134+
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
135+
expect(existsSync(join(getSkillsDir(), "demo", "extra.md"))).toBe(false);
136+
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
137+
});
138+
});

packages/core/tests/skills-status.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const PUB = "2026-07-23T00:00:00+08:00";
77

88
function makeIndex(skills: Record<string, string>): SkillsIndex {
99
return {
10-
version: 1,
1110
skills: Object.fromEntries(
1211
Object.entries(skills).map(([name, contentHash]) => [
1312
name,

0 commit comments

Comments
 (0)