mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(cli): harden managed install store
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9241ebb8c6
commit
9d758ad730
5 files changed
+246
-31
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
+31
-24
@@ -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}).`));
|
||||
|
||||
@@ -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<void> {
|
||||
const paths = resolveInstallStorePaths();
|
||||
if (fs.existsSync(paths.cliRoot)) assertManagedInstallStore(paths);
|
||||
const shimRemoved = removeManagedShim(paths);
|
||||
fs.rmSync(paths.cliRoot, { recursive: true, force: true });
|
||||
|
||||
|
||||
+147
-5
@@ -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<T>(
|
||||
callback: () => Promise<T>,
|
||||
paths = resolveInstallStorePaths(),
|
||||
): Promise<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user