feat(config): add env var substitution in config values

Support ${VAR_NAME} syntax in any config string value, substituted at
config load time. Useful for referencing API keys and secrets from
environment variables without hardcoding them in the config file.

- Only uppercase env vars matched: [A-Z_][A-Z0-9_]*
- Missing/empty env vars throw MissingEnvVarError with path context
- Escape with $${VAR} to output literal ${VAR}
- Works with $include (included files also get substitution)

Closes #1009
This commit is contained in:
Sebastian
2026-01-16 16:32:07 -05:00
parent d887027e95
commit a36735b913
5 changed files with 469 additions and 4 deletions

View File

@@ -19,6 +19,7 @@ import {
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js";
@@ -30,6 +31,7 @@ import { ClawdbotSchema } from "./zod-schema.js";
// Re-export for backwards compatibility
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
export { MissingEnvVarError } from "./env-substitution.js";
const SHELL_ENV_EXPECTED_KEYS = [
"OPENAI_API_KEY",
@@ -219,8 +221,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parseJson: (raw) => deps.json5.parse(raw),
});
const migrated = applyLegacyMigrations(resolved);
const resolvedConfig = migrated.next ?? resolved;
// Substitute ${VAR} env var references
const substituted = resolveConfigEnvVars(resolved, deps.env);
const migrated = applyLegacyMigrations(substituted);
const resolvedConfig = migrated.next ?? substituted;
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
@@ -346,8 +351,30 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
const migrated = applyLegacyMigrations(resolved);
const resolvedConfigRaw = migrated.next ?? resolved;
// Substitute ${VAR} env var references
let substituted: unknown;
try {
substituted = resolveConfigEnvVars(resolved, deps.env);
} catch (err) {
const message =
err instanceof MissingEnvVarError
? err.message
: `Env var substitution failed: ${String(err)}`;
return {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
valid: false,
config: {},
hash,
issues: [{ path: "", message }],
legacyIssues: [],
};
}
const migrated = applyLegacyMigrations(substituted);
const resolvedConfigRaw = migrated.next ?? substituted;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw);