diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index a1ba3d4f..6734ebd3 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -768,13 +768,17 @@ if [ -n "${GITHUB_PATH}" ] && [ -f "${GITHUB_PATH}" ]; then done < "${GITHUB_PATH}" [ -n "${_github_path_prefix}" ] && export PATH="${_github_path_prefix}${PATH}" fi -# Dynamically scan /opt/hostedtoolcache for all installed tool bin directories -# This covers tools installed by any setup-* action (setup-ruby, setup-dart, -# setup-python, setup-node, setup-go, setup-java, etc.) +# Dynamically scan toolcache roots for all installed tool bin directories. +# This covers tools installed by setup-* actions on both hosted runners +# (/opt/hostedtoolcache) and self-hosted runners ($HOME/work/_tool). # Append (not prepend) so that $GITHUB_PATH entries and standard paths above # retain priority; discovered toolcache dirs serve as fallbacks only. -if [ -d "/opt/hostedtoolcache" ]; then - for tool_dir in /opt/hostedtoolcache/*/; do +append_toolcache_bins() { + local toolcache_root="$1" + if [ ! -d "${toolcache_root}" ]; then + return + fi + for tool_dir in "${toolcache_root}"/*/; do for version_dir in "$tool_dir"*/; do for arch_dir in "$version_dir"*/; do if [ -d "${arch_dir}bin" ]; then @@ -786,7 +790,9 @@ if [ -d "/opt/hostedtoolcache" ]; then done done done -fi +} +append_toolcache_bins "/opt/hostedtoolcache" +append_toolcache_bins "${HOME}/work/_tool" # Add user's local bin if it exists [ -d "$HOME/.local/bin" ] && export PATH="$HOME/.local/bin:$PATH" # Add Cargo bin for Rust (common in development) @@ -813,7 +819,7 @@ 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 + echo "[entrypoint][ERROR] Example locations include /opt/hostedtoolcache/..., $HOME/work/_tool/..., and $HOME/.nvm/..." >&2 exit 127 fi AWFEOF diff --git a/src/services/agent-volumes.test.ts b/src/services/agent-volumes.test.ts index 36d89944..d9de9b1a 100644 --- a/src/services/agent-volumes.test.ts +++ b/src/services/agent-volumes.test.ts @@ -347,6 +347,70 @@ describe('agent service', () => { expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); }); + it('should mount self-hosted runner toolcache when present under HOME/work/_tool', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + const originalHome = process.env.HOME; + const originalSudoUser = process.env.SUDO_USER; + delete process.env.SUDO_USER; + process.env.HOME = fakeHome; + + try { + const toolcacheDir = path.join(fakeHome, 'work', '_tool'); + fs.mkdirSync(toolcacheDir, { recursive: true }); + + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + if (originalSudoUser !== undefined) { + process.env.SUDO_USER = originalSudoUser; + } else { + delete process.env.SUDO_USER; + } + fs.rmSync(fakeHome, { recursive: true, force: true }); + } + }); + + it('should not mount HOME/work/_tool when it is a symlink', () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + const symlinkTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-tool-target-')); + const originalHome = process.env.HOME; + const originalSudoUser = process.env.SUDO_USER; + delete process.env.SUDO_USER; + process.env.HOME = fakeHome; + + try { + const workDir = path.join(fakeHome, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + const toolcacheDir = path.join(workDir, '_tool'); + fs.symlinkSync(symlinkTarget, toolcacheDir); + + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + } finally { + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + if (originalSudoUser !== undefined) { + process.env.SUDO_USER = originalSudoUser; + } else { + delete process.env.SUDO_USER; + } + fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(symlinkTarget, { recursive: true, force: true }); + } + }); + it('should skip .copilot bind mount when directory does not exist at non-standard HOME path', () => { const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); const originalHome = process.env.HOME; @@ -365,6 +429,8 @@ describe('agent service', () => { expect(fs.existsSync(copilotDir)).toBe(false); // The blanket .copilot mount should be absent expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); + // Optional self-hosted runner toolcache mount should also be absent + expect(volumes).not.toContain(`${fakeHome}/work/_tool:/host${fakeHome}/work/_tool:ro`); // But session-state and logs overlays are always present expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/session-state:rw`)); expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/logs:rw`)); diff --git a/src/services/agent-volumes.ts b/src/services/agent-volumes.ts index bcce18ab..97d81cb9 100644 --- a/src/services/agent-volumes.ts +++ b/src/services/agent-volumes.ts @@ -203,6 +203,19 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] { // Mount ~/.nvm for Node.js installations managed by nvm on self-hosted runners agentVolumes.push(`${effectiveHome}/.nvm:/host${effectiveHome}/.nvm:rw`); + // Mount self-hosted runner toolcache when present (e.g. ARC/DinD under $HOME/work/_tool) + // Use lstatSync (not existsSync/statSync) so a symlink at _tool does not pass the + // isDirectory() check — prevents an attacker from redirecting the bind-mount via symlink. + const runnerToolCacheDir = path.join(effectiveHome, 'work', '_tool'); + try { + const stat = fs.lstatSync(runnerToolCacheDir); + if (stat.isDirectory()) { + agentVolumes.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); + } + } catch { + // Directory does not exist or is not accessible — skip the optional mount + } + // Minimal /etc - only what's needed for runtime // Note: /etc/shadow is NOT mounted (contains password hashes) agentVolumes.push( diff --git a/tests/chroot-path-ordering.test.sh b/tests/chroot-path-ordering.test.sh index 901a835b..2cf20278 100755 --- a/tests/chroot-path-ordering.test.sh +++ b/tests/chroot-path-ordering.test.sh @@ -44,6 +44,12 @@ printf '#!/bin/sh\necho "ruby 3.3.0"\n' > "${TOOLCACHE}/Ruby/3.3.0/x64/bin/ruby" chmod +x "${TOOLCACHE}/Ruby/3.1.0/x64/bin/ruby" chmod +x "${TOOLCACHE}/Ruby/3.3.0/x64/bin/ruby" +# Build a fake self-hosted runner toolcache with a Node.js version +RUNNER_TOOLCACHE="${TMPDIR_TEST}/home/runner/work/_tool" +mkdir -p "${RUNNER_TOOLCACHE}/node/20.19.0/x64/bin" +printf '#!/bin/sh\necho "node 20.19.0"\n' > "${RUNNER_TOOLCACHE}/node/20.19.0/x64/bin/node" +chmod +x "${RUNNER_TOOLCACHE}/node/20.19.0/x64/bin/node" + # --------------------------------------------------------------------------- # Helper: run the extracted PATH logic in a clean environment, then evaluate # the resulting PATH with a provided test expression. @@ -54,13 +60,15 @@ run_path_test() { local check_expr="$3" # bash expression to evaluate after PATH is set # Build the inline script from the relevant heredoc section of entrypoint.sh. - # We replace /opt/hostedtoolcache with our fake TOOLCACHE so the test is + # We replace the toolcache roots with fake test fixtures so the test is # self-contained and doesn't depend on the host runner layout. local script script="$( sed -n '/^# Prepend entries from \$GITHUB_PATH/,/^AWFEOF$/{ /^AWFEOF$/d; p; }' \ "${ENTRYPOINT}" | - sed "s|/opt/hostedtoolcache|${TOOLCACHE}|g" + sed \ + -e "s|/opt/hostedtoolcache|${TOOLCACHE}|g" \ + -e "s|\${HOME}/work/_tool|${RUNNER_TOOLCACHE}|g" )" # Run the script in a sub-shell with a clean environment @@ -152,6 +160,29 @@ else fail "toolcache scan duplicated a directory already in PATH from GITHUB_PATH" fi +# --------------------------------------------------------------------------- +# Test 5: Self-hosted runner toolcache is scanned for fallback binaries +# --------------------------------------------------------------------------- +FALLBACK_BASE_PATH="/tmp/awf-empty-path" + +if run_path_test "" "${FALLBACK_BASE_PATH}" " + case \"\${PATH}\" in + *\"${RUNNER_TOOLCACHE}/node/20.19.0/x64/bin\"*) exit 0;; + *) exit 1;; + esac +"; then + pass "self-hosted runner toolcache bins are appended to PATH" +else + fail "self-hosted runner toolcache bins were not added to PATH" +fi + +if run_path_test "" "${FALLBACK_BASE_PATH}" \ + '[ "$(command -v node 2>/dev/null)" = "${RUNNER_TOOLCACHE}/node/20.19.0/x64/bin/node" ]'; then + pass "node resolves from self-hosted runner toolcache fallback" +else + fail "node does not resolve from self-hosted runner toolcache fallback" +fi + # --------------------------------------------------------------------------- # Summary # ---------------------------------------------------------------------------