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(), ); vi.mock("../../daemon/systemd-exec.js", async (importOriginal) => ({ ...(await importOriginal()), execBusctlUser: busctl, })); vi.mock("../../daemon/systemd-system.js", async (importOriginal) => ({ ...(await importOriginal()), assertNoSystemSystemdOwnership: async () => {}, })); const serviceMock = vi.hoisted(() => ({ label: "Gateway", loadedText: "loaded", notLoadedText: "not loaded", stage: vi.fn(async (_opts?: { environment?: Record }) => {}), 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 >(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> { return JSON.parse(await fs.readFile(filePath, "utf8")) as Record; } 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; 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 => { 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(); } }, ); });