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.
This commit is contained in:
Matias Wainsten
2026-01-25 07:35:55 -03:00
committed by GitHub
parent bfa57aae44
commit f29f51569a
8 changed files with 85 additions and 1 deletions

View File

@@ -100,6 +100,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (json) warnings.push(message);
else defaultRuntime.log(message);
},
configEnvVars: cfg.env?.vars,
});
try {

View File

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

View File

@@ -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", () => {

View File

@@ -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<string, string | undefined>;
}): Promise<GatewayInstallPlan> {
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<string, string | undefined> = {};
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 };
}

View File

@@ -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({

View File

@@ -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);

View File

@@ -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({

View File

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