fix: include .env file vars in gateway service environment on install

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
This commit is contained in:
Kevin ONeill
2026-03-22 17:24:24 -05:00
committed by Peter Steinberger
parent 3afb6a2b95
commit 77ec7b4adf
2 changed files with 187 additions and 3 deletions

View File

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

View File

@@ -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<string, string | undefined>,
): Record<string, string> {
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<string, string> = {};
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<string, string | undefined> = {
...readStateDirDotEnvVars(params.env),
...collectConfigServiceEnvVars(params.config),
...collectAuthProfileServiceEnvVars({
env: params.env,