mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-20 18:00:23 +00:00
Before this change, gh auth login wrote the token file with default permissions, and chmod 600 was applied afterward — leaving a window where the file could be read by other users on multi-user systems. Now the credential directory is created with 700 permissions and umask is set to 077 before the write, so the token file is created with restrictive permissions from the start. Agent: complexity-hunter Fixes #3030 Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
375 lines
14 KiB
Bash
Executable file
375 lines
14 KiB
Bash
Executable file
#!/bin/bash
|
|
# Standalone GitHub auth helper — installs gh CLI and runs OAuth login
|
|
# Executable directly via curl|bash; also sourceable using the CDN URL with eval.
|
|
#
|
|
# Usage (via curl|bash — recommended):
|
|
# curl -fsSL --proto '=https' https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash
|
|
# curl -fsSL --proto '=https' https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/sh/shared/github-auth.sh | bash
|
|
#
|
|
# Usage (sourced using absolute path or CDN URL):
|
|
# eval "$(curl -fsSL --proto '=https' https://openrouter.ai/labs/spawn/shared/github-auth.sh)"
|
|
# ensure_github_auth
|
|
|
|
# ============================================================
|
|
# Logging helpers
|
|
# ============================================================
|
|
|
|
log_info() { printf '[github-auth] %s\n' "$*" >&2; }
|
|
log_error() { printf '[github-auth] ERROR: %s\n' "$*" >&2; }
|
|
|
|
# ============================================================
|
|
# ensure_gh_cli — Install gh CLI if not already present
|
|
# ============================================================
|
|
|
|
# Install gh via Homebrew (macOS)
|
|
_install_gh_brew() {
|
|
if command -v brew &>/dev/null; then
|
|
brew install gh || {
|
|
log_error "Failed to install gh via Homebrew"
|
|
return 1
|
|
}
|
|
else
|
|
log_error "Homebrew not found. Install Homebrew first: https://brew.sh"
|
|
log_error "Then run: brew install gh"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Install gh via APT with GitHub's official repository (Debian/Ubuntu)
|
|
_install_gh_apt() {
|
|
# Use sudo only when not already root (some cloud containers run as root)
|
|
local SUDO=""
|
|
if [[ "$(id -u)" -ne 0 ]]; then SUDO="sudo"; fi
|
|
|
|
log_info "Adding GitHub CLI APT repository..."
|
|
curl -fsSL --proto '=https' https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
| ${SUDO} dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 2>/dev/null
|
|
${SUDO} chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
|
printf 'deb [arch=%s signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\n' \
|
|
"$(dpkg --print-architecture)" \
|
|
| ${SUDO} tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
|
${SUDO} apt-get update -qq
|
|
DEBIAN_FRONTEND=noninteractive ${SUDO} apt-get install -y --no-install-recommends gh || {
|
|
log_error "Failed to install gh via apt"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
# Install gh via DNF (Fedora/RHEL)
|
|
_install_gh_dnf() {
|
|
local SUDO=""
|
|
if [[ "$(id -u)" -ne 0 ]]; then SUDO="sudo"; fi
|
|
${SUDO} dnf install -y gh || {
|
|
log_error "Failed to install gh via dnf"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
ensure_gh_cli() {
|
|
if command -v gh &>/dev/null; then
|
|
log_info "GitHub CLI (gh) available: $(gh --version | head -1)"
|
|
return 0
|
|
fi
|
|
|
|
log_info "Installing GitHub CLI (gh)..."
|
|
|
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
_install_gh_brew || return 1
|
|
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
|
if command -v apt-get &>/dev/null; then
|
|
_install_gh_apt || return 1
|
|
elif command -v dnf &>/dev/null; then
|
|
_install_gh_dnf || return 1
|
|
else
|
|
_install_gh_binary || return 1
|
|
fi
|
|
else
|
|
_install_gh_binary || return 1
|
|
fi
|
|
|
|
if ! command -v gh &>/dev/null; then
|
|
log_error "gh not found in PATH after installation"
|
|
return 1
|
|
fi
|
|
|
|
log_info "GitHub CLI (gh) installed: $(gh --version | head -1)"
|
|
}
|
|
|
|
# ============================================================
|
|
# Binary fallback installer (non-apt/non-brew systems)
|
|
# ============================================================
|
|
|
|
# Detect OS and architecture for binary downloads, outputting "os arch" on stdout.
|
|
# Returns 1 with error message if platform is unsupported.
|
|
_detect_gh_platform() {
|
|
local os arch gh_os gh_arch
|
|
os="$(uname -s)"
|
|
arch="$(uname -m)"
|
|
|
|
case "${os}" in
|
|
Linux) gh_os="linux" ;;
|
|
Darwin) gh_os="macOS" ;;
|
|
*)
|
|
log_error "Unsupported OS: ${os}. Install manually from https://cli.github.com/"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
case "${arch}" in
|
|
x86_64|amd64) gh_arch="amd64" ;;
|
|
aarch64|arm64) gh_arch="arm64" ;;
|
|
*)
|
|
log_error "Unsupported architecture: ${arch}. Install manually from https://cli.github.com/"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
echo "${gh_os} ${gh_arch}"
|
|
}
|
|
|
|
# Fetch the latest gh release version string from GitHub API
|
|
_fetch_gh_latest_version() {
|
|
local api_response
|
|
api_response=$(curl -fsSL --proto '=https' "https://api.github.com/repos/cli/cli/releases/latest") || {
|
|
log_error "Failed to fetch latest gh release version"
|
|
return 1
|
|
}
|
|
|
|
local latest_version=""
|
|
# Prefer jq for safe JSON parsing; fall back to bun -e (never python)
|
|
if command -v jq &>/dev/null; then
|
|
latest_version=$(printf '%s' "${api_response}" | jq -r '.tag_name // empty' 2>/dev/null) || true
|
|
elif command -v bun &>/dev/null; then
|
|
latest_version=$(_GH_API_RESPONSE="${api_response}" bun -e "
|
|
const data = JSON.parse(process.env._GH_API_RESPONSE || '{}');
|
|
const tag = typeof data.tag_name === 'string' ? data.tag_name : '';
|
|
process.stdout.write(tag);
|
|
" 2>/dev/null) || true
|
|
else
|
|
log_error "Neither jq nor bun available for safe JSON parsing"
|
|
return 1
|
|
fi
|
|
|
|
# Strip leading 'v' prefix if present (tag_name is e.g. "v2.62.0")
|
|
latest_version="${latest_version#v}"
|
|
|
|
if [[ -z "${latest_version}" ]]; then
|
|
log_error "Could not determine latest gh version"
|
|
return 1
|
|
fi
|
|
|
|
# Validate version looks like a semver (digits and dots only)
|
|
if [[ ! "${latest_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
log_error "Unexpected version format: ${latest_version}"
|
|
return 1
|
|
fi
|
|
|
|
echo "${latest_version}"
|
|
}
|
|
|
|
# Download and extract a gh release tarball into ~/.local/bin
|
|
# Usage: _download_and_install_gh VERSION GH_OS GH_ARCH
|
|
_download_and_install_gh() {
|
|
local version="${1}" gh_os="${2}" gh_arch="${3}"
|
|
|
|
log_info "Downloading gh v${version} for ${gh_os}/${gh_arch}..."
|
|
|
|
local tarball="gh_${version}_${gh_os}_${gh_arch}.tar.gz"
|
|
local url="https://github.com/cli/cli/releases/download/v${version}/${tarball}"
|
|
local tmpdir
|
|
tmpdir=$(mktemp -d)
|
|
|
|
curl -fsSL --proto '=https' "${url}" -o "${tmpdir}/${tarball}" || {
|
|
log_error "Failed to download ${url}"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
}
|
|
|
|
# Verify SHA256 checksum before extracting (CWE-494: integrity check)
|
|
local checksums_url="https://github.com/cli/cli/releases/download/v${version}/gh_${version}_checksums.txt"
|
|
local checksums_file="${tmpdir}/gh_${version}_checksums.txt"
|
|
curl -fsSL --proto '=https' "${checksums_url}" -o "${checksums_file}" || {
|
|
log_error "Failed to download checksums from ${checksums_url}"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
}
|
|
|
|
# Use sha256sum on Linux, shasum -a 256 on macOS
|
|
local sha_cmd=""
|
|
if command -v sha256sum &>/dev/null; then
|
|
sha_cmd="sha256sum"
|
|
elif command -v shasum &>/dev/null; then
|
|
sha_cmd="shasum -a 256"
|
|
else
|
|
log_error "No SHA256 tool available (need sha256sum or shasum)"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
fi
|
|
|
|
# Extract expected checksum for our tarball from the checksums file
|
|
local expected_checksum
|
|
expected_checksum=$(grep " ${tarball}"'$' "${checksums_file}" | awk '{print $1}')
|
|
if [[ -z "${expected_checksum}" ]]; then
|
|
log_error "Checksum for ${tarball} not found in checksums.txt"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
fi
|
|
|
|
# Compute actual checksum of downloaded file
|
|
local actual_checksum
|
|
actual_checksum=$(cd "${tmpdir}" && ${sha_cmd} "${tarball}" | awk '{print $1}')
|
|
if [[ "${actual_checksum}" != "${expected_checksum}" ]]; then
|
|
log_error "SHA256 checksum mismatch for ${tarball}"
|
|
log_error " expected: ${expected_checksum}"
|
|
log_error " actual: ${actual_checksum}"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
fi
|
|
|
|
log_info "SHA256 checksum verified for ${tarball}"
|
|
|
|
# Defense-in-depth: reject tarballs containing absolute paths or ../ traversal
|
|
# (CWE-22: path traversal). This check is cross-platform (GNU + BSD tar).
|
|
if tar -tzf "${tmpdir}/${tarball}" | grep -qE '(^/|\.\.)'; then
|
|
log_error "Tarball contains absolute paths or path traversal — refusing to extract"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
fi
|
|
|
|
tar -xzf "${tmpdir}/${tarball}" -C "${tmpdir}" || {
|
|
log_error "Failed to extract ${tarball}"
|
|
rm -rf "${tmpdir}"
|
|
return 1
|
|
}
|
|
|
|
mkdir -p "${HOME}/.local/bin"
|
|
cp "${tmpdir}/gh_${version}_${gh_os}_${gh_arch}/bin/gh" "${HOME}/.local/bin/gh"
|
|
chmod +x "${HOME}/.local/bin/gh"
|
|
rm -rf "${tmpdir}"
|
|
|
|
# Add ~/.local/bin to PATH if not already there
|
|
case ":${PATH}:" in
|
|
*":${HOME}/.local/bin:"*) ;;
|
|
*) export PATH="${HOME}/.local/bin:${PATH}" ;;
|
|
esac
|
|
|
|
log_info "gh installed to ${HOME}/.local/bin/gh"
|
|
}
|
|
|
|
_install_gh_binary() {
|
|
log_info "Installing gh from GitHub releases (binary fallback)..."
|
|
|
|
local platform
|
|
platform=$(_detect_gh_platform) || return 1
|
|
local gh_os gh_arch
|
|
read -r gh_os gh_arch <<< "${platform}"
|
|
|
|
local latest_version
|
|
latest_version=$(_fetch_gh_latest_version) || return 1
|
|
|
|
_download_and_install_gh "${latest_version}" "${gh_os}" "${gh_arch}"
|
|
}
|
|
|
|
# ============================================================
|
|
# ensure_gh_auth — Authenticate with GitHub via gh auth login
|
|
# ============================================================
|
|
|
|
ensure_gh_auth() {
|
|
# When GITHUB_TOKEN is set, persist it to gh's credential store so it
|
|
# survives into the interactive session (where the env var is absent).
|
|
# NOTE: This writes the token to ~/.config/gh/hosts.yml in plaintext,
|
|
# which is standard gh CLI behavior (same as `gh auth login`).
|
|
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
|
# Validate token format: must start with a known GitHub prefix
|
|
case "${GITHUB_TOKEN}" in
|
|
ghp_*|gho_*|ghu_*|ghs_*|ghr_*|github_pat_*)
|
|
;;
|
|
*)
|
|
log_error "GITHUB_TOKEN has unexpected format (expected ghp_, gho_, ghu_, ghs_, ghr_, or github_pat_ prefix)"
|
|
return 1
|
|
;;
|
|
esac
|
|
# SECURITY: Reject tokens containing newlines, tabs, or carriage returns
|
|
# to prevent credential file corruption and bypass of downstream validation.
|
|
if [[ "${GITHUB_TOKEN}" =~ $'\n' ]] || [[ "${GITHUB_TOKEN}" =~ $'\t' ]] || [[ "${GITHUB_TOKEN}" =~ $'\r' ]]; then
|
|
log_error "GITHUB_TOKEN contains invalid control characters (newline/tab/CR)"
|
|
return 1
|
|
fi
|
|
|
|
# Fast path: skip persistence if gh is already authenticated with
|
|
# stored credentials (not just the env var). Temporarily unset
|
|
# GITHUB_TOKEN so gh auth status checks disk credentials only.
|
|
local _gh_token="${GITHUB_TOKEN}"
|
|
unset GITHUB_TOKEN
|
|
if gh auth status &>/dev/null; then
|
|
export GITHUB_TOKEN="${_gh_token}"
|
|
log_info "Authenticated with GitHub CLI (credentials already persisted)"
|
|
return 0
|
|
fi
|
|
|
|
log_info "Persisting GITHUB_TOKEN to gh credential store..."
|
|
# Ensure credential directory exists with restrictive permissions BEFORE writing token
|
|
# (prevents race condition where token file is world-readable before chmod)
|
|
mkdir -p "${HOME}/.config/gh"
|
|
chmod 700 "${HOME}/.config/gh" 2>/dev/null || printf 'Warning: could not set restrictive permissions on gh config directory\n' >&2
|
|
# Set restrictive umask so the token file is created with 0600 permissions
|
|
_old_umask=$(umask)
|
|
umask 077
|
|
# GITHUB_TOKEN is already unset above so gh auth login won't refuse
|
|
# with "The value of the GITHUB_TOKEN environment variable is being
|
|
# used for authentication."
|
|
gh auth login --with-token <<EOF || {
|
|
${_gh_token}
|
|
EOF
|
|
log_error "Failed to authenticate with GITHUB_TOKEN"
|
|
umask "${_old_umask}"
|
|
export GITHUB_TOKEN="${_gh_token}"
|
|
return 1
|
|
}
|
|
umask "${_old_umask}"
|
|
# Belt-and-suspenders: explicitly restrict token file permissions
|
|
chmod 600 "${HOME}/.config/gh/hosts.yml" 2>/dev/null || printf 'Warning: could not set restrictive permissions on gh credentials file\n' >&2
|
|
export GITHUB_TOKEN="${_gh_token}"
|
|
elif gh auth status &>/dev/null; then
|
|
log_info "Authenticated with GitHub CLI"
|
|
return 0
|
|
else
|
|
# Device code flow — works on headless/remote servers
|
|
# Shows a URL + code; user opens URL in local browser and enters the code
|
|
log_info "Authenticating via device code flow..."
|
|
log_info "A URL and code will appear below. Open the URL in your browser and enter the code."
|
|
gh auth login --web -p https -h github.com || {
|
|
log_error "GitHub authentication failed"
|
|
log_error "Run manually: gh auth login"
|
|
return 1
|
|
}
|
|
fi
|
|
|
|
if ! gh auth status &>/dev/null; then
|
|
log_error "gh auth status check failed after login"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Authenticated with GitHub CLI"
|
|
return 0
|
|
}
|
|
|
|
# ============================================================
|
|
# ensure_github_auth — Combined convenience wrapper
|
|
# ============================================================
|
|
|
|
ensure_github_auth() {
|
|
ensure_gh_cli || return 1
|
|
ensure_gh_auth || return 1
|
|
}
|
|
|
|
# ============================================================
|
|
# Direct execution support
|
|
# ============================================================
|
|
|
|
# If executed directly (not sourced), run ensure_github_auth
|
|
# When piped via curl|bash, BASH_SOURCE[0] is empty and $0 is "bash"
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]] || [[ -z "${BASH_SOURCE[0]:-}" ]]; then
|
|
set -eo pipefail
|
|
ensure_github_auth
|
|
fi
|