openclaw / src /cli /daemon-cli /install.integration.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
43.2 kB
import { spawnSync } from "node:child_process";
// Daemon install integration tests cover service install paths with filesystem fixtures.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { buildServiceEnvironment } from "../../daemon/service-env.js";
import type {
GatewayServiceCommandConfig,
GatewayServiceInstallArgs,
} from "../../daemon/service-types.js";
import {
buildSystemdManagerPropertyOutput,
buildSystemdUnitPropertyOutput,
mockSystemAccountHome,
} from "../../daemon/service.test-helpers.js";
import { buildSystemdUnit, parseSystemdExecStart } from "../../daemon/systemd-unit.js";
import { systemdManagerVersionProbe } from "../../daemon/systemd-user-bus.test-support.js";
import { makeTempWorkspace } from "../../test-helpers/workspace.js";
import { captureEnv, withEnvAsync } from "../../test-utils/env.js";
import { resolveTestNodeExecPath } from "../../test-utils/node-process.js";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
const { runtimeLogs, runtimeErrors, defaultRuntime, resetRuntimeCapture } =
createCliRuntimeCapture();
const busctl = vi.hoisted(() =>
vi.fn<typeof import("../../daemon/systemd-exec.js").execBusctlUser>(),
);
vi.mock("../../daemon/systemd-exec.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../daemon/systemd-exec.js")>()),
execBusctlUser: busctl,
}));
vi.mock("../../daemon/systemd-system.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../daemon/systemd-system.js")>()),
assertNoSystemSystemdOwnership: async () => {},
}));
const serviceMock = vi.hoisted(() => ({
label: "Gateway",
loadedText: "loaded",
notLoadedText: "not loaded",
stage: vi.fn(async (_opts?: { environment?: Record<string, string | undefined> }) => {}),
install: vi.fn(async (_opts?: GatewayServiceInstallArgs) => {}),
uninstall: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
restart: vi.fn(async () => {}),
isLoaded: vi.fn(async () => false),
readDefinitionMutationCapability: vi.fn<
(args?: {
env?: NodeJS.ProcessEnv;
environment?: NodeJS.ProcessEnv;
}) => Promise<import("../../daemon/service-types.js").ServiceDefinitionMutationCapability>
>(async (_args?: { env?: NodeJS.ProcessEnv; environment?: NodeJS.ProcessEnv }) => ({
kind: "writable" as const,
})),
readCommand: vi.fn<
typeof import("../../daemon/systemd-service-files.js").readSystemdServiceExecStart
>(async () => null),
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
}));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => serviceMock,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime,
}));
const daemonExec = await import("../../daemon/exec-file.js");
const { runDaemonInstall } = await import("./install.js");
const { buildLaunchAgentPlist, readLaunchAgentProgramArgumentsFromFile } =
await import("../../daemon/launchd-plist.js");
const { decodeLaunchAgentPlistFixture } =
await import("../../daemon/launchd-plist.test-support.js");
const processExec = await import("../../process/exec.js");
const { clearConfigCache, clearRuntimeConfigSnapshot, readConfigFileSnapshot } =
await import("../../config/config.js");
const { readSystemdDefinitionMutationCapability } =
await import("../../daemon/systemd-definition-mutation.js");
const { readSystemdServiceExecStart } = await import("../../daemon/systemd-service-files.js");
const { assertServiceDefinitionWritable } = await import("../../daemon/service-types.js");
async function readJson(filePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
}
async function createInstalledServiceCommand() {
// An installed service has already observed its config; include that health store in snapshots.
await readConfigFileSnapshot();
const programArguments = ["openclaw", "gateway", "run"];
const environment = buildServiceEnvironment({
env: process.env,
port: 18789,
execPath: programArguments[0],
});
return {
programArguments,
// Service readers return only persisted strings, including the host's required TLS CA bundle.
environment: Object.fromEntries(
Object.entries(environment).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
};
}
describe("runDaemonInstall integration", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let accountHome: string;
let tempHome: string;
let configPath: string;
async function snapshotConfig() {
const contents = await fs.readFile(configPath);
const { ino, mode, uid } = await fs.lstat(configPath);
return { contents, ino, mode, uid, entries: (await fs.readdir(tempHome)).toSorted() };
}
beforeAll(async () => {
envSnapshot = captureEnv([
"HOME",
"DBUS_SESSION_BUS_ADDRESS",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
]);
accountHome = await makeTempWorkspace("openclaw-daemon-install-int-");
tempHome = path.join(accountHome, ".openclaw");
await fs.mkdir(tempHome);
configPath = path.join(tempHome, "openclaw.json");
process.env.HOME = accountHome;
process.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${path.join(accountHome, "bus")}`;
process.env.OPENCLAW_STATE_DIR = tempHome;
process.env.OPENCLAW_CONFIG_PATH = configPath;
});
afterAll(async () => {
envSnapshot.restore();
await fs.rm(accountHome, { recursive: true, force: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(async () => {
vi.clearAllMocks();
mockSystemAccountHome();
vi.spyOn(daemonExec, "execFileUtf8").mockImplementation(systemdManagerVersionProbe);
resetRuntimeCapture();
clearRuntimeConfigSnapshot();
// Keep these defined-but-empty so dotenv won't repopulate from local .env.
process.env.OPENCLAW_GATEWAY_TOKEN = "";
process.env.OPENCLAW_GATEWAY_PASSWORD = "";
serviceMock.isLoaded.mockResolvedValue(false);
serviceMock.install.mockReset();
serviceMock.install.mockResolvedValue(undefined);
serviceMock.readDefinitionMutationCapability.mockReset();
serviceMock.readDefinitionMutationCapability.mockResolvedValue({ kind: "writable" });
serviceMock.readCommand.mockReset();
serviceMock.readCommand.mockResolvedValue(null);
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
clearConfigCache();
});
it.each(
(
[
{ platform: "darwin", force: false },
{ platform: "linux", force: false },
{ platform: "darwin", force: true },
{ platform: "linux", force: true },
] as const
).flatMap(({ platform, force }) =>
["unsupported", "broken-decoder", "unsafe-sqlite", "missing", "non-executable"].map(
(condition) => ({
platform,
force,
condition,
}),
),
),
)(
"repairs $condition Node in the $platform definition (force=$force)",
async ({ platform, force, condition }) => {
const execPathDescriptor = Object.getOwnPropertyDescriptor(process, "execPath")!;
const testNodeExecPath = resolveTestNodeExecPath();
vi.spyOn(process, "platform", "get").mockReturnValue(platform);
if (process.versions.bun) {
Object.defineProperty(process, "execPath", {
value: testNodeExecPath,
configurable: true,
});
}
const entry = path.join(tempHome, "dist", "index.js");
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(entry, "");
const originalArgv = process.argv;
const oldNode = path.join(
accountHome,
`.hermes-${condition}-${platform}-${force}`,
"node",
"bin",
"node",
);
if (condition === "non-executable") {
await fs.mkdir(path.dirname(oldNode), { recursive: true });
await fs.writeFile(oldNode, "not executable\n", { mode: 0o600 });
}
const definitionPath = path.join(
tempHome,
platform === "darwin" ? "gateway.plist" : "gateway.service",
);
const render = (programArguments: string[]) =>
platform === "darwin"
? buildLaunchAgentPlist({
label: "ai.openclaw.gateway",
programArguments,
stdoutPath: path.join(tempHome, "stdout.log"),
stderrPath: path.join(tempHome, "stderr.log"),
})
: buildSystemdUnit({ programArguments });
const runExec = processExec.runExec;
vi.spyOn(processExec, "runExec").mockImplementation(async (file, args, options) => {
if (
file === oldNode &&
["unsupported", "broken-decoder", "unsafe-sqlite"].includes(condition)
) {
const sqliteVersion = condition === "unsafe-sqlite" ? "3.51.0" : "3.53.4";
return {
stdout: JSON.stringify({
nodeVersion: condition === "unsupported" ? "22.23.1" : "26.8.1",
sqliteVersion,
sqliteProbe: {
available: true,
version: sqliteVersion,
text: condition !== "broken-decoder",
blob: true,
json: true,
},
}),
stderr: "",
};
}
if (file === "/usr/bin/plutil") {
if (typeof options === "number" || !options?.input) {
throw new Error("Missing plist fixture input");
}
return decodeLaunchAgentPlistFixture(options.input);
}
return runExec(file, args, options);
});
const readDefinition = async (): Promise<GatewayServiceCommandConfig | null> => {
if (platform === "darwin") {
return readLaunchAgentProgramArgumentsFromFile(definitionPath, {
requireEffective: true,
});
}
const unit = await fs.readFile(definitionPath, "utf8");
const execStart = unit.split("\n").find((line) => line.startsWith("ExecStart="));
if (!execStart) {
throw new Error("Missing systemd command");
}
return {
programArguments: parseSystemdExecStart(execStart.slice("ExecStart=".length)),
sourcePath: definitionPath,
};
};
await fs.writeFile(definitionPath, render([oldNode, entry, "gateway"]));
serviceMock.isLoaded.mockResolvedValue(true);
serviceMock.readCommand.mockImplementation(readDefinition);
serviceMock.install.mockImplementationOnce(async (plan) => {
if (!plan) {
throw new Error("Missing install plan");
}
await fs.writeFile(definitionPath, render(plan.programArguments));
});
try {
process.argv = [process.execPath, entry];
await runDaemonInstall({ json: true, force });
expect(serviceMock.install).toHaveBeenCalledOnce();
const repaired = await readDefinition();
const nodePath = repaired?.programArguments[0];
if (!nodePath) {
throw new Error("Missing repaired runtime");
}
expect(await fs.realpath(nodePath)).toBe(await fs.realpath(testNodeExecPath));
expect(repaired?.programArguments).toContain(entry);
expect(await fs.readFile(definitionPath, "utf8")).not.toContain(oldNode);
expect(runtimeLogs.join("\n")).toContain(
condition === "unsupported"
? "Replacing unsupported Gateway service Node 22.23.1"
: condition === "broken-decoder" || condition === "unsafe-sqlite"
? "Replacing unsupported Gateway service Node 26.8.1"
: `Replacing missing Gateway service Node (${oldNode})`,
);
if (condition === "broken-decoder") {
expect(runtimeLogs.join("\n")).toContain("node:sqlite truncates TEXT at embedded NUL");
}
} finally {
process.argv = originalArgv;
if (process.versions.bun) {
Object.defineProperty(process, "execPath", execPathDescriptor);
}
}
},
);
it.each([
{ mode: "Nix before external supervision", reason: "Nix mode detected" },
{ mode: "external supervision", reason: "managed by an external supervisor" },
{ mode: "relocated home", reason: "non-default state dir or config path" },
{ mode: "sudo user manager", reason: "Refusing a sudo-to-root" },
])("preserves config and skips native inspection for $mode", async ({ mode, reason }) => {
// Keep the synthetic account fixed when the invocation relocates HOME.
// Following that override would erase the ownership mismatch being tested.
const account = os.userInfo();
vi.spyOn(os, "homedir").mockReturnValue(accountHome);
vi.spyOn(os, "userInfo").mockReturnValue({ ...account, homedir: accountHome });
if (mode === "sudo user manager") {
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
vi.spyOn(os, "userInfo").mockReturnValue({
...account,
username: "root",
homedir: accountHome,
});
if (process.geteuid) {
vi.spyOn(process, "geteuid").mockReturnValue(0);
}
}
const before = await snapshotConfig();
await withEnvAsync(
{
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: undefined,
OPENCLAW_LAUNCHD_LABEL: undefined,
OPENCLAW_SYSTEMD_UNIT: undefined,
OPENCLAW_WINDOWS_TASK_NAME: undefined,
OPENCLAW_NIX_MODE: mode.startsWith("Nix") ? "1" : undefined,
OPENCLAW_SUPERVISOR_MODE: mode.includes("supervision") ? " ExTeRnAl " : undefined,
HOME: mode === "relocated home" ? path.join(accountHome, "relocated") : accountHome,
SUDO_USER: mode === "sudo user manager" ? "service-fixture" : undefined,
},
async () => {
await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1");
expect(runtimeLogs.join("\n")).toContain(reason);
expect(serviceMock.isLoaded).not.toHaveBeenCalled();
expect(serviceMock.readCommand).not.toHaveBeenCalled();
expect(serviceMock.readDefinitionMutationCapability).not.toHaveBeenCalled();
expect(serviceMock.install).not.toHaveBeenCalled();
expect(await snapshotConfig()).toEqual(before);
},
);
});
it("fails closed when token SecretRef is required but unresolved", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
},
},
},
},
null,
2,
),
);
clearConfigCache();
await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1");
expect(serviceMock.install).not.toHaveBeenCalled();
const joined = runtimeLogs.join("\n");
expect(joined).toContain("SecretRef is configured but unresolved");
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
});
it.each([true, false])(
"explains unsafe publication permissions and recovers without bypassing SecretRefs (json=%s)",
async (json) => {
const fixture = await fs.realpath(
await fs.mkdtemp(path.join(tempHome, "private-path-canary-")),
);
const ancestor = path.join(fixture, ".config");
const config = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
};
await fs.mkdir(ancestor);
await fs.chmod(ancestor, 0o777);
await fs.writeFile(configPath, JSON.stringify(config));
clearConfigCache();
busctl.mockResolvedValue({
code: 1,
termination: "exit",
stdout: "",
stderr: "Call failed: Unit openclaw-gateway.service not found.",
});
const env = { ...process.env, HOME: fixture, OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway" };
serviceMock.readCommand.mockImplementation((_env, options) =>
readSystemdServiceExecStart(env, options),
);
serviceMock.readDefinitionMutationCapability.mockImplementation(() =>
readSystemdDefinitionMutationCapability(env),
);
const before = await snapshotConfig();
try {
await expect(runDaemonInstall({ json, force: true })).rejects.toThrow("__exit__:1");
expect(await snapshotConfig()).toEqual(before);
expect(await fs.readdir(ancestor)).toEqual([]);
expect(serviceMock.install).not.toHaveBeenCalled();
const output = [...runtimeLogs, ...runtimeErrors].join("\n");
expect(output).toContain("SERVICE_DEFINITION_UNKNOWN");
expect(output).toContain("unsafe-permissions");
expect(output).toContain("service directory");
expect(output).toContain("group/world-writable");
expect(output).toContain("chmod go-w");
expect(output).not.toContain("private-path-canary");
expect(output).not.toContain("MISSING_GATEWAY_TOKEN");
await fs.chmod(ancestor, 0o700);
resetRuntimeCapture();
await expect(runDaemonInstall({ json, force: true })).rejects.toThrow("__exit__:1");
const recovered = [...runtimeLogs, ...runtimeErrors].join("\n");
expect(recovered).not.toContain("SERVICE_DEFINITION_UNKNOWN");
expect(recovered).toContain("SecretRef is configured but unresolved");
expect((await readJson(configPath)).gateway).toEqual({ ...config.gateway, mode: "local" });
expect(await fs.readdir(ancestor)).toEqual([]);
expect(serviceMock.install).not.toHaveBeenCalled();
} finally {
await fs.chmod(ancestor, 0o700);
await fs.rm(fixture, { recursive: true, force: true });
}
},
);
it("names an unreadable Linux unit without changing config or replacing it", async () => {
const unit = path.join(accountHome, ".config/systemd/user/openclaw-gateway.service");
const readFile = fs.readFile.bind(fs);
vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
if (args[0] === unit) {
throw Object.assign(new Error("private-native-error-canary"), { code: "EACCES" });
}
return readFile(...args);
});
serviceMock.readCommand.mockImplementation(readSystemdServiceExecStart);
serviceMock.isLoaded.mockRejectedValue(new Error("Failed to get unit file state"));
const before = await snapshotConfig();
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
const output = runtimeLogs.join("\n");
expect(output).toContain(JSON.stringify(unit).slice(1, -1));
expect(output).toContain("unreadable");
expect(output).not.toContain("private-native-error-canary");
expect(await snapshotConfig()).toEqual(before);
expect(serviceMock.install).not.toHaveBeenCalled();
expect(serviceMock.isLoaded).not.toHaveBeenCalled();
});
it.each(["fragment", "drop-in"])(
"blocks a root-owned manager %s before config or token writes",
async (kind) => {
const fixture = await fs.realpath(await fs.mkdtemp(path.join(tempHome, "manager-owner-")));
const unitPath = path.join(fixture, ".config/systemd/user/openclaw-gateway.service");
const extra = path.join(fixture, "global-user", "operator.conf");
// Reach the foreign-owner check even when the test process has a permissive umask.
await fs.mkdir(path.dirname(unitPath), { recursive: true, mode: 0o700 });
await fs.mkdir(path.dirname(extra), { mode: 0o700 });
await fs.writeFile(extra, "[Service]\nEnvironment=TOKEN=operator-secret-canary\n", {
mode: 0o600,
});
if (kind === "drop-in") {
await fs.writeFile(unitPath, "[Service]\nExecStart=/usr/bin/node gateway\n", {
mode: 0o600,
});
}
const originalLstat = fs.lstat.bind(fs);
const lstat = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
const stat = await originalLstat(...args);
if (args[0] === extra) {
Object.defineProperty(stat, "uid", { value: 0 });
}
return stat;
});
busctl.mockImplementation(async (_env, args) => ({
code: 0,
termination: "exit",
stderr: "",
stdout: args.includes("LoadUnit")
? JSON.stringify({ type: "o", data: ["/org/freedesktop/systemd1/unit/owned"] })
: args.includes("org.freedesktop.systemd1.Unit")
? buildSystemdUnitPropertyOutput({
fragmentPath: kind === "fragment" ? extra : unitPath,
dropInPaths: kind === "fragment" ? [] : [extra],
})
: buildSystemdManagerPropertyOutput({ programArguments: ["/usr/bin/node", "gateway"] }),
}));
const env = { ...process.env, HOME: fixture, OPENCLAW_SYSTEMD_UNIT: "openclaw-gateway" };
serviceMock.readCommand.mockImplementationOnce((_env, options) =>
readSystemdServiceExecStart(env, options),
);
serviceMock.readDefinitionMutationCapability.mockImplementationOnce(() =>
readSystemdDefinitionMutationCapability(env),
);
const before = await snapshotConfig();
const managedEntries = await fs.readdir(path.dirname(unitPath));
try {
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
expect(await snapshotConfig()).toEqual(before);
expect(await fs.readdir(path.dirname(unitPath))).toEqual(managedEntries);
expect(await fs.readFile(extra, "utf8")).toContain("operator-secret-canary");
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("SERVICE_DEFINITION_SEALED");
expect(runtimeLogs.join("\n")).not.toContain("secret-canary");
} finally {
lstat.mockRestore();
await fs.rm(fixture, { recursive: true, force: true });
}
},
);
it("checks the planned generated environment after a drop-in redirects effective state", async () => {
const fixture = await fs.realpath(await fs.mkdtemp(path.join(tempHome, "planned-owner-")));
const plannedState = path.join(fixture, ".openclaw");
const effectiveState = path.join(fixture, "effective");
const unit = path.join(fixture, ".config/systemd/user/openclaw-gateway.service");
const dropIn = `${unit}.d/override.conf`;
const plannedFile = path.join(plannedState, "gateway.systemd.env");
const effectiveFile = path.join(effectiveState, "gateway.systemd.env");
const invocation = captureEnv(["HOME", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH"]);
await fs.mkdir(path.dirname(dropIn), { recursive: true, mode: 0o700 });
await fs.mkdir(plannedState, { mode: 0o700 });
await fs.mkdir(effectiveState, { mode: 0o700 });
await fs.writeFile(plannedFile, "OPERATOR_VALUE=planned\n", { mode: 0o600 });
await fs.writeFile(effectiveFile, "OPERATOR_VALUE=effective\n", { mode: 0o600 });
await fs.writeFile(
unit,
`[Service]\nExecStart=/usr/bin/node gateway\nEnvironment=OPENCLAW_STATE_DIR=${plannedState}\nEnvironmentFile=${plannedFile}\n`,
{ mode: 0o600 },
);
await fs.writeFile(
dropIn,
`[Service]\nEnvironment=OPENCLAW_STATE_DIR=${effectiveState}\nEnvironmentFile=\nEnvironmentFile=${effectiveFile}\n`,
{ mode: 0o600 },
);
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { auth: { mode: "token", token: "existing-token" } } }),
);
process.env.HOME = fixture;
process.env.OPENCLAW_STATE_DIR = plannedState;
process.env.OPENCLAW_CONFIG_PATH = path.join(plannedState, "openclaw.json");
clearConfigCache();
const lstat = fs.lstat.bind(fs);
const owner = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
const stat = await lstat(...args);
if (args[0] === plannedFile) {
Object.defineProperty(stat, "uid", { value: 0 });
}
return stat;
});
busctl.mockImplementation(async (_env, args) => ({
code: 0,
termination: "exit",
stderr: "",
stdout: args.includes("LoadUnit")
? JSON.stringify({ type: "o", data: ["/org/freedesktop/systemd1/unit/owned"] })
: args.includes("org.freedesktop.systemd1.Unit")
? buildSystemdUnitPropertyOutput({ fragmentPath: unit, dropInPaths: [dropIn] })
: buildSystemdManagerPropertyOutput({
programArguments: ["/usr/bin/node", "gateway"],
environment: [`OPENCLAW_STATE_DIR=${effectiveState}`],
environmentFiles: [[effectiveFile, false]],
}),
}));
serviceMock.readCommand.mockImplementation(readSystemdServiceExecStart);
serviceMock.readDefinitionMutationCapability.mockImplementation((args) =>
readSystemdDefinitionMutationCapability(args?.env ?? process.env, {
environment: args?.environment,
}),
);
// Model the actual writer's planned scope without operating a native manager.
serviceMock.install.mockImplementationOnce(async (args) => {
assertServiceDefinitionWritable(
await readSystemdDefinitionMutationCapability(process.env, {
environment: args?.environment,
}),
);
});
const before = await snapshotConfig();
try {
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
expect(await snapshotConfig()).toEqual(before);
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("SERVICE_DEFINITION_SEALED");
expect(await fs.readdir(plannedState)).toEqual(["gateway.systemd.env"]);
expect(await fs.readdir(effectiveState)).toEqual(["gateway.systemd.env"]);
} finally {
owner.mockRestore();
invocation.restore();
serviceMock.install.mockReset().mockResolvedValue(undefined);
clearConfigCache();
clearRuntimeConfigSnapshot();
await fs.rm(fixture, { recursive: true, force: true });
}
});
it("refuses service install when config was written by a newer OpenClaw", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
{
meta: {
lastTouchedVersion: "9999.1.1",
},
gateway: {
auth: {
mode: "token",
},
},
},
null,
2,
),
);
clearConfigCache();
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("Refusing to install or rewrite the gateway service");
});
it.each([
{
name: "gateway.mode is missing",
capability: { kind: "sealed" as const, reason: "foreign-owner" as const },
config: { gateway: { auth: { mode: "token", token: "existing-token" } } },
marker: "SERVICE_DEFINITION_SEALED",
},
{
name: "the gateway token is missing",
capability: { kind: "sealed" as const, reason: "foreign-owner" as const },
config: { gateway: { mode: "local", auth: { mode: "token" } } },
marker: "SERVICE_DEFINITION_SEALED",
},
{
name: "gateway.mode is missing and definition authority is unknown",
capability: { kind: "unknown" as const, reason: "inspection-failed" as const },
config: { gateway: { auth: { mode: "token" } } },
marker: "SERVICE_DEFINITION_UNKNOWN",
},
])(
"preserves config bytes and directory entries when definition access is refused and $name",
async ({ capability, config, marker }) => {
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
clearConfigCache();
serviceMock.readDefinitionMutationCapability.mockResolvedValueOnce(capability);
const before = await snapshotConfig();
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
expect(await snapshotConfig()).toEqual(before);
expect(serviceMock.install).not.toHaveBeenCalled();
expect(serviceMock.readCommand).toHaveBeenCalledOnce();
expect(runtimeLogs.join("\n")).toContain(marker);
expect(runtimeLogs.join("\n")).toContain(
capability.kind === "sealed" ? "deployment owner" : "Inspect service definition access",
);
},
);
it.each([
{ name: "forced fresh install", loaded: false, force: true },
{ name: "loaded auto-refresh", loaded: true, force: false },
{ name: "forced loaded refresh", loaded: true, force: true },
])(
"preserves config, token, and state when $name cannot inspect its command",
async ({ loaded, force }) => {
const secret = "service-command-inspection-secret-canary";
await fs.writeFile(configPath, JSON.stringify({ gateway: { auth: { mode: "token" } } }));
clearConfigCache();
serviceMock.isLoaded.mockResolvedValue(loaded);
serviceMock.readCommand.mockRejectedValueOnce(new Error(secret));
const before = await snapshotConfig();
await expect(runDaemonInstall({ json: true, force })).rejects.toThrow("__exit__:1");
expect(await snapshotConfig()).toEqual(before);
expect(serviceMock.readCommand).toHaveBeenCalledWith(expect.any(Object), {
requireEffective: true,
});
expect(serviceMock.readDefinitionMutationCapability).not.toHaveBeenCalled();
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("SERVICE_DEFINITION_UNKNOWN");
expect(runtimeLogs.join("\n")).not.toContain(secret);
},
);
it.each([undefined, "26.8.1", "24.15.0"])(
"keeps an already-installed service read-only with Node %s",
async (nodeVersion) => {
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { mode: "local", auth: { mode: "token", token: "existing" } } }),
);
clearConfigCache();
serviceMock.isLoaded.mockResolvedValue(true);
const command = await createInstalledServiceCommand();
if (nodeVersion) {
const nodePath = "/opt/vendor/bin/node";
command.programArguments[0] = nodePath;
const runExec = processExec.runExec;
vi.spyOn(processExec, "runExec").mockImplementation(async (file, args, options) =>
file === nodePath
? {
stdout: JSON.stringify({
nodeVersion,
sqliteVersion: "3.53.4",
sqliteProbe: {
available: true,
version: "3.53.4",
text: true,
blob: true,
json: true,
},
}),
stderr: "",
}
: runExec(file, args, options),
);
}
serviceMock.readCommand.mockResolvedValue(command);
const before = await snapshotConfig();
await runDaemonInstall({ json: true });
expect(runtimeLogs.join("\n")).toContain('"result": "already-installed"');
expect(serviceMock.readDefinitionMutationCapability).not.toHaveBeenCalled();
expect(serviceMock.install).not.toHaveBeenCalled();
expect(await snapshotConfig()).toEqual(before);
if (nodeVersion === "24.15.0") {
expect(runtimeLogs.join("\n")).toContain("unsupported version, capability probe passed");
}
},
);
it("repairs missing gateway mode for a loaded sealed service without rewriting its definition", async () => {
const config = { gateway: { auth: { mode: "token", token: "existing-token" } } };
await fs.writeFile(configPath, JSON.stringify(config));
clearConfigCache();
serviceMock.isLoaded.mockResolvedValue(true);
serviceMock.readDefinitionMutationCapability.mockResolvedValue({
kind: "sealed",
reason: "foreign-owner",
});
serviceMock.readCommand.mockResolvedValue(await createInstalledServiceCommand());
await runDaemonInstall({ json: true });
expect((await readJson(configPath)).gateway).toEqual({ ...config.gateway, mode: "local" });
expect(runtimeLogs.join("\n")).toContain('"result": "already-installed"');
expect(serviceMock.readDefinitionMutationCapability).not.toHaveBeenCalled();
expect(serviceMock.install).not.toHaveBeenCalled();
});
it("refuses loaded-service auto-refresh before persisting missing gateway defaults", async () => {
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { auth: { mode: "token", token: "existing-token" } } }),
);
clearConfigCache();
serviceMock.isLoaded.mockResolvedValue(true);
serviceMock.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_GATEWAY_TOKEN: "outdated-token" },
} as never);
serviceMock.readDefinitionMutationCapability.mockResolvedValueOnce({
kind: "sealed",
reason: "foreign-owner",
});
const before = await snapshotConfig();
await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1");
expect(runtimeLogs.join("\n")).toContain("SERVICE_DEFINITION_SEALED");
expect(serviceMock.install).not.toHaveBeenCalled();
expect(await snapshotConfig()).toEqual(before);
});
it("refuses a loaded service's sealed effective state before persisting config or a token", async () => {
const effectiveStateDir = path.join(tempHome, "sealed-service-state");
await fs.writeFile(configPath, JSON.stringify({ gateway: { auth: { mode: "token" } } }));
clearConfigCache();
serviceMock.isLoaded.mockResolvedValue(true);
serviceMock.readCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: { OPENCLAW_STATE_DIR: effectiveStateDir },
} as never);
serviceMock.readDefinitionMutationCapability.mockImplementationOnce(async (args) =>
args?.environment?.OPENCLAW_STATE_DIR === effectiveStateDir
? { kind: "sealed", reason: "foreign-owner" }
: { kind: "writable" },
);
const before = await snapshotConfig();
await expect(runDaemonInstall({ json: true, force: true })).rejects.toThrow("__exit__:1");
expect(serviceMock.readDefinitionMutationCapability).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({ OPENCLAW_STATE_DIR: tempHome }),
environment: expect.objectContaining({ OPENCLAW_STATE_DIR: effectiveStateDir }),
}),
);
expect(await snapshotConfig()).toEqual(before);
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("SERVICE_DEFINITION_SEALED");
});
it.each([
{ name: "sealed definition without force", kind: "sealed", force: false },
{ name: "sealed definition with force", kind: "sealed", force: true },
{ name: "uninspectable definition", kind: "unknown", force: true },
{ name: "rejected definition inspection", kind: "rejected", force: false },
])("leaves absent config and state untouched for $name", async ({ kind, force }) => {
const isolatedHome = await fs.mkdtemp(path.join(tempHome, "sealed-install-"));
const stateDir = path.join(isolatedHome, ".openclaw");
await fs.mkdir(stateDir);
const missingConfigPath = path.join(stateDir, "openclaw.json");
const originalHome = process.env.HOME;
process.env.HOME = isolatedHome;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const secret = "direct-install-capability-secret-canary";
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_CONFIG_PATH = missingConfigPath;
clearConfigCache();
if (kind === "rejected") {
serviceMock.readDefinitionMutationCapability.mockRejectedValueOnce(new Error(secret));
} else {
serviceMock.readDefinitionMutationCapability.mockResolvedValueOnce({
kind,
reason: kind === "sealed" ? "foreign-owner" : "inspection-failed",
detail: secret,
} as never);
}
try {
await expect(runDaemonInstall({ json: true, force })).rejects.toThrow("__exit__:1");
expect(await fs.readdir(stateDir)).toEqual([]);
await expect(fs.access(missingConfigPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(serviceMock.readCommand).toHaveBeenCalledOnce();
expect(serviceMock.install).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain(
kind === "sealed" ? "SERVICE_DEFINITION_SEALED" : "SERVICE_DEFINITION_UNKNOWN",
);
expect(runtimeLogs.join("\n")).not.toContain(secret);
} finally {
process.env.HOME = originalHome;
process.env.OPENCLAW_STATE_DIR = originalStateDir;
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
clearConfigCache();
await fs.rm(isolatedHome, { recursive: true, force: true });
}
});
it("auto-mints token when no source exists without embedding it into service env", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
{
gateway: {
auth: {
mode: "token",
},
},
},
null,
2,
),
);
clearConfigCache();
serviceMock.isLoaded.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
await runDaemonInstall({ json: true });
expect(serviceMock.install).toHaveBeenCalledTimes(1);
const updated = await readJson(configPath);
const gateway = (updated.gateway ?? {}) as { auth?: { token?: string } };
const persistedToken = gateway.auth?.token;
expect(persistedToken).toEqual(expect.stringMatching(/^[0-9a-f]{48}$/));
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
});
it("logs a generated-token warning without callback indexes or warning arrays", async () => {
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { mode: "local", auth: { mode: "token" } } }),
);
clearConfigCache();
serviceMock.isLoaded.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
await runDaemonInstall({});
expect(
defaultRuntime.log.mock.calls.filter(([message]) =>
String(message).includes("No gateway token found"),
),
).toEqual([["No gateway token found. Auto-generated one and saving to config."]]);
});
it.each([
{
name: "operator heap cap",
options: "--max-old-space-size=512",
overrides: { environment: { keys: ["NODE_OPTIONS"] } },
expected: [],
},
{
name: "same-value empty override",
options: "",
overrides: { environment: { keys: ["NODE_OPTIONS"] } },
expected: [],
},
{
name: "UnsetEnvironment",
options: undefined,
overrides: { environment: { keys: ["NODE_OPTIONS"] } },
expected: [],
},
{
name: "inline reset",
options: undefined,
overrides: { environment: { resetInline: true } },
expected: [],
},
{
name: "file reset",
options: undefined,
source: "file",
overrides: { environment: { resetFiles: true } },
expected: [],
},
{
name: "unknown environment authority",
options: "",
overrides: { environment: true },
expected: [],
},
{
name: "legacy effective difference",
options: "--max-old-space-size=512",
overrides: undefined,
expected: [],
},
{
name: "PATH-only override",
options: "",
overrides: { environment: { keys: ["PATH"] } },
expected: ["--max-old-space-size=16384"],
},
{
name: "stored managed argv",
options: "--max-old-space-size=512",
baseArgs: ["--max-old-space-size=1024"],
overrides: { environment: { keys: ["NODE_OPTIONS"] } },
expected: ["--max-old-space-size=1024"],
},
] satisfies Array<{
name: string;
options: string | undefined;
source?: "file";
baseArgs?: string[];
overrides: GatewayServiceCommandConfig["managedOverrides"];
expected: string[];
}>)(
"preserves $name through the real install plan without importing operator values",
async (testCase) => {
const originalArgv = process.argv;
const physical = vi.spyOn(os, "totalmem").mockReturnValue(64 * 1024 ** 3);
const constrained = vi.spyOn(process, "constrainedMemory").mockReturnValue(0);
const entry = path.join(tempHome, "dist", "index.js");
await fs.mkdir(path.dirname(entry), { recursive: true });
await fs.writeFile(entry, "");
process.argv = [process.execPath, entry];
const programArguments = [
process.execPath,
...(testCase.baseArgs ?? []),
entry,
"gateway",
"--port",
"19991",
];
serviceMock.readCommand.mockResolvedValue({
programArguments,
environment: {
...(testCase.options === undefined ? {} : { NODE_OPTIONS: testCase.options }),
PATH: "/operator/bin",
},
managedDefinition: {
programArguments,
environment: { NODE_OPTIONS: "" },
...(testCase.source
? { environmentValueSources: { NODE_OPTIONS: testCase.source } }
: {}),
},
managedOverrides: testCase.overrides,
});
try {
serviceMock.isLoaded.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
await runDaemonInstall({ json: true, force: true });
expect(serviceMock.install).toHaveBeenCalledOnce();
const plan = serviceMock.install.mock.calls[0]?.[0];
expect(plan?.environment?.NODE_OPTIONS).toBe("");
expect(plan?.environment?.PATH).not.toContain("/operator/bin");
const generatedArgs = plan?.programArguments ?? [];
const heapArgs = generatedArgs.slice(1, generatedArgs.indexOf(entry));
expect(heapArgs).toEqual(testCase.expected);
if (testCase.name === "operator heap cap") {
const measure = (flags: string[]) => {
const child = spawnSync(
resolveTestNodeExecPath(),
[
...flags,
"-e",
"console.log(require('node:v8').getHeapStatistics().heap_size_limit)",
],
{ env: { NODE_OPTIONS: testCase.options }, encoding: "utf8" },
);
expect(child.status, child.stderr).toBe(0);
return Number(child.stdout);
};
expect(measure(heapArgs)).toBe(measure([]));
}
} finally {
process.argv = originalArgv;
physical.mockRestore();
constrained.mockRestore();
}
},
);
});