mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 21:01:54 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The CLI subsystem provides background service management (`paperclipai service install`) generating systemd user units on Linux. > - When systemctl stops or restarts the service, systemd uses default `KillMode=control-group` and sends SIGTERM simultaneously to all cgroup processes, including embedded PostgreSQL. > - This races the server's clean shutdown sequence and triggers a smart shutdown in PostgreSQL instead of a fast shutdown, leading to ungraceful exit. > - This pull request adds `KillMode=mixed` to the `[Service]` section of the generated systemd unit definition. > - The benefit is that SIGTERM is delivered only to the main server process, allowing the server to cleanly drain and shut down embedded PostgreSQL in order. ## Linked Issues or Issue Description Fixes #18595 ## What Changed - Added `KillMode=mixed` to `renderSystemdUnit` under `[Service]` in `cli/src/services/service-manager.ts`. - Added test assertion for `KillMode=mixed` in `cli/src/__tests__/service-manager.test.ts`. ## Verification - Automated test: `cli/src/__tests__/service-manager.test.ts` checks that `renderSystemdUnit` generates a unit containing `KillMode=mixed`. - Manual verification: Checked rendered systemd unit output to ensure valid systemd syntax and correct section placement under `[Service]`. ## Risks - Low risk: `KillMode=mixed` only applies to systemd user services on Linux generated via `paperclipai service install`. If the main process fails to exit within `TimeoutStopSec` (300s), systemd sends SIGKILL to all remaining child processes. ## Model Used - Google Antigravity (Gemini 3.8 Flash High) with chain-of-thought reasoning and codebase tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green
218 lines
11 KiB
TypeScript
218 lines
11 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
assertForegroundRunAllowed,
|
|
detectServiceManager,
|
|
LaunchdServiceManager,
|
|
renderLaunchdPlist,
|
|
renderSystemdUnit,
|
|
SystemdServiceManager,
|
|
type CommandRunner,
|
|
type ServiceManager,
|
|
} from "../services/service-manager.js";
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
|
delete process.env.PAPERCLIP_SERVICE_MANAGED;
|
|
});
|
|
|
|
async function temporaryDirectory(): Promise<string> {
|
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-test-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
describe("service definition generation", () => {
|
|
it("generates a stable systemd notify unit without secrets", () => {
|
|
const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" });
|
|
expect(unit).toContain("Type=notify");
|
|
expect(unit).toContain("NotifyAccess=all");
|
|
expect(unit).toContain("KillMode=mixed");
|
|
expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"');
|
|
expect(unit).toContain("Restart=always");
|
|
expect(unit).toContain("TimeoutStopSec=300");
|
|
expect(unit).not.toContain("API_KEY");
|
|
});
|
|
|
|
it("escapes systemd variable and specifier expansion in configured values", () => {
|
|
const unit = renderSystemdUnit({
|
|
instanceId: "team-$USER-%i",
|
|
shimPath: "/home/$USER/%i/paperclipai",
|
|
homeDir: "/home/$USER/%i/.paperclip",
|
|
});
|
|
|
|
expect(unit).toContain('ExecStart="/home/$$USER/%%i/paperclipai" run --instance "team-$$USER-%%i"');
|
|
expect(unit).toContain('Environment="PAPERCLIP_HOME=/home/$$USER/%%i/.paperclip"');
|
|
});
|
|
|
|
it.each([
|
|
["instanceId", { instanceId: "team-a\nExecStartPre=/tmp/attack", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" }],
|
|
["shimPath", { instanceId: "team-a", shimPath: "/home/alice/bin/paperclipai\r\nExecStartPre=/tmp/attack", homeDir: "/home/alice/.paperclip" }],
|
|
["homeDir", { instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip\nEnvironment=ATTACK=1" }],
|
|
])("rejects line breaks in the systemd %s", (_field, input) => {
|
|
expect(() => renderSystemdUnit(input)).toThrow("Systemd service values must not contain line breaks");
|
|
});
|
|
|
|
it("generates a launchd agent with keepalive and instance logs", () => {
|
|
const plist = renderLaunchdPlist({ instanceId: "team-a", shimPath: "/Users/alice/.local/bin/paperclipai", homeDir: "/Users/alice/.paperclip", stdoutPath: "/Users/alice/.paperclip/instances/team-a/logs/service.log", stderrPath: "/Users/alice/.paperclip/instances/team-a/logs/service.err.log" });
|
|
expect(plist).toContain("ing.paperclip.paperclipai.team-a");
|
|
expect(plist).toContain("<key>RunAtLoad</key><true/>");
|
|
expect(plist).toContain("<key>KeepAlive</key><true/>");
|
|
expect(plist).toContain("service.err.log");
|
|
});
|
|
});
|
|
|
|
describe("systemd drift regeneration", () => {
|
|
it("rewrites a drifted unit and reloads the user manager", async () => {
|
|
const userHome = await temporaryDirectory();
|
|
const calls: string[] = [];
|
|
const runner: CommandRunner = async (command, args) => {
|
|
calls.push([command, ...args].join(" "));
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
|
|
await fs.writeFile(manager.definitionPath, "stale\n", "utf8");
|
|
|
|
const result = await manager.install({ startNow: false, startOnLogin: false });
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition());
|
|
expect(calls).toContain("systemctl --user daemon-reload");
|
|
});
|
|
|
|
it("keeps the unit installed when stopping an active service fails", async () => {
|
|
const userHome = await temporaryDirectory();
|
|
const runner: CommandRunner = async (command, args) => {
|
|
if (args.includes("--property=LoadState,ActiveState,UnitFileState,MainPID")) return { stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nMainPID=42\n", stderr: "" };
|
|
if (command === "systemctl" && args.includes("stop")) throw new Error("stop failed");
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
|
|
await fs.writeFile(manager.definitionPath, manager.renderDefinition(), "utf8");
|
|
|
|
await expect(manager.uninstall()).rejects.toThrow("stop failed");
|
|
await expect(fs.access(manager.definitionPath)).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("service adapter dispatch", () => {
|
|
it("selects launchd on macOS", async () => {
|
|
const detection = await detectServiceManager({ platform: "darwin", instanceId: "default" });
|
|
expect(detection.supported).toBe(true);
|
|
if (detection.supported) expect(detection.manager).toBeInstanceOf(LaunchdServiceManager);
|
|
});
|
|
|
|
it("selects systemd only when the user manager is reachable", async () => {
|
|
const runner: CommandRunner = async () => ({ stdout: "", stderr: "" });
|
|
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
|
|
expect(detection.supported).toBe(true);
|
|
if (detection.supported) expect(detection.manager).toBeInstanceOf(SystemdServiceManager);
|
|
});
|
|
|
|
it("returns a foreground-run skip on unsupported hosts", async () => {
|
|
const runner: CommandRunner = async () => { throw new Error("no bus"); };
|
|
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
|
|
expect(detection).toEqual({ supported: false, reason: expect.stringContaining("paperclipai run") });
|
|
});
|
|
});
|
|
|
|
describe("launchd lifecycle", () => {
|
|
it("starts without changing the saved login preference", async () => {
|
|
const userHome = await temporaryDirectory();
|
|
const calls: string[] = [];
|
|
const runner: CommandRunner = async (command, args) => {
|
|
calls.push([command, ...args].join(" "));
|
|
if (args[0] === "print-disabled") return { stdout: `\"ing.paperclip.paperclipai.team-a\" => true`, stderr: "" };
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
|
|
await manager.start();
|
|
|
|
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
|
|
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[] = [];
|
|
const runner: CommandRunner = async (command, args) => {
|
|
calls.push([command, ...args].join(" "));
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
|
|
await manager.install({ startNow: false, startOnLogin: false });
|
|
await manager.stop();
|
|
|
|
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
|
|
expect(calls).toContain(`launchctl bootout gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
|
|
expect(calls.some((call) => call.includes("launchctl kill"))).toBe(false);
|
|
});
|
|
|
|
it("disables login startup when uninstalled", async () => {
|
|
const userHome = await temporaryDirectory();
|
|
const calls: string[] = [];
|
|
const runner: CommandRunner = async (command, args) => {
|
|
calls.push([command, ...args].join(" "));
|
|
return { stdout: "", stderr: "" };
|
|
};
|
|
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
|
|
await manager.uninstall();
|
|
|
|
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
|
|
});
|
|
});
|
|
|
|
describe("single-writer guard", () => {
|
|
const activeManager = { status: async () => ({ active: true, serviceName: "paperclipai.service" }) } as unknown as ServiceManager;
|
|
const detector = async () => ({ supported: true as const, manager: activeManager });
|
|
|
|
it("refuses a second foreground writer", async () => {
|
|
await expect(assertForegroundRunAllowed("default", false, detector)).rejects.toThrow("already running");
|
|
});
|
|
|
|
it("allows an explicit force override", async () => {
|
|
await expect(assertForegroundRunAllowed("default", true, detector)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("allows the supervisor-owned process", async () => {
|
|
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
|
|
await expect(assertForegroundRunAllowed("default", false, detector)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("refuses to replace a symlinked service definition", async () => {
|
|
const userHome = await temporaryDirectory();
|
|
const manager = new SystemdServiceManager("default", async () => ({ stdout: "", stderr: "" }), path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
|
|
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
|
|
const target = path.join(userHome, "target.service"); await fs.writeFile(target, "preserve\n"); await fs.symlink(target, manager.definitionPath);
|
|
await expect(manager.install({ startNow: false, startOnLogin: false })).rejects.toThrow("unsafe service definition");
|
|
expect(await fs.readFile(target, "utf8")).toBe("preserve\n");
|
|
});
|
|
|
|
});
|