Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import path from 'node:path';
import { loadConfig, runBeforeSessionHook } from '@agentv/core';
import {
loadConfig,
loadEnvPathFiles,
runBeforeSessionHook,
runEnvFromEntries,
} from '@agentv/core';
import { binary, run, subcommands } from 'cmd-ts';
import { findRepoRoot } from './commands/eval/shared.js';

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

if (shouldRunBeforeSessionHook(processedArgv)) {
// Run before_session hook once at startup, before any command executes.
// Uses cwd as the search root for .agentv/config.yaml.
// Load env_path/env_from and run the before_session hook once at startup,
// before any command executes. Uses cwd as the search root for
// .agentv/config.yaml so validate/eval commands see the injected vars.
const cwd = process.cwd();
const repoRoot = await findRepoRoot(cwd);
const sessionConfig = await loadConfig(path.join(cwd, '_'), repoRoot);
const configDir = sessionConfig?.configDir ?? repoRoot;

if (sessionConfig?.env_path) {
await loadEnvPathFiles(sessionConfig.env_path, configDir);
}
if (sessionConfig?.env_from) {
await runEnvFromEntries(sessionConfig.env_from, { cwd: configDir });
}

const beforeSessionCommand = sessionConfig?.hooks?.before_session;
if (beforeSessionCommand) {
runBeforeSessionHook(beforeSessionCommand);
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/content/docs/docs/next/targets/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,53 @@ targets:
This keeps secrets out of version-controlled files and avoids requiring a CI step that rewrites
already-exported secrets into `.env`.

### Loading Environment Variables from Config

Set `env_path` and/or `env_from` in `.agentv/config.yaml` to load environment variables before
validation and eval run, so `{{ env.* }}` references resolve for both `agentv validate` and
`agentv eval`:

```yaml
# .agentv/config.yaml
env_path:
- .env
- .env.local

env_from:
- command: ["bun", "scripts/load-secrets.ts"]
format: shell_exports
- command: ["node", "scripts/print-env-json.mjs"]
format: json
```

- `env_path` loads one or more dotenv-style files, relative to the project directory (the parent
of `.agentv/`) unless the path is absolute. A missing file logs a warning and is skipped.
- `env_from` runs one or more argv commands and injects their parsed stdout. `command` must be a
non-empty argv array — shell command strings are not accepted. `format` defaults to
`shell_exports` (lines like `export KEY=value` or `KEY=value`); `format: json` expects a flat
JSON object of string values. A failing command aborts the CLI invocation with an error.
- Both singular (`env_path: .env`, `env_from: {command: [...]}`) and list forms are accepted.
- Values already present in the process environment are never overwritten, and injected values
are never printed to logs.
- An argv command that explicitly invokes a shell, such as `["sh", "-c", "..."]`, is still
accepted — the requirement is an argv array, not a ban on shells. Only implicit shell command
strings (`command: "bun scripts/load-secrets.ts"`) are rejected.

`hooks.before_session` still runs for backward compatibility but is deprecated; migrate its
command to `env_from` (or a `.env` file plus `env_path`) instead.

**Known limitations:**

- `env_path`/`env_from` are discovered from the current working directory at CLI startup, the
same as legacy `hooks.before_session`. In a multi-project workspace, a nested project's own
`.agentv/config.yaml` `env_from` only runs when a command is invoked from within (or below)
that project's directory, not when run from a parent directory that resolves to a different
`.agentv/config.yaml`.
- A `.agentv/config.yaml`'s own fields (for example `results.repo`) are interpolated before that
same file's `env_path`/`env_from` runs, so they cannot reference values produced by its own
`env_path`/`env_from`. Use an already-exported process environment variable, or a value loaded
by an ancestor project's config, for fields inside `config.yaml` itself.

## Supported Providers

| Provider | Type | Description |
Expand Down
214 changes: 214 additions & 0 deletions packages/core/src/evaluation/env-injection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* v5-native environment injection for AgentV project config.
*
* `env_path` loads dotenv-style files and `env_from` runs argv commands and
* parses their stdout, both injecting into `process.env` before validation
* and eval so target `{{ env.* }}` interpolation can see the values. This is
* the replacement path for the deprecated `hooks.before_session`.
*
* Existing `process.env` values always win — neither source overwrites a key
* that is already set. Values are never printed; only file paths, commands,
* and counts are logged.
*
* @module
*/

import { spawnSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';

import type { EnvFromEntry, EnvFromFormat } from './loaders/config-loader.js';

const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;

/**
* Parse `KEY=value` / `export KEY=value` lines, shared by `env_path` dotenv
* files and `env_from` `shell_exports` output. Quotes are stripped; blank
* lines, comments, and non-matching lines are skipped.
*/
export function parseShellExportsEnv(content: string): Record<string, string> {
const result: Record<string, string> = {};

for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;

const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;

const key = match[1];
let value = match[2];
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}

result[key] = value;
}

return result;
}

/**
* Parse a flat JSON object of string values for `env_from` `format: json`.
* Throws on invalid JSON, non-object shapes, invalid env var names, or
* non-string values.
*/
export function parseJsonEnv(content: string): Record<string, string> {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
// Do not include the parser's message: JS engines embed a snippet of the
// offending content in JSON syntax errors, which could leak a secret
// value from malformed env_from output.
throw new Error('invalid JSON');
}

if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('expected a flat JSON object of string values');
}

