openclaw/src/cli/update-cli/restart-helper.test.ts
artale b1d5c71609 fix(cli): use standalone script for service restart after update (#17225)
The updater was previously attempting to restart the service using the
installed codebase, which could be in an inconsistent state during the
update process. This caused the service to stall when the updater
deleted its own files before the restart could complete.

Changes:
- restart-helper.ts: new module that writes a platform-specific restart
  script to os.tmpdir() before the update begins (Linux systemd, macOS
  launchctl, Windows schtasks).
- update-command.ts: prepares the restart script before installing, then
  uses it for service restart instead of the standard runDaemonRestart.
- restart-helper.test.ts: 12 tests covering all platforms, custom
  profiles, error cases, and shell injection safety.

Review feedback addressed:
- Use spawn(detached: true) + unref() so restart script survives parent
  process termination (Greptile).
- Shell-escape profile values using single-quote wrapping to prevent
  injection via OPENCLAW_PROFILE (Greptile).
- Reject unsafe batch characters on Windows.
- Self-cleanup: scripts delete themselves after execution (Copilot).
- Add tests for write failures and custom profiles (Copilot).

Fixes #17225
2026-02-17 00:00:16 +01:00

214 lines
6.8 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
}));
describe("restart-helper", () => {
const originalPlatform = process.platform;
const originalGetUid = process.getuid;
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform });
process.getuid = originalGetUid;
});
describe("prepareRestartScript", () => {
it("creates a systemd restart script on Linux", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "default",
});
expect(scriptPath).toBeTruthy();
expect(scriptPath!.endsWith(".sh")).toBe(true);
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain("#!/bin/sh");
expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'");
// Script should self-cleanup
expect(content).toContain('rm -f "$0"');
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("creates a launchd restart script on macOS", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "default",
});
expect(scriptPath).toBeTruthy();
expect(scriptPath!.endsWith(".sh")).toBe(true);
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain("#!/bin/sh");
expect(content).toContain("launchctl kickstart -k 'gui/501/ai.openclaw.gateway'");
expect(content).toContain('rm -f "$0"');
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("creates a schtasks restart script on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "default",
});
expect(scriptPath).toBeTruthy();
expect(scriptPath!.endsWith(".bat")).toBe(true);
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain("@echo off");
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"');
expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway"');
// Batch self-cleanup
expect(content).toContain('del "%~f0"');
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("uses custom profile in service names", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "production",
});
expect(scriptPath).toBeTruthy();
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain("openclaw-gateway-production.service");
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("uses custom profile in macOS launchd label", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 502;
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "staging",
});
expect(scriptPath).toBeTruthy();
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain("gui/502/ai.openclaw.staging");
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("uses custom profile in Windows task name", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "production",
});
expect(scriptPath).toBeTruthy();
const content = await fs.readFile(scriptPath!, "utf-8");
expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"');
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("returns null for unsupported platforms", async () => {
Object.defineProperty(process, "platform", { value: "aix" });
const scriptPath = await prepareRestartScript({});
expect(scriptPath).toBeNull();
});
it("returns null when script creation fails", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const writeFileSpy = vi
.spyOn(fs, "writeFile")
.mockRejectedValueOnce(new Error("simulated write failure"));
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "default",
});
expect(scriptPath).toBeNull();
writeFileSpy.mockRestore();
});
it("escapes single quotes in profile names for shell scripts", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "it's-a-test",
});
expect(scriptPath).toBeTruthy();
const content = await fs.readFile(scriptPath!, "utf-8");
// Single quotes should be escaped with '\'' pattern
expect(content).not.toContain("it's");
expect(content).toContain("it'\\''s");
if (scriptPath) {
await fs.unlink(scriptPath);
}
});
it("rejects unsafe batch profile names on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = await prepareRestartScript({
OPENCLAW_PROFILE: "test&whoami",
});
expect(scriptPath).toBeNull();
});
});
describe("runRestartScript", () => {
it("spawns the script as a detached process on Linux", async () => {
Object.defineProperty(process, "platform", { value: "linux" });
const scriptPath = "/tmp/fake-script.sh";
const mockChild = { unref: vi.fn() };
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
await runRestartScript(scriptPath);
expect(spawn).toHaveBeenCalledWith("/bin/sh", [scriptPath], {
detached: true,
stdio: "ignore",
});
expect(mockChild.unref).toHaveBeenCalled();
});
it("uses cmd.exe on Windows", async () => {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = "C:\\Temp\\fake-script.bat";
const mockChild = { unref: vi.fn() };
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
await runRestartScript(scriptPath);
expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/c", scriptPath], {
detached: true,
stdio: "ignore",
});
expect(mockChild.unref).toHaveBeenCalled();
});
});
});