mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 16:32:29 +00:00
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:
committed by
Peter Steinberger
parent
3afb6a2b95
commit
77ec7b4adf
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user