const result: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!ENV_NAME_PATTERN.test(key)) {
throw new Error(`invalid environment variable name "${key}"`);
}
if (typeof value !== 'string') {
throw new Error(`value for "${key}" must be a string`);
}
result[key] = value;
}

return result;
}

/** Injects vars into process.env; existing keys and invalid names are skipped. Returns injected count. */
function injectEnv(vars: Record<string, string>): number {
let injected = 0;
for (const [key, value] of Object.entries(vars)) {
if (!ENV_NAME_PATTERN.test(key)) continue;
if (process.env[key] === undefined) {
process.env[key] = value;
injected++;
}
}
return injected;
}

export type EnvPathLoadResult = {
readonly loaded: readonly string[];
readonly missing: readonly string[];
readonly injectedCount: number;
};

/**
* Load one or more dotenv-style files and inject their variables into
* `process.env`. Relative paths resolve against `baseDir`. A missing file
* warns and is skipped rather than failing the command.
*/
export async function loadEnvPathFiles(
envPaths: readonly string[],
baseDir: string,
): Promise<EnvPathLoadResult> {
const loaded: string[] = [];
const missing: string[] = [];
let injectedCount = 0;

for (const envPath of envPaths) {
const resolvedPath = path.isAbsolute(envPath) ? envPath : path.join(baseDir, envPath);

let content: string;
try {
content = await readFile(resolvedPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
missing.push(resolvedPath);
logWarning(`env_path file not found: ${resolvedPath}`);
continue;
}
throw new Error(`Could not read env_path file ${resolvedPath}: ${(error as Error).message}`);
}

injectedCount += injectEnv(parseShellExportsEnv(content));
loaded.push(resolvedPath);
}

if (injectedCount > 0) {
console.log(
`env_path injected ${injectedCount} environment variable(s) from ${loaded.length} file(s).`,
);
}

return { loaded, missing, injectedCount };
}

export type EnvFromRunResult = {
readonly injectedCount: number;
};

function parseEnvFromOutput(stdout: string, format: EnvFromFormat): Record<string, string> {
return format === 'json' ? parseJsonEnv(stdout) : parseShellExportsEnv(stdout);
}

/**
* Run one or more `env_from` argv commands and inject their parsed stdout
* into `process.env`. A non-zero exit, spawn failure, or unparseable output
* throws — command failures must fail the invoking command. stdout is never
* logged or included in error messages since it may carry secret values.
*/
export async function runEnvFromEntries(
entries: readonly EnvFromEntry[],
options: { readonly cwd: string },
): Promise<EnvFromRunResult> {
let injectedCount = 0;

for (const entry of entries) {
const [command, ...args] = entry.command;
const commandLabel = entry.command.join(' ');
console.log(`Running env_from command: ${commandLabel}`);

const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});

if (result.stderr) {
process.stderr.write(result.stderr);
}

if (result.error) {
throw new Error(`env_from command failed to start: ${commandLabel}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`env_from command exited with code ${result.status ?? 'unknown'}: ${commandLabel}`,
);
}

const format = entry.format ?? 'shell_exports';
let vars: Record<string, string>;
try {
vars = parseEnvFromOutput(result.stdout ?? '', format);
} catch (error) {
throw new Error(
`env_from command produced invalid ${format} output: ${commandLabel}: ${(error as Error).message}`,
);
}

injectedCount += injectEnv(vars);
}

if (injectedCount > 0) {
console.log(`env_from injected ${injectedCount} environment variable(s).`);
}

return { injectedCount };
}

function logWarning(message: string): void {
console.warn(`Warning: ${message}`);
}
Loading
Loading