diff --git a/docs/environment.md b/docs/environment.md index 72ce3597..cde09380 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -11,6 +11,12 @@ awf -e FOO=1 -e BAR=2 'command' # Pass all host variables (development only) awf --env-all 'command' + +# Read variables from a file +awf --env-file /tmp/runtime-paths.env 'command' + +# Combine file and explicit overrides (--env takes precedence over --env-file) +awf --env-file /tmp/runtime-paths.env -e MY_VAR=override 'command' ``` ## Default Behavior @@ -36,6 +42,33 @@ Using `--env-all` passes all host environment variables to the container, which **Proxy variables:** `HTTP_PROXY`, `HTTPS_PROXY`, `https_proxy` (and their lowercase/uppercase variants) from the host are ignored when using `--env-all` because the firewall always sets these to point to Squid. Host proxy settings cannot be passed through as they would conflict with the firewall's traffic routing. +## `--env-file` Support + +`--env-file ` reads environment variables from a file and injects them into the agent container. This is useful when variables are written to a file rather than exported into the current shell (e.g., step outputs from earlier GitHub Actions steps). + +**File format:** +- One `KEY=VALUE` pair per line +- Lines starting with `#` are comments and are ignored +- Blank lines are ignored +- Values are taken literally (no quote stripping, no variable expansion) + +**Precedence (lowest → highest):** +1. Built-in framework variables (proxy, DNS, etc.) +2. `--env-all` host variables +3. `--env-file` variables +4. `--env` / `-e` explicit variables (highest priority) + +**Excluded variables** in `--env-file` (same list as `--env-all`): `PATH`, `PWD`, `HOME`, `SUDO_*`, etc. + +**Example use case — Safe Outputs MCP:** +```bash +# Step output written to a file by the compiler +echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=/tmp/config.json" >> /tmp/runtime-paths.env + +# AWF picks it up via --env-file +awf --env-file /tmp/runtime-paths.env --allow-domains github.com -- agent-command +``` + ## Best Practices ✅ **Use `--env` for specific variables:** diff --git a/src/cli.test.ts b/src/cli.test.ts index 8b1349ca..a522ae18 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2326,6 +2326,48 @@ describe('cli', () => { enableApiProxy: false, }); }); + + it('should use the exitCode from the thrown error when available', async () => { + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const errorWithExitCode = Object.assign(new Error('pull failed'), { exitCode: 2 }); + jest.resetModules(); + jest.doMock('./commands/predownload', () => ({ + predownloadCommand: jest.fn().mockRejectedValue(errorWithExitCode), + })); + + await expect(handlePredownloadAction({ + imageRegistry: 'test', + imageTag: 'latest', + agentImage: 'default', + enableApiProxy: false, + })).rejects.toThrow('process.exit called'); + + expect(mockExit).toHaveBeenCalledWith(2); + mockExit.mockRestore(); + }); + + it('should default to exit code 1 when error has no exitCode property', async () => { + const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const errorWithoutExitCode = new Error('unexpected failure'); + jest.resetModules(); + jest.doMock('./commands/predownload', () => ({ + predownloadCommand: jest.fn().mockRejectedValue(errorWithoutExitCode), + })); + + await expect(handlePredownloadAction({ + imageRegistry: 'test', + imageTag: 'latest', + agentImage: 'default', + enableApiProxy: false, + })).rejects.toThrow('process.exit called'); + + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); }); describe('hasRateLimitOptions', () => { @@ -2360,6 +2402,14 @@ describe('cli', () => { expect(domains).toEqual([]); }); + it('should use process.env by default when no env argument is provided', () => { + const saved = process.env.ENGINE_API_TARGET; + delete process.env.ENGINE_API_TARGET; + const domains = extractGhesDomainsFromEngineApiTarget(); + expect(domains).toEqual([]); + if (saved !== undefined) process.env.ENGINE_API_TARGET = saved; + }); + it('should extract GHES domains from api.github.* format', () => { const env = { ENGINE_API_TARGET: 'https://api.github.mycompany.com' }; const domains = extractGhesDomainsFromEngineApiTarget(env); @@ -2471,6 +2521,17 @@ describe('cli', () => { const domains = extractGhecDomainsFromServerUrl({ GITHUB_API_URL: 'not-a-valid-url' }); expect(domains).toEqual([]); }); + + it('should use process.env by default when no env argument is provided', () => { + const savedServerUrl = process.env.GITHUB_SERVER_URL; + const savedApiUrl = process.env.GITHUB_API_URL; + delete process.env.GITHUB_SERVER_URL; + delete process.env.GITHUB_API_URL; + const domains = extractGhecDomainsFromServerUrl(); + expect(domains).toEqual([]); + if (savedServerUrl !== undefined) process.env.GITHUB_SERVER_URL = savedServerUrl; + if (savedApiUrl !== undefined) process.env.GITHUB_API_URL = savedApiUrl; + }); }); describe('resolveApiTargetsToAllowedDomains with GHEC', () => { diff --git a/src/cli.ts b/src/cli.ts index 0aa743f8..43d96155 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1270,6 +1270,10 @@ program 'Pass all host environment variables to container (excludes system vars like PATH)', false ) + .option( + '--env-file ', + 'Read environment variables from a file (KEY=VALUE format, one per line)' + ) .option( '-v, --mount ', 'Volume mount (repeatable). Format: host_path:container_path[:ro|rw]', @@ -1570,6 +1574,14 @@ program additionalEnv = parsed.env; } + // Validate --env-file path if provided + if (options.envFile) { + if (!fs.existsSync(options.envFile)) { + logger.error(`--env-file: file not found: ${options.envFile}`); + process.exit(1); + } + } + // Parse and validate volume mounts from --mount flags let volumeMounts: string[] | undefined = undefined; if (options.mount && Array.isArray(options.mount) && options.mount.length > 0) { @@ -1694,6 +1706,7 @@ program imageTag: options.imageTag, additionalEnv: Object.keys(additionalEnv).length > 0 ? additionalEnv : undefined, envAll: options.envAll, + envFile: options.envFile, volumeMounts, containerWorkDir: options.containerWorkdir, dnsServers, @@ -1747,6 +1760,11 @@ program logger.warn(' This may expose sensitive credentials if logs or configs are shared'); } + // Log --env-file usage + if (config.envFile) { + logger.debug(`Loading environment variables from file: ${config.envFile}`); + } + // Validate --allow-host-service-ports (port format & range) const servicePortsResult = applyHostServicePortsConfig( config.allowHostServicePorts, diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 80318b6d..e1d1ab71 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, readEnvFile, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE } from './docker-manager'; import { WrapperConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -1334,6 +1334,78 @@ describe('docker-manager', () => { } }); + describe('envFile option', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-envfile-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should inject variables from env file into agent environment', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'MY_CUSTOM_VAR=hello\nANOTHER_VAR=world\n'); + + const config = { ...mockConfig, envFile }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.MY_CUSTOM_VAR).toBe('hello'); + expect(env.ANOTHER_VAR).toBe('world'); + }); + + it('should allow --env flags to override env-file values', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'MY_VAR=from_file\n'); + + const config = { ...mockConfig, envFile, additionalEnv: { MY_VAR: 'from_flag' } }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.MY_VAR).toBe('from_flag'); + }); + + it('should not overwrite already-set env vars with env-file values', () => { + const envFile = path.join(tmpDir, '.env'); + // AWF_DNS_SERVERS is set before envFile processing; file should not clobber it + fs.writeFileSync(envFile, 'AWF_DNS_SERVERS=1.1.1.1\n'); + + const config = { ...mockConfig, envFile }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + // AWF_DNS_SERVERS is set by the framework; file should NOT override it + expect(env.AWF_DNS_SERVERS).not.toBe('1.1.1.1'); + }); + + it('should skip excluded system vars from env file', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'PATH=/evil/path\nHOME=/evil/home\nMY_VAR=ok\n'); + + const config = { ...mockConfig, envFile }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.PATH).not.toBe('/evil/path'); + expect(env.HOME).not.toBe('/evil/home'); + expect(env.MY_VAR).toBe('ok'); + }); + + it('should skip comment lines and blank lines in env file', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, '# comment\n\nFOO=bar\n'); + + const config = { ...mockConfig, envFile }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.FOO).toBe('bar'); + }); + }); + it('should configure DNS to use Google DNS', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; @@ -3380,4 +3452,74 @@ describe('docker-manager', () => { } }); }); + + describe('readEnvFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-readenvfile-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should parse KEY=VALUE pairs', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'FOO=bar\nBAZ=qux\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar', BAZ: 'qux' }); + }); + + it('should skip comment lines starting with #', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, '# comment\nFOO=bar\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should skip blank lines', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, '\nFOO=bar\n\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should allow empty values', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'FOO=\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: '' }); + }); + + it('should allow values containing = signs', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'FOO=a=b=c\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'a=b=c' }); + }); + + it('should ignore lines that do not match KEY=VALUE format', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'INVALID LINE\nFOO=bar\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should reject keys starting with a digit', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, '123KEY=value\nFOO=bar\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should reject keys containing hyphens', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, 'KEY-NAME=value\nFOO=bar\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should handle lines with leading whitespace by trimming them', () => { + const envFile = path.join(tmpDir, '.env'); + fs.writeFileSync(envFile, ' FOO=bar\n'); + expect(readEnvFile(envFile)).toEqual({ FOO: 'bar' }); + }); + + it('should throw when file does not exist', () => { + expect(() => readEnvFile(path.join(tmpDir, 'missing.env'))).toThrow(); + }); + }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 3958458c..27e60965 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -207,6 +207,36 @@ export function mergeGitHubPathEntries(currentPath: string, githubPathEntries: s return [...newEntries, ...currentEntries].join(':'); } +/** + * Reads environment variables from a KEY=VALUE file (like Docker's --env-file). + * + * Rules: + * - Lines starting with '#' are comments and are ignored. + * - Empty/whitespace-only lines are ignored. + * - Each non-comment line must match the pattern KEY=VALUE where KEY starts with a + * letter or underscore and contains only letters, digits, or underscores. + * - Values may be empty (KEY=). + * - Values are taken literally; no quote-stripping or variable expansion is done. + * + * @param filePath - Absolute or relative path to the env file + * @returns An object mapping variable names to their values + * @throws {Error} If the file cannot be read + */ +export function readEnvFile(filePath: string): Record { + const content = fs.readFileSync(filePath, 'utf-8'); + const result: Record = {}; + for (const raw of content.split('\n')) { + const line = raw.trim(); + // Skip comments and blank lines + if (line === '' || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (match) { + result[match[1]] = match[2]; + } + } + return result; +} + /** * Gets existing Docker network subnets to avoid conflicts */ @@ -611,6 +641,16 @@ export function generateDockerCompose( environment.AWF_ONE_SHOT_TOKEN_DEBUG = process.env.AWF_ONE_SHOT_TOKEN_DEBUG; } + // Environment variables from --env-file (injected before --env flags so explicit flags win) + if (config.envFile) { + const fileEnv = readEnvFile(config.envFile); + for (const [key, value] of Object.entries(fileEnv)) { + if (!EXCLUDED_ENV_VARS.has(key) && !Object.prototype.hasOwnProperty.call(environment, key)) { + environment[key] = value; + } + } + } + // Additional environment variables from --env flags (these override everything) if (config.additionalEnv) { Object.assign(environment, config.additionalEnv); diff --git a/src/types.ts b/src/types.ts index 0d387dc0..9765d3e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -247,6 +247,21 @@ export interface WrapperConfig { */ envAll?: boolean; + /** + * Path to a file containing environment variables to inject into the container + * + * The file should contain KEY=VALUE pairs, one per line. Lines starting with + * '#' are treated as comments and ignored. Empty lines are also ignored. + * Variables in the file are injected before `additionalEnv` (--env flags), + * so explicit --env values take precedence. + * + * Excluded system variables (PATH, HOME, etc.) are never injected regardless + * of whether they appear in the file. + * + * @example '/tmp/runtime-paths.env' + */ + envFile?: string; + /** * Custom volume mounts to add to the agent execution container *