// Node daemon tests cover node daemon command runtime behavior and errors. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { runNodeDaemonInstall, runNodeDaemonRestart, runNodeDaemonStart, runNodeDaemonStatus, runNodeDaemonStop, runNodeDaemonUninstall, } from "./daemon.js"; const TLS_FINGERPRINT = "ab".repeat(32); const mocks = vi.hoisted(() => { const service = { label: "Node service", loadedText: "loaded", notLoadedText: "not loaded", stage: vi.fn(), install: vi.fn(), uninstall: vi.fn(), stop: vi.fn(), restart: vi.fn(), isLoaded: vi.fn(async () => true), readCommand: vi.fn<() => Promise>(async () => null), readRuntime: vi.fn<() => Promise>(async () => ({ status: "running" })), }; return { runtime: { log: vi.fn<(line: string) => void>(), error: vi.fn<(line: string) => void>(), writeJson: vi.fn(), exit: vi.fn(), }, service, buildNodeInstallPlan: vi.fn(async () => ({ programArguments: ["node", "node-host"], environment: {}, environmentValueSources: {}, })), loadNodeHostConfig: vi.fn(), isSystemdUserServiceAvailable: vi.fn(async () => true), resolveSystemdUserServiceAccount: vi.fn(() => "pi"), readSystemdUserLingerStatus: vi.fn( async (): Promise<{ user: string; linger: "yes" | "no" }> => ({ user: "pi", linger: "no", }), ), runServiceRestart: vi.fn(), runServiceStart: vi.fn(), runServiceStop: vi.fn(), runServiceUninstall: vi.fn(), }; }); vi.mock("../../runtime.js", () => ({ defaultRuntime: mocks.runtime, })); vi.mock("../../daemon/node-service.js", () => ({ resolveNodeService: () => mocks.service, })); vi.mock("../../commands/node-daemon-install-helpers.js", () => ({ buildNodeInstallPlan: mocks.buildNodeInstallPlan, })); vi.mock("../../node-host/config.js", () => ({ loadNodeHostConfig: mocks.loadNodeHostConfig, })); vi.mock("../daemon-cli/lifecycle-core.js", () => ({ runServiceRestart: mocks.runServiceRestart, runServiceStart: mocks.runServiceStart, runServiceStop: mocks.runServiceStop, runServiceUninstall: mocks.runServiceUninstall, })); vi.mock("../../daemon/runtime-hints.js", () => ({ buildPlatformRuntimeLogHints: () => [ "Logs: node service log", "Restart attempts: node restart log", ], buildPlatformServiceStartHints: () => ["openclaw node install", "openclaw node start"], })); vi.mock("../../daemon/systemd.js", async () => { const actual = await vi.importActual("../../daemon/systemd.js"); return { ...actual, isSystemdUserServiceAvailable: mocks.isSystemdUserServiceAvailable, resolveSystemdUserServiceAccount: mocks.resolveSystemdUserServiceAccount, readSystemdUserLingerStatus: mocks.readSystemdUserLingerStatus, }; }); vi.mock("../../../packages/terminal-core/src/theme.js", async () => { const actual = await vi.importActual< typeof import("../../../packages/terminal-core/src/theme.js") >("../../../packages/terminal-core/src/theme.js"); return { ...actual, colorize: (_rich: boolean, _theme: unknown, text: string) => text, }; }); vi.mock("../daemon-cli/shared.js", async () => { const actual = await vi.importActual("../daemon-cli/shared.js"); return { ...actual, createCliStatusTextStyles: () => ({ rich: false, label: (text: string) => text, accent: (text: string) => text, infoText: (text: string) => text, okText: (text: string) => text, warnText: (text: string) => text, errorText: (text: string) => text, }), formatRuntimeStatus: (runtime: GatewayServiceRuntime | undefined) => runtime?.status ?? "", resolveRuntimeStatusColor: () => "", }; }); function useLinuxPlatform(): void { vi.spyOn(process, "platform", "get").mockReturnValue("linux"); } afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); describe("runNodeDaemonInstall", () => { beforeEach(() => { mocks.runtime.log.mockClear(); mocks.runtime.error.mockClear(); mocks.runtime.writeJson.mockClear(); mocks.runtime.exit.mockClear(); vi.stubEnv("OPENCLAW_NIX_MODE", undefined); mocks.service.install.mockReset().mockResolvedValue(undefined); mocks.service.isLoaded.mockReset().mockResolvedValue(false); mocks.buildNodeInstallPlan.mockReset().mockResolvedValue({ programArguments: ["node", "node-host"], environment: {}, environmentValueSources: {}, }); mocks.loadNodeHostConfig.mockReset().mockResolvedValue({ gateway: { host: "saved-gateway.local", port: 18789, contextPath: "/saved", tls: true, tlsFingerprint: TLS_FINGERPRINT, }, }); mocks.isSystemdUserServiceAvailable.mockReset().mockResolvedValue(true); mocks.resolveSystemdUserServiceAccount.mockReset().mockReturnValue("pi"); mocks.readSystemdUserLingerStatus.mockReset().mockResolvedValue({ user: "pi", linger: "no", }); }); it.each([ ["host", { host: "new-gateway.local" }], ["port", { port: 19_001 }], ])("does not inherit saved TLS when %s explicitly retargets the gateway", async (_name, opts) => { await runNodeDaemonInstall({ ...opts, force: true }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ contextPath: undefined, tls: false, tlsFingerprint: undefined, }), ); }); it("inherits saved TLS and forwards explicit command restrictions to the install plan", async () => { await runNodeDaemonInstall({ force: true, commands: ["fixture.list", "fixture.read"] }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ host: "saved-gateway.local", port: 18789, contextPath: "/saved", tls: true, tlsFingerprint: TLS_FINGERPRINT, commands: ["fixture.list", "fixture.read"], }), ); }); it("forwards a full-surface reset when replacing a restricted service", async () => { mocks.loadNodeHostConfig.mockResolvedValue({ gateway: { host: "saved-gateway.local", port: 18789 }, commands: ["fixture.read"], }); await runNodeDaemonInstall({ force: true, allCommands: true }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ allCommands: true, commands: undefined }), ); expect(mocks.service.install).toHaveBeenCalledOnce(); }); it.each([ ["host", { host: "saved-gateway.local" }], ["port", { port: 18_789 }], ])("keeps saved TLS when explicit %s resolves to the saved endpoint", async (_name, opts) => { await runNodeDaemonInstall({ ...opts, force: true }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ contextPath: "/saved", tls: true, tlsFingerprint: TLS_FINGERPRINT, }), ); }); it("installs an explicitly plaintext node for a saved TLS gateway", async () => { await runNodeDaemonInstall({ force: true, tls: false }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ host: "saved-gateway.local", port: 18789, contextPath: "/saved", tls: false, tlsFingerprint: undefined, }), ); }); it("rejects a TLS fingerprint when installing an explicitly plaintext node", async () => { await runNodeDaemonInstall({ force: true, tls: false, tlsFingerprint: TLS_FINGERPRINT }); expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); expect(mocks.runtime.error).toHaveBeenCalledWith( expect.stringContaining("--no-tls cannot be combined with --tls-fingerprint"), ); }); it("rejects an invalid TLS fingerprint before building an install plan", async () => { await runNodeDaemonInstall({ force: true, tlsFingerprint: "sha256:abc123" }); expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); expect(mocks.runtime.error).toHaveBeenCalledWith( expect.stringContaining("Invalid TLS fingerprint"), ); }); it("rejects Access credentials before installing a plaintext node service", async () => { mocks.loadNodeHostConfig.mockResolvedValue({ gateway: { host: "saved-gateway.local", port: 18789, tls: false, cloudflareAccess: { clientId: "$CF_ACCESS_CLIENT_ID", clientSecret: "$CF_ACCESS_CLIENT_SECRET", }, }, }); await runNodeDaemonInstall({ force: true }); expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); expect(mocks.runtime.error).toHaveBeenCalledWith( "Cloudflare Access credentials require --tls for the node Gateway connection", ); }); it.each([ ["an invalid explicit port", { port: "abc" }, "Invalid --port"], ["an unsupported runtime", { runtime: "deno" }, 'Invalid --runtime (use "node" or "bun"'], ])("rejects %s before building an install plan", async (_name, opts, error) => { await runNodeDaemonInstall(opts); expect(mocks.runtime.error).toHaveBeenCalledWith(expect.stringContaining(error)); expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); expect(mocks.service.install).not.toHaveBeenCalled(); }); it("forwards Bun as the explicit node-service runtime", async () => { await runNodeDaemonInstall({ runtime: "bun", force: true }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ runtime: "bun" }), ); }); it.each([false, true])( "rejects Nix installs before config or service inspection (json=%s)", async (json) => { await withEnvAsync({ OPENCLAW_NIX_MODE: "1" }, async () => { await runNodeDaemonInstall({ json, force: true }); }); const message = "Nix mode detected; service install is disabled."; expect(mocks.runtime.exit).toHaveBeenCalledExactlyOnceWith(1); expect(mocks.loadNodeHostConfig).not.toHaveBeenCalled(); expect(mocks.service.isLoaded).not.toHaveBeenCalled(); expect(mocks.buildNodeInstallPlan).not.toHaveBeenCalled(); expect(mocks.service.install).not.toHaveBeenCalled(); expect(mocks.runtime.log).not.toHaveBeenCalled(); if (json) { expect(mocks.runtime.writeJson).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ action: "install", ok: false, error: message }), ); expect(mocks.runtime.error).not.toHaveBeenCalled(); } else { expect(mocks.runtime.error).toHaveBeenCalledExactlyOnceWith(message); expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); } }, ); it.each([ { restriction: "external Gateway supervision", env: { OPENCLAW_SUPERVISOR_MODE: "external" }, }, { restriction: "noncanonical Gateway state", env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-node-custom-state" }, }, ])("does not apply $restriction to Node installation", async ({ env }) => { mocks.service.isLoaded.mockResolvedValueOnce(false).mockResolvedValue(true); await withEnvAsync(env, async () => { await runNodeDaemonInstall({ json: true }); }); expect(mocks.buildNodeInstallPlan).toHaveBeenCalledOnce(); expect(mocks.service.install).toHaveBeenCalledOnce(); expect(mocks.runtime.writeJson).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ action: "install", ok: true, result: "installed", service: expect.objectContaining({ label: "Node service", loaded: true }), }), ); expect(mocks.runtime.error).not.toHaveBeenCalled(); expect(mocks.runtime.exit).not.toHaveBeenCalled(); }); it("warns about disabled systemd lingering after a fresh install (text mode)", async () => { useLinuxPlatform(); // isLoaded=true so the service-load verification passes and the linger // diagnostic runs on the verified-success path. mocks.service.isLoaded.mockResolvedValue(true); await runNodeDaemonInstall({ force: true }); expect(mocks.readSystemdUserLingerStatus).toHaveBeenCalled(); expect(mocks.runtime.log).toHaveBeenCalledWith( expect.stringContaining("sudo loginctl enable-linger pi"), ); }); it("checks lingering for the same sudo target user as the systemd service", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); mocks.resolveSystemdUserServiceAccount.mockReturnValue("debian"); mocks.readSystemdUserLingerStatus.mockResolvedValue({ user: "debian", linger: "no" }); await runNodeDaemonInstall({ force: true }); expect(mocks.resolveSystemdUserServiceAccount).toHaveBeenCalledWith(process.env); expect(mocks.readSystemdUserLingerStatus).toHaveBeenCalledWith({ env: process.env, user: "debian", }); expect(mocks.runtime.log).toHaveBeenCalledWith( expect.stringContaining("sudo loginctl enable-linger debian"), ); }); it("includes the linger warning in JSON warnings after a fresh install", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); await runNodeDaemonInstall({ force: true, json: true }); expect(mocks.runtime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ warnings: expect.arrayContaining([expect.stringContaining("enable-linger pi")]), }), ); }); it("warns about disabled lingering on the already-installed short-circuit path", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); await runNodeDaemonInstall({ force: false }); expect(mocks.readSystemdUserLingerStatus).toHaveBeenCalled(); expect(mocks.runtime.log).toHaveBeenCalledWith( expect.stringContaining("sudo loginctl enable-linger pi"), ); }); it("does not warn when systemd lingering is already enabled", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); mocks.readSystemdUserLingerStatus.mockResolvedValue({ user: "pi", linger: "yes" }); await runNodeDaemonInstall({ force: true }); expect(mocks.runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("enable-linger")); }); it("does not pollute the failure output when service.install throws", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); mocks.service.install.mockRejectedValue(new Error("disk full")); await runNodeDaemonInstall({ force: true, json: true }); // install() threw before verification, so onVerified never runs and no // linger warning accompanies the install-failure payload. const calls = mocks.runtime.writeJson.mock.calls; const failurePayload = calls .map(([payload]) => payload as { ok?: boolean; error?: string; warnings?: string[] }) .find((payload) => payload.ok === false); expect(failurePayload).toBeDefined(); expect(failurePayload?.error).toContain("install failed"); expect(failurePayload?.warnings ?? []).toEqual( expect.not.arrayContaining([expect.stringContaining("enable-linger")]), ); }); it("does not warn when service-load verification fails (regression for #107033 review)", async () => { useLinuxPlatform(); // install() succeeded but the service is not loaded: the linger diagnostic // must NOT run, so a failed verification never tells the operator to fix // lingering for a service that was not successfully installed. mocks.service.isLoaded.mockResolvedValue(false); await runNodeDaemonInstall({ force: true, json: true }); expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled(); const calls = mocks.runtime.writeJson.mock.calls; const failurePayload = calls .map(([payload]) => payload as { ok?: boolean; error?: string; warnings?: string[] }) .find((payload) => payload.ok === false); expect(failurePayload).toBeDefined(); expect(failurePayload?.error).toContain("verification failed"); expect(failurePayload?.warnings ?? []).toEqual( expect.not.arrayContaining([expect.stringContaining("enable-linger")]), ); }); it("skips the linger check when systemd user services are unavailable", async () => { useLinuxPlatform(); mocks.service.isLoaded.mockResolvedValue(true); mocks.isSystemdUserServiceAvailable.mockResolvedValue(false); await runNodeDaemonInstall({ force: true }); expect(mocks.readSystemdUserLingerStatus).not.toHaveBeenCalled(); }); }); describe("node daemon lifecycle adapters", () => { beforeEach(() => { mocks.runServiceRestart.mockReset(); mocks.runServiceStart.mockReset(); mocks.runServiceStop.mockReset(); mocks.runServiceUninstall.mockReset(); }); it.each([ { name: "start", action: runNodeDaemonStart, delegate: mocks.runServiceStart, expected: { renderStartHints: expect.any(Function) }, }, { name: "stop", action: runNodeDaemonStop, delegate: mocks.runServiceStop, expected: {}, }, { name: "restart", action: runNodeDaemonRestart, delegate: mocks.runServiceRestart, expected: { renderStartHints: expect.any(Function) }, }, { name: "uninstall", action: runNodeDaemonUninstall, delegate: mocks.runServiceUninstall, expected: { stopBeforeUninstall: false, assertNotLoadedAfterUninstall: false, }, }, ])( "delegates $name with node-specific service options", async ({ action, delegate, expected }) => { await action({ json: true }); expect(delegate).toHaveBeenCalledWith( expect.objectContaining({ serviceNoun: "Node", service: mocks.service, opts: { json: true }, ...expected, }), ); }, ); }); describe("runNodeDaemonStatus", () => { function stdout(): string { return mocks.runtime.log.mock.calls.map(([line]) => line).join("\n"); } function stderr(): string { return mocks.runtime.error.mock.calls.map(([line]) => line).join("\n"); } beforeEach(() => { mocks.runtime.log.mockClear(); mocks.runtime.error.mockClear(); mocks.runtime.writeJson.mockClear(); mocks.runtime.exit.mockClear(); mocks.service.isLoaded.mockReset().mockResolvedValue(true); mocks.service.readCommand.mockReset().mockResolvedValue(null); mocks.service.readRuntime.mockReset().mockResolvedValue({ status: "running" }); }); it("reports a failed service check instead of claiming the node is not installed", async () => { mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable")); await runNodeDaemonStatus(); expect(mocks.runtime.error).toHaveBeenCalledWith( "Node service check failed: systemd unavailable", ); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); expect(stdout()).not.toContain("not loaded"); expect(stdout()).not.toContain("openclaw node install"); }); it("reports a failed service check as JSON without inventing node status", async () => { const secret = "sk-abcdefghijklmnopqrstuv"; const error = new Error(`systemd unavailable: Authorization: Bearer ${secret}`); error.name = "ServiceManagerError"; mocks.service.isLoaded.mockRejectedValue(error); await expect(runNodeDaemonStatus({ json: true })).rejects.toThrow( "Node service check failed: systemd unavailable", ); expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); expect(mocks.runtime.exit).not.toHaveBeenCalled(); expect(mocks.runtime.error).not.toHaveBeenCalled(); }); it("reports an unknown runtime when runtime inspection fails", async () => { const error = new Error("permission denied"); error.name = "RuntimeInspectionError"; mocks.service.readRuntime.mockRejectedValue(error); await runNodeDaemonStatus({ json: true }); expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ service: expect.objectContaining({ runtime: { status: "unknown", detail: "permission denied" }, }), }); expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(error.name); }); it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => { mocks.service.readRuntime.mockResolvedValue({ status: "stopped", missingUnit: true }); await runNodeDaemonStatus(); expect(stderr()).toContain("Service unit not found."); expect(stdout()).toContain("Logs: node service log"); expect(stdout()).toContain("Restart attempts: node restart log"); expect(stderr()).not.toContain("Logs: node service log"); expect(stderr()).not.toContain("Restart attempts: node restart log"); }); it("keeps stopped status on stderr and prints recovery hints on stdout", async () => { mocks.service.readRuntime.mockResolvedValue({ status: "stopped" }); await runNodeDaemonStatus(); expect(stderr()).toContain("Service is loaded but not running."); expect(stdout()).toContain("Logs: node service log"); expect(stdout()).toContain("Restart attempts: node restart log"); expect(stderr()).not.toContain("Logs: node service log"); expect(stderr()).not.toContain("Restart attempts: node restart log"); }); it("redacts service credentials from JSON status output", async () => { const command: GatewayServiceCommandConfig = { programArguments: ["node", "node-host"], environment: { OPENCLAW_PROFILE: "work", OPENCLAW_GATEWAY_TOKEN: "gateway-token", OPENCLAW_GATEWAY_PASSWORD: "gateway-password", }, managedDefinition: { programArguments: ["node", "node-host"], environment: { OPENCLAW_GATEWAY_TOKEN: "managed-base-token" }, }, managedOverrides: { launcher: "command", environment: { keys: ["OPENCLAW_GATEWAY_TOKEN"] } }, definitionPaths: ["/etc/systemd/user/node-definition.conf"], environmentValueSources: { OPENCLAW_PROFILE: "file" }, reloadPending: true, }; mocks.service.readCommand.mockResolvedValue(command); await runNodeDaemonStatus({ json: true }); expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ service: expect.objectContaining({ command: expect.objectContaining({ environment: { OPENCLAW_PROFILE: "work" }, definitionPaths: command.definitionPaths, environmentValueSources: command.environmentValueSources, reloadPending: true, }), }), }); const payload = JSON.stringify(mocks.runtime.writeJson.mock.calls[0]?.[0]); expect(payload).not.toContain("gateway-token"); expect(payload).not.toContain("gateway-password"); expect(payload).not.toContain("managed-base-token"); expect(payload).not.toContain("managedDefinition"); expect(payload).not.toContain("managedOverrides"); expect(command.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("gateway-token"); expect(command.managedDefinition).toBeDefined(); expect(command.managedOverrides).toBeDefined(); }); });