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
20 changes: 13 additions & 7 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions src/services/agent-volumes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`));
Expand Down
13 changes: 13 additions & 0 deletions src/services/agent-volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
35 changes: 33 additions & 2 deletions tests/chroot-path-ordering.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading