From ea3465a8e371a12d0167a06b60f93878e3a3df44 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 3 Jun 2026 00:44:21 +0200 Subject: [PATCH] fix(coding-agent): move temporary extension cache (#5345) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 13 ++++++-- packages/coding-agent/test/git-update.test.ts | 27 ++++++++++------ .../coding-agent/test/package-manager.test.ts | 32 ++++++++++++++++++- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5ddda5242..7da2178d8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed +- Fixed temporary extension package installs to use a private `~/.pi/agent/tmp/extensions` directory with `0700` permissions instead of `os.tmpdir()/pi-extensions`. - Fixed git package source handling to reject unsafe host/path components and keep managed clone paths inside install roots. - Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters. - Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 7c0e96a17..ac35a2737 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,7 +1,7 @@ import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; function getEnv(): NodeJS.ProcessEnv { if (process.platform !== "linux" || Object.keys(process.env).length > 0) { @@ -206,6 +206,13 @@ function getHomeDir(): string { return process.env.HOME || homedir(); } +export function getExtensionTempFolder(agentDir: string): string { + const tempFolder = join(agentDir, "tmp", "extensions"); + mkdirSync(tempFolder, { recursive: true, mode: 0o700 }); + chmodSync(tempFolder, 0o700); + return tempFolder; +} + function prefixIgnorePattern(line: string, prefix: string): string | null { const trimmed = line.trim(); if (!trimmed) return null; @@ -1970,7 +1977,7 @@ export class DefaultPackageManager implements PackageManager { } private getTemporaryDir(prefix: string, suffix?: string): string { - const root = this.resolveManagedPath(join(tmpdir(), "pi-extensions"), prefix); + const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix); const hash = createHash("sha256") .update(`${prefix}-${suffix ?? ""}`) .digest("hex") diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index ea8fae4d1..1508adcdc 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -7,7 +7,6 @@ */ import { spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -51,6 +50,20 @@ function getFileContent(repoDir: string, filename: string): string { return readFileSync(join(repoDir, filename), "utf-8"); } +type GitSourceForTest = { + type: "git"; + repo: string; + host: string; + path: string; + pinned: boolean; + ref?: string; +}; + +interface PackageManagerPathInternals { + parseSource(source: string): GitSourceForTest; + getGitInstallPath(source: GitSourceForTest, scope: "temporary"): string; +} + describe("DefaultPackageManager git update", () => { let tempDir: string; let remoteDir: string; // Simulates the "remote" repository @@ -376,10 +389,8 @@ describe("DefaultPackageManager git update", () => { describe("temporary git sources", () => { it("should refresh cached temporary git sources when resolving", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); @@ -423,10 +434,8 @@ describe("DefaultPackageManager git update", () => { }); it("should not refresh pinned temporary git sources", async () => { - const gitHost = "github.com"; - const gitPath = "test/extension"; - const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8); - const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath); + const managerWithPaths = packageManager as unknown as PackageManagerPathInternals; + const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary"); const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts"); rmSync(cachedDir, { recursive: true, force: true }); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 848b37bde..cd1f4532d 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { PassThrough } from "node:stream"; @@ -33,6 +33,16 @@ interface PackageManagerInternals { options?: { cwd?: string; timeoutMs?: number; env?: Record }, ): Promise; getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string; fetchArgs: string[] }>; + parseSource( + source: string, + ): + | { type: "npm"; spec: string; name: string; pinned: boolean } + | { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string } + | { type: "local"; path: string }; + getNpmInstallPath( + source: { type: "npm"; spec: string; name: string; pinned: boolean }, + scope: "user" | "project" | "temporary", + ): string; getGitInstallPath( source: { type: "git"; repo: string; host: string; path: string; pinned: boolean; ref?: string }, scope: "user" | "project" | "temporary", @@ -1161,6 +1171,26 @@ Content`, }); }); + describe("temporary install paths", () => { + it("should place temporary npm packages under the agent temp extension folder", () => { + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + const source = managerWithInternals.parseSource("npm:left-pad"); + if (source.type !== "npm") { + throw new Error("Expected npm source"); + } + + const installPath = managerWithInternals.getNpmInstallPath(source, "temporary"); + const tempRoot = join(agentDir, "tmp", "extensions"); + + expect(pathEndsWith(installPath, "node_modules/left-pad")).toBe(true); + expect(relative(tempRoot, installPath).startsWith("..")).toBe(false); + expect(installPath.startsWith(join(tmpdir(), "pi-extensions"))).toBe(false); + if (process.platform !== "win32") { + expect(statSync(tempRoot).mode & 0o777).toBe(0o700); + } + }); + }); + describe("settings source normalization", () => { it("should store global local packages relative to agent settings base", () => { const pkgDir = join(tempDir, "packages", "local-global-pkg");