diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 15d4aa4d4..501fb37b5 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -66,6 +66,7 @@ cat ~/.clawdbot/clawdbot.json - Legacy service migration and extra gateway detection. - Gateway runtime checks (service installed but not running; cached launchd label). - Supervisor config audit (launchd/systemd/schtasks) with optional repair. +- Gateway runtime best-practice checks (Node vs Bun, version-manager paths). - Gateway port collision diagnostics (default `18789`). - Security warnings for open DM policies. - systemd linger check on Linux. @@ -179,11 +180,18 @@ service is installed but not actually running. It also checks for port collision on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel). -### 13) Config write + wizard metadata +### 13) Gateway runtime best practices +Doctor warns when the gateway service runs on Bun or a version-managed Node path +(`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node, +and version-manager paths can break after upgrades because the daemon does not +load your shell init. Doctor offers to migrate to a system Node install when +available (Homebrew/apt/choco). + +### 14) Config write + wizard metadata Doctor persists any config changes and stamps wizard metadata to record the doctor run. -### 14) Workspace tips (backup + memory system) +### 15) Workspace tips (backup + memory system) Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 02b9f9b28..e6cd507fe 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -31,6 +31,21 @@ Doctor/daemon will show runtime state (PID/last exit) and log hints. - Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager` - Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST` +### Service Environment (PATH + runtime) + +The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft: +- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` +- Linux: `/usr/local/bin`, `/usr/bin`, `/bin` + +This intentionally excludes version managers (nvm/fnm/volta/asdf) and package +managers (pnpm/npm) because the daemon does not load your shell init. Runtime +variables like `DISPLAY` should live in `~/.clawdbot/.env` (loaded early by the +gateway). + +WhatsApp + Telegram providers require **Node**; Bun is unsupported. If your +service was installed with Bun or a version-managed Node path, run `clawdbot doctor` +to migrate to a system Node install. + ### Service Running but Port Not Listening If the service reports **running** but nothing is listening on the gateway port, diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index f2652f1d0..908c4fb7a 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -32,9 +32,11 @@ import { import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { ServiceConfigAudit } from "../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../daemon/service-audit.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import { callGateway } from "../gateway/call.js"; import { resolveGatewayBindHost } from "../gateway/net.js"; import { @@ -807,25 +809,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: runtimeRaw, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port, dev: devMode, runtime: runtimeRaw, + nodePath, }); - const environment: Record = { - 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: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN, - CLAWDBOT_LAUNCHD_LABEL: + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); try { await service.install({ diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 549e3d95d..c0b263072 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -31,7 +31,9 @@ import { } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -611,18 +613,24 @@ async function maybeInstallDaemon(params: { const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: daemonRuntime, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port: params.port, dev: devMode, runtime: daemonRuntime, + nodePath, }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken, - CLAWDBOT_LAUNCHD_LABEL: + const environment = buildServiceEnvironment({ + env: process.env, + port: params.port, + token: params.gatewayToken, + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); await service.install({ env: process.env, stdout: process.stdout, diff --git a/src/commands/daemon-runtime.ts b/src/commands/daemon-runtime.ts index 15c7f492c..354588034 100644 --- a/src/commands/daemon-runtime.ts +++ b/src/commands/daemon-runtime.ts @@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{ { value: "node", label: "Node (recommended)", - hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.", + hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.", }, ]; diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 0931e9445..51d869562 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -14,8 +14,16 @@ import { uninstallLegacyGatewayServices, } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { + resolvePreferredNodePath, + resolveSystemNodePath, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; -import { auditGatewayServiceConfig } from "../daemon/service-audit.js"; +import { + auditGatewayServiceConfig, + needsNodeRuntimeMigration, +} from "../daemon/service-audit.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import type { RuntimeEnv } from "../runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, @@ -103,19 +111,24 @@ export async function maybeMigrateLegacyGatewayService( process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); const port = resolveGatewayPort(cfg, process.env); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: daemonRuntime, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port, dev: devMode, runtime: daemonRuntime, + nodePath, }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDBOT_GATEWAY_TOKEN: - cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - CLAWDBOT_LAUNCHD_LABEL: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); await service.install({ env: process.env, stdout: process.stdout, @@ -191,6 +204,17 @@ export async function maybeRepairGatewayServiceConfig( }); if (!repair) return; + const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues); + const systemNodePath = needsNodeRuntime + ? await resolveSystemNodePath(process.env) + : null; + if (needsNodeRuntime && !systemNodePath) { + note( + "System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.", + "Gateway runtime", + ); + } + const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); @@ -200,19 +224,16 @@ export async function maybeRepairGatewayServiceConfig( await resolveGatewayProgramArguments({ port, dev: devMode, - runtime: runtimeChoice, + runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, + nodePath: systemNodePath ?? undefined, }); - const environment: Record = { - 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: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); try { await service.install({ diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index feefad70f..2e28d4322 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -12,7 +12,9 @@ import { import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -306,25 +308,27 @@ export async function doctorCommand( process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); const port = resolveGatewayPort(cfg, process.env); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: daemonRuntime, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port, dev: devMode, runtime: daemonRuntime, + nodePath, }); - const environment: Record = { - 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: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - CLAWDBOT_LAUNCHD_LABEL: + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); await service.install({ env: process.env, stdout: process.stdout, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 7e3821fa1..af4ebfc7f 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -13,7 +13,9 @@ import { } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -272,18 +274,24 @@ export async function runNonInteractiveOnboarding( const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: daemonRuntimeRaw, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port, dev: devMode, runtime: daemonRuntimeRaw, + nodePath, }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDBOT_GATEWAY_TOKEN: gatewayToken, - CLAWDBOT_LAUNCHD_LABEL: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: gatewayToken, + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); await service.install({ env: process.env, stdout: process.stdout, diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index f4e58a035..43ded6b30 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: { port: number; dev?: boolean; runtime?: GatewayRuntimePreference; + nodePath?: string; }): Promise { 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], diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts new file mode 100644 index 000000000..e6fc7d616 --- /dev/null +++ b/src/daemon/runtime-paths.ts @@ -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, + 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 = 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 = process.env, + platform: NodeJS.Platform = process.platform, +): Promise { + 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; + runtime?: string; +}): Promise { + if (params.runtime !== "node") return undefined; + const systemNode = await resolveSystemNodePath(params.env); + return systemNode ?? undefined; +} diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts new file mode 100644 index 000000000..c5c7baa51 --- /dev/null +++ b/src/daemon/service-audit.test.ts @@ -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); + }); +}); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index feb28dc4a..dc88504cf 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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 = /KeepAlive<\/key>\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, + 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; 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); diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts new file mode 100644 index 000000000..f43b25091 --- /dev/null +++ b/src/daemon/service-env.test.ts @@ -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"); + }); +}); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts new file mode 100644 index 000000000..51f60bd61 --- /dev/null +++ b/src/daemon/service-env.ts @@ -0,0 +1,71 @@ +import path from "node:path"; + +export type MinimalServicePathOptions = { + platform?: NodeJS.Platform; + extraDirs?: string[]; +}; + +type BuildServicePathOptions = MinimalServicePathOptions & { + env?: Record; +}; + +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; + port: number; + token?: string; + launchdLabel?: string; +}): Record { + 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, + }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 1dd468c10..f787a11ba 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -46,7 +46,9 @@ import { } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; +import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -480,20 +482,26 @@ export async function runOnboardingWizard( const devMode = process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts"); + const nodePath = await resolvePreferredNodePath({ + env: process.env, + runtime: daemonRuntime, + }); const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port, dev: devMode, runtime: daemonRuntime, + nodePath, }); - const environment: Record = { - PATH: process.env.PATH, - CLAWDBOT_GATEWAY_TOKEN: gatewayToken, - CLAWDBOT_LAUNCHD_LABEL: + const environment = buildServiceEnvironment({ + env: process.env, + port, + token: gatewayToken, + launchdLabel: process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined, - }; + }); await service.install({ env: process.env, stdout: process.stdout,