feat(doctor): audit supervisor config + docs

This commit is contained in:
Peter Steinberger
2026-01-08 21:28:40 +01:00
parent d0c4ce6749
commit 01641b34ea
9 changed files with 310 additions and 4 deletions

View File

@@ -33,6 +33,8 @@ import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js";
import {
@@ -89,6 +91,7 @@ type DaemonStatus = {
cachedLabel?: boolean;
missingUnit?: boolean;
};
configAudit?: ServiceConfigAudit;
};
config?: {
cli: ConfigSummary;
@@ -343,6 +346,10 @@ async function gatherDaemonStatus(opts: {
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
const configAudit = await auditGatewayServiceConfig({
env: process.env,
command,
});
const serviceEnv = command?.environment ?? undefined;
const mergedDaemonEnv = {
@@ -484,6 +491,7 @@ async function gatherDaemonStatus(opts: {
notLoadedText: service.notLoadedText,
command,
runtime,
configAudit,
},
config: {
cli: cliConfigSummary,
@@ -538,6 +546,16 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (daemonEnvLines.length > 0) {
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
}
if (service.configAudit?.issues.length) {
defaultRuntime.error(
"Service config looks out of date or non-standard.",
);
for (const issue of service.configAudit.issues) {
const detail = issue.detail ? ` (${issue.detail})` : "";
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
}
defaultRuntime.error('Recommendation: run "clawdbot doctor".');
}
if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`Config (cli): ${cliCfg}`);

View File

@@ -15,6 +15,7 @@ import {
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
import type { RuntimeEnv } from "../runtime.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
@@ -23,6 +24,18 @@ import {
} from "./daemon-runtime.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
function detectGatewayRuntime(
programArguments: string[] | undefined,
): GatewayDaemonRuntime {
const first = programArguments?.[0];
if (first) {
const base = path.basename(first).toLowerCase();
if (base === "bun" || base === "bun.exe") return "bun";
if (base === "node" || base === "node.exe") return "node";
}
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
}
export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
mode: "local" | "remote",
@@ -112,6 +125,83 @@ export async function maybeMigrateLegacyGatewayService(
});
}
export async function maybeRepairGatewayServiceConfig(
cfg: ClawdbotConfig,
mode: "local" | "remote",
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
if (resolveIsNixMode(process.env)) {
note("Nix mode detected; skip service updates.", "Gateway");
return;
}
if (mode === "remote") {
note("Gateway mode is remote; skipped local service audit.", "Gateway");
return;
}
const service = resolveGatewayService();
const command = await service.readCommand(process.env).catch(() => null);
if (!command) return;
const audit = await auditGatewayServiceConfig({
env: process.env,
command,
});
if (audit.issues.length === 0) return;
note(
audit.issues
.map((issue) =>
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
)
.join("\n"),
"Gateway service config",
);
const repair = await prompter.confirmSkipInNonInteractive({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return;
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeChoice,
});
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
CLAWDBOT_LAUNCHD_LABEL:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
};
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
} catch (err) {
runtime.error(`Gateway service update failed: ${String(err)}`);
}
}
export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
const extraServices = await findExtraGatewayServices(process.env, {
deep: options.deep,

View File

@@ -30,6 +30,7 @@ import {
} from "./doctor-format.js";
import {
maybeMigrateLegacyGatewayService,
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} from "./doctor-gateway-services.js";
import {
@@ -157,6 +158,12 @@ export async function doctorCommand(
prompter,
);
await maybeScanExtraGatewayServices(options);
await maybeRepairGatewayServiceConfig(
cfg,
resolveMode(cfg),
runtime,
prompter,
);
await noteSecurityWarnings(cfg);

165
src/daemon/service-audit.ts Normal file
View File

@@ -0,0 +1,165 @@
import fs from "node:fs/promises";
import { resolveLaunchAgentPlistPath } from "./launchd.js";
import { resolveSystemdUserUnitPath } from "./systemd.js";
export type GatewayServiceCommand = {
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
sourcePath?: string;
} | null;
export type ServiceConfigIssue = {
code: string;
message: string;
detail?: string;
};
export type ServiceConfigAudit = {
ok: boolean;
issues: ServiceConfigIssue[];
};
function hasGatewaySubcommand(programArguments?: string[]): boolean {
return Boolean(programArguments?.some((arg) => arg === "gateway"));
}
function parseSystemdUnit(content: string): {
after: Set<string>;
wants: Set<string>;
restartSec?: string;
} {
const after = new Set<string>();
const wants = new Set<string>();
let restartSec: string | undefined;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("#") || line.startsWith(";")) continue;
if (line.startsWith("[")) continue;
const idx = line.indexOf("=");
if (idx <= 0) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (!value) continue;
if (key === "After") {
for (const entry of value.split(/\s+/)) {
if (entry) after.add(entry);
}
} else if (key === "Wants") {
for (const entry of value.split(/\s+/)) {
if (entry) wants.add(entry);
}
} else if (key === "RestartSec") {
restartSec = value;
}
}
return { after, wants, restartSec };
}
function isRestartSecPreferred(value: string | undefined): boolean {
if (!value) return false;
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) return false;
return Math.abs(parsed - 5) < 0.01;
}
async function auditSystemdUnit(
env: Record<string, string | undefined>,
issues: ServiceConfigIssue[],
) {
const unitPath = resolveSystemdUserUnitPath(env);
let content = "";
try {
content = await fs.readFile(unitPath, "utf8");
} catch {
return;
}
const parsed = parseSystemdUnit(content);
if (!parsed.after.has("network-online.target")) {
issues.push({
code: "systemd-after-network-online",
message: "Missing systemd After=network-online.target",
detail: unitPath,
});
}
if (!parsed.wants.has("network-online.target")) {
issues.push({
code: "systemd-wants-network-online",
message: "Missing systemd Wants=network-online.target",
detail: unitPath,
});
}
if (!isRestartSecPreferred(parsed.restartSec)) {
issues.push({
code: "systemd-restart-sec",
message: "RestartSec does not match the recommended 5s",
detail: unitPath,
});
}
}
async function auditLaunchdPlist(
env: Record<string, string | undefined>,
issues: ServiceConfigIssue[],
) {
const plistPath = resolveLaunchAgentPlistPath(env);
let content = "";
try {
content = await fs.readFile(plistPath, "utf8");
} catch {
return;
}
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
if (!hasRunAtLoad) {
issues.push({
code: "launchd-run-at-load",
message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath,
});
}
if (!hasKeepAlive) {
issues.push({
code: "launchd-keep-alive",
message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath,
});
}
}
function auditGatewayCommand(
programArguments: string[] | undefined,
issues: ServiceConfigIssue[],
) {
if (!programArguments || programArguments.length === 0) return;
if (!hasGatewaySubcommand(programArguments)) {
issues.push({
code: "gateway-command-missing",
message: "Service command does not include the gateway subcommand",
});
}
}
export async function auditGatewayServiceConfig(params: {
env: Record<string, string | undefined>;
command: GatewayServiceCommand;
platform?: NodeJS.Platform;
}): Promise<ServiceConfigAudit> {
const issues: ServiceConfigIssue[] = [];
const platform = params.platform ?? process.platform;
auditGatewayCommand(params.command?.programArguments, issues);
if (platform === "linux") {
await auditSystemdUnit(params.env, issues);
} else if (platform === "darwin") {
await auditLaunchdPlist(params.env, issues);
}
return { ok: issues.length === 0, issues };
}

View File

@@ -33,6 +33,12 @@ function resolveSystemdUnitPath(
return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME);
}
export function resolveSystemdUserUnitPath(
env: Record<string, string | undefined>,
): string {
return resolveSystemdUnitPath(env);
}
function resolveLoginctlUser(
env: Record<string, string | undefined>,
): string | null {