From 59f89678b21b0b7b14631180312e562d37560b50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 22:37:06 +0100 Subject: [PATCH] feat: allow inline env vars in config --- CHANGELOG.md | 1 + docs/concepts/models.md | 2 +- docs/environment.md | 60 +++++++++++++++++++++ docs/gateway/configuration-examples.md | 4 ++ docs/gateway/configuration.md | 22 ++++++++ docs/start/faq.md | 13 +++++ src/config/config.test.ts | 74 ++++++++++++++++++++++++++ src/config/io.ts | 31 +++++++++++ src/config/types.ts | 8 +++ src/config/zod-schema.ts | 2 + 10 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 docs/environment.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ac079428c..fcb2734e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 399d9d5ea..0d1367be3 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -105,7 +105,7 @@ and an **Auth overview** section showing which providers have profiles/env/model Input - OpenRouter `/models` list (filter `:free`) -- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY` +- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY` (see [/environment](/environment)) - Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates` - Probe controls: `--timeout`, `--concurrency` diff --git a/docs/environment.md b/docs/environment.md new file mode 100644 index 000000000..60d2921ff --- /dev/null +++ b/docs/environment.md @@ -0,0 +1,60 @@ +--- +summary: "Where Clawdbot loads environment variables and the precedence order" +read_when: + - You need to know which env vars are loaded, and in what order + - You are debugging missing API keys in the Gateway + - You are documenting provider auth or deployment environments +--- +# Environment variables + +Clawdbot pulls environment variables from multiple sources. The rule is **never override existing values**. + +## Precedence (highest → lowest) + +1) **Process environment** (what the Gateway process already has from the parent shell/daemon). +2) **`.env` in the current working directory** (dotenv default; does not override). +3) **Global `.env`** at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`; does not override). +4) **Config `env` block** in `~/.clawdbot/clawdbot.json` (applied only if missing). +5) **Optional login-shell import** (`env.shellEnv.enabled` or `CLAWDBOT_LOAD_SHELL_ENV=1`), applied only for missing expected keys. + +If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled. + +## Config `env` block + +Two equivalent ways to set inline env vars (both are non-overriding): + +```json5 +{ + env: { + OPENROUTER_API_KEY: "sk-or-...", + vars: { + GROQ_API_KEY: "gsk-..." + } + } +} +``` + +## Shell env import + +`env.shellEnv` runs your login shell and imports only **missing** expected keys: + +```json5 +{ + env: { + shellEnv: { + enabled: true, + timeoutMs: 15000 + } + } +} +``` + +Env var equivalents: +- `CLAWDBOT_LOAD_SHELL_ENV=1` +- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000` + +## Related + +- [Gateway configuration](/gateway/configuration) +- [FAQ: env vars and .env loading](/start/faq#env-vars-and-env-loading) +- [Models overview](/concepts/models) diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 45b414ff0..2a0e110b8 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -48,6 +48,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number. { // Environment + shell env: { + OPENROUTER_API_KEY: "sk-or-...", + vars: { + GROQ_API_KEY: "gsk-..." + }, shellEnv: { enabled: true, timeoutMs: 15000 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 86880352a..62b3e619f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -91,6 +91,22 @@ Additionally, it loads: Neither `.env` file overrides existing env vars. +You can also provide inline env vars in config. These are only applied if the +process env is missing the key (same non-overriding rule): + +```json5 +{ + env: { + OPENROUTER_API_KEY: "sk-or-...", + vars: { + GROQ_API_KEY: "gsk-..." + } + } +} +``` + +See [/environment](/environment) for full precedence and sources. + ### `env.shellEnv` (optional) Opt-in convenience: if enabled and none of the expected keys are set yet, CLAWDBOT runs your login shell and imports only the missing expected keys (never overrides). @@ -1178,6 +1194,12 @@ Example: } ``` +Notes: +- `agent.elevated` is **global** (not per-agent). Availability is based on sender allowlists. +- `/elevated on|off` stores state per session key; inline directives apply to a single message. +- Elevated `bash` runs on the host and bypasses sandboxing. +- Tool policy still applies; if `bash` is denied, elevated cannot be used. + `agent.maxConcurrent` sets the maximum number of embedded agent runs that can execute in parallel across sessions. Each session is still serialized (one run per session key at a time). Default: 1. diff --git a/docs/start/faq.md b/docs/start/faq.md index a02d226a7..353c47a7a 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -185,6 +185,19 @@ Clawdbot reads env vars from the parent process (shell, launchd/systemd, CI, etc Neither `.env` file overrides existing env vars. +You can also define inline env vars in config (applied only if missing from the process env): + +```json5 +{ + env: { + OPENROUTER_API_KEY: "sk-or-...", + vars: { GROQ_API_KEY: "gsk-..." } + } +} +``` + +See [/environment](/environment) for full precedence and sources. + ### “I started the Gateway via a daemon and my env vars disappeared.” What now? Two common fixes: diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 28a7c4f71..8582b750c 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -327,6 +327,80 @@ describe("config identity defaults", () => { }); }); +describe("config env vars", () => { + it("applies env vars from env block when missing", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + env: { OPENROUTER_API_KEY: "config-key" }, + }, + null, + 2, + ), + "utf-8", + ); + + await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => { + const { loadConfig } = await import("./config.js"); + loadConfig(); + expect(process.env.OPENROUTER_API_KEY).toBe("config-key"); + }); + }); + }); + + it("does not override existing env vars", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + env: { OPENROUTER_API_KEY: "config-key" }, + }, + null, + 2, + ), + "utf-8", + ); + + await withEnvOverride({ OPENROUTER_API_KEY: "existing-key" }, async () => { + const { loadConfig } = await import("./config.js"); + loadConfig(); + expect(process.env.OPENROUTER_API_KEY).toBe("existing-key"); + }); + }); + }); + + it("applies env vars from env.vars when missing", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + env: { vars: { GROQ_API_KEY: "gsk-config" } }, + }, + null, + 2, + ), + "utf-8", + ); + + await withEnvOverride({ GROQ_API_KEY: undefined }, async () => { + const { loadConfig } = await import("./config.js"); + loadConfig(); + expect(process.env.GROQ_API_KEY).toBe("gsk-config"); + }); + }); + }); +}); + describe("config pruning defaults", () => { it("defaults contextPruning mode to adaptive", async () => { await withTempHome(async (home) => { diff --git a/src/config/io.ts b/src/config/io.ts index d361484e2..13cf63ef5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -41,6 +41,7 @@ const SHELL_ENV_EXPECTED_KEYS = [ "ANTHROPIC_OAUTH_TOKEN", "GEMINI_API_KEY", "ZAI_API_KEY", + "OPENROUTER_API_KEY", "MINIMAX_API_KEY", "ELEVENLABS_API_KEY", "TELEGRAM_BOT_TOKEN", @@ -78,6 +79,34 @@ function warnOnConfigMiskeys( } } +function applyConfigEnv( + cfg: ClawdbotConfig, + env: NodeJS.ProcessEnv, +): void { + const envConfig = cfg.env; + if (!envConfig) return; + + const entries: Record = {}; + + if (envConfig.vars) { + for (const [key, value] of Object.entries(envConfig.vars)) { + if (!value) continue; + entries[key] = value; + } + } + + for (const [key, value] of Object.entries(envConfig)) { + if (key === "shellEnv" || key === "vars") continue; + if (typeof value !== "string" || !value.trim()) continue; + entries[key] = value; + } + + for (const [key, value] of Object.entries(entries)) { + if (env[key]?.trim()) continue; + env[key] = value; + } +} + function resolveConfigPathForDeps(deps: Required): string { if (deps.configPath) return deps.configPath; return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir)); @@ -155,6 +184,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { throw new DuplicateAgentDirError(duplicates); } + applyConfigEnv(cfg, deps.env); + const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true; diff --git a/src/config/types.ts b/src/config/types.ts index cfbf37bd7..0a9245058 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1031,6 +1031,14 @@ export type ClawdbotConfig = { /** Timeout for the login shell exec (ms). Default: 15000. */ timeoutMs?: number; }; + /** Inline env vars to apply when not already present in the process env. */ + vars?: Record; + /** Sugar: allow env vars directly under env (string values only). */ + [key: string]: + | string + | Record + | { enabled?: boolean; timeoutMs?: number } + | undefined; }; identity?: { name?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 77a1b92ee..554c9d390 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -793,7 +793,9 @@ export const ClawdbotSchema = z.object({ timeoutMs: z.number().int().nonnegative().optional(), }) .optional(), + vars: z.record(z.string()).optional(), }) + .catchall(z.string()) .optional(), identity: z .object({