diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8f1f3fd..95c9c8c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639 - macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan - WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj +- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`. - Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas). - Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`). - Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed). diff --git a/README.md b/README.md index cd600de6f..8bbb8257c 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ Minimal `~/.clawdbot/clawdbot.json`: } ``` +Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values. + ### WhatsApp - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). diff --git a/src/index.ts b/src/index.ts index cc9d1f58a..459b3057e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; -import dotenv from "dotenv"; import { getReplyFromConfig } from "./auto-reply/reply.js"; import { applyTemplate } from "./auto-reply/templating.js"; import { createDefaultDeps } from "./cli/deps.js"; @@ -17,6 +16,7 @@ import { saveSessionStore, } from "./config/sessions.js"; import { ensureBinary } from "./infra/binaries.js"; +import { loadDotEnv } from "./infra/dotenv.js"; import { normalizeEnv } from "./infra/env.js"; import { isMainModule } from "./infra/is-main.js"; import { ensureClawdbotCliOnPath } from "./infra/path-env.js"; @@ -32,7 +32,7 @@ import { runCommandWithTimeout, runExec } from "./process/exec.js"; import { monitorWebProvider } from "./provider-web.js"; import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js"; -dotenv.config({ quiet: true }); +loadDotEnv({ quiet: true }); normalizeEnv(); ensureClawdbotCliOnPath(); diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts new file mode 100644 index 000000000..902eafe62 --- /dev/null +++ b/src/infra/dotenv.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { loadDotEnv } from "./dotenv.js"; + +async function writeEnvFile(filePath: string, contents: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf8"); +} + +describe("loadDotEnv", () => { + it("loads ~/.clawdbot/.env as fallback without overriding CWD .env", async () => { + const prevEnv = { ...process.env }; + const prevCwd = process.cwd(); + + const base = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-dotenv-test-"), + ); + const cwdDir = path.join(base, "cwd"); + const stateDir = path.join(base, "state"); + + process.env.CLAWDBOT_STATE_DIR = stateDir; + + await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\nBAR=1\n"); + await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n"); + + process.chdir(cwdDir); + delete process.env.FOO; + delete process.env.BAR; + + loadDotEnv({ quiet: true }); + + expect(process.env.FOO).toBe("from-cwd"); + expect(process.env.BAR).toBe("1"); + + process.chdir(prevCwd); + for (const key of Object.keys(process.env)) { + if (!(key in prevEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(prevEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("does not override an already-set env var from the shell", async () => { + const prevEnv = { ...process.env }; + const prevCwd = process.cwd(); + + const base = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-dotenv-test-"), + ); + const cwdDir = path.join(base, "cwd"); + const stateDir = path.join(base, "state"); + + process.env.CLAWDBOT_STATE_DIR = stateDir; + process.env.FOO = "from-shell"; + + await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n"); + await writeEnvFile(path.join(cwdDir, ".env"), "FOO=from-cwd\n"); + + process.chdir(cwdDir); + + loadDotEnv({ quiet: true }); + + expect(process.env.FOO).toBe("from-shell"); + + process.chdir(prevCwd); + for (const key of Object.keys(process.env)) { + if (!(key in prevEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(prevEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +}); diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts new file mode 100644 index 000000000..6a0669dec --- /dev/null +++ b/src/infra/dotenv.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; + +import dotenv from "dotenv"; + +import { resolveConfigDir } from "../utils.js"; + +export function loadDotEnv(opts?: { quiet?: boolean }) { + const quiet = opts?.quiet ?? true; + + // Load from process CWD first (dotenv default). + dotenv.config({ quiet }); + + // Then load global fallback: ~/.clawdbot/.env (or CLAWDBOT_STATE_DIR/.env), + // without overriding any env vars already present. + const globalEnvPath = path.join(resolveConfigDir(process.env), ".env"); + if (!fs.existsSync(globalEnvPath)) return; + + dotenv.config({ quiet, path: globalEnvPath, override: false }); +} diff --git a/src/macos/relay.ts b/src/macos/relay.ts index 670d7316c..8448d92b0 100644 --- a/src/macos/relay.ts +++ b/src/macos/relay.ts @@ -34,8 +34,8 @@ async function main() { await patchBunLongForProtobuf(); - const { default: dotenv } = await import("dotenv"); - dotenv.config({ quiet: true }); + const { loadDotEnv } = await import("../infra/dotenv.js"); + loadDotEnv({ quiet: true }); const { ensureClawdbotCliOnPath } = await import("../infra/path-env.js"); ensureClawdbotCliOnPath();