Skip to content

Commit d4b5374

Browse files
christsoclaude
andauthored
feat(config): add v5-native env_path/env_from environment injection (#1689)
Adds explicit .agentv/config.yaml support for env_path (dotenv file loading) and env_from (argv command output injection), replacing the ad hoc hooks.before_session shell hook as the supported way to feed secrets into {{ env.* }} interpolation before validate/eval. - env_path loads dotenv-style files; missing files warn, don't fail. - env_from runs argv commands (shell strings rejected) and parses shell_exports or json stdout into process.env. - Existing process.env always wins; injected values are never logged. - hooks.before_session keeps running, now with a deprecation warning, and config validation no longer flags hooks as an unexpected field. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 02e26c5 commit d4b5374

9 files changed

Lines changed: 1030 additions & 7 deletions

File tree

apps/cli/src/index.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import path from 'node:path';
2-
import { loadConfig, runBeforeSessionHook } from '@agentv/core';
2+
import {
3+
loadConfig,
4+
loadEnvPathFiles,
5+
runBeforeSessionHook,
6+
runEnvFromEntries,
7+
} from '@agentv/core';
38
import { binary, run, subcommands } from 'cmd-ts';
49
import { findRepoRoot } from './commands/eval/shared.js';
510

@@ -170,11 +175,21 @@ export async function runCli(argv: string[] = process.argv): Promise<void> {
170175
}
171176

172177
if (shouldRunBeforeSessionHook(processedArgv)) {
173-
// Run before_session hook once at startup, before any command executes.
174-
// Uses cwd as the search root for .agentv/config.yaml.
178+
// Load env_path/env_from and run the before_session hook once at startup,
179+
// before any command executes. Uses cwd as the search root for
180+
// .agentv/config.yaml so validate/eval commands see the injected vars.
175181
const cwd = process.cwd();
176182
const repoRoot = await findRepoRoot(cwd);
177183
const sessionConfig = await loadConfig(path.join(cwd, '_'), repoRoot);
184+
const configDir = sessionConfig?.configDir ?? repoRoot;
185+
186+
if (sessionConfig?.env_path) {
187+
await loadEnvPathFiles(sessionConfig.env_path, configDir);
188+
}
189+
if (sessionConfig?.env_from) {
190+
await runEnvFromEntries(sessionConfig.env_from, { cwd: configDir });
191+
}
192+
178193
const beforeSessionCommand = sessionConfig?.hooks?.before_session;
179194
if (beforeSessionCommand) {
180195
runBeforeSessionHook(beforeSessionCommand);

apps/web/src/content/docs/docs/next/targets/configuration.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,53 @@ targets:
193193
This keeps secrets out of version-controlled files and avoids requiring a CI step that rewrites
194194
already-exported secrets into `.env`.
195195

196+
### Loading Environment Variables from Config
197+
198+
Set `env_path` and/or `env_from` in `.agentv/config.yaml` to load environment variables before
199+
validation and eval run, so `{{ env.* }}` references resolve for both `agentv validate` and
200+
`agentv eval`:
201+
202+
```yaml
203+
# .agentv/config.yaml
204+
env_path:
205+
- .env
206+
- .env.local
207+
208+
env_from:
209+
- command: ["bun", "scripts/load-secrets.ts"]
210+
format: shell_exports
211+
- command: ["node", "scripts/print-env-json.mjs"]
212+
format: json
213+
```
214+
215+
- `env_path` loads one or more dotenv-style files, relative to the project directory (the parent
216+
of `.agentv/`) unless the path is absolute. A missing file logs a warning and is skipped.
217+
- `env_from` runs one or more argv commands and injects their parsed stdout. `command` must be a
218+
non-empty argv array — shell command strings are not accepted. `format` defaults to
219+
`shell_exports` (lines like `export KEY=value` or `KEY=value`); `format: json` expects a flat
220+
JSON object of string values. A failing command aborts the CLI invocation with an error.
221+
- Both singular (`env_path: .env`, `env_from: {command: [...]}`) and list forms are accepted.
222+
- Values already present in the process environment are never overwritten, and injected values
223+
are never printed to logs.
224+
- An argv command that explicitly invokes a shell, such as `["sh", "-c", "..."]`, is still
225+
accepted — the requirement is an argv array, not a ban on shells. Only implicit shell command
226+
strings (`command: "bun scripts/load-secrets.ts"`) are rejected.
227+
228+
`hooks.before_session` still runs for backward compatibility but is deprecated; migrate its
229+
command to `env_from` (or a `.env` file plus `env_path`) instead.
230+
231+
**Known limitations:**
232+
233+
- `env_path`/`env_from` are discovered from the current working directory at CLI startup, the
234+
same as legacy `hooks.before_session`. In a multi-project workspace, a nested project's own
235+
`.agentv/config.yaml` `env_from` only runs when a command is invoked from within (or below)
236+
that project's directory, not when run from a parent directory that resolves to a different
237+
`.agentv/config.yaml`.
238+
- A `.agentv/config.yaml`'s own fields (for example `results.repo`) are interpolated before that
239+
same file's `env_path`/`env_from` runs, so they cannot reference values produced by its own
240+
`env_path`/`env_from`. Use an already-exported process environment variable, or a value loaded
241+
by an ancestor project's config, for fields inside `config.yaml` itself.
242+
196243
## Supported Providers
197244

198245
| Provider | Type | Description |
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/**
2+
* v5-native environment injection for AgentV project config.
3+
*
4+
* `env_path` loads dotenv-style files and `env_from` runs argv commands and
5+
* parses their stdout, both injecting into `process.env` before validation
6+
* and eval so target `{{ env.* }}` interpolation can see the values. This is
7+
* the replacement path for the deprecated `hooks.before_session`.
8+
*
9+
* Existing `process.env` values always win — neither source overwrites a key
10+
* that is already set. Values are never printed; only file paths, commands,
11+
* and counts are logged.
12+
*
13+
* @module
14+
*/
15+
16+
import { spawnSync } from 'node:child_process';
17+
import { readFile } from 'node:fs/promises';
18+
import path from 'node:path';
19+
20+
import type { EnvFromEntry, EnvFromFormat } from './loaders/config-loader.js';
21+
22+
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
23+
24+
/**
25+
* Parse `KEY=value` / `export KEY=value` lines, shared by `env_path` dotenv
26+
* files and `env_from` `shell_exports` output. Quotes are stripped; blank
27+
* lines, comments, and non-matching lines are skipped.
28+
*/
29+
export function parseShellExportsEnv(content: string): Record<string, string> {
30+
const result: Record<string, string> = {};
31+
32+
for (const line of content.split('\n')) {
33+
const trimmed = line.trim();
34+
if (!trimmed || trimmed.startsWith('#')) continue;
35+
36+
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
37+
if (!match) continue;
38+
39+
const key = match[1];
40+
let value = match[2];
41+
if (
42+
(value.startsWith('"') && value.endsWith('"')) ||
43+
(value.startsWith("'") && value.endsWith("'"))
44+
) {
45+
value = value.slice(1, -1);
46+
}
47+
48+
result[key] = value;
49+
}
50+
51+
return result;
52+
}
53+
54+
/**
55+
* Parse a flat JSON object of string values for `env_from` `format: json`.
56+
* Throws on invalid JSON, non-object shapes, invalid env var names, or
57+
* non-string values.
58+
*/
59+
export function parseJsonEnv(content: string): Record<string, string> {
60+
let parsed: unknown;
61+
try {
62+
parsed = JSON.parse(content);
63+
} catch {
64+
// Do not include the parser's message: JS engines embed a snippet of the
65+
// offending content in JSON syntax errors, which could leak a secret
66+
// value from malformed env_from output.
67+
throw new Error('invalid JSON');
68+
}
69+
70+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
71+
throw new Error('expected a flat JSON object of string values');
72+
}
73+
74+
const result: Record<string, string> = {};
75+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
76+
if (!ENV_NAME_PATTERN.test(key)) {
77+
throw new Error(`invalid environment variable name "${key}"`);
78+
}
79+
if (typeof value !== 'string') {
80+
throw new Error(`value for "${key}" must be a string`);
81+
}
82+
result[key] = value;
83+
}
84+
85+
return result;
86+
}
87+
88+
/** Injects vars into process.env; existing keys and invalid names are skipped. Returns injected count. */
89+
function injectEnv(vars: Record<string, string>): number {
90+
let injected = 0;
91+
for (const [key, value] of Object.entries(vars)) {
92+
if (!ENV_NAME_PATTERN.test(key)) continue;
93+
if (process.env[key] === undefined) {
94+
process.env[key] = value;
95+
injected++;
96+
}
97+
}
98+
return injected;
99+
}
100+
101+
export type EnvPathLoadResult = {
102+
readonly loaded: readonly string[];
103+
readonly missing: readonly string[];
104+
readonly injectedCount: number;
105+
};
106+
107+
/**
108+
* Load one or more dotenv-style files and inject their variables into
109+
* `process.env`. Relative paths resolve against `baseDir`. A missing file
110+
* warns and is skipped rather than failing the command.
111+
*/
112+
export async function loadEnvPathFiles(
113+
envPaths: readonly string[],
114+
baseDir: string,
115+
): Promise<EnvPathLoadResult> {
116+
const loaded: string[] = [];
117+
const missing: string[] = [];
118+
let injectedCount = 0;
119+
120+
for (const envPath of envPaths) {
121+
const resolvedPath = path.isAbsolute(envPath) ? envPath : path.join(baseDir, envPath);
122+
123+
let content: string;
124+
try {
125+
content = await readFile(resolvedPath, 'utf8');
126+
} catch (error) {
127+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
128+
missing.push(resolvedPath);
129+
logWarning(`env_path file not found: ${resolvedPath}`);
130+
continue;
131+
}
132+
throw new Error(`Could not read env_path file ${resolvedPath}: ${(error as Error).message}`);
133+
}
134+
135+
injectedCount += injectEnv(parseShellExportsEnv(content));
136+
loaded.push(resolvedPath);
137+
}
138+
139+
if (injectedCount > 0) {
140+
console.log(
141+
`env_path injected ${injectedCount} environment variable(s) from ${loaded.length} file(s).`,
142+
);
143+
}
144+
145+
return { loaded, missing, injectedCount };
146+
}
147+
148+
export type EnvFromRunResult = {
149+
readonly injectedCount: number;
150+
};
151+
152+
function parseEnvFromOutput(stdout: string, format: EnvFromFormat): Record<string, string> {
153+
return format === 'json' ? parseJsonEnv(stdout) : parseShellExportsEnv(stdout);
154+
}
155+
156+
/**
157+
* Run one or more `env_from` argv commands and inject their parsed stdout
158+
* into `process.env`. A non-zero exit, spawn failure, or unparseable output
159+
* throws — command failures must fail the invoking command. stdout is never
160+
* logged or included in error messages since it may carry secret values.
161+
*/
162+
export async function runEnvFromEntries(
163+
entries: readonly EnvFromEntry[],
164+
options: { readonly cwd: string },
165+
): Promise<EnvFromRunResult> {
166+
let injectedCount = 0;
167+
168+
for (const entry of entries) {
169+
const [command, ...args] = entry.command;
170+
const commandLabel = entry.command.join(' ');
171+
console.log(`Running env_from command: ${commandLabel}`);
172+
173+
const result = spawnSync(command, args, {
174+
cwd: options.cwd,
175+
encoding: 'utf8',
176+
stdio: ['ignore', 'pipe', 'pipe'],
177+
});
178+
179+
if (result.stderr) {
180+
process.stderr.write(result.stderr);
181+
}
182+
183+
if (result.error) {
184+
throw new Error(`env_from command failed to start: ${commandLabel}: ${result.error.message}`);
185+
}
186+
if (result.status !== 0) {
187+
throw new Error(
188+
`env_from command exited with code ${result.status ?? 'unknown'}: ${commandLabel}`,
189+
);
190+
}
191+
192+
const format = entry.format ?? 'shell_exports';
193+
let vars: Record<string, string>;
194+
try {
195+
vars = parseEnvFromOutput(result.stdout ?? '', format);
196+
} catch (error) {
197+
throw new Error(
198+
`env_from command produced invalid ${format} output: ${commandLabel}: ${(error as Error).message}`,
199+
);
200+
}
201+
202+
injectedCount += injectEnv(vars);
203+
}
204+
205+
if (injectedCount > 0) {
206+
console.log(`env_from injected ${injectedCount} environment variable(s).`);
207+
}
208+
209+
return { injectedCount };
210+
}
211+
212+
function logWarning(message: string): void {
213+
console.warn(`Warning: ${message}`);
214+
}

0 commit comments

Comments
 (0)