From 77ec7b4adf3d8a0cb1e6eb49b769ae061ffd4621 Mon Sep 17 00:00:00 2001 From: Kevin ONeill Date: Sun, 22 Mar 2026 17:24:24 -0500 Subject: [PATCH] fix: include .env file vars in gateway service environment on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When building the gateway install plan, read and parse ~/.openclaw/.env (or $OPENCLAW_STATE_DIR/.env) and merge those key-value pairs into the service environment at the lowest priority — below config env vars, auth-profile refs, and the core service environment (HOME, PATH, OPENCLAW_*). This ensures that user-defined secrets stored in .env (e.g. BRAVE_API_KEY, OPENROUTER_API_KEY, DISCORD_BOT_TOKEN) are embedded in the LaunchAgent plist (macOS), systemd unit (Linux), and Scheduled Task (Windows) at install time, rather than relying solely on the gateway process loading them via dotenv.config() at startup. Previously, on macOS the LaunchAgent plist never included .env vars, which meant: - launchctl print did not show user secrets (hard to debug) - Child processes spawned before dotenv loaded had no access - If the same key existed in both .env and the plist, the stale plist value won via dotenv override:false semantics Dangerous host env vars (NODE_OPTIONS, LD_PRELOAD, etc.) are filtered using the same security policy applied to config env vars. Fixes #37101 Relates to #22663 --- src/commands/daemon-install-helpers.test.ts | 139 +++++++++++++++++++- src/commands/daemon-install-helpers.ts | 51 ++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 113e7edd637..a05176aee9f 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -1,4 +1,7 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loadAuthProfileStoreForSecretsRuntime: vi.fn(), @@ -30,6 +33,7 @@ vi.mock("../daemon/service-env.js", () => ({ import { buildGatewayInstallPlan, gatewayInstallErrorHint, + readStateDirDotEnvVars, resolveGatewayDevMode, } from "./daemon-install-helpers.js"; @@ -328,6 +332,139 @@ describe("buildGatewayInstallPlan", () => { }); }); +describe("readStateDirDotEnvVars", () => { + let tmpDir: string; + + function writeDotEnv(content: string): void { + const ocDir = path.join(tmpDir, ".openclaw"); + fs.mkdirSync(ocDir, { recursive: true }); + fs.writeFileSync(path.join(ocDir, ".env"), content, "utf8"); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-dotenv-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("reads key-value pairs from state dir .env file", () => { + writeDotEnv("BRAVE_API_KEY=BSA-test-key\nDISCORD_BOT_TOKEN=discord-tok\n"); + const vars = readStateDirDotEnvVars({ HOME: tmpDir }); + expect(vars.BRAVE_API_KEY).toBe("BSA-test-key"); + expect(vars.DISCORD_BOT_TOKEN).toBe("discord-tok"); + }); + + it("returns empty record when .env file is missing", () => { + const vars = readStateDirDotEnvVars({ HOME: tmpDir }); + expect(vars).toEqual({}); + }); + + it("drops dangerous env vars like NODE_OPTIONS", () => { + writeDotEnv("NODE_OPTIONS=--require /tmp/evil.js\nSAFE_KEY=safe\n"); + const vars = readStateDirDotEnvVars({ HOME: tmpDir }); + expect(vars.NODE_OPTIONS).toBeUndefined(); + expect(vars.SAFE_KEY).toBe("safe"); + }); + + it("drops empty and whitespace-only values", () => { + writeDotEnv("EMPTY=\nBLANK= \nVALID=ok\n"); + const vars = readStateDirDotEnvVars({ HOME: tmpDir }); + expect(vars.EMPTY).toBeUndefined(); + expect(vars.BLANK).toBeUndefined(); + expect(vars.VALID).toBe("ok"); + }); + + it("respects OPENCLAW_STATE_DIR override", () => { + const customDir = path.join(tmpDir, "custom-state"); + fs.mkdirSync(customDir, { recursive: true }); + fs.writeFileSync(path.join(customDir, ".env"), "CUSTOM_KEY=from-override\n", "utf8"); + const vars = readStateDirDotEnvVars({ OPENCLAW_STATE_DIR: customDir }); + expect(vars.CUSTOM_KEY).toBe("from-override"); + }); +}); + +describe("buildGatewayInstallPlan — dotenv merge", () => { + let tmpDir: string; + + function writeDotEnv(content: string): void { + const ocDir = path.join(tmpDir, ".openclaw"); + fs.mkdirSync(ocDir, { recursive: true }); + fs.writeFileSync(path.join(ocDir, ".env"), content, "utf8"); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-plan-dotenv-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("merges .env file vars into the install plan", async () => { + writeDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n"); + mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + }); + + expect(plan.environment.BRAVE_API_KEY).toBe("BSA-from-env"); + expect(plan.environment.OPENROUTER_API_KEY).toBe("or-key"); + expect(plan.environment.OPENCLAW_PORT).toBe("3000"); + }); + + it("config env vars override .env file vars", async () => { + writeDotEnv("MY_KEY=from-dotenv\n"); + mockNodeGatewayPlanFixture({ serviceEnvironment: {} }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + config: { + env: { + vars: { + MY_KEY: "from-config", + }, + }, + }, + }); + + expect(plan.environment.MY_KEY).toBe("from-config"); + }); + + it("service env overrides .env file vars", async () => { + writeDotEnv("HOME=/from-dotenv\n"); + mockNodeGatewayPlanFixture({ + serviceEnvironment: { HOME: "/from-service" }, + }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + }); + + expect(plan.environment.HOME).toBe("/from-service"); + }); + + it("works when .env file does not exist", async () => { + mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); + + const plan = await buildGatewayInstallPlan({ + env: { HOME: tmpDir }, + port: 3000, + runtime: "node", + }); + + expect(plan.environment.OPENCLAW_PORT).toBe("3000"); + }); +}); + describe("gatewayInstallErrorHint", () => { it("returns platform-specific hints", () => { expect(gatewayInstallErrorHint("win32")).toContain("Startup-folder login item"); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index fcd4a6447fb..e3ad0b491ee 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -1,13 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; import { loadAuthProfileStoreForSecretsRuntime, type AuthProfileStore, } from "../agents/auth-profiles.js"; import { formatCliCommand } from "../cli/command-format.js"; import { collectConfigServiceEnvVars } from "../config/env-vars.js"; +import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, +} from "../infra/host-env-security.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, @@ -18,6 +26,41 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export { resolveGatewayDevMode } from "./daemon-install-plan.shared.js"; +/** + * Read and parse `~/.openclaw/.env` (or `$OPENCLAW_STATE_DIR/.env`), returning + * a filtered record of key-value pairs suitable for embedding in a service + * environment (LaunchAgent plist, systemd unit, Scheduled Task). + * + * Security: dangerous host env vars (NODE_OPTIONS, LD_PRELOAD, etc.) are + * dropped, matching the same policy applied to config env vars. + */ +export function readStateDirDotEnvVars( + env: Record, +): Record { + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + const dotEnvPath = path.join(stateDir, ".env"); + + let content: string; + try { + content = fs.readFileSync(dotEnvPath, "utf8"); + } catch { + return {}; + } + + const parsed = dotenv.parse(content); + const entries: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!key || !value?.trim()) { + continue; + } + if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { + continue; + } + entries[key] = value; + } + return entries; +} + export type GatewayInstallPlan = { programArguments: string[]; workingDirectory?: string; @@ -93,9 +136,13 @@ export async function buildGatewayInstallPlan(params: { extraPathDirs: resolveDaemonNodeBinDir(nodePath), }); - // Merge config env vars into the service environment (vars + inline env keys). - // Config env vars are added first so service-specific vars take precedence. + // Merge env sources into the service environment in ascending priority: + // 1. ~/.openclaw/.env file vars (lowest — user secrets / fallback keys) + // 2. Config env vars (openclaw.json env.vars + inline keys) + // 3. Auth-profile env refs (credential store → env var lookups) + // 4. Service environment (HOME, PATH, OPENCLAW_* — highest) const environment: Record = { + ...readStateDirDotEnvVars(params.env), ...collectConfigServiceEnvVars(params.config), ...collectAuthProfileServiceEnvVars({ env: params.env,