fix(daemon): audit runtime best practices

This commit is contained in:
Peter Steinberger
2026-01-08 22:15:46 +00:00
parent cd2f3bd355
commit 1cf8503017
15 changed files with 576 additions and 63 deletions

View File

@@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath;
const runtime = params.runtime ?? "auto";
if (runtime === "node") {
const nodePath = isNodeRuntime(execPath)
? execPath
: await resolveNodePath();
const nodePath =
params.nodePath ??
(isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],

View File

@@ -0,0 +1,88 @@
import fs from "node:fs/promises";
import path from "node:path";
const VERSION_MANAGER_MARKERS = [
"/.nvm/",
"/.fnm/",
"/.volta/",
"/.asdf/",
"/.n/",
"/.nodenv/",
"/.nodebrew/",
"/nvs/",
];
function normalizeForCompare(input: string, platform: NodeJS.Platform): string {
const normalized = path.normalize(input);
if (platform === "win32") {
return normalized.replaceAll("\\", "/").toLowerCase();
}
return normalized;
}
function buildSystemNodeCandidates(
env: Record<string, string | undefined>,
platform: NodeJS.Platform,
): string[] {
if (platform === "darwin") {
return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
}
if (platform === "linux") {
return ["/usr/local/bin/node", "/usr/bin/node"];
}
if (platform === "win32") {
const programFiles = env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 =
env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
return [
path.join(programFiles, "nodejs", "node.exe"),
path.join(programFilesX86, "nodejs", "node.exe"),
];
}
return [];
}
export function isVersionManagedNodePath(
nodePath: string,
platform: NodeJS.Platform = process.platform,
): boolean {
const normalized = normalizeForCompare(nodePath, platform);
return VERSION_MANAGER_MARKERS.some((marker) => normalized.includes(marker));
}
export function isSystemNodePath(
nodePath: string,
env: Record<string, string | undefined> = process.env,
platform: NodeJS.Platform = process.platform,
): boolean {
const normalized = normalizeForCompare(nodePath, platform);
return buildSystemNodeCandidates(env, platform).some((candidate) => {
const normalizedCandidate = normalizeForCompare(candidate, platform);
return normalized === normalizedCandidate;
});
}
export async function resolveSystemNodePath(
env: Record<string, string | undefined> = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<string | null> {
const candidates = buildSystemNodeCandidates(env, platform);
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// keep going
}
}
return null;
}
export async function resolvePreferredNodePath(params: {
env?: Record<string, string | undefined>;
runtime?: string;
}): Promise<string | undefined> {
if (params.runtime !== "node") return undefined;
const systemNode = await resolveSystemNodePath(params.env);
return systemNode ?? undefined;
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
auditGatewayServiceConfig,
SERVICE_AUDIT_CODES,
} from "./service-audit.js";
describe("auditGatewayServiceConfig", () => {
it("flags bun runtime", async () => {
const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" },
platform: "darwin",
command: {
programArguments: ["/opt/homebrew/bin/bun", "gateway"],
environment: { PATH: "/usr/bin:/bin" },
},
});
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun,
),
).toBe(true);
});
it("flags version-managed node paths", async () => {
const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" },
platform: "darwin",
command: {
programArguments: [
"/Users/test/.nvm/versions/node/v22.0.0/bin/node",
"gateway",
],
environment: {
PATH: "/usr/bin:/bin:/Users/test/.nvm/versions/node/v22.0.0/bin",
},
},
});
expect(
audit.issues.some(
(issue) =>
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
),
).toBe(true);
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
),
).toBe(true);
expect(
audit.issues.some(
(issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
),
).toBe(true);
});
});

View File

