From fcfa4bf0c121c30a33d4aa57bc41c1fffa84ba85 Mon Sep 17 00:00:00 2001 From: Mitchell Etzel <16581152+etzelm@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:28:17 -0700 Subject: [PATCH] feat(config): support ${VAR:-default} in config env substitution (#155164) Support inline environment defaults when a variable is unset or empty, using the same token grammar for substitution and authored config write-back. Preserve escapes, unsupported forms, and escaped-reference activation guards. Include regression coverage and user documentation. This intentionally changes previously literal fallback-shaped values on upgrade. Operators preserving such an existing Gateway credential can supply its exact bytes through OPENCLAW_GATEWAY_TOKEN, remove the inline token, and retain token mode; isolated production loader/writeback/auth-boundary proof verifies that transition. Closes #149551 Co-authored-by: Mitchell Etzel <16581152+etzelm@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> --- docs/gateway/config-secrets-env.md | 24 ++++ src/config/config.env-vars.test.ts | 23 +++ src/config/env-preserve-authored.ts | 36 ++--- src/config/env-preserve.test.ts | 63 ++++++++ src/config/env-preserve.ts | 11 +- src/config/env-substitution.test.ts | 214 ++++++++++++++++++++++++++++ src/config/env-substitution.ts | 113 +++++++++++++-- 7 files changed, 443 insertions(+), 41 deletions(-) diff --git a/docs/gateway/config-secrets-env.md b/docs/gateway/config-secrets-env.md index 1cd87ffa9f9c..a322d6e7e94f 100644 --- a/docs/gateway/config-secrets-env.md +++ b/docs/gateway/config-secrets-env.md @@ -52,6 +52,30 @@ Reference env vars in any config string with `${VAR_NAME}`: - Escape with `$${VAR}` to produce a literal `${VAR}` value. - Works with `$include`. +#### Default values + +Add a fallback with `${VAR_NAME:-fallback}`. It is used when the variable is unset or empty: + +```json5 +{ + mcp: { + servers: { + nautobot: { + env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" }, + }, + }, + }, +} +``` + +- A reference with a fallback always resolves, so it never emits a missing-var warning. +- An empty fallback is allowed: `${VAR:-}` resolves to an empty string. +- The fallback is literal text. It must not contain `$` or `{`, so a nested reference such as `${A:-${B}}` is not a fallback expression; it stays literal and only the inner `${B}` substitutes. +- Only `:-` is supported. Other shell operators (`:=`, `:?`, `:+`, `-`, `#`, `%`, `/`, `^`) are left untouched as literal text. Bash's `-` is omitted deliberately: OpenClaw treats unset and empty as the same state, so it could not differ from `:-`. +- Escaping still wins: `$${VAR:-x}` produces the literal `${VAR:-x}` and reads nothing from the environment. +- Authored fallbacks survive config write-back; OpenClaw restores `${VAR:-fallback}` rather than inlining the value it resolved to. +- A fallback is config text, not a secret store. Put credentials in `env.vars` or a [SecretRef](#secretref) and reference them bare. + --- ## Secrets diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 560015e091d9..a87a6c2c409d 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -168,6 +168,29 @@ describe("config env vars", () => { }); }); + it("skips env.vars values holding an unresolved reference, bare or with a default", async () => { + await withEnvAsync( + { BARE_REF: undefined, DEFAULT_REF: undefined, PLAIN_VALUE: undefined }, + async () => { + applyConfigEnvVars({ + env: { + vars: { + BARE_REF: "${SOME_VAR}", + DEFAULT_REF: "${SOME_VAR:-fallback}", + PLAIN_VALUE: "literal", + }, + }, + } as OpenClawConfig); + + // applyConfigEnvVars runs before env substitution, so a reference that is still + // a template would otherwise be exported into process.env as literal "${...}" text. + expect(process.env.BARE_REF).toBeUndefined(); + expect(process.env.DEFAULT_REF).toBeUndefined(); + expect(process.env.PLAIN_VALUE).toBe("literal"); + }, + ); + }); + it("can build a merged runtime env without mutating process.env", async () => { await withEnvAsync({ OPENROUTER_API_KEY: undefined }, async () => { const merged = createConfigRuntimeEnv({ diff --git a/src/config/env-preserve-authored.ts b/src/config/env-preserve-authored.ts index 18f6cb38608b..c77302a3139e 100644 --- a/src/config/env-preserve-authored.ts +++ b/src/config/env-preserve-authored.ts @@ -1,33 +1,21 @@ import { isDeepStrictEqual } from "node:util"; import { isPlainObject } from "../infra/plain-object.js"; -import { containsEnvVarReference } from "./env-substitution.js"; - -const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; +import { containsEnvVarReference, scanEnvTemplateTokens } from "./env-substitution.js"; +/** + * Keyed by bare variable name, deliberately: `${VAR}` and `${VAR:-x}` are one identity + * here. These counts feed fail-closed guards against a write turning an authored + * `$${VAR}` literal into an active reference. Keying on the authored text instead would + * let `$${VAR}` to `${VAR:-x}` slip past the guard that already rejects `$${VAR}` to + * `${VAR}`. + */ type AuthoredEnvRef = { kind: "escaped" | "unescaped"; name: string }; function collectAuthoredEnvRefs(value: string): AuthoredEnvRef[] { - const refs: AuthoredEnvRef[] = []; - for (let index = 0; index < value.length; index += 1) { - if (value[index] !== "$") { - continue; - } - const isEscaped = value[index + 1] === "$" && value[index + 2] === "{"; - const nameStart = index + (isEscaped ? 3 : 2); - if (!isEscaped && value[index + 1] !== "{") { - continue; - } - const nameEnd = value.indexOf("}", nameStart); - if (nameEnd === -1 || !ENV_VAR_NAME_PATTERN.test(value.slice(nameStart, nameEnd))) { - continue; - } - refs.push({ - kind: isEscaped ? "escaped" : "unescaped", - name: value.slice(nameStart, nameEnd), - }); - index = nameEnd; - } - return refs; + return scanEnvTemplateTokens(value).map((token) => ({ + kind: token.kind === "escaped" ? ("escaped" as const) : ("unescaped" as const), + name: token.name, + })); } function hasEscapedEnvVarRef(value: string): boolean { diff --git a/src/config/env-preserve.test.ts b/src/config/env-preserve.test.ts index 9e49a6b12d76..1da44ca3a451 100644 --- a/src/config/env-preserve.test.ts +++ b/src/config/env-preserve.test.ts @@ -118,6 +118,50 @@ describe("restoreEnvVarRefs", () => { expect(result).toEqual({ value: "${API_TOKEN}:${OPTIONAL_SUFFIX}" }); }); + it("restores a ${VAR:-default} template that resolved from its fallback", () => { + // Without this, an authored fallback is inlined into openclaw.json on the next write + // and the operator's template is lost: the value resolves to "60", the writer sees a + // plain string, and nothing links it back to what was authored. + const incoming = { env: { NAUTOBOT_TIMEOUT: "60" } }; + const parsed = { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } }; + + const result = restoreEnvVarRefs(incoming, parsed, {}); + + expect(result).toEqual({ env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } }); + }); + + it("restores a ${VAR:-default} template that resolved from the environment", () => { + const incoming = { timeout: "90" }; + const parsed = { timeout: "${NAUTOBOT_TIMEOUT:-60}" }; + + const result = restoreEnvVarRefs(incoming, parsed, { NAUTOBOT_TIMEOUT: "90" }); + + expect(result).toEqual({ timeout: "${NAUTOBOT_TIMEOUT:-60}" }); + }); + + it("keeps a deliberate edit over a ${VAR:-default} template", () => { + const incoming = { timeout: "120" }; + const parsed = { timeout: "${NAUTOBOT_TIMEOUT:-60}" }; + + const result = restoreEnvVarRefs(incoming, parsed, {}); + + expect(result).toEqual({ timeout: "120" }); + }); + + it("restores composite and escaped ${VAR:-default} templates", () => { + expect( + restoreEnvVarRefs( + { url: "https://api.example.com/v1" }, + { url: "https://${API_HOST:-api.example.com}/v1" }, + {}, + ), + ).toEqual({ url: "https://${API_HOST:-api.example.com}/v1" }); + + expect(restoreEnvVarRefs({ literal: "${VAR:-x}" }, { literal: "$${VAR:-x}" }, {})).toEqual({ + literal: "$${VAR:-x}", + }); + }); + it("rejects structural changes to arrays containing environment references", () => { const duplicateEnv = { PLUGIN_A: "same-plugin", @@ -129,6 +173,25 @@ describe("restoreEnvVarRefs", () => { ).toThrow("Config write would reorder or modify an array containing environment references"); }); + it("rejects activating an escaped literal as a ${VAR:-default} reference", () => { + // Rewriting an entry the operator deliberately escaped into a live env read must fail + // closed. Write-back accounting keys refs by bare variable name so ${VAR:-x} is caught + // here; keying on the authored text would let this through. + expectEnvRefArrayMutationError(() => + restoreEnvVarRefs([{ v: "${VAR:-x}" }, { v: "other" }], [{ v: "$${VAR}" }, { v: "other" }], { + VAR: "from-env", + }), + ); + + // The unchanged round trip is not an activation: the incoming value is exactly what the + // escape resolves to, so the authored escape is restored rather than rejected. + expect( + restoreEnvVarRefs([{ v: "${VAR}" }, { v: "other" }], [{ v: "$${VAR}" }, { v: "other" }], { + VAR: "from-env", + }), + ).toEqual([{ v: "$${VAR}" }, { v: "other" }]); + }); + it("allows array edits when placeholders are escaped literals", () => { const result = restoreEnvVarRefs(["${ESCAPED}", "changed"], ["$${ESCAPED}", "literal"], {}); diff --git a/src/config/env-preserve.ts b/src/config/env-preserve.ts index 3c8ff6b16b81..3d76b6caf73a 100644 --- a/src/config/env-preserve.ts +++ b/src/config/env-preserve.ts @@ -8,7 +8,7 @@ import { containsUnaccountedActiveEscapedEnvRef, preservesAuthoredEscapedEnvRefs, } from "./env-preserve-authored.js"; -import { resolveConfigEnvVars } from "./env-substitution.js"; +import { resolveConfigEnvVars, scanEnvTemplateTokens } from "./env-substitution.js"; /** * Preserves `${VAR}` environment variable references during config write-back. @@ -26,8 +26,6 @@ import { resolveConfigEnvVars } from "./env-substitution.js"; * resolves to), the new value is kept as-is. */ -const ENV_VAR_PATTERN = /\$\{[A-Z_][A-Z0-9_]*\}/; - class EnvRefArrayMutationError extends Error { constructor() { super("Config write would reorder or modify an array containing environment references."); @@ -36,10 +34,13 @@ class EnvRefArrayMutationError extends Error { } /** - * Check if a string contains any `${VAR}` env var references. + * Check if a string contains any `${VAR}` env var references, escaped or not. + * + * Escaped `$${VAR}` counts: it still changes under substitution, so the authored text + * must be restored on write-back the same way an active reference is. */ function hasEnvVarRef(value: string): boolean { - return ENV_VAR_PATTERN.test(value); + return scanEnvTemplateTokens(value).length > 0; } type ArrayIdentityPath = string[]; diff --git a/src/config/env-substitution.test.ts b/src/config/env-substitution.test.ts index a4b0b136a661..51af855edffc 100644 --- a/src/config/env-substitution.test.ts +++ b/src/config/env-substitution.test.ts @@ -556,4 +556,218 @@ describe("resolveConfigEnvVars", () => { expectResolvedScenarios(scenarios); }); }); + + describe("default value syntax", () => { + it("resolves ${VAR:-default} from the fallback, the env value, or an empty fallback", () => { + const scenarios: SubstitutionScenario[] = [ + { + name: "filed case: unset var falls back to the default", + config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } }, + env: {}, + expected: { env: { NAUTOBOT_TIMEOUT: "60" } }, + }, + { + name: "set var wins over the default", + config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } }, + env: { NAUTOBOT_TIMEOUT: "90" }, + expected: { env: { NAUTOBOT_TIMEOUT: "90" } }, + }, + { + name: "empty var takes the default, matching existing missing-var semantics", + config: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } }, + env: { NAUTOBOT_TIMEOUT: "" }, + expected: { env: { NAUTOBOT_TIMEOUT: "60" } }, + }, + { + name: "empty fallback resolves to an empty string", + config: { key: "${OPTIONAL_SUFFIX:-}" }, + env: {}, + expected: { key: "" }, + }, + { + name: "fallback is used inline", + config: { key: "https://${API_HOST:-api.example.com}/v1" }, + env: {}, + expected: { key: "https://api.example.com/v1" }, + }, + { + name: "multiple references in one string mix resolved and fallback", + config: { key: "${API_HOST:-localhost}:${API_PORT:-8080}" }, + env: { API_HOST: "example.com" }, + expected: { key: "example.com:8080" }, + }, + { + name: "fallback text is preserved verbatim", + config: { key: "${VAR:-a b c}" }, + env: {}, + expected: { key: "a b c" }, + }, + { + name: "fallback may itself contain the operator", + config: { key: "${VAR:-:-}" }, + env: {}, + expected: { key: ":-" }, + }, + { + name: "fallback may start with a dash", + config: { key: "${VAR:--5}" }, + env: {}, + expected: { key: "-5" }, + }, + ]; + + expectResolvedScenarios(scenarios); + }); + + it("treats a fallback as a resolution, so no warning is collected", () => { + const warnings: EnvSubstitutionWarning[] = []; + const resolved = resolveConfigEnvVars( + { + mcp: { servers: { nautobot: { env: { NAUTOBOT_TIMEOUT: "${NAUTOBOT_TIMEOUT:-60}" } } } }, + }, + {}, + { onMissing: (warning) => warnings.push(warning) }, + ); + + expect(resolved).toEqual({ + mcp: { servers: { nautobot: { env: { NAUTOBOT_TIMEOUT: "60" } } } }, + }); + expect(warnings).toEqual([]); + }); + + it("does not throw MissingEnvVarError when a fallback is authored", () => { + expect(resolveConfigEnvVars({ key: "${ABSENT_VAR:-fallback}" }, {})).toEqual({ + key: "fallback", + }); + // A bare reference with no fallback keeps throwing. + expect(() => resolveConfigEnvVars({ key: "${ABSENT_VAR}" }, {})).toThrow(MissingEnvVarError); + }); + + it("keeps the escape winning over the fallback form", () => { + const scenarios: SubstitutionScenario[] = [ + { + name: "escaped fallback form stays a literal even when the var is set", + config: { key: "$${VAR:-x}" }, + env: { VAR: "from-env" }, + expected: { key: "${VAR:-x}" }, + }, + { + name: "escaped fallback form stays a literal when the var is unset", + config: { key: "$${VAR:-x}" }, + env: {}, + expected: { key: "${VAR:-x}" }, + }, + ]; + + expectResolvedScenarios(scenarios); + }); + + it("leaves every operator other than :- untouched", () => { + const scenarios: SubstitutionScenario[] = [ + { name: "assign", config: { key: "${VAR:=d}" }, env: {}, expected: { key: "${VAR:=d}" } }, + { name: "error", config: { key: "${VAR:?m}" }, env: {}, expected: { key: "${VAR:?m}" } }, + { name: "alt", config: { key: "${VAR:+a}" }, env: {}, expected: { key: "${VAR:+a}" } }, + { + name: "unset-only dash is not supported; empty and unset are one state here", + config: { key: "${VAR-d}" }, + env: {}, + expected: { key: "${VAR-d}" }, + }, + { name: "prefix", config: { key: "${VAR#p}" }, env: {}, expected: { key: "${VAR#p}" } }, + { name: "suffix", config: { key: "${VAR%s}" }, env: {}, expected: { key: "${VAR%s}" } }, + { + name: "replace", + config: { key: "${VAR/a/b}" }, + env: {}, + expected: { key: "${VAR/a/b}" }, + }, + { + name: "json modifier proposed by PR #95603 does not collide with :-", + config: { key: "${VAR:json}" }, + env: {}, + expected: { key: "${VAR:json}" }, + }, + ]; + + expectResolvedScenarios(scenarios); + }); + + it("requires a valid uppercase name to the left of the operator", () => { + const scenarios: SubstitutionScenario[] = [ + { + name: "lowercase name", + config: { key: "${lowercase:-d}" }, + env: { lowercase: "value" }, + expected: { key: "${lowercase:-d}" }, + }, + { + name: "mixed-case name", + config: { key: "${MixedCase:-d}" }, + env: {}, + expected: { key: "${MixedCase:-d}" }, + }, + { + name: "numeric prefix", + config: { key: "${123INVALID:-d}" }, + env: {}, + expected: { key: "${123INVALID:-d}" }, + }, + { name: "empty name", config: { key: "${:-d}" }, env: {}, expected: { key: "${:-d}" } }, + { + name: "other template dialects stay untouched", + config: { key: "${my-service} ${count+1} ${a=b}" }, + env: {}, + expected: { key: "${my-service} ${count+1} ${a=b}" }, + }, + ]; + + expectResolvedScenarios(scenarios); + }); + + it("does not change how a fallback containing $ or { is handled", () => { + // The fallback grammar deliberately excludes "$" and "{" so the scan for the closing + // brace stays a plain indexOf("}"). These inputs therefore resolve exactly as they do + // without default-value support: the outer expression stays literal and only a valid + // inner reference substitutes. + const scenarios: SubstitutionScenario[] = [ + { + name: "nested reference: outer literal, inner substitutes", + config: { key: "${A:-${B}}" }, + env: { A: "a-value", B: "b-value" }, + expected: { key: "${A:-b-value}" }, + }, + { + name: "nested reference with both unset", + config: { key: "${A:-${B:-c}}" }, + env: {}, + expected: { key: "${A:-c}" }, + }, + { + name: "fallback containing a brace", + config: { key: '${A:-{"n":1}}' }, + env: {}, + expected: { key: '${A:-{"n":1}}' }, + }, + { + name: "fallback containing a bare dollar", + config: { key: "${PRICE:-$5}" }, + env: {}, + expected: { key: "${PRICE:-$5}" }, + }, + ]; + + expectResolvedScenarios(scenarios); + }); + + it("counts a fallback reference as an env var reference", () => { + expect(containsEnvVarReference("${VAR:-x}")).toBe(true); + expect(containsEnvVarReference("prefix-${VAR:-x}")).toBe(true); + expect(containsEnvVarReference("${VAR:-}")).toBe(true); + // Escaped and unsupported forms are still not references. + expect(containsEnvVarReference("$${VAR:-x}")).toBe(false); + expect(containsEnvVarReference("${VAR:=x}")).toBe(false); + expect(containsEnvVarReference("${VAR-x}")).toBe(false); + expect(containsEnvVarReference("${lowercase:-x}")).toBe(false); + }); + }); }); diff --git a/src/config/env-substitution.ts b/src/config/env-substitution.ts index e048d77de8e8..605768d7b918 100644 --- a/src/config/env-substitution.ts +++ b/src/config/env-substitution.ts @@ -3,8 +3,9 @@ * * Supports `${VAR_NAME}` syntax in string values, substituted at config load time. * - Only uppercase env vars are matched: `[A-Z_][A-Z0-9_]*` + * - `${VAR_NAME:-fallback}` uses `fallback` when the var is unset or empty * - Escape with `$${}` to output literal `${}` - * - Missing env vars throw `MissingEnvVarError` with context + * - Missing env vars without a fallback throw `MissingEnvVarError` with context * * @example * ```json5 @@ -28,6 +29,17 @@ import { parseEnvTemplateSecretRef } from "./types.secrets.js"; const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; +/** + * Bash-style default-value operator: `${VAR:-fallback}`. + * + * Only `:-` is recognized, the form the reported issue names. Bash also has `-`, which + * substitutes when the var is unset but not when it is set to `""`. That is implementable + * here as a local branch on the missing test below, so it is left out by choice, not by + * constraint: `${VAR}` already treats `""` as missing, and putting `${VAR-x}` beside + * `${VAR:-x}` would place two different notions of "set" in one config file. + */ +const DEFAULT_VALUE_OPERATOR = ":-"; + /** Error thrown when a config value references a missing or empty environment variable. */ export class MissingEnvVarError extends Error { constructor( @@ -39,9 +51,52 @@ export class MissingEnvVarError extends Error { } } -type EnvToken = - | { kind: "escaped"; name: string; end: number } - | { kind: "substitution"; name: string; end: number }; +/** One recognized `${VAR}` / `${VAR:-fallback}` placeholder, without its position. */ +export type EnvTemplateToken = { + kind: "escaped" | "substitution"; + name: string; + /** Authored fallback text, or `undefined` for a bare reference. `""` is a real empty fallback. */ + defaultValue?: string; +}; + +type EnvToken = EnvTemplateToken & { end: number }; + +/** + * Parses the text between `${` and the first following `}`. + * + * A fallback is recognized only when it carries no `$` and no `{`. That keeps the scan + * for the closing brace a plain `indexOf("}")`, so no input that is left literal today + * starts parsing differently: `${A:-${B}}` still falls through to the literal path and + * its inner `${B}` is still the only thing that substitutes, exactly as before. + */ +function parseEnvTokenBody(body: string): Omit | null { + if (ENV_VAR_NAME_PATTERN.test(body)) { + return { name: body }; + } + + const operatorIndex = body.indexOf(DEFAULT_VALUE_OPERATOR); + if (operatorIndex === -1) { + return null; + } + + const name = body.slice(0, operatorIndex); + if (!ENV_VAR_NAME_PATTERN.test(name)) { + return null; + } + + const defaultValue = body.slice(operatorIndex + DEFAULT_VALUE_OPERATOR.length); + if (defaultValue.includes("$") || defaultValue.includes("{")) { + return null; + } + return { name, defaultValue }; +} + +/** Rebuilds the authored placeholder text for a parsed token. */ +function renderEnvTemplateToken(token: EnvTemplateToken): string { + return token.defaultValue === undefined + ? `\${${token.name}}` + : `\${${token.name}${DEFAULT_VALUE_OPERATOR}${token.defaultValue}}`; +} function parseEnvTokenAt(value: string, index: number): EnvToken | null { if (value[index] !== "$") { @@ -57,9 +112,9 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null { const start = index + 3; const end = value.indexOf("}", start); if (end !== -1) { - const name = value.slice(start, end); - if (ENV_VAR_NAME_PATTERN.test(name)) { - return { kind: "escaped", name, end }; + const body = parseEnvTokenBody(value.slice(start, end)); + if (body) { + return { kind: "escaped", ...body, end }; } } } @@ -69,9 +124,9 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null { const start = index + 2; const end = value.indexOf("}", start); if (end !== -1) { - const name = value.slice(start, end); - if (ENV_VAR_NAME_PATTERN.test(name)) { - return { kind: "substitution", name, end }; + const body = parseEnvTokenBody(value.slice(start, end)); + if (body) { + return { kind: "substitution", ...body, end }; } } } @@ -79,6 +134,33 @@ function parseEnvTokenAt(value: string, index: number): EnvToken | null { return null; } +/** + * Lists every recognized placeholder in authoring order. + * + * Exported so config write-back preservation shares this grammar instead of keeping its + * own copy; a second scanner would silently stop restoring authored templates the moment + * the two drifted. + */ +export function scanEnvTemplateTokens(value: string): EnvTemplateToken[] { + const tokens: EnvTemplateToken[] = []; + if (!value.includes("$")) { + return tokens; + } + + for (let i = 0; i < value.length; i += 1) { + if (value[i] !== "$") { + continue; + } + const token = parseEnvTokenAt(value, i); + if (!token) { + continue; + } + tokens.push({ kind: token.kind, name: token.name, defaultValue: token.defaultValue }); + i = token.end; + } + return tokens; +} + /** Missing environment variable warning emitted when substitution is configured to continue. */ export type EnvSubstitutionWarning = { varName: string; @@ -119,20 +201,27 @@ function substituteString( const token = parseEnvTokenAt(value, i); if (token?.kind === "escaped") { - chunks.push(`\${${token.name}}`); + chunks.push(renderEnvTemplateToken(token)); i = token.end; continue; } if (token?.kind === "substitution") { const envValue = env[token.name]; if (envValue === undefined || envValue === "") { + if (token.defaultValue !== undefined) { + // An authored fallback resolves the reference, so this is not a missing var: + // no warning, no MissingEnvVarError, and no pending-SecretRef signal. + chunks.push(token.defaultValue); + i = token.end; + continue; + } if (opts?.onMissing) { opts.onMissing({ varName: token.name, configPath }); if (authoredRef?.id === token.name) { opts.onPendingEnvSecretRef?.(token.name, configPath); } // Preserve the original placeholder so the value is visibly unresolved. - chunks.push(`\${${token.name}}`); + chunks.push(renderEnvTemplateToken(token)); i = token.end; continue; }