fix(cli): secure git installs and restart locks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:32:13 +00:00
1 parent 0f5a674ea0
commit 871dbe0302
5 files changed
+67 -5

No files matched your search

+11 -2
View File
@@ -73,6 +73,15 @@ describe("managed install commands", () => {
expect(() => resolveGitInstallRequest({ repo: "HenkDz/paperclip" })).toThrow("requires --ref");
});
it("requires explicit non-interactive consent before resolving git refs", async () => {
const runCommand = vi.fn();
await expect(installCommand({ ref: "master", repo: "HenkDz/paperclip" }, { runCommand }))
.rejects.toThrow("Re-run with --yes");
expect(runCommand).not.toHaveBeenCalled();
});
it("reuses a SHA-keyed git payload without downloading or rebuilding", async () => {
const sha = "b".repeat(40);
const paths = resolveInstallStorePaths();
@@ -107,8 +116,8 @@ describe("managed install commands", () => {
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
throw new Error(`Unexpected command: ${file} ${args.join(" ")}`);
});
await installCommand({ ref: "master", repo: "HenkDz/paperclip" }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip" }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
const manifest = readInstallManifest(resolveInstallStorePaths());
expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha });
expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12)));
@@ -93,6 +93,22 @@ describe("service health doctor checks", () => {
expect(order).toEqual(["first-start", "first-end", "second-start"]);
});
it("reclaims restart locks left by terminated processes", async () => {
const lockPath = path.join(process.env.PAPERCLIP_HOME!, "instances", "default", "hot-restart.lock");
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, "424242:stale-token\n");
const callback = vi.fn(async () => "restarted");
await expect(withHotRestartLock("default", callback, {
pollMs: 1,
timeoutMs: 20,
isProcessAlive: () => false,
})).resolves.toBe("restarted");
expect(callback).toHaveBeenCalledOnce();
expect(fs.existsSync(lockPath)).toBe(false);
});
it("brackets configured IPv6 hosts in health URLs", () => {
expect(buildLocalHealthUrl("::1", 3100)).toBe("http://[::1]:3100/api/health");
expect(buildLocalHealthUrl("::", 3100)).toBe("http://127.0.0.1:3100/api/health");
+17 -1
View File
@@ -236,6 +236,22 @@ async function ensureShimOnPath(options: InstallOptions): Promise<void> {
console.log(changed ? pc.green(`Updated ${rcPath}.`) : pc.dim(`${rcPath} already contains the PATH block.`));
}
async function confirmGitInstall(options: InstallOptions, repo: string, ref: string): Promise<void> {
const warning = `Installing ${repo}@${ref} executes dependency and build scripts from that repository.`;
console.log(pc.yellow(`Warning: ${warning}`));
if (options.yes === true) return;
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error(`${warning} Re-run with --yes to consent in non-interactive environments.`);
}
const confirmed = await p.confirm({
message: `${warning} Continue?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
throw new Error("Git-ref install cancelled before downloading or executing repository code.");
}
}
export async function installCommand(
options: InstallOptions,
dependencies: { runCommand?: CommandRunner; now?: () => Date } = {},
@@ -244,6 +260,7 @@ export async function installCommand(
const runCommand = dependencies.runCommand ?? execFileAsync;
const gitRequest = resolveGitInstallRequest(options);
if (gitRequest) {
await confirmGitInstall(options, gitRequest.repo, gitRequest.ref);
const sha = await resolveGitHubRef(gitRequest.repo, gitRequest.ref, runCommand);
const paths = resolveInstallStorePaths();
const installed = await withInstallStoreLock(async () => {
@@ -258,7 +275,6 @@ export async function installCommand(
writeManagedShim(paths); pruneInstallPayloads(nextManifest, paths); return payload;
}, paths);
await ensureShimOnPath(options);
console.log(pc.yellow(`Warning: running unreleased code from ${gitRequest.repo}@${gitRequest.ref} (${sha.slice(0, 12)}).`));
console.log(pc.green(`${installed.reused ? "Activated cached" : "Installed"} paperclipai git payload ${sha.slice(0, 12)}.`));
return;
}
+22 -1
View File
@@ -57,13 +57,21 @@ export function resolveRestartExpectedVersion(expectedVersion: string | null | u
export async function withHotRestartLock<T>(
instanceId: string,
callback: () => Promise<T>,
options: { timeoutMs?: number; pollMs?: number } = {},
options: { timeoutMs?: number; pollMs?: number; isProcessAlive?: (pid: number) => boolean } = {},
): Promise<T> {
const instanceRoot = resolvePaperclipInstanceRoot(instanceId);
const lockPath = path.join(instanceRoot, "hot-restart.lock");
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
const deadline = Date.now() + (options.timeoutMs ?? 120_000);
const pollMs = options.pollMs ?? 100;
const isProcessAlive = options.isProcessAlive ?? ((pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
});
await fs.mkdir(instanceRoot, { recursive: true });
while (true) {
@@ -72,6 +80,19 @@ export async function withHotRestartLock<T>(
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
try {
const existingToken = (await fs.readFile(lockPath, "utf8")).trim();
const ownerPid = Number.parseInt(existingToken.split(":", 1)[0] ?? "", 10);
if (Number.isInteger(ownerPid) && ownerPid > 0 && !isProcessAlive(ownerPid)) {
if ((await fs.readFile(lockPath, "utf8")).trim() === existingToken) {
await fs.rm(lockPath, { force: true });
continue;
}
}
} catch (readError) {
if ((readError as NodeJS.ErrnoException).code === "ENOENT") continue;
throw readError;
}
if (Date.now() >= deadline) {
throw new Error(
`Another restart for instance ${instanceId} is still running. ` +
+1 -1
View File
@@ -66,7 +66,7 @@ program
.option("--version <version>", "Install an exact published npm version")
.option("--ref <ref>", "Install a GitHub branch, tag, or commit SHA")
.option("--repo <owner/name>", "Override the GitHub repository used with --ref")
.option("-y, --yes", "Consent to the supported shell PATH update without prompting")
.option("-y, --yes", "Consent to git-ref code execution and supported shell PATH updates without prompting")
.action(installCommand);
program