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>
This commit is contained in:
Mitchell Etzel 2026-09-23 12:28:17 -07:00 • committed by GitHub
parent 31587f12b6
commit fcfa4bf0c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 443 additions and 41 deletions

View file

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

View file

@ -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({

View file

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

View file

@ -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"], {});

View file

@ -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[];

View file

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

View file

@ -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<EnvTemplateToken, "kind"> | 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;
}