mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
fix(installer): pin npm registry trust boundary
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
94b68d2437
commit
b9df8bf4f5
6 files changed
+83
-7
No files matched your search
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
|
||||
import type { CommandRunner } from "../commands/install.js";
|
||||
import { detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
|
||||
|
||||
let root: string;
|
||||
@@ -27,6 +28,7 @@ beforeEach(() => {
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
@@ -90,6 +92,26 @@ describe("update command", () => {
|
||||
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
|
||||
});
|
||||
|
||||
it("isolates global npm updates from hostile registry configuration", async () => {
|
||||
const paths = resolveInstallStorePaths();
|
||||
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
|
||||
vi.stubEnv("NPM_CONFIG_REGISTRY", "http://attacker-registry.invalid");
|
||||
fs.mkdirSync(process.env.HOME!, { recursive: true });
|
||||
fs.writeFileSync(path.join(process.env.HOME!, ".npmrc"), "registry=http://attacker-registry.invalid\n");
|
||||
const runCommand = vi.fn(async (_file: string, args: string[], commandOptions?: Parameters<CommandRunner>[2]) => {
|
||||
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
|
||||
expect(args).toContain("--registry=https://registry.npmjs.org");
|
||||
expect(args).toContain("--@paperclipai:registry=https://registry.npmjs.org");
|
||||
expect(commandOptions?.env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org");
|
||||
expect(commandOptions?.env?.npm_config_registry).toBe("https://registry.npmjs.org");
|
||||
expect(commandOptions?.env?.NPM_CONFIG_USERCONFIG).toBe(commandOptions?.env?.npm_config_userconfig);
|
||||
expect(fs.readFileSync(commandOptions!.env!.NPM_CONFIG_USERCONFIG!, "utf8")).toContain("registry=https://registry.npmjs.org");
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
await updateCommand({}, { paths, executablePath: executable, runCommand });
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("backs up, installs side-by-side, flips, and rolls back instantly", async () => {
|
||||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "../install-store.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
export const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
const DEFAULT_GITHUB_REPO = "paperclipai/paperclip";
|
||||
const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -6,7 +7,7 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
|
||||
import { dbBackupCommand } from "./db-backup.js";
|
||||
import { installGitPayload, installNpmPayload, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
|
||||
import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown";
|
||||
@@ -108,8 +109,26 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
|
||||
const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1;
|
||||
if (options.check) { emit(options, { mode, currentVersion: currentVersion ?? null, targetVersion, updateAvailable: comparison > 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; }
|
||||
if (mode === "global-npm") {
|
||||
const args = ["install", "-g", `paperclipai@${targetVersion}`]; console.log(`Running: npm ${args.join(" ")}`);
|
||||
if (!options.dryRun) await runCommand("npm", args, { maxBuffer: 16 * 1024 * 1024 });
|
||||
const args = ["install", "-g", `paperclipai@${targetVersion}`, `--registry=${PUBLIC_NPM_REGISTRY}`, `--@paperclipai:registry=${PUBLIC_NPM_REGISTRY}`]; console.log(`Running: npm ${args.join(" ")}`);
|
||||
if (!options.dryRun) {
|
||||
const npmConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-npm-"));
|
||||
const npmUserConfigPath = path.join(npmConfigDir, "npmrc");
|
||||
try {
|
||||
fs.writeFileSync(npmUserConfigPath, `registry=${PUBLIC_NPM_REGISTRY}\n@paperclipai:registry=${PUBLIC_NPM_REGISTRY}\n`, { mode: 0o600 });
|
||||
await runCommand("npm", args, {
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_registry: PUBLIC_NPM_REGISTRY,
|
||||
NPM_CONFIG_REGISTRY: PUBLIC_NPM_REGISTRY,
|
||||
npm_config_userconfig: npmUserConfigPath,
|
||||
NPM_CONFIG_USERCONFIG: npmUserConfigPath,
|
||||
},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(npmConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
emit(options, { mode, action: "update", targetVersion, dryRun: Boolean(options.dryRun), command: ["npm", ...args] }, options.dryRun ? "Dry run complete." : pc.green(`Updated global npm install to ${targetVersion}.`)); return;
|
||||
}
|
||||
if (!manifest) throw new Error("Managed install metadata is missing.");
|
||||
|
||||
Reference in new issue
Block a user