| import { spawnSync } from "node:child_process"; |
| |
| 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() { |
| |
| await readConfigFileSnapshot(); |
| const programArguments = ["openclaw", "gateway", "run"]; |
| const environment = buildServiceEnvironment({ |
| env: process.env, |
| port: 18789, |
| execPath: programArguments[0], |
| }); |
| return { |
| programArguments, |
| |
| 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(); |
| |
| 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 }) => { |
| |
| |
| 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"); |
| |
| 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, |
| }), |
| ); |
| |
| 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(); |
| } |
| }, |
| ); |
| }); |
|
|