Skip to content

Commit 6e31d9f

Browse files
authored
Ensure Copilot bootstrap can find Node.js inside AWF chroot (#2160)
* Initial plan * fix: make node available and validated for copilot chroot startup Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/345ed70b-1477-4fae-bb25-801df1d3fab2 * fix: harden copilot node preflight detection and diagnostics Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/345ed70b-1477-4fae-bb25-801df1d3fab2 * fix: avoid broad copilot matching in node preflight gate Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/345ed70b-1477-4fae-bb25-801df1d3fab2 * fix: tighten copilot executable detection for node preflight Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/345ed70b-1477-4fae-bb25-801df1d3fab2 * fix: cover copilot auth node preflight and stderr diagnostics Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/b0b3e2e5-02f9-4241-ab98-9abfb180b848 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 4c62bc9 commit 6e31d9f

3 files changed

Lines changed: 61 additions & 3 deletions

File tree

containers/agent/entrypoint.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,17 @@ AWFEOF
706706
echo 'fi' >> "/host${SCRIPT_FILE}"
707707
echo 'mkdir -p "$NPM_CONFIG_PREFIX/bin" 2>/dev/null' >> "/host${SCRIPT_FILE}"
708708
echo 'export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"' >> "/host${SCRIPT_FILE}"
709+
if [ "${AWF_REQUIRE_NODE:-}" = "1" ]; then
710+
cat >> "/host${SCRIPT_FILE}" << 'AWFEOF'
711+
if ! command -v node >/dev/null 2>&1; then
712+
echo "[entrypoint][ERROR] Copilot CLI requires Node.js, but 'node' is not available inside AWF chroot." >&2
713+
echo "[entrypoint][ERROR] Ensure Node.js is installed on the runner and reachable from PATH inside the chroot." >&2
714+
echo "[entrypoint][ERROR] If using setup-node or nvm, verify the install path is present and bind-mounted into /host." >&2
715+
echo "[entrypoint][ERROR] Example locations include /opt/hostedtoolcache/... and $HOME/.nvm/..." >&2
716+
exit 127
717+
fi
718+
AWFEOF
719+
fi
709720
# Append the actual command arguments
710721
# Docker CMD passes commands as ['/bin/bash', '-c', 'command_string'].
711722
# Instead of writing the full [bash, -c, cmd] via printf '%q' (which creates

src/docker-manager.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ describe('docker-manager', () => {
857857
expect(volumes).toContain(`${workspaceDir}:/host${workspaceDir}:rw`);
858858
});
859859

860-
it('should mount Rust toolchain, npm cache, and CLI state directories', () => {
860+
it('should mount Rust toolchain, Node/npm caches, and CLI state directories', () => {
861861
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
862862
const agent = result.services.agent;
863863
const volumes = agent.volumes as string[];
@@ -868,6 +868,8 @@ describe('docker-manager', () => {
868868
expect(volumes).toContain(`${homeDir}/.rustup:/host${homeDir}/.rustup:rw`);
869869
// npm cache
870870
expect(volumes).toContain(`${homeDir}/.npm:/host${homeDir}/.npm:rw`);
871+
// nvm-managed Node.js cache/installations
872+
expect(volumes).toContain(`${homeDir}/.nvm:/host${homeDir}/.nvm:rw`);
871873
// CLI state directories
872874
expect(volumes).toContain(`${homeDir}/.claude:/host${homeDir}/.claude:rw`);
873875
expect(volumes).toContain(`${homeDir}/.anthropic:/host${homeDir}/.anthropic:rw`);
@@ -944,6 +946,39 @@ describe('docker-manager', () => {
944946
expect(environment.AWF_CHROOT_ENABLED).toBe('true');
945947
});
946948

949+
it('should set AWF_REQUIRE_NODE when running Copilot CLI command', () => {
950+
const result = generateDockerCompose(
951+
{ ...mockConfig, agentCommand: 'copilot --version' },
952+
mockNetworkConfig,
953+
);
954+
const environment = result.services.agent.environment as Record<string, string>;
955+
956+
expect(environment.AWF_REQUIRE_NODE).toBe('1');
957+
});
958+
959+
it.each([
960+
{ copilotGithubToken: 'ghu_test_token' },
961+
{ copilotApiKey: 'cpat_test_key' },
962+
])('should set AWF_REQUIRE_NODE when Copilot auth config is present: %o', (copilotConfig) => {
963+
const result = generateDockerCompose(
964+
{ ...mockConfig, agentCommand: 'echo test', ...copilotConfig },
965+
mockNetworkConfig,
966+
);
967+
const environment = result.services.agent.environment as Record<string, string>;
968+
969+
expect(environment.AWF_REQUIRE_NODE).toBe('1');
970+
});
971+
972+
it('should not set AWF_REQUIRE_NODE for non-Copilot commands', () => {
973+
const result = generateDockerCompose(
974+
{ ...mockConfig, agentCommand: 'echo test' },
975+
mockNetworkConfig,
976+
);
977+
const environment = result.services.agent.environment as Record<string, string>;
978+
979+
expect(environment.AWF_REQUIRE_NODE).toBeUndefined();
980+
});
981+
947982
it('should pass GOROOT, CARGO_HOME, RUSTUP_HOME, JAVA_HOME, DOTNET_ROOT, BUN_INSTALL to container when env vars are set', () => {
948983
const originalGoroot = process.env.GOROOT;
949984
const originalCargoHome = process.env.CARGO_HOME;
@@ -3641,7 +3676,7 @@ describe('docker-manager', () => {
36413676
// Verify chroot home subdirectories were created
36423677
const expectedDirs = [
36433678
'.copilot', '.cache', '.config', '.local',
3644-
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
3679+
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm', '.nvm',
36453680
];
36463681
for (const dir of expectedDirs) {
36473682
expect(fs.existsSync(path.join(fakeHome, dir))).toBe(true);

src/docker-manager.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,15 @@ export function generateDockerCompose(
815815
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',
816816
};
817817

818+
// Copilot CLI requires Node.js. Ask the agent entrypoint to fail fast with a
819+
// clear diagnostic if node is not reachable inside the chroot before startup.
820+
const commandExecutable = config.agentCommand.trim().split(/\s+/, 1)[0] || '';
821+
const commandExecutableBase = path.posix.basename(commandExecutable.replace(/\\/g, '/'));
822+
const isCopilotCommand = commandExecutableBase.toLowerCase() === 'copilot';
823+
if (config.copilotGithubToken || config.copilotApiKey || isCopilotCommand) {
824+
environment.AWF_REQUIRE_NODE = '1';
825+
}
826+
818827
// When api-proxy is enabled with Copilot, set placeholder tokens early
819828
// so --env-all won't override them with real values from host environment
820829
if (config.enableApiProxy && config.copilotGithubToken) {
@@ -1222,6 +1231,9 @@ export function generateDockerCompose(
12221231
// npm requires write access to ~/.npm for caching packages and writing logs
12231232
agentVolumes.push(`${effectiveHome}/.npm:/host${effectiveHome}/.npm:rw`);
12241233

1234+
// Mount ~/.nvm for Node.js installations managed by nvm on self-hosted runners
1235+
agentVolumes.push(`${effectiveHome}/.nvm:/host${effectiveHome}/.nvm:rw`);
1236+
12251237
// Minimal /etc - only what's needed for runtime
12261238
// Note: /etc/shadow is NOT mounted (contains password hashes)
12271239
agentVolumes.push(
@@ -2202,7 +2214,7 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
22022214
// Ensure source directories for subdirectory mounts exist with correct ownership
22032215
const chrootHomeDirs = [
22042216
'.copilot', '.cache', '.config', '.local',
2205-
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm',
2217+
'.anthropic', '.claude', '.gemini', '.cargo', '.rustup', '.npm', '.nvm',
22062218
];
22072219
for (const dir of chrootHomeDirs) {
22082220
const dirPath = path.join(effectiveHome, dir);

0 commit comments

Comments
 (0)