From 1f66aadfa3305d2f167b077c3708e2b72fca4112 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 25 Jun 2026 07:06:11 -0400 Subject: [PATCH] fix(openclaw): normalize Windows skill-loader URLs before fileURLToPath (#5679) --- integrations/openclaw/skill-loader.test.ts | 27 ++++++++++++++++++++++ integrations/openclaw/skill-loader.ts | 12 ++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/integrations/openclaw/skill-loader.test.ts b/integrations/openclaw/skill-loader.test.ts index ceb970884..cc5d82ed9 100644 --- a/integrations/openclaw/skill-loader.test.ts +++ b/integrations/openclaw/skill-loader.test.ts @@ -4,10 +4,12 @@ import { describe, it, expect } from "vitest"; import { safePath, + normalizeModuleUrlToPath, loadSkill, loadTriagePrompt, loadCompactTriagePrompt, } from "./skill-loader.ts"; +import { fileURLToPath, pathToFileURL } from "node:url"; // --------------------------------------------------------------------------- // safePath — path containment @@ -74,6 +76,31 @@ describe("loadSkill path traversal", () => { }); }); +describe("normalizeModuleUrlToPath", () => { + it("normalizes raw Windows paths before fileURLToPath conversion", () => { + const rawWindowsMetaUrl = "C:\\Users\\example\\openclaw\\index.ts"; + const result = normalizeModuleUrlToPath(rawWindowsMetaUrl); + // Assert the decoded property directly rather than reconstructing via the function body + expect(typeof result).toBe("string"); + expect(result).not.toContain("%5C"); + }); + + it("leaves already-correct file URLs unchanged", () => { + const fileMetaUrl = "file:///C:/Users/example/openclaw/index.ts"; + const expected = fileURLToPath(fileMetaUrl); + + expect(normalizeModuleUrlToPath(fileMetaUrl)).toBe(expected); + }); + + it.skipIf(process.platform === "win32")( + "passes POSIX absolute paths through unchanged", + () => { + const posixPath = "/home/user/openclaw/index.ts"; + expect(normalizeModuleUrlToPath(posixPath)).toBe(posixPath); + }, + ); +}); + describe("loadCompactTriagePrompt", () => { it("keeps the core triage instructions without inlining the full skill body", () => { const prompt = loadCompactTriagePrompt(); diff --git a/integrations/openclaw/skill-loader.ts b/integrations/openclaw/skill-loader.ts index b4958cb09..628c98e23 100644 --- a/integrations/openclaw/skill-loader.ts +++ b/integrations/openclaw/skill-loader.ts @@ -4,7 +4,7 @@ */ import * as path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { SkillsConfig, CategoryConfig } from "./types.ts"; import { readText, exists } from "./fs-safe.ts"; @@ -84,6 +84,14 @@ function parseSkillFile(content: string): ParsedSkill { }; } +/** @internal — exported for testing only */ +export function normalizeModuleUrlToPath(moduleUrl: string): string { + const normalizedUrl = moduleUrl.startsWith("file:") + ? moduleUrl + : pathToFileURL(moduleUrl).toString(); + return fileURLToPath(normalizedUrl); +} + // ============================================================================ // Skill Loader // ============================================================================ @@ -96,7 +104,7 @@ function resolveSkillsDir(): string { // Strategy 1: import.meta.url (works in native ESM) try { - const metaDir = path.dirname(fileURLToPath(import.meta.url)); + const metaDir = path.dirname(normalizeModuleUrlToPath(import.meta.url)); candidates.push(path.join(metaDir, "skills")); candidates.push(path.join(metaDir, "..", "skills")); } catch {