From c0e829b799c3dc48ba5eb0ea236098e34ba3b8c3 Mon Sep 17 00:00:00 2001 From: Christopher Date: Fri, 1 May 2026 12:20:15 +1000 Subject: [PATCH 1/3] feat(core): coerce interpolated env vars to native types in config.yaml When an entire config value is a single ${{ VAR }} reference, resolve and coerce the result to its native type: 'true'/'false' -> boolean, numeric strings -> number. Partial/inline substitutions remain strings. This allows boolean fields like results.export.auto_push to be driven by environment variables: auto_push: ${{ AGENTV_AUTO_PUSH }} # AGENTV_AUTO_PUSH=true works Closes #1202 --- packages/core/src/evaluation/interpolation.ts | 39 +++++++++++++++++++ .../test/evaluation/interpolation.test.ts | 36 +++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/core/src/evaluation/interpolation.ts b/packages/core/src/evaluation/interpolation.ts index 6fee62ab7..ad1ff11c1 100644 --- a/packages/core/src/evaluation/interpolation.ts +++ b/packages/core/src/evaluation/interpolation.ts @@ -2,13 +2,52 @@ import type { EnvLookup } from './providers/types.js'; const ENV_VAR_PATTERN = /\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; +/** + * Regex that matches a string consisting of exactly one `${{ VAR }}` reference + * and nothing else. Used to detect whole-value substitutions eligible for type coercion. + */ +const WHOLE_VAR_PATTERN = /^\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/; + +/** + * Coerce a resolved string to its native primitive type when appropriate. + * "true"/"false" become booleans; integer/float strings become numbers. + * All other strings (including empty string) are returned as-is. + */ +function coercePrimitive(value: string): unknown { + if (value === 'true') return true; + if (value === 'false') return false; + if (value !== '' && !Number.isNaN(Number(value))) return Number(value); + return value; +} + /** * Recursively interpolate `${{ VAR }}` references in all string values. * Missing variables resolve to empty string. * Non-string values pass through unchanged. Returns a new object (no mutation). + * + * Type coercion: when the **entire** string value is a single `${{ VAR }}` reference + * (no surrounding text), the resolved value is coerced to its native type — + * `"true"`/`"false"` become booleans, numeric strings become numbers. This allows + * boolean and numeric config fields to be driven by environment variables: + * + * ```yaml + * # .agentv/config.yaml + * results: + * export: + * auto_push: ${{ AGENTV_AUTO_PUSH }} # AGENTV_AUTO_PUSH=true → boolean true + * ``` + * + * Inline/partial substitutions (e.g. `"prefix-${{ VAR }}"`) are always strings. */ export function interpolateEnv(value: unknown, env: EnvLookup): unknown { if (typeof value === 'string') { + // Whole-value substitution: coerce the resolved value to its native type. + const wholeMatch = WHOLE_VAR_PATTERN.exec(value); + if (wholeMatch) { + const resolved = env[wholeMatch[1] as string] ?? ''; + return coercePrimitive(resolved); + } + // Partial/inline substitution: always produces a string. return value.replace(ENV_VAR_PATTERN, (_, varName: string) => env[varName] ?? ''); } if (Array.isArray(value)) { diff --git a/packages/core/test/evaluation/interpolation.test.ts b/packages/core/test/evaluation/interpolation.test.ts index cb89ac7ef..ab439e9fd 100644 --- a/packages/core/test/evaluation/interpolation.test.ts +++ b/packages/core/test/evaluation/interpolation.test.ts @@ -64,6 +64,42 @@ describe('interpolateEnv', () => { expect(interpolateEnv('${{ EMPTY }}', env)).toBe(''); }); + describe('whole-value type coercion', () => { + it('coerces "true" to boolean true', () => { + expect(interpolateEnv('${{ FLAG }}', { FLAG: 'true' })).toBe(true); + }); + + it('coerces "false" to boolean false', () => { + expect(interpolateEnv('${{ FLAG }}', { FLAG: 'false' })).toBe(false); + }); + + it('coerces integer string to number', () => { + expect(interpolateEnv('${{ COUNT }}', { COUNT: '10' })).toBe(10); + }); + + it('coerces float string to number', () => { + expect(interpolateEnv('${{ RATIO }}', { RATIO: '0.75' })).toBe(0.75); + }); + + it('leaves empty string as string (missing var)', () => { + expect(interpolateEnv('${{ MISSING }}', {})).toBe(''); + }); + + it('leaves plain string values as strings', () => { + expect(interpolateEnv('${{ HOME }}', env)).toBe('/home/user'); + }); + + it('does not coerce partial/inline substitutions', () => { + // "true" appears only after inline replacement — no coercion + expect(interpolateEnv('enabled=${{ FLAG }}', { FLAG: 'true' })).toBe('enabled=true'); + }); + + it('coerces inside nested objects', () => { + const input = { auto_push: '${{ PUSH }}', label: 'runs' }; + expect(interpolateEnv(input, { PUSH: 'true' })).toEqual({ auto_push: true, label: 'runs' }); + }); + }); + it('is case-sensitive for variable names', () => { expect(interpolateEnv('${{ home }}', env)).toBe(''); expect(interpolateEnv('${{ HOME }}', env)).toBe('/home/user'); From d3b39150ff70aeba86beed20f4be76ea2bb55a73 Mon Sep 17 00:00:00 2001 From: Christopher Date: Fri, 1 May 2026 12:29:07 +1000 Subject: [PATCH 2/3] fix(core): apply interpolateEnv in config and targets validators --- packages/core/src/evaluation/validation/config-validator.ts | 3 ++- packages/core/src/evaluation/validation/targets-validator.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/evaluation/validation/config-validator.ts b/packages/core/src/evaluation/validation/config-validator.ts index df695f127..a6f5d1af3 100644 --- a/packages/core/src/evaluation/validation/config-validator.ts +++ b/packages/core/src/evaluation/validation/config-validator.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; +import { interpolateEnv } from '../interpolation.js'; import { parseYamlValue } from '../yaml-loader.js'; import type { ValidationError, ValidationResult } from './types.js'; @@ -11,7 +12,7 @@ export async function validateConfigFile(filePath: string): Promise Date: Fri, 1 May 2026 12:54:47 +1000 Subject: [PATCH 3/3] fix(core): narrow numeric coercion to plain integers/decimals only --- packages/core/src/evaluation/interpolation.ts | 13 ++++++++-- .../test/evaluation/interpolation.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/core/src/evaluation/interpolation.ts b/packages/core/src/evaluation/interpolation.ts index ad1ff11c1..7bd2dbc17 100644 --- a/packages/core/src/evaluation/interpolation.ts +++ b/packages/core/src/evaluation/interpolation.ts @@ -8,15 +8,24 @@ const ENV_VAR_PATTERN = /\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; */ const WHOLE_VAR_PATTERN = /^\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/; +/** + * Pattern matching plain integers (e.g. "42", "-7") and decimal fractions + * (e.g. "3.14", "-0.5"). Excludes hex ("0x10"), scientific notation ("1e3"), + * "Infinity", "NaN", and whitespace-only strings that `Number()` accepts. + */ +const PLAIN_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; + /** * Coerce a resolved string to its native primitive type when appropriate. - * "true"/"false" become booleans; integer/float strings become numbers. + * "true"/"false" become booleans; plain integer/decimal strings become numbers. + * Strings that happen to be valid JS numbers but are not plain decimal notation + * (hex, scientific notation, "Infinity") are left as strings. * All other strings (including empty string) are returned as-is. */ function coercePrimitive(value: string): unknown { if (value === 'true') return true; if (value === 'false') return false; - if (value !== '' && !Number.isNaN(Number(value))) return Number(value); + if (PLAIN_NUMBER_PATTERN.test(value)) return Number(value); return value; } diff --git a/packages/core/test/evaluation/interpolation.test.ts b/packages/core/test/evaluation/interpolation.test.ts index ab439e9fd..d416f7d0f 100644 --- a/packages/core/test/evaluation/interpolation.test.ts +++ b/packages/core/test/evaluation/interpolation.test.ts @@ -98,6 +98,31 @@ describe('interpolateEnv', () => { const input = { auto_push: '${{ PUSH }}', label: 'runs' }; expect(interpolateEnv(input, { PUSH: 'true' })).toEqual({ auto_push: true, label: 'runs' }); }); + + // Numeric edge-case regression tests — these must stay as strings + it('does not coerce scientific notation (1e3)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '1e3' })).toBe('1e3'); + }); + + it('does not coerce hex strings (0x10)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '0x10' })).toBe('0x10'); + }); + + it('does not coerce "Infinity"', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: 'Infinity' })).toBe('Infinity'); + }); + + it('does not coerce whitespace-only string', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: ' ' })).toBe(' '); + }); + + it('does not coerce leading-zero string (00123)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '00123' })).toBe('00123'); + }); + + it('coerces negative integer', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '-7' })).toBe(-7); + }); }); it('is case-sensitive for variable names', () => {