fix(daemon): audit runtime best practices
This commit is contained in:
@@ -66,6 +66,7 @@ cat ~/.clawdbot/clawdbot.json
|
|||||||
- Legacy service migration and extra gateway detection.
|
- Legacy service migration and extra gateway detection.
|
||||||
- Gateway runtime checks (service installed but not running; cached launchd label).
|
- Gateway runtime checks (service installed but not running; cached launchd label).
|
||||||
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
|
- 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`).
|
- Gateway port collision diagnostics (default `18789`).
|
||||||
- Security warnings for open DM policies.
|
- Security warnings for open DM policies.
|
||||||
- systemd linger check on Linux.
|
- 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
|
on the gateway port (default `18789`) and reports likely causes (gateway already
|
||||||
running, SSH tunnel).
|
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 persists any config changes and stamps wizard metadata to record the
|
||||||
doctor run.
|
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
|
Doctor suggests a workspace memory system when missing and prints a backup tip
|
||||||
if the workspace is not already under git.
|
if the workspace is not already under git.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
- Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
|
||||||
- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST`
|
- 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
|
### Service Running but Port Not Listening
|
||||||
|
|
||||||
If the service reports **running** but nothing is listening on the gateway port,
|
If the service reports **running** but nothing is listening on the gateway port,
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ import {
|
|||||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||||
import { findLegacyGatewayServices } from "../daemon/legacy.js";
|
import { findLegacyGatewayServices } from "../daemon/legacy.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
|
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
|
||||||
import { auditGatewayServiceConfig } 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 { callGateway } from "../gateway/call.js";
|
||||||
import { resolveGatewayBindHost } from "../gateway/net.js";
|
import { resolveGatewayBindHost } from "../gateway/net.js";
|
||||||
import {
|
import {
|
||||||
@@ -807,25 +809,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
|||||||
const devMode =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: runtimeRaw,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: runtimeRaw,
|
runtime: runtimeRaw,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
port,
|
||||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
token:
|
||||||
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
|
||||||
CLAWDBOT_GATEWAY_PORT: String(port),
|
|
||||||
CLAWDBOT_GATEWAY_TOKEN:
|
|
||||||
opts.token ||
|
opts.token ||
|
||||||
cfg.gateway?.auth?.token ||
|
cfg.gateway?.auth?.token ||
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN,
|
process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
launchdLabel:
|
||||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
};
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await service.install({
|
await service.install({
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -611,18 +613,24 @@ async function maybeInstallDaemon(params: {
|
|||||||
const devMode =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: daemonRuntime,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port: params.port,
|
port: params.port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
|
port: params.port,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
token: params.gatewayToken,
|
||||||
|
launchdLabel:
|
||||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
};
|
});
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
|
|||||||
{
|
{
|
||||||
value: "node",
|
value: "node",
|
||||||
label: "Node (recommended)",
|
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.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ import {
|
|||||||
uninstallLegacyGatewayServices,
|
uninstallLegacyGatewayServices,
|
||||||
} from "../daemon/legacy.js";
|
} from "../daemon/legacy.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import {
|
||||||
|
resolvePreferredNodePath,
|
||||||
|
resolveSystemNodePath,
|
||||||
|
} from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.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 type { RuntimeEnv } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
@@ -103,19 +111,24 @@ export async function maybeMigrateLegacyGatewayService(
|
|||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
const port = resolveGatewayPort(cfg, process.env);
|
const port = resolveGatewayPort(cfg, process.env);
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: daemonRuntime,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_GATEWAY_TOKEN:
|
port,
|
||||||
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
launchdLabel:
|
||||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
};
|
});
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
@@ -191,6 +204,17 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
});
|
});
|
||||||
if (!repair) return;
|
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 =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
@@ -200,19 +224,16 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: runtimeChoice,
|
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||||
|
nodePath: systemNodePath ?? undefined,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
port,
|
||||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
|
launchdLabel:
|
||||||
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,
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
};
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await service.install({
|
await service.install({
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||||
import type { RuntimeEnv } from "../runtime.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]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
const port = resolveGatewayPort(cfg, process.env);
|
const port = resolveGatewayPort(cfg, process.env);
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: daemonRuntime,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
|
port,
|
||||||
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
|
token:
|
||||||
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,
|
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
launchdLabel:
|
||||||
process.platform === "darwin"
|
process.platform === "darwin"
|
||||||
? GATEWAY_LAUNCH_AGENT_LABEL
|
? GATEWAY_LAUNCH_AGENT_LABEL
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
});
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
@@ -272,18 +274,24 @@ export async function runNonInteractiveOnboarding(
|
|||||||
const devMode =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: daemonRuntimeRaw,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: daemonRuntimeRaw,
|
runtime: daemonRuntimeRaw,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
|
port,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
token: gatewayToken,
|
||||||
|
launchdLabel:
|
||||||
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
|
||||||
};
|
});
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
|
|||||||
@@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: {
|
|||||||
port: number;
|
port: number;
|
||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
runtime?: GatewayRuntimePreference;
|
runtime?: GatewayRuntimePreference;
|
||||||
|
nodePath?: string;
|
||||||
}): Promise<GatewayProgramArgs> {
|
}): Promise<GatewayProgramArgs> {
|
||||||
const gatewayArgs = ["gateway", "--port", String(params.port)];
|
const gatewayArgs = ["gateway", "--port", String(params.port)];
|
||||||
const execPath = process.execPath;
|
const execPath = process.execPath;
|
||||||
const runtime = params.runtime ?? "auto";
|
const runtime = params.runtime ?? "auto";
|
||||||
|
|
||||||
if (runtime === "node") {
|
if (runtime === "node") {
|
||||||
const nodePath = isNodeRuntime(execPath)
|
const nodePath =
|
||||||
? execPath
|
params.nodePath ??
|
||||||
: await resolveNodePath();
|
(isNodeRuntime(execPath) ? execPath : await resolveNodePath());
|
||||||
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
const cliEntrypointPath = await resolveCliEntrypointPathForService();
|
||||||
return {
|
return {
|
||||||
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
|
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
|
||||||
|
|||||||
88
src/daemon/runtime-paths.ts
Normal file
88
src/daemon/runtime-paths.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/daemon/service-audit.test.ts
Normal file
55
src/daemon/service-audit.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||||
|
import {
|
||||||
|
isSystemNodePath,
|
||||||
|
isVersionManagedNodePath,
|
||||||
|
resolveSystemNodePath,
|
||||||
|
} from "./runtime-paths.js";
|
||||||
|
import { getMinimalServicePathParts } from "./service-env.js";
|
||||||
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
import { resolveSystemdUserUnitPath } from "./systemd.js";
|
||||||
|
|
||||||
export type GatewayServiceCommand = {
|
export type GatewayServiceCommand = {
|
||||||
@@ -21,6 +28,31 @@ export type ServiceConfigAudit = {
|
|||||||
issues: ServiceConfigIssue[];
|
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 {
|
function hasGatewaySubcommand(programArguments?: string[]): boolean {
|
||||||
return Boolean(programArguments?.some((arg) => arg === "gateway"));
|
return Boolean(programArguments?.some((arg) => arg === "gateway"));
|
||||||
}
|
}
|
||||||
@@ -82,7 +114,7 @@ async function auditSystemdUnit(
|
|||||||
const parsed = parseSystemdUnit(content);
|
const parsed = parseSystemdUnit(content);
|
||||||
if (!parsed.after.has("network-online.target")) {
|
if (!parsed.after.has("network-online.target")) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "systemd-after-network-online",
|
code: SERVICE_AUDIT_CODES.systemdAfterNetworkOnline,
|
||||||
message: "Missing systemd After=network-online.target",
|
message: "Missing systemd After=network-online.target",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
@@ -90,7 +122,7 @@ async function auditSystemdUnit(
|
|||||||
}
|
}
|
||||||
if (!parsed.wants.has("network-online.target")) {
|
if (!parsed.wants.has("network-online.target")) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "systemd-wants-network-online",
|
code: SERVICE_AUDIT_CODES.systemdWantsNetworkOnline,
|
||||||
message: "Missing systemd Wants=network-online.target",
|
message: "Missing systemd Wants=network-online.target",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
@@ -98,7 +130,7 @@ async function auditSystemdUnit(
|
|||||||
}
|
}
|
||||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "systemd-restart-sec",
|
code: SERVICE_AUDIT_CODES.systemdRestartSec,
|
||||||
message: "RestartSec does not match the recommended 5s",
|
message: "RestartSec does not match the recommended 5s",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
@@ -122,7 +154,7 @@ async function auditLaunchdPlist(
|
|||||||
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
|
||||||
if (!hasRunAtLoad) {
|
if (!hasRunAtLoad) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "launchd-run-at-load",
|
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
|
||||||
message: "LaunchAgent is missing RunAtLoad=true",
|
message: "LaunchAgent is missing RunAtLoad=true",
|
||||||
detail: plistPath,
|
detail: plistPath,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
@@ -130,7 +162,7 @@ async function auditLaunchdPlist(
|
|||||||
}
|
}
|
||||||
if (!hasKeepAlive) {
|
if (!hasKeepAlive) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "launchd-keep-alive",
|
code: SERVICE_AUDIT_CODES.launchdKeepAlive,
|
||||||
message: "LaunchAgent is missing KeepAlive=true",
|
message: "LaunchAgent is missing KeepAlive=true",
|
||||||
detail: plistPath,
|
detail: plistPath,
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
@@ -145,13 +177,139 @@ function auditGatewayCommand(
|
|||||||
if (!programArguments || programArguments.length === 0) return;
|
if (!programArguments || programArguments.length === 0) return;
|
||||||
if (!hasGatewaySubcommand(programArguments)) {
|
if (!hasGatewaySubcommand(programArguments)) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: "gateway-command-missing",
|
code: SERVICE_AUDIT_CODES.gatewayCommandMissing,
|
||||||
message: "Service command does not include the gateway subcommand",
|
message: "Service command does not include the gateway subcommand",
|
||||||
level: "aggressive",
|
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: {
|
export async function auditGatewayServiceConfig(params: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
command: GatewayServiceCommand;
|
command: GatewayServiceCommand;
|
||||||
@@ -161,6 +319,8 @@ export async function auditGatewayServiceConfig(params: {
|
|||||||
const platform = params.platform ?? process.platform;
|
const platform = params.platform ?? process.platform;
|
||||||
|
|
||||||
auditGatewayCommand(params.command?.programArguments, issues);
|
auditGatewayCommand(params.command?.programArguments, issues);
|
||||||
|
auditGatewayServicePath(params.command, issues, platform);
|
||||||
|
await auditGatewayRuntime(params.env, params.command, issues, platform);
|
||||||
|
|
||||||
if (platform === "linux") {
|
if (platform === "linux") {
|
||||||
await auditSystemdUnit(params.env, issues);
|
await auditSystemdUnit(params.env, issues);
|
||||||
|
|||||||
62
src/daemon/service-env.test.ts
Normal file
62
src/daemon/service-env.test.ts
Normal 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
71
src/daemon/service-env.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,7 +46,9 @@ import {
|
|||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
|
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -480,20 +482,26 @@ export async function runOnboardingWizard(
|
|||||||
const devMode =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
|
const nodePath = await resolvePreferredNodePath({
|
||||||
|
env: process.env,
|
||||||
|
runtime: daemonRuntime,
|
||||||
|
});
|
||||||
const { programArguments, workingDirectory } =
|
const { programArguments, workingDirectory } =
|
||||||
await resolveGatewayProgramArguments({
|
await resolveGatewayProgramArguments({
|
||||||
port,
|
port,
|
||||||
dev: devMode,
|
dev: devMode,
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
|
nodePath,
|
||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment = buildServiceEnvironment({
|
||||||
PATH: process.env.PATH,
|
env: process.env,
|
||||||
CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
|
port,
|
||||||
CLAWDBOT_LAUNCHD_LABEL:
|
token: gatewayToken,
|
||||||
|
launchdLabel:
|
||||||
process.platform === "darwin"
|
process.platform === "darwin"
|
||||||
? GATEWAY_LAUNCH_AGENT_LABEL
|
? GATEWAY_LAUNCH_AGENT_LABEL
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
});
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
|
|||||||
Reference in New Issue
Block a user