File size: 5,488 Bytes
f778c12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
// Launchd recovery tests cover daemon recovery behavior for macOS launchd services.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

const launchAgentPlistExists = vi.hoisted(() => vi.fn());
const repairLaunchAgentBootstrap = vi.hoisted(() => vi.fn());
const assertNoSystemLaunchDaemonOwnership = vi.hoisted(() => vi.fn());

vi.mock("../../daemon/launchd.js", () => ({
  formatLaunchAgentGuiSessionError: (params: {
    detail: string;
    domain: string;
    actionHint: string;
  }) =>
    [
      `launchctl bootstrap failed: ${params.detail}`,
      `LaunchAgent ${params.actionHint} requires a logged-in macOS GUI session for this user (${params.domain}).`,
    ].join("\n"),
  launchAgentPlistExists: (env: Record<string, string | undefined>) => launchAgentPlistExists(env),
  repairLaunchAgentBootstrap: (args: { env?: Record<string, string | undefined> }) =>
    repairLaunchAgentBootstrap(args),
  resolveLaunchAgentLabel: (env: Record<string, string | undefined>) =>
    env.OPENCLAW_LAUNCHD_LABEL ?? "ai.openclaw.gateway",
}));

vi.mock("../../daemon/launchd-system.js", () => ({
  assertNoSystemLaunchDaemonOwnership: (label: string) =>
    assertNoSystemLaunchDaemonOwnership(label),
}));

let recoverInstalledLaunchAgent: typeof import("./launchd-recovery.js").recoverInstalledLaunchAgent;

describe("recoverInstalledLaunchAgent", () => {
  beforeAll(async () => {
    ({ recoverInstalledLaunchAgent } = await import("./launchd-recovery.js"));
  });

  beforeEach(() => {
    launchAgentPlistExists.mockReset();
    repairLaunchAgentBootstrap.mockReset();
    assertNoSystemLaunchDaemonOwnership.mockReset();
    assertNoSystemLaunchDaemonOwnership.mockResolvedValue(undefined);
    launchAgentPlistExists.mockResolvedValue(false);
    repairLaunchAgentBootstrap.mockResolvedValue({ ok: true, status: "repaired" });
  });

  it("returns null outside macOS", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("linux");

    await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
    expect(launchAgentPlistExists).not.toHaveBeenCalled();
    expect(assertNoSystemLaunchDaemonOwnership).not.toHaveBeenCalled();
  });

  it("returns null when the LaunchAgent plist is missing", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    launchAgentPlistExists.mockResolvedValue(false);

    await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
    expect(assertNoSystemLaunchDaemonOwnership).toHaveBeenCalledWith("ai.openclaw.gateway");
    expect(repairLaunchAgentBootstrap).not.toHaveBeenCalled();
  });

  it("returns a loaded recovery result when bootstrap repair succeeds", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    launchAgentPlistExists.mockResolvedValue(true);

    await expect(recoverInstalledLaunchAgent({ result: "restarted" })).resolves.toEqual({
      result: "restarted",
      loaded: true,
      message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.",
    });
    expect(launchAgentPlistExists).toHaveBeenCalledWith(process.env);
    expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env });
  });

  it("returns null when bootstrap repair fails", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    launchAgentPlistExists.mockResolvedValue(true);
    repairLaunchAgentBootstrap.mockResolvedValue({
      ok: false,
      status: "kickstart-failed",
      detail: "permission denied",
    });

    await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull();
  });

  it("surfaces headless GUI bootstrap failures instead of falling back to unmanaged restart", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    launchAgentPlistExists.mockResolvedValue(true);
    repairLaunchAgentBootstrap.mockResolvedValue({
      ok: false,
      status: "gui-session-unavailable",
      detail: "Bootstrap failed: 125: Domain does not support specified action",
      domain: "gui/501",
    });

    await expect(recoverInstalledLaunchAgent({ result: "restarted" })).rejects.toThrow(
      "requires a logged-in macOS GUI session",
    );
  });

  it("surfaces system ownership even when no user LaunchAgent plist exists", async () => {
    vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
    launchAgentPlistExists.mockResolvedValue(false);
    assertNoSystemLaunchDaemonOwnership.mockRejectedValue(
      new Error("System LaunchDaemon system/ai.openclaw.gateway owns this label"),
    );

    await expect(recoverInstalledLaunchAgent({ result: "started" })).rejects.toThrow(
      "System LaunchDaemon system/ai.openclaw.gateway owns this label",
    );
    expect(launchAgentPlistExists).not.toHaveBeenCalled();
  });

  it.each(["system-launchdaemon-conflict", "system-launchdaemon-unverifiable"] as const)(
    "preserves typed %s bootstrap failures",
    async (status) => {
      vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
      launchAgentPlistExists.mockResolvedValue(true);
      repairLaunchAgentBootstrap.mockResolvedValue({
        ok: false,
        status,
        detail: "system ownership recovery guidance",
      });

      await expect(recoverInstalledLaunchAgent({ result: "restarted" })).rejects.toThrow(
        "system ownership recovery guidance",
      );
    },
  );
});