From afab4b36664212b49591ad3de916cfd329ec8978 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 14 Apr 2026 14:21:34 -0700 Subject: [PATCH 1/2] fix: recover toolchain env vars from $GITHUB_ENV file When AWF runs via sudo, non-standard env vars like GOROOT, CARGO_HOME, JAVA_HOME are stripped. Add readGitHubEnvEntries() to read the $GITHUB_ENV file directly (analogous to existing readGitHubPathEntries for $GITHUB_PATH) and use it as a fallback for toolchain variables. Key changes: - Add parseGitHubEnvFile() supporting KEY=VALUE and heredoc formats - Add readGitHubEnvEntries() reading from $GITHUB_ENV file path - Replace individual process.env checks with TOOLCHAIN_ENV_VARS loop that falls back to $GITHUB_ENV when process.env is empty - 14 new tests covering parser, file reader, and integration Closes #1958 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/docker-manager.test.ts | 181 ++++++++++++++++++++++++++++++++++++- src/docker-manager.ts | 136 ++++++++++++++++++++++------ 2 files changed, 290 insertions(+), 27 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index e3d74ec7..7b1e6e72 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, fastKillAgentContainer, isAgentExternallyKilled, resetAgentExternallyKilled, AGENT_CONTAINER_NAME, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, readEnvFile, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, stripScheme, collectDiagnosticLogs, setAwfDockerHost } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, fastKillAgentContainer, isAgentExternallyKilled, resetAgentExternallyKilled, AGENT_CONTAINER_NAME, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, readGitHubEnvEntries, parseGitHubEnvFile, readEnvFile, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, stripScheme, collectDiagnosticLogs, setAwfDockerHost } from './docker-manager'; import { WrapperConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -4329,6 +4329,185 @@ describe('docker-manager', () => { }); }); + describe('parseGitHubEnvFile', () => { + it('should parse simple KEY=VALUE entries', () => { + const result = parseGitHubEnvFile('GOROOT=/usr/local/go\nJAVA_HOME=/usr/lib/jvm/java-17\n'); + expect(result).toEqual({ + GOROOT: '/usr/local/go', + JAVA_HOME: '/usr/lib/jvm/java-17', + }); + }); + + it('should handle values containing = characters', () => { + const result = parseGitHubEnvFile('MY_VAR=key=value=extra\n'); + expect(result).toEqual({ MY_VAR: 'key=value=extra' }); + }); + + it('should handle heredoc multiline values', () => { + const content = 'MULTI_LINE< { + const result = parseGitHubEnvFile('GOROOT=/usr/local/go\r\nJAVA_HOME=/usr/lib/jvm\r\n'); + expect(result).toEqual({ + GOROOT: '/usr/local/go', + JAVA_HOME: '/usr/lib/jvm', + }); + }); + + it('should handle mixed simple and heredoc entries', () => { + const content = 'SIMPLE=value\nHEREDOC< { + const result = parseGitHubEnvFile('\n\nGOROOT=/go\n\n'); + expect(result).toEqual({ GOROOT: '/go' }); + }); + + it('should return empty object for empty content', () => { + expect(parseGitHubEnvFile('')).toEqual({}); + }); + + it('should handle unterminated heredoc gracefully', () => { + const content = 'BROKEN< { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-github-env-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return empty object when GITHUB_ENV is not set', () => { + const original = process.env.GITHUB_ENV; + delete process.env.GITHUB_ENV; + + try { + const result = readGitHubEnvEntries(); + expect(result).toEqual({}); + } finally { + if (original !== undefined) process.env.GITHUB_ENV = original; + else delete process.env.GITHUB_ENV; + } + }); + + it('should read entries from GITHUB_ENV file', () => { + const original = process.env.GITHUB_ENV; + const envFile = path.join(tmpDir, 'github_env'); + fs.writeFileSync(envFile, 'GOROOT=/usr/local/go\nCARGO_HOME=/home/.cargo\n'); + process.env.GITHUB_ENV = envFile; + + try { + const result = readGitHubEnvEntries(); + expect(result.GOROOT).toBe('/usr/local/go'); + expect(result.CARGO_HOME).toBe('/home/.cargo'); + } finally { + if (original !== undefined) process.env.GITHUB_ENV = original; + else delete process.env.GITHUB_ENV; + } + }); + + it('should return empty object when file does not exist', () => { + const original = process.env.GITHUB_ENV; + process.env.GITHUB_ENV = '/nonexistent/path/github_env'; + + try { + const result = readGitHubEnvEntries(); + expect(result).toEqual({}); + } finally { + if (original !== undefined) process.env.GITHUB_ENV = original; + else delete process.env.GITHUB_ENV; + } + }); + }); + + describe('toolchain var fallback to GITHUB_ENV', () => { + let tmpDir: string; + const testConfig: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo "test"', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-toolchain-test', + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + }; + const testNetworkConfig = { + subnet: '172.30.0.0/24', + squidIp: '172.30.0.10', + agentIp: '172.30.0.20', + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-toolchain-')); + fs.mkdirSync(testConfig.workDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(testConfig.workDir, { recursive: true, force: true }); + }); + + it('should recover AWF_GOROOT from GITHUB_ENV when process.env.GOROOT is absent', () => { + const savedGoroot = process.env.GOROOT; + const savedGithubEnv = process.env.GITHUB_ENV; + delete process.env.GOROOT; + + const envFile = path.join(tmpDir, 'github_env'); + fs.writeFileSync(envFile, 'GOROOT=/opt/hostedtoolcache/go/1.22/x64\n'); + process.env.GITHUB_ENV = envFile; + + try { + const result = generateDockerCompose(testConfig, testNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.AWF_GOROOT).toBe('/opt/hostedtoolcache/go/1.22/x64'); + } finally { + if (savedGoroot !== undefined) process.env.GOROOT = savedGoroot; + else delete process.env.GOROOT; + if (savedGithubEnv !== undefined) process.env.GITHUB_ENV = savedGithubEnv; + else delete process.env.GITHUB_ENV; + } + }); + + it('should prefer process.env over GITHUB_ENV for toolchain vars', () => { + const savedGoroot = process.env.GOROOT; + const savedGithubEnv = process.env.GITHUB_ENV; + process.env.GOROOT = '/usr/local/go-from-env'; + + const envFile = path.join(tmpDir, 'github_env'); + fs.writeFileSync(envFile, 'GOROOT=/opt/go-from-file\n'); + process.env.GITHUB_ENV = envFile; + + try { + const result = generateDockerCompose(testConfig, testNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.AWF_GOROOT).toBe('/usr/local/go-from-env'); + } finally { + if (savedGoroot !== undefined) process.env.GOROOT = savedGoroot; + else delete process.env.GOROOT; + if (savedGithubEnv !== undefined) process.env.GITHUB_ENV = savedGithubEnv; + else delete process.env.GITHUB_ENV; + } + }); + }); + describe('mergeGitHubPathEntries', () => { it('should return current PATH when no github path entries', () => { const result = mergeGitHubPathEntries('/usr/bin:/usr/local/bin', []); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 802abd61..c02d69a2 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -259,6 +259,104 @@ export function readGitHubPathEntries(): string[] { } } +/** + * Reads key-value environment entries from the $GITHUB_ENV file. + * + * The Actions runner writes to this file when steps call `core.exportVariable()`. + * When AWF runs via `sudo`, non-standard env vars may be stripped. This function + * reads the file directly to recover them. + * + * Supports both formats used by the Actions runner: + * - Simple: `KEY=VALUE` (value may contain `=`) + * - Heredoc: `KEY< { + const githubEnvFile = process.env.GITHUB_ENV; + if (!githubEnvFile) { + logger.debug('GITHUB_ENV env var is not set; skipping $GITHUB_ENV file read'); + return {}; + } + + try { + const content = fs.readFileSync(githubEnvFile, 'utf-8'); + return parseGitHubEnvFile(content); + } catch { + logger.debug(`GITHUB_ENV file at '${githubEnvFile}' could not be read; skipping`); + return {}; + } +} + +/** + * Parses the content of a $GITHUB_ENV file into key-value pairs. + * @internal Exported for testing + */ +export function parseGitHubEnvFile(content: string): Record { + const result: Record = {}; + // Normalize CRLF to LF + const lines = content.replace(/\r\n/g, '\n').split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // Skip empty lines + if (line.trim() === '') { + i++; + continue; + } + + // Check for heredoc format: KEY< 0) { + const key = line.slice(0, eqIdx); + const value = line.slice(eqIdx + 1); + result[key] = value; + } + + i++; + } + + return result; +} + +/** + * Toolchain environment variables that should be recovered from $GITHUB_ENV + * when sudo strips them from process.env. These are set by setup-* actions + * (setup-go, setup-java, setup-dotnet, etc.) and are needed for correct + * tool resolution inside the agent container. + */ +const TOOLCHAIN_ENV_VARS = [ + 'GOROOT', + 'CARGO_HOME', + 'RUSTUP_HOME', + 'JAVA_HOME', + 'DOTNET_ROOT', + 'BUN_INSTALL', +] as const; + /** * Merges path entries from the $GITHUB_PATH file into a PATH string. * Entries from $GITHUB_PATH are prepended (they have higher priority, matching @@ -757,32 +855,18 @@ export function generateDockerCompose( logger.debug(`Merged ${githubPathEntries.length} path(s) from $GITHUB_PATH into AWF_HOST_PATH`); } } - // Go on GitHub Actions uses trimmed binaries that require GOROOT to be set - // Pass GOROOT as AWF_GOROOT so entrypoint.sh can export it in the chroot script - if (process.env.GOROOT) { - environment.AWF_GOROOT = process.env.GOROOT; - } - // Rust: Pass CARGO_HOME so entrypoint can add $CARGO_HOME/bin to PATH - if (process.env.CARGO_HOME) { - environment.AWF_CARGO_HOME = process.env.CARGO_HOME; - } - // Rust: Pass RUSTUP_HOME so rustc/cargo can find the toolchain - if (process.env.RUSTUP_HOME) { - environment.AWF_RUSTUP_HOME = process.env.RUSTUP_HOME; - } - // Java: Pass JAVA_HOME so entrypoint can add $JAVA_HOME/bin to PATH and set JAVA_HOME - if (process.env.JAVA_HOME) { - environment.AWF_JAVA_HOME = process.env.JAVA_HOME; - } - // .NET: Pass DOTNET_ROOT so entrypoint can add it to PATH and set DOTNET_ROOT - if (process.env.DOTNET_ROOT) { - environment.AWF_DOTNET_ROOT = process.env.DOTNET_ROOT; - } - // Bun: Pass BUN_INSTALL so entrypoint can add $BUN_INSTALL/bin to PATH - // Bun crashes with core dump when installed inside chroot (restricted /proc access), - // so it must be pre-installed on the host via setup-bun action - if (process.env.BUN_INSTALL) { - environment.AWF_BUN_INSTALL = process.env.BUN_INSTALL; + // Toolchain variables (GOROOT, CARGO_HOME, JAVA_HOME, etc.) set by setup-* actions. + // When AWF runs via sudo, these may be stripped from process.env. Fall back to + // reading $GITHUB_ENV file directly (analogous to readGitHubPathEntries for $GITHUB_PATH). + const githubEnvEntries = readGitHubEnvEntries(); + for (const varName of TOOLCHAIN_ENV_VARS) { + const value = process.env[varName] || githubEnvEntries[varName]; + if (value) { + environment[`AWF_${varName}`] = value; + if (!process.env[varName] && githubEnvEntries[varName]) { + logger.debug(`Recovered ${varName} from $GITHUB_ENV (sudo likely stripped it from process.env)`); + } + } } // If --exclude-env names were specified, add them to the excluded set From 9e836a5b751765b38c82c91e8ab2f2570fabc5fe Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 14 Apr 2026 14:25:58 -0700 Subject: [PATCH 2/2] Update src/docker-manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/docker-manager.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index c02d69a2..f83ec30f 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -858,12 +858,14 @@ export function generateDockerCompose( // Toolchain variables (GOROOT, CARGO_HOME, JAVA_HOME, etc.) set by setup-* actions. // When AWF runs via sudo, these may be stripped from process.env. Fall back to // reading $GITHUB_ENV file directly (analogous to readGitHubPathEntries for $GITHUB_PATH). - const githubEnvEntries = readGitHubEnvEntries(); + const runningUnderSudo = + process.getuid?.() === 0 && (Boolean(process.env.SUDO_UID) || Boolean(process.env.SUDO_USER)); + const githubEnvEntries = runningUnderSudo ? readGitHubEnvEntries() : {}; for (const varName of TOOLCHAIN_ENV_VARS) { - const value = process.env[varName] || githubEnvEntries[varName]; + const value = process.env[varName] || (runningUnderSudo ? githubEnvEntries[varName] : undefined); if (value) { environment[`AWF_${varName}`] = value; - if (!process.env[varName] && githubEnvEntries[varName]) { + if (!process.env[varName] && runningUnderSudo && githubEnvEntries[varName]) { logger.debug(`Recovered ${varName} from $GITHUB_ENV (sudo likely stripped it from process.env)`); } }