diff --git a/CHANGELOG.md b/CHANGELOG.md index 3672c73be..257f6da23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2. - Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup. - Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs. +- Config: support env var substitution in config values. (#1044) — thanks @sebslight. ### Fixes - Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes. diff --git a/src/config/env-substitution.test.ts b/src/config/env-substitution.test.ts index 5027c237a..53dde329f 100644 --- a/src/config/env-substitution.test.ts +++ b/src/config/env-substitution.test.ts @@ -129,10 +129,25 @@ describe("resolveConfigEnvVars", () => { expect(result).toEqual({ key: "resolved/${LITERAL}" }); }); + it("handles escaped and unescaped of the same var (escaped first)", () => { + const result = resolveConfigEnvVars({ key: "$${FOO} ${FOO}" }, { FOO: "bar" }); + expect(result).toEqual({ key: "${FOO} bar" }); + }); + + it("handles escaped and unescaped of the same var (unescaped first)", () => { + const result = resolveConfigEnvVars({ key: "${FOO} $${FOO}" }, { FOO: "bar" }); + expect(result).toEqual({ key: "bar ${FOO}" }); + }); + it("handles multiple escaped vars", () => { const result = resolveConfigEnvVars({ key: "$${A}:$${B}" }, {}); expect(result).toEqual({ key: "${A}:${B}" }); }); + + it("does not unescape $${VAR} sequences from env values", () => { + const result = resolveConfigEnvVars({ key: "${FOO}" }, { FOO: "$${BAR}" }); + expect(result).toEqual({ key: "$${BAR}" }); + }); }); describe("non-matching patterns unchanged", () => { diff --git a/src/config/env-substitution.ts b/src/config/env-substitution.ts index 3332fa3b8..0fee5386c 100644 --- a/src/config/env-substitution.ts +++ b/src/config/env-substitution.ts @@ -22,10 +22,7 @@ // Pattern for valid uppercase env var names: starts with letter or underscore, // followed by letters, numbers, or underscores (all uppercase) -const ENV_VAR_PATTERN = /\$\{([A-Z_][A-Z0-9_]*)\}/g; - -// Pattern for escaped env vars: $${...} -> ${...} -const ESCAPED_PATTERN = /\$\$\{([A-Z_][A-Z0-9_]*)\}/g; +const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; export class MissingEnvVarError extends Error { constructor( @@ -46,26 +43,65 @@ function isPlainObject(value: unknown): value is Record { ); } +function readEnvVarName( + value: string, + start: number, +): { name: string | null; end: number } { + const end = value.indexOf("}", start); + if (end === -1) { + return { name: null, end: -1 }; + } + + const name = value.slice(start, end); + if (!ENV_VAR_NAME_PATTERN.test(name)) { + return { name: null, end: -1 }; + } + + return { name, end }; +} + function substituteString(value: string, env: NodeJS.ProcessEnv, configPath: string): string { - // First pass: substitute real env vars - const substituted = value.replace(ENV_VAR_PATTERN, (match, varName: string) => { - // Check if this is actually an escaped var (preceded by $) - // We handle this in the second pass, so check the original string - const idx = value.indexOf(match); - if (idx > 0 && value[idx - 1] === "$") { - // This will be handled by ESCAPED_PATTERN, return as-is for now - return match; + let result = ""; + + for (let i = 0; i < value.length; i += 1) { + const char = value[i]; + if (char !== "$") { + result += char; + continue; } - const envValue = env[varName]; - if (envValue === undefined || envValue === "") { - throw new MissingEnvVarError(varName, configPath); - } - return envValue; - }); + const next = value[i + 1]; + const afterNext = value[i + 2]; - // Second pass: convert escaped $${VAR} to literal ${VAR} - return substituted.replace(ESCAPED_PATTERN, (_, varName: string) => `\${${varName}}`); + // Escaped: $${VAR} -> ${VAR} + if (next === "$" && afterNext === "{") { + const { name, end } = readEnvVarName(value, i + 3); + if (name !== null) { + result += `\${${name}}`; + i = end; + continue; + } + } + + // Substitution: ${VAR} -> value + if (next === "{") { + const { name, end } = readEnvVarName(value, i + 2); + if (name !== null) { + const envValue = env[name]; + if (envValue === undefined || envValue === "") { + throw new MissingEnvVarError(name, configPath); + } + result += envValue; + i = end; + continue; + } + } + + // Leave untouched if not a recognized pattern + result += char; + } + + return result; } function substituteAny(value: unknown, env: NodeJS.ProcessEnv, path: string): unknown {