diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index b564c33055b..8adc0aa5a9f 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeStateDirDotEnv } from "../config/test-helpers.js"; const mocks = vi.hoisted(() => ({ loadAuthProfileStoreForSecretsRuntime: vi.fn(), @@ -33,7 +34,6 @@ vi.mock("../daemon/service-env.js", () => ({ import { buildGatewayInstallPlan, gatewayInstallErrorHint, - readStateDirDotEnvVars, resolveGatewayDevMode, } from "./daemon-install-helpers.js"; @@ -342,68 +342,9 @@ 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-")); }); @@ -413,7 +354,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }); it("merges .env file vars into the install plan", async () => { - writeDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n"); + await writeStateDirDotEnv("BRAVE_API_KEY=BSA-from-env\nOPENROUTER_API_KEY=or-key\n", { + stateDir: path.join(tmpDir, ".openclaw"), + }); mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } }); const plan = await buildGatewayInstallPlan({ @@ -428,7 +371,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }); it("config env vars override .env file vars", async () => { - writeDotEnv("MY_KEY=from-dotenv\n"); + await writeStateDirDotEnv("MY_KEY=from-dotenv\n", { + stateDir: path.join(tmpDir, ".openclaw"), + }); mockNodeGatewayPlanFixture({ serviceEnvironment: {} }); const plan = await buildGatewayInstallPlan({ @@ -448,7 +393,9 @@ describe("buildGatewayInstallPlan — dotenv merge", () => { }); it("service env overrides .env file vars", async () => { - writeDotEnv("HOME=/from-dotenv\n"); + await writeStateDirDotEnv("HOME=/from-dotenv\n", { + stateDir: path.join(tmpDir, ".openclaw"), + }); mockNodeGatewayPlanFixture({ serviceEnvironment: { HOME: "/from-service" }, }); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 3797ab8ee09..62d18ea7d0d 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -1,22 +1,13 @@ -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 { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.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, - normalizeEnvVarKey, -} from "../infra/host-env-security.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, @@ -27,45 +18,6 @@ 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 [rawKey, value] of Object.entries(parsed)) { - if (!value?.trim()) { - continue; - } - const key = normalizeEnvVarKey(rawKey, { portable: true }); - if (!key) { - continue; - } - if (isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key)) { - continue; - } - entries[key] = value; - } - return entries; -} - export type GatewayInstallPlan = { programArguments: string[]; workingDirectory?: string; @@ -99,6 +51,26 @@ function collectAuthProfileServiceEnvVars(params: { return entries; } +function buildGatewayInstallEnvironment(params: { + env: Record; + config?: OpenClawConfig; + authStore?: AuthProfileStore; + serviceEnvironment: Record; +}): Record { + const environment: Record = { + ...collectDurableServiceEnvVars({ + env: params.env, + config: params.config, + }), + ...collectAuthProfileServiceEnvVars({ + env: params.env, + authStore: params.authStore, + }), + }; + Object.assign(environment, params.serviceEnvironment); + return environment; +} + export async function buildGatewayInstallPlan(params: { env: Record; port: number; @@ -146,17 +118,16 @@ export async function buildGatewayInstallPlan(params: { // 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({ + return { + programArguments, + workingDirectory, + environment: buildGatewayInstallEnvironment({ env: params.env, + config: params.config, authStore: params.authStore, + serviceEnvironment, }), }; - Object.assign(environment, serviceEnvironment); - - return { programArguments, workingDirectory, environment }; } export function gatewayInstallErrorHint(platform = process.platform): string { diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 8dc30207bd0..5946dee10d0 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -257,6 +257,38 @@ describe("resolveGatewayInstallToken", () => { expect(writeConfigFileMock).not.toHaveBeenCalled(); }); + it("passes the install env through to gateway auth resolution", async () => { + const env = { + OPENCLAW_GATEWAY_PASSWORD: "dotenv-password", // pragma: allowlist secret + } as NodeJS.ProcessEnv; + shouldRequireGatewayTokenForInstallMock.mockReturnValue(false); + resolveGatewayAuthMock.mockReturnValue({ + mode: "password", + token: undefined, + password: undefined, + allowTailscale: false, + }); + + const result = await resolveGatewayInstallToken({ + config: { + gateway: { auth: {} }, + } as OpenClawConfig, + env, + autoGenerateWhenMissing: true, + persistGeneratedToken: true, + }); + + expect(resolveGatewayAuthMock).toHaveBeenCalledWith({ + authConfig: {}, + env, + tailscaleMode: "off", + }); + expect(result.token).toBeUndefined(); + expect(result.unavailableReason).toBeUndefined(); + expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); + expect(writeConfigFileMock).not.toHaveBeenCalled(); + }); + it("skips token SecretRef resolution when token auth is not required", async () => { const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }; resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef }); diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 2f9e86bd867..5664cc8187d 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -24,6 +24,91 @@ export type GatewayInstallTokenResolution = { warnings: string[]; }; +function resolveConfiguredGatewayInstallToken(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + explicitToken?: string; + tokenRef: unknown; +}): string | undefined { + const configToken = + params.tokenRef || typeof params.config.gateway?.auth?.token !== "string" + ? undefined + : params.config.gateway.auth.token.trim() || undefined; + const explicitToken = params.explicitToken?.trim() || undefined; + const envToken = readGatewayTokenEnv(params.env); + return explicitToken || configToken || (params.tokenRef ? undefined : envToken); +} + +async function validateGatewayInstallTokenSecretRef(params: { + tokenRef: NonNullable["ref"]>; + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Promise { + try { + const resolved = await resolveSecretRefValues([params.tokenRef], { + config: params.config, + env: params.env, + }); + const value = resolved.get(secretRefKey(params.tokenRef)); + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error("gateway.auth.token resolved to an empty or non-string value."); + } + return undefined; + } catch (err) { + return `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`; + } +} + +async function maybePersistAutoGeneratedGatewayInstallToken(params: { + token: string; + config: OpenClawConfig; + warnings: string[]; +}): Promise { + try { + const snapshot = await readConfigFileSnapshot(); + if (snapshot.exists && !snapshot.valid) { + params.warnings.push( + "Warning: config file exists but is invalid; skipping token persistence.", + ); + return params.token; + } + + const baseConfig = snapshot.exists ? snapshot.config : {}; + const existingTokenRef = resolveSecretInputRef({ + value: baseConfig.gateway?.auth?.token, + defaults: baseConfig.secrets?.defaults, + }).ref; + const baseConfigToken = + existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string" + ? undefined + : baseConfig.gateway.auth.token.trim() || undefined; + if (!existingTokenRef && !baseConfigToken) { + await writeConfigFile({ + ...baseConfig, + gateway: { + ...baseConfig.gateway, + auth: { + ...baseConfig.gateway?.auth, + mode: baseConfig.gateway?.auth?.mode ?? "token", + token: params.token, + }, + }, + }); + return params.token; + } + if (baseConfigToken) { + return baseConfigToken; + } + params.warnings.push( + "Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.", + ); + return undefined; + } catch (err) { + params.warnings.push(`Warning: could not persist token to config: ${String(err)}`); + return params.token; + } +} + function formatAmbiguousGatewayAuthModeReason(): string { return [ "gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.", @@ -41,12 +126,6 @@ export async function resolveGatewayInstallToken( defaults: cfg.secrets?.defaults, }).ref; const tokenRefConfigured = Boolean(tokenRef); - const configToken = - tokenRef || typeof cfg.gateway?.auth?.token !== "string" - ? undefined - : cfg.gateway.auth.token.trim() || undefined; - const explicitToken = options.explicitToken?.trim() || undefined; - const envToken = readGatewayTokenEnv(options.env); if (hasAmbiguousGatewayAuthModeConfig(cfg)) { return { @@ -59,29 +138,30 @@ export async function resolveGatewayInstallToken( const resolvedAuth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, + env: options.env, tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", }); const needsToken = shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale; - let token: string | undefined = explicitToken || configToken || (tokenRef ? undefined : envToken); + let token = resolveConfiguredGatewayInstallToken({ + config: cfg, + env: options.env, + explicitToken: options.explicitToken, + tokenRef, + }); let unavailableReason: string | undefined; if (tokenRef && !token && needsToken) { - try { - const resolved = await resolveSecretRefValues([tokenRef], { - config: cfg, - env: options.env, - }); - const value = resolved.get(secretRefKey(tokenRef)); - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error("gateway.auth.token resolved to an empty or non-string value."); - } + unavailableReason = await validateGatewayInstallTokenSecretRef({ + tokenRef, + config: cfg, + env: options.env, + }); + if (!unavailableReason) { warnings.push( "gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.", ); - } catch (err) { - unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`; } } @@ -96,45 +176,11 @@ export async function resolveGatewayInstallToken( ); if (persistGeneratedToken) { - // Persist token in config so daemon and CLI share a stable credential source. - try { - const snapshot = await readConfigFileSnapshot(); - if (snapshot.exists && !snapshot.valid) { - warnings.push("Warning: config file exists but is invalid; skipping token persistence."); - } else { - const baseConfig = snapshot.exists ? snapshot.config : {}; - const existingTokenRef = resolveSecretInputRef({ - value: baseConfig.gateway?.auth?.token, - defaults: baseConfig.secrets?.defaults, - }).ref; - const baseConfigToken = - existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string" - ? undefined - : baseConfig.gateway.auth.token.trim() || undefined; - if (!existingTokenRef && !baseConfigToken) { - await writeConfigFile({ - ...baseConfig, - gateway: { - ...baseConfig.gateway, - auth: { - ...baseConfig.gateway?.auth, - mode: baseConfig.gateway?.auth?.mode ?? "token", - token, - }, - }, - }); - } else if (baseConfigToken) { - token = baseConfigToken; - } else { - token = undefined; - warnings.push( - "Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.", - ); - } - } - } catch (err) { - warnings.push(`Warning: could not persist token to config: ${String(err)}`); - } + token = await maybePersistAutoGeneratedGatewayInstallToken({ + token, + config: cfg, + warnings, + }); } }