openclaw / src /cli /daemon-cli /install.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
18.8 kB
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();
});
});