diff --git a/packages/cli/postinstall.js b/packages/cli/postinstall.js index bcd92831..6050035b 100644 --- a/packages/cli/postinstall.js +++ b/packages/cli/postinstall.js @@ -23,8 +23,10 @@ */ import { createHash } from "node:crypto"; import { + copyFileSync, createWriteStream, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -33,7 +35,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createBrotliDecompress } from "node:zlib"; @@ -52,6 +54,7 @@ const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/; const INDEX_TIMEOUT_MS = 3000; const DOWNLOAD_TIMEOUT_MS = 30000; +const WINDOWS_LEGACY_MAX_PATH = 259; function getConfigDir() { if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR; @@ -170,24 +173,115 @@ function computeDirContentHash(dir) { return `sha256:${hash.digest("hex")}`; } +function listTreeEntries(rootDir) { + const entries = []; + const visit = (currentDir) => { + for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((left, right) => + left.name.localeCompare(right.name), + )) { + const path = join(currentDir, entry.name); + const relativePath = relative(rootDir, path); + if (entry.isDirectory()) { + entries.push({ relativePath, type: "directory" }); + visit(path); + } else if (entry.isFile()) { + entries.push({ relativePath, type: "file" }); + } + } + }; + visit(rootDir); + return entries; +} + +function assertWindowsCompatiblePaths(sourceDir, projectedRoot) { + if (process.platform !== "win32") return; + for (const entry of listTreeEntries(sourceDir)) { + const pathLength = join(projectedRoot, entry.relativePath).length; + if (pathLength > WINDOWS_LEGACY_MAX_PATH) { + throw new Error( + `Windows-incompatible skill path (${pathLength} characters): ${entry.relativePath}. ` + + "The skill package must shorten this path before it can be installed safely.", + ); + } + } +} + +/** Replace children without renaming a root directory held open by a Windows agent. */ +function reconcileDirectoryContents(sourceDir, destDir) { + const sourceEntries = listTreeEntries(sourceDir); + const expectedPaths = new Set(sourceEntries.map((entry) => entry.relativePath)); + mkdirSync(destDir, { recursive: true }); + + for (const entry of sourceEntries) { + const sourcePath = join(sourceDir, entry.relativePath); + const destPath = join(destDir, entry.relativePath); + if (existsSync(destPath)) { + const destStat = lstatSync(destPath); + const typeMatches = entry.type === "directory" ? destStat.isDirectory() : destStat.isFile(); + if (!typeMatches || destStat.isSymbolicLink()) { + rmSync(destPath, { recursive: true, force: true }); + } + } + if (entry.type === "directory") { + mkdirSync(destPath, { recursive: true }); + } else { + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(sourcePath, destPath); + } + } + + const staleEntries = listTreeEntries(destDir) + .filter((entry) => !expectedPaths.has(entry.relativePath)) + .sort((left, right) => right.relativePath.length - left.relativePath.length); + for (const entry of staleEntries) { + rmSync(join(destDir, entry.relativePath), { recursive: true, force: true }); + } +} + +function cleanupBackup(backup) { + try { + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); + } catch { + /* keep the backup on disk rather than report a completed swap as failed */ + } +} + /** Atomic swap: tmpDir (same volume) → catalogDir. */ function atomicSwap(tmpDir, catalogDir) { mkdirSync(dirname(catalogDir), { recursive: true }); const backup = `${catalogDir}.old-${Date.now()}`; - if (existsSync(catalogDir)) renameSync(catalogDir, backup); + if (existsSync(catalogDir)) { + try { + renameSync(catalogDir, backup); + } catch (error) { + if (process.platform !== "win32" || (error?.code !== "EPERM" && error?.code !== "EBUSY")) { + throw error; + } + try { + reconcileDirectoryContents(catalogDir, backup); + reconcileDirectoryContents(tmpDir, catalogDir); + cleanupBackup(backup); + return; + } catch (fallbackError) { + try { + if (existsSync(backup)) reconcileDirectoryContents(backup, catalogDir); + } catch { + /* retain backup for manual recovery if an open file also blocks rollback */ + } + throw new Error( + `Skill directory is in use and could not be updated in place. Close running agent hosts and retry. ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, + { cause: fallbackError }, + ); + } + } + } try { renameSync(tmpDir, catalogDir); } catch (err) { if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir); throw err; } - // Best-effort cleanup (symmetric with core skills/extract.ts): the swap already - // succeeded, so a backup deletion failure must not fail the pre-download - try { - if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); - } catch { - /* keep the backup on disk rather than report a completed swap as failed */ - } + cleanupBackup(backup); } async function main() { @@ -208,6 +302,7 @@ async function main() { try { mkdirSync(tmpDir, { recursive: true }); await extractTarBr(tarBuf, tmpDir); + assertWindowsCompatiblePaths(tmpDir, catalogDir); // Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch // before touching the canonical dir if (entry.contentHash.startsWith("sha256:")) { diff --git a/packages/core/src/skills/extract.ts b/packages/core/src/skills/extract.ts index 85ec2cb4..275ea8c5 100644 --- a/packages/core/src/skills/extract.ts +++ b/packages/core/src/skills/extract.ts @@ -4,8 +4,10 @@ * zlib + tar-stream, no extra decompression dependencies. */ import { + copyFileSync, createWriteStream, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -13,7 +15,7 @@ import { rmSync, } from "node:fs"; import { createHash } from "node:crypto"; -import { dirname, join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { createBrotliDecompress } from "node:zlib"; @@ -85,27 +87,168 @@ export function computeDirContentHash(dir: string): string { return `sha256:${hash.digest("hex")}`; } +export const WINDOWS_LEGACY_MAX_PATH = 259; + +export interface WindowsPathViolation { + root: string; + relativePath: string; + pathLength: number; +} + +/** + * Project an extracted tree onto Windows-visible roots and return the longest path + * that exceeds the legacy MAX_PATH budget. This protects host agents that do not + * opt into long-path support even when the Node.js installer itself can write it. + */ +export function findWindowsPathViolation( + sourceDir: string, + projectedRoots: string[], + maxPath = WINDOWS_LEGACY_MAX_PATH, +): WindowsPathViolation | undefined { + let longestViolation: WindowsPathViolation | undefined; + const visit = (currentDir: string): void => { + for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const sourcePath = join(currentDir, entry.name); + const relativePath = relative(sourceDir, sourcePath); + for (const root of projectedRoots) { + const pathLength = join(root, relativePath).length; + if (pathLength > maxPath && pathLength > (longestViolation?.pathLength ?? 0)) { + longestViolation = { root, relativePath, pathLength }; + } + } + if (entry.isDirectory()) visit(sourcePath); + } + }; + + for (const root of projectedRoots) { + if (root.length > maxPath && root.length > (longestViolation?.pathLength ?? 0)) { + longestViolation = { root, relativePath: "", pathLength: root.length }; + } + } + visit(sourceDir); + return longestViolation; +} + +interface TreeEntry { + relativePath: string; + type: "directory" | "file"; +} + +function listTreeEntries(rootDir: string): TreeEntry[] { + const entries: TreeEntry[] = []; + const visit = (currentDir: string): void => { + for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const path = join(currentDir, entry.name); + const relativePath = relative(rootDir, path); + if (entry.isDirectory()) { + entries.push({ relativePath, type: "directory" }); + visit(path); + } else if (entry.isFile()) { + entries.push({ relativePath, type: "file" }); + } + } + }; + visit(rootDir); + return entries; +} + +/** Replace children without renaming the root directory, which may be held open on Windows. */ +function reconcileDirectoryContents(sourceDir: string, destDir: string): void { + const sourceEntries = listTreeEntries(sourceDir); + const expectedPaths = new Set(sourceEntries.map((entry) => entry.relativePath)); + mkdirSync(destDir, { recursive: true }); + + for (const entry of sourceEntries) { + const sourcePath = join(sourceDir, entry.relativePath); + const destPath = join(destDir, entry.relativePath); + if (existsSync(destPath)) { + const destStat = lstatSync(destPath); + const typeMatches = entry.type === "directory" ? destStat.isDirectory() : destStat.isFile(); + if (!typeMatches || destStat.isSymbolicLink()) { + rmSync(destPath, { recursive: true, force: true }); + } + } + if (entry.type === "directory") { + mkdirSync(destPath, { recursive: true }); + } else { + mkdirSync(dirname(destPath), { recursive: true }); + copyFileSync(sourcePath, destPath); + } + } + + const staleEntries = listTreeEntries(destDir) + .filter((entry) => !expectedPaths.has(entry.relativePath)) + .sort((left, right) => right.relativePath.length - left.relativePath.length); + for (const entry of staleEntries) { + rmSync(join(destDir, entry.relativePath), { recursive: true, force: true }); + } +} + +function isBlockedRenameError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EPERM" || code === "EBUSY"; +} + +function cleanupBackup(backup: string): void { + try { + if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); + } catch { + /* keep the backup on disk rather than report a completed install as failed */ + } +} + +export interface AtomicSwapOptions { + /** Test seam; defaults to enabled only on Windows. */ + allowInPlaceFallback?: boolean; +} + /** * Atomic swap: replace destDir with the extracted content from tmpDir. * tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic. + * If Windows blocks renaming an agent-open directory, preserve a copied backup and + * reconcile its children in place so the stable directory handle remains valid. */ -export function atomicSwap(tmpDir: string, destDir: string): void { +export function atomicSwap(tmpDir: string, destDir: string, options: AtomicSwapOptions = {}): void { mkdirSync(dirname(destDir), { recursive: true }); const backup = `${destDir}.old-${Date.now()}`; - if (existsSync(destDir)) renameSync(destDir, backup); + if (existsSync(destDir)) { + try { + renameSync(destDir, backup); + } catch (error) { + const allowInPlaceFallback = options.allowInPlaceFallback ?? process.platform === "win32"; + if (!allowInPlaceFallback || !isBlockedRenameError(error)) throw error; + + try { + reconcileDirectoryContents(destDir, backup); + reconcileDirectoryContents(tmpDir, destDir); + cleanupBackup(backup); + return; + } catch (fallbackError) { + try { + if (existsSync(backup)) reconcileDirectoryContents(backup, destDir); + } catch { + /* retain backup for manual recovery if an open file also blocks rollback */ + } + throw new Error( + `Skill directory is in use and could not be updated in place. Close running agent hosts and retry. / Skill 目录正被占用,无法原地更新;请关闭正在运行的 Agent 后重试。 ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, + { cause: fallbackError }, + ); + } + } + } try { renameSync(tmpDir, destDir); - } catch (err) { + } catch (error) { // Swap failed → roll back the old directory to avoid leaving a hole if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir); - throw err; + throw error; } // Best-effort cleanup: the swap already succeeded, so a backup deletion failure // (permissions, host safe-delete guards on large dirs) must not fail the install; // leftover .old-* dirs are inert (skill status scans ignore them) - try { - if (existsSync(backup)) rmSync(backup, { recursive: true, force: true }); - } catch { - /* keep the backup on disk rather than report a completed install as failed */ - } + cleanupBackup(backup); } diff --git a/packages/core/src/skills/installer.ts b/packages/core/src/skills/installer.ts index 05f1d6c5..eb0600c4 100644 --- a/packages/core/src/skills/installer.ts +++ b/packages/core/src/skills/installer.ts @@ -3,7 +3,12 @@ import { join } from "node:path"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import { detectInstalledAgents, fanOutSkillToAgents, type AgentTarget } from "./agents.ts"; -import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts"; +import { + atomicSwap, + computeDirContentHash, + extractTarBr, + findWindowsPathViolation, +} from "./extract.ts"; import { getSkillsDir } from "./lock.ts"; import { downloadSkillAsset } from "./registry.ts"; import { isSafeSkillName } from "./sanitize.ts"; @@ -36,6 +41,7 @@ export async function installSkillFromBuffer( name: string, tarBrBuffer: Buffer, expectedContentHash?: string, + windowsPathRoots?: string[], ): Promise { assertSafeName(name); const skillsDir = getSkillsDir(); @@ -45,6 +51,15 @@ export async function installSkillFromBuffer( try { mkdirSync(tmpDir, { recursive: true }); await extractTarBr(tarBrBuffer, tmpDir); + const projectedRoots = windowsPathRoots ?? (process.platform === "win32" ? [dest] : []); + const pathViolation = findWindowsPathViolation(tmpDir, projectedRoots); + if (pathViolation) { + throw new BailianError( + `Skill ${name} contains a Windows-incompatible path (${pathViolation.pathLength} characters): ${pathViolation.relativePath}`, + ExitCode.GENERAL, + "The skill package must shorten this path before it can be installed safely on Windows. / Skill 包必须缩短该路径后才能在 Windows 上安全安装。", + ); + } // Integrity check before touching canonical: recompute the publisher fingerprint over // the extracted files; on mismatch the current installation is left untouched if (expectedContentHash?.startsWith("sha256:")) { @@ -76,6 +91,7 @@ export async function installSkill( name: string, entry: SkillIndexEntry, downloadAttempts?: number, + windowsPathRoots?: string[], ): Promise { if (entry.compression && entry.compression !== "tar.br") { throw new BailianError( @@ -85,7 +101,7 @@ export async function installSkill( ); } const buffer = await downloadSkillAsset(name, entry, downloadAttempts); - return installSkillFromBuffer(name, buffer, entry.contentHash); + return installSkillFromBuffer(name, buffer, entry.contentHash, windowsPathRoots); } /** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */ @@ -136,7 +152,11 @@ export async function installSkillWithFanout( recordedLinks: string[] = [], downloadAttempts?: number, ): Promise { - await installSkill(name, entry, downloadAttempts); + const windowsPathRoots = + process.platform === "win32" + ? [join(getSkillsDir(), name), ...agents.map((agent) => join(agent.skillsDir, name))] + : undefined; + await installSkill(name, entry, downloadAttempts, windowsPathRoots); const fanout = fanOutSkillToAgents(name, agents, recordedLinks); return { lockEntry: buildSkillLockEntry(entry, fanout.links), diff --git a/packages/core/tests/skills-installer.test.ts b/packages/core/tests/skills-installer.test.ts index 32f82829..5940ba28 100644 --- a/packages/core/tests/skills-installer.test.ts +++ b/packages/core/tests/skills-installer.test.ts @@ -1,4 +1,13 @@ -import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -7,20 +16,22 @@ import tar from "tar-stream"; import { afterEach, expect, test, vi } from "vite-plus/test"; import { BailianError } from "../src/errors/base.ts"; import type { AgentTarget } from "../src/skills/agents.ts"; -import { isSafeEntryName } from "../src/skills/extract.ts"; +import { atomicSwap, isSafeEntryName } from "../src/skills/extract.ts"; import { installSkillFromBuffer, installSkillWithFanout } from "../src/skills/installer.ts"; import { getSkillsDir } from "../src/skills/lock.ts"; import { downloadSkillAsset, fetchSkillsIndex } from "../src/skills/registry.ts"; /** rmSync wrapped in a spy so tests can simulate host deletion guards (e.g. safe-delete) */ const fsMocks = vi.hoisted(() => ({ + renameSync: vi.fn(), rmSync: vi.fn(), })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); + fsMocks.renameSync.mockImplementation(actual.renameSync); fsMocks.rmSync.mockImplementation(actual.rmSync); - return { ...actual, rmSync: fsMocks.rmSync }; + return { ...actual, renameSync: fsMocks.renameSync, rmSync: fsMocks.rmSync }; }); /** Run in an isolated temp config dir, restore env afterwards. */ @@ -87,6 +98,29 @@ test("installer: reinstall atomically swaps, no old files left behind", async () }); }); +test("installer: blocked Windows root rename falls back to in-place reconciliation", async () => { + const root = mkdtempSync(join(tmpdir(), "bl-skill-swap-")); + const destDir = join(root, "demo"); + const tmpDir = join(root, ".tmp-demo"); + mkdirSync(join(destDir, "stale"), { recursive: true }); + mkdirSync(join(tmpDir, "references"), { recursive: true }); + writeFileSync(join(destDir, "SKILL.md"), "old\n"); + writeFileSync(join(destDir, "stale", "old.md"), "old\n"); + writeFileSync(join(tmpDir, "SKILL.md"), "new\n"); + writeFileSync(join(tmpDir, "references", "usage.md"), "new reference\n"); + + fsMocks.renameSync.mockImplementationOnce(() => { + throw Object.assign(new Error("directory is locked"), { code: "EPERM" }); + }); + atomicSwap(tmpDir, destDir, { allowInPlaceFallback: true }); + + expect(readFileSync(join(destDir, "SKILL.md"), "utf-8")).toBe("new\n"); + expect(readFileSync(join(destDir, "references", "usage.md"), "utf-8")).toBe("new reference\n"); + expect(existsSync(join(destDir, "stale"))).toBe(false); + expect(readdirSync(root).filter((entry) => entry.includes(".old-"))).toEqual([]); + rmSync(root, { recursive: true, force: true }); +}); + test("installer: tar-slip entry → rejected and canonical not written", async () => { await inTempConfigDir(async () => { const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "../evil.txt": "pwned\n" }); @@ -107,6 +141,22 @@ test("installer: backslash entry names rejected (Windows tar-slip vector)", asyn }); }); +test("installer: rejects a skill that would exceed a Windows agent path before swapping", async () => { + await inTempConfigDir(async () => { + await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD })); + const longRoot = join("C:\\Users\\demo\\.agent\\skills", "x".repeat(230), "demo"); + const updated = "---\nname: demo\ndescription: updated\n---\n"; + + await expect( + installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": updated }), undefined, [ + longRoot, + ]), + ).rejects.toThrow(/Windows-incompatible path/); + + expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD); + }); +}); + test("extract: entry name safety rules", async () => { expect(isSafeEntryName("SKILL.md")).toBe(true); expect(isSafeEntryName("references/usage.md")).toBe(true);