From c15282062f047ea63ce0f63d2011f14412545c77 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 22:04:46 -0700 Subject: [PATCH] refactor: split durable service env helpers --- .../doctor-gateway-auth-token.test.ts | 19 ++++ src/config/config-env-vars.ts | 97 ++++++++++++++++ src/config/config.env-vars.test.ts | 67 ++++++++++- src/config/env-vars.ts | 105 ++---------------- src/config/state-dir-dotenv.ts | 69 ++++++++++++ src/config/test-helpers.ts | 17 +++ src/gateway/auth-install-policy.ts | 49 +++++--- 7 files changed, 309 insertions(+), 114 deletions(-) create mode 100644 src/config/config-env-vars.ts create mode 100644 src/config/state-dir-dotenv.ts diff --git a/src/commands/doctor-gateway-auth-token.test.ts b/src/commands/doctor-gateway-auth-token.test.ts index f09ce2f6e98..1aff1f037df 100644 --- a/src/commands/doctor-gateway-auth-token.test.ts +++ b/src/commands/doctor-gateway-auth-token.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { withTempHome, writeStateDirDotEnv } from "../config/test-helpers.js"; import { withEnvAsync } from "../test-utils/env.js"; import { resolveGatewayAuthTokenForService, @@ -238,6 +239,24 @@ describe("shouldRequireGatewayTokenForInstall", () => { expect(required).toBe(false); }); + it("does not require token in inferred mode when password env exists in state-dir .env", async () => { + await withTempHome(async (_home) => { + await writeStateDirDotEnv("OPENCLAW_GATEWAY_PASSWORD=dotenv-password\n", { + env: process.env, + }); + + const required = shouldRequireGatewayTokenForInstall( + { + gateway: { + auth: {}, + }, + } as OpenClawConfig, + process.env, + ); + expect(required).toBe(false); + }); + }); + it("requires token in inferred mode when no password candidate exists", () => { const required = shouldRequireGatewayTokenForInstall( { diff --git a/src/config/config-env-vars.ts b/src/config/config-env-vars.ts new file mode 100644 index 00000000000..8692e163e22 --- /dev/null +++ b/src/config/config-env-vars.ts @@ -0,0 +1,97 @@ +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, + normalizeEnvVarKey, +} from "../infra/host-env-security.js"; +import { containsEnvVarReference } from "./env-substitution.js"; +import type { OpenClawConfig } from "./types.js"; + +function isBlockedConfigEnvVar(key: string): boolean { + return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key); +} + +function collectConfigEnvVarsByTarget(cfg?: OpenClawConfig): Record { + const envConfig = cfg?.env; + if (!envConfig) { + return {}; + } + + const entries: Record = {}; + + if (envConfig.vars) { + for (const [rawKey, value] of Object.entries(envConfig.vars)) { + if (!value) { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + if (isBlockedConfigEnvVar(key)) { + continue; + } + entries[key] = value; + } + } + + for (const [rawKey, value] of Object.entries(envConfig)) { + if (rawKey === "shellEnv" || rawKey === "vars") { + continue; + } + if (typeof value !== "string" || !value.trim()) { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + if (isBlockedConfigEnvVar(key)) { + continue; + } + entries[key] = value; + } + + return entries; +} + +export function collectConfigRuntimeEnvVars(cfg?: OpenClawConfig): Record { + return collectConfigEnvVarsByTarget(cfg); +} + +export function collectConfigServiceEnvVars(cfg?: OpenClawConfig): Record { + return collectConfigEnvVarsByTarget(cfg); +} + +/** @deprecated Use `collectConfigRuntimeEnvVars` or `collectConfigServiceEnvVars`. */ +export function collectConfigEnvVars(cfg?: OpenClawConfig): Record { + return collectConfigRuntimeEnvVars(cfg); +} + +export function createConfigRuntimeEnv( + cfg: OpenClawConfig, + baseEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + applyConfigEnvVars(cfg, env); + return env; +} + +export function applyConfigEnvVars( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): void { + const entries = collectConfigRuntimeEnvVars(cfg); + for (const [key, value] of Object.entries(entries)) { + if (env[key]?.trim()) { + continue; + } + // Skip values containing unresolved ${VAR} references — applyConfigEnvVars runs + // before env substitution, so these would pollute process.env with literal placeholders + // (e.g. process.env.OPENCLAW_GATEWAY_TOKEN = "${VAULT_TOKEN}") which downstream auth + // resolution would accept as valid credentials. + if (containsEnvVarReference(value)) { + continue; + } + env[key] = value; + } +} diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 389edc6d11d..5910fe268aa 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -5,10 +5,12 @@ import { loadDotEnv } from "../infra/dotenv.js"; import { resolveConfigEnvVars } from "./env-substitution.js"; import { applyConfigEnvVars, + collectDurableServiceEnvVars, collectConfigRuntimeEnvVars, createConfigRuntimeEnv, + readStateDirDotEnvVars, } from "./env-vars.js"; -import { withEnvOverride, withTempHome } from "./test-helpers.js"; +import { withEnvOverride, withTempHome, writeStateDirDotEnv } from "./test-helpers.js"; import type { OpenClawConfig } from "./types.js"; describe("config env vars", () => { @@ -130,4 +132,67 @@ describe("config env vars", () => { }); }); }); + + it("reads key-value pairs from the state-dir .env file", async () => { + await withTempHome(async (_home) => { + await writeStateDirDotEnv("BRAVE_API_KEY=BSA-test-key\nDISCORD_BOT_TOKEN=discord-tok\n", { + env: process.env, + }); + const vars = readStateDirDotEnvVars(process.env); + expect(vars.BRAVE_API_KEY).toBe("BSA-test-key"); + expect(vars.DISCORD_BOT_TOKEN).toBe("discord-tok"); + }); + }); + + it("returns empty record when the state-dir .env file is missing", async () => { + await withTempHome(async (_home) => { + expect(readStateDirDotEnvVars(process.env)).toEqual({}); + }); + }); + + it("drops dangerous and empty values from the state-dir .env file", async () => { + await withTempHome(async (_home) => { + await writeStateDirDotEnv("NODE_OPTIONS=--require /tmp/evil.js\nEMPTY=\nVALID=ok\n", { + env: process.env, + }); + const vars = readStateDirDotEnvVars(process.env); + expect(vars.NODE_OPTIONS).toBeUndefined(); + expect(vars.EMPTY).toBeUndefined(); + expect(vars.VALID).toBe("ok"); + }); + }); + + it("respects OPENCLAW_STATE_DIR when reading state-dir .env vars", async () => { + await withTempHome(async (_home) => { + const customStateDir = path.join(process.env.OPENCLAW_STATE_DIR ?? "", "custom-state"); + await writeStateDirDotEnv("CUSTOM_KEY=from-override\n", { + stateDir: customStateDir, + }); + expect( + readStateDirDotEnvVars({ + OPENCLAW_STATE_DIR: customStateDir, + }).CUSTOM_KEY, + ).toBe("from-override"); + }); + }); + + it("lets config service env vars override state-dir .env vars", async () => { + await withTempHome(async (_home) => { + await writeStateDirDotEnv("MY_KEY=from-dotenv\n", { + env: process.env, + }); + expect( + collectDurableServiceEnvVars({ + env: process.env, + config: { + env: { + vars: { + MY_KEY: "from-config", + }, + }, + } as OpenClawConfig, + }).MY_KEY, + ).toBe("from-config"); + }); + }); }); diff --git a/src/config/env-vars.ts b/src/config/env-vars.ts index 8692e163e22..94a1c7e6b4c 100644 --- a/src/config/env-vars.ts +++ b/src/config/env-vars.ts @@ -1,97 +1,8 @@ -import { - isDangerousHostEnvOverrideVarName, - isDangerousHostEnvVarName, - normalizeEnvVarKey, -} from "../infra/host-env-security.js"; -import { containsEnvVarReference } from "./env-substitution.js"; -import type { OpenClawConfig } from "./types.js"; - -function isBlockedConfigEnvVar(key: string): boolean { - return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key); -} - -function collectConfigEnvVarsByTarget(cfg?: OpenClawConfig): Record { - const envConfig = cfg?.env; - if (!envConfig) { - return {}; - } - - const entries: Record = {}; - - if (envConfig.vars) { - for (const [rawKey, value] of Object.entries(envConfig.vars)) { - if (!value) { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } - if (isBlockedConfigEnvVar(key)) { - continue; - } - entries[key] = value; - } - } - - for (const [rawKey, value] of Object.entries(envConfig)) { - if (rawKey === "shellEnv" || rawKey === "vars") { - continue; - } - if (typeof value !== "string" || !value.trim()) { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } - if (isBlockedConfigEnvVar(key)) { - continue; - } - entries[key] = value; - } - - return entries; -} - -export function collectConfigRuntimeEnvVars(cfg?: OpenClawConfig): Record { - return collectConfigEnvVarsByTarget(cfg); -} - -export function collectConfigServiceEnvVars(cfg?: OpenClawConfig): Record { - return collectConfigEnvVarsByTarget(cfg); -} - -/** @deprecated Use `collectConfigRuntimeEnvVars` or `collectConfigServiceEnvVars`. */ -export function collectConfigEnvVars(cfg?: OpenClawConfig): Record { - return collectConfigRuntimeEnvVars(cfg); -} - -export function createConfigRuntimeEnv( - cfg: OpenClawConfig, - baseEnv: NodeJS.ProcessEnv = process.env, -): NodeJS.ProcessEnv { - const env = { ...baseEnv }; - applyConfigEnvVars(cfg, env); - return env; -} - -export function applyConfigEnvVars( - cfg: OpenClawConfig, - env: NodeJS.ProcessEnv = process.env, -): void { - const entries = collectConfigRuntimeEnvVars(cfg); - for (const [key, value] of Object.entries(entries)) { - if (env[key]?.trim()) { - continue; - } - // Skip values containing unresolved ${VAR} references — applyConfigEnvVars runs - // before env substitution, so these would pollute process.env with literal placeholders - // (e.g. process.env.OPENCLAW_GATEWAY_TOKEN = "${VAULT_TOKEN}") which downstream auth - // resolution would accept as valid credentials. - if (containsEnvVarReference(value)) { - continue; - } - env[key] = value; - } -} +export { + applyConfigEnvVars, + collectConfigEnvVars, + collectConfigRuntimeEnvVars, + collectConfigServiceEnvVars, + createConfigRuntimeEnv, +} from "./config-env-vars.js"; +export { collectDurableServiceEnvVars, readStateDirDotEnvVars } from "./state-dir-dotenv.js"; diff --git a/src/config/state-dir-dotenv.ts b/src/config/state-dir-dotenv.ts new file mode 100644 index 00000000000..670f6ee7cca --- /dev/null +++ b/src/config/state-dir-dotenv.ts @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import dotenv from "dotenv"; +import { + isDangerousHostEnvOverrideVarName, + isDangerousHostEnvVarName, + normalizeEnvVarKey, +} from "../infra/host-env-security.js"; +import { collectConfigServiceEnvVars } from "./config-env-vars.js"; +import { resolveStateDir } from "./paths.js"; +import type { OpenClawConfig } from "./types.js"; + +function isBlockedServiceEnvVar(key: string): boolean { + return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key); +} + +/** + * 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). + */ +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 [rawKey, value] of Object.entries(parsed)) { + if (!value?.trim()) { + continue; + } + const key = normalizeEnvVarKey(rawKey, { portable: true }); + if (!key) { + continue; + } + if (isBlockedServiceEnvVar(key)) { + continue; + } + entries[key] = value; + } + return entries; +} + +/** + * Durable service env sources survive beyond the invoking shell and are safe to + * persist into gateway install metadata. + * + * Precedence: + * 1. state-dir `.env` file vars + * 2. config service env vars + */ +export function collectDurableServiceEnvVars(params: { + env: Record; + config?: OpenClawConfig; +}): Record { + return { + ...readStateDirDotEnvVars(params.env), + ...collectConfigServiceEnvVars(params.config), + }; +} diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index c8e3c539d14..12c15838cc1 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -14,6 +14,23 @@ export async function writeOpenClawConfig(home: string, config: unknown): Promis return configPath; } +export async function writeStateDirDotEnv( + content: string, + params?: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + }, +): Promise<{ dotEnvPath: string; stateDir: string }> { + const stateDir = params?.stateDir ?? params?.env?.OPENCLAW_STATE_DIR?.trim(); + if (!stateDir) { + throw new Error("Expected OPENCLAW_STATE_DIR or explicit stateDir for .env test setup"); + } + const dotEnvPath = path.join(stateDir, ".env"); + await fs.mkdir(path.dirname(dotEnvPath), { recursive: true }); + await fs.writeFile(dotEnvPath, content, "utf-8"); + return { dotEnvPath, stateDir }; +} + export async function withTempHomeConfig( config: unknown, fn: (params: { home: string; configPath: string }) => Promise, diff --git a/src/gateway/auth-install-policy.ts b/src/gateway/auth-install-policy.ts index 9e3360f439f..bffc3b43b34 100644 --- a/src/gateway/auth-install-policy.ts +++ b/src/gateway/auth-install-policy.ts @@ -1,35 +1,52 @@ import type { OpenClawConfig } from "../config/config.js"; -import { collectConfigServiceEnvVars } from "../config/env-vars.js"; +import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; -export function shouldRequireGatewayTokenForInstall( - cfg: OpenClawConfig, - _env: NodeJS.ProcessEnv, -): boolean { - const mode = cfg.gateway?.auth?.mode; +type GatewayInstallAuthMode = NonNullable["auth"]>["mode"]; + +function hasExplicitGatewayInstallAuthMode( + mode: GatewayInstallAuthMode | undefined, +): boolean | undefined { if (mode === "token") { return true; } if (mode === "password" || mode === "none" || mode === "trusted-proxy") { return false; } + return undefined; +} - const hasConfiguredPassword = hasConfiguredSecretInput( - cfg.gateway?.auth?.password, - cfg.secrets?.defaults, +function hasConfiguredGatewayPasswordForInstall(cfg: OpenClawConfig): boolean { + return hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults); +} + +function hasDurableGatewayPasswordEnvForInstall( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): boolean { + const durableServiceEnv = collectDurableServiceEnvVars({ env, config: cfg }); + return Boolean( + durableServiceEnv.OPENCLAW_GATEWAY_PASSWORD?.trim() || + durableServiceEnv.CLAWDBOT_GATEWAY_PASSWORD?.trim(), ); - if (hasConfiguredPassword) { +} + +export function shouldRequireGatewayTokenForInstall( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): boolean { + const explicitModeDecision = hasExplicitGatewayInstallAuthMode(cfg.gateway?.auth?.mode); + if (explicitModeDecision !== undefined) { + return explicitModeDecision; + } + + if (hasConfiguredGatewayPasswordForInstall(cfg)) { return false; } // Service install should only infer password mode from durable sources that // survive outside the invoking shell. - const configServiceEnv = collectConfigServiceEnvVars(cfg); - const hasConfiguredPasswordEnvCandidate = Boolean( - configServiceEnv.OPENCLAW_GATEWAY_PASSWORD?.trim() || - configServiceEnv.CLAWDBOT_GATEWAY_PASSWORD?.trim(), - ); - if (hasConfiguredPasswordEnvCandidate) { + if (hasDurableGatewayPasswordEnvForInstall(cfg, env)) { return false; }