Skip to content

Commit 437ee4c

Browse files
authored
Merge pull request #226 from modelstudioai/fix/skill-issue
fix(skill): harden Windows updates and path handling
2 parents 72bc8fc + 5a73c60 commit 437ee4c

4 files changed

Lines changed: 333 additions & 25 deletions

File tree

‎packages/cli/postinstall.js‎

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
*/
2424
import { createHash } from "node:crypto";
2525
import {
26+
copyFileSync,
2627
createWriteStream,
2728
existsSync,
29+
lstatSync,
2830
mkdirSync,
2931
readdirSync,
3032
readFileSync,
@@ -33,7 +35,7 @@ import {
3335
writeFileSync,
3436
} from "node:fs";
3537
import { homedir } from "node:os";
36-
import { dirname, join } from "node:path";
38+
import { dirname, join, relative } from "node:path";
3739
import { Readable } from "node:stream";
3840
import { pipeline } from "node:stream/promises";
3941
import { createBrotliDecompress } from "node:zlib";
@@ -52,6 +54,7 @@ const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
5254

5355
const INDEX_TIMEOUT_MS = 3000;
5456
const DOWNLOAD_TIMEOUT_MS = 30000;
57+
const WINDOWS_LEGACY_MAX_PATH = 259;
5558

5659
function getConfigDir() {
5760
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
@@ -170,24 +173,115 @@ function computeDirContentHash(dir) {
170173
return `sha256:${hash.digest("hex")}`;
171174
}
172175

176+
function listTreeEntries(rootDir) {
177+
const entries = [];
178+
const visit = (currentDir) => {
179+
for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((left, right) =>
180+
left.name.localeCompare(right.name),
181+
)) {
182+
const path = join(currentDir, entry.name);
183+
const relativePath = relative(rootDir, path);
184+
if (entry.isDirectory()) {
185+
entries.push({ relativePath, type: "directory" });
186+
visit(path);
187+
} else if (entry.isFile()) {
188+
entries.push({ relativePath, type: "file" });
189+
}
190+
}
191+
};
192+
visit(rootDir);
193+
return entries;
194+
}
195+
196+
function assertWindowsCompatiblePaths(sourceDir, projectedRoot) {
197+
if (process.platform !== "win32") return;
198+
for (const entry of listTreeEntries(sourceDir)) {
199+
const pathLength = join(projectedRoot, entry.relativePath).length;
200+
if (pathLength > WINDOWS_LEGACY_MAX_PATH) {
201+
throw new Error(
202+
`Windows-incompatible skill path (${pathLength} characters): ${entry.relativePath}. ` +
203+
"The skill package must shorten this path before it can be installed safely.",
204+
);
205+
}
206+
}
207+
}
208+
209+
/** Replace children without renaming a root directory held open by a Windows agent. */
210+
function reconcileDirectoryContents(sourceDir, destDir) {
211+
const sourceEntries = listTreeEntries(sourceDir);
212+
const expectedPaths = new Set(sourceEntries.map((entry) => entry.relativePath));
213+
mkdirSync(destDir, { recursive: true });
214+
215+
for (const entry of sourceEntries) {
216+
const sourcePath = join(sourceDir, entry.relativePath);
217+
const destPath = join(destDir, entry.relativePath);
218+
if (existsSync(destPath)) {
219+
const destStat = lstatSync(destPath);
220+
const typeMatches = entry.type === "directory" ? destStat.isDirectory() : destStat.isFile();
221+
if (!typeMatches || destStat.isSymbolicLink()) {
222+
rmSync(destPath, { recursive: true, force: true });
223+
}
224+
}
225+
if (entry.type === "directory") {
226+
mkdirSync(destPath, { recursive: true });
227+
} else {
228+
mkdirSync(dirname(destPath), { recursive: true });
229+
copyFileSync(sourcePath, destPath);
230+
}
231+
}
232+
233+
const staleEntries = listTreeEntries(destDir)
234+
.filter((entry) => !expectedPaths.has(entry.relativePath))
235+
.sort((left, right) => right.relativePath.length - left.relativePath.length);
236+
for (const entry of staleEntries) {
237+
rmSync(join(destDir, entry.relativePath), { recursive: true, force: true });
238+
}
239+
}
240+
241+
function cleanupBackup(backup) {
242+
try {
243+
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
244+
} catch {
245+
/* keep the backup on disk rather than report a completed swap as failed */
246+
}
247+
}
248+
173249
/** Atomic swap: tmpDir (same volume) → catalogDir. */
174250
function atomicSwap(tmpDir, catalogDir) {
175251
mkdirSync(dirname(catalogDir), { recursive: true });
176252
const backup = `${catalogDir}.old-${Date.now()}`;
177-
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
253+
if (existsSync(catalogDir)) {
254+
try {
255+
renameSync(catalogDir, backup);
256+
} catch (error) {
257+
if (process.platform !== "win32" || (error?.code !== "EPERM" && error?.code !== "EBUSY")) {
258+
throw error;
259+
}
260+
try {
261+
reconcileDirectoryContents(catalogDir, backup);
262+
reconcileDirectoryContents(tmpDir, catalogDir);
263+
cleanupBackup(backup);
264+
return;
265+
} catch (fallbackError) {
266+
try {
267+
if (existsSync(backup)) reconcileDirectoryContents(backup, catalogDir);
268+
} catch {
269+
/* retain backup for manual recovery if an open file also blocks rollback */
270+
}
271+
throw new Error(
272+
`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)}`,
273+
{ cause: fallbackError },
274+
);
275+
}
276+
}
277+
}
178278
try {
179279
renameSync(tmpDir, catalogDir);
180280
} catch (err) {
181281
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
182282
throw err;
183283
}
184-
// Best-effort cleanup (symmetric with core skills/extract.ts): the swap already
185-
// succeeded, so a backup deletion failure must not fail the pre-download
186-
try {
187-
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
188-
} catch {
189-
/* keep the backup on disk rather than report a completed swap as failed */
190-
}
284+
cleanupBackup(backup);
191285
}
192286

193287
async function main() {
@@ -208,6 +302,7 @@ async function main() {
208302
try {
209303
mkdirSync(tmpDir, { recursive: true });
210304
await extractTarBr(tarBuf, tmpDir);
305+
assertWindowsCompatiblePaths(tmpDir, catalogDir);
211306
// Symmetric with layer 2 (core installer): reject archive/index fingerprint mismatch
212307
// before touching the canonical dir
213308
if (entry.contentHash.startsWith("sha256:")) {

‎packages/core/src/skills/extract.ts‎

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44
* zlib + tar-stream, no extra decompression dependencies.
55
*/
66
import {
7+
copyFileSync,
78
createWriteStream,
89
existsSync,
10+
lstatSync,
911
mkdirSync,
1012
readdirSync,
1113
readFileSync,
1214
renameSync,
1315
rmSync,
1416
} from "node:fs";
1517
import { createHash } from "node:crypto";
16-
import { dirname, join } from "node:path";
18+
import { dirname, join, relative } from "node:path";
1719
import { Readable } from "node:stream";
1820
import { pipeline } from "node:stream/promises";
1921
import { createBrotliDecompress } from "node:zlib";
@@ -85,27 +87,168 @@ export function computeDirContentHash(dir: string): string {
8587
return `sha256:${hash.digest("hex")}`;
8688
}
8789

90+
export const WINDOWS_LEGACY_MAX_PATH = 259;
91+
92+
export interface WindowsPathViolation {
93+
root: string;
94+
relativePath: string;
95+
pathLength: number;
96+
}
97+
98+
/**
99+
* Project an extracted tree onto Windows-visible roots and return the longest path
100+
* that exceeds the legacy MAX_PATH budget. This protects host agents that do not
101+
* opt into long-path support even when the Node.js installer itself can write it.
102+
*/
103+
export function findWindowsPathViolation(
104+
sourceDir: string,
105+
projectedRoots: string[],
106+
maxPath = WINDOWS_LEGACY_MAX_PATH,
107+
): WindowsPathViolation | undefined {
108+
let longestViolation: WindowsPathViolation | undefined;
109+
const visit = (currentDir: string): void => {
110+
for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) =>
111+
a.name.localeCompare(b.name),
112+
)) {
113+
const sourcePath = join(currentDir, entry.name);
114+
const relativePath = relative(sourceDir, sourcePath);
115+
for (const root of projectedRoots) {
116+
const pathLength = join(root, relativePath).length;
117+
if (pathLength > maxPath && pathLength > (longestViolation?.pathLength ?? 0)) {
118+
longestViolation = { root, relativePath, pathLength };
119+
}
120+
}
121+
if (entry.isDirectory()) visit(sourcePath);
122+
}
123+
};
124+
125+
for (const root of projectedRoots) {
126+
if (root.length > maxPath && root.length > (longestViolation?.pathLength ?? 0)) {
127+
longestViolation = { root, relativePath: "", pathLength: root.length };
128+
}
129+
}
130+
visit(sourceDir);
131+
return longestViolation;
132+
}
133+
134+
interface TreeEntry {
135+
relativePath: string;
136+
type: "directory" | "file";
137+
}
138+
139+
function listTreeEntries(rootDir: string): TreeEntry[] {
140+
const entries: TreeEntry[] = [];
141+
const visit = (currentDir: string): void => {
142+
for (const entry of readdirSync(currentDir, { withFileTypes: true }).sort((a, b) =>
143+
a.name.localeCompare(b.name),
144+
)) {
145+
const path = join(currentDir, entry.name);
146+
const relativePath = relative(rootDir, path);
147+
if (entry.isDirectory()) {
148+
entries.push({ relativePath, type: "directory" });
149+
visit(path);
150+
} else if (entry.isFile()) {
151+
entries.push({ relativePath, type: "file" });
152+
}
153+
}
154+
};
155+
visit(rootDir);
156+
return entries;
157+
}
158+
159+
/** Replace children without renaming the root directory, which may be held open on Windows. */
160+
function reconcileDirectoryContents(sourceDir: string, destDir: string): void {
161+
const sourceEntries = listTreeEntries(sourceDir);
162+
const expectedPaths = new Set(sourceEntries.map((entry) => entry.relativePath));
163+
mkdirSync(destDir, { recursive: true });
164+
165+
for (const entry of sourceEntries) {
166+
const sourcePath = join(sourceDir, entry.relativePath);
167+
const destPath = join(destDir, entry.relativePath);
168+
if (existsSync(destPath)) {
169+
const destStat = lstatSync(destPath);
170+
const typeMatches = entry.type === "directory" ? destStat.isDirectory() : destStat.isFile();
171+
if (!typeMatches || destStat.isSymbolicLink()) {
172+
rmSync(destPath, { recursive: true, force: true });
173+
}
174+
}
175+
if (entry.type === "directory") {
176+
mkdirSync(destPath, { recursive: true });
177+
} else {
178+
mkdirSync(dirname(destPath), { recursive: true });
179+
copyFileSync(sourcePath, destPath);
180+
}
181+
}
182+
183+
const staleEntries = listTreeEntries(destDir)
184+
.filter((entry) => !expectedPaths.has(entry.relativePath))
185+
.sort((left, right) => right.relativePath.length - left.relativePath.length);
186+
for (const entry of staleEntries) {
187+
rmSync(join(destDir, entry.relativePath), { recursive: true, force: true });
188+
}
189+
}
190+
191+
function isBlockedRenameError(error: unknown): boolean {
192+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
193+
return code === "EPERM" || code === "EBUSY";
194+
}
195+
196+
function cleanupBackup(backup: string): void {
197+
try {
198+
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
199+
} catch {
200+
/* keep the backup on disk rather than report a completed install as failed */
201+
}
202+
}
203+
204+
export interface AtomicSwapOptions {
205+
/** Test seam; defaults to enabled only on Windows. */
206+
allowInPlaceFallback?: boolean;
207+
}
208+
88209
/**
89210
* Atomic swap: replace destDir with the extracted content from tmpDir.
90211
* tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic.
212+
* If Windows blocks renaming an agent-open directory, preserve a copied backup and
213+
* reconcile its children in place so the stable directory handle remains valid.
91214
*/
92-
export function atomicSwap(tmpDir: string, destDir: string): void {
215+
export function atomicSwap(tmpDir: string, destDir: string, options: AtomicSwapOptions = {}): void {
93216
mkdirSync(dirname(destDir), { recursive: true });
94217
const backup = `${destDir}.old-${Date.now()}`;
95-
if (existsSync(destDir)) renameSync(destDir, backup);
218+
if (existsSync(destDir)) {
219+
try {
220+
renameSync(destDir, backup);
221+
} catch (error) {
222+
const allowInPlaceFallback = options.allowInPlaceFallback ?? process.platform === "win32";
223+
if (!allowInPlaceFallback || !isBlockedRenameError(error)) throw error;
224+
225+
try {
226+
reconcileDirectoryContents(destDir, backup);
227+
reconcileDirectoryContents(tmpDir, destDir);
228+
cleanupBackup(backup);
229+
return;
230+
} catch (fallbackError) {
231+
try {
232+
if (existsSync(backup)) reconcileDirectoryContents(backup, destDir);
233+
} catch {
234+
/* retain backup for manual recovery if an open file also blocks rollback */
235+
}
236+
throw new Error(
237+
`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)}`,
238+
{ cause: fallbackError },
239+
);
240+
}
241+
}
242+
}
96243
try {
97244
renameSync(tmpDir, destDir);
98-
} catch (err) {
245+
} catch (error) {
99246
// Swap failed → roll back the old directory to avoid leaving a hole
100247
if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir);
101-
throw err;
248+
throw error;
102249
}
103250
// Best-effort cleanup: the swap already succeeded, so a backup deletion failure
104251
// (permissions, host safe-delete guards on large dirs) must not fail the install;
105252
// leftover .old-* dirs are inert (skill status scans ignore them)
106-
try {
107-
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
108-
} catch {
109-
/* keep the backup on disk rather than report a completed install as failed */
110-
}
253+
cleanupBackup(backup);
111254
}

0 commit comments

Comments
 (0)