| |
| import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
| import type { OpenClawConfig } from "../../config/config.js"; |
| import type { GatewayServiceControlArgs } from "../../daemon/service-types.js"; |
| import type { GatewayService } from "../../daemon/service.js"; |
| import { mockSystemAccountHome } from "../../daemon/service.test-helpers.js"; |
| import { withEnvAsync } from "../../test-utils/env.js"; |
| import { |
| createGatewayServiceRunArgs as createServiceRunArgs, |
| createGatewayUninstallArgs, |
| lifecycleTestRuntime, |
| resetLifecycleRuntimeLogs, |
| resetLifecycleServiceMocks, |
| lifecycleRuntimeLogs, |
| service, |
| stubEmptyGatewayEnv, |
| } from "./test-helpers/lifecycle-core-harness.js"; |
|
|
| const loadConfig = vi.fn<() => OpenClawConfig>(() => ({ |
| gateway: { |
| auth: { |
| token: "config-token", |
| }, |
| }, |
| })); |
| const writeGatewayRestartIntentSync = vi.fn(); |
| const clearGatewayRestartIntentSync = vi.fn(); |
| const appendGatewayLifecycleAudit = vi.fn(); |
| const MISSING_SERVICE_PROGRAM = "/openclaw-test-missing-runtime/node"; |
| const SERVICE_REPAIR_COMMAND_CASES = [ |
| ["Gateway", "", "", "openclaw gateway", "restart"], |
| ["Node", "", "", "openclaw node", "install --force"], |
| ["Node", "work", "", "openclaw --profile work node", "install --force"], |
| ["Node", "work", "demo", "openclaw --container demo node", "install --force"], |
| ] as const; |
| const createGatewayLifecycleMutationAudit = vi.fn( |
| (params: { action: string; source?: string }) => (mutation: { mode: string; pid?: number }) => |
| appendGatewayLifecycleAudit({ |
| action: params.action, |
| source: params.source ?? "cli", |
| ...mutation, |
| }), |
| ); |
|
|
| vi.mock("../../config/config.js", () => ({ |
| getRuntimeConfig: () => loadConfig(), |
| loadConfig: () => loadConfig(), |
| readBestEffortConfig: async () => loadConfig(), |
| })); |
|
|
| vi.mock("../../runtime.js", () => ({ |
| defaultRuntime: lifecycleTestRuntime, |
| })); |
|
|
| vi.mock("../../infra/restart-intent.js", () => ({ |
| clearGatewayRestartIntentSync: () => clearGatewayRestartIntentSync(), |
| writeGatewayRestartIntentSync: (opts: unknown) => writeGatewayRestartIntentSync(opts), |
| })); |
|
|
| vi.mock("./lifecycle-audit.js", () => ({ |
| appendGatewayLifecycleAudit: (params: unknown) => appendGatewayLifecycleAudit(params), |
| createGatewayLifecycleMutationAudit: (params: { action: string; source?: string }) => |
| createGatewayLifecycleMutationAudit(params), |
| createServiceLifecycleMutationAudit: (params: { serviceNoun: string; action: string }) => |
| params.serviceNoun === "Gateway" ? createGatewayLifecycleMutationAudit(params) : undefined, |
| appendServiceLifecycleRepairAudit: (params: { |
| serviceNoun: string; |
| action: string; |
| pid?: number; |
| }) => { |
| if (params.serviceNoun === "Gateway") { |
| appendGatewayLifecycleAudit({ |
| action: params.action, |
| source: "cli", |
| mode: "service-repair", |
| ...(params.pid === undefined ? {} : { pid: params.pid }), |
| }); |
| } |
| }, |
| })); |
|
|
| let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart; |
| let runServiceStart: typeof import("./lifecycle-core.js").runServiceStart; |
| let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop; |
| let runServiceUninstall: typeof import("./lifecycle-core.js").runServiceUninstall; |
|
|
| |
| function readJsonLog<T extends object>() { |
| const jsonLine = lifecycleRuntimeLogs.find((line) => line.trim().startsWith("{")); |
| return JSON.parse(jsonLine ?? "{}") as T; |
| } |
|
|
| function stubConfigSecretRefGatewayToken() { |
| loadConfig.mockReturnValue({ |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| gateway: { |
| auth: { |
| mode: "token", |
| token: { |
| source: "env", |
| provider: "default", |
| id: "SERVICE_GATEWAY_TOKEN", |
| }, |
| }, |
| }, |
| }); |
| } |
|
|
| function stubServiceGatewayTokenEnv() { |
| service.readCommand.mockResolvedValue({ |
| programArguments: [], |
| environment: { |
| OPENCLAW_GATEWAY_TOKEN: "service-token", |
| SERVICE_GATEWAY_TOKEN: "service-token", |
| }, |
| }); |
| } |
|
|
| async function withUnsupportedGatewayService( |
| run: (unsupportedService: GatewayService) => Promise<void>, |
| ) { |
| const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("aix"); |
| try { |
| const { resolveGatewayService } = await import("../../daemon/service.js"); |
| await run(resolveGatewayService()); |
| } finally { |
| platformSpy.mockRestore(); |
| } |
| } |
|
|
| function expectUnsupportedServiceCheckFailure() { |
| const payload = readJsonLog<{ ok?: boolean; error?: string }>(); |
| expect(payload.ok).toBe(false); |
| expect(payload.error).toContain( |
| "Gateway service check failed: Error: Gateway service install not supported on aix", |
| ); |
| } |
|
|
| describe("runServiceRestart token drift", () => { |
| beforeAll(async () => { |
| ({ runServiceRestart, runServiceStart, runServiceStop, runServiceUninstall } = |
| await import("./lifecycle-core.js")); |
| }); |
|
|
| afterEach(() => { |
| vi.restoreAllMocks(); |
| }); |
|
|
| beforeEach(() => { |
| mockSystemAccountHome(); |
| appendGatewayLifecycleAudit.mockClear(); |
| createGatewayLifecycleMutationAudit.mockClear(); |
| resetLifecycleRuntimeLogs(); |
| loadConfig.mockReset(); |
| loadConfig.mockReturnValue({ |
| gateway: { |
| auth: { |
| token: "config-token", |
| }, |
| }, |
| }); |
| resetLifecycleServiceMocks(); |
| writeGatewayRestartIntentSync.mockClear(); |
| clearGatewayRestartIntentSync.mockClear(); |
| service.readCommand.mockResolvedValue({ |
| programArguments: [], |
| environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" }, |
| }); |
| stubEmptyGatewayEnv(); |
| }); |
|
|
| it("rejects unsupported-platform start before not-loaded recovery", async () => { |
| const onNotLoaded = vi.fn(async () => ({ |
| result: "started" as const, |
| message: "should not run", |
| loaded: true, |
| })); |
|
|
| await withUnsupportedGatewayService(async (unsupportedService) => { |
| await expect( |
| runServiceStart({ |
| serviceNoun: "Gateway", |
| service: unsupportedService, |
| renderStartHints: () => ["openclaw gateway install"], |
| opts: { json: true }, |
| onNotLoaded, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
| }); |
|
|
| expect(onNotLoaded).not.toHaveBeenCalled(); |
| expectUnsupportedServiceCheckFailure(); |
| }); |
|
|
| it("rejects unsupported-platform stop before unmanaged fallback", async () => { |
| const onNotLoaded = vi.fn(async () => ({ |
| result: "stopped" as const, |
| message: "should not run", |
| })); |
|
|
| await withUnsupportedGatewayService(async (unsupportedService) => { |
| await expect( |
| runServiceStop({ |
| serviceNoun: "Gateway", |
| service: unsupportedService, |
| opts: { json: true }, |
| onNotLoaded, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
| }); |
|
|
| expect(onNotLoaded).not.toHaveBeenCalled(); |
| expectUnsupportedServiceCheckFailure(); |
| }); |
|
|
| it("rejects unsupported-platform restart before unmanaged fallback", async () => { |
| const onNotLoaded = vi.fn(async () => ({ |
| result: "restarted" as const, |
| message: "should not run", |
| })); |
| const postRestartCheck = vi.fn(async () => {}); |
|
|
| await withUnsupportedGatewayService(async (unsupportedService) => { |
| await expect( |
| runServiceRestart({ |
| serviceNoun: "Gateway", |
| service: unsupportedService, |
| renderStartHints: () => ["openclaw gateway install"], |
| opts: { json: true }, |
| onNotLoaded, |
| postRestartCheck, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
| }); |
|
|
| expect(onNotLoaded).not.toHaveBeenCalled(); |
| expect(postRestartCheck).not.toHaveBeenCalled(); |
| expectUnsupportedServiceCheckFailure(); |
| }); |
|
|
| it.each([ |
| { |
| name: "initial uninstall inspection", |
| arrange: () => service.isLoaded.mockRejectedValue(new Error("initial inspection failed")), |
| run: () => runServiceUninstall(createGatewayUninstallArgs()), |
| action: "uninstall", |
| detail: "initial inspection failed", |
| stopCalls: 0, |
| uninstallCalls: 0, |
| }, |
| { |
| name: "post-uninstall verification", |
| arrange: () => |
| service.isLoaded |
| .mockResolvedValueOnce(false) |
| .mockRejectedValueOnce(new Error("uninstall verification failed")), |
| run: () => runServiceUninstall(createGatewayUninstallArgs()), |
| action: "uninstall", |
| detail: "uninstall verification failed", |
| stopCalls: 0, |
| uninstallCalls: 1, |
| }, |
| { |
| name: "post-stop verification", |
| arrange: () => |
| service.isLoaded |
| .mockResolvedValueOnce(true) |
| .mockRejectedValueOnce(new Error("stop verification failed")), |
| run: () => runServiceStop({ serviceNoun: "Gateway", service, opts: { json: true } }), |
| action: "stop", |
| detail: "stop verification failed", |
| stopCalls: 1, |
| uninstallCalls: 0, |
| }, |
| ])("fails $name without reporting false absence", async (testCase) => { |
| testCase.arrange(); |
|
|
| await expect(testCase.run()).rejects.toThrow("__exit__:1"); |
|
|
| expect( |
| readJsonLog<{ action?: string; ok?: boolean; result?: string; error?: string }>(), |
| ).toEqual( |
| expect.objectContaining({ |
| action: testCase.action, |
| ok: false, |
| error: expect.stringContaining(testCase.detail), |
| }), |
| ); |
| expect(service.stop).toHaveBeenCalledTimes(testCase.stopCalls); |
| expect(service.uninstall).toHaveBeenCalledTimes(testCase.uninstallCalls); |
| }); |
|
|
| it("fails restart with the container hint when no service is installed", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| service.readCommand.mockResolvedValue(null); |
| const hasInstalledDefinition = vi.fn(async () => false); |
| vi.stubEnv("OPENCLAW_CONTAINER_HINT", "openclaw-demo-container"); |
|
|
| await expect( |
| runServiceRestart({ |
| serviceNoun: "Gateway", |
| service: { ...service, hasInstalledDefinition } as GatewayService, |
| renderStartHints: () => [ |
| "Restart the container or the service that manages it for openclaw-demo-container.", |
| "openclaw gateway install", |
| ], |
| opts: { json: true }, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
|
|
| const payload = readJsonLog<{ |
| action?: string; |
| ok?: boolean; |
| error?: string; |
| hints?: string[]; |
| hintItems?: Array<{ kind: string; text: string }>; |
| }>(); |
| expect(payload).toMatchObject({ |
| action: "restart", |
| ok: false, |
| error: "Gateway service not loaded.", |
| }); |
| expect(payload.hints).toContain( |
| "Restart the container or the service that manages it for openclaw-demo-container.", |
| ); |
| expect(payload.hintItems).toContainEqual( |
| expect.objectContaining({ kind: "container-restart" }), |
| ); |
| expect(hasInstalledDefinition).toHaveBeenCalledWith({ env: process.env }); |
| }); |
|
|
| it("restarts a disabled installed service through its native manager", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| const hasInstalledDefinition = vi.fn(async () => true); |
| const onNotLoaded = vi.fn(async () => null); |
| const renderStartHints = vi.fn(() => ["openclaw gateway install"]); |
| service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.onMutation?.({ mode: "systemctl-restart" }); |
| return { outcome: "completed" }; |
| }); |
|
|
| await expect( |
| runServiceRestart({ |
| ...createServiceRunArgs(), |
| service: { ...service, hasInstalledDefinition } as GatewayService, |
| renderStartHints, |
| onNotLoaded, |
| }), |
| ).resolves.toBe(true); |
|
|
| expect(hasInstalledDefinition).toHaveBeenCalledWith({ env: process.env }); |
| expect(service.restart).toHaveBeenCalledTimes(1); |
| expect(onNotLoaded).not.toHaveBeenCalled(); |
| expect(renderStartHints).not.toHaveBeenCalled(); |
| expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({ |
| action: "restart", |
| source: "cli", |
| mode: "systemctl-restart", |
| }); |
| expect(readJsonLog<{ ok?: boolean; result?: string; hints?: string[] }>()).toMatchObject({ |
| ok: true, |
| result: "restarted", |
| }); |
| }); |
|
|
| it("runs the service mutation guard before restarting a loaded service", async () => { |
| const beforeServiceMutation = vi.fn(); |
|
|
| await runServiceRestart({ |
| ...createServiceRunArgs(), |
| beforeServiceMutation, |
| }); |
|
|
| expect(beforeServiceMutation).toHaveBeenCalledTimes(1); |
| expect(beforeServiceMutation.mock.invocationCallOrder[0]).toBeLessThan( |
| service.restart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, |
| ); |
| }); |
|
|
| it("fails restart when an installed service cannot be inspected", async () => { |
| service.isLoaded.mockRejectedValue( |
| new Error( |
| "systemctl is-enabled unavailable: Command failed during launch or output capture (EACCES)", |
| ), |
| ); |
| service.readCommand.mockResolvedValue(null); |
| const hasInstalledDefinition = vi.fn(async () => true); |
| const postRestartCheck = vi.fn(async () => {}); |
|
|
| await expect( |
| runServiceRestart({ |
| ...createServiceRunArgs(), |
| service: { ...service, hasInstalledDefinition } as GatewayService, |
| postRestartCheck, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
|
|
| expect(hasInstalledDefinition).not.toHaveBeenCalled(); |
| expect(service.restart).not.toHaveBeenCalled(); |
| expect(postRestartCheck).not.toHaveBeenCalled(); |
| expect(readJsonLog<{ ok?: boolean; error?: string }>()).toMatchObject({ |
| ok: false, |
| error: expect.stringContaining("systemctl is-enabled unavailable"), |
| }); |
| }); |
|
|
| it("aborts loaded-service mutation when the service guard rejects", async () => { |
| const repairLoadedService = vi.fn(); |
|
|
| await expect( |
| runServiceRestart({ |
| ...createServiceRunArgs(), |
| beforeServiceMutation: () => { |
| throw new Error("service mutation denied"); |
| }, |
| repairLoadedService, |
| }), |
| ).rejects.toThrow("service mutation denied"); |
|
|
| expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled(); |
| expect(repairLoadedService).not.toHaveBeenCalled(); |
| expect(service.restart).not.toHaveBeenCalled(); |
| }); |
|
|
| it("does not run the service mutation guard before not-loaded recovery", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| service.readCommand.mockResolvedValue(null); |
| const beforeServiceMutation = vi.fn(); |
|
|
| await runServiceRestart({ |
| ...createServiceRunArgs(), |
| beforeServiceMutation, |
| onNotLoaded: async () => ({ |
| result: "restarted", |
| message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.", |
| }), |
| }); |
|
|
| expect(beforeServiceMutation).not.toHaveBeenCalled(); |
| expect(service.restart).not.toHaveBeenCalled(); |
| }); |
|
|
| it("repairs managed port drift before restarting", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); |
| service.readCommand.mockResolvedValue({ |
| programArguments: ["openclaw", "gateway", "--port", "18789"], |
| environment: { OPENCLAW_GATEWAY_PORT: "18789" }, |
| }); |
| type RepairLoadedService = NonNullable< |
| Parameters<typeof runServiceRestart>[0]["repairLoadedService"] |
| >; |
| const repairLoadedService = vi.fn<RepairLoadedService>(async () => ({ |
| result: "restarted" as const, |
| message: "Gateway service definition repaired and restarted.", |
| loaded: true, |
| })); |
|
|
| await runServiceRestart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true, restartIntent: { waitMs: 2_500 } }, |
| expectedPort: 19_001, |
| repairLoadedService, |
| }); |
|
|
| expect(repairLoadedService).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| issues: [ |
| { |
| code: "port-mismatch", |
| message: "service port 18789 does not match current gateway config port 19001", |
| }, |
| ], |
| }), |
| ); |
| expect(service.restart).not.toHaveBeenCalled(); |
| expect(writeGatewayRestartIntentSync).toHaveBeenCalledWith({ |
| targetPid: 1234, |
| reason: "gateway.restart", |
| intent: { waitMs: 2_500 }, |
| }); |
| expect(readJsonLog<{ result?: string; message?: string }>()).toMatchObject({ |
| result: "restarted", |
| message: "Gateway service definition repaired and restarted.", |
| }); |
| }); |
|
|
| it.each([true, false])( |
| "keeps Nix restart available without suggesting a forbidden token reinstall (json=%s)", |
| async (json) => { |
| await withEnvAsync({ OPENCLAW_NIX_MODE: "1" }, async () => { |
| await expect( |
| runServiceRestart({ ...createServiceRunArgs(true), opts: { json } }), |
| ).resolves.toBe(true); |
|
|
| expect(service.restart).toHaveBeenCalledOnce(); |
| const output = json |
| ? readJsonLog<{ warnings: string[] }>().warnings.join("\n") |
| : lifecycleRuntimeLogs.join("\n"); |
| expect(output).toContain("Config token differs from service token"); |
| expect(output).toContain("Nix mode detected; service install is disabled."); |
| expect(output).not.toContain("gateway install --force"); |
| }); |
| }, |
| ); |
|
|
| it("emits drift warning when enabled", async () => { |
| await runServiceRestart(createServiceRunArgs(true)); |
|
|
| expect(loadConfig).toHaveBeenCalledTimes(1); |
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings?.some((warning) => warning.includes("gateway install --force"))).toBe( |
| true, |
| ); |
| }); |
|
|
| it("compares restart drift against config token even when caller env is set", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| auth: { |
| token: "config-token", |
| }, |
| }, |
| }); |
| service.readCommand.mockResolvedValue({ |
| programArguments: [], |
| environment: { OPENCLAW_GATEWAY_TOKEN: "env-token" }, |
| }); |
| vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "env-token"); |
|
|
| await runServiceRestart(createServiceRunArgs(true)); |
|
|
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings?.some((warning) => warning.includes("gateway install --force"))).toBe( |
| true, |
| ); |
| }); |
|
|
| it("resolves config token SecretRefs using service command env before drift checks", async () => { |
| stubConfigSecretRefGatewayToken(); |
| stubServiceGatewayTokenEnv(); |
|
|
| await runServiceRestart(createServiceRunArgs(true)); |
|
|
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings).toBeUndefined(); |
| }); |
|
|
| it("prefers service command env over process env for SecretRef token drift resolution", async () => { |
| stubConfigSecretRefGatewayToken(); |
| stubServiceGatewayTokenEnv(); |
| vi.stubEnv("SERVICE_GATEWAY_TOKEN", "process-token"); |
|
|
| await runServiceRestart(createServiceRunArgs(true)); |
|
|
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings).toBeUndefined(); |
| }); |
|
|
| it("skips drift warning when disabled", async () => { |
| await runServiceRestart({ |
| serviceNoun: "Node", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| }); |
|
|
| expect(loadConfig).not.toHaveBeenCalled(); |
| expect(service.readCommand).not.toHaveBeenCalled(); |
| expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled(); |
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings).toBeUndefined(); |
| }); |
|
|
| it("emits stopped when an unmanaged process handles stop", async () => { |
| service.isLoaded.mockResolvedValue(false); |
|
|
| await runServiceStop({ |
| serviceNoun: "Gateway", |
| service, |
| opts: { json: true }, |
| onNotLoaded: async () => ({ |
| result: "stopped", |
| message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.", |
| }), |
| }); |
|
|
| const payload = readJsonLog<{ result?: string; message?: string }>(); |
| expect(payload.result).toBe("stopped"); |
| expect(payload.message).toContain("unmanaged process"); |
| expect(service.stop).not.toHaveBeenCalled(); |
| }); |
|
|
| it("runs a requested managed stop even when the service is not loaded", async () => { |
| const onNotLoaded = vi.fn(async () => ({ |
| result: "stopped" as const, |
| message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.", |
| })); |
| service.isLoaded.mockResolvedValue(false); |
|
|
| await runServiceStop({ |
| serviceNoun: "Gateway", |
| service, |
| opts: { json: true, disable: true }, |
| stopWhenNotLoaded: true, |
| onNotLoaded, |
| }); |
|
|
| const payload = readJsonLog<{ result?: string; service?: { loaded?: boolean } }>(); |
| expect(payload.result).toBe("stopped"); |
| expect(payload.service?.loaded).toBe(false); |
| expect(service.stop).toHaveBeenCalledTimes(1); |
| const [stopOptions] = service.stop.mock.calls[0] ?? []; |
| expect(stopOptions?.env).toBe(process.env); |
| expect(stopOptions?.disable).toBe(true); |
| expect(onNotLoaded).not.toHaveBeenCalled(); |
| }); |
|
|
| it("emits started when a not-loaded start path repairs the service", async () => { |
| service.isLoaded.mockResolvedValue(false); |
|
|
| await runServiceStart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| onNotLoaded: async () => ({ |
| result: "started", |
| message: |
| "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", |
| loaded: true, |
| }), |
| }); |
|
|
| const payload = readJsonLog<{ |
| result?: string; |
| message?: string; |
| service?: { loaded?: boolean }; |
| }>(); |
| expect(payload.result).toBe("started"); |
| expect(payload.message).toContain("re-bootstrapped"); |
| expect(payload.service?.loaded).toBe(true); |
| expect(service.start).not.toHaveBeenCalled(); |
| }); |
|
|
| it("runs restart health checks after an unmanaged restart signal", async () => { |
| const postRestartCheck = vi.fn(async () => {}); |
| service.isLoaded.mockResolvedValue(false); |
| service.readCommand.mockResolvedValue(null); |
|
|
| await runServiceRestart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| onNotLoaded: async () => ({ |
| result: "restarted", |
| message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.", |
| }), |
| postRestartCheck, |
| }); |
|
|
| expect(postRestartCheck).toHaveBeenCalledExactlyOnceWith( |
| expect.objectContaining({ activationAccepted: true }), |
| ); |
| expect(service.restart).not.toHaveBeenCalled(); |
| const payload = readJsonLog<{ result?: string; message?: string }>(); |
| expect(payload.result).toBe("restarted"); |
| expect(payload.message).toContain("unmanaged process"); |
| }); |
|
|
| it("emits loaded restart state when launchd repair handles a not-loaded restart", async () => { |
| const postRestartCheck = vi.fn(async () => {}); |
| service.isLoaded.mockResolvedValue(false); |
| service.readCommand.mockResolvedValue(null); |
|
|
| await runServiceRestart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| onNotLoaded: async () => ({ |
| result: "restarted", |
| message: |
| "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", |
| loaded: true, |
| }), |
| postRestartCheck, |
| }); |
|
|
| expect(postRestartCheck).toHaveBeenCalledTimes(1); |
| expect(service.restart).not.toHaveBeenCalled(); |
| const payload = readJsonLog<{ |
| result?: string; |
| message?: string; |
| service?: { loaded?: boolean }; |
| }>(); |
| expect(payload.result).toBe("restarted"); |
| expect(payload.message).toContain("re-bootstrapped"); |
| expect(payload.service?.loaded).toBe(true); |
| }); |
|
|
| it("skips restart health checks when restart is only scheduled", async () => { |
| const postRestartCheck = vi.fn(async () => {}); |
| service.restart.mockResolvedValue({ outcome: "scheduled" }); |
|
|
| const result = await runServiceRestart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| postRestartCheck, |
| }); |
|
|
| expect(result).toBe(true); |
| expect(postRestartCheck).not.toHaveBeenCalled(); |
| const payload = readJsonLog<{ result?: string; message?: string }>(); |
| expect(payload.result).toBe("scheduled"); |
| expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); |
| }); |
|
|
| it("writes a restart intent before service-manager restart", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); |
|
|
| await runServiceRestart(createServiceRunArgs()); |
|
|
| expect(writeGatewayRestartIntentSync).toHaveBeenCalledWith({ |
| targetPid: 1234, |
| reason: "gateway.restart", |
| }); |
| expect(clearGatewayRestartIntentSync).not.toHaveBeenCalled(); |
| expect(service.restart).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("captures service restart warnings in json restart output", async () => { |
| service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.warn?.( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| return { outcome: "completed" }; |
| }); |
|
|
| await runServiceRestart(createServiceRunArgs()); |
|
|
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings).toContain( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| expect(service.restart).toHaveBeenCalledWith( |
| expect.objectContaining({ warn: expect.any(Function) }), |
| ); |
| }); |
|
|
| it("writes restart force and wait options into the service-manager intent", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); |
|
|
| await runServiceRestart({ |
| ...createServiceRunArgs(), |
| opts: { |
| json: true, |
| restartIntent: { |
| waitMs: 2_500, |
| }, |
| }, |
| }); |
|
|
| expect(writeGatewayRestartIntentSync).toHaveBeenCalledWith({ |
| targetPid: 1234, |
| reason: "gateway.restart", |
| intent: { |
| waitMs: 2_500, |
| }, |
| }); |
| }); |
|
|
| it("clears restart intent when service-manager restart fails before signaling", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); |
| writeGatewayRestartIntentSync.mockReturnValueOnce(true); |
| service.restart.mockRejectedValueOnce(new Error("launchctl failed before signaling")); |
|
|
| await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); |
|
|
| expect(writeGatewayRestartIntentSync).toHaveBeenCalledWith({ |
| targetPid: 1234, |
| reason: "gateway.restart", |
| }); |
| expect(clearGatewayRestartIntentSync).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("reports an already-running gateway without starting it", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 4242 }); |
|
|
| await runServiceStart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| }); |
|
|
| const payload = readJsonLog<{ ok?: boolean; result?: string; message?: string }>(); |
| expect(payload).toMatchObject({ |
| ok: true, |
| result: "already-running", |
| message: "Gateway service already running (pid 4242).", |
| }); |
| expect(service.start).not.toHaveBeenCalled(); |
| expect(appendGatewayLifecycleAudit).not.toHaveBeenCalled(); |
| }); |
|
|
| it.each(SERVICE_REPAIR_COMMAND_CASES)( |
| "warns in json with the %s service repair command and active context", |
| async (serviceNoun, profile, container, command, repairAction) => { |
| vi.stubEnv("OPENCLAW_PROFILE", profile); |
| vi.stubEnv("OPENCLAW_CONTAINER_HINT", container); |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 4242 }); |
| service.readCommand.mockResolvedValue({ |
| programArguments: [MISSING_SERVICE_PROGRAM, "openclaw", serviceNoun.toLowerCase()], |
| }); |
|
|
| await runServiceStart({ |
| ...createServiceRunArgs(), |
| serviceNoun, |
| repairLoadedService: serviceNoun === "Gateway" ? vi.fn(async () => null) : undefined, |
| }); |
|
|
| const payload = readJsonLog<{ result?: string; warnings?: string[] }>(); |
| expect(payload.result).toBe("already-running"); |
| expect(payload.warnings).toEqual([ |
| `${serviceNoun} service already running, but its installed service definition needs repair: service command points at a missing path: ${MISSING_SERVICE_PROGRAM}; run \`${command} ${repairAction}\` to apply.`, |
| ]); |
| expect(service.start).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it.each([ |
| ["Gateway", "restart"], |
| ["Node", "install --force"], |
| ])( |
| "prints one warning line when an already-running %s service needs repair", |
| async (serviceNoun, repairAction) => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 4242 }); |
| service.readCommand.mockResolvedValue({ |
| programArguments: [MISSING_SERVICE_PROGRAM, "openclaw", serviceNoun.toLowerCase()], |
| }); |
|
|
| await runServiceStart({ |
| serviceNoun, |
| service, |
| renderStartHints: () => [], |
| repairLoadedService: serviceNoun === "Gateway" ? vi.fn(async () => null) : undefined, |
| }); |
|
|
| const repairWarnings = lifecycleRuntimeLogs.filter((line) => |
| line.startsWith( |
| `${serviceNoun} service already running, but its installed service definition needs repair:`, |
| ), |
| ); |
| expect(repairWarnings).toHaveLength(1); |
| expect(repairWarnings[0]).toContain( |
| `run \`openclaw ${serviceNoun.toLowerCase()} ${repairAction}\` to apply.`, |
| ); |
| expect(service.start).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it("audits a service start that actually mutates the gateway", async () => { |
| service.start.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.onMutation?.({ mode: "kickstart" }); |
| }); |
|
|
| await runServiceStart(createServiceRunArgs()); |
|
|
| expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({ |
| action: "start", |
| source: "cli", |
| mode: "kickstart", |
| }); |
| }); |
|
|
| it("audits direct managed restart mutations", async () => { |
| service.readRuntime.mockResolvedValue({ status: "running", pid: 4242 }); |
| service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.onMutation?.({ mode: "kickstart" }); |
| return { outcome: "completed" }; |
| }); |
|
|
| await runServiceRestart(createServiceRunArgs()); |
|
|
| expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({ |
| action: "restart", |
| source: "cli", |
| mode: "kickstart", |
| }); |
| }); |
|
|
| it("audits direct managed stop mutations", async () => { |
| service.stop.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.onMutation?.({ mode: "bootout" }); |
| }); |
|
|
| await runServiceStop({ |
| serviceNoun: "Gateway", |
| service, |
| opts: { json: true }, |
| }); |
|
|
| expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({ |
| action: "stop", |
| source: "cli", |
| mode: "bootout", |
| }); |
| }); |
|
|
| it("captures service start warnings in json start output", async () => { |
| service.start.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { |
| args?.warn?.( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| }); |
|
|
| await runServiceStart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| }); |
|
|
| const payload = readJsonLog<{ warnings?: string[] }>(); |
| expect(payload.warnings).toContain( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| expect(service.start).toHaveBeenCalledWith( |
| expect.objectContaining({ warn: expect.any(Function) }), |
| ); |
| }); |
|
|
| it("repairs loaded services with port drift during start before reporting success", async () => { |
| service.readCommand.mockResolvedValue({ |
| programArguments: ["openclaw", "gateway", "--port", "18789"], |
| }); |
| type RepairLoadedService = NonNullable< |
| Parameters<typeof runServiceStart>[0]["repairLoadedService"] |
| >; |
| const repairLoadedService = vi.fn<RepairLoadedService>(async (ctx) => { |
| ctx.warn?.( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| return { |
| result: "started" as const, |
| message: "Gateway service definition repaired and started.", |
| warnings: ["service port 18789 does not match current gateway config port 19001"], |
| loaded: true, |
| }; |
| }); |
|
|
| await runServiceStart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => [], |
| opts: { json: true }, |
| repairLoadedService, |
| expectedPort: 19_001, |
| }); |
|
|
| expect(repairLoadedService).toHaveBeenCalledTimes(1); |
| expect(service.start).not.toHaveBeenCalled(); |
| const payload = readJsonLog<{ |
| result?: string; |
| message?: string; |
| warnings?: string[]; |
| service?: { loaded?: boolean }; |
| }>(); |
| expect(payload.result).toBe("started"); |
| expect(payload.message).toBe("Gateway service definition repaired and started."); |
| expect(payload.warnings).toEqual( |
| expect.arrayContaining([ |
| expect.stringContaining("service port 18789"), |
| expect.stringContaining("custom behavior and will be overwritten"), |
| ]), |
| ); |
| expect(payload.service?.loaded).toBe(true); |
| }); |
|
|
| it.each(SERVICE_REPAIR_COMMAND_CASES)( |
| "fails %s service start with its own install hint when repair is required", |
| async (serviceNoun, profile, container, command) => { |
| vi.stubEnv("OPENCLAW_PROFILE", profile); |
| vi.stubEnv("OPENCLAW_CONTAINER_HINT", container); |
| service.readCommand.mockResolvedValue({ |
| programArguments: [MISSING_SERVICE_PROGRAM, "openclaw", serviceNoun.toLowerCase()], |
| }); |
|
|
| await expect(runServiceStart({ ...createServiceRunArgs(), serviceNoun })).rejects.toThrow( |
| "__exit__:1", |
| ); |
|
|
| const payload = readJsonLog<{ |
| ok?: boolean; |
| error?: string; |
| hints?: string[]; |
| hintItems?: Array<{ kind: string; text: string }>; |
| }>(); |
| expect(payload.ok).toBe(false); |
| expect(payload.error).toContain("service needs repair"); |
| expect(payload.hints).toEqual([`${command} install --force`]); |
| expect(payload.hintItems).toEqual([{ kind: "install", text: `${command} install --force` }]); |
|
|
| resetLifecycleRuntimeLogs(); |
| await expect( |
| runServiceStart({ |
| ...createServiceRunArgs(), |
| serviceNoun, |
| opts: { json: false }, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
| expect(lifecycleRuntimeLogs).toContain(`Tip: ${command} install --force`); |
| expect(service.start).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it("fails start when starting a stopped installed service errors", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| service.start.mockRejectedValue(new Error("launchctl kickstart failed: permission denied")); |
|
|
| await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); |
|
|
| const payload = readJsonLog<{ ok?: boolean; error?: string }>(); |
| expect(payload.ok).toBe(false); |
| expect(payload.error).toContain("launchctl kickstart failed: permission denied"); |
| }); |
|
|
| it("runs the start health check before reporting success", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| const postStartCheck = vi.fn(async () => {}); |
|
|
| await runServiceStart({ ...createServiceRunArgs(), postStartCheck }); |
|
|
| expect(postStartCheck).toHaveBeenCalledOnce(); |
| const payload = readJsonLog<{ ok?: boolean; result?: string }>(); |
| expect(payload).toMatchObject({ ok: true, result: "started" }); |
| }); |
|
|
| it("fails start with install hints when no service is installed", async () => { |
| service.isLoaded.mockResolvedValue(false); |
| service.readCommand.mockResolvedValue(null); |
|
|
| await expect( |
| runServiceStart({ |
| serviceNoun: "Gateway", |
| service, |
| renderStartHints: () => ["openclaw gateway install"], |
| opts: { json: true }, |
| }), |
| ).rejects.toThrow("__exit__:1"); |
|
|
| const payload = readJsonLog<{ |
| ok?: boolean; |
| error?: string; |
| hints?: string[]; |
| hintItems?: Array<{ kind: string; text: string }>; |
| }>(); |
| expect(payload.ok).toBe(false); |
| expect(payload.error).toBe("Gateway service not loaded."); |
| expect(payload.hints?.includes("openclaw gateway install")).toBe(true); |
| expect( |
| payload.hintItems?.some( |
| (item) => item.kind === "install" && item.text === "openclaw gateway install", |
| ), |
| ).toBe(true); |
| expect(service.start).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|