glab-usr
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Robot Dev Team Project
# File: glab-usr
# Description: Switch GitLab CLI credentials for the active agent.
# License: MIT
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 MCKNLY LLC
set -euo pipefail
HOST="${GLAB_HOST:-gitlab.example.com}"
# API host may differ from the web host (e.g. when GitLab runs on a
# non-standard port). Defaults to HOST when unset.
API_HOST="${GLAB_API_HOST:-$HOST}"
# Protocol for API and git operations. Use 'http' for local HTTP deployments.
PROTOCOL="${GLAB_PROTOCOL:-https}"
# Loud-fail when GLAB_HOST resolves to the placeholder so we never silently
# target a non-existent server. Intent-based: trips even when explicitly
# exported, and matches ported variants (e.g. gitlab.example.com:8080).
# The hint deliberately names no example host: this file ships in the published
# image, and the one example a reader would find obvious is the placeholder the
# guard just rejected.
case "${HOST%%:*}" in
gitlab.example.com)
printf 'Error: GLAB_HOST is not configured (resolves to placeholder "%s").\n' "$HOST" >&2
printf ' Export GLAB_HOST=<your-gitlab-host> and retry.\n' >&2
exit 1
;;
esac
# Reject scheme in GLAB_HOST / GLAB_API_HOST: glab auth flags and the
# credential URL construction below both assume a bare host[:port].
for _h in "$HOST" "$API_HOST"; do
case "$_h" in
*://*)
printf 'Error: GLAB_HOST/GLAB_API_HOST must be a bare host (no scheme); got "%s".\n' "$_h" >&2
printf ' Use bare <host>[:port] and set GLAB_PROTOCOL=http when needed.\n' >&2
exit 1
;;
esac
done
unset _h
# Validate GLAB_PROTOCOL: only http/https are meaningful for glab and for
# the credential URL written below.
case "$PROTOCOL" in
http|https) ;;
*)
printf 'Error: GLAB_PROTOCOL must be "http" or "https"; got "%s".\n' "$PROTOCOL" >&2
exit 1
;;
esac
# When invoked under sudo, resolve the *original* user's home via tilde
# expansion so credential writes land in their account, not root's. Without
# sudo, use $HOME directly: the login shell / container entrypoint sets
# it, and tilde-expansion through /etc/passwd would override deliberately
# scoped HOME values (e.g. in isolated test environments).
if [[ -n "${SUDO_USER:-}" ]]; then
BASE_USER="$SUDO_USER"
BASE_HOME=$(eval echo "~$BASE_USER" 2>/dev/null || echo "${HOME:-/root}")
else
BASE_USER="$(id -un 2>/dev/null || printf 'root')"
BASE_HOME="${HOME:-$(eval echo "~$BASE_USER" 2>/dev/null || echo /root)}"
fi
CONFIG_FILE="$BASE_HOME/.config/glab-cli/config.yml"
# Global-fallback credential store, used when no writable repository is
# available. Deliberately NOT $BASE_HOME/.git-credentials: that path is
# git's shared default store and holds entries for other hosts (GitHub
# mirrors, other GitLab instances). This script writes its store as a
# single line, so pointing it at the shared file destroys every unrelated
# credential on each invocation. A dedicated per-host file removes that
# blast radius by construction. HOST may carry a port, so slugify anything
# outside the portable filename set.
HOST_SLUG="$(printf '%s' "$HOST" | tr -c 'A-Za-z0-9._-' '_')"
GLOBAL_CRED_FILE="$BASE_HOME/.config/glab-cli/git-credentials-$HOST_SLUG"
# Self-defense against root-owned config files in $BASE_HOME. A stray
# `docker compose exec app ...` (which defaults to root) that invokes
# glab will create $BASE_HOME/.config/glab-cli/ owned by root with mode
# 700, after which appuser-driven calls fail with a cryptic
# "failed to create config directory: mkdir ... permission denied".
# Catch that here with a one-line remediation instead of letting glab
# emit the unhelpful error. Skips silently when paths don't exist yet
# (normal first-run path).
#
# Writability/executability tests are routed through $BASE_USER so root
# callers (sudo, `docker exec` as root) do not bypass the guard via
# Unix permission-ignore semantics. `-e` follows symlinks, so a
# dangling symlink reads as nonexistent and slips past -- which is the
# desired behavior; there is no poisoning to detect on a broken link.
_check_as_base() {
if [[ -n "${SUDO_USER:-}" ]]; then
sudo -u "$SUDO_USER" test "$@"
else
test "$@"
fi
}
_bad_path=""
# Directories must be writable (create files) and searchable (traverse).
for _dir in "$BASE_HOME/.config" "$BASE_HOME/.config/glab-cli"; do
if [[ -e "$_dir" ]] && { ! _check_as_base -w "$_dir" || ! _check_as_base -x "$_dir"; }; then
_bad_path="$_dir"
break
fi
done
# Files: directories may be appuser-owned while the file itself was
# written by root (e.g. `git config --global` ran as root with .config
# already appuser-owned). Catch those too.
if [[ -z "$_bad_path" ]]; then
for _file in \
"$BASE_HOME/.config/glab-cli/config.yml" \
"$BASE_HOME/.gitconfig" \
"$GLOBAL_CRED_FILE"; do
if [[ -f "$_file" ]] && ! _check_as_base -w "$_file"; then
_bad_path="$_file"
break
fi
done
fi
if [[ -n "$_bad_path" ]]; then
printf 'Error: %s exists but is not writable by %s (uid %s).\n' \
"$_bad_path" "$BASE_USER" "${SUDO_UID:-$(id -u)}" >&2
printf ' This usually means a previous root-owned `docker exec` created\n' >&2
printf ' these files. Fix from the docker host with:\n' >&2
printf ' docker compose exec -u root app chown -R %s %s\n' \
"$BASE_USER" "$BASE_HOME" >&2
printf ' To avoid recurrence, always pass `-u %s` when exec-ing into the container.\n' \
"$BASE_USER" >&2
exit 1
fi
unset _dir _file _bad_path
unset -f _check_as_base
timestamp() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
current_glab_user() {
[[ -f "$CONFIG_FILE" ]] || return 0
awk -v host="$HOST" '
$1 == "hosts:" { in_hosts = 1; next }
in_hosts && $1 ~ /^[^:]+:$/ {
current = $1
sub(/:$/, "", current)
in_target = (current == host)
next
}
in_target && $1 == "user:" { print $2; exit }
' "$CONFIG_FILE"
}
resolve_agent_name() {
if [[ -n "${CURRENT_AGENT:-}" ]]; then
printf '%s' "$CURRENT_AGENT"
return
fi
if [[ -n "${ROBOT_AGENT_NAME:-}" ]]; then
printf '%s' "$ROBOT_AGENT_NAME"
return
fi
if [[ -n "${CURRENT_GLAB_USER:-}" ]]; then
printf '%s' "$CURRENT_GLAB_USER"
else
printf 'unknown'
fi
}
log_tool_call() {
local command="$1"
printf '%s | INFO | tool-call | tool=glab-usr agent=%s command=%s\n' \
"$(timestamp)" "$(resolve_agent_name)" "$command" >&2
}
read_token() {
local env_value="$1"
local file_path="$2"
if [[ -n "$env_value" ]]; then
printf '%s' "$env_value"
return
fi
if [[ -r "$file_path" ]]; then
tr -d '\r\n' < "$file_path"
return
fi
printf ''
}
run_as_base() {
if [[ -n "${SUDO_USER:-}" ]]; then
sudo -u "$SUDO_USER" "$@"
else
"$@"
fi
}
is_writable_dir() {
local dir="$1"
[[ -d "$dir" && -w "$dir" ]]
}
ensure_safe_directory() {
local repo_root="$1"
if ! run_as_base git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$repo_root"; then
run_as_base git config --global --add safe.directory "$repo_root" 2>/dev/null || true
fi
}
# Resolve the checkout the credential store and the git identity attach to.
# Sets REPO_ROOT and RESOLVED_COMMON_DIR; both are empty when cwd is not inside
# a repository, which is the caller's signal to take the global fallback.
#
# The resolved common dir rather than "$REPO_ROOT/.git": in a linked worktree
# .git is a *file* holding a gitdir: pointer, and in a submodule it points into
# <parent>/.git/modules/<name>. --git-common-dir resolves the shared directory
# in both shapes, which is where per-repository config and the credential store
# belong. The old `-d .git` predicate simply failed on both and dropped to the
# global path from inside a perfectly writable checkout.
#
# Scope note: this makes the store and helper *shared across every worktree of
# a repository*, because `git config --local` from a linked worktree writes
# the common config. One agent per repository is therefore an invariant --
# authenticating in one worktree re-points its siblings. Isolating them would
# mean `--worktree` config plus enabling extensions.worktreeConfig on the
# operator's repository, a repository-wide semantic change for a guarantee the
# rest of the system (shared ~/.config/glab-cli state) does not offer anyway.
# See docs/AGENT_ONBOARDING.md.
resolve_repo_paths() {
REPO_ROOT=""
RESOLVED_COMMON_DIR=""
# Walk up and register safe.directory *before* any git-native resolution:
# on an ownership-mismatched mount rev-parse fails the dubious-ownership
# check before it can report a path. `-e` covers the .git file shapes above;
# registering the worktree/submodule top level alone is enough for git to
# then resolve the common dir (verified against git 2.39.5).
local potential_root
potential_root=$(pwd)
while [[ "$potential_root" != "/" ]]; do
if [[ -e "$potential_root/.git" ]]; then
ensure_safe_directory "$potential_root"
break
fi
potential_root=$(dirname "$potential_root")
done
REPO_ROOT=$(run_as_base git rev-parse --show-toplevel 2>/dev/null) || REPO_ROOT=""
[[ -n "$REPO_ROOT" ]] || return 0
local common_dir
common_dir=$(run_as_base git rev-parse --git-common-dir 2>/dev/null) || common_dir=""
case "$common_dir" in
"") RESOLVED_COMMON_DIR="" ;;
/*) RESOLVED_COMMON_DIR="$common_dir" ;;
*) RESOLVED_COMMON_DIR=$(cd "$common_dir" 2>/dev/null && pwd -P) || RESOLVED_COMMON_DIR="" ;;
esac
}
# Wrap a value in POSIX single quotes so git can hand the helper string to
# /bin/sh without word-splitting it. Git runs credential helpers through a
# shell, so an unquoted checkout path containing a space is parsed as extra
# arguments and the helper aborts with a usage error.
shell_quote() {
printf "'%s'" "${1//\'/\'\\\'\'}"
}
# The host-scoped credential key. Single source of truth: consumers that need
# to reproduce this configuration on a git command line (see the
# CREDENTIAL-CONFIG contract line below) read it from us rather than deriving
# it again, because a wrong key fails silently -- it simply does not match the
# request, and whatever helper was inherited answers as before.
#
# The key must be built from GLAB_PROTOCOL and from GLAB_HOST *including any
# port*: a `https://` key does not match an `http` credential request, and a
# portless key does not match a `host:8080` request. Both were verified
# against git 2.39.5.
credential_helper_key() {
printf 'credential.%s://%s.helper' "$PROTOCOL" "$HOST"
}
# The helper value for a store path, shell-quoted (see shell_quote).
credential_helper_value() {
printf 'store --file %s' "$(shell_quote "$1")"
}
# Point git at the selected agent's credential store for GLAB_HOST, and make
# sure nothing else answers first.
#
# Credential helpers accumulate across configuration scopes and are queried
# in order (system -> global -> local). A plain `credential.helper` written
# at repository scope therefore loses to any helper the operator already
# configured globally: the wrong agent's credential answers the prompt, and
# the push is attributed to that agent even though glab-usr reported success
# and the commits carry the right author. Writing a *host-scoped* key whose
# first value is empty resets the inherited helper list for GLAB_HOST only,
# which leaves the selected agent's store as the sole responder while
# helpers for every other host keep working.
#
# See credential_helper_key for the constraints on the key itself.
configure_credential_helper() {
local scope="$1"
local cred_file="$2"
local repo_root="${3:-}"
local key helper legacy
local -a git_cmd
key="$(credential_helper_key)"
helper="$(credential_helper_value "$cred_file")"
if [[ "$scope" == "repository" ]]; then
git_cmd=(git -C "$repo_root" config --local)
legacy="store --file $cred_file"
else
git_cmd=(git config --global)
legacy="store --file $BASE_HOME/.git-credentials"
fi
# glab-usr runs on every agent dispatch, so clear the scoped key before
# re-adding it; otherwise the config grows a duplicate reset/store pair
# per run. `--unset-all` exits 5 when the key is absent, which is the
# normal first-run path.
run_as_base "${git_cmd[@]}" --unset-all "$key" >/dev/null 2>&1 || true
# Drop the generic entry older versions of this script wrote, matching the
# exact value only so unrelated helpers in the same scope (GCM, cache)
# survive. Best-effort: --fixed-value needs git >= 2.30, and leaving the
# stale entry behind is harmless because the scoped reset outranks it.
run_as_base "${git_cmd[@]}" --fixed-value --unset-all credential.helper "$legacy" >/dev/null 2>&1 || true
# Fatal rather than a warning: a half-configured helper means the next
# push authenticates as whoever the inherited helper returns, which is the
# misattribution this function exists to prevent.
if ! run_as_base "${git_cmd[@]}" --add "$key" "" >/dev/null 2>&1 ||
! run_as_base "${git_cmd[@]}" --add "$key" "$helper" >/dev/null 2>&1; then
echo "[glab-usr] ERROR: failed to configure $scope credential helper for $HOST" >&2
exit 1
fi
}
# Warn when a global-scope run lands in a checkout that already carries a
# *different* agent's repository-scoped helper.
#
# Repository config is read last, so its scoped reset discards the global one:
# git resolves the other agent's credential for every operation run inside
# that checkout, and the "configured global credential store" line above would
# otherwise report success for a configuration that is never consulted there.
# Read-only project mounts hit this by construction -- docker-compose exposes
# the same tree read-write and read-only, so the .git/config a writable
# dispatch wrote is the one the read-only view reads.
#
# Deliberately a warning, not a hard failure: the read-only path cannot push,
# and glab / gitlab-connect authenticate from GLAB_TOKEN rather than the git
# helper, so exiting non-zero would break every read-only dispatch over an
# identity those dispatches never use. Callers that *do* write to the remote
# from a foreign checkout (the branch pruner) must override the helper on the
# git command line -- see the CREDENTIAL-CONFIG contract below.
warn_if_shadowed_by_repository_helper() {
local repo_root="$1"
local cred_file="$2"
local key expected value
key="$(credential_helper_key)"
expected="$(credential_helper_value "$cred_file")"
while IFS= read -r value; do
# The empty first value is git's helper-list reset, not a store.
[[ -n "$value" ]] || continue
[[ "$value" != "$expected" ]] || continue
echo "[glab-usr] WARN: $repo_root has a repository-scoped credential helper ($value)" >&2
echo "[glab-usr] WARN: repository config outranks global, so git operations run inside that" >&2
echo "[glab-usr] WARN: checkout will authenticate with that store, not the one configured above." >&2
return
done < <(run_as_base git -C "$repo_root" config --local --get-all "$key" 2>/dev/null || true)
}
update_git_credentials() {
local username="$1"
local token="$2"
local repo_root cred_file cred_scope
resolve_repo_paths
repo_root="$REPO_ROOT"
if is_writable_dir "$RESOLVED_COMMON_DIR"; then
cred_file="$RESOLVED_COMMON_DIR/credentials"
cred_scope="repository"
fi
if [[ -z "${cred_file:-}" ]]; then
cred_file="$GLOBAL_CRED_FILE"
cred_scope="global"
fi
# Git credentials key on the *web/git* host (GLAB_HOST), not the API
# host. When GLAB_HOST and GLAB_API_HOST differ, git push/pull
# authenticates against the remote URL host (GLAB_HOST). Writing
# credentials keyed to GLAB_API_HOST would leave git operations
# unauthenticated.
# The token is passed through the environment, never argv, so it cannot
# surface in process listings or in the tool-call log above.
if ! run_as_base env HOST="$HOST" USERNAME="$username" TOKEN="$token" CRED_FILE="$cred_file" PROTOCOL="$PROTOCOL" python3 - <<'PY'
import os
import pathlib
cred_file = pathlib.Path(os.environ["CRED_FILE"]).expanduser()
# Only tighten a directory this script creates. A repository store's parent
# is the resolved common git directory, whose mode belongs to git.
if not cred_file.parent.exists():
cred_file.parent.mkdir(parents=True, exist_ok=True)
os.chmod(cred_file.parent, 0o700)
with cred_file.open("w", encoding="utf-8") as fh:
fh.write(f"{os.environ['PROTOCOL']}://{os.environ['USERNAME']}:{os.environ['TOKEN']}@{os.environ['HOST']}\n")
os.chmod(cred_file, 0o600)
PY
then
echo "[glab-usr] ERROR: failed to write $cred_scope credential store at $cred_file" >&2
exit 1
fi
configure_credential_helper "$cred_scope" "$cred_file" "${repo_root:-}"
echo "[glab-usr] INFO: configured $cred_scope credential store at $cred_file" >&2
# repo_root stays set when a repository was found but rejected as
# unwritable, which is exactly the case that can be shadowed.
if [[ "$cred_scope" == "global" && -n "${repo_root:-}" ]]; then
warn_if_shadowed_by_repository_helper "$repo_root" "$cred_file"
fi
# Machine-readable contract for callers that must reproduce this
# configuration on a git command line (`git -c key= -c key=<helper> ...`),
# which is the only scope that outranks a foreign checkout's repository
# config without writing to it. Emitting the key and the quoted helper here
# keeps their derivation in one place; a second copy would drift, and the
# drift is silent. Consumed by app/services/branch_pruning.py.
#
# On stderr with the other [glab-usr] diagnostics so stdout stays exactly
# what it was for existing callers. Contains a path, never token material.
printf '[glab-usr] CREDENTIAL-CONFIG scope=%s key=%s helper=%s\n' \
"$cred_scope" "$(credential_helper_key)" "$(credential_helper_value "$cred_file")" >&2
}
set_git_identity_local() {
local repo_root="$1"
local name="$2"
local email="$3"
run_as_base git -C "$repo_root" config user.name "$name" >/dev/null 2>&1 || \
echo "[glab-usr] WARN: failed to set repository user.name" >&2
run_as_base git -C "$repo_root" config user.email "$email" >/dev/null 2>&1 || \
echo "[glab-usr] WARN: failed to set repository user.email" >&2
}
set_git_identity_global() {
local name="$1"
local email="$2"
run_as_base git config --global user.name "$name" >/dev/null 2>&1 || \
echo "[glab-usr] WARN: failed to set global user.name" >&2
run_as_base git config --global user.email "$email" >/dev/null 2>&1 || \
echo "[glab-usr] WARN: failed to set global user.email" >&2
}
configure_git_identity() {
local agent="$1"
local name email config_scope
# Convention-based identity resolution: derive env var names from the
# agent name. E.g. agent "qwen-code" -> QWEN_CODE_AGENT_GIT_NAME /
# QWEN_CODE_AGENT_GIT_EMAIL. Defaults use a capitalised agent name.
local agent_upper
agent_upper="$(printf '%s' "$agent" | tr '[:lower:]-' '[:upper:]_')"
local name_var="${agent_upper}_AGENT_GIT_NAME"
local email_var="${agent_upper}_AGENT_GIT_EMAIL"
# Title-case the default display name (capitalise each hyphen-separated word)
local default_name
default_name="$(printf '%s' "$agent" | sed 's/\b./\U&/g; s/-/ /g') Agent"
name="${!name_var:-$default_name}"
email="${!email_var:-${agent}@example.com}"
# Loud-fail when the email is the placeholder. Falling back silently
# would land commits with @example.com authorship. Note: this check
# also blocks read-only flows (e.g. `gitlab-connect issue view`) when
# the agent has no configured email; that is intentional for the
# automation environment, which expects every configured agent to have
# a real identity. Document via *_AGENT_GIT_EMAIL in .env.
if [[ "$email" == *"@example.com" ]]; then
printf 'Error: %s is not configured (resolves to placeholder "%s").\n' "$email_var" "$email" >&2
printf ' Export %s=<real-email> (and optionally %s=<real-name>) before authenticating.\n' "$email_var" "$name_var" >&2
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "[glab-usr] WARN: git not available; skipping identity configuration" >&2
return
fi
# Same resolution as update_git_credentials, so the identity can never land
# in a different scope than the credential that will be used to push it.
resolve_repo_paths
if [[ -n "$REPO_ROOT" ]] && is_writable_dir "$RESOLVED_COMMON_DIR"; then
set_git_identity_local "$REPO_ROOT" "$name" "$email"
config_scope="repository"
else
set_git_identity_global "$name" "$email"
config_scope="global"
fi
echo "[glab-usr] INFO: configured $config_scope git identity ($name <$email>)" >&2
}
if [[ $# -ne 1 ]]; then
echo "usage: $(basename "$0") <username>" >&2
exit 1
fi
target_user="$1"
# Convention-based token resolution: derive env var and token file path
# from the agent name. E.g. agent "qwen-code" resolves to env var
# QWEN_CODE_AGENT_GITLAB_TOKEN and token file ~/.qwen-code/glab-token.
# The token file directory always uses the lowercase-hyphenated form of
# the agent name (matching docker-entrypoint.sh normalisation).
token_env_var="$(printf '%s' "$target_user" | tr '[:lower:]-' '[:upper:]_')_AGENT_GITLAB_TOKEN"
token_dir="$(printf '%s' "$target_user" | tr '[:upper:]_' '[:lower:]-')"
token=$(read_token "${!token_env_var:-}" "$BASE_HOME/.$token_dir/glab-token")
if [[ -z "$token" ]]; then
echo "missing token for user $target_user (expected env var $token_env_var or file $BASE_HOME/.$token_dir/glab-token)" >&2
exit 1
fi
CURRENT_GLAB_USER="$(current_glab_user)"
current_user="$CURRENT_GLAB_USER"
if [[ "$current_user" == "$target_user" ]]; then
log_tool_call "noop (already authenticated as $target_user)"
update_git_credentials "$target_user" "$token"
configure_git_identity "$target_user"
echo "user already logged in"
exit 0
fi
echo "Switching authentication to $target_user on $HOST" >&2
log_tool_call "glab auth login --hostname $(printf '%q' "$HOST") --api-host $(printf '%q' "$API_HOST") --api-protocol $PROTOCOL --git-protocol $PROTOCOL --token ***redacted***"
if [[ -n "${SUDO_USER:-}" ]]; then
sudo -u "$SUDO_USER" env GLAB_NO_PROMPT=1 glab auth login \
--hostname "$HOST" --api-host "$API_HOST" \
--api-protocol "$PROTOCOL" --git-protocol "$PROTOCOL" \
--token "$token"
else
GLAB_NO_PROMPT=1 glab auth login \
--hostname "$HOST" --api-host "$API_HOST" \
--api-protocol "$PROTOCOL" --git-protocol "$PROTOCOL" \
--token "$token"
fi
status=$?
if [[ $status -eq 0 ]]; then
echo "authenticated as $target_user"
update_git_credentials "$target_user" "$token"
configure_git_identity "$target_user"
else
echo "failed to authenticate as $target_user" >&2
fi
exit $status