From 121fa1317fde8feb892e05f3012c647202c6ec0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Wed, 5 Aug 2026 10:17:06 +0800 Subject: [PATCH 1/2] feat(skills): align agent registry with upstream and harden cross-platform install --- packages/cli/postinstall.js | 44 ++- packages/commands/src/commands/skill/add.ts | 7 +- .../commands/src/commands/skill/update.ts | 15 +- packages/core/src/advisor/sync.ts | 30 +- packages/core/src/skills/agents.ts | 295 ++++++++++++++++-- packages/core/src/skills/extract.ts | 5 + packages/core/src/skills/index.ts | 2 + packages/core/src/skills/installer.ts | 19 +- packages/core/tests/advisor-sync.test.ts | 118 +++++++ packages/core/tests/skills-agents.test.ts | 262 +++++++++++++++- packages/core/tests/skills-installer.test.ts | 107 ++++++- 11 files changed, 858 insertions(+), 46 deletions(-) create mode 100644 packages/core/tests/advisor-sync.test.ts diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.js index a502a0b..8dd3f5e 100644 --- a/packages/cli/postinstall.js +++ b/packages/cli/postinstall.js @@ -9,7 +9,8 @@ * 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry * 2. Download skills/bailian-docs-llm-wiki/ (sha256-.tar.br, brotli q6, ~2.3MB); * legacy fallback to skill.tar.br when the entry has no valid object field - * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir + * 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir, + * then recompute contentHash over the extracted files and reject on mismatch (symmetric with core installer) * 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/ * 5. Write ~/.bailian/wiki-sync-state.json * 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill) @@ -20,10 +21,12 @@ * - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling * - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs) */ +import { createHash } from "node:crypto"; import { createWriteStream, existsSync, mkdirSync, + readdirSync, readFileSync, renameSync, rmSync, @@ -106,6 +109,9 @@ async function downloadBuffer(url) { /** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */ function isSafeEntryName(name) { + // Symmetric with core skills/extract.ts: backslashes can escape the extraction + // dir on Windows (path.join expands "\.." segments, leading "\" hits drive root) + if (name.includes("\\") || name.includes("\0")) return false; if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false; return !name.split("/").includes(".."); } @@ -140,6 +146,30 @@ async function extractTarBr(tarBrBuffer, destDir) { await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract); } +/** + * Recompute the publisher's deterministic content hash over an extracted directory + * (same accumulation as core skills/extract.ts computeDirContentHash): regular files + * sorted by "/"-separated relative path, sha256 over relPath + bytes. + */ +function computeDirContentHash(dir) { + const relPaths = []; + const walk = (sub) => { + for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) { + const rel = sub ? `${sub}/${dirent.name}` : dirent.name; + if (dirent.isDirectory()) walk(rel); + else if (dirent.isFile()) relPaths.push(rel); + } + }; + walk(""); + relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); + const hash = createHash("sha256"); + for (const rel of relPaths) { + hash.update(rel); + hash.update(readFileSync(join(dir, rel))); + } + return `sha256:${hash.digest("hex")}`; +} + /** Atomic swap: tmpDir (same volume) → catalogDir. */ function atomicSwap(tmpDir, catalogDir) { mkdirSync(dirname(catalogDir), { recursive: true }); @@ -166,12 +196,22 @@ async function main() { entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME; const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`); - // 3. Extract to same-volume temp dir + atomic swap + // 3. Extract to same-volume temp dir + integrity check + atomic swap const catalogDir = getCatalogDir(); const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`; try { mkdirSync(tmpDir, { recursive: true }); await extractTarBr(tarBuf, tmpDir); + // Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch + // before touching the canonical dir + if (entry.contentHash.startsWith("sha256:")) { + const actualContentHash = computeDirContentHash(tmpDir); + if (actualContentHash !== entry.contentHash) { + throw new Error( + `content hash mismatch: index says ${entry.contentHash}, archive is ${actualContentHash}`, + ); + } + } atomicSwap(tmpDir, catalogDir); } catch (err) { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); diff --git a/packages/commands/src/commands/skill/add.ts b/packages/commands/src/commands/skill/add.ts index 5cb6f97..073a80a 100644 --- a/packages/commands/src/commands/skill/add.ts +++ b/packages/commands/src/commands/skill/add.ts @@ -56,7 +56,12 @@ export default defineCommand({ return { name, status: "failed", reason: "skill not found in registry" }; } try { - const record = await installSkillWithFanout(name, entry, agents); + const record = await installSkillWithFanout( + name, + entry, + agents, + lock.skills[name]?.links ?? [], + ); lock.skills[name] = record.lockEntry; return { name, diff --git a/packages/commands/src/commands/skill/update.ts b/packages/commands/src/commands/skill/update.ts index 127e641..61e1b1f 100644 --- a/packages/commands/src/commands/skill/update.ts +++ b/packages/commands/src/commands/skill/update.ts @@ -4,6 +4,7 @@ import { defineCommand, detectOutputFormat, detectInstalledAgents, + fanOutSkillToAgents, fetchSkillsIndex, getSkillRegistryBaseUrl, installSkillWithFanout, @@ -45,6 +46,7 @@ export default defineCommand({ const lock = readSkillLock(); const disk = new Set(listSkillDirsOnDisk()); + const agents = detectInstalledAgents(); const results: UpdateOutcome[] = []; const targets: string[] = []; if (requested === "all") { @@ -60,6 +62,11 @@ export default defineCommand({ continue; } if (entry.contentHash === locked.contentHash && disk.has(name)) { + // Self-healing: content unchanged, but still fill fan-out links for agents + // detected since the last install (and refresh recorded copies); the merged + // ledger keeps paths of unvisited agents reclaimable by bl skill remove + const fanout = fanOutSkillToAgents(name, agents, locked.links ?? []); + lock.skills[name] = { ...locked, links: fanout.links }; results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt }); continue; } @@ -80,14 +87,18 @@ export default defineCommand({ } } - const agents = detectInstalledAgents(); const tasks = targets.map((name) => async (): Promise => { const entry = index.skills[name]; if (!entry) { return { name, status: "failed", reason: "skill not found in registry" }; } try { - const record = await installSkillWithFanout(name, entry, agents); + const record = await installSkillWithFanout( + name, + entry, + agents, + lock.skills[name]?.links ?? [], + ); lock.skills[name] = record.lockEntry; return { name, status: "updated", publishedAt: entry.publishedAt }; } catch (err) { diff --git a/packages/core/src/advisor/sync.ts b/packages/core/src/advisor/sync.ts index 6e36ce7..45d9cb4 100644 --- a/packages/core/src/advisor/sync.ts +++ b/packages/core/src/advisor/sync.ts @@ -23,6 +23,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../config/paths.ts"; +import { detectInstalledAgents, fanOutSkillToAgents } from "../skills/agents.ts"; import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts"; import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts"; import { fetchSkillsIndex } from "../skills/registry.ts"; @@ -90,12 +91,17 @@ function recordWikiInLock(lockEntry: SkillLockEntry): void { } } -/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */ -function wikiLockUpToDate(contentHash: string): boolean { +/** + * Whether the lock still needs a wiki backfill: content fingerprint mismatch, or the + * record carries no fan-out links (postinstall writes contentHash only and never fans + * out, so agents would otherwise never see the wiki skill until content changes). + */ +function wikiLockNeedsBackfill(contentHash: string): boolean { try { - return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash; + const locked = readSkillLock().skills[WIKI_SKILL_NAME]; + return locked?.contentHash !== contentHash || !Array.isArray(locked.links); } catch { - return false; + return true; } } @@ -136,15 +142,25 @@ export async function maybeSyncWikiData(): Promise { const dataOk = catalogDataExists(); if (dataOk && (!state || state.contentHash === entry.contentHash)) { writeState({ lastChecked: now, contentHash: entry.contentHash }); - // Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill - if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, [])); + // Lock record missing/stale (e.g. postinstall wrote canonical only, without fan-out) → backfill + if (wikiLockNeedsBackfill(entry.contentHash)) { + const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? []; + const fanout = fanOutSkillToAgents(WIKI_SKILL_NAME, detectInstalledAgents(), previousLinks); + recordWikiInLock(buildSkillLockEntry(entry, fanout.links)); + } return false; } // 4. Different content or missing data: delegate to the shared skill install pipeline // (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links) try { - const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry); + const previousLinks = readSkillLock().skills[WIKI_SKILL_NAME]?.links ?? []; + const record = await installSkillWithFanout( + WIKI_SKILL_NAME, + entry, + detectInstalledAgents(), + previousLinks, + ); recordWikiInLock(record.lockEntry); } catch { // Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries diff --git a/packages/core/src/skills/agents.ts b/packages/core/src/skills/agents.ts index 135fa0e..1ddd369 100644 --- a/packages/core/src/skills/agents.ts +++ b/packages/core/src/skills/agents.ts @@ -29,9 +29,16 @@ export interface AgentTarget { detectDirs: string[]; } -/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */ +/** + * Computed on each call (depends on homedir / XDG_CONFIG_HOME / cwd; easy to override in tests). + * Registry mirrors the vercel-labs/skills agent list, minus agents that cannot participate in + * global symlink fan-out (eve: no global dir, upstream forces direct writes; promptscript: + * project-only). Shared-dir agents (Cline/Warp/Zed/Kimi/… read ~/.agents/skills; Amp/Replit + * read $XDG_CONFIG_HOME/agents/skills) are folded into the universal pseudo-agents' detectDirs. + */ export function getAgentTargets(): AgentTarget[] { const home = homedir(); + const cwd = process.cwd(); const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config"); const simple = (id: string, displayName: string, dir: string): AgentTarget => ({ id, @@ -39,29 +46,194 @@ export function getAgentTargets(): AgentTarget[] { skillsDir: join(home, dir, "skills"), detectDirs: [join(home, dir)], }); + /** Config base dir that can be relocated via the agent's official env var */ + const envBase = (envValue: string | undefined, fallbackDir: string): string => { + const trimmed = envValue?.trim(); + return trimmed ? trimmed : join(home, fallbackDir); + }; + /** Target derived from an absolute base dir (detection and skills dir stay in sync) */ + const fromBase = (id: string, displayName: string, baseDir: string): AgentTarget => ({ + id, + displayName, + skillsDir: join(baseDir, "skills"), + detectDirs: [baseDir], + }); + + // OpenClaw was renamed over time (.openclaw → .clawdbot → .moltbot): link into the first + // home that actually exists, detect any of them + const openclawCandidates = [".openclaw", ".clawdbot", ".moltbot"].map((dir) => join(home, dir)); + const openclawHome = openclawCandidates.find((dir) => existsSync(dir)) ?? openclawCandidates[0]!; + + // Zed's config_dir(): XDG on Linux/macOS, %APPDATA% on Windows, Flatpak override + const zedDetectDirs = [join(xdgConfig, "zed")]; + const zedAppData = process.env.APPDATA?.trim(); + if (zedAppData) zedDetectDirs.push(join(zedAppData, "Zed")); + const zedFlatpakConfig = process.env.FLATPAK_XDG_CONFIG_HOME?.trim(); + if (zedFlatpakConfig) zedDetectDirs.push(join(zedFlatpakConfig, "zed")); + + const codexHome = envBase(process.env.CODEX_HOME, ".codex"); + return [ - // universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.) + // universal pseudo-agent: ~/.agents/skills is a shared dir read by Cline, Warp, Zed, + // Kimi Code, Dexto, Firebender, Loaf, … { id: "universal", displayName: "Universal (~/.agents/skills)", skillsDir: join(home, ".agents", "skills"), - detectDirs: [join(home, ".agents"), join(home, ".cline")], + detectDirs: [ + join(home, ".agents"), + join(home, ".cline"), + join(home, ".dexto"), + join(home, ".firebender"), + join(home, ".kimi-code"), + join(home, ".kimi"), + join(home, ".loaf"), + join(home, ".warp"), + ...zedDetectDirs, + ], }, - simple("claude-code", "Claude Code", ".claude"), - simple("openclaw", "OpenClaw", ".openclaw"), - simple("hermes", "Hermes Agent", ".hermes"), + // XDG variant: $XDG_CONFIG_HOME/agents/skills, shared dir read by Amp-style agents; Replit + // also reads it and is detected project-locally via cwd/.replit + { + id: "universal-xdg", + displayName: "Universal (XDG agents/skills)", + skillsDir: join(xdgConfig, "agents", "skills"), + detectDirs: [join(xdgConfig, "agents"), join(xdgConfig, "amp"), join(cwd, ".replit")], + }, + simple("adal", "AdaL", ".adal"), + simple("aider-desk", "AiderDesk", ".aider-desk"), + simple("antigravity", "Antigravity", ".gemini/antigravity"), + simple("antigravity-cli", "Antigravity CLI", ".gemini/antigravity-cli"), + { + id: "astrbot", + displayName: "AstrBot", + skillsDir: join(home, ".astrbot", "data", "skills"), + detectDirs: [join(cwd, "data", "skills"), join(home, ".astrbot")], + }, + fromBase("autohand-code", "Autohand Code CLI", envBase(process.env.AUTOHAND_HOME, ".autohand")), + simple("augment", "Augment", ".augment"), + simple("bob", "IBM Bob", ".bob"), + fromBase("claude-code", "Claude Code", envBase(process.env.CLAUDE_CONFIG_DIR, ".claude")), + simple("codearts-agent", "CodeArts Agent", ".codeartsdoer"), + { + id: "codebuddy", + displayName: "CodeBuddy", + skillsDir: join(home, ".codebuddy", "skills"), + detectDirs: [join(cwd, ".codebuddy"), join(home, ".codebuddy")], + }, + simple("codemaker", "Codemaker", ".codemaker"), + simple("codestudio", "Code Studio", ".codestudio"), + { + id: "codex", + displayName: "Codex", + skillsDir: join(codexHome, "skills"), + detectDirs: [codexHome, "/etc/codex"], + }, + simple("command-code", "Command Code", ".commandcode"), + { + id: "continue", + displayName: "Continue", + skillsDir: join(home, ".continue", "skills"), + detectDirs: [join(cwd, ".continue"), join(home, ".continue")], + }, + simple("cortex", "Cortex Code", ".snowflake/cortex"), + simple("crush", "Crush", ".config/crush"), + simple("cursor", "Cursor", ".cursor"), + { + id: "deepagents", + displayName: "Deep Agents", + skillsDir: join(home, ".deepagents", "agent", "skills"), + detectDirs: [join(home, ".deepagents")], + }, + { + id: "devin", + displayName: "Devin for Terminal", + skillsDir: join(xdgConfig, "devin", "skills"), + detectDirs: [join(xdgConfig, "devin")], + }, + simple("droid", "Droid", ".factory"), + simple("forgecode", "ForgeCode", ".forge"), + simple("gemini-cli", "Gemini CLI", ".gemini"), + simple("github-copilot", "GitHub Copilot", ".copilot"), + { + id: "goose", + displayName: "Goose", + skillsDir: join(xdgConfig, "goose", "skills"), + detectDirs: [join(xdgConfig, "goose")], + }, + fromBase("grok", "Grok Build", envBase(process.env.GROK_HOME, ".grok")), + fromBase("hermes", "Hermes Agent", envBase(process.env.HERMES_HOME, ".hermes")), + simple("iflow-cli", "iFlow CLI", ".iflow"), + simple("inference-sh", "inference.sh", ".inferencesh"), + { + id: "jazz", + displayName: "Jazz", + skillsDir: join(home, ".jazz", "skills"), + detectDirs: [join(home, ".jazz"), join(cwd, ".jazz")], + }, + simple("junie", "Junie", ".junie"), + simple("kilo", "Kilo Code", ".kilocode"), + { + id: "kimchi", + displayName: "Kimchi", + skillsDir: join(home, ".config", "kimchi", "harness", "skills"), + detectDirs: [join(home, ".config", "kimchi")], + }, + simple("kiro-cli", "Kiro CLI", ".kiro"), + simple("kode", "Kode", ".kode"), + simple("lingma", "Lingma", ".lingma"), + simple("mcpjam", "MCPJam", ".mcpjam"), + { + id: "minimax-code", + displayName: "MiniMax Code", + skillsDir: join(home, ".minimax", "skills"), + detectDirs: [join(home, ".minimax"), "/Applications/MiniMax Code.app"], + }, + fromBase("mistral-vibe", "Mistral Vibe", envBase(process.env.VIBE_HOME, ".vibe")), + simple("moxby", "Moxby", ".moxby"), + simple("mux", "Mux", ".mux"), + simple("neovate", "Neovate", ".neovate"), { id: "opencode", displayName: "OpenCode", skillsDir: join(xdgConfig, "opencode", "skills"), detectDirs: [join(xdgConfig, "opencode")], }, - simple("cursor", "Cursor", ".cursor"), - simple("codex", "Codex", ".codex"), - simple("qwen-code", "Qwen Code", ".qwen"), + { + id: "openclaw", + displayName: "OpenClaw", + skillsDir: join(openclawHome, "skills"), + detectDirs: openclawCandidates, + }, + simple("openhands", "OpenHands", ".openhands"), + simple("ona", "Ona", ".ona"), + simple("pi", "Pi", ".pi/agent"), + simple("pochi", "Pochi", ".pochi"), simple("qoder", "Qoder", ".qoder"), simple("qoder-cn", "Qoder CN", ".qoder-cn"), - simple("kilo", "Kilo Code", ".kilocode"), + simple("qwen-code", "Qwen Code", ".qwen"), + simple("reasonix", "Reasonix", ".reasonix"), + simple("rovodev", "Rovo Dev", ".rovodev"), + simple("roo", "Roo Code", ".roo"), + { + id: "tabnine-cli", + displayName: "Tabnine CLI", + skillsDir: join(home, ".tabnine", "agent", "skills"), + detectDirs: [join(home, ".tabnine")], + }, + simple("terramind", "Terramind", ".terramind"), + simple("tinycloud", "Tinycloud", ".tinycloud"), + simple("trae", "Trae", ".trae"), + simple("trae-cn", "Trae CN", ".trae-cn"), + simple("windsurf", "Windsurf", ".codeium/windsurf"), + { + id: "zcode", + displayName: "ZCode", + skillsDir: join(home, ".zcode", "skills"), + detectDirs: [join(home, ".zcode"), "/Applications/ZCode.app"], + }, + // Zenflow reads the same ~/.zencoder/skills dir, so one target covers both + simple("zencoder", "Zencoder", ".zencoder"), ]; } @@ -69,13 +241,45 @@ export function detectInstalledAgents(): AgentTarget[] { return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir))); } +/** Path equality that respects the host filesystem's case rules (Windows is case-insensitive) */ +function samePath(left: string, right: string): boolean { + if (process.platform === "win32") return left.toLowerCase() === right.toLowerCase(); + return left === right; +} + +/** Whether absPath is the canonical skills dir or lives inside it (case-aware on Windows) */ +function isUnderCanonicalDir(absPath: string): boolean { + const skillsDir = getSkillsDir(); + if (process.platform === "win32") { + const lowerPath = absPath.toLowerCase(); + const lowerDir = skillsDir.toLowerCase(); + return lowerPath === lowerDir || lowerPath.startsWith(lowerDir + sep); + } + return absPath === skillsDir || absPath.startsWith(skillsDir + sep); +} + /** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */ function isManagedLink(linkPath: string): boolean { try { if (!lstatSync(linkPath).isSymbolicLink()) return false; const target = readlinkSync(linkPath); const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target); - return abs === getSkillsDir() || abs.startsWith(getSkillsDir() + sep); + return isUnderCanonicalDir(abs); + } catch { + return false; + } +} + +/** + * Whether linkPath is a copy-fallback artifact recorded in the lock: a real directory + * (not a symlink) at a path this tool previously wrote when symlink creation failed + * (typical: Windows without Developer Mode). Only recorded paths qualify — foreign + * directories are never touched. + */ +function isRecordedCopy(linkPath: string, recordedLinks: string[]): boolean { + if (!recordedLinks.some((recorded) => samePath(recorded, linkPath))) return false; + try { + return lstatSync(linkPath).isDirectory(); } catch { return false; } @@ -88,15 +292,20 @@ export interface LinkResult { reason?: string; } +/** Fixed skip reason for foreign paths; fanOutSkillToAgents keys ledger drops off this value */ +const UNMANAGED_SKIP_REASON = "existing file/dir not managed by bl skill"; + /** * Fan out a skill from canonical to each agent's skills dir. - * Stale links created by this tool are rebuilt; existing files/dirs NOT managed by this tool - * are always skipped (never delete user content). Falls back to copy when symlink fails - * (e.g. Windows without Developer Mode). + * Stale links created by this tool are rebuilt; recorded copy-fallback artifacts + * (real dirs at paths present in recordedLinks) are replaced with fresh content; + * any other existing files/dirs are always skipped (never delete user content). + * Falls back to copy when symlink fails (e.g. Windows without Developer Mode). */ export function linkSkillToAgents( name: string, agents: AgentTarget[] = detectInstalledAgents(), + recordedLinks: string[] = [], ): LinkResult[] { const target = join(getSkillsDir(), name); const results: LinkResult[] = []; @@ -111,16 +320,21 @@ export function linkSkillToAgents( /* does not exist */ } if (existing) { - if (!isManagedLink(linkPath)) { + if (isManagedLink(linkPath)) { + rmSync(linkPath); + } else if (isRecordedCopy(linkPath, recordedLinks)) { + // Copy-fallback artifact from a previous install → replace so updates + // reach agents that have no symlink permission + rmSync(linkPath, { recursive: true, force: true }); + } else { results.push({ agent: agent.id, path: linkPath, mode: "skipped", - reason: "existing file/dir not managed by bl skill", + reason: UNMANAGED_SKIP_REASON, }); continue; } - rmSync(linkPath); } mkdirSync(agent.skillsDir, { recursive: true }); try { @@ -143,6 +357,51 @@ export function linkSkillToAgents( return results; } +/** + * Fan-out workflow: link to agents AND compute the next lock ledger in one step. + * Shared by bl skill add/update (fresh install and self-healing) and advisor wiki sync, + * so every channel applies the same ledger-merge rules. + */ +export interface FanoutOutcome { + results: LinkResult[]; + /** Agent ids that actually received a link/copy this run (skipped ones excluded) */ + linkedAgents: string[]; + /** Next lock links ledger; see merge rules in fanOutSkillToAgents */ + links: string[]; +} + +/** + * Fan out and merge the resulting paths with the previously recorded ledger: + * - effective paths from this run are recorded; + * - recorded paths NOT visited this run are preserved (agent uninstalled/undetected — + * the artifact may still exist and must stay reclaimable by bl skill remove); + * - recorded paths that failed transiently this run are preserved for the same reason; + * - recorded paths confirmed foreign this run (unmanaged skip) are dropped — the user + * replaced our artifact, and keeping the record would let remove delete user content. + */ +export function fanOutSkillToAgents( + name: string, + agents: AgentTarget[] = detectInstalledAgents(), + recordedLinks: string[] = [], +): FanoutOutcome { + const results = linkSkillToAgents(name, agents, recordedLinks); + const effective = results.filter((result) => result.mode !== "skipped"); + const effectivePaths = effective.map((result) => result.path); + const confirmedForeign = results + .filter((result) => result.mode === "skipped" && result.reason === UNMANAGED_SKIP_REASON) + .map((result) => result.path); + const preserved = recordedLinks.filter( + (recorded) => + !effectivePaths.some((path) => samePath(path, recorded)) && + !confirmedForeign.some((path) => samePath(path, recorded)), + ); + return { + results, + linkedAgents: effective.map((result) => result.agent), + links: [...effectivePaths, ...preserved], + }; +} + /** * Reclaim fan-out artifacts for a skill across all agent dirs. * Symlinks pointing to canonical are removed (including historical links not in lock, @@ -166,7 +425,7 @@ export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = [] rmSync(linkPath); removed.push(linkPath); } - } else if (recordedLinks.includes(linkPath)) { + } else if (recordedLinks.some((recorded) => samePath(recorded, linkPath))) { rmSync(linkPath, { recursive: true, force: true }); removed.push(linkPath); } diff --git a/packages/core/src/skills/extract.ts b/packages/core/src/skills/extract.ts index 48a8a99..d8ed825 100644 --- a/packages/core/src/skills/extract.ts +++ b/packages/core/src/skills/extract.ts @@ -21,6 +21,11 @@ import tar from "tar-stream"; /** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */ export function isSafeEntryName(name: string): boolean { + // Reject backslashes outright: on Windows path.join expands backslash-separated + // ".." segments and a leading "\" resolves to the drive root, so such names can + // escape the extraction dir even though they pass the "/"-based checks below. + // The publisher always packs with "/" separators, so this never rejects legit archives. + if (name.includes("\\") || name.includes("\0")) return false; if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false; return !name.split("/").includes(".."); } diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 58529b2..740d159 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -29,9 +29,11 @@ export { getAgentTargets, detectInstalledAgents, linkSkillToAgents, + fanOutSkillToAgents, unlinkSkillFromAgents, type AgentTarget, type LinkResult, + type FanoutOutcome, } from "./agents.ts"; export { installSkill, diff --git a/packages/core/src/skills/installer.ts b/packages/core/src/skills/installer.ts index 0bcba40..1f93d1e 100644 --- a/packages/core/src/skills/installer.ts +++ b/packages/core/src/skills/installer.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; -import { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts"; +import { detectInstalledAgents, fanOutSkillToAgents, type AgentTarget } from "./agents.ts"; import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts"; import { getSkillsDir } from "./lock.ts"; import { downloadSkillAsset } from "./registry.ts"; @@ -112,22 +112,21 @@ export interface SkillInstallRecord { /** * Full install workflow for one skill: install into canonical, fan out to agents, and build - * the lock entry recording effective links. Callers decide how to persist the lock entry - * (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels). + * the lock entry recording the merged links ledger. Callers decide how to persist the lock + * entry (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels). + * recordedLinks = the skill's previously recorded fan-out paths from the lock; lets the + * fan-out replace copy-fallback artifacts and keeps unvisited paths reclaimable. */ export async function installSkillWithFanout( name: string, entry: SkillIndexEntry, agents: AgentTarget[] = detectInstalledAgents(), + recordedLinks: string[] = [], ): Promise { await installSkill(name, entry); - const links = linkSkillToAgents(name, agents); - const effective = links.filter((link) => link.mode !== "skipped"); + const fanout = fanOutSkillToAgents(name, agents, recordedLinks); return { - lockEntry: buildSkillLockEntry( - entry, - effective.map((link) => link.path), - ), - linkedAgents: effective.map((link) => link.agent), + lockEntry: buildSkillLockEntry(entry, fanout.links), + linkedAgents: fanout.linkedAgents, }; } diff --git a/packages/core/tests/advisor-sync.test.ts b/packages/core/tests/advisor-sync.test.ts new file mode 100644 index 0000000..4f171e6 --- /dev/null +++ b/packages/core/tests/advisor-sync.test.ts @@ -0,0 +1,118 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { maybeSyncWikiData } from "../src/advisor/sync.ts"; +import { readSkillLock } from "../src/skills/lock.ts"; + +const WIKI_SKILL_NAME = "bailian-docs-llm-wiki"; +const CONTENT_HASH = `sha256:${"a".repeat(64)}`; + +/** Isolated HOME/XDG/BAILIAN_CONFIG_DIR; agent config-dir overrides cleared for determinism */ +async function inFakeHome(fn: (home: string) => Promise): Promise { + const saved = { + HOME: process.env.HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CODEX_HOME: process.env.CODEX_HOME, + }; + const home = mkdtempSync(join(tmpdir(), "bl-advisor-sync-")); + process.env.HOME = home; + process.env.XDG_CONFIG_HOME = join(home, ".config"); + process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian"); + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEX_HOME; + try { + await fn(home); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(home, { recursive: true, force: true }); + } +} + +/** Stub the registry fetch to serve a wiki entry with the given fingerprint */ +function stubRegistryIndex() { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + skills: { + [WIKI_SKILL_NAME]: { contentHash: CONTENT_HASH, publishedAt: "2026-08-01 10:00:00" }, + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +/** Seed what postinstall leaves behind: canonical data + expired state + lock entry WITHOUT links */ +function seedPostinstallState(configDir: string): void { + const catalogDir = join(configDir, "skills", WIKI_SKILL_NAME); + mkdirSync(join(catalogDir, "models"), { recursive: true }); + writeFileSync(join(catalogDir, "models", "models.jsonl"), "{}\n"); + writeFileSync(join(catalogDir, "SKILL.md"), "---\nname: wiki\ndescription: docs\n---\n"); + // State older than the 12h throttle so the hash-hit branch is reached + writeFileSync( + join(configDir, "wiki-sync-state.json"), + JSON.stringify({ lastChecked: Date.now() - 13 * 3600_000, contentHash: CONTENT_HASH }), + ); + writeFileSync( + join(configDir, "skills", "skill-lock.json"), + JSON.stringify({ + version: 1, + skills: { + [WIKI_SKILL_NAME]: { + contentHash: CONTENT_HASH, + installedAt: "2026-08-01T00:00:00.000Z", + sourceType: "oss", + }, + }, + }), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("advisor sync: hash-hit backfills fan-out links missing from postinstall lock entry", async () => { + await inFakeHome(async (home) => { + seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!); + mkdirSync(join(home, ".claude"), { recursive: true }); + const fetchMock = stubRegistryIndex(); + + const updated = await maybeSyncWikiData(); + + // Content unchanged → no data update, but the fan-out gap is repaired + expect(updated).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + const locked = readSkillLock().skills[WIKI_SKILL_NAME]; + expect(locked?.links).toEqual([join(home, ".claude", "skills", WIKI_SKILL_NAME)]); + + // Second run: fresh throttle + links already recorded → no fetch, no rewrite + await maybeSyncWikiData(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +test("advisor sync: hash-hit keeps an existing links array untouched", async () => { + await inFakeHome(async (home) => { + seedPostinstallState(process.env.BAILIAN_CONFIG_DIR!); + // Upgrade the lock entry to "already fanned out" shape + const existingLink = join(home, ".claude", "skills", WIKI_SKILL_NAME); + const lockPath = join(process.env.BAILIAN_CONFIG_DIR!, "skills", "skill-lock.json"); + const lockContent = JSON.parse(readFileSync(lockPath, "utf-8")); + lockContent.skills[WIKI_SKILL_NAME].links = [existingLink]; + writeFileSync(lockPath, JSON.stringify(lockContent)); + mkdirSync(join(home, ".claude"), { recursive: true }); + stubRegistryIndex(); + + await maybeSyncWikiData(); + + expect(readSkillLock().skills[WIKI_SKILL_NAME]?.links).toEqual([existingLink]); + }); +}); diff --git a/packages/core/tests/skills-agents.test.ts b/packages/core/tests/skills-agents.test.ts index eee877a..dc80ca3 100644 --- a/packages/core/tests/skills-agents.test.ts +++ b/packages/core/tests/skills-agents.test.ts @@ -13,6 +13,7 @@ import { join } from "path"; import { expect, test } from "vite-plus/test"; import { detectInstalledAgents, + fanOutSkillToAgents, getAgentTargets, linkSkillToAgents, unlinkSkillFromAgents, @@ -28,11 +29,32 @@ async function inFakeHome(fn: (home: string) => Promise): Promise { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CODEX_HOME: process.env.CODEX_HOME, + VIBE_HOME: process.env.VIBE_HOME, + HERMES_HOME: process.env.HERMES_HOME, + AUTOHAND_HOME: process.env.AUTOHAND_HOME, + GROK_HOME: process.env.GROK_HOME, + APPDATA: process.env.APPDATA, + FLATPAK_XDG_CONFIG_HOME: process.env.FLATPAK_XDG_CONFIG_HOME, }; const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-")); process.env.HOME = home; process.env.XDG_CONFIG_HOME = join(home, ".config"); process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian"); + // Agent config-dir overrides must not leak in from the dev machine + for (const key of [ + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "VIBE_HOME", + "HERMES_HOME", + "AUTOHAND_HOME", + "GROK_HOME", + "APPDATA", + "FLATPAK_XDG_CONFIG_HOME", + ]) { + delete process.env[key]; + } try { await fn(home); } finally { @@ -52,10 +74,16 @@ function seedCanonicalSkill(name: string): string { return dir; } -test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => { +test("agents: registry mirrors upstream agent list minus non-symlinkable agents", async () => { await inFakeHome(async (home) => { - expect(getAgentTargets().map((a) => a.id)).toContain("universal"); - expect(getAgentTargets()).toHaveLength(11); + const ids = getAgentTargets().map((agent) => agent.id); + expect(ids).toContain("universal"); + expect(ids).toContain("universal-xdg"); + expect(getAgentTargets()).toHaveLength(65); + // eve (no global dir, upstream forces direct writes) and promptscript (project-only) + // cannot participate in global symlink fan-out + expect(ids).not.toContain("eve"); + expect(ids).not.toContain("promptscript"); expect(detectInstalledAgents()).toEqual([]); mkdirSync(join(home, ".claude"), { recursive: true }); @@ -68,6 +96,100 @@ test("agents: registry has universal + 11 agents, only detects those whose confi }); }); +test("agents: expanded registry detects per-agent config dirs", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".roo"), { recursive: true }); + mkdirSync(join(home, ".trae"), { recursive: true }); + mkdirSync(join(home, ".gemini"), { recursive: true }); + mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true }); + mkdirSync(join(home, ".snowflake", "cortex"), { recursive: true }); + + const detected = detectInstalledAgents().map((agent) => agent.id); + expect(detected).toEqual(["cortex", "gemini-cli", "roo", "trae", "windsurf"]); + + // skills dirs follow each agent's own convention + const targets = getAgentTargets(); + expect(targets.find((agent) => agent.id === "windsurf")?.skillsDir).toBe( + join(home, ".codeium", "windsurf", "skills"), + ); + expect(targets.find((agent) => agent.id === "cortex")?.skillsDir).toBe( + join(home, ".snowflake", "cortex", "skills"), + ); + }); +}); + +test("agents: shared-dir agents (Warp/Zed/Kimi/…) light up the universal target", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".warp"), { recursive: true }); + mkdirSync(join(home, ".config", "zed"), { recursive: true }); + + const detected = detectInstalledAgents(); + expect(detected.map((agent) => agent.id)).toEqual(["universal"]); + expect(detected[0].skillsDir).toBe(join(home, ".agents", "skills")); + + seedCanonicalSkill("demo"); + linkSkillToAgents("demo"); + expect(lstatSync(join(home, ".agents", "skills", "demo")).isSymbolicLink()).toBe(true); + // No per-agent dirs were invented for shared-dir agents + expect(existsSync(join(home, ".warp", "skills"))).toBe(false); + }); +}); + +test("agents: Replit project marker in cwd lights up universal-xdg", async () => { + await inFakeHome(async (home) => { + const previousCwd = process.cwd(); + process.chdir(home); + try { + expect(detectInstalledAgents()).toEqual([]); + mkdirSync(join(home, ".replit"), { recursive: true }); + const detected = detectInstalledAgents(); + expect(detected.map((agent) => agent.id)).toEqual(["universal-xdg"]); + expect(detected[0].skillsDir).toBe(join(home, ".config", "agents", "skills")); + } finally { + process.chdir(previousCwd); + } + }); +}); + +test("agents: OpenClaw historical alias dirs are detected and link into the existing home", async () => { + await inFakeHome(async (home) => { + // Only the legacy .clawdbot home exists → links must land there, not in .openclaw + mkdirSync(join(home, ".clawdbot"), { recursive: true }); + const openclaw = detectInstalledAgents().find((agent) => agent.id === "openclaw"); + expect(openclaw?.skillsDir).toBe(join(home, ".clawdbot", "skills")); + + seedCanonicalSkill("demo"); + linkSkillToAgents("demo"); + expect(lstatSync(join(home, ".clawdbot", "skills", "demo")).isSymbolicLink()).toBe(true); + expect(existsSync(join(home, ".openclaw"))).toBe(false); + }); +}); + +test("agents: VIBE_HOME/HERMES_HOME/AUTOHAND_HOME/GROK_HOME relocate their agents", async () => { + await inFakeHome(async (home) => { + const customDirs = { + "mistral-vibe": join(home, "custom-vibe"), + hermes: join(home, "custom-hermes"), + "autohand-code": join(home, "custom-autohand"), + grok: join(home, "custom-grok"), + }; + process.env.VIBE_HOME = customDirs["mistral-vibe"]; + process.env.HERMES_HOME = customDirs.hermes; + process.env.AUTOHAND_HOME = customDirs["autohand-code"]; + process.env.GROK_HOME = customDirs.grok; + for (const dir of Object.values(customDirs)) { + mkdirSync(dir, { recursive: true }); + } + + const targets = getAgentTargets(); + for (const [id, baseDir] of Object.entries(customDirs)) { + const target = targets.find((agent) => agent.id === id); + expect(target?.skillsDir).toBe(join(baseDir, "skills")); + expect(detectInstalledAgents().map((agent) => agent.id)).toContain(id); + } + }); +}); + test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => { await inFakeHome(async (home) => { mkdirSync(join(home, ".claude"), { recursive: true }); @@ -127,3 +249,137 @@ test("agents: unlink reclaims managed links, leaves foreign content untouched", expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false); }); }); + +test("agents: official config-dir env vars relocate detection and fan-out", async () => { + await inFakeHome(async (home) => { + const customClaude = join(home, "relocated-claude"); + const customCodex = join(home, "relocated-codex"); + mkdirSync(customClaude, { recursive: true }); + mkdirSync(customCodex, { recursive: true }); + process.env.CLAUDE_CONFIG_DIR = customClaude; + process.env.CODEX_HOME = customCodex; + + const targets = getAgentTargets(); + const claude = targets.find((agent) => agent.id === "claude-code"); + const codex = targets.find((agent) => agent.id === "codex"); + expect(claude?.skillsDir).toBe(join(customClaude, "skills")); + expect(codex?.detectDirs).toEqual([customCodex, "/etc/codex"]); + + // Detected via the relocated dirs even though default ~/.claude and ~/.codex are absent + const detected = detectInstalledAgents().map((agent) => agent.id); + expect(detected).toContain("claude-code"); + expect(detected).toContain("codex"); + expect(existsSync(join(home, ".claude"))).toBe(false); + + // Fan-out lands in the relocated config dir, not the default location + seedCanonicalSkill("demo"); + const results = linkSkillToAgents("demo"); + const claudeLink = results.find((link) => link.agent === "claude-code"); + expect(claudeLink?.path).toBe(join(customClaude, "skills", "demo")); + expect(lstatSync(claudeLink!.path).isSymbolicLink()).toBe(true); + }); +}); + +test("agents: Amp-style XDG config dir lights up the universal-xdg shared target", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".config", "amp"), { recursive: true }); + const xdg = detectInstalledAgents().find((agent) => agent.id === "universal-xdg"); + expect(xdg?.skillsDir).toBe(join(home, ".config", "agents", "skills")); + + seedCanonicalSkill("demo"); + linkSkillToAgents("demo"); + const sharedLink = join(home, ".config", "agents", "skills", "demo"); + expect(lstatSync(sharedLink).isSymbolicLink()).toBe(true); + }); +}); + +test("agents: recorded copy-fallback artifact is replaced; unrecorded dir stays skipped", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + const canonical = seedCanonicalSkill("demo"); + const copyPath = join(home, ".claude", "skills", "demo"); + + // Simulate a previous install that fell back to copy (no symlink permission, e.g. Windows) + mkdirSync(copyPath, { recursive: true }); + writeFileSync(join(copyPath, "SKILL.md"), "stale copy"); + + // Without a lock record the dir is foreign → skipped, content untouched + const unrecorded = linkSkillToAgents("demo"); + expect(unrecorded[0].mode).toBe("skipped"); + expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toBe("stale copy"); + + // With the recorded link the artifact is rebuilt and points at canonical again + const recorded = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]); + expect(recorded[0]).toMatchObject({ agent: "claude-code", mode: "symlink" }); + expect(lstatSync(copyPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(copyPath)).toBe(canonical); + + // Subsequent runs keep refreshing through the rebuilt link + writeFileSync(join(canonical, "SKILL.md"), "---\nname: x\ndescription: y\n---\nv2\n"); + const refreshed = linkSkillToAgents("demo", detectInstalledAgents(), [copyPath]); + expect(refreshed[0].mode).toBe("symlink"); + expect(readFileSync(join(copyPath, "SKILL.md"), "utf-8")).toContain("v2"); + }); +}); + +test("agents: recorded plain file (not a copy dir) is never replaced", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude", "skills"), { recursive: true }); + seedCanonicalSkill("demo"); + const filePath = join(home, ".claude", "skills", "demo"); + writeFileSync(filePath, "user file"); + + // Even when (erroneously) recorded, a non-directory never qualifies as a copy artifact + const results = linkSkillToAgents("demo", detectInstalledAgents(), [filePath]); + expect(results[0].mode).toBe("skipped"); + expect(readFileSync(filePath, "utf-8")).toBe("user file"); + }); +}); + +test("agents: unlink removes recorded copy-fallback directories", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + seedCanonicalSkill("demo"); + const copyPath = join(home, ".claude", "skills", "demo"); + mkdirSync(copyPath, { recursive: true }); + writeFileSync(join(copyPath, "SKILL.md"), "copy"); + + const removed = unlinkSkillFromAgents("demo", [copyPath]); + expect(removed).toEqual([copyPath]); + expect(existsSync(copyPath)).toBe(false); + }); +}); + +test("fanout: recorded path of an unvisited agent stays in the ledger", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude"), { recursive: true }); + seedCanonicalSkill("demo"); + // Simulate a copy artifact left by an agent that is no longer detected (e.g. uninstalled + // Qoder): its recorded path must survive the merge so bl skill remove can still reclaim it + const orphanPath = join(home, ".qoder", "skills", "demo"); + + const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [orphanPath]); + + expect(fanout.linkedAgents).toEqual(["claude-code"]); + const claudeLink = join(home, ".claude", "skills", "demo"); + expect(fanout.links).toContain(claudeLink); + expect(fanout.links).toContain(orphanPath); + }); +}); + +test("fanout: recorded path confirmed foreign this run is dropped from the ledger", async () => { + await inFakeHome(async (home) => { + mkdirSync(join(home, ".claude", "skills"), { recursive: true }); + seedCanonicalSkill("demo"); + // User replaced our artifact with their own plain file → scanned, skipped as unmanaged; + // keeping the record would let bl skill remove delete user content + const foreignPath = join(home, ".claude", "skills", "demo"); + writeFileSync(foreignPath, "user file"); + + const fanout = fanOutSkillToAgents("demo", detectInstalledAgents(), [foreignPath]); + + expect(fanout.linkedAgents).toEqual([]); + expect(fanout.links).not.toContain(foreignPath); + expect(readFileSync(foreignPath, "utf-8")).toBe("user file"); + }); +}); diff --git a/packages/core/tests/skills-installer.test.ts b/packages/core/tests/skills-installer.test.ts index 63dd3d0..13dfcb7 100644 --- a/packages/core/tests/skills-installer.test.ts +++ b/packages/core/tests/skills-installer.test.ts @@ -1,12 +1,14 @@ -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; +import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs"; import { createHash } from "crypto"; import { tmpdir } from "os"; import { join } from "path"; import { brotliCompressSync } from "zlib"; import tar from "tar-stream"; -import { expect, test } from "vite-plus/test"; +import { afterEach, expect, test, vi } from "vite-plus/test"; import { BailianError } from "../src/errors/base.ts"; -import { installSkillFromBuffer } from "../src/skills/installer.ts"; +import type { AgentTarget } from "../src/skills/agents.ts"; +import { isSafeEntryName } from "../src/skills/extract.ts"; +import { installSkillFromBuffer, installSkillWithFanout } from "../src/skills/installer.ts"; import { getSkillsDir } from "../src/skills/lock.ts"; /** Run in an isolated temp config dir, restore env afterwards. */ @@ -82,6 +84,31 @@ test("installer: tar-slip entry → rejected and canonical not written", async ( }); }); +test("installer: backslash entry names rejected (Windows tar-slip vector)", async () => { + await inTempConfigDir(async () => { + const buf = await buildTarBr({ + "SKILL.md": VALID_SKILL_MD, + "foo\\..\\evil.txt": "pwned\n", + }); + await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/); + expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false); + }); +}); + +test("extract: entry name safety rules", async () => { + expect(isSafeEntryName("SKILL.md")).toBe(true); + expect(isSafeEntryName("references/usage.md")).toBe(true); + expect(isSafeEntryName("../evil")).toBe(false); + expect(isSafeEntryName("a/../../evil")).toBe(false); + expect(isSafeEntryName("/abs/path")).toBe(false); + expect(isSafeEntryName("C:/windows")).toBe(false); + // Backslashes: drive-root escape and "\.." expansion on Windows + expect(isSafeEntryName("foo\\bar")).toBe(false); + expect(isSafeEntryName("\\evil")).toBe(false); + expect(isSafeEntryName("foo\\..\\evil")).toBe(false); + expect(isSafeEntryName("nul\0byte")).toBe(false); +}); + test("installer: SKILL.md validation fails → previously installed version preserved as-is", async () => { await inTempConfigDir(async () => { await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD })); @@ -136,3 +163,77 @@ test("installer: contentHash mismatch → rejected, previous install preserved", expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]); }); }); + +// ---- installSkillWithFanout: download + install + fan-out + lock entry in one workflow ---- + +/** Stub global fetch to serve the given archive for any asset URL */ +function stubAssetDownload(tarBrBuffer: Buffer): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + arrayBuffer: async () => + tarBrBuffer.buffer.slice( + tarBrBuffer.byteOffset, + tarBrBuffer.byteOffset + tarBrBuffer.byteLength, + ), + })), + ); +} + +/** Fake agent whose skills dir lives inside the temp config dir (never touches real HOME) */ +function fakeAgent(id: string, baseDir: string): AgentTarget { + return { + id, + displayName: id, + skillsDir: join(baseDir, id, "skills"), + detectDirs: [join(baseDir, id)], + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test("fanout install: downloads, links agents, and builds lock entry with merged ledger", async () => { + await inTempConfigDir(async () => { + const configDir = process.env.BAILIAN_CONFIG_DIR!; + const files = { "SKILL.md": VALID_SKILL_MD }; + stubAssetDownload(await buildTarBr(files)); + const agent = fakeAgent("claude-code", configDir); + // Recorded path of an agent absent from this run: must survive into the merged ledger + const orphanPath = join(configDir, "gone-agent", "skills", "demo"); + + const record = await installSkillWithFanout( + "demo", + { contentHash: expectedHashOf(files), publishedAt: "2026-08-01 10:00:00" }, + [agent], + [orphanPath], + ); + + expect(record.linkedAgents).toEqual(["claude-code"]); + const linkPath = join(agent.skillsDir, "demo"); + expect(lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(record.lockEntry).toMatchObject({ + contentHash: expectedHashOf(files), + publishedAt: "2026-08-01 10:00:00", + sourceType: "oss", + }); + expect(record.lockEntry.links).toContain(linkPath); + expect(record.lockEntry.links).toContain(orphanPath); + }); +}); + +test("fanout install: download failure surfaces as BailianError and leaves no canonical dir", async () => { + await inTempConfigDir(async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 404 })), + ); + await expect( + installSkillWithFanout("demo", { contentHash: "sha256:whatever" }, []), + ).rejects.toThrow(BailianError); + expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false); + }); +}); From 01ec13aad88406338a6d818a2cf464934cb46c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Wed, 5 Aug 2026 17:34:00 +0800 Subject: [PATCH 2/2] feat: add CLI source config tags --- AGENTS.md | 1 + docs/agents/telemetry-change.md | 165 ++++++++++++++++++ .../commands/src/commands/speech/recognize.ts | 5 +- packages/core/src/client/client.ts | 7 +- packages/core/src/client/headers.ts | 30 ++-- packages/core/src/client/http.ts | 6 +- packages/core/src/client/index.ts | 2 +- .../core/src/client/instrumented-fetch.ts | 2 +- packages/core/src/client/mcp.ts | 2 +- packages/core/src/files/upload.ts | 23 ++- .../core/tests/instrumented-fetch.test.ts | 18 +- packages/runtime/src/utils/download.ts | 6 +- packages/runtime/src/utils/update-checker.ts | 6 +- 13 files changed, 230 insertions(+), 43 deletions(-) create mode 100644 docs/agents/telemetry-change.md diff --git a/AGENTS.md b/AGENTS.md index e064e64..ce3b7bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,7 @@ Skill / 命令手册随 `skills/bailian-*/` 经 `npx skills add modelstudioai/cl | Skill 文案 / 路由 | 改 SKILL 路由、安装约定、hand-off、hub/领域边界 | [docs/agents/skill-change.md](docs/agents/skill-change.md) | | 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | | URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 埋点变更 | 改 AEM 命令事件、后端渠道 header、User-Agent | [docs/agents/telemetry-change.md](docs/agents/telemetry-change.md) | | 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | | 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | | Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) | diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md new file mode 100644 index 0000000..9838e92 --- /dev/null +++ b/docs/agents/telemetry-change.md @@ -0,0 +1,165 @@ +# 埋点变更 + +## 触发条件 + +- 调整 AEM 命令事件、事件字段或参数 allowlist +- 调整 `User-Agent`、`x-dashscope-source-config` 或其他后端渠道标识 +- 新增鉴权域、请求网关或绕开统一 Client 的网络出口 +- 排查命令量、成功率、版本、鉴权域或后端渠道数据不一致 + +## 当前数据流 + +三套鉴权对应三套请求域,但不代表三套网关使用相同的后端埋点。命令侧另有一套覆盖所有实际执行命令的 AEM 客户端事件,两者必须分开理解。 + +```text +命令进入 run + ├─ telemetryStage + │ ├─ ~/.bailian/telemetry.jsonl + │ └─ AEM(pid=bailian-cli-node, event name=命令路径) + │ + └─ authStage + ├─ apiKey → DashScope / 模型域 + ├─ console → Bailian Console Gateway + ├─ openapi → 阿里云 OpenAPI + └─ none → 无凭证域;本地命令也仍有 AEM 命令事件 +``` + +### 1. 三套鉴权与埋点标识 + +| 命令声明 | 凭证 / 请求域 | 主要请求出口 | 后端埋点标识 | 前端埋点标识(AEM) | +| ----------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------ | +| `auth: "apiKey"` | API Key;DashScope / OpenAI-compatible 模型域 | `Client.request/requestJson`、`McpClient`、Managed Agent instrumented fetch、上传策略 | 有:`User-Agent`、`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=apiKey` | +| `auth: "console"` | Console access token;Bailian Console Gateway | `callConsoleGateway()` → `/cli/api.json` | 无 | 有:`pid=bailian-cli-node`、`authMethod=console` | +| `auth: "openapi"` | AccessKey ID/Secret,可选 STS token;阿里云 OpenAPI | `Client.openApiJson()` | 有:`x-dashscope-source-config` | 有:`pid=bailian-cli-node`、`authMethod=openapi` | +| `auth: "none"` | 无凭证域 | 本地逻辑或命令自行管理的登录/配置流程 | 无 | 有:`pid=bailian-cli-node`、`authMethod=none` | + +`authMethod` 记录的是命令声明的鉴权域,不是凭证来源。它不会区分 API Key 来自 flag、env 还是 config。 +鉴权域是命令的准入门槛和主请求域,不保证命令内部只有一种网络出口;例如部分 `apiKey` 命令也可能读取匿名 Console 公共目录,Managed Agent 还可能访问其他 provider。 + +表中的后端埋点按该鉴权域的主要业务请求填写: + +- Managed Agent 的 `User-Agent` 对所有 SDK 请求注入;`x-dashscope-source-config` 仅对阿里云 host 注入 +- DashScope 上传策略 `getPolicy` 只有 `x-dashscope-source-config`,没有显式 CLI `User-Agent` +- OpenAPI 的 ACS 签名头,以及 Console Gateway 的 `product`、`action`、`api` 是鉴权或路由字段,不计为埋点标识 + +### 2. 后端渠道参数 + +当前 `x-dashscope-source-config` 结构为: + +```json +{ + "channel": "bailian-cli", + "tags": { + "t1": "public", + "t2": "bl 或 kscli", + "t3": "实际 CLI 版本" + } +} +``` + +- `t2` 取产品 `identity.binName`:完整 CLI 为 `bl`,Knowledge Studio CLI 为 `kscli` +- `t3` 取产品 `identity.version`,由产品入口的 `package.json` 注入 +- `channel` 与 `t1` 是当前固定口径 +- `User-Agent` 是独立标识:`bl` 为 `bailian-cli/`,`kscli` 为 `knowledge-studio-cli/` + +source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网络传输: + +| 请求 | source-config | +| ------------------------------------ | ------------- | +| 模型 API、任务提交与轮询 | 有 | +| Bailian MCP / OpenAPI | 有 | +| DashScope 上传策略 `getPolicy` | 有 | +| OSS 文件上传 | 无 | +| 图片、视频、音频、转录结果下载 | 无 | +| npm / 二进制更新检查、Skill registry | 无 | + +当前已知例外:Pipeline runtime 自建的 `Identity.version` 为 `0.0.0-dev`,因此 Pipeline 内部模型请求的 `t3` 不代表产品包版本;现阶段不纳入本轮收敛。 + +### 3. 全命令 AEM 客户端埋点 + +`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。 + +以下情况不会形成命令事件,因为没有进入 middleware 的 `run`: + +- 根帮助、子命令 `--help`、`--version` +- 未识别命令、参数解析失败、缺少必填参数 +- `defineCommand.validate` 在 dispatch 阶段拒绝的请求 + +遥测默认开启;`DO_NOT_TRACK=1` 一票否决,配置文件 `telemetry: false` 也可关闭。关闭后本地和远端均不记录。 + +单条 `TrackingEvent` 当前包含: + +- `command`、`timestamp`、`durationMs`、`success` +- `cliVersion`、`nodeVersion`、`os` +- `authMethod` +- 失败时的 `errorMessage`、`httpStatus`、`requestId` +- 安全 allowlist 过滤后的 `params` + +参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。 + +事件同时写入两处: + +1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建 +2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod` + +底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。 + +当前 AEM 事件没有 `binName` 或 `clientName` 产品维度,并且 `bl`、`kscli` 共用 `pid=bailian-cli-node`。两边相同路径的 `config show`、`config set`、`update` 无法仅凭当前事件稳定区分产品;Knowledge 命令虽然因路径映射不同而表现为 `knowledge chat` 与 `chat`,也不应把命令路径当作长期产品标识。后端 source-config 的 `t2` 已能区分 `bl/kscli`,但这个维度尚未进入 AEM 客户端事件。 + +AEM 映射: + +| AEM 字段 | 内容 | +| ---------- | ----------------------------------------- | +| event name | 命令路径 | +| `et` | `EXP` | +| `ext` | 除 `command`、`params` 外的结构化事件字段 | +| `c1` | allowlist 参数 | +| `c2` | `success` / `failure` | +| `c3` | HTTP status | +| `c4` | 错误文案,最多 500 字符 | +| `c5` | request ID | + +远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。 + +## 必查清单 + +### A. 新增或调整命令 + +- [ ] `defineCommand({ auth })` 必须声明真实请求域;AEM 的 `authMethod` 直接读取该值 +- [ ] 新命令进入 `run` 后自动有基础事件,不得在命令内重复发送同名事件 +- [ ] 需要按产品分析 AEM 数据时,必须显式设计产品字段;不得从命令路径推断 `bl/kscli` +- [ ] 只有可枚举、数值或布尔等低风险字段才可加入 `PARAM_ALLOWLIST` +- [ ] 新增 console raw API flag 时只允许记录公开 API 名,不得记录请求 `data` + +### B. 调整后端渠道参数 + +- [ ] 同时核对 `packages/core/src/client/http.ts`、`mcp.ts`、`instrumented-fetch.ts`、`client.ts` 与 `files/upload.ts` +- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测 +- [ ] `bl` 与 `kscli` 必须分别验证 `binName`、`clientName`、`version` +- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config +- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单 + +### C. 调整 AEM 事件 + +- [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射 +- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径 +- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码 +- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口 +- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径 + +## 完成后自查 + +```sh +rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts' +rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts' +vp check +vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts +``` + +## 常见漏点 + +- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计 +- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域 +- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config,或把它发给 OSS / npm / 第三方下载地址 +- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误 +- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware diff --git a/packages/commands/src/commands/speech/recognize.ts b/packages/commands/src/commands/speech/recognize.ts index 8fbaa66..5af606b 100644 --- a/packages/commands/src/commands/speech/recognize.ts +++ b/packages/commands/src/commands/speech/recognize.ts @@ -9,7 +9,6 @@ import { type DashScopeASRRequest, type DashScopeASRTaskResult, type DashScopeAsyncResponse, - trackingHeaders, stripUndefined, taskPath, speechRecognizePath, @@ -201,9 +200,7 @@ async function handleAsyncMode( } // Fetch transcription JSON - const transRes = await fetch(subResult.transcription_url, { - headers: trackingHeaders(), - }); + const transRes = await fetch(subResult.transcription_url); if (!transRes.ok) { throw new BailianError( `Failed to download transcription: HTTP ${transRes.status}`, diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index b9810bf..e24558a 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -126,7 +126,10 @@ export class Client { /** Resolve a file arg: upload a local path to OSS (returns oss:// URL), or pass a URL through. */ uploadFile(source: string, model: string, opts: { signal?: AbortSignal } = {}): Promise { if (!isLocalFile(source)) return Promise.resolve(source); - return resolveFileUrl(source, this.requireApi().token, model, opts); + return resolveFileUrl(source, this.requireApi().token, model, { + ...opts, + identity: this.deps.identity, + }); } /** @@ -233,7 +236,7 @@ export class Client { const timeoutMs = this.deps.settings.timeout * 1000; const res = await fetch(endpoint, { method: opts.method, - headers: { ...headers, ...trackingHeaders() }, + headers: { ...headers, ...trackingHeaders(this.deps.identity) }, body: bodyStr || undefined, signal: AbortSignal.timeout(timeoutMs), }); diff --git a/packages/core/src/client/headers.ts b/packages/core/src/client/headers.ts index d572950..9d84d39 100644 --- a/packages/core/src/client/headers.ts +++ b/packages/core/src/client/headers.ts @@ -1,23 +1,31 @@ /** * Shared HTTP request headers for all outgoing requests. * - * Centralises the `x-dashscope-source-config` header so every fetch call - * (both via the central http client and the bypass paths) uses the - * same values from a single source of truth. + * Centralises the `x-dashscope-source-config` header so Bailian/DashScope API + * transports use the same product identity. Generic npm, OSS, and result-file + * transfers deliberately do not send this gateway-consumed metadata. */ +import type { Identity } from "../config/schema.ts"; + export const CHANNEL = "bailian-cli"; -export const TAGS = { t1: "public", t2: "" }; +export type TrackingIdentity = Pick; -export const SOURCE_CONFIG = JSON.stringify({ - channel: CHANNEL, - tags: TAGS, -}); +export function sourceConfig(identity: TrackingIdentity): string { + return JSON.stringify({ + channel: CHANNEL, + tags: { + t1: "public", + t2: identity.binName, + t3: identity.version, + }, + }); +} -/** Standard tracking headers required on every outbound request. */ -export function trackingHeaders(): Record { +/** Tracking headers for Bailian/DashScope API requests. */ +export function trackingHeaders(identity: TrackingIdentity): Record { return { - "x-dashscope-source-config": SOURCE_CONFIG, + "x-dashscope-source-config": sourceConfig(identity), }; } diff --git a/packages/core/src/client/http.ts b/packages/core/src/client/http.ts index ace47a8..d4e37ab 100644 --- a/packages/core/src/client/http.ts +++ b/packages/core/src/client/http.ts @@ -4,7 +4,7 @@ import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { mapApiError } from "../errors/api.ts"; import { maskToken } from "../utils/token.ts"; -import { SOURCE_CONFIG, trackingHeaders } from "./headers.ts"; +import { sourceConfig, trackingHeaders } from "./headers.ts"; /** 传输层依赖:UA 用 identity,timeout/verbose 用 settings。凭证由调用方(Client)注头。 */ export interface HttpDeps { @@ -39,7 +39,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise = { "User-Agent": `${deps.identity.clientName}/${deps.identity.version}`, - ...trackingHeaders(), + ...trackingHeaders(deps.identity), ...opts.headers, }; @@ -59,7 +59,7 @@ export async function request(deps: HttpDeps, opts: RequestOpts): Promise ${opts.method ?? "GET"} ${opts.url}`); const auth = headers["Authorization"]; if (auth) console.error(`> Auth: ${maskToken(auth.replace(/^Bearer /, ""))}`); - console.error(`> x-dashscope-source-config: ${SOURCE_CONFIG}`); + console.error(`> x-dashscope-source-config: ${sourceConfig(deps.identity)}`); } const timeoutMs = (opts.timeout ?? deps.settings.timeout) * 1000; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index a10e28d..31bd04a 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -34,7 +34,7 @@ export { type ImageInputStyle, type ImageSizeProfile, } from "./image-routes.ts"; -export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts"; +export { CHANNEL, sourceConfig, trackingHeaders, type TrackingIdentity } from "./headers.ts"; export type { HttpDeps, RequestOpts } from "./http.ts"; export { request, requestJson } from "./http.ts"; export { createInstrumentedFetch, type FetchImplementation } from "./instrumented-fetch.ts"; diff --git a/packages/core/src/client/instrumented-fetch.ts b/packages/core/src/client/instrumented-fetch.ts index da27495..d053de6 100644 --- a/packages/core/src/client/instrumented-fetch.ts +++ b/packages/core/src/client/instrumented-fetch.ts @@ -50,7 +50,7 @@ export function createInstrumentedFetch(deps: HttpDeps): FetchImplementation { headers.set("User-Agent", `${deps.identity.clientName}/${deps.identity.version}`); } if (isAlibabaCloudHost(url)) { - for (const [name, value] of Object.entries(trackingHeaders())) { + for (const [name, value] of Object.entries(trackingHeaders(deps.identity))) { headers.set(name, value); } } diff --git a/packages/core/src/client/mcp.ts b/packages/core/src/client/mcp.ts index 47bd3ec..ef2d5d8 100644 --- a/packages/core/src/client/mcp.ts +++ b/packages/core/src/client/mcp.ts @@ -148,7 +148,7 @@ export class McpClient { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "User-Agent": `${this.deps.identity.clientName}/${this.deps.identity.version}`, - ...trackingHeaders(), + ...trackingHeaders(this.deps.identity), }; if (this.authToken) { diff --git a/packages/core/src/files/upload.ts b/packages/core/src/files/upload.ts index 2dffe61..e3ca40e 100644 --- a/packages/core/src/files/upload.ts +++ b/packages/core/src/files/upload.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync, statSync } from "fs"; import { basename, extname } from "path"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; -import { trackingHeaders } from "../client/headers.ts"; +import { trackingHeaders, type TrackingIdentity } from "../client/headers.ts"; import { REGIONS } from "../config/schema.ts"; // Pinned to cn region; thread baseUrl through if overseas upload becomes a requirement. @@ -36,6 +36,7 @@ interface UploadPolicyResponse { async function getUploadPolicy( apiKey: string, model: string, + identity: TrackingIdentity, signal?: AbortSignal, ): Promise { const url = `${UPLOAD_API}?action=getPolicy&model=${encodeURIComponent(model)}`; @@ -44,7 +45,7 @@ async function getUploadPolicy( headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", - ...trackingHeaders(), + ...trackingHeaders(identity), }, signal: policySignal.signal, }).finally(policySignal.cleanup); @@ -87,9 +88,6 @@ async function uploadToOSS( const uploadSignal = combineWithTimeout(120_000, signal); const res = await fetch(policy.upload_host, { method: "POST", - headers: { - ...trackingHeaders(), - }, body: form, signal: uploadSignal.signal, }).finally(uploadSignal.cleanup); @@ -109,6 +107,7 @@ export interface UploadOptions { apiKey: string; model: string; filePath: string; + identity: TrackingIdentity; signal?: AbortSignal; } @@ -160,7 +159,7 @@ export function redactDataUri(input: string): string { * The URL is valid for 48 hours. */ export async function uploadFile(opts: UploadOptions): Promise { - const { apiKey, model, filePath, signal } = opts; + const { apiKey, model, filePath, identity, signal } = opts; if (!existsSync(filePath)) { throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE); @@ -171,7 +170,7 @@ export async function uploadFile(opts: UploadOptions): Promise { throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE); } - const policy = await getUploadPolicy(apiKey, model, signal); + const policy = await getUploadPolicy(apiKey, model, identity, signal); return uploadToOSS(policy, filePath, signal); } @@ -193,10 +192,16 @@ export async function resolveFileUrl( input: string, apiKey: string, model: string, - opts: { signal?: AbortSignal } = {}, + opts: { identity: TrackingIdentity; signal?: AbortSignal }, ): Promise { if (!isLocalFile(input)) return input; - return uploadFile({ apiKey, model, filePath: input, signal: opts.signal }); + return uploadFile({ + apiKey, + model, + filePath: input, + identity: opts.identity, + signal: opts.signal, + }); } function combineWithTimeout( diff --git a/packages/core/tests/instrumented-fetch.test.ts b/packages/core/tests/instrumented-fetch.test.ts index 501c262..ccecf99 100644 --- a/packages/core/tests/instrumented-fetch.test.ts +++ b/packages/core/tests/instrumented-fetch.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vite-plus/test"; import type { Identity, Settings } from "../src/index.ts"; -import { createInstrumentedFetch, SOURCE_CONFIG } from "../src/index.ts"; +import { createInstrumentedFetch, sourceConfig } from "../src/index.ts"; const identity: Identity = { binName: "bl", @@ -51,7 +51,12 @@ test("adds UA and tracking header on Alibaba Cloud hosts", async () => { { method: "POST", headers: { Authorization: "Bearer k" } }, ); expect(headers.get("user-agent")).toBe("bailian-cli/1.2.3"); - expect(headers.get("x-dashscope-source-config")).toBe(SOURCE_CONFIG); + expect(headers.get("x-dashscope-source-config")).toBe( + JSON.stringify({ + channel: "bailian-cli", + tags: { t1: "public", t2: "bl", t3: "1.2.3" }, + }), + ); expect(headers.get("authorization")).toBe("Bearer k"); }); @@ -82,3 +87,12 @@ test("passes non-URL-parseable inputs through without tracking headers", async ( expect(url).toBe("/relative/path"); expect(headers.get("x-dashscope-source-config")).toBeNull(); }); + +test("uses kscli identity and version in source config", () => { + expect(sourceConfig({ binName: "kscli", version: "1.13.1" })).toBe( + JSON.stringify({ + channel: "bailian-cli", + tags: { t1: "public", t2: "kscli", t3: "1.13.1" }, + }), + ); +}); diff --git a/packages/runtime/src/utils/download.ts b/packages/runtime/src/utils/download.ts index 55224c2..9858c01 100644 --- a/packages/runtime/src/utils/download.ts +++ b/packages/runtime/src/utils/download.ts @@ -1,6 +1,6 @@ import { createWriteStream, mkdirSync, unlinkSync } from "fs"; import { dirname } from "path"; -import { BailianError, ExitCode, trackingHeaders } from "bailian-cli-core"; +import { BailianError, ExitCode } from "bailian-cli-core"; import { createProgressBar } from "../output/progress.ts"; import type { ReadableStreamReadResult } from "stream/web"; @@ -9,9 +9,7 @@ export async function downloadFile( destPath: string, opts?: { quiet?: boolean }, ): Promise<{ size: number }> { - const res = await fetch(url, { - headers: trackingHeaders(), - }); + const res = await fetch(url); if (!res.ok) { throw new BailianError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index d22812b..44dcabc 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -5,7 +5,6 @@ import { DEFAULT_INSTALL_PS1_URL, DEFAULT_INSTALL_SCRIPT_URL, getConfigDir, - trackingHeaders, getUpdateInstallMethod, } from "bailian-cli-core"; @@ -165,10 +164,7 @@ export async function fetchLatestVersion( try { const encoded = npmPackage.replace("/", "%2f"); const res = await fetch(`${NPM_REGISTRY}/${encoded}/latest`, { - headers: { - Accept: "application/json", - ...trackingHeaders(), - }, + headers: { Accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return null;