diff --git a/.github/workflows/smoke-ci.lock.yml b/.github/workflows/smoke-ci.lock.yml index 1b319f62234..33e5965e58f 100644 --- a/.github/workflows/smoke-ci.lock.yml +++ b/.github/workflows/smoke-ci.lock.yml @@ -1078,6 +1078,7 @@ jobs: export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" mkdir -p /tmp/gh-aw + GH_AW_PREV_UMASK="$(umask)" umask 0177 cat > /tmp/gh-aw/engine-command.sh <<'GH_AW_ENGINE_COMMAND_EOF' #!/usr/bin/env bash @@ -1087,6 +1088,7 @@ jobs: GH_AW_ENGINE_COMMAND_EOF chmod 700 /tmp/gh-aw/engine-command.sh + umask "$GH_AW_PREV_UMASK" touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN diff --git a/actions/setup/sh/install_antigravity_cli.sh b/actions/setup/sh/install_antigravity_cli.sh index 858ffbbe5b3..c185d6ebf4b 100644 --- a/actions/setup/sh/install_antigravity_cli.sh +++ b/actions/setup/sh/install_antigravity_cli.sh @@ -2,13 +2,16 @@ set +o histexpand # Install Antigravity CLI (agy) from Google Cloud Storage -# Usage: install_antigravity_cli.sh VERSION +# Usage: install_antigravity_cli.sh VERSION [--rootless] # # This script downloads and installs the Antigravity CLI binary directly from # Google Cloud Storage (https://storage.googleapis.com/antigravity-public/). # # Arguments: -# VERSION - Antigravity CLI version to install (required) +# VERSION - Antigravity CLI version to install (required) +# --rootless - Install to ~/.local/bin without sudo; appends that directory to +# $GITHUB_PATH so subsequent steps find the binary. Use this on +# ARC/DinD runners that enforce allowPrivilegeEscalation: false. # # Security features: # - Downloads binary directly from Google Cloud Storage over HTTPS @@ -20,16 +23,55 @@ set +o histexpand set -euo pipefail # Configuration -VERSION="${1:-}" GCS_BASE_URL="https://storage.googleapis.com/antigravity-public/antigravity-cli" INSTALL_DIR="/usr/local/bin" BINARY_NAME="agy" +# Parse arguments: treat the first non-flag argument as VERSION, all -- arguments as flags. +VERSION="" +ROOTLESS=false +for arg in "$@"; do + case "$arg" in + --rootless) ROOTLESS=true ;; + --*) echo "WARNING: Unknown flag: $arg" >&2 ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$arg" + fi + ;; + esac +done + if [ -z "$VERSION" ]; then echo "ERROR: Version argument is required" + echo "Usage: $0 VERSION [--rootless]" exit 1 fi +# In rootless mode, install into the user's home directory instead of /usr/local/bin +# so that ARC/DinD runners with allowPrivilegeEscalation: false can run without sudo. +if [ "$ROOTLESS" = "true" ]; then + INSTALL_DIR="${HOME}/.local/bin" +fi + +# maybe_sudo runs a command with sudo unless --rootless was specified. +# In rootless mode, sudo is not available or needed. +maybe_sudo() { + if [ "$ROOTLESS" = "true" ]; then + "$@" + else + sudo "$@" + fi +} + +# Rootless mode preflight: create and verify write access to the install directory. +if [ "$ROOTLESS" = "true" ]; then + if ! { mkdir -p "${INSTALL_DIR}" && [ -w "${INSTALL_DIR}" ]; }; then + echo "ERROR: --rootless could not create a writable install directory at ${INSTALL_DIR}" >&2 + exit 1 + fi +fi + # Detect OS and architecture OS="$(uname -s)" ARCH="$(uname -m)" @@ -131,7 +173,17 @@ if [ ! -f "${TEMP_DIR}/antigravity" ]; then echo "ERROR: Expected binary 'antigravity' not found in the extracted archive" exit 1 fi -sudo install -m 755 "${TEMP_DIR}/antigravity" "${INSTALL_DIR}/${BINARY_NAME}" +maybe_sudo install -m 755 "${TEMP_DIR}/antigravity" "${INSTALL_DIR}/${BINARY_NAME}" + +# In rootless mode, add the install dir to PATH for subsequent steps. +if [ "$ROOTLESS" = "true" ]; then + if [ -n "${GITHUB_PATH:-}" ]; then + echo "${INSTALL_DIR}" >> "${GITHUB_PATH}" + echo " Exported ${INSTALL_DIR} to GITHUB_PATH" + else + echo " GITHUB_PATH not set — binary installed at ${INSTALL_DIR}/${BINARY_NAME}" + fi +fi # Verify installation echo "Verifying Antigravity CLI installation..." diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index 191d0e79f87..5dada75d2bc 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -2,14 +2,17 @@ set +o histexpand # Install GitHub Copilot CLI with SHA256 checksum verification -# Usage: install_copilot_cli.sh [VERSION] +# Usage: install_copilot_cli.sh [VERSION] [--rootless] # # This script downloads and installs the GitHub Copilot CLI directly from GitHub # releases with SHA256 checksum verification, following the secure pattern from # install_awf_binary.sh to avoid executing unverified downloaded scripts. # # Arguments: -# VERSION - Optional Copilot CLI version to install (default: latest release) +# VERSION - Optional Copilot CLI version to install (default: latest release) +# --rootless - Install to ~/.local/bin without sudo; appends that directory to +# $GITHUB_PATH so subsequent steps find the binary. Use this on +# ARC/DinD runners that enforce allowPrivilegeEscalation: false. # # Security features: # - Downloads binary directly from GitHub releases (no installer script execution) @@ -20,7 +23,6 @@ set -euo pipefail # Configuration SECONDS_PER_DAY=86400 -VERSION="${1:-}" COPILOT_REPO="github/copilot-cli" INSTALL_DIR="/usr/local/bin" COPILOT_DIR="${HOME}/.copilot" @@ -34,6 +36,45 @@ COMPAT_MATCHED_MIN_AGENT="" COMPAT_MATCHED_MAX_AGENT="" COMPAT_CACHE_TTL_DAYS="" +# Parse arguments: treat the first non-flag argument as VERSION, all -- arguments as flags. +VERSION="" +ROOTLESS=false +for arg in "$@"; do + case "$arg" in + --rootless) ROOTLESS=true ;; + --*) echo "WARNING: Unknown flag: $arg" >&2 ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$arg" + fi + ;; + esac +done + +# In rootless mode, install into the user's home directory instead of /usr/local/bin +# so that ARC/DinD runners with allowPrivilegeEscalation: false can run without sudo. +if [ "$ROOTLESS" = "true" ]; then + INSTALL_DIR="${HOME}/.local/bin" +fi + +# maybe_sudo runs a command with sudo unless --rootless was specified. +# In rootless mode, sudo is not available or needed. +maybe_sudo() { + if [ "$ROOTLESS" = "true" ]; then + "$@" + else + sudo "$@" + fi +} + +# Rootless mode preflight: create and verify write access to the install directory. +if [ "$ROOTLESS" = "true" ]; then + if ! { mkdir -p "${INSTALL_DIR}" && [ -w "${INSTALL_DIR}" ]; }; then + echo "ERROR: --rootless could not create a writable install directory at ${INSTALL_DIR}" >&2 + exit 1 + fi +fi + # Fix directory ownership before installation # This is needed because a previous AWF run on the same runner may have used # `sudo -E awf --enable-chroot ...`, which creates the .copilot directory with @@ -41,7 +82,7 @@ COMPAT_CACHE_TTL_DAYS="" # trying to create subdirectories. See: https://github.com/github/gh-aw/issues/12066 echo "Ensuring correct ownership of $COPILOT_DIR..." mkdir -p "$COPILOT_DIR" -sudo chown -R "$(id -u):$(id -g)" "$COPILOT_DIR" +maybe_sudo chown -R "$(id -u):$(id -g)" "$COPILOT_DIR" # Clean up any stale AWF chroot directories left by previous runs. # When AWF ran with `sudo -E awf --enable-host-access`, it created @@ -51,8 +92,8 @@ sudo chown -R "$(id -u):$(id -g)" "$COPILOT_DIR" # reports as "engine terminated unexpectedly" or fatal EACCES errors. # Remove them here before the agent starts so the runner is in a clean state. echo "Cleaning up stale AWF chroot directories..." -sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null || true -sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -exec rm -rf -- {} + 2>/dev/null || true +maybe_sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null || true +maybe_sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -exec rm -rf -- {} + 2>/dev/null || true # Detect OS and architecture OS="$(uname -s)" @@ -454,7 +495,7 @@ activate_cached_copilot_bin() { #!/usr/bin/env bash exec "$cached_copilot_bin" "\$@" EOF - sudo install -m 0755 "$wrapper_path" "${INSTALL_DIR}/copilot" + maybe_sudo install -m 0755 "$wrapper_path" "${INSTALL_DIR}/copilot" echo " Wrapper installed at ${INSTALL_DIR}/copilot" } @@ -546,12 +587,31 @@ echo "✓ Checksum verification passed for ${TARBALL_NAME}" # Extract and install binary echo "Installing binary to ${INSTALL_DIR}..." -sudo tar -xz -C "${INSTALL_DIR}" -f "${TEMP_DIR}/${TARBALL_NAME}" -sudo chmod +x "${INSTALL_DIR}/copilot" +maybe_sudo tar -xz -C "${INSTALL_DIR}" -f "${TEMP_DIR}/${TARBALL_NAME}" +maybe_sudo chmod +x "${INSTALL_DIR}/copilot" + +# In rootless mode, add the install dir to PATH for subsequent steps. +# $GITHUB_PATH is the mechanism for persisting PATH additions across steps in GitHub Actions. +if [ "$ROOTLESS" = "true" ]; then + if [ -n "${GITHUB_PATH:-}" ]; then + echo "${INSTALL_DIR}" >> "${GITHUB_PATH}" + echo " Exported ${INSTALL_DIR} to GITHUB_PATH" + else + echo "WARNING: --rootless install complete but \$GITHUB_PATH is unset; add ${INSTALL_DIR} to PATH manually" >&2 + fi +fi # Verify installation echo "Verifying Copilot CLI installation..." -if command -v copilot >/dev/null 2>&1; then +if [ "$ROOTLESS" = "true" ]; then + if [ -x "${INSTALL_DIR}/copilot" ]; then + "${INSTALL_DIR}/copilot" --version + echo "✓ Copilot CLI installation complete" + else + echo "ERROR: Copilot CLI installation failed - binary not found at ${INSTALL_DIR}/copilot" + exit 1 + fi +elif command -v copilot >/dev/null 2>&1; then copilot --version echo "✓ Copilot CLI installation complete" else diff --git a/actions/setup/sh/install_threat_detect_binary.sh b/actions/setup/sh/install_threat_detect_binary.sh index 7e50b0135f3..102a3f67bc5 100755 --- a/actions/setup/sh/install_threat_detect_binary.sh +++ b/actions/setup/sh/install_threat_detect_binary.sh @@ -5,10 +5,13 @@ set +o histexpand # Used when `features: gh-aw-detection: true` is set in the workflow frontmatter to enable # the external threat-detect binary detection path instead of inline engine execution. # -# Usage: install_threat_detect_binary.sh VERSION +# Usage: install_threat_detect_binary.sh VERSION [--rootless] # # Arguments: -# VERSION - threat-detect version to install (e.g., v0.2.2) +# VERSION - threat-detect version to install (e.g., v0.2.2) +# --rootless - Install to ~/.local/bin without sudo; appends that directory to +# $GITHUB_PATH so subsequent steps find the binary. Use this on +# ARC/DinD runners that enforce allowPrivilegeEscalation: false. # # Platform support: # - Linux (x64, arm64): Downloads pre-built binary @@ -21,17 +24,55 @@ set +o histexpand set -euo pipefail # Configuration -THREAT_DETECT_VERSION="${1:-}" THREAT_DETECT_REPO="github/gh-aw-threat-detection" THREAT_DETECT_INSTALL_DIR="/usr/local/bin" THREAT_DETECT_INSTALL_NAME="threat-detect" +# Parse arguments: treat the first non-flag argument as VERSION, all -- arguments as flags. +THREAT_DETECT_VERSION="" +ROOTLESS=false +for arg in "$@"; do + case "$arg" in + --rootless) ROOTLESS=true ;; + --*) echo "WARNING: Unknown flag: $arg" >&2 ;; + *) + if [ -z "$THREAT_DETECT_VERSION" ]; then + THREAT_DETECT_VERSION="$arg" + fi + ;; + esac +done + if [ -z "$THREAT_DETECT_VERSION" ]; then echo "ERROR: threat-detect version is required" - echo "Usage: $0 VERSION" + echo "Usage: $0 VERSION [--rootless]" exit 1 fi +# In rootless mode, install into the user's home directory instead of /usr/local/bin +# so that ARC/DinD runners with allowPrivilegeEscalation: false can run without sudo. +if [ "$ROOTLESS" = "true" ]; then + THREAT_DETECT_INSTALL_DIR="${HOME}/.local/bin" +fi + +# maybe_sudo runs a command with sudo unless --rootless was specified. +# In rootless mode, sudo is not available or needed. +maybe_sudo() { + if [ "$ROOTLESS" = "true" ]; then + "$@" + else + sudo "$@" + fi +} + +# Rootless mode preflight: create and verify write access to the install directory. +if [ "$ROOTLESS" = "true" ]; then + if ! { mkdir -p "${THREAT_DETECT_INSTALL_DIR}" && [ -w "${THREAT_DETECT_INSTALL_DIR}" ]; }; then + echo "ERROR: --rootless could not create a writable install directory at ${THREAT_DETECT_INSTALL_DIR}" >&2 + exit 1 + fi +fi + # Detect OS and architecture OS="$(uname -s)" ARCH="$(uname -m)" @@ -106,7 +147,7 @@ install_linux_binary() { # Make binary executable and install chmod +x "${TEMP_DIR}/${binary_name}" - sudo mv "${TEMP_DIR}/${binary_name}" "${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" + maybe_sudo mv "${TEMP_DIR}/${binary_name}" "${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" } install_darwin_binary() { @@ -127,7 +168,7 @@ install_darwin_binary() { # Make binary executable and install chmod +x "${TEMP_DIR}/${binary_name}" - sudo mv "${TEMP_DIR}/${binary_name}" "${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" + maybe_sudo mv "${TEMP_DIR}/${binary_name}" "${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" } case "$OS" in @@ -143,6 +184,16 @@ case "$OS" in ;; esac +# In rootless mode, add the install dir to PATH for subsequent steps. +if [ "$ROOTLESS" = "true" ]; then + if [ -n "${GITHUB_PATH:-}" ]; then + echo "${THREAT_DETECT_INSTALL_DIR}" >> "${GITHUB_PATH}" + echo " Exported ${THREAT_DETECT_INSTALL_DIR} to GITHUB_PATH" + else + echo " GITHUB_PATH not set — binary installed at ${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" + fi +fi + # Verify installation "${THREAT_DETECT_INSTALL_DIR}/${THREAT_DETECT_INSTALL_NAME}" --version diff --git a/docs/src/content/docs/guides/arc-dind-copilot-agent.md b/docs/src/content/docs/guides/arc-dind-copilot-agent.md index 4f243e581dc..ee5a27c8331 100644 --- a/docs/src/content/docs/guides/arc-dind-copilot-agent.md +++ b/docs/src/content/docs/guides/arc-dind-copilot-agent.md @@ -157,7 +157,7 @@ gh aw compile | DinD container mode | **Yes** | GitHub Copilot coding agent needs a Docker daemon in the runner pod. | | `NET_ADMIN` capability | **No** | Not required. AWF enforces egress via Docker network topology (network isolation mode), not host `iptables`. The DinD sidecar daemon manages all network enforcement internally. | | `ghcr.io/actions/actions-runner:latest` | Recommended | Use the official runner image, or a compatible custom image with equivalent runner requirements. | -| Runner user | **Yes** | Non-root runner users are supported. `sudo` must remain available on the runner container for the Copilot CLI install script (binary installation and file ownership operations). | +| Runner user | **Yes** | Non-root runner users are supported. By default, `sudo` must be available on the runner container for the Copilot CLI install script. If your cluster enforces `allowPrivilegeEscalation: false`, use the `--rootless` flag — see [Pod security and rootless install](#pod-security-and-rootless-install) below. | | DinD sidecar privilege | **Yes** | ARC DinD mode configures a privileged sidecar for Docker daemon operation. | | Shared work volume (`/home/runner/_work`) | **Yes** | Runner and Docker daemon share this volume in ARC DinD mode, so workspace mounts work without host path translation. | | Specific Kubernetes distribution | **No** | Any conformant cluster works (for example minikube, EKS, AKS, or GKE). | @@ -205,9 +205,34 @@ To migrate: 4. Add `runner.topology: arc-dind` to frontmatter. 5. Run `gh aw compile` and commit the updated lock file. +## Pod security and rootless install + +Clusters that enforce `allowPrivilegeEscalation: false` (via PodSecurity Admission or OPA policies) prevent the default Copilot CLI install script from using `sudo`. + +Pass `--rootless` to `install_copilot_cli.sh` in your `copilot-setup-steps` to install to `~/.local/bin` without any `sudo` calls. The script adds that directory to `$GITHUB_PATH` so subsequent steps find the binary: + +```yaml +copilot-setup-steps: + runs-on: arc-runner-set + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: github/gh-aw + path: gh-aw + - name: Install Copilot CLI (rootless) + run: bash "${GITHUB_WORKSPACE}/gh-aw/actions/setup/sh/install_copilot_cli.sh" --rootless + env: + GH_HOST: github.com +``` + +In rootless mode: +- The binary is installed to `~/.local/bin/copilot` (no `sudo` required). +- `~/.local/bin` is appended to `$GITHUB_PATH` so `copilot` is on `PATH` for all later steps. +- Ownership and cleanup operations that previously used `sudo` run as the current user (which owns the relevant files in rootless deployments). + ## Known limitations -- **`allowPrivilegeEscalation: false` is not supported.** The Copilot CLI binary installation script (`install_copilot_cli.sh`) uses `sudo` to install to `/usr/local/bin` and fix file ownership. Clusters that enforce `no-new-privileges` via PodSecurity Admission or OPA policies will fail at the install step. Note that the AWF install script already supports rootless installation; this constraint is specific to the Copilot CLI. Rootless Copilot CLI installation support is tracked in [#46046](https://github.com/github/gh-aw/issues/46046). +- **MCP gateway Docker socket access** — on runners where `DOCKER_HOST` is a TCP endpoint and no Unix socket exists at `/var/run/docker.sock`, the MCP gateway may fail to connect to the Docker daemon (`Docker daemon is not accessible`). As a workaround, expose the DinD sidecar's Unix socket on the runner container at `/var/run/docker.sock` via a shared volume or symlink. See [#44251](https://github.com/github/gh-aw/issues/44251) for tracking. ## Troubleshooting @@ -226,7 +251,7 @@ The threat-detection job can't find the Copilot binary. This was fixed in gh-aw ### `sudo: The "no new privileges" flag is set` -The runner pod's security context has `allowPrivilegeEscalation: false`. Remove that constraint or adjust your PodSecurity policy to allow privilege escalation in the runner container. +The runner pod's security context has `allowPrivilegeEscalation: false`. Pass `--rootless` to the Copilot CLI install script so it installs without `sudo` — see [Pod security and rootless install](#pod-security-and-rootless-install) above. ### `awf-cli-proxy could not connect to the external DIFC proxy` diff --git a/docs/src/content/docs/reference/self-hosted-runners.md b/docs/src/content/docs/reference/self-hosted-runners.md index 1049828ac44..7fc85c43da5 100644 --- a/docs/src/content/docs/reference/self-hosted-runners.md +++ b/docs/src/content/docs/reference/self-hosted-runners.md @@ -13,7 +13,7 @@ Self-hosted runners may require `sudo` depending on the selected engine and conf - **AWF (Agentic Workflow Firewall)**: Runs rootless in the default network-isolation mode. Egress is enforced via Docker network topology — an internal Docker network (`awf-net`) with no internet route and a dual-homed Squid proxy as the sole egress path. No `sudo` and no `NET_ADMIN` are required on the runner for AWF in this mode. Container-level `iptables`, Squid proxy ACLs, and capability drops provide defense in depth, all managed inside the Docker daemon's domain. -- **Copilot CLI install**: The `install_copilot_cli.sh` script runs as the runner user but escalates via `sudo` for specific file operations (fixing `.copilot` directory ownership, cleaning stale chroot directories, and installing the Copilot binary). ARC pods with `allowPrivilegeEscalation: false` will fail at this step with `sudo: The "no new privileges" flag is set`. +- **Copilot CLI install**: The `install_copilot_cli.sh` script runs as the runner user. By default it escalates via `sudo` for file operations (fixing `.copilot` directory ownership, cleaning stale chroot directories, and installing the Copilot binary to `/usr/local/bin`). Pass `--rootless` to the script to install to `~/.local/bin` without `sudo`, which is required on ARC pods with `allowPrivilegeEscalation: false`. ## ARC with Docker-in-Docker (DinD) @@ -279,4 +279,4 @@ The dind sidecar requires `privileged: true` so `dockerd` can run. The runner co In network-isolation mode (the default for `topology: arc-dind`), AWF enforces egress via Docker network topology — an internal Docker network with no internet route and a dual-homed Squid proxy. All network enforcement happens inside the Docker daemon's domain (the dind sidecar). The runner container only issues Docker API commands via the socket; it never manipulates host `iptables` or network namespaces. > [!NOTE] -> If your cluster enforces `allowPrivilegeEscalation: false` or `no-new-privileges` on the runner container, the Copilot CLI install script will fail. See [Known limitations](/gh-aw/guides/arc-dind-copilot-agent/#known-limitations) in the ARC DinD guide. +> If your cluster enforces `allowPrivilegeEscalation: false` or `no-new-privileges` on the runner container, pass `--rootless` to the Copilot CLI install script so it installs to `~/.local/bin` without `sudo`. See [Pod security and rootless install](/gh-aw/guides/arc-dind-copilot-agent/#pod-security-and-rootless-install) in the ARC DinD guide. diff --git a/pkg/cli/install_copilot_cli_test.go b/pkg/cli/install_copilot_cli_test.go index 5cc7b9f6c59..d8140f8decf 100644 --- a/pkg/cli/install_copilot_cli_test.go +++ b/pkg/cli/install_copilot_cli_test.go @@ -183,3 +183,57 @@ exit 97 } assert.Equal(t, 1, compatFetches, "compat.json should be fetched exactly once (no double fallback)") } + +func TestInstallCopilotCLIScriptRootlessModeUsesRealScriptWithToolcacheAndNoSudo(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err, "Failed to get working directory") + + projectRoot := filepath.Join(wd, "..", "..") + installScript := filepath.Join(projectRoot, "actions", "setup", "sh", "install_copilot_cli.sh") + + testCases := []struct { + name string + args []string + }{ + {name: "rootless flag before version", args: []string{"--rootless", "1.2.3"}}, + {name: "rootless flag after version", args: []string{"1.2.3", "--rootless"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + homeDir := filepath.Join(tempDir, "home") + require.NoError(t, os.MkdirAll(homeDir, 0o755)) + + toolcacheBin := filepath.Join(tempDir, "toolcache", "copilot-cli", "1.2.3", "x64", "bin") + require.NoError(t, os.MkdirAll(toolcacheBin, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(toolcacheBin, "copilot"), []byte("#!/usr/bin/env bash\necho 'copilot 1.2.3'\n"), 0o755)) + + fakeBinDir := filepath.Join(tempDir, "fake-bin") + require.NoError(t, os.MkdirAll(fakeBinDir, 0o755)) + + sudoScript := filepath.Join(fakeBinDir, "sudo") + sudoLog := filepath.Join(tempDir, "sudo.log") + require.NoError(t, os.WriteFile(sudoScript, []byte(`#!/usr/bin/env bash +echo "sudo-called: $*" >> "`+sudoLog+`" +exit 99 +`), 0o755)) + + cmd := exec.Command("bash", append([]string{installScript}, tc.args...)...) + cmd.Env = append(os.Environ(), + "HOME="+homeDir, + "RUNNER_TOOL_CACHE="+filepath.Join(tempDir, "toolcache"), + "GITHUB_PATH=", + "PATH="+fakeBinDir+":"+os.Getenv("PATH"), + ) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "install_copilot_cli.sh should succeed in rootless mode with toolcache and no sudo: %s", output) + + assert.Contains(t, string(output), "Using cached GitHub Copilot CLI", "script should use cached copilot CLI") + assert.Contains(t, string(output), "GITHUB_PATH not set — installing wrapper at "+filepath.Join(homeDir, ".local", "bin", "copilot")) + assert.FileExists(t, filepath.Join(homeDir, ".local", "bin", "copilot")) + assert.NoFileExists(t, sudoLog, "sudo should not be called in rootless mode") + }) + } +} diff --git a/pkg/workflow/antigravity_engine.go b/pkg/workflow/antigravity_engine.go index a1ebbf0f368..5d7584bf9d5 100644 --- a/pkg/workflow/antigravity_engine.go +++ b/pkg/workflow/antigravity_engine.go @@ -104,7 +104,7 @@ func (e *AntigravityEngine) GetInstallationSteps(workflowData *WorkflowData) []G if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { version = workflowData.EngineConfig.Version } - installSteps := GenerateAntigravityInstallerSteps(version, "Install Antigravity CLI") + installSteps := GenerateAntigravityInstallerSteps(version, "Install Antigravity CLI", false) return BuildNpmEngineInstallStepsWithAWF(installSteps, workflowData) } diff --git a/pkg/workflow/antigravity_installer.go b/pkg/workflow/antigravity_installer.go index 18342a58973..9f9663a4476 100644 --- a/pkg/workflow/antigravity_installer.go +++ b/pkg/workflow/antigravity_installer.go @@ -9,22 +9,28 @@ var antigravityInstallerLog = logger.New("workflow:antigravity_installer") // GenerateAntigravityInstallerSteps creates GitHub Actions steps to install the Antigravity CLI // using the official binary from Google Cloud Storage. -func GenerateAntigravityInstallerSteps(version, stepName string) []GitHubActionStep { +// When rootless is true, the script installs into $HOME/.local/bin without sudo. +func GenerateAntigravityInstallerSteps(version, stepName string, rootless bool) []GitHubActionStep { // If no version is specified, use the pinned default version from constants. if version == "" { version = string(constants.DefaultAntigravityVersion) antigravityInstallerLog.Printf("No version specified, using default: %s", version) } - antigravityInstallerLog.Printf("Generating Antigravity installer steps using install_antigravity_cli.sh: version=%s", version) + antigravityInstallerLog.Printf("Generating Antigravity installer steps using install_antigravity_cli.sh: version=%s, rootless=%v", version, rootless) // Always pass the version via an env var rather than direct shell interpolation. // This prevents injection from user-supplied engine.version values (e.g. values // with spaces or shell metacharacters) and also handles GitHub Actions expressions // like ${{ inputs.engine-version }} safely. + installCmd := `bash "${RUNNER_TEMP}/gh-aw/actions/install_antigravity_cli.sh" "${ENGINE_VERSION}"` + if rootless { + installCmd += " --rootless" + } + installStep := GitHubActionStep([]string{ " - name: " + stepName, - ` run: bash "${RUNNER_TEMP}/gh-aw/actions/install_antigravity_cli.sh" "${ENGINE_VERSION}"`, + " run: " + installCmd, " env:", " ENGINE_VERSION: " + version, }) diff --git a/pkg/workflow/antigravity_installer_test.go b/pkg/workflow/antigravity_installer_test.go new file mode 100644 index 00000000000..cf9230f635a --- /dev/null +++ b/pkg/workflow/antigravity_installer_test.go @@ -0,0 +1,38 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestGenerateAntigravityInstallerSteps_Rootless(t *testing.T) { + steps := GenerateAntigravityInstallerSteps("1.0.0", "Install Antigravity CLI", true) + + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join(steps[0], "\n") + + if !strings.Contains(stepContent, "--rootless") { + t.Errorf("Expected step to contain --rootless flag, got:\n%s", stepContent) + } + + if !strings.Contains(stepContent, "install_antigravity_cli.sh") { + t.Errorf("Expected step to use install_antigravity_cli.sh, got:\n%s", stepContent) + } +} + +func TestGenerateAntigravityInstallerSteps_NoRootless(t *testing.T) { + steps := GenerateAntigravityInstallerSteps("1.0.0", "Install Antigravity CLI", false) + + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join(steps[0], "\n") + + if strings.Contains(stepContent, "--rootless") { + t.Errorf("Expected step to NOT contain --rootless flag, got:\n%s", stepContent) + } +} diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index 15fb7b3afb0..83dac99da39 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -699,11 +699,13 @@ func buildEngineCommandScriptSetup(command string) string { } return fmt.Sprintf(`mkdir -p /tmp/gh-aw +GH_AW_PREV_UMASK="$(umask)" umask 0177 cat > %s <<'%s' %s %s -chmod 700 %s`, customEngineCommandScriptPath, heredocDelimiter, scriptContent, heredocDelimiter, customEngineCommandScriptPath) +chmod 700 %s +umask "$GH_AW_PREV_UMASK"`, customEngineCommandScriptPath, heredocDelimiter, scriptContent, heredocDelimiter, customEngineCommandScriptPath) } // generateCopilotSessionFileCopyStep generates a step to copy the entire Copilot diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 25f4e40a099..5760eb5c6bf 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -125,7 +125,12 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu // Use the installer script for global installation copilotInstallLog.Print("Using new installer script for Copilot installation") - npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI") + // On ARC/DinD runners, sudo may not be available (allowPrivilegeEscalation: false). + // Pass --rootless so the script installs to ~/.local/bin without sudo. + // The "Copy Copilot CLI to daemon-visible path" step in nodejs.go then copies from + // the rootless location to ${RUNNER_TEMP}/gh-aw/bin/copilot where AWF expects it. + rootless := isArcDindTopology(workflowData) + npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI", rootless) if len(sdkInstallStep) > 0 { npmSteps = append(npmSteps, sdkInstallStep) } diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 7199bcca871..06feb003b98 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -2802,6 +2802,12 @@ func TestBuildEngineCommandScriptSetup(t *testing.T) { if !strings.Contains(setup, "umask 0177") { t.Fatalf("Expected restrictive umask in script setup, got:\n%s", setup) } + if !strings.Contains(setup, `GH_AW_PREV_UMASK="$(umask)"`) { + t.Fatalf("Expected script setup to preserve original umask, got:\n%s", setup) + } + if !strings.Contains(setup, `umask "$GH_AW_PREV_UMASK"`) { + t.Fatalf("Expected script setup to restore original umask, got:\n%s", setup) + } if !strings.Contains(setup, "chmod 700 /tmp/gh-aw/engine-command.sh") { t.Fatalf("Expected owner-only execute permissions, got:\n%s", setup) } diff --git a/pkg/workflow/copilot_installer.go b/pkg/workflow/copilot_installer.go index 1fc421878b3..c98f8719953 100644 --- a/pkg/workflow/copilot_installer.go +++ b/pkg/workflow/copilot_installer.go @@ -8,14 +8,20 @@ import ( var copilotInstallerLog = logger.New("workflow:copilot_installer") // GenerateCopilotInstallerSteps creates GitHub Actions steps to install the Copilot CLI using the official installer. -func GenerateCopilotInstallerSteps(version, stepName string) []GitHubActionStep { +// When rootless is true, the script installs into $HOME/.local/bin without sudo. +func GenerateCopilotInstallerSteps(version, stepName string, rootless bool) []GitHubActionStep { // If no version is specified, use the pinned default version from constants. if version == "" { version = string(constants.DefaultCopilotVersion) copilotInstallerLog.Printf("No version specified, using default: %s", version) } - copilotInstallerLog.Printf("Generating Copilot installer steps using install_copilot_cli.sh: version=%s", version) + copilotInstallerLog.Printf("Generating Copilot installer steps using install_copilot_cli.sh: version=%s, rootless=%v", version, rootless) + + rootlessFlag := "" + if rootless { + rootlessFlag = " --rootless" + } // Use the install_copilot_cli.sh script from actions/setup/sh // This script includes retry logic for robustness against transient network failures. @@ -30,7 +36,7 @@ func GenerateCopilotInstallerSteps(version, stepName string) []GitHubActionStep copilotInstallerLog.Printf("Version contains GitHub Actions expression, using env var for injection safety: %s", version) stepLines := []string{ " - name: " + stepName, - ` run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" "${ENGINE_VERSION}"`, + ` run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" "${ENGINE_VERSION}"` + rootlessFlag, " env:", " GH_HOST: github.com", " ENGINE_VERSION: " + version, @@ -40,7 +46,7 @@ func GenerateCopilotInstallerSteps(version, stepName string) []GitHubActionStep stepLines := []string{ " - name: " + stepName, - " run: bash \"${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh\" " + version, + " run: bash \"${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh\" " + version + rootlessFlag, " env:", " GH_HOST: github.com", } diff --git a/pkg/workflow/copilot_installer_test.go b/pkg/workflow/copilot_installer_test.go index 356db6d3c35..72072020b41 100644 --- a/pkg/workflow/copilot_installer_test.go +++ b/pkg/workflow/copilot_installer_test.go @@ -76,7 +76,7 @@ func TestGenerateCopilotInstallerSteps(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - steps := GenerateCopilotInstallerSteps(tt.version, tt.stepName) + steps := GenerateCopilotInstallerSteps(tt.version, tt.stepName, false) if len(steps) != 1 { t.Errorf("Expected 1 step, got %d", len(steps)) @@ -180,7 +180,7 @@ func TestGenerateCopilotInstallerSteps_ExpressionVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - steps := GenerateCopilotInstallerSteps(tt.version, "Install GitHub Copilot CLI") + steps := GenerateCopilotInstallerSteps(tt.version, "Install GitHub Copilot CLI", false) if len(steps) != 1 { t.Errorf("Expected 1 step, got %d", len(steps)) @@ -212,6 +212,42 @@ func TestGenerateCopilotInstallerSteps_ExpressionVersion(t *testing.T) { } } +func TestGenerateCopilotInstallerSteps_Rootless(t *testing.T) { + steps := GenerateCopilotInstallerSteps("1.2.3", "Install Copilot CLI", true) + + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join(steps[0], "\n") + + if !strings.Contains(stepContent, "--rootless") { + t.Errorf("Expected step to contain --rootless flag, got:\n%s", stepContent) + } + + if !strings.Contains(stepContent, "install_copilot_cli.sh") { + t.Errorf("Expected step to use install_copilot_cli.sh, got:\n%s", stepContent) + } +} + +func TestGenerateCopilotInstallerSteps_RootlessWithExpression(t *testing.T) { + steps := GenerateCopilotInstallerSteps("${{ inputs.version }}", "Install Copilot CLI", true) + + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join(steps[0], "\n") + + if !strings.Contains(stepContent, "--rootless") { + t.Errorf("Expected step to contain --rootless flag, got:\n%s", stepContent) + } + + if !strings.Contains(stepContent, `"${ENGINE_VERSION}"`) { + t.Errorf("Expected step to use ENGINE_VERSION env var, got:\n%s", stepContent) + } +} + func TestCopilotInstallerExpressionVersion_ViaEngineConfig(t *testing.T) { // Test that expression version from engine config is ignored in favor of default pinned version engine := NewCopilotEngine() diff --git a/pkg/workflow/nodejs.go b/pkg/workflow/nodejs.go index 85f93f8d073..9a726dfe4fb 100644 --- a/pkg/workflow/nodejs.go +++ b/pkg/workflow/nodejs.go @@ -174,15 +174,16 @@ func BuildNpmEngineInstallStepsWithAWF(npmSteps []GitHubActionStep, workflowData } // Copy Copilot CLI to daemon-visible path for ARC/DinD. - // The install script puts copilot at /usr/local/bin/copilot which is inside the - // sysroot image — not the runner's filesystem. On ARC/DinD, the AWF command - // references ${RUNNER_TEMP}/gh-aw/bin/copilot which is daemon-visible. + // With --rootless, the binary is at ~/.local/bin/copilot; otherwise /usr/local/bin/copilot. + // On ARC/DinD, the AWF command references ${RUNNER_TEMP}/gh-aw/bin/copilot which is + // daemon-visible, so we copy from wherever the install script placed it. if isFirewallEnabled(workflowData) && isArcDindTopology(workflowData) { copyStep := GitHubActionStep([]string{ " - name: Copy Copilot CLI to daemon-visible path", " run: |", " mkdir -p \"${RUNNER_TEMP}/gh-aw/bin\"", - " cp /usr/local/bin/copilot \"${RUNNER_TEMP}/gh-aw/bin/copilot\"", + ` COPILOT_SRC="$(command -v copilot)"`, + " cp \"$COPILOT_SRC\" \"${RUNNER_TEMP}/gh-aw/bin/copilot\"", " chmod +x \"${RUNNER_TEMP}/gh-aw/bin/copilot\"", }) steps = append(steps, copyStep)