diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index ff70bfd2f3e..749ee0192b5 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -1,6 +1,7 @@ // Daemon lifecycle core tests cover service lifecycle transitions and platform adapters. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { GatewayService } from "../../daemon/service.js"; import { defaultRuntime, resetLifecycleRuntimeLogs, @@ -85,6 +86,26 @@ function stubServiceGatewayTokenEnv() { }); } +async function withUnsupportedGatewayService( + run: (unsupportedService: GatewayService) => Promise, +) { + 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 } = await import("./lifecycle-core.js")); @@ -110,6 +131,75 @@ describe("runServiceRestart token drift", () => { 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("prints the container restart hint when restart is requested for a not-loaded service", async () => { service.isLoaded.mockResolvedValue(false); vi.stubEnv("OPENCLAW_CONTAINER_HINT", "openclaw-demo-container"); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 9257bed56ef..e0688557dad 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -61,7 +61,9 @@ const readGatewayRestartHandoffSync = vi.fn< >(() => null); const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined); const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true); -const serviceReadRuntime = vi.fn(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" })); +const serviceReadRuntime = vi.fn< + (_env?: NodeJS.ProcessEnv) => Promise<{ status: string; detail?: string }> +>(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" })); const inspectGatewayRestart = vi.fn<(opts?: unknown) => Promise>( async (_opts?: unknown) => ({ runtime: { status: "running", pid: 1234 }, @@ -74,7 +76,7 @@ const serviceReadCommand = vi.fn< (env?: NodeJS.ProcessEnv) => Promise<{ programArguments: string[]; environment?: Record; - }> + } | null> >(async (_env?: NodeJS.ProcessEnv) => ({ programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], environment: { @@ -488,6 +490,29 @@ describe("gatherDaemonStatus", () => { expect((status.service.runtime as { detail?: string }).detail).toBe("19001"); }); + it("keeps gateway status read-only when service management is unsupported", async () => { + serviceReadCommand.mockResolvedValueOnce(null); + serviceIsLoaded.mockResolvedValueOnce(false); + serviceReadRuntime.mockResolvedValueOnce({ + status: "unknown", + detail: "Gateway service install not supported on aix", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: false, + }); + + expect(status.service.command).toBeNull(); + expect(status.service.loaded).toBe(false); + expect(status.service.runtime).toEqual({ + status: "unknown", + detail: "Gateway service install not supported on aix", + }); + expect(inspectGatewayRestart).not.toHaveBeenCalled(); + }); + it("surfaces recent service restart handoffs only during deep status", async () => { readGatewayRestartHandoffSync.mockReturnValueOnce({ kind: "gateway-supervisor-restart-handoff", diff --git a/src/commands/status.daemon.test.ts b/src/commands/status.daemon.test.ts index 35cd4c0f0a3..bba4271ef7a 100644 --- a/src/commands/status.daemon.test.ts +++ b/src/commands/status.daemon.test.ts @@ -99,4 +99,23 @@ describe("status daemon summary", () => { const summary = await getDaemonStatusSummary(); expect(summary.runtimeShort).toBe("running (pid 1234)"); }); + + it("keeps gateway status readable for unsupported service adapters", async () => { + mocks.readServiceStatusSummary.mockResolvedValueOnce({ + label: "Gateway service", + installed: false, + loaded: false, + managedByOpenClaw: false, + externallyManaged: false, + loadedText: "not installed", + runtime: { status: "unknown", detail: "Gateway service install not supported on aix" }, + }); + + const summary = await getDaemonStatusSummary(); + + expect(mocks.resolveGatewayService).toHaveBeenCalled(); + expect(summary.label).toBe("Gateway service"); + expect(summary.installed).toBe(false); + expect(summary.runtimeShort).toBe("unknown (Gateway service install not supported on aix)"); + }); }); diff --git a/src/commands/status.service-summary.test.ts b/src/commands/status.service-summary.test.ts index 49a3fd1ffc2..1e8a07b5828 100644 --- a/src/commands/status.service-summary.test.ts +++ b/src/commands/status.service-summary.test.ts @@ -2,10 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { GatewayService } from "../daemon/service.js"; +import { resolveGatewayService, type GatewayService } from "../daemon/service.js"; import type { GatewayServiceEnvArgs } from "../daemon/service.js"; import { createMockGatewayService } from "../daemon/service.test-helpers.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; +import { withMockedPlatform } from "../test-utils/vitest-spies.js"; import { readServiceStatusSummary } from "./status.service-summary.js"; function createService(overrides: Partial): GatewayService { @@ -65,6 +66,23 @@ describe("readServiceStatusSummary", () => { expect(summary.loadedText).toBe("disabled"); }); + it("keeps unsupported service adapters readable", async () => { + await withMockedPlatform("aix", async () => { + const summary = await readServiceStatusSummary(resolveGatewayService(), "Daemon"); + + expect(summary.label).toBe("Gateway service"); + expect(summary.installed).toBe(false); + expect(summary.loaded).toBe(false); + expect(summary.managedByOpenClaw).toBe(false); + expect(summary.externallyManaged).toBe(false); + expect(summary.loadedText).toBe("not installed"); + expect(summary.runtime).toEqual({ + status: "unknown", + detail: "Gateway service install not supported on aix", + }); + }); + }); + it("passes command environment to runtime and loaded checks", async () => { const isLoaded = vi.fn(async ({ env }: GatewayServiceEnvArgs) => { return env?.OPENCLAW_GATEWAY_PORT === "18789"; diff --git a/src/daemon/service.test.ts b/src/daemon/service.test.ts index 08187e93635..651f6d59b20 100644 --- a/src/daemon/service.test.ts +++ b/src/daemon/service.test.ts @@ -40,9 +40,21 @@ describe("resolveGatewayService", () => { expect(service.loadedText).toBe(loadedText); }); - it("throws for unsupported platforms", () => { + it("returns a read-only unsupported-platform adapter", async () => { setPlatform("aix"); - expect(() => resolveGatewayService()).toThrow("Gateway service install not supported on aix"); + const service = resolveGatewayService(); + + await expect(service.readCommand(process.env)).resolves.toBeNull(); + await expect(service.isLoaded({ env: process.env })).rejects.toThrow( + "Gateway service install not supported on aix", + ); + await expect(service.readRuntime(process.env)).resolves.toEqual({ + status: "unknown", + detail: "Gateway service install not supported on aix", + }); + await expect(service.restart({ env: process.env, stdout: process.stdout })).rejects.toThrow( + "Gateway service install not supported on aix", + ); }); it("guards mutating service adapters when config was written by a newer OpenClaw", async () => { diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 8b60f8d4b78..ec78e7db610 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -264,6 +264,33 @@ export function describeGatewayServiceRestart( type SupportedGatewayServicePlatform = "darwin" | "linux" | "win32"; +function createUnsupportedGatewayServiceError(): Error { + return new Error(`Gateway service install not supported on ${process.platform}`); +} + +async function rejectUnsupportedGatewayService(): Promise { + throw createUnsupportedGatewayServiceError(); +} + +function createUnsupportedGatewayService(): GatewayService { + return { + label: "Gateway service", + loadedText: "available", + notLoadedText: "not installed", + stage: rejectUnsupportedGatewayService, + install: rejectUnsupportedGatewayService, + uninstall: rejectUnsupportedGatewayService, + stop: rejectUnsupportedGatewayService, + restart: rejectUnsupportedGatewayService, + isLoaded: rejectUnsupportedGatewayService, + readCommand: async () => null, + readRuntime: async () => ({ + status: "unknown", + detail: createUnsupportedGatewayServiceError().message, + }), + }; +} + const GATEWAY_SERVICE_REGISTRY: Record = { darwin: { label: "LaunchAgent", @@ -344,5 +371,5 @@ export function resolveGatewayService(): GatewayService { if (isSupportedGatewayServicePlatform(process.platform)) { return withFutureConfigGuard(GATEWAY_SERVICE_REGISTRY[process.platform]); } - throw new Error(`Gateway service install not supported on ${process.platform}`); + return createUnsupportedGatewayService(); }