mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
6938f7dddb
* fix: gateway service commands refuse a named profile or relocated OPENCLAW_HOME - Resolve the default install identity against the canonical state directory for the active OpenClaw home and profile instead of the unprofiled OS account default. - `--profile <name>` / `--dev` project `.openclaw-<profile>` state and config paths, so every named profile was classified as isolated state and refused `install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling. - `OPENCLAW_HOME` relocates all OpenClaw path defaults and is documented for running as a dedicated service user; a relocated home is now an install identity. `HOME` alone still is not. - An `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` pointing outside those canonical paths is still treated as isolated state. - Recovery guidance in the refusal message now names the paths that must match. Verified: focused vitest shards for the changed suites plus the daemon, CLI, and doctor suites that consume the identity check; tsgo core and core-test lanes; oxlint; docs format, MDX, link, and map checks. * fix(gateway): keep relocated homes isolated * fix(config): validate service profile identity * fix(daemon): enforce named-profile service ownership * fix(update): reject drifted service selectors before probes * test(windows): prove scheduled task lifecycle * test(windows): harden scheduled task proof cleanup * test(windows): bind lifecycle proof to checkout * test(windows): normalize cleanup exit status * test(windows): verify effective task privilege * test(windows): protect scheduled task proof roots * test(windows): prove listener-owned task lifecycle * test(windows): fix scheduled task proof contracts * test(windows): remove redundant mock coercions * test(windows): measure fallback before task probes * test(windows): prove scheduled task process origin * fix(gateway): preserve unmanaged restart fallback * test(gateway): cover denied restart ownership * test(gateway): keep restart helper types private * test(gateway): classify lifecycle helpers as test code --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
// Launchd integration tests cover daemon CLI behavior in macOS-like scenarios.
|
|
import { spawnSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { withEnvAsync } from "../test-utils/env.js";
|
|
import { withTimeout } from "../utils/with-timeout.js";
|
|
import {
|
|
installLaunchAgent,
|
|
readLaunchAgentRuntime,
|
|
repairLaunchAgentBootstrap,
|
|
restartLaunchAgent,
|
|
resolveLaunchAgentPlistPath,
|
|
startLaunchAgent,
|
|
stopLaunchAgent,
|
|
uninstallLaunchAgent,
|
|
} from "./launchd.js";
|
|
import type { GatewayServiceEnv } from "./service-types.js";
|
|
import { resolveGatewayService, startGatewayService } from "./service.js";
|
|
|
|
const WAIT_INTERVAL_MS = 200;
|
|
const WAIT_TIMEOUT_MS = 30_000;
|
|
const STARTUP_TIMEOUT_MS = 45_000;
|
|
|
|
function canRunLaunchdIntegration(): boolean {
|
|
if (process.platform !== "darwin") {
|
|
return false;
|
|
}
|
|
if (typeof process.getuid !== "function") {
|
|
return false;
|
|
}
|
|
const domain = `gui/${process.getuid()}`;
|
|
const probe = spawnSync("launchctl", ["print", domain], { encoding: "utf8" });
|
|
if (probe.error) {
|
|
return false;
|
|
}
|
|
return probe.status === 0;
|
|
}
|
|
|
|
const describeLaunchdIntegration = canRunLaunchdIntegration() ? describe : describe.skip;
|
|
|
|
function resolveGuiDomain(): string {
|
|
return `gui/${process.getuid?.() ?? 501}`;
|
|
}
|
|
|
|
async function waitForRunningRuntime(params: {
|
|
env: GatewayServiceEnv;
|
|
pidNot?: number;
|
|
timeoutMs?: number;
|
|
}): Promise<{ pid: number }> {
|
|
const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS;
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastStatus = "unknown";
|
|
let lastPid: number | undefined;
|
|
while (Date.now() < deadline) {
|
|
const runtime = await readLaunchAgentRuntime(params.env);
|
|
lastStatus = runtime.status ?? "unknown";
|
|
lastPid = runtime.pid;
|
|
if (
|
|
runtime.status === "running" &&
|
|
typeof runtime.pid === "number" &&
|
|
runtime.pid > 1 &&
|
|
(params.pidNot === undefined || runtime.pid !== params.pidNot)
|
|
) {
|
|
return { pid: runtime.pid };
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, WAIT_INTERVAL_MS);
|
|
});
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for launchd runtime (status=${lastStatus}, pid=${lastPid ?? "none"})`,
|
|
);
|
|
}
|
|
|
|
async function waitForNotRunningRuntime(params: {
|
|
env: GatewayServiceEnv;
|
|
timeoutMs?: number;
|
|
}): Promise<void> {
|
|
const timeoutMs = params.timeoutMs ?? WAIT_TIMEOUT_MS;
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastStatus = "unknown";
|
|
let lastPid: number | undefined;
|
|
while (Date.now() < deadline) {
|
|
const runtime = await readLaunchAgentRuntime(params.env);
|
|
lastStatus = runtime.status ?? "unknown";
|
|
lastPid = runtime.pid;
|
|
if (runtime.status !== "running" && runtime.pid === undefined) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, WAIT_INTERVAL_MS);
|
|
});
|
|
}
|
|
throw new Error(
|
|
`Timed out waiting for launchd runtime to stop (status=${lastStatus}, pid=${lastPid ?? "none"})`,
|
|
);
|
|
}
|
|
|
|
function launchEnvOrThrow(env: GatewayServiceEnv | undefined): GatewayServiceEnv {
|
|
if (!env) {
|
|
throw new Error("launchd integration env was not initialized");
|
|
}
|
|
return env;
|
|
}
|
|
|
|
async function initializeLaunchdRuntime(launchEnv: GatewayServiceEnv, stdout: PassThrough) {
|
|
await withTimeout(
|
|
(async () => {
|
|
await installLaunchAgent({
|
|
env: launchEnv,
|
|
stdout,
|
|
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
|
|
});
|
|
await waitForRunningRuntime({ env: launchEnv });
|
|
})(),
|
|
STARTUP_TIMEOUT_MS,
|
|
{ message: "Timed out initializing launchd integration runtime" },
|
|
);
|
|
}
|
|
|
|
async function writeLaunchAgentProbeScript(params: {
|
|
eventsPath: string;
|
|
scriptPath: string;
|
|
}): Promise<void> {
|
|
await fs.writeFile(
|
|
params.scriptPath,
|
|
[
|
|
'const fs = require("node:fs");',
|
|
`const eventsPath = ${JSON.stringify(params.eventsPath)};`,
|
|
"fs.appendFileSync(eventsPath, `start ${process.pid}\\n`);",
|
|
'for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {',
|
|
" process.on(signal, () => {",
|
|
" fs.appendFileSync(eventsPath, `${signal} ${process.pid}\\n`);",
|
|
" process.exit(0);",
|
|
" });",
|
|
"}",
|
|
"setInterval(() => {}, 1000);",
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function expectRuntimePidReplaced(params: {
|
|
env: GatewayServiceEnv;
|
|
previousPid: number;
|
|
}): Promise<void> {
|
|
const after = await waitForRunningRuntime({
|
|
env: params.env,
|
|
pidNot: params.previousPid,
|
|
});
|
|
expect(after.pid).toBeGreaterThan(1);
|
|
expect(after.pid).not.toBe(params.previousPid);
|
|
await fs.access(resolveLaunchAgentPlistPath(params.env));
|
|
}
|
|
|
|
describeLaunchdIntegration("launchd integration", () => {
|
|
let env: GatewayServiceEnv | undefined;
|
|
let homeDir = "";
|
|
const stdout = new PassThrough();
|
|
|
|
beforeAll(async () => {
|
|
const testId = randomUUID().slice(0, 8);
|
|
homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-launchd-int-${testId}-`));
|
|
env = {
|
|
HOME: homeDir,
|
|
OPENCLAW_LAUNCHD_LABEL: `ai.openclaw.launchd-int-${testId}`,
|
|
OPENCLAW_LOG_PREFIX: `gateway-launchd-int-${testId}`,
|
|
};
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (env) {
|
|
try {
|
|
await uninstallLaunchAgent({ env, stdout });
|
|
} catch {
|
|
// Best-effort cleanup in case launchctl state already changed.
|
|
}
|
|
}
|
|
if (homeDir) {
|
|
await fs.rm(homeDir, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
it("restarts launchd service and keeps it running with a new pid", async () => {
|
|
const launchEnv = launchEnvOrThrow(env);
|
|
await initializeLaunchdRuntime(launchEnv, stdout);
|
|
const before = await waitForRunningRuntime({ env: launchEnv });
|
|
await restartLaunchAgent({ env: launchEnv, stdout });
|
|
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
|
}, 60_000);
|
|
|
|
it("manages a named profile through the guarded host-service lifecycle", async () => {
|
|
const testId = randomUUID().slice(0, 8);
|
|
const profile = `launchd-int-${testId}`;
|
|
const accountHome = os.userInfo().homedir;
|
|
const stateDir = path.join(accountHome, `.openclaw-${profile}`);
|
|
const profileEnv: GatewayServiceEnv = {
|
|
HOME: accountHome,
|
|
OPENCLAW_HOME: undefined,
|
|
OPENCLAW_PROFILE: profile,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
|
|
OPENCLAW_LAUNCHD_LABEL: undefined,
|
|
OPENCLAW_SUPERVISOR_MODE: undefined,
|
|
};
|
|
|
|
await withEnvAsync(profileEnv, async () => {
|
|
const service = resolveGatewayService();
|
|
try {
|
|
await service.install({
|
|
env: profileEnv,
|
|
stdout,
|
|
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
|
|
});
|
|
const installed = await waitForRunningRuntime({ env: profileEnv });
|
|
|
|
await service.stop({ env: profileEnv, stdout });
|
|
await waitForNotRunningRuntime({ env: profileEnv });
|
|
|
|
const startResult = await startGatewayService(service, { env: profileEnv, stdout });
|
|
expect(startResult.outcome).toBe("started");
|
|
const started = await waitForRunningRuntime({
|
|
env: profileEnv,
|
|
pidNot: installed.pid,
|
|
});
|
|
|
|
await service.restart({ env: profileEnv, stdout });
|
|
await expectRuntimePidReplaced({ env: profileEnv, previousPid: started.pid });
|
|
} finally {
|
|
try {
|
|
await service.uninstall({ env: profileEnv, stdout });
|
|
} finally {
|
|
await fs.rm(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
}, 60_000);
|
|
|
|
it("refuses a relocated OPENCLAW_HOME before launchd mutation", async () => {
|
|
const testId = randomUUID().slice(0, 8);
|
|
const relocatedHome = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), `openclaw-relocated-home-${testId}-`),
|
|
);
|
|
const relocatedEnv: GatewayServiceEnv = {
|
|
HOME: os.userInfo().homedir,
|
|
OPENCLAW_HOME: relocatedHome,
|
|
OPENCLAW_PROFILE: `launchd-int-${testId}`,
|
|
};
|
|
|
|
try {
|
|
await withEnvAsync(relocatedEnv, async () => {
|
|
const service = resolveGatewayService();
|
|
await expect(
|
|
service.install({
|
|
env: relocatedEnv,
|
|
stdout,
|
|
programArguments: [process.execPath, "-e", "setInterval(() => {}, 1000);"],
|
|
}),
|
|
).rejects.toThrow("service management skipped: non-default state dir or config path");
|
|
await expect(fs.access(resolveLaunchAgentPlistPath(relocatedEnv))).rejects.toThrow();
|
|
});
|
|
} finally {
|
|
await fs.rm(relocatedHome, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps LaunchAgent supervision after a raw SIGTERM", async () => {
|
|
const launchEnv = launchEnvOrThrow(env);
|
|
await initializeLaunchdRuntime(launchEnv, stdout);
|
|
|
|
const before = await waitForRunningRuntime({ env: launchEnv });
|
|
process.kill(before.pid, "SIGTERM");
|
|
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
|
}, 60_000);
|
|
|
|
it("stops persistently without reinstall and starts later", async () => {
|
|
const launchEnv = launchEnvOrThrow(env);
|
|
await initializeLaunchdRuntime(launchEnv, stdout);
|
|
|
|
const before = await waitForRunningRuntime({ env: launchEnv });
|
|
await stopLaunchAgent({ env: launchEnv, stdout });
|
|
await waitForNotRunningRuntime({ env: launchEnv });
|
|
await startLaunchAgent({ env: launchEnv, stdout });
|
|
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
|
}, 60_000);
|
|
|
|
it("stops persistently without reinstall and restarts later", async () => {
|
|
const launchEnv = launchEnvOrThrow(env);
|
|
await initializeLaunchdRuntime(launchEnv, stdout);
|
|
|
|
const before = await waitForRunningRuntime({ env: launchEnv });
|
|
await stopLaunchAgent({ env: launchEnv, stdout });
|
|
await waitForNotRunningRuntime({ env: launchEnv });
|
|
await restartLaunchAgent({ env: launchEnv, stdout });
|
|
await expectRuntimePidReplaced({ env: launchEnv, previousPid: before.pid });
|
|
}, 60_000);
|
|
|
|
it("repairs a missing bootstrap without kickstarting the fresh LaunchAgent", async () => {
|
|
const launchEnv = launchEnvOrThrow(env);
|
|
const eventsPath = path.join(homeDir, "repair-probe.events.log");
|
|
const scriptPath = path.join(homeDir, "repair-probe.cjs");
|
|
await writeLaunchAgentProbeScript({ eventsPath, scriptPath });
|
|
await installLaunchAgent({
|
|
env: launchEnv,
|
|
stdout,
|
|
programArguments: [process.execPath, scriptPath],
|
|
});
|
|
await waitForRunningRuntime({ env: launchEnv });
|
|
const bootout = spawnSync(
|
|
"launchctl",
|
|
["bootout", resolveGuiDomain(), resolveLaunchAgentPlistPath(launchEnv)],
|
|
{ encoding: "utf8" },
|
|
);
|
|
expect(bootout.status).toBe(0);
|
|
await waitForNotRunningRuntime({ env: launchEnv });
|
|
await fs.access(resolveLaunchAgentPlistPath(launchEnv));
|
|
await fs.writeFile(eventsPath, "", "utf8");
|
|
|
|
const repair = await withTimeout(
|
|
repairLaunchAgentBootstrap({ env: launchEnv }),
|
|
STARTUP_TIMEOUT_MS,
|
|
{ message: "Timed out repairing launchd integration runtime" },
|
|
);
|
|
expect(repair).toEqual({ ok: true, status: "repaired" });
|
|
await waitForRunningRuntime({ env: launchEnv });
|
|
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 1_500);
|
|
});
|
|
const events = await fs.readFile(eventsPath, "utf8");
|
|
const trimmedEvents = events.trim();
|
|
const lines = trimmedEvents.length > 0 ? trimmedEvents.split(/\r?\n/) : [];
|
|
expect(lines.reduce((count, line) => count + (line.startsWith("start ") ? 1 : 0), 0)).toBe(1);
|
|
const signalLines = lines.filter((line) => /^(SIGHUP|SIGINT|SIGTERM) /.test(line));
|
|
expect(signalLines).toStrictEqual([]);
|
|
}, 60_000);
|
|
});
|