From f29f51569a8a4d396b74a87f140d78c44b323bbe Mon Sep 17 00:00:00 2001 From: Matias Wainsten Date: Sun, 25 Jan 2026 07:35:55 -0300 Subject: [PATCH] fix: propagate config.env.vars to LaunchAgent/systemd service environment (#1735) When installing the Gateway daemon via LaunchAgent (macOS) or systemd (Linux), environment variables defined in config.env.vars were not being included in the service environment. This caused API keys and other env vars configured in clawdbot.json5 to be unavailable when the Gateway ran as a service. The fix adds a configEnvVars parameter to buildGatewayInstallPlan() which merges config.env.vars into the service environment. Service-specific variables (CLAWDBOT_*, HOME, PATH) take precedence over config env vars. Fixes the issue where users had to manually edit the LaunchAgent plist to add environment variables like GOOGLE_API_KEY. --- src/cli/daemon-cli/install.ts | 1 + src/commands/configure.daemon.ts | 3 + src/commands/daemon-install-helpers.test.ts | 63 +++++++++++++++++++ src/commands/daemon-install-helpers.ts | 14 ++++- src/commands/doctor-gateway-daemon-flow.ts | 1 + src/commands/doctor-gateway-services.ts | 2 + .../local/daemon-install.ts | 1 + src/wizard/onboarding.finalize.ts | 1 + 8 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 6f1998d5d..469c11d69 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -100,6 +100,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { if (json) warnings.push(message); else defaultRuntime.log(message); }, + configEnvVars: cfg.env?.vars, }); try { diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 7115d49a4..4e1b1c79c 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -11,6 +11,7 @@ import { } from "./daemon-runtime.js"; import { guardCancel } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; +import { loadConfig } from "../config/config.js"; export async function maybeInstallDaemon(params: { runtime: RuntimeEnv; @@ -81,12 +82,14 @@ export async function maybeInstallDaemon(params: { progress.setLabel("Preparing Gateway service…"); + const cfg = loadConfig(); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port: params.port, token: params.gatewayToken, runtime: daemonRuntime, warn: (message, title) => note(message, title), + configEnvVars: cfg.env?.vars, }); progress.setLabel("Installing Gateway service…"); diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 22ae7f24d..24a062c48 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -95,6 +95,69 @@ describe("buildGatewayInstallPlan", () => { expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime"); expect(mocks.resolvePreferredNodePath).toHaveBeenCalled(); }); + + it("merges configEnvVars into the environment", async () => { + mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); + mocks.resolveGatewayProgramArguments.mockResolvedValue({ + programArguments: ["node", "gateway"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/opt/node", + version: "22.0.0", + supported: true, + }); + mocks.buildServiceEnvironment.mockReturnValue({ + CLAWDBOT_PORT: "3000", + HOME: "/Users/me", + }); + + const plan = await buildGatewayInstallPlan({ + env: {}, + port: 3000, + runtime: "node", + configEnvVars: { + GOOGLE_API_KEY: "test-key", + CUSTOM_VAR: "custom-value", + }, + }); + + // Config env vars should be present + expect(plan.environment.GOOGLE_API_KEY).toBe("test-key"); + expect(plan.environment.CUSTOM_VAR).toBe("custom-value"); + // Service environment vars should take precedence + expect(plan.environment.CLAWDBOT_PORT).toBe("3000"); + expect(plan.environment.HOME).toBe("/Users/me"); + }); + + it("does not include empty configEnvVars values", async () => { + mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); + mocks.resolveGatewayProgramArguments.mockResolvedValue({ + programArguments: ["node", "gateway"], + workingDirectory: "/Users/me", + }); + mocks.resolveSystemNodeInfo.mockResolvedValue({ + path: "/opt/node", + version: "22.0.0", + supported: true, + }); + mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" }); + + const plan = await buildGatewayInstallPlan({ + env: {}, + port: 3000, + runtime: "node", + configEnvVars: { + VALID_KEY: "valid", + EMPTY_KEY: "", + UNDEFINED_KEY: undefined, + }, + }); + + expect(plan.environment.VALID_KEY).toBe("valid"); + expect(plan.environment.EMPTY_KEY).toBeUndefined(); + expect(plan.environment.UNDEFINED_KEY).toBeUndefined(); + }); }); describe("gatewayInstallErrorHint", () => { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 26fe7ada5..9e386b865 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -31,6 +31,8 @@ export async function buildGatewayInstallPlan(params: { devMode?: boolean; nodePath?: string; warn?: WarnFn; + /** Environment variables from config.env.vars to include in the service environment */ + configEnvVars?: Record; }): Promise { const devMode = params.devMode ?? resolveGatewayDevMode(); const nodePath = @@ -50,7 +52,7 @@ export async function buildGatewayInstallPlan(params: { const warning = renderSystemNodeWarning(systemNode, programArguments[0]); if (warning) params.warn?.(warning, "Gateway runtime"); } - const environment = buildServiceEnvironment({ + const serviceEnvironment = buildServiceEnvironment({ env: params.env, port: params.port, token: params.token, @@ -60,6 +62,16 @@ export async function buildGatewayInstallPlan(params: { : undefined, }); + // Merge config.env.vars into the service environment. + // Config env vars are added first so service-specific vars take precedence. + const environment: Record = {}; + if (params.configEnvVars) { + for (const [key, value] of Object.entries(params.configEnvVars)) { + if (value) environment[key] = value; + } + } + Object.assign(environment, serviceEnvironment); + return { programArguments, workingDirectory, environment }; } diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 83a4f515e..8623c5b3b 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -161,6 +161,7 @@ export async function maybeRepairGatewayDaemon(params: { token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, runtime: daemonRuntime, warn: (message, title) => note(message, title), + configEnvVars: params.cfg.env?.vars, }); try { await service.install({ diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index e3005428d..f5ecccade 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -110,6 +110,7 @@ export async function maybeMigrateLegacyGatewayService( token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, runtime: daemonRuntime, warn: (message, title) => note(message, title), + configEnvVars: cfg.env?.vars, }); try { await service.install({ @@ -177,6 +178,7 @@ export async function maybeRepairGatewayServiceConfig( runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, nodePath: systemNodePath ?? undefined, warn: (message, title) => note(message, title), + configEnvVars: cfg.env?.vars, }); const expectedEntrypoint = findGatewayEntrypoint(programArguments); const currentEntrypoint = findGatewayEntrypoint(command.programArguments); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index 5b2e77b63..6f073db7f 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -38,6 +38,7 @@ export async function installGatewayDaemonNonInteractive(params: { token: gatewayToken, runtime: daemonRuntimeRaw, warn: (message) => runtime.log(message), + configEnvVars: params.nextConfig.env?.vars, }); try { await service.install({ diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index ed9ce580d..672833a26 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -169,6 +169,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption token: settings.gatewayToken, runtime: daemonRuntime, warn: (message, title) => prompter.note(message, title), + configEnvVars: nextConfig.env?.vars, }); progress.update("Installing Gateway service…");