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…");