From 6ea986afe31c42c914b036ef9e0c68b10bec4049 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:59:25 +0000 Subject: [PATCH 1/4] Initial plan From e49f298102c24f93da0be3ca8741640141dd01a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:05:53 +0000 Subject: [PATCH 2/4] fix: support self-hosted runner toolcache in chroot --- containers/agent/entrypoint.sh | 20 +++++++++++------ src/services/agent-volumes.test.ts | 32 +++++++++++++++++++++++++++ src/services/agent-volumes.ts | 6 +++++ tests/chroot-path-ordering.test.sh | 35 ++++++++++++++++++++++++++++-- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index a1ba3d4f2..1969195ee 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/runner/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/runner/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/runner/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 36d899442..cc242e430 100644 --- a/src/services/agent-volumes.test.ts +++ b/src/services/agent-volumes.test.ts @@ -347,6 +347,36 @@ 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 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 +395,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 bcce18abe..551b49a5a 100644 --- a/src/services/agent-volumes.ts +++ b/src/services/agent-volumes.ts @@ -203,6 +203,12 @@ 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) + const runnerToolCacheDir = path.join(effectiveHome, 'work', '_tool'); + if (fs.existsSync(runnerToolCacheDir)) { + agentVolumes.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); + } + // 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 901a835b4..5881e35bb 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/runner/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 # --------------------------------------------------------------------------- From 400a3c852df8744b654bda68d8c604a4cb5c58be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 18:42:35 +0000 Subject: [PATCH 3/4] fix: use HOME/work/_tool in entrypoint; reject symlinks in volumes --- containers/agent/entrypoint.sh | 6 +++--- src/services/agent-volumes.test.ts | 34 ++++++++++++++++++++++++++++++ src/services/agent-volumes.ts | 11 ++++++++-- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 1969195ee..6734ebd31 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -770,7 +770,7 @@ if [ -n "${GITHUB_PATH}" ] && [ -f "${GITHUB_PATH}" ]; then fi # 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/runner/work/_tool). +# (/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. append_toolcache_bins() { @@ -792,7 +792,7 @@ append_toolcache_bins() { done } append_toolcache_bins "/opt/hostedtoolcache" -append_toolcache_bins "/home/runner/work/_tool" +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) @@ -819,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/..., /home/runner/work/_tool/..., 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 cc242e430..d9de9b1ab 100644 --- a/src/services/agent-volumes.test.ts +++ b/src/services/agent-volumes.test.ts @@ -377,6 +377,40 @@ describe('agent service', () => { } }); + 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; diff --git a/src/services/agent-volumes.ts b/src/services/agent-volumes.ts index 551b49a5a..97d81cb90 100644 --- a/src/services/agent-volumes.ts +++ b/src/services/agent-volumes.ts @@ -204,9 +204,16 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] { 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'); - if (fs.existsSync(runnerToolCacheDir)) { - agentVolumes.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); + 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 From 2e927a74678d96b7534f43dfa9f57a8217e16a7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 20:12:31 +0000 Subject: [PATCH 4/4] fix: update test sed pattern for HOME/work/_tool variable --- tests/chroot-path-ordering.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/chroot-path-ordering.test.sh b/tests/chroot-path-ordering.test.sh index 5881e35bb..2cf202780 100755 --- a/tests/chroot-path-ordering.test.sh +++ b/tests/chroot-path-ordering.test.sh @@ -68,7 +68,7 @@ run_path_test() { "${ENTRYPOINT}" | sed \ -e "s|/opt/hostedtoolcache|${TOOLCACHE}|g" \ - -e "s|/home/runner/work/_tool|${RUNNER_TOOLCACHE}|g" + -e "s|\${HOME}/work/_tool|${RUNNER_TOOLCACHE}|g" )" # Run the script in a sub-shell with a clean environment