Ctrl K
ComunidadGuías…

glab-usr

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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