diff --git a/cli/src/__tests__/service-manager.test.ts b/cli/src/__tests__/service-manager.test.ts index 74411e78414..b5f4fb457aa 100644 --- a/cli/src/__tests__/service-manager.test.ts +++ b/cli/src/__tests__/service-manager.test.ts @@ -130,6 +130,23 @@ describe("launchd lifecycle", () => { expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`); }); + it("preserves disabled state when the service name contains regex metacharacters", async () => { + const userHome = await temporaryDirectory(); + const calls: string[] = []; + const serviceName = "ing.paperclip.paperclipai.team[qa]+"; + const runner: CommandRunner = async (command, args) => { + calls.push([command, ...args].join(" ")); + if (args[0] === "print-disabled") return { stdout: `"${serviceName}" => true`, stderr: "" }; + return { stdout: "", stderr: "" }; + }; + const manager = new LaunchdServiceManager("team[qa]+", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome); + + await manager.start(); + + expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/${serviceName}`); + expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/${serviceName}`); + }); + it("disables login startup and unloads the keepalive job when stopped", async () => { const userHome = await temporaryDirectory(); const calls: string[] = []; diff --git a/cli/src/services/service-manager.ts b/cli/src/services/service-manager.ts index 182ae41e3d1..ebaeabfead9 100644 --- a/cli/src/services/service-manager.ts +++ b/cli/src/services/service-manager.ts @@ -64,6 +64,10 @@ function escapeXml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export function resolveServiceShimPath(homeDir = os.homedir()): string { return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai"); } @@ -258,7 +262,7 @@ export class LaunchdServiceManager implements ServiceManager { private async isEnabled(): Promise { try { const result = await this.runner("launchctl", ["print-disabled", this.domain]); - return !new RegExp(`"${this.serviceName.replaceAll(".", "\\.")}"\\s*=>\\s*true`).test(result.stdout); + return !new RegExp(`"${escapeRegExp(this.serviceName)}"\\s*=>\\s*true`).test(result.stdout); } catch { return true; } }