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:
@@ -100,6 +100,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
if (json) warnings.push(message);
|
||||
else defaultRuntime.log(message);
|
||||
},
|
||||
configEnvVars: cfg.env?.vars,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -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…");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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…");
|
||||
|
||||
Reference in New Issue
Block a user