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
11 changes: 11 additions & 0 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,17 @@ AWFEOF
echo 'fi' >> "/host${SCRIPT_FILE}"
echo 'mkdir -p "$NPM_CONFIG_PREFIX/bin" 2>/dev/null' >> "/host${SCRIPT_FILE}"
echo 'export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"' >> "/host${SCRIPT_FILE}"
if [ "${AWF_REQUIRE_NODE:-}" = "1" ]; then
cat >> "/host${SCRIPT_FILE}" << 'AWFEOF'
if ! command -v node >/dev/null 2>&1; then
echo "[entrypoint][ERROR] Copilot CLI requires Node.js, but 'node' is not available inside AWF chroot." >&2
echo "[entrypoint][ERROR] Ensure Node.js is installed on the runner and reachable from PATH inside the chroot." >&2
echo "[entrypoint][ERROR] If using setup-node or nvm, verify the install path is present and bind-mounted into /host." >&2
echo "[entrypoint][ERROR] Example locations include /opt/hostedtoolcache/... and $HOME/.nvm/..." >&2
exit 127
fi
AWFEOF
fi
# Append the actual command arguments
# Docker CMD passes commands as ['/bin/bash', '-c', 'command_string'].
# Instead of writing the full [bash, -c, cmd] via printf '%q' (which creates
Expand Down
39 changes: 37 additions & 2 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ describe('docker-manager', () => {
expect(volumes).toContain(`${workspaceDir}:/host${workspaceDir}:rw`);
});

it('should mount Rust toolchain, npm cache, and CLI state directories', () => {
it('should mount Rust toolchain, Node/npm caches, and CLI state directories', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
const agent = result.services.agent;
const volumes = agent.volumes as string[];
Expand All @@ -868,6 +868,8 @@ describe('docker-manager', () => {
expect(volumes).toContain(`${homeDir}/.rustup:/host${homeDir}/.rustup:rw`);
// npm cache
expect(volumes).toContain(`${homeDir}/.npm:/host${homeDir}/.npm:rw`);
// nvm-managed Node.js cache/installations
expect(volumes).toContain(`${homeDir}/.nvm:/host${homeDir}/.nvm:rw`);
// CLI state directories
expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`);
expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`);
Expand Down Expand Up @@ -944,6 +946,39 @@ describe('docker-manager', () => {
expect(environment.AWF_CHROOT_ENABLED).toBe('true');
});

it('should set AWF_REQUIRE_NODE when running Copilot CLI command', () => {
const result = generateDockerCompose(
{ ...mockConfig, agentCommand: 'copilot --version' },
mockNetworkConfig,
);
const environment = result.services.agent.environment as Record<string, string>;

expect(environment.AWF_REQUIRE_NODE).toBe('1');
});

it.each([
{ copilotGithubToken: 'ghu_test_token' },
{ copilotApiKey: 'cpat_test_key' },
])('should set AWF_REQUIRE_NODE when Copilot auth config is present: %o', (copilotConfig) => {
const result = generateDockerCompose(
{ ...mockConfig, agentCommand: 'echo test', ...copilotConfig },
mockNetworkConfig,
);
const environment = result.services.agent.environment as Record<string, string>;

expect(environment.AWF_REQUIRE_NODE).toBe('1');
});

it('should not set AWF_REQUIRE_NODE for non-Copilot commands', () => {
const result = generateDockerCompose(
{ ...mockConfig, agentCommand: 'echo test' },
mockNetworkConfig,
);
const environment = result.services.agent.environment as Record<string, string>;

expect(environment.AWF_REQUIRE_NODE).toBeUndefined();
});

it('should pass GOROOT, CARGO_HOME, RUSTUP_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when env vars are set', () => {
const originalGoroot = process.env.GOROOT;
const originalCargoHome = process.env.CARGO_HOME;
Expand Down Expand Up @@ -3641,7 +3676,7 @@ describe('docker-manager', () => {
// Verify chroot home subdirectories were created
const expectedDirs = [
'.copilot', '.cache', '.config', '.local',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm', '.nvm',
];
for (const dir of expectedDirs) {
expect(fs.existsSync(path.join(fakeHome, dir))).toBe(true);
Expand Down
14 changes: 13 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,15 @@ export function generateDockerCompose(
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_API_KEY,COPILOT_PROVIDER_API_KEY',
};

// Copilot CLI requires Node.js. Ask the agent entrypoint to fail fast with a
// clear diagnostic if node is not reachable inside the chroot before startup.
const commandExecutable = config.agentCommand.trim().split(/\s+/, 1)[0] || '';
const commandExecutableBase = path.posix.basename(commandExecutable.replace(/\\/g, '/'));
const isCopilotCommand = commandExecutableBase.toLowerCase() === 'copilot';
if (config.copilotGithubToken || config.copilotApiKey || isCopilotCommand) {
environment.AWF_REQUIRE_NODE = '1';
}
Comment on lines +821 to +825
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The new AWF_REQUIRE_NODE behavior is also enabled when Copilot auth env is present (copilotGithubToken/copilotApiKey), not just when the command executable is copilot. There are tests for the copilot ... command case, but none asserting the env-var-driven case; adding a test would help prevent regressions and ensure this broader gating remains intentional.

Copilot uses AI. Check for mistakes.

// When api-proxy is enabled with Copilot, set placeholder tokens early
// so --env-all won't override them with real values from host environment
if (config.enableApiProxy && config.copilotGithubToken) {
Expand Down Expand Up @@ -1222,6 +1231,9 @@ export function generateDockerCompose(
// npm requires write access to ~/.npm for caching packages and writing logs
agentVolumes.push(`${effectiveHome}/.npm:/host${effectiveHome}/.npm:rw`);

// Mount ~/.nvm for Node.js installations managed by nvm on self-hosted runners
agentVolumes.push(`${effectiveHome}/.nvm:/host${effectiveHome}/.nvm:rw`);

// Minimal /etc - only what's needed for runtime
// Note: /etc/shadow is NOT mounted (contains password hashes)
agentVolumes.push(
Expand Down Expand Up @@ -2202,7 +2214,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
// Ensure source directories for subdirectory mounts exist with correct ownership
const chrootHomeDirs = [
'.copilot', '.cache', '.config', '.local',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm', '.nvm',
];
for (const dir of chrootHomeDirs) {
const dirPath = path.join(effectiveHome, dir);
Expand Down
Loading