diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index 4a1d932e34c..c8292de7d6d 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -85,4 +85,27 @@ describe("managed install commands", () => { expect(fs.existsSync(paths.shimPath)).toBe(false); expect(fs.readFileSync(userData, "utf8")).toBe("keep"); }); + + it("rejects a symlinked installs root before npm writes outside the store", async () => { + const paths = resolveInstallStorePaths(); + const outside = path.join(root, "outside"); + fs.mkdirSync(paths.cliRoot, { recursive: true }); + fs.mkdirSync(outside); + fs.symlinkSync(outside, paths.installsRoot, "dir"); + const runCommand = vi.fn(async () => ({ stdout: JSON.stringify("2026.720.0"), stderr: "" })); + + await expect(installCommand({}, { runCommand })).rejects.toThrow("non-directory install-store path"); + expect(runCommand).toHaveBeenCalledTimes(1); + expect(fs.readdirSync(outside)).toEqual([]); + }); + + it("refuses to uninstall an unverified cli directory", async () => { + const paths = resolveInstallStorePaths(); + const unrelatedFile = path.join(paths.cliRoot, "keep.txt"); + fs.mkdirSync(paths.cliRoot, { recursive: true }); + fs.writeFileSync(unrelatedFile, "keep"); + + await expect(uninstallCommand()).rejects.toThrow("unverified install store"); + expect(fs.readFileSync(unrelatedFile, "utf8")).toBe("keep"); + }); }); diff --git a/cli/src/__tests__/install-store.test.ts b/cli/src/__tests__/install-store.test.ts index fe203568abc..9e6afb9c899 100644 --- a/cli/src/__tests__/install-store.test.ts +++ b/cli/src/__tests__/install-store.test.ts @@ -8,11 +8,14 @@ import { addManagedPathBlock, buildNextManifest, flipCurrentAtomic, + isManagedExecutable, payloadPathFor, pruneInstallPayloads, readInstallManifest, removeManagedPathBlock, + removeManagedShim, resolveInstallStorePaths, + withInstallStoreLock, writeInstallManifestAtomic, writeManagedShim, type InstallManifest, @@ -101,10 +104,12 @@ describe("managed install store", () => { expect(payloads.slice(1).every((payload) => fs.existsSync(payload))).toBe(true); }); - it("writes a stable shim and manages an idempotent shell PATH block", () => { + it("writes a stable shim with the validated runtime and custom store path", () => { writeManagedShim(paths); const shim = fs.readFileSync(paths.shimPath, "utf8"); - expect(shim).toContain("$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js"); + expect(shim).toContain(process.execPath); + expect(shim).toContain(paths.currentPath); + expect(shim).not.toContain("PAPERCLIP_HOME"); expect(fs.statSync(paths.shimPath).mode & 0o777).toBe(0o755); const rcPath = path.join(root, "home", ".bashrc"); @@ -114,6 +119,42 @@ describe("managed install store", () => { expect(fs.readFileSync(rcPath, "utf8")).not.toContain("paperclipai managed PATH"); }); + it("rejects marker substrings that are not the exact managed shim format", () => { + fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true }); + fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`); + + expect(removeManagedShim(paths)).toBe(false); + expect(fs.existsSync(paths.shimPath)).toBe(true); + expect(() => writeManagedShim(paths)).toThrow("non-managed command"); + }); + + it("serializes install-store mutations with an exclusive lock", async () => { + await expect( + withInstallStoreLock( + () => withInstallStoreLock(async () => undefined, paths), + paths, + ), + ).rejects.toThrow("already running"); + expect(fs.existsSync(paths.lockPath)).toBe(false); + }); + + it("reports managed provenance only for the payload selected by current", () => { + const manifestPayload = payloadPathFor(paths, "npm", "1.0.0"); + const currentPayload = payloadPathFor(paths, "npm", "2.0.0"); + const executable = path.join(manifestPayload, "node_modules", "paperclipai", "dist", "index.js"); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.writeFileSync(executable, ""); + fs.mkdirSync(currentPayload, { recursive: true }); + flipCurrentAtomic(currentPayload, paths); + const manifest: InstallManifest = { + schemaVersion: INSTALL_MANIFEST_VERSION, + ...record(manifestPayload, "1.0.0"), + previous: [], + }; + + expect(isManagedExecutable(executable, manifest, paths)).toBe(false); + }); + it("refuses symlinked payload roots and pre-existing non-managed shims", () => { const outside = path.join(root, "outside"); fs.mkdirSync(outside, { recursive: true }); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 36975f08ab5..9ca839befb4 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -13,6 +13,7 @@ import { pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, + withInstallStoreLock, writeInstallManifestAtomic, writeManagedShim, type InstallChannel, @@ -88,8 +89,11 @@ async function smokePayload(payloadPath: string, expectedVersion: string, runCom } } -async function installNpmPayload(version: string, runCommand: CommandRunner): Promise<{ payloadPath: string; reused: boolean }> { - const paths = resolveInstallStorePaths(); +async function installNpmPayload( + version: string, + runCommand: CommandRunner, + paths = resolveInstallStorePaths(), +): Promise<{ payloadPath: string; reused: boolean }> { const payloadPath = payloadPathFor(paths, "npm", version); if (fs.existsSync(payloadPath)) { await smokePayload(payloadPath, version, runCommand); @@ -185,28 +189,31 @@ export async function installCommand( console.log(`Installing paperclipai@${version}...`); const paths = resolveInstallStorePaths(); - assertManagedShimWritable(paths); - const currentManifest = readInstallManifest(paths); - const installed = await installNpmPayload(version, runCommand); - const record: InstallRecord = { - source: "npm", - version, - channel: request.channel, - payloadPath: installed.payloadPath, - installedAt: (dependencies.now?.() ?? new Date()).toISOString(), - }; - const nextManifest = buildNextManifest(record, currentManifest); - const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null; - flipCurrentAtomic(installed.payloadPath, paths); - try { - writeInstallManifestAtomic(nextManifest, paths); - } catch (error) { - if (oldTarget) flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); - else fs.rmSync(paths.currentPath, { force: true }); - throw error; - } - writeManagedShim(paths); - pruneInstallPayloads(nextManifest, paths); + const installed = await withInstallStoreLock(async () => { + assertManagedShimWritable(paths); + const currentManifest = readInstallManifest(paths); + const payload = await installNpmPayload(version, runCommand, paths); + const record: InstallRecord = { + source: "npm", + version, + channel: request.channel, + payloadPath: payload.payloadPath, + installedAt: (dependencies.now?.() ?? new Date()).toISOString(), + }; + const nextManifest = buildNextManifest(record, currentManifest); + const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null; + flipCurrentAtomic(payload.payloadPath, paths); + try { + writeInstallManifestAtomic(nextManifest, paths); + } catch (error) { + if (oldTarget) flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); + else fs.rmSync(paths.currentPath, { force: true }); + throw error; + } + writeManagedShim(paths); + pruneInstallPayloads(nextManifest, paths); + return payload; + }, paths); await ensureShimOnPath(options); console.log(pc.green(`${installed.reused ? "Activated cached" : "Installed"} paperclipai ${version} (${request.channel}).`)); diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts index 4677a363c21..c1dc4eba027 100644 --- a/cli/src/commands/uninstall.ts +++ b/cli/src/commands/uninstall.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import pc from "picocolors"; import { + assertManagedInstallStore, removeManagedPathBlock, removeManagedShim, resolveInstallStorePaths, @@ -9,6 +10,7 @@ import { export async function uninstallCommand(): Promise { const paths = resolveInstallStorePaths(); + if (fs.existsSync(paths.cliRoot)) assertManagedInstallStore(paths); const shimRemoved = removeManagedShim(paths); fs.rmSync(paths.cliRoot, { recursive: true, force: true }); diff --git a/cli/src/install-store.ts b/cli/src/install-store.ts index f2953ce72eb..164376b5341 100644 --- a/cli/src/install-store.ts +++ b/cli/src/install-store.ts @@ -3,7 +3,8 @@ import path from "node:path"; import { resolvePaperclipHomeDir } from "./config/home.js"; export const INSTALL_MANIFEST_VERSION = 1; -export const MANAGED_SHIM_MARKER = "paperclipai managed install shim"; +export const MANAGED_SHIM_MARKER = "paperclipai managed install shim v1"; +export const MANAGED_STORE_MARKER = "paperclipai managed install store v1\n"; export const PATH_BLOCK_START = "# >>> paperclipai managed PATH >>>"; export const PATH_BLOCK_END = "# <<< paperclipai managed PATH <<<"; @@ -31,6 +32,8 @@ export type InstallStorePaths = { cliRoot: string; installsRoot: string; manifestPath: string; + markerPath: string; + lockPath: string; currentPath: string; shimPath: string; }; @@ -76,11 +79,129 @@ export function resolveInstallStorePaths(options: { cliRoot, installsRoot: path.join(cliRoot, "installs"), manifestPath: path.join(cliRoot, "install.json"), + markerPath: path.join(cliRoot, ".managed-install"), + lockPath: path.join(cliRoot, ".install.lock"), currentPath: path.join(cliRoot, "current"), shimPath: path.join(homeDir, ".local", "bin", "paperclipai"), }; } +export function initializeInstallStore(paths = resolveInstallStorePaths()): void { + ensurePrivateDirectory(paths.cliRoot); + ensurePrivateDirectory(paths.installsRoot); + try { + const markerStat = fs.lstatSync(paths.markerPath); + if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) { + throw new Error(`Refusing to use unsafe install-store marker ${paths.markerPath}.`); + } + assertOwnedByCurrentUser(markerStat, paths.markerPath); + if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) { + throw new Error(`Refusing to use unrecognized install store ${paths.cliRoot}.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER, { mode: 0o600, flag: "wx" }); + } catch (writeError) { + if ( + (writeError as NodeJS.ErrnoException).code !== "EEXIST" || + fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER + ) { + throw writeError; + } + } + } +} + +export function assertManagedInstallStore(paths = resolveInstallStorePaths()): InstallManifest { + const cliStat = fs.lstatSync(paths.cliRoot); + if (!cliStat.isDirectory() || cliStat.isSymbolicLink()) { + throw new Error(`Refusing to remove unsafe install-store path ${paths.cliRoot}.`); + } + assertOwnedByCurrentUser(cliStat, paths.cliRoot); + let markerStat: fs.Stats; + try { + markerStat = fs.lstatSync(paths.markerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`); + } + throw error; + } + if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) { + throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`); + } + assertOwnedByCurrentUser(markerStat, paths.markerPath); + if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) { + throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`); + } + const manifest = readInstallManifest(paths); + if (!manifest) throw new Error(`Refusing to remove install store without a manifest at ${paths.cliRoot}.`); + const relativePayload = path.relative(paths.installsRoot, path.resolve(manifest.payloadPath)); + if (!relativePayload || relativePayload.startsWith("..") || path.isAbsolute(relativePayload)) { + throw new Error(`Refusing to remove install store with an invalid manifest at ${paths.cliRoot}.`); + } + return manifest; +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +export async function withInstallStoreLock( + callback: () => Promise, + paths = resolveInstallStorePaths(), +): Promise { + initializeInstallStore(paths); + const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const acquire = (): void => { + const temporaryPath = `${paths.lockPath}.${token}.tmp`; + try { + fs.writeFileSync(temporaryPath, `${token}\n`, { mode: 0o600, flag: "wx" }); + try { + fs.linkSync(temporaryPath, paths.lockPath); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + const owner = fs.readFileSync(paths.lockPath, "utf8").trim(); + const ownerPid = Number.parseInt(owner.split(":", 1)[0] ?? "", 10); + if (Number.isInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) { + throw new Error(`Another managed install is already running (pid ${ownerPid}).`); + } + fs.rmSync(paths.lockPath, { force: true }); + try { + fs.linkSync(temporaryPath, paths.lockPath); + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error("Another managed install started while recovering a stale lock."); + } + throw retryError; + } + } finally { + fs.rmSync(temporaryPath, { force: true }); + } + }; + + acquire(); + try { + return await callback(); + } finally { + try { + if (fs.readFileSync(paths.lockPath, "utf8").trim() === token) { + fs.rmSync(paths.lockPath, { force: true }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } +} + export function payloadPathFor( paths: InstallStorePaths, source: InstallSource, @@ -246,7 +367,7 @@ export function assertManagedShimWritable(paths = resolveInstallStorePaths()): v assertOwnedByCurrentUser(stat, paths.shimPath); if (stat.nlink > 1) throw new Error(`Refusing to replace multiply linked shim ${paths.shimPath}.`); const existing = fs.readFileSync(paths.shimPath, "utf8"); - if (!existing.includes(MANAGED_SHIM_MARKER)) { + if (!isManagedShimContents(existing)) { throw new Error(`Refusing to replace existing non-managed command ${paths.shimPath}.`); } } catch (error) { @@ -254,6 +375,22 @@ export function assertManagedShimWritable(paths = resolveInstallStorePaths()): v } } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function isManagedShimContents(contents: string): boolean { + const lines = contents.split("\n"); + return ( + lines.length === 5 && + lines[0] === "#!/bin/sh" && + lines[1] === `# ${MANAGED_SHIM_MARKER}` && + lines[2] === "set -eu" && + /^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) && + lines[4] === "" + ); +} + export function writeManagedShim(paths = resolveInstallStorePaths()): void { assertManagedShimWritable(paths); const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath))); @@ -262,14 +399,15 @@ export function writeManagedShim(paths = resolveInstallStorePaths()): void { fs.mkdirSync(localDir, { mode: 0o755 }); fs.mkdirSync(path.dirname(paths.shimPath), { mode: 0o755 }); assertManagedShimWritable(paths); - const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nPAPERCLIP_HOME="\${PAPERCLIP_HOME:-\$HOME/.paperclip}"\nexec node "\$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js" "\$@"\n`; + const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js"); + const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`; writeFileAtomic(paths.shimPath, contents, 0o755); } export function removeManagedShim(paths = resolveInstallStorePaths()): boolean { try { const contents = fs.readFileSync(paths.shimPath, "utf8"); - if (!contents.includes(MANAGED_SHIM_MARKER)) return false; + if (!isManagedShimContents(contents)) return false; fs.rmSync(paths.shimPath, { force: true }); return true; } catch (error) { @@ -333,7 +471,11 @@ export function isManagedExecutable( try { const executableRealPath = fs.realpathSync(executablePath); const payloadRealPath = fs.realpathSync(manifest.payloadPath); - return executableRealPath.startsWith(`${payloadRealPath}${path.sep}`) && fs.existsSync(paths.currentPath); + const currentRealPath = fs.realpathSync(paths.currentPath); + return ( + currentRealPath === payloadRealPath && + executableRealPath.startsWith(`${payloadRealPath}${path.sep}`) + ); } catch { return false; }