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
33 changes: 33 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <path>` 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)
Comment on lines +53 to +59
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --env-file docs claim (1) env-file values are “taken literally” and (2) precedence is built-in → env-all → env-file → env. In the current implementation, lines are trimmed during parsing, framework vars are protected from being overwritten by env-all/env-file, and env-file does not override env-all. Please adjust this section to match actual behavior (or update the implementation to match the documented precedence).

Suggested change
- 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)
- Values are read as raw text (no variable expansion). Leading and trailing whitespace on each line is trimmed by the parser, and quotes inside the value are preserved (no quote stripping).
**Precedence (lowest → highest):**
1. Built-in framework variables (proxy, DNS, etc.). For these keys, framework-provided values are always used and are **not** overridden by `--env-all` or `--env-file`.
2. `--env-file` variables
3. `--env-all` host variables
4. `--env` / `-e` explicit variables (highest priority for non-framework variables)

Copilot uses AI. Check for mistakes.

**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:**
Expand Down
61 changes: 61 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -2326,6 +2326,48 @@
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', () => {
Expand Down Expand Up @@ -2360,6 +2402,14 @@
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);
Expand Down Expand Up @@ -2471,6 +2521,17 @@
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', () => {
Expand Down
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,10 @@ program
'Pass all host environment variables to container (excludes system vars like PATH)',
false
)
.option(
'--env-file <path>',
'Read environment variables from a file (KEY=VALUE format, one per line)'
)
.option(
'-v, --mount <host_path:container_path[:mode]>',
'Volume mount (repeatable). Format: host_path:container_path[:ro|rw]',
Expand Down Expand Up @@ -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}`);
Comment on lines +1579 to +1580
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --env-file validation only checks existsSync. If the path exists but is a directory or is unreadable, the error will surface later (likely as an uncaught read error) and the CLI message won’t be as clear. Consider validating that the path is a readable regular file (e.g., statSync().isFile() + accessSync(R_OK)) and exiting with a consistent error message.

Suggested change
if (!fs.existsSync(options.envFile)) {
logger.error(`--env-file: file not found: ${options.envFile}`);
try {
if (!fs.existsSync(options.envFile)) {
logger.error(`--env-file: file not found: ${options.envFile}`);
process.exit(1);
}
const envFileStat = fs.statSync(options.envFile);
if (!envFileStat.isFile()) {
logger.error(`--env-file: not a regular file: ${options.envFile}`);
process.exit(1);
}
fs.accessSync(options.envFile, fs.constants.R_OK);
} catch (error) {
logger.error(`--env-file: cannot read file: ${options.envFile}`);

Copilot uses AI. Check for mistakes.
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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
144 changes: 143 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, 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';
Expand Down Expand Up @@ -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<string, string>;

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<string, string>;

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<string, string>;

// AWF_DNS_SERVERS is set by the framework; file should NOT override it
expect(env.AWF_DNS_SERVERS).not.toBe('1.1.1.1');
Comment on lines +1373 to +1381
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test’s comment/intent doesn’t match the actual ordering in generateDockerCompose: AWF_DNS_SERVERS is assigned after the env-file injection block, so the env-file value would be overwritten later regardless of the “don’t clobber existing vars” guard. If the goal is to verify env-file doesn’t override already-set framework vars, use a variable that is definitely set before env-file processing (e.g., one of the proxy vars or AWF_HOST_PATH) or assert the exact precedence rules you want.

This issue also appears on line 3503 of the same file.

Suggested change
// 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<string, string>;
// AWF_DNS_SERVERS is set by the framework; file should NOT override it
expect(env.AWF_DNS_SERVERS).not.toBe('1.1.1.1');
// AWF_HOST_PATH is set before envFile processing; file should not clobber it
fs.writeFileSync(envFile, 'AWF_HOST_PATH=/malicious/path\n');
const config = { ...mockConfig, envFile };
const result = generateDockerCompose(config, mockNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
// AWF_HOST_PATH is set by the framework; file should NOT override it
expect(env.AWF_HOST_PATH).not.toBe('/malicious/path');

Copilot uses AI. Check for mistakes.
});

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<string, string>;

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<string, string>;

expect(env.FOO).toBe('bar');
});
});

it('should configure DNS to use Google DNS', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
const agent = result.services.agent;
Expand Down Expand Up @@ -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();
});
});
});
40 changes: 40 additions & 0 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const content = fs.readFileSync(filePath, 'utf-8');
const result: Record<string, string> = {};
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];
}
Comment on lines +223 to +235
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readEnvFile() JSDoc says each non-comment line “must match” KEY=VALUE, but the implementation silently ignores non-matching lines (including invalid keys) instead of throwing or surfacing an error. Either update the docs to say invalid lines are ignored, or change the function to fail fast with a helpful error including the line number/content.

This issue also appears on line 228 of the same file.

Suggested change
* @throws {Error} If the file cannot be read
*/
export function readEnvFile(filePath: string): Record<string, string> {
const content = fs.readFileSync(filePath, 'utf-8');
const result: Record<string, string> = {};
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];
}
* @throws {Error} If the file cannot be read or contains an invalid line
*/
export function readEnvFile(filePath: string): Record<string, string> {
const content = fs.readFileSync(filePath, 'utf-8');
const result: Record<string, string> = {};
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
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) {
throw new Error(
`Invalid env file line in ${filePath} at line ${i + 1}: "${raw}" ` +
'(expected KEY=VALUE with KEY matching /^[A-Za-z_][A-Za-z0-9_]*$/)'
);
}
result[match[1]] = match[2];

Copilot uses AI. Check for mistakes.
}
return result;
}

/**
* Gets existing Docker network subnets to avoid conflicts
*/
Expand Down Expand Up @@ -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)) {
Comment on lines +644 to +648
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--env-file is documented as having higher precedence than --env-all, but this block only sets values when environment does not already have the key. Since --env-all runs earlier and also populates environment, env-file entries won’t override env-all entries. Either adjust the precedence (allow env-file to override env-all while still protecting framework/excluded vars) or update the docs/comments/tests to reflect the actual “first writer wins” behavior.

Suggested change
// 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 variables from --env-file (applied after --env-all but before --env flags;
// env-file entries override env-all for non-excluded vars, while explicit --env flags still win)
if (config.envFile) {
const fileEnv = readEnvFile(config.envFile);
for (const [key, value] of Object.entries(fileEnv)) {
if (!EXCLUDED_ENV_VARS.has(key)) {

Copilot uses AI. Check for mistakes.
environment[key] = value;
}
}
}

// Additional environment variables from --env flags (these override everything)
if (config.additionalEnv) {
Object.assign(environment, config.additionalEnv);
Expand Down
Loading
Loading