From bd6033ecbd7b8d732bce666450b422f8915545e8 Mon Sep 17 00:00:00 2001 From: Pier Carlo Cadoppi Date: Fri, 14 Aug 2026 09:54:47 +0000 Subject: [PATCH 1/3] fix(coding-agent): realpath extension entries before jiti import (fixes #8092) --- .../src/core/extensions/loader.ts | 9 ++- .../8092-extension-realpath-jiti.test.ts | 81 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index a16c78530..25b1ac918 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -25,7 +25,7 @@ import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. import * as _bundledPiCodingAgent from "../../index.ts"; -import { resolvePath } from "../../utils/paths.ts"; +import { canonicalizePath, resolvePath } from "../../utils/paths.ts"; import { createEventBus, type EventBus } from "../event-bus.ts"; import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; @@ -452,7 +452,12 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension : { alias: getAliases() }), }); - const module = await jiti.import(extensionPath, { default: true }); + // Realpath the entry before handing it to jiti: pnpm's isolated layout + // symlinks node_modules/ into .pnpm/@/node_modules/, + // and jiti's resolver (unlike Node's native loader) doesn't realpath + // before resolving imports, so upward traversal from the symlink path + // misses declared deps that only exist as store siblings. See #8092. + const module = await jiti.import(canonicalizePath(extensionPath), { default: true }); const factory = module as ExtensionFactory; if (typeof factory !== "function") { return undefined; diff --git a/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts b/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts new file mode 100644 index 000000000..6f1b62a5b --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts @@ -0,0 +1,81 @@ +import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearExtensionCache, loadExtensions } from "../../../src/core/extensions/loader.ts"; + +interface TestState { + depValue?: unknown; +} + +function state(): TestState { + const global = globalThis as typeof globalThis & { __extensionRealpathTest?: TestState }; + if (!global.__extensionRealpathTest) { + global.__extensionRealpathTest = {}; + } + return global.__extensionRealpathTest; +} + +function resetState(): void { + delete (globalThis as typeof globalThis & { __extensionRealpathTest?: TestState }).__extensionRealpathTest; +} + +const roots: string[] = []; + +function fixture(): string { + const root = join(tmpdir(), `pi-ext-realpath-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(root, { recursive: true }); + roots.push(root); + return root; +} + +afterEach(() => { + while (roots.length > 0) { + const root = roots.pop(); + if (root && existsSync(root)) { + rmSync(root, { recursive: true, force: true }); + } + } + resetState(); + clearExtensionCache(); +}); + +describe("regression #8092: extension entries are realpath'd before jiti load", () => { + it("loads an extension whose declared dep is only reachable via the pnpm store", async () => { + const root = fixture(); + + // pnpm isolated layout: node_modules/ is a symlink into the + // virtual store, and declared deps exist only as store siblings. + const storePkgDir = join(root, "node_modules", ".pnpm", "@test+ext@1.0.0", "node_modules", "@test"); + const extDir = join(storePkgDir, "ext"); + const depDir = join(storePkgDir, "dep"); + mkdirSync(extDir, { recursive: true }); + mkdirSync(depDir, { recursive: true }); + + writeFileSync(join(depDir, "index.js"), `export const value = 42;\n`, "utf-8"); + writeFileSync( + join(extDir, "index.ts"), + ` +import { value } from "@test/dep"; + +export default function () { + (globalThis.__extensionRealpathTest ??= {}).depValue = value; +} +`, + "utf-8", + ); + + const topLevel = join(root, "node_modules", "@test"); + mkdirSync(topLevel, { recursive: true }); + symlinkSync(join("..", ".pnpm", "@test+ext@1.0.0", "node_modules", "@test", "ext"), join(topLevel, "ext")); + + const entry = join(topLevel, "ext", "index.ts"); + expect(existsSync(entry)).toBe(true); + + const result = await loadExtensions([entry], root); + + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(1); + expect(state().depValue).toBe(42); + }); +}); From 548b97ef3780c7e197d1288778fc398324b4ade9 Mon Sep 17 00:00:00 2001 From: Pier Carlo Cadoppi Date: Fri, 14 Aug 2026 10:05:14 +0000 Subject: [PATCH 2/3] test(coding-agent): use junction link type on win32 in #8092 regression test --- .../suite/regressions/8092-extension-realpath-jiti.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts b/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts index 6f1b62a5b..154e2e92f 100644 --- a/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts +++ b/packages/coding-agent/test/suite/regressions/8092-extension-realpath-jiti.test.ts @@ -22,6 +22,8 @@ function resetState(): void { const roots: string[] = []; +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + function fixture(): string { const root = join(tmpdir(), `pi-ext-realpath-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(root, { recursive: true }); @@ -67,7 +69,9 @@ export default function () { const topLevel = join(root, "node_modules", "@test"); mkdirSync(topLevel, { recursive: true }); - symlinkSync(join("..", ".pnpm", "@test+ext@1.0.0", "node_modules", "@test", "ext"), join(topLevel, "ext")); + // Junctions (win32) require an absolute target; pnpm's own relative + // link targets are equivalent here since the store path is absolute. + symlinkSync(join(storePkgDir, "ext"), join(topLevel, "ext"), DIRECTORY_LINK_TYPE); const entry = join(topLevel, "ext", "index.ts"); expect(existsSync(entry)).toBe(true); From 933faf0a65297327939cfc8f8f3f82d3e9e78e92 Mon Sep 17 00:00:00 2001 From: Pier Carlo Cadoppi Date: Fri, 14 Aug 2026 10:09:24 +0000 Subject: [PATCH 3/3] fix(coding-agent): drop verbose comment from realpath change --- packages/coding-agent/src/core/extensions/loader.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 25b1ac918..b04e057d3 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -452,11 +452,6 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension : { alias: getAliases() }), }); - // Realpath the entry before handing it to jiti: pnpm's isolated layout - // symlinks node_modules/ into .pnpm/@/node_modules/, - // and jiti's resolver (unlike Node's native loader) doesn't realpath - // before resolving imports, so upward traversal from the symlink path - // misses declared deps that only exist as store siblings. See #8092. const module = await jiti.import(canonicalizePath(extensionPath), { default: true }); const factory = module as ExtensionFactory; if (typeof factory !== "function") {