feat: allow inline env vars in config

This commit is contained in:
Peter Steinberger
2026-01-08 22:37:06 +01:00
parent 9a1267b530
commit 59f89678b2
10 changed files with 216 additions and 1 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased ## 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. - Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - 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 - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete

View File

@@ -105,7 +105,7 @@ and an **Auth overview** section showing which providers have profiles/env/model
Input Input
- OpenRouter `/models` list (filter `:free`) - 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` - Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
- Probe controls: `--timeout`, `--concurrency` - Probe controls: `--timeout`, `--concurrency`

60
docs/environment.md Normal file
View File

@@ -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)

View File

@@ -48,6 +48,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
{ {
// Environment + shell // Environment + shell
env: { env: {
OPENROUTER_API_KEY: "sk-or-...",
vars: {
GROQ_API_KEY: "gsk-..."
},
shellEnv: { shellEnv: {
enabled: true, enabled: true,
timeoutMs: 15000 timeoutMs: 15000

View File

@@ -91,6 +91,22 @@ Additionally, it loads:
Neither `.env` file overrides existing env vars. 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) ### `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). 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 `agent.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1. per session key at a time). Default: 1.

View File

@@ -185,6 +185,19 @@ Clawdbot reads env vars from the parent process (shell, launchd/systemd, CI, etc
Neither `.env` file overrides existing env vars. 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? ### “I started the Gateway via a daemon and my env vars disappeared.” What now?
Two common fixes: Two common fixes:

View File

@@ -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", () => { describe("config pruning defaults", () => {
it("defaults contextPruning mode to adaptive", async () => { it("defaults contextPruning mode to adaptive", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {

View File

@@ -41,6 +41,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
"ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN",
"GEMINI_API_KEY", "GEMINI_API_KEY",
"ZAI_API_KEY", "ZAI_API_KEY",
"OPENROUTER_API_KEY",
"MINIMAX_API_KEY", "MINIMAX_API_KEY",
"ELEVENLABS_API_KEY", "ELEVENLABS_API_KEY",
"TELEGRAM_BOT_TOKEN", "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<string, string> = {};
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<ConfigIoDeps>): string { function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
if (deps.configPath) return deps.configPath; if (deps.configPath) return deps.configPath;
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir)); return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
@@ -155,6 +184,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
throw new DuplicateAgentDirError(duplicates); throw new DuplicateAgentDirError(duplicates);
} }
applyConfigEnv(cfg, deps.env);
const enabled = const enabled =
shouldEnableShellEnvFallback(deps.env) || shouldEnableShellEnvFallback(deps.env) ||
cfg.env?.shellEnv?.enabled === true; cfg.env?.shellEnv?.enabled === true;

View File

@@ -1031,6 +1031,14 @@ export type ClawdbotConfig = {
/** Timeout for the login shell exec (ms). Default: 15000. */ /** Timeout for the login shell exec (ms). Default: 15000. */
timeoutMs?: number; timeoutMs?: number;
}; };
/** Inline env vars to apply when not already present in the process env. */
vars?: Record<string, string>;
/** Sugar: allow env vars directly under env (string values only). */
[key: string]:
| string
| Record<string, string>
| { enabled?: boolean; timeoutMs?: number }
| undefined;
}; };
identity?: { identity?: {
name?: string; name?: string;

View File

@@ -793,7 +793,9 @@ export const ClawdbotSchema = z.object({
timeoutMs: z.number().int().nonnegative().optional(), timeoutMs: z.number().int().nonnegative().optional(),
}) })
.optional(), .optional(),
vars: z.record(z.string()).optional(),
}) })
.catchall(z.string())
.optional(), .optional(),
identity: z identity: z
.object({ .object({