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
181 changes: 180 additions & 1 deletion src/docker-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<<EOF\nline1\nline2\nline3\nEOF\n';
const result = parseGitHubEnvFile(content);
expect(result).toEqual({ MULTI_LINE: 'line1\nline2\nline3' });
});

it('should handle CRLF line endings', () => {
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<<END\nmulti\nline\nEND\nANOTHER=val2\n';
const result = parseGitHubEnvFile(content);
expect(result).toEqual({
SIMPLE: 'value',
HEREDOC: 'multi\nline',
ANOTHER: 'val2',
});
});

it('should skip empty lines', () => {
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<<EOF\nline1\nline2';
const result = parseGitHubEnvFile(content);
expect(result).toEqual({ BROKEN: 'line1\nline2' });
});
});

describe('readGitHubEnvEntries', () => {
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<string, string>;
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<string, string>;
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', []);
Expand Down
138 changes: 112 additions & 26 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<<DELIMITER\nVALUE_LINES\nDELIMITER`
*
* @returns Map of environment variable names to values
* @internal Exported for testing
*/
export function readGitHubEnvEntries(): Record<string, string> {
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<string, string> {
const result: Record<string, string> = {};
// 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<<DELIMITER
const heredocMatch = line.match(/^([^=]+)<<(.+)$/);
if (heredocMatch) {
const key = heredocMatch[1];
const delimiter = heredocMatch[2];
const valueLines: string[] = [];
i++;

// Collect lines until we find the delimiter
while (i < lines.length && lines[i] !== delimiter) {
valueLines.push(lines[i]);
i++;
}
// Skip the closing delimiter line
if (i < lines.length) i++;

result[key] = valueLines.join('\n');
continue;
}

// Simple format: KEY=VALUE (split on first = only)
const eqIdx = line.indexOf('=');
if (eqIdx > 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
Expand Down Expand Up @@ -757,32 +855,20 @@ 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 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] || (runningUnderSudo ? githubEnvEntries[varName] : undefined);
if (value) {
environment[`AWF_${varName}`] = value;
if (!process.env[varName] && runningUnderSudo && 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
Expand Down
Loading