fix(coding-agent): Clean up Path Handling (#4873)

This commit is contained in:
Armin Ronacher
2026-05-22 11:25:15 +02:00
committed by GitHub
parent bf56a86e1e
commit c100620bf4
23 changed files with 363 additions and 214 deletions

View File

@@ -123,6 +123,31 @@ describe("extensions discovery", () => {
expect(result.extensions[0].path).toContain("main.ts");
});
it("keeps package.json pi extension entries with leading tilde package-relative", async () => {
const subdir = path.join(extensionsDir, "tilde-package");
const directExtensionPath = path.join(subdir, "~entry.ts");
const slashExtensionPath = path.join(subdir, "~", "entry.ts");
fs.mkdirSync(path.join(subdir, "~"), { recursive: true });
fs.writeFileSync(directExtensionPath, extensionCode);
fs.writeFileSync(slashExtensionPath, extensionCode);
fs.writeFileSync(
path.join(subdir, "package.json"),
JSON.stringify({
name: "tilde-package",
pi: {
extensions: ["~entry.ts", "~/entry.ts"],
},
}),
);
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
expect(result.errors).toHaveLength(0);
expect(result.extensions.map((extension) => extension.path).sort()).toEqual(
[directExtensionPath, slashExtensionPath].sort(),
);
});
it("package.json can declare multiple extensions", async () => {
const subdir = path.join(extensionsDir, "my-package");
fs.mkdirSync(subdir);

View File

@@ -584,6 +584,40 @@ Content`,
);
});
it("should keep pi manifest entries with leading tilde package-relative", async () => {
const pkgDir = join(tempDir, "tilde-manifest-package");
const directExtensionPath = join(pkgDir, "~extensions", "main.ts");
const slashExtensionPath = join(pkgDir, "~", "extensions", "alt.ts");
const directSkillPath = join(pkgDir, "~skills", "direct-skill", "SKILL.md");
const slashSkillPath = join(pkgDir, "~", "skills", "slash-skill", "SKILL.md");
mkdirSync(join(pkgDir, "~extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~", "extensions"), { recursive: true });
mkdirSync(join(pkgDir, "~skills", "direct-skill"), { recursive: true });
mkdirSync(join(pkgDir, "~", "skills", "slash-skill"), { recursive: true });
writeFileSync(directExtensionPath, "export default function() {}");
writeFileSync(slashExtensionPath, "export default function() {}");
writeFileSync(directSkillPath, "---\nname: direct-skill\ndescription: Direct\n---\nContent");
writeFileSync(slashSkillPath, "---\nname: slash-skill\ndescription: Slash\n---\nContent");
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "tilde-manifest-package",
pi: {
extensions: ["~extensions/main.ts", "~/extensions/alt.ts"],
skills: ["~skills", "~/skills"],
},
}),
);
const result = await packageManager.resolveExtensionSources([pkgDir]);
expect(result.extensions.some((r) => r.path === directExtensionPath && r.enabled)).toBe(true);
expect(result.extensions.some((r) => r.path === slashExtensionPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === directSkillPath && r.enabled)).toBe(true);
expect(result.skills.some((r) => r.path === slashSkillPath && r.enabled)).toBe(true);
});
it("should handle directories with auto-discovery layout", async () => {
const pkgDir = join(tempDir, "auto-pkg");
mkdirSync(join(pkgDir, "extensions"), { recursive: true });

View File

@@ -16,6 +16,11 @@ describe("path-utils", () => {
expect(result).not.toContain("~/");
});
it("should keep tilde-prefixed filenames literal", () => {
expect(expandPath("~draft.md")).toBe("~draft.md");
expect(expandPath("@~draft.md")).toBe("~draft.md");
});
it("should normalize Unicode spaces", () => {
// Non-breaking space (U+00A0) should become regular space
const withNBSP = "file\u00A0name.txt";
@@ -26,14 +31,21 @@ describe("path-utils", () => {
describe("resolveToCwd", () => {
it("should resolve absolute paths as-is", () => {
const result = resolveToCwd("/absolute/path/file.txt", "/some/cwd");
expect(result).toBe("/absolute/path/file.txt");
const absolutePath = resolve(tmpdir(), "absolute", "path", "file.txt");
const result = resolveToCwd(absolutePath, resolve(tmpdir(), "some", "cwd"));
expect(result).toBe(absolutePath);
});
it("should resolve relative paths against cwd", () => {
const result = resolveToCwd("relative/file.txt", "/some/cwd");
expect(result).toBe(resolve("/some/cwd", "relative/file.txt"));
});
it("should resolve tilde-prefixed filenames against cwd", () => {
const cwd = join(tmpdir(), "pi-path-utils-cwd");
expect(resolveToCwd("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(resolveToCwd("@~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
});
});
describe("resolveReadPath", () => {

View File

@@ -1,8 +1,9 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { homedir, tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.ts";
import { canonicalizePath, getCwdRelativePath, isLocalPath, normalizePath, resolvePath } from "../src/utils/paths.ts";
let tempDir: string;
@@ -73,6 +74,55 @@ describe("getCwdRelativePath", () => {
});
});
describe("resolvePath", () => {
it("expands only home tilde shortcuts", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(normalizePath("~")).toBe(homedir());
expect(normalizePath("~/file.txt")).toBe(join(homedir(), "file.txt"));
expect(resolvePath("~draft.md", cwd)).toBe(resolve(cwd, "~draft.md"));
expect(normalizePath("~draft.md")).toBe("~draft.md");
});
it("resolves relative paths against the base directory", () => {
const cwd = join(tmpdir(), "pi-paths-cwd");
expect(resolvePath("subdir/file.txt", cwd)).toBe(resolve(cwd, "subdir/file.txt"));
expect(resolvePath("subdir/file.txt", pathToFileURL(cwd).href)).toBe(resolve(cwd, "subdir/file.txt"));
});
it("accepts file URLs", () => {
const dir = createTempDir();
const filePath = join(dir, "file with spaces.txt");
expect(resolvePath(pathToFileURL(filePath).href, join(dir, "base"))).toBe(resolve(filePath));
});
it("throws for invalid file URLs", () => {
expect(() => resolvePath("file:///%E0%A4%A")).toThrow();
});
it("preserves POSIX absolute paths with literal percent sequences", () => {
if (process.platform === "win32") {
return;
}
const dir = createTempDir();
for (const filePath of [join(dir, "report%2026.md"), join(dir, "foo%2Fbar"), join(dir, "malformed%A.md")]) {
expect(resolvePath(filePath, join(dir, "base"))).toBe(resolve(filePath));
}
});
it("does not treat Windows file URL pathname strings as native paths", () => {
if (process.platform !== "win32") {
return;
}
const dir = createTempDir();
const filePath = join(dir, "dir", "SKILL.md");
const pathname = pathToFileURL(filePath).pathname;
expect(pathname).toMatch(/^\/[A-Za-z]:/);
expect(resolvePath(pathname, "E:\\project")).toBe(resolve(pathname));
});
});
describe("isLocalPath", () => {
it("returns true for bare names", () => {
expect(isLocalPath("my-package")).toBe(true);
@@ -82,6 +132,10 @@ describe("isLocalPath", () => {
expect(isLocalPath("./foo")).toBe(true);
});
it("returns true for file URLs", () => {
expect(isLocalPath("file:///tmp/foo")).toBe(true);
});
it("returns false for npm: protocol", () => {
expect(isLocalPath("npm:package")).toBe(false);
});

View File

@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
@@ -405,6 +406,44 @@ Extra prompt content`,
expect(loadedPrompt?.sourceInfo?.source).toBe("extension:extra");
expect(loadedPrompt?.sourceInfo?.path).toBe(promptPath);
});
it("should load extension resources returned as file URLs", async () => {
const extraSkillDir = join(tempDir, "extra skills", "file-url-skill");
mkdirSync(extraSkillDir, { recursive: true });
const skillPath = join(extraSkillDir, "SKILL.md");
writeFileSync(
skillPath,
`---
name: file-url-skill
description: File URL skill
---
Extra content`,
);
const loader = new DefaultResourceLoader({ cwd, agentDir });
await loader.reload();
loader.extendResources({
skillPaths: [
{
path: pathToFileURL(extraSkillDir).href,
metadata: {
source: "extension:file-url",
scope: "temporary",
origin: "top-level",
baseDir: extraSkillDir,
},
},
],
});
const { skills, diagnostics } = loader.getSkills();
expect(diagnostics).toEqual([]);
const loadedSkill = skills.find((skill) => skill.name === "file-url-skill");
expect(loadedSkill).toBeDefined();
expect(loadedSkill?.filePath).toBe(skillPath);
expect(loadedSkill?.sourceInfo?.source).toBe("extension:file-url");
});
});
describe("noSkills option", () => {