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,