Skip to content

Commit 06b99eb

Browse files
lpcoxCopilotCopilot
authored
fix: recover toolchain env vars from $GITHUB_ENV file (#1977)
* 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> * Update src/docker-manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 501f0b9 commit 06b99eb

2 files changed

Lines changed: 292 additions & 27 deletions

File tree

src/docker-manager.test.ts

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
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';
1+
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';
22
import { WrapperConfig } from './types';
33
import * as fs from 'fs';
44
import * as path from 'path';
@@ -4329,6 +4329,185 @@ describe('docker-manager', () => {
43294329
});
43304330
});
43314331

4332+
describe('parseGitHubEnvFile', () => {
4333+
it('should parse simple KEY=VALUE entries', () => {
4334+
const result = parseGitHubEnvFile('GOROOT=/usr/local/go\nJAVA_HOME=/usr/lib/jvm/java-17\n');
4335+
expect(result).toEqual({
4336+
GOROOT: '/usr/local/go',
4337+
JAVA_HOME: '/usr/lib/jvm/java-17',
4338+
});
4339+
});
4340+
4341+
it('should handle values containing = characters', () => {
4342+
const result = parseGitHubEnvFile('MY_VAR=key=value=extra\n');
4343+
expect(result).toEqual({ MY_VAR: 'key=value=extra' });
4344+
});
4345+
4346+
it('should handle heredoc multiline values', () => {
4347+
const content = 'MULTI_LINE<<EOF\nline1\nline2\nline3\nEOF\n';
4348+
const result = parseGitHubEnvFile(content);
4349+
expect(result).toEqual({ MULTI_LINE: 'line1\nline2\nline3' });
4350+
});
4351+
4352+
it('should handle CRLF line endings', () => {
4353+
const result = parseGitHubEnvFile('GOROOT=/usr/local/go\r\nJAVA_HOME=/usr/lib/jvm\r\n');
4354+
expect(result).toEqual({
4355+
GOROOT: '/usr/local/go',
4356+
JAVA_HOME: '/usr/lib/jvm',
4357+
});
4358+
});
4359+
4360+
it('should handle mixed simple and heredoc entries', () => {
4361+
const content = 'SIMPLE=value\nHEREDOC<<END\nmulti\nline\nEND\nANOTHER=val2\n';
4362+
const result = parseGitHubEnvFile(content);
4363+
expect(result).toEqual({
4364+
SIMPLE: 'value',
4365+
HEREDOC: 'multi\nline',
4366+
ANOTHER: 'val2',
4367+
});
4368+
});
4369+
4370+
it('should skip empty lines', () => {
4371+
const result = parseGitHubEnvFile('\n\nGOROOT=/go\n\n');
4372+
expect(result).toEqual({ GOROOT: '/go' });
4373+
});
4374+
4375+
it('should return empty object for empty content', () => {
4376+
expect(parseGitHubEnvFile('')).toEqual({});
4377+
});
4378+
4379+
it('should handle unterminated heredoc gracefully', () => {
4380+
const content = 'BROKEN<<EOF\nline1\nline2';
4381+
const result = parseGitHubEnvFile(content);
4382+
expect(result).toEqual({ BROKEN: 'line1\nline2' });
4383+
});
4384+
});
4385+
4386+
describe('readGitHubEnvEntries', () => {
4387+
let tmpDir: string;
4388+
4389+
beforeEach(() => {
4390+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-github-env-'));
4391+
});
4392+
4393+
afterEach(() => {
4394+
fs.rmSync(tmpDir, { recursive: true, force: true });
4395+
});
4396+
4397+
it('should return empty object when GITHUB_ENV is not set', () => {
4398+
const original = process.env.GITHUB_ENV;
4399+
delete process.env.GITHUB_ENV;
4400+
4401+
try {
4402+
const result = readGitHubEnvEntries();
4403+
expect(result).toEqual({});
4404+
} finally {
4405+
if (original !== undefined) process.env.GITHUB_ENV = original;
4406+
else delete process.env.GITHUB_ENV;
4407+
}
4408+
});
4409+
4410+
it('should read entries from GITHUB_ENV file', () => {
4411+
const original = process.env.GITHUB_ENV;
4412+
const envFile = path.join(tmpDir, 'github_env');
4413+
fs.writeFileSync(envFile, 'GOROOT=/usr/local/go\nCARGO_HOME=/home/.cargo\n');
4414+
process.env.GITHUB_ENV = envFile;
4415+
4416+
try {
4417+
const result = readGitHubEnvEntries();
4418+
expect(result.GOROOT).toBe('/usr/local/go');
4419+
expect(result.CARGO_HOME).toBe('/home/.cargo');
4420+
} finally {
4421+
if (original !== undefined) process.env.GITHUB_ENV = original;
4422+
else delete process.env.GITHUB_ENV;
4423+
}
4424+
});
4425+
4426+
it('should return empty object when file does not exist', () => {
4427+
const original = process.env.GITHUB_ENV;
4428+
process.env.GITHUB_ENV = '/nonexistent/path/github_env';
4429+
4430+
try {
4431+
const result = readGitHubEnvEntries();
4432+
expect(result).toEqual({});
4433+
} finally {
4434+
if (original !== undefined) process.env.GITHUB_ENV = original;
4435+
else delete process.env.GITHUB_ENV;
4436+
}
4437+
});
4438+
});
4439+
4440+
describe('toolchain var fallback to GITHUB_ENV', () => {
4441+
let tmpDir: string;
4442+
const testConfig: WrapperConfig = {
4443+
allowedDomains: ['github.com'],
4444+
agentCommand: 'echo "test"',
4445+
logLevel: 'info',
4446+
keepContainers: false,
4447+
workDir: '/tmp/awf-toolchain-test',
4448+
buildLocal: false,
4449+
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
4450+
imageTag: 'latest',
4451+
};
4452+
const testNetworkConfig = {
4453+
subnet: '172.30.0.0/24',
4454+
squidIp: '172.30.0.10',
4455+
agentIp: '172.30.0.20',
4456+
};
4457+
4458+
beforeEach(() => {
4459+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-toolchain-'));
4460+
fs.mkdirSync(testConfig.workDir, { recursive: true });
4461+
});
4462+
4463+
afterEach(() => {
4464+
fs.rmSync(tmpDir, { recursive: true, force: true });
4465+
fs.rmSync(testConfig.workDir, { recursive: true, force: true });
4466+
});
4467+
4468+
it('should recover AWF_GOROOT from GITHUB_ENV when process.env.GOROOT is absent', () => {
4469+
const savedGoroot = process.env.GOROOT;
4470+
const savedGithubEnv = process.env.GITHUB_ENV;
4471+
delete process.env.GOROOT;
4472+
4473+
const envFile = path.join(tmpDir, 'github_env');
4474+
fs.writeFileSync(envFile, 'GOROOT=/opt/hostedtoolcache/go/1.22/x64\n');
4475+
process.env.GITHUB_ENV = envFile;
4476+
4477+
try {
4478+
const result = generateDockerCompose(testConfig, testNetworkConfig);
4479+
const env = result.services.agent.environment as Record<string, string>;
4480+
expect(env.AWF_GOROOT).toBe('/opt/hostedtoolcache/go/1.22/x64');
4481+
} finally {
4482+
if (savedGoroot !== undefined) process.env.GOROOT = savedGoroot;
4483+
else delete process.env.GOROOT;
4484+
if (savedGithubEnv !== undefined) process.env.GITHUB_ENV = savedGithubEnv;
4485+
else delete process.env.GITHUB_ENV;
4486+
}
4487+
});
4488+
4489+
it('should prefer process.env over GITHUB_ENV for toolchain vars', () => {
4490+
const savedGoroot = process.env.GOROOT;
4491+
const savedGithubEnv = process.env.GITHUB_ENV;
4492+
process.env.GOROOT = '/usr/local/go-from-env';
4493+
4494+
const envFile = path.join(tmpDir, 'github_env');
4495+
fs.writeFileSync(envFile, 'GOROOT=/opt/go-from-file\n');
4496+
process.env.GITHUB_ENV = envFile;
4497+
4498+
try {
4499+
const result = generateDockerCompose(testConfig, testNetworkConfig);
4500+
const env = result.services.agent.environment as Record<string, string>;
4501+
expect(env.AWF_GOROOT).toBe('/usr/local/go-from-env');
4502+
} finally {
4503+
if (savedGoroot !== undefined) process.env.GOROOT = savedGoroot;
4504+
else delete process.env.GOROOT;
4505+
if (savedGithubEnv !== undefined) process.env.GITHUB_ENV = savedGithubEnv;
4506+
else delete process.env.GITHUB_ENV;
4507+
}
4508+
});
4509+
});
4510+
43324511
describe('mergeGitHubPathEntries', () => {
43334512
it('should return current PATH when no github path entries', () => {
43344513
const result = mergeGitHubPathEntries('/usr/bin:/usr/local/bin', []);

src/docker-manager.ts

Lines changed: 112 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,104 @@ export function readGitHubPathEntries(): string[] {
259259
}
260260
}
261261

262+
/**
263+
* Reads key-value environment entries from the $GITHUB_ENV file.
264+
*
265+
* The Actions runner writes to this file when steps call `core.exportVariable()`.
266+
* When AWF runs via `sudo`, non-standard env vars may be stripped. This function
267+
* reads the file directly to recover them.
268+
*
269+
* Supports both formats used by the Actions runner:
270+
* - Simple: `KEY=VALUE` (value may contain `=`)
271+
* - Heredoc: `KEY<<DELIMITER\nVALUE_LINES\nDELIMITER`
272+
*
273+
* @returns Map of environment variable names to values
274+
* @internal Exported for testing
275+
*/
276+
export function readGitHubEnvEntries(): Record<string, string> {
277+
const githubEnvFile = process.env.GITHUB_ENV;
278+
if (!githubEnvFile) {
279+
logger.debug('GITHUB_ENV env var is not set; skipping $GITHUB_ENV file read');
280+
return {};
281+
}
282+
283+
try {
284+
const content = fs.readFileSync(githubEnvFile, 'utf-8');
285+
return parseGitHubEnvFile(content);
286+
} catch {
287+
logger.debug(`GITHUB_ENV file at '${githubEnvFile}' could not be read; skipping`);
288+
return {};
289+
}
290+
}
291+
292+
/**
293+
* Parses the content of a $GITHUB_ENV file into key-value pairs.
294+
* @internal Exported for testing
295+
*/
296+
export function parseGitHubEnvFile(content: string): Record<string, string> {
297+
const result: Record<string, string> = {};
298+
// Normalize CRLF to LF
299+
const lines = content.replace(/\r\n/g, '\n').split('\n');
300+
let i = 0;
301+
302+
while (i < lines.length) {
303+
const line = lines[i];
304+
305+
// Skip empty lines
306+
if (line.trim() === '') {
307+
i++;
308+
continue;
309+
}
310+
311+
// Check for heredoc format: KEY<<DELIMITER
312+
const heredocMatch = line.match(/^([^=]+)<<(.+)$/);
313+
if (heredocMatch) {
314+
const key = heredocMatch[1];
315+
const delimiter = heredocMatch[2];
316+
const valueLines: string[] = [];
317+
i++;
318+
319+
// Collect lines until we find the delimiter
320+
while (i < lines.length && lines[i] !== delimiter) {
321+
valueLines.push(lines[i]);
322+
i++;
323+
}
324+
// Skip the closing delimiter line
325+
if (i < lines.length) i++;
326+
327+
result[key] = valueLines.join('\n');
328+
continue;
329+
}
330+
331+
// Simple format: KEY=VALUE (split on first = only)
332+
const eqIdx = line.indexOf('=');
333+
if (eqIdx > 0) {
334+
const key = line.slice(0, eqIdx);
335+
const value = line.slice(eqIdx + 1);
336+
result[key] = value;
337+
}
338+
339+
i++;
340+
}
341+
342+
return result;
343+
}
344+
345+
/**
346+
* Toolchain environment variables that should be recovered from $GITHUB_ENV
347+
* when sudo strips them from process.env. These are set by setup-* actions
348+
* (setup-go, setup-java, setup-dotnet, etc.) and are needed for correct
349+
* tool resolution inside the agent container.
350+
*/
351+
const TOOLCHAIN_ENV_VARS = [
352+
'GOROOT',
353+
'CARGO_HOME',
354+
'RUSTUP_HOME',
355+
'JAVA_HOME',
356+
'DOTNET_ROOT',
357+
'BUN_INSTALL',
358+
] as const;
359+
262360
/**
263361
* Merges path entries from the $GITHUB_PATH file into a PATH string.
264362
* Entries from $GITHUB_PATH are prepended (they have higher priority, matching
@@ -757,32 +855,20 @@ export function generateDockerCompose(
757855
logger.debug(`Merged ${githubPathEntries.length} path(s) from $GITHUB_PATH into AWF_HOST_PATH`);
758856
}
759857
}
760-
// Go on GitHub Actions uses trimmed binaries that require GOROOT to be set
761-
// Pass GOROOT as AWF_GOROOT so entrypoint.sh can export it in the chroot script
762-
if (process.env.GOROOT) {
763-
environment.AWF_GOROOT = process.env.GOROOT;
764-
}
765-
// Rust: Pass CARGO_HOME so entrypoint can add $CARGO_HOME/bin to PATH
766-
if (process.env.CARGO_HOME) {
767-
environment.AWF_CARGO_HOME = process.env.CARGO_HOME;
768-
}
769-
// Rust: Pass RUSTUP_HOME so rustc/cargo can find the toolchain
770-
if (process.env.RUSTUP_HOME) {
771-
environment.AWF_RUSTUP_HOME = process.env.RUSTUP_HOME;
772-
}
773-
// Java: Pass JAVA_HOME so entrypoint can add $JAVA_HOME/bin to PATH and set JAVA_HOME
774-
if (process.env.JAVA_HOME) {
775-
environment.AWF_JAVA_HOME = process.env.JAVA_HOME;
776-
}
777-
// .NET: Pass DOTNET_ROOT so entrypoint can add it to PATH and set DOTNET_ROOT
778-
if (process.env.DOTNET_ROOT) {
779-
environment.AWF_DOTNET_ROOT = process.env.DOTNET_ROOT;
780-
}
781-
// Bun: Pass BUN_INSTALL so entrypoint can add $BUN_INSTALL/bin to PATH
782-
// Bun crashes with core dump when installed inside chroot (restricted /proc access),
783-
// so it must be pre-installed on the host via setup-bun action
784-
if (process.env.BUN_INSTALL) {
785-
environment.AWF_BUN_INSTALL = process.env.BUN_INSTALL;
858+
// Toolchain variables (GOROOT, CARGO_HOME, JAVA_HOME, etc.) set by setup-* actions.
859+
// When AWF runs via sudo, these may be stripped from process.env. Fall back to
860+
// reading $GITHUB_ENV file directly (analogous to readGitHubPathEntries for $GITHUB_PATH).
861+
const runningUnderSudo =
862+
process.getuid?.() === 0 && (Boolean(process.env.SUDO_UID) || Boolean(process.env.SUDO_USER));
863+
const githubEnvEntries = runningUnderSudo ? readGitHubEnvEntries() : {};
864+
for (const varName of TOOLCHAIN_ENV_VARS) {
865+
const value = process.env[varName] || (runningUnderSudo ? githubEnvEntries[varName] : undefined);
866+
if (value) {
867+
environment[`AWF_${varName}`] = value;
868+
if (!process.env[varName] && runningUnderSudo && githubEnvEntries[varName]) {
869+
logger.debug(`Recovered ${varName} from $GITHUB_ENV (sudo likely stripped it from process.env)`);
870+
}
871+
}
786872
}
787873

788874
// If --exclude-env names were specified, add them to the excluded set

0 commit comments

Comments
 (0)