| import "./install.test-support.js"; |
| import { describe, expect, it, vi } from "vitest"; |
| import { ServiceInspectionError } from "../../daemon/service-inspection-error.js"; |
| import { withGatewayServiceUpdateAuthority } from "../../daemon/service-update-authority.js"; |
|
|
| const { |
| actionState, |
| buildGatewayInstallPlanMock, |
| expectFields, |
| expectFirstInstallPlanCallOmitsToken, |
| installDaemonServiceAndEmitMock, |
| isGatewayDaemonRuntimeMock, |
| mockResolvedGatewayTokenSecretRef, |
| randomTokenMock, |
| readConfigFileSnapshotMock, |
| readFirstConfigWriteParams, |
| readFirstInstallPlanArg, |
| replaceConfigFileMock, |
| resolveGatewayAuthMock, |
| resolveGatewayBindHostMock, |
| resolveSecretRefValuesMock, |
| runDaemonInstall, |
| service, |
| setupInstallTests, |
| } = await import("./install.test-support.js"); |
|
|
| describe("runDaemonInstall", () => { |
| setupInstallTests(); |
|
|
| it("refuses update-owned gateway defaults when authority expires during write preparation", async () => { |
| const snapshot = await readConfigFileSnapshotMock(); |
| readConfigFileSnapshotMock.mockResolvedValue({ ...snapshot, sourceConfig: {} }); |
| let current = true; |
| let committed = false; |
| replaceConfigFileMock.mockImplementationOnce(async (params) => { |
| await Promise.resolve(); |
| current = false; |
| await params.writeOptions.beforeCommit?.(); |
| params.writeOptions.assertCurrent?.(); |
| committed = true; |
| }); |
| await expect( |
| withGatewayServiceUpdateAuthority( |
| () => expect(current, "original owner revoked").toBe(true), |
| () => runDaemonInstall({ force: true, json: true }), |
| ), |
| ).rejects.toThrow("original owner revoked"); |
| expect(committed).toBe(false); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("fails install when token auth requires an unresolved token SecretRef", async () => { |
| mockResolvedGatewayTokenSecretRef(); |
| resolveSecretRefValuesMock.mockRejectedValue(new Error("secret unavailable")); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain("gateway.auth.token SecretRef is configured"); |
| expect(actionState.failed[0]?.message).toContain("unresolved"); |
| expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("blocks external-supervisor installs before reading or mutating config", async () => { |
| process.env.OPENCLAW_SUPERVISOR_MODE = "external"; |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain( |
| "gateway lifecycle is managed by an external supervisor", |
| ); |
| expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect(service.isLoaded).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("blocks sudo-to-root systemd installs before persistent mutation", async () => { |
| vi.spyOn(process, "platform", "get").mockReturnValue("linux"); |
| vi.spyOn(process, "geteuid").mockReturnValue(0); |
| process.env.HOME = "/root"; |
| process.env.USER = "root"; |
| process.env.LOGNAME = "root"; |
| process.env.SUDO_USER = "operator"; |
| delete process.env.XDG_RUNTIME_DIR; |
| delete process.env.DBUS_SESSION_BUS_ADDRESS; |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain("Rerun the same command without sudo"); |
| expect(actionState.failed[0]?.message).toContain("chmod go-w <path>"); |
| expect(actionState.failed[0]?.message).toContain( |
| "https://docs.openclaw.ai/cli/gateway#install-identity", |
| ); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect(randomTokenMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it.each([ |
| ["systemd-user-bus-unavailable", "systemd user session bus"], |
| ["launchd-gui-domain-unavailable", "launchd GUI domain"], |
| ] as const)("explains %s before writing config", async (reason, detail) => { |
| service.readCommand.mockRejectedValueOnce(new ServiceInspectionError(reason)); |
| await runDaemonInstall({ json: true }); |
| expect(actionState.failed[0]?.message).toContain(detail); |
| expect(actionState.failed[0]?.message).not.toContain("SERVICE_DEFINITION_UNKNOWN"); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("blocks inaccessible definitions before config reads or credential generation", async () => { |
| service.readDefinitionMutationCapability.mockRejectedValueOnce(new Error("secret-canary")); |
| await runDaemonInstall({ json: true, force: true }); |
| expect(actionState.failed[0]?.message).toContain("SERVICE_DEFINITION_UNKNOWN"); |
| expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); |
| expect(randomTokenMock).not.toHaveBeenCalled(); |
| expect(service.readCommand).toHaveBeenCalledOnce(); |
| }); |
|
|
| it("blocks non-default install identities before inspecting host services", async () => { |
| process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-non-default-service-state"; |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain( |
| "service management skipped: non-default state dir or config path", |
| ); |
| expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); |
| expect(service.isLoaded).not.toHaveBeenCalled(); |
| expect(service.readCommand).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("validates token SecretRef but does not serialize resolved token into service env", async () => { |
| mockResolvedGatewayTokenSecretRef(); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); |
| expectFirstInstallPlanCallOmitsToken(); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect( |
| actionState.warnings.some((warning) => |
| warning.includes("gateway.auth.token is SecretRef-managed"), |
| ), |
| ).toBe(true); |
| }); |
|
|
| it.each(["darwin", "win32"] as const)( |
| "refuses deferred activation on %s before writing configuration or service state", |
| async (platform) => { |
| vi.spyOn(process, "platform", "get").mockReturnValue(platform); |
| await runDaemonInstall({ json: true, force: true, deferActivation: true }); |
| expect(actionState.failed.at(-1)?.message).toContain("Deferred service load requires Linux"); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect(service.install).not.toHaveBeenCalled(); |
| expect(service.isLoaded).not.toHaveBeenCalled(); |
| }, |
| ); |
|
|
| it("refuses an unparented deferred install before reading or writing the selected profile", async () => { |
| vi.spyOn(process, "platform", "get").mockReturnValue("linux"); |
| await runDaemonInstall({ json: true, force: true, deferActivation: true }); |
| expect(actionState.failed.at(-1)?.message).toContain("updater IPC channel"); |
| expect(readConfigFileSnapshotMock).not.toHaveBeenCalled(); |
| expect(service.install).not.toHaveBeenCalled(); |
| }); |
|
|
| it("passes service environment value sources through to service install", async () => { |
| buildGatewayInstallPlanMock.mockResolvedValueOnce({ |
| programArguments: ["openclaw", "gateway", "run"], |
| workingDirectory: "/tmp", |
| environment: { |
| OPENROUTER_API_KEY: "or-operator-key", |
| }, |
| environmentValueSources: { |
| OPENROUTER_API_KEY: "file", |
| }, |
| }); |
| installDaemonServiceAndEmitMock.mockImplementationOnce(async (params?: unknown) => { |
| await (params as { install: () => Promise<void> }).install(); |
| }); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(service.install).toHaveBeenCalledWith( |
| expect.objectContaining({ |
| environment: { OPENROUTER_API_KEY: "or-operator-key" }, |
| environmentValueSources: { OPENROUTER_API_KEY: "file" }, |
| }), |
| ); |
| }); |
|
|
| it("captures service install warnings in json install output", async () => { |
| installDaemonServiceAndEmitMock.mockImplementationOnce(async (params?: unknown) => { |
| await (params as { install: () => Promise<void> }).install(); |
| }); |
| service.install.mockImplementationOnce(async (args?: unknown) => { |
| (args as { warn?: (message: string) => void }).warn?.( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| }); |
|
|
| await runDaemonInstall({ json: true, force: true }); |
|
|
| expect(actionState.warnings).toContain( |
| "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", |
| ); |
| }); |
|
|
| it("does not treat env-template gateway.auth.token as plaintext during install", async () => { |
| mockResolvedGatewayTokenSecretRef("${OPENCLAW_GATEWAY_TOKEN}"); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1); |
| expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); |
| expectFirstInstallPlanCallOmitsToken(); |
| }); |
|
|
| it.each([ |
| { mode: "local", allowUnconfigured: false }, |
| { mode: "remote", allowUnconfigured: true }, |
| { mode: "local", allowUnconfigured: undefined }, |
| { mode: "remote", allowUnconfigured: undefined }, |
| ])( |
| "auto-mints a local auth token with $mode primary and override $allowUnconfigured", |
| async ({ mode, allowUnconfigured }) => { |
| randomTokenMock.mockReturnValue("minted-token"); |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config: { gateway: { mode, auth: { mode: "token" } } }, |
| sourceConfig: { gateway: { mode, auth: { mode: "token" } } }, |
| }); |
|
|
| await runDaemonInstall({ json: true, force: true, allowUnconfigured }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(replaceConfigFileMock).toHaveBeenCalledTimes(1); |
| const writeParams = readFirstConfigWriteParams(); |
| expect(writeParams.sourceConfig?.gateway?.auth?.token).toBe("minted-token"); |
| expect(writeParams.sourceConfig?.gateway?.mode).toBe(mode); |
| expectFields(readFirstInstallPlanArg(), { |
| port: 18789, |
| allowUnconfigured, |
| }); |
| expectFirstInstallPlanCallOmitsToken(); |
| expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); |
| expect(actionState.warnings.join("\n")).toContain("Auto-generated"); |
| }, |
| ); |
|
|
| it("persists local gateway mode when installing from config missing gateway.mode", async () => { |
| readConfigFileSnapshotMock |
| .mockResolvedValueOnce({ |
| exists: true, |
| valid: true, |
| config: { gateway: { auth: { mode: "token", token: "durable-token" } } }, |
| sourceConfig: { gateway: { auth: { mode: "token", token: "durable-token" } } }, |
| }) |
| .mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config: { |
| gateway: { mode: "local", auth: { mode: "token", token: "durable-token" } }, |
| }, |
| sourceConfig: { |
| gateway: { mode: "local", auth: { mode: "token", token: "durable-token" } }, |
| }, |
| }); |
| resolveGatewayAuthMock.mockReturnValue({ |
| mode: "token", |
| token: "durable-token", |
| password: undefined, |
| allowTailscale: false, |
| }); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(replaceConfigFileMock).toHaveBeenCalledTimes(1); |
| expect(readFirstConfigWriteParams().sourceConfig?.gateway?.mode).toBe("local"); |
| expect(actionState.warnings).toContain( |
| "No gateway.mode found. Set gateway.mode=local for managed gateway install.", |
| ); |
| expectFields(readFirstInstallPlanArg().config as Record<string, unknown>, { |
| gateway: { |
| mode: "local", |
| auth: { mode: "token", token: "durable-token" }, |
| }, |
| }); |
| }); |
|
|
| it("blocks managed install when explicit no-auth would bind to LAN", async () => { |
| const config = { |
| gateway: { |
| mode: "local", |
| bind: "lan", |
| auth: { |
| mode: "none", |
| token: "test-token", |
| }, |
| }, |
| }; |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config, |
| sourceConfig: config, |
| }); |
| resolveGatewayAuthMock.mockReturnValue({ |
| mode: "none", |
| token: "test-token", |
| password: undefined, |
| allowTailscale: false, |
| }); |
| resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0"); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain("Gateway install blocked"); |
| expect(actionState.failed[0]?.message).toContain("gateway.bind=lan"); |
| expect(actionState.failed[0]?.message).toContain("gateway.auth.mode=none"); |
| expect(actionState.failed[0]?.message).toContain("openclaw config set gateway.auth.mode token"); |
| expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it.each([ |
| { |
| name: "custom bind resolving to a network interface", |
| bind: "custom" as const, |
| customBindHost: "192.168.1.20", |
| resolvedHost: "192.168.1.20", |
| blocked: true, |
| message: undefined, |
| }, |
| { |
| name: "tailnet bind resolving to a tailnet interface", |
| bind: "tailnet" as const, |
| customBindHost: undefined, |
| resolvedHost: "100.64.0.20", |
| blocked: true, |
| message: undefined, |
| }, |
| { |
| name: "tailnet bind falling back to loopback", |
| bind: "tailnet" as const, |
| customBindHost: undefined, |
| resolvedHost: "127.0.0.1", |
| blocked: true, |
| message: "can later resolve to a Tailnet interface", |
| }, |
| { |
| name: "loopback bind", |
| bind: "loopback" as const, |
| customBindHost: undefined, |
| resolvedHost: "127.0.0.1", |
| blocked: false, |
| message: undefined, |
| }, |
| ])("handles explicit no-auth for $name", async (testCase) => { |
| const config = { |
| gateway: { |
| mode: "local" as const, |
| bind: testCase.bind, |
| customBindHost: testCase.customBindHost, |
| auth: { mode: "none" as const }, |
| }, |
| }; |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config, |
| sourceConfig: config, |
| }); |
| resolveGatewayAuthMock.mockReturnValue({ |
| mode: "none", |
| token: undefined, |
| password: undefined, |
| allowTailscale: false, |
| }); |
| resolveGatewayBindHostMock.mockResolvedValue(testCase.resolvedHost); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(resolveGatewayBindHostMock).toHaveBeenCalledWith(testCase.bind, testCase.customBindHost); |
| if (testCase.blocked) { |
| expect(actionState.failed[0]?.message).toContain(`gateway.bind=${testCase.bind}`); |
| if (testCase.message) { |
| expect(actionState.failed[0]?.message).toContain(testCase.message); |
| } |
| expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| } else { |
| expect(actionState.failed).toStrictEqual([]); |
| expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); |
| expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); |
| } |
| }); |
|
|
| it("allows a managed LAN install with trusted-proxy auth", async () => { |
| const config = { |
| gateway: { |
| mode: "local" as const, |
| bind: "lan" as const, |
| trustedProxies: ["127.0.0.1"], |
| auth: { mode: "trusted-proxy" as const }, |
| }, |
| }; |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config, |
| sourceConfig: config, |
| }); |
| resolveGatewayAuthMock.mockReturnValue({ |
| mode: "trusted-proxy", |
| token: undefined, |
| password: undefined, |
| allowTailscale: false, |
| }); |
| resolveGatewayBindHostMock.mockResolvedValue("0.0.0.0"); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); |
| expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("does not persist gateway mode when runtime validation fails", async () => { |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config: { gateway: { auth: { mode: "token", token: "durable-token" } } }, |
| sourceConfig: { gateway: { auth: { mode: "token", token: "durable-token" } } }, |
| }); |
| isGatewayDaemonRuntimeMock.mockReturnValue(false); |
|
|
| await runDaemonInstall({ json: true, runtime: "bogus" }); |
|
|
| expect(actionState.failed[0]?.message).toContain("Invalid --runtime"); |
| expect(replaceConfigFileMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("forwards Bun as the explicit managed-service runtime", async () => { |
| await runDaemonInstall({ json: true, runtime: "bun" }); |
|
|
| expect(readFirstInstallPlanArg().runtime).toBe("bun"); |
| expect(actionState.failed).toStrictEqual([]); |
| }); |
|
|
| it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { |
| service.isLoaded.mockRejectedValueOnce( |
| new Error("systemctl is-enabled unavailable: Failed to connect to bus"), |
| ); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed).toStrictEqual([]); |
| expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("fails install when service probe reports an unrelated error", async () => { |
| service.isLoaded.mockRejectedValueOnce( |
| new Error("systemctl is-enabled unavailable: read-only file system"), |
| ); |
|
|
| await runDaemonInstall({ json: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain("Gateway service check failed"); |
| expect(actionState.failed[0]?.message).toContain("read-only file system"); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
|
|
| it("blocks install from an older binary when config was written by a newer one", async () => { |
| readConfigFileSnapshotMock.mockResolvedValue({ |
| exists: true, |
| valid: true, |
| config: { meta: { lastTouchedVersion: "9999.1.1" } }, |
| sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } }, |
| }); |
|
|
| await runDaemonInstall({ json: true, force: true }); |
|
|
| expect(actionState.failed[0]?.message).toContain( |
| "Refusing to install or rewrite the gateway service", |
| ); |
| expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); |
| expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|