@@ -1,5 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveLaunchAgentPlistPath } from "./launchd.js";
import {
isSystemNodePath,
isVersionManagedNodePath,
resolveSystemNodePath,
} from "./runtime-paths.js";
import { getMinimalServicePathParts } from "./service-env.js";
import { resolveSystemdUserUnitPath } from "./systemd.js";
export type GatewayServiceCommand = {
@@ -21,6 +28,31 @@ export type ServiceConfigAudit = {
issues: ServiceConfigIssue[];
};
export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing",
gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal",
gatewayRuntimeBun: "gateway-runtime-bun",
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing",
launchdKeepAlive: "launchd-keep-alive",
launchdRunAtLoad: "launchd-run-at-load",
systemdAfterNetworkOnline: "systemd-after-network-online",
systemdRestartSec: "systemd-restart-sec",
systemdWantsNetworkOnline: "systemd-wants-network-online",
} as const;
export function needsNodeRuntimeMigration(
issues: ServiceConfigIssue[],
): boolean {
return issues.some(
(issue) =>
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun ||
issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
);
}
function hasGatewaySubcommand(programArguments?: string[]): boolean {
return Boolean(programArguments?.some((arg) => arg === "gateway"));
}
@@ -82,7 +114,7 @@ async function auditSystemdUnit(
const parsed = parseSystemdUnit(content);
if (!parsed.after.has("network-online.target")) {
issues.push({
code: "systemd-after-network-online",
code: SERVICE_AUDIT_CODES.systemdAfterNetworkOnline,
message: "Missing systemd After=network-online.target",
detail: unitPath,
level: "recommended",
@@ -90,7 +122,7 @@ async function auditSystemdUnit(
}
if (!parsed.wants.has("network-online.target")) {
issues.push({
code: "systemd-wants-network-online",
code: SERVICE_AUDIT_CODES.systemdWantsNetworkOnline,
message: "Missing systemd Wants=network-online.target",
detail: unitPath,
level: "recommended",
@@ -98,7 +130,7 @@ async function auditSystemdUnit(
}
if (!isRestartSecPreferred(parsed.restartSec)) {
issues.push({
code: "systemd-restart-sec",
code: SERVICE_AUDIT_CODES.systemdRestartSec,
message: "RestartSec does not match the recommended 5s",
detail: unitPath,
level: "recommended",
@@ -122,7 +154,7 @@ async function auditLaunchdPlist(
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
if (!hasRunAtLoad) {
issues.push({
code: "launchd-run-at-load",
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath,
level: "recommended",
@@ -130,7 +162,7 @@ async function auditLaunchdPlist(
}
if (!hasKeepAlive) {
issues.push({
code: "launchd-keep-alive",
code: SERVICE_AUDIT_CODES.launchdKeepAlive,
message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath,
level: "recommended",
@@ -145,13 +177,139 @@ function auditGatewayCommand(
if (!programArguments || programArguments.length === 0) return;
if (!hasGatewaySubcommand(programArguments)) {
issues.push({
code: "gateway-command-missing",
code: SERVICE_AUDIT_CODES.gatewayCommandMissing,
message: "Service command does not include the gateway subcommand",
level: "aggressive",
});
}
}
function isNodeRuntime(execPath: string): boolean {
const base = path.basename(execPath).toLowerCase();
return base === "node" || base === "node.exe";
}
function isBunRuntime(execPath: string): boolean {
const base = path.basename(execPath).toLowerCase();
return base === "bun" || base === "bun.exe";
}
function normalizePathEntry(entry: string, platform: NodeJS.Platform): string {
const normalized = path.normalize(entry);
if (platform === "win32") {
return normalized.replaceAll("\\", "/").toLowerCase();
}
return normalized;
}
function auditGatewayServicePath(
command: GatewayServiceCommand,
issues: ServiceConfigIssue[],
platform: NodeJS.Platform,
) {
if (platform === "win32") return;
const servicePath = command?.environment?.PATH;
if (!servicePath) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathMissing,
message:
"Gateway service PATH is not set; the daemon should use a minimal PATH.",
level: "recommended",
});
return;
}
const expected = getMinimalServicePathParts({ platform });
const parts = servicePath
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
const normalizedParts = parts.map((entry) =>
normalizePathEntry(entry, platform),
);
const missing = expected.filter((entry) => {
const normalized = normalizePathEntry(entry, platform);
return !normalizedParts.includes(normalized);
});
if (missing.length > 0) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
message: `Gateway service PATH missing required dirs: ${missing.join(", ")}`,
level: "recommended",
});
}
const nonMinimal = parts.filter((entry) => {
const normalized = normalizePathEntry(entry, platform);
return (
normalized.includes("/.nvm/") ||
normalized.includes("/.fnm/") ||
normalized.includes("/.volta/") ||
normalized.includes("/.asdf/") ||
normalized.includes("/.n/") ||
normalized.includes("/.nodenv/") ||
normalized.includes("/.nodebrew/") ||
normalized.includes("/nvs/") ||
normalized.includes("/.local/share/pnpm/") ||
normalized.includes("/pnpm/") ||
normalized.endsWith("/pnpm")
);
});
if (nonMinimal.length > 0) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
message:
"Gateway service PATH includes version managers or package managers; recommend a minimal PATH.",
detail: nonMinimal.join(", "),
level: "recommended",
});
}
}
async function auditGatewayRuntime(
env: Record<string, string | undefined>,
command: GatewayServiceCommand,
issues: ServiceConfigIssue[],
platform: NodeJS.Platform,
) {
const execPath = command?.programArguments?.[0];
if (!execPath) return;
if (isBunRuntime(execPath)) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
message:
"Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram providers.",
detail: execPath,
level: "recommended",
});
return;
}
if (!isNodeRuntime(execPath)) return;
if (isVersionManagedNodePath(execPath, platform)) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
message:
"Gateway service uses Node from a version manager; it can break after upgrades.",
detail: execPath,
level: "recommended",
});
if (!isSystemNodePath(execPath, env, platform)) {
const systemNode = await resolveSystemNodePath(env, platform);
if (!systemNode) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
message:
"System Node 22+ not found; install it before migrating away from version managers.",
level: "recommended",
});
}
}
}
}
export async function auditGatewayServiceConfig(params: {
env: Record<string, string | undefined>;
command: GatewayServiceCommand;
@@ -161,6 +319,8 @@ export async function auditGatewayServiceConfig(params: {
const platform = params.platform ?? process.platform;
auditGatewayCommand(params.command?.programArguments, issues);
auditGatewayServicePath(params.command, issues, platform);
await auditGatewayRuntime(params.env, params.command, issues, platform);
if (platform === "linux") {
await auditSystemdUnit(params.env, issues);

View File

@@ -0,0 +1,62 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildMinimalServicePath,
buildServiceEnvironment,
} from "./service-env.js";
describe("buildMinimalServicePath", () => {
it("includes Homebrew + system dirs on macOS", () => {
const result = buildMinimalServicePath({
platform: "darwin",
});
const parts = result.split(path.delimiter);
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
expect(parts).toContain("/bin");
});
it("returns PATH as-is on Windows", () => {
const result = buildMinimalServicePath({
env: { PATH: "C:\\\\Windows\\\\System32" },
platform: "win32",
});
expect(result).toBe("C:\\\\Windows\\\\System32");
});
it("includes extra directories when provided", () => {
const result = buildMinimalServicePath({
platform: "linux",
extraDirs: ["/custom/tools"],
});
expect(result.split(path.delimiter)).toContain("/custom/tools");
});
it("deduplicates directories", () => {
const result = buildMinimalServicePath({
platform: "linux",
extraDirs: ["/usr/bin"],
});
const parts = result.split(path.delimiter);
const unique = [...new Set(parts)];
expect(parts.length).toBe(unique.length);
});
});
describe("buildServiceEnvironment", () => {
it("sets minimal PATH and gateway vars", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user" },
port: 18789,
token: "secret",
});
if (process.platform === "win32") {
expect(env.PATH).toBe("");
} else {
expect(env.PATH).toContain("/usr/bin");
}
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
});
});

71
src/daemon/service-env.ts Normal file
View File

@@ -0,0 +1,71 @@
import path from "node:path";
export type MinimalServicePathOptions = {
platform?: NodeJS.Platform;
extraDirs?: string[];
};
type BuildServicePathOptions = MinimalServicePathOptions & {
env?: Record<string, string | undefined>;
};
function resolveSystemPathDirs(platform: NodeJS.Platform): string[] {
if (platform === "darwin") {
return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
}
if (platform === "linux") {
return ["/usr/local/bin", "/usr/bin", "/bin"];
}
return [];
}
export function getMinimalServicePathParts(
options: MinimalServicePathOptions = {},
): string[] {
const platform = options.platform ?? process.platform;
if (platform === "win32") return [];
const parts: string[] = [];
const extraDirs = options.extraDirs ?? [];
const systemDirs = resolveSystemPathDirs(platform);
const add = (dir: string) => {
if (!dir) return;
if (!parts.includes(dir)) parts.push(dir);
};
for (const dir of extraDirs) add(dir);
for (const dir of systemDirs) add(dir);
return parts;
}
export function buildMinimalServicePath(
options: BuildServicePathOptions = {},
): string {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
if (platform === "win32") {
return env.PATH ?? "";
}
return getMinimalServicePathParts(options).join(path.delimiter);
}
export function buildServiceEnvironment(params: {
env: Record<string, string | undefined>;
port: number;
token?: string;
launchdLabel?: string;
}): Record<string, string | undefined> {
const { env, port, token, launchdLabel } = params;
return {
PATH: buildMinimalServicePath({ env }),
CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: String(port),
CLAWDBOT_GATEWAY_TOKEN: token,
CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
};
}