diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index ea68a68b..cfa307b0 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -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 diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 7663f381..7801c05f 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -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[]; @@ -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`); @@ -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; + + 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; + + 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; + + 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; @@ -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); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index e60deb22..812c9187 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -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'; + } + // 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) { @@ -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( @@ -2202,7 +2214,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { // 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);