feat(doctor): repair launch agent bootstrap
Co-authored-by: Dr Alexander Mikhalev <alex@metacortex.engineer>
This commit is contained in:
@@ -5,7 +5,88 @@ import { PassThrough } from "node:stream";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { installLaunchAgent, parseLaunchctlPrint, resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||
import {
|
||||
installLaunchAgent,
|
||||
isLaunchAgentListed,
|
||||
parseLaunchctlPrint,
|
||||
repairLaunchAgentBootstrap,
|
||||
resolveLaunchAgentPlistPath,
|
||||
} from "./launchd.js";
|
||||
|
||||
async function withLaunchctlStub(
|
||||
options: { listOutput?: string },
|
||||
run: (context: { env: Record<string, string | undefined>; logPath: string }) => Promise<void>,
|
||||
) {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
|
||||
const originalListOutput = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT;
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-"));
|
||||
try {
|
||||
const binDir = path.join(tmpDir, "bin");
|
||||
const homeDir = path.join(tmpDir, "home");
|
||||
const logPath = path.join(tmpDir, "launchctl.log");
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
|
||||
const stubJsPath = path.join(binDir, "launchctl.js");
|
||||
await fs.writeFile(
|
||||
stubJsPath,
|
||||
[
|
||||
'import fs from "node:fs";',
|
||||
"const args = process.argv.slice(2);",
|
||||
"const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;",
|
||||
"if (logPath) {",
|
||||
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
|
||||
"}",
|
||||
'if (args[0] === "list") {',
|
||||
" const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
|
||||
" process.stdout.write(output);",
|
||||
"}",
|
||||
"process.exit(0);",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
path.join(binDir, "launchctl.cmd"),
|
||||
`@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
} else {
|
||||
const shPath = path.join(binDir, "launchctl");
|
||||
await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8");
|
||||
await fs.chmod(shPath, 0o755);
|
||||
}
|
||||
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath;
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = options.listOutput ?? "";
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
|
||||
|
||||
await run({
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
CLAWDBOT_PROFILE: "default",
|
||||
},
|
||||
logPath,
|
||||
});
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalLogPath === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath;
|
||||
}
|
||||
if (originalListOutput === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT = originalListOutput;
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("launchd runtime parsing", () => {
|
||||
it("parses state, pid, and exit status", () => {
|
||||
@@ -24,6 +105,46 @@ describe("launchd runtime parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchctl list detection", () => {
|
||||
it("detects the resolved label in launchctl list", async () => {
|
||||
await withLaunchctlStub(
|
||||
{ listOutput: "123 0 com.clawdbot.gateway\n" },
|
||||
async ({ env }) => {
|
||||
const listed = await isLaunchAgentListed({ env });
|
||||
expect(listed).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the label is missing", async () => {
|
||||
await withLaunchctlStub({ listOutput: "123 0 com.other.service\n" }, async ({ env }) => {
|
||||
const listed = await isLaunchAgentListed({ env });
|
||||
expect(listed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd bootstrap repair", () => {
|
||||
it("bootstraps and kickstarts the resolved label", async () => {
|
||||
await withLaunchctlStub({}, async ({ env, logPath }) => {
|
||||
const repair = await repairLaunchAgentBootstrap({ env });
|
||||
expect(repair.ok).toBe(true);
|
||||
|
||||
const calls = (await fs.readFile(logPath, "utf8"))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as string[]);
|
||||
|
||||
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
||||
const label = "com.clawdbot.gateway";
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
|
||||
expect(calls).toContainEqual(["bootstrap", domain, plistPath]);
|
||||
expect(calls).toContainEqual(["kickstart", "-k", `${domain}/${label}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("launchd install", () => {
|
||||
it("enables service before bootstrap (clears persisted disabled state)", async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
@@ -170,9 +170,22 @@ export async function isLaunchAgentLoaded(args: {
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
async function hasLaunchAgentPlist(env: Record<string, string | undefined>): Promise<boolean> {
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
export async function isLaunchAgentListed(args: {
|
||||
env?: Record<string, string | undefined>;
|
||||
}): Promise<boolean> {
|
||||
const label = resolveLaunchAgentLabel({ env: args.env });
|
||||
const res = await execLaunchctl(["list"]);
|
||||
if (res.code !== 0) return false;
|
||||
return res.stdout
|
||||
.split(/\r?\n/)
|
||||
.some((line) => line.trim().split(/\s+/).at(-1) === label);
|
||||
}
|
||||
|
||||
export async function launchAgentPlistExists(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
await fs.access(plistPath);
|
||||
return true;
|
||||
} catch {
|
||||
@@ -194,7 +207,7 @@ export async function readLaunchAgentRuntime(
|
||||
};
|
||||
}
|
||||
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
|
||||
const plistExists = await hasLaunchAgentPlist(env);
|
||||
const plistExists = await launchAgentPlistExists(env);
|
||||
const state = parsed.state?.toLowerCase();
|
||||
const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown";
|
||||
return {
|
||||
@@ -207,6 +220,24 @@ export async function readLaunchAgentRuntime(
|
||||
};
|
||||
}
|
||||
|
||||
export async function repairLaunchAgentBootstrap(args: {
|
||||
env?: Record<string, string | undefined>;
|
||||
}): Promise<{ ok: boolean; detail?: string }> {
|
||||
const env = args.env ?? (process.env as Record<string, string | undefined>);
|
||||
const domain = resolveGuiDomain();
|
||||
const label = resolveLaunchAgentLabel({ env });
|
||||
const plistPath = resolveLaunchAgentPlistPath(env);
|
||||
const boot = await execLaunchctl(["bootstrap", domain, plistPath]);
|
||||
if (boot.code !== 0) {
|
||||
return { ok: false, detail: (boot.stderr || boot.stdout).trim() || undefined };
|
||||
}
|
||||
const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
|
||||
if (kick.code !== 0) {
|
||||
return { ok: false, detail: (kick.stderr || kick.stdout).trim() || undefined };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type LegacyLaunchAgent = {
|
||||
label: string;
|
||||
plistPath: string;
|
||||
|
||||
Reference in New Issue
Block a user