Skip to content

Commit 121fa13

Browse files
committed
feat(skills): align agent registry with upstream and harden cross-platform install
1 parent cf2592c commit 121fa13

11 files changed

Lines changed: 858 additions & 46 deletions

File tree

packages/cli/postinstall.js

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
1010
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
1111
* legacy fallback to skill.tar.br when the entry has no valid object field
12-
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
12+
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir,
13+
* then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer)
1314
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
1415
* 5. Write ~/.bailian/wiki-sync-state.json
1516
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
@@ -20,10 +21,12 @@
2021
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
2122
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
2223
*/
24+
import { createHash } from "node:crypto";
2325
import {
2426
createWriteStream,
2527
existsSync,
2628
mkdirSync,
29+
readdirSync,
2730
readFileSync,
2831
renameSync,
2932
rmSync,
@@ -106,6 +109,9 @@ async function downloadBuffer(url) {
106109

107110
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
108111
function isSafeEntryName(name) {
112+
// Symmetric with core skills/extract.ts: backslashes can escape the extraction
113+
// dir on Windows (path.join expands "\.." segments, leading "\" hits drive root)
114+
if (name.includes("\\") || name.includes("\0")) return false;
109115
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
110116
return !name.split("/").includes("..");
111117
}
@@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) {
140146
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
141147
}
142148

149+
/**
150+
* Recompute the publisher's deterministic content hash over an extracted directory
151+
* (same accumulation as core skills/extract.ts computeDirContentHash): regular files
152+
* sorted by "/"-separated relative path, sha256 over relPath + bytes.
153+
*/
154+
function computeDirContentHash(dir) {
155+
const relPaths = [];
156+
const walk = (sub) => {
157+
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
158+
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
159+
if (dirent.isDirectory()) walk(rel);
160+
else if (dirent.isFile()) relPaths.push(rel);
161+
}
162+
};
163+
walk("");
164+
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
165+
const hash = createHash("sha256");
166+
for (const rel of relPaths) {
167+
hash.update(rel);
168+
hash.update(readFileSync(join(dir, rel)));
169+
}
170+
return `sha256:${hash.digest("hex")}`;
171+
}
172+
143173
/** Atomic swap: tmpDir (same volume) → catalogDir. */
144174
function atomicSwap(tmpDir, catalogDir) {
145175
mkdirSync(dirname(catalogDir), { recursive: true });
@@ -166,12 +196,22 @@ async function main() {
166196
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
167197
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
168198

169-
// 3. Extract to same-volume temp dir + atomic swap
199+
// 3. Extract to same-volume temp dir + integrity check + atomic swap
170200
const catalogDir = getCatalogDir();
171201
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
172202
try {
173203
mkdirSync(tmpDir, { recursive: true });
174204
await extractTarBr(tarBuf, tmpDir);
205+
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
206+
// before touching the canonical dir
207+
if (entry.contentHash.startsWith("sha256:")) {
208+
const actualContentHash = computeDirContentHash(tmpDir);
209+
if (actualContentHash !== entry.contentHash) {
210+
throw new Error(
211+
`content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`,
212+
);
213+
}
214+
}
175215
atomicSwap(tmpDir, catalogDir);
176216
} catch (err) {
177217
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });

packages/commands/src/commands/skill/add.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ export default defineCommand({
5656
return { name, status: "failed", reason: "skill not found in registry" };
5757
}
5858
try {
59-
const record = await installSkillWithFanout(name, entry, agents);
59+
const record = await installSkillWithFanout(
60+
name,
61+
entry,
62+
agents,
63+
lock.skills[name]?.links ?? [],
64+
);
6065
lock.skills[name] = record.lockEntry;
6166
return {
6267
name,

packages/commands/src/commands/skill/update.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
defineCommand,
55
detectOutputFormat,
66
detectInstalledAgents,
7+
fanOutSkillToAgents,
78
fetchSkillsIndex,
89
getSkillRegistryBaseUrl,
910
installSkillWithFanout,
@@ -45,6 +46,7 @@ export default defineCommand({
4546
const lock = readSkillLock();
4647
const disk = new Set(listSkillDirsOnDisk());
4748

49+
const agents = detectInstalledAgents();
4850
const results: UpdateOutcome[] = [];
4951
const targets: string[] = [];
5052
if (requested === "all") {
@@ -60,6 +62,11 @@ export default defineCommand({
6062
continue;
6163
}
6264
if (entry.contentHash === locked.contentHash && disk.has(name)) {
65+
// Self-healing: content unchanged, but still fill fan-out links for agents
66+
// detected since the last install (and refresh recorded copies); the merged
67+
// ledger keeps paths of unvisited agents reclaimable by bl skill remove
68+
const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []);
69+
lock.skills[name] = { ...locked, links: fanout.links };
6370
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
6471
continue;
6572
}
@@ -80,14 +87,18 @@ export default defineCommand({
8087
}
8188
}
8289

83-
const agents = detectInstalledAgents();
8490
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
8591
const entry = index.skills[name];
8692
if (!entry) {
8793
return { name, status: "failed", reason: "skill not found in registry" };
8894
}
8995
try {
90-
const record = await installSkillWithFanout(name, entry, agents);
96+
const record = await installSkillWithFanout(
97+
name,
98+
entry,
99+
agents,
100+
lock.skills[name]?.links ?? [],
101+
);
91102
lock.skills[name] = record.lockEntry;
92103
return { name, status: "updated", publishedAt: entry.publishedAt };
93104
} catch (err) {

packages/core/src/advisor/sync.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import { existsSync, readFileSync, writeFileSync } from "node:fs";
2424
import { join } from "node:path";
2525
import { getConfigDir } from "../config/paths.ts";
26+
import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts";
2627
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
2728
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
2829
import { fetchSkillsIndex } from "../skills/registry.ts";
@@ -90,12 +91,17 @@ function recordWikiInLock(lockEntry: SkillLockEntry): void {
9091
}
9192
}
9293

93-
/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
94-
function wikiLockUpToDate(contentHash: string): boolean {
94+
/**
95+
* Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the
96+
* record carries no fan-out links (postinstall writes contentHash only and never fans
97+
* out, so agents would otherwise never see the wiki skill until content changes).
98+
*/
99+
function wikiLockNeedsBackfill(contentHash: string): boolean {
95100
try {
96-
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
101+
const locked = readSkillLock().skills[WIKI_SKILL_NAME];
102+
return locked?.contentHash !== contentHash || !Array.isArray(locked.links);
97103
} catch {
98-
return false;
104+
return true;
99105
}
100106
}
101107

@@ -136,15 +142,25 @@ export async function maybeSyncWikiData(): Promise<boolean> {
136142
const dataOk = catalogDataExists();
137143
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
138144
writeState({ lastChecked: now, contentHash: entry.contentHash });
139-
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
140-
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
145+
// Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill
146+
if (wikiLockNeedsBackfill(entry.contentHash)) {
147+
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
148+
const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks);
149+
recordWikiInLock(buildSkillLockEntry(entry, fanout.links));
150+
}
141151
return false;
142152
}
143153

144154
// 4. Different content or missing data: delegate to the shared skill install pipeline
145155
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
146156
try {
147-
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
157+
const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? [];
158+
const record = await installSkillWithFanout(
159+
WIKI_SKILL_NAME,
160+
entry,
161+
detectInstalledAgents(),
162+
previousLinks,
163+
);
148164
recordWikiInLock(record.lockEntry);
149165
} catch {
150166
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries

0 commit comments

Comments
 (0)