diff --git a/scripts/lib/pi-undici-proxy-patch.mjs b/scripts/lib/pi-undici-proxy-patch.mjs index d2ee6074..3f3a1e10 100644 --- a/scripts/lib/pi-undici-proxy-patch.mjs +++ b/scripts/lib/pi-undici-proxy-patch.mjs @@ -170,7 +170,13 @@ export function patchPiUndiciProxyTree(nodeModulesPath, fallbackPackagePath, req const temporaryPath = `${nestedPackagePath}.feynman-proxy-${process.pid}`; rmSync(temporaryPath, { recursive: true, force: true }); mkdirSync(dirname(temporaryPath), { recursive: true }); - cpSync(safePackagePath, temporaryPath, { recursive: true }); + // `safePackagePath` is often a link: the bundled workspace exposes + // packages through junctions, so copy the real files instead of + // recreating a link. Without `dereference`, `cpSync` reproduces the + // symlink, and creating one on Windows needs + // SeCreateSymbolicLinkPrivilege, which fails with EPERM in a normal + // non-elevated shell unless Developer Mode is on. + cpSync(safePackagePath, temporaryPath, { dereference: true, recursive: true }); rmSync(nestedPackagePath, { recursive: true, force: true }); renameSync(temporaryPath, nestedPackagePath); changed = true; diff --git a/tests/pi-undici-proxy-patch.test.ts b/tests/pi-undici-proxy-patch.test.ts index b8948df4..b3b393ee 100644 --- a/tests/pi-undici-proxy-patch.test.ts +++ b/tests/pi-undici-proxy-patch.test.ts @@ -1,5 +1,12 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -179,6 +186,35 @@ test("Pi Undici patch replaces the nested package tree and is idempotent", () => assert.equal(patchPiUndiciProxyTree(nodeModules), false); }); +test("Pi Undici patch materializes real files when the source tree is a link", () => { + const root = mkdtempSync(join(tmpdir(), "feynman-pi-undici-link-")); + const nodeModules = join(root, "node_modules"); + const realPackage = join(root, "undici-real"); + const safePackage = join(nodeModules, "undici"); + const piRoot = join(nodeModules, "@earendil-works", "pi-coding-agent"); + const nestedPackage = join(piRoot, "node_modules", "undici"); + mkdirSync(realPackage, { recursive: true }); + mkdirSync(nodeModules, { recursive: true }); + mkdirSync(nestedPackage, { recursive: true }); + writeFileSync(join(realPackage, "package.json"), JSON.stringify({ name: "undici", version: FEYNMAN_UNDICI_VERSION })); + writeFileSync(join(realPackage, "index.js"), "module.exports = { fixed: true };\n"); + // The bundled workspace exposes packages through links, matching + // `linkBundledPackage` in scripts/patch-embedded-pi.mjs. Junctions need no + // privilege on Windows; the type argument is ignored elsewhere. + symlinkSync(realPackage, safePackage, process.platform === "win32" ? "junction" : "dir"); + writeFileSync(join(nestedPackage, "package.json"), JSON.stringify({ name: "undici", version: upstreamVersion })); + writeFileSync(join(piRoot, "package.json"), piPackageJson()); + writeFileSync(join(piRoot, "npm-shrinkwrap.json"), piShrinkwrap()); + + // Recreating the link here would need SeCreateSymbolicLinkPrivilege on + // Windows and fail with EPERM for a normal non-elevated user. + assert.equal(patchPiUndiciProxyTree(nodeModules), true); + assert.equal(lstatSync(nestedPackage).isSymbolicLink(), false); + assert.equal(JSON.parse(readFileSync(join(nestedPackage, "package.json"), "utf8")).version, FEYNMAN_UNDICI_VERSION); + assert.equal(readFileSync(join(nestedPackage, "index.js"), "utf8"), "module.exports = { fixed: true };\n"); + assert.equal(patchPiUndiciProxyTree(nodeModules), false); +}); + test("embedded Pi resolves the patched Undici package", () => { const piRoot = join(process.cwd(), "node_modules", "@earendil-works", "pi-coding-agent"); const manifest = JSON.parse(readFileSync(join(piRoot, "package.json"), "utf8"));