From 162145b55924726723d57f71f4d2e6a5127821f9 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 6 Jun 2026 15:02:09 -0700 Subject: [PATCH] feat(git-auth): fleet-wide non-interactive git auth Add setup-git-auth.sh: idempotent, fail-silent script that primes the git credential store from the vault Gitea token, scoped per-repo by the actual origin host. Only seizes the helper from the prompting GCM `manager` (leaves Mac osxkeychain alone); fast-path no-op once set. Wire it into a backgrounded SessionStart hook and set GIT_TERMINAL_PROMPT=0 / GCM_INTERACTIVE=Never in settings.json env so no session on any machine can hang on a credential prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../feedback_git_noninteractive_auth.md | 6 ++ .claude/scripts/setup-git-auth.sh | 102 ++++++++++++++++++ .claude/settings.json | 9 ++ 3 files changed, 117 insertions(+) create mode 100644 .claude/scripts/setup-git-auth.sh diff --git a/.claude/memory/feedback_git_noninteractive_auth.md b/.claude/memory/feedback_git_noninteractive_auth.md index 8752af6..c199e3a 100644 --- a/.claude/memory/feedback_git_noninteractive_auth.md +++ b/.claude/memory/feedback_git_noninteractive_auth.md @@ -16,4 +16,10 @@ Mike (admin, owner) clarified: he doesn't dislike git itself or the PowerShell-v - Run git from the PowerShell tool (native `git.exe`). Under PowerShell 5.1, git's stderr progress (even "Everything up-to-date") surfaces as a red `NativeCommandError` on success — trust `$LASTEXITCODE`, not the text. - The Gitea Agent definition (`.claude/agents/gitea.md`) carries this same guidance so delegated pushes also stay non-interactive. +**Fleet-wide automation (set for ALL sessions, every machine):** +- `.claude/scripts/setup-git-auth.sh` primes the credential store from the vault token for the claudetools + vault repos, deriving each repo's host from its actual `origin` (this box: `http://172.16.3.20:3000`; Mac likely `https://git.azcomputerguru.com`). Idempotent, fast-path no-op once configured, fail-silent. Only seizes the helper from GCM `manager`/unset — leaves a Mac osxkeychain setup alone. +- A backgrounded `SessionStart` hook in `.claude/settings.json` runs it every session, so a fresh clone / reinstalled machine self-heals. +- `.claude/settings.json` `env` sets `GIT_TERMINAL_PROMPT=0` and `GCM_INTERACTIVE=Never` (committed → all sessions, all machines) so git can never hang on a prompt even before the store is primed. +- Token field in vault: `services/gitea.sops.yaml` -> `credentials.api.api-token`. `get-field` needs PyYAML (`py -m pip install pyyaml`); the script falls back to `get`+grep if PyYAML/yq is absent. + Related Windows gotchas (separate issues, still real): [[feedback_windows_bash_mapping]], [[feedback_tmp_path_windows]], [[feedback_jq_crlf_windows]]. Gitea API auth detail: [[reference_gitea_api_credential]]. diff --git a/.claude/scripts/setup-git-auth.sh b/.claude/scripts/setup-git-auth.sh new file mode 100644 index 0000000..1a60e5f --- /dev/null +++ b/.claude/scripts/setup-git-auth.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# setup-git-auth.sh — make git push/fetch fully non-interactive on this machine. +# +# Mike's requirement: git must NEVER sit at an interactive credential prompt +# (Git Credential Manager popups hang automation/background pushes). This script +# primes the git "store" credential helper with the shared azcomputerguru Gitea +# API token (from the SOPS vault), scoped to each repo's actual remote host. +# +# Properties: +# - Idempotent + fast-path: if every managed repo already has a stored +# credential for its remote host, it exits WITHOUT touching the vault. +# - Conservative: only switches a repo to the `store` helper when the current +# helper is empty or the prompting GCM `manager` (so a Mac osxkeychain setup +# that already works silently is left untouched). +# - Fail-silent: always exits 0; never blocks a session. +# +# Runs from the SessionStart hook (backgrounded) and from onboarding. +# See: .claude/memory/feedback_git_noninteractive_auth.md + +set -u + +# --- locate repo root + identity ------------------------------------------------ +CT_ROOT="${CLAUDE_PROJECT_DIR:-}" +if [ -z "$CT_ROOT" ]; then + # two levels up from this script: .claude/scripts/ -> repo root + CT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)" +fi +IDENTITY="$CT_ROOT/.claude/identity.json" +VAULT="$CT_ROOT/.claude/scripts/vault.sh" +CRED_FILE="$HOME/.git-credentials" +GIT_USER="azcomputerguru" + +# Extract a flat string field from identity.json without requiring jq. +json_field() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$IDENTITY" 2>/dev/null | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/'; } + +VAULT_PATH="$(json_field vault_path)" + +# Candidate repos to make non-interactive: this repo + the vault repo. +REPOS=("$CT_ROOT") +[ -n "$VAULT_PATH" ] && [ -d "$VAULT_PATH/.git" ] && REPOS+=("$VAULT_PATH") + +# --- derive scheme + host (authority) from a remote URL ------------------------- +remote_authority() { # echoes "scheme host[:port]" or nothing + local url="$1" scheme rest auth host + case "$url" in + http://*|https://*) scheme="${url%%://*}";; + *) return 0;; # ssh/git@ remotes don't use the credential store + esac + rest="${url#*://}" + auth="${rest%%/*}" # strip path + host="${auth##*@}" # strip any userinfo + [ -n "$host" ] && printf '%s %s' "$scheme" "$host" +} + +# Does the cred file already have an entry for this scheme://user@host ? +have_cred() { # $1=scheme $2=host + [ -f "$CRED_FILE" ] || return 1 + grep -qE "^$1://$GIT_USER:[^@]*@$2$" "$CRED_FILE" 2>/dev/null +} + +# --- fast path: everything already configured? --------------------------------- +needs_priming=0 +for repo in "${REPOS[@]}"; do + url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue + read -r scheme host <<<"$(remote_authority "$url")" + [ -n "${host:-}" ] || continue + have_cred "$scheme" "$host" || needs_priming=1 +done + +# --- fetch token only if needed ------------------------------------------------ +TOKEN="" +if [ "$needs_priming" -eq 1 ] && [ -f "$VAULT" ]; then + TOKEN="$(bash "$VAULT" get-field services/gitea.sops.yaml credentials.api.api-token 2>/dev/null | tr -d '\r\n ')" + # Fallback for machines missing PyYAML/yq: parse the full decrypted entry. + if ! printf '%s' "$TOKEN" | grep -qE '^[0-9a-f]{40}$'; then + TOKEN="$(bash "$VAULT" get services/gitea.sops.yaml 2>/dev/null | grep -oE 'api-token:[[:space:]]*[0-9a-f]{40}' | grep -oE '[0-9a-f]{40}' | head -1)" + fi +fi + +# --- configure each repo ------------------------------------------------------- +touch "$CRED_FILE" 2>/dev/null && chmod 600 "$CRED_FILE" 2>/dev/null || true +for repo in "${REPOS[@]}"; do + url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || continue + read -r scheme host <<<"$(remote_authority "$url")" + [ -n "${host:-}" ] || continue + + # Prime the store entry if missing and we have a token. + if ! have_cred "$scheme" "$host" && [ -n "$TOKEN" ]; then + printf '%s://%s:%s@%s\n' "$scheme" "$GIT_USER" "$TOKEN" "$host" >>"$CRED_FILE" + fi + + # Only seize the helper away from the prompting GCM (or an unset helper). + helper="$(git -C "$repo" config --get credential.helper 2>/dev/null)" + case "$helper" in + ""|*manager*) + git -C "$repo" config --local --unset-all credential.helper 2>/dev/null || true + git -C "$repo" config --local credential.helper store 2>/dev/null || true + ;; + esac +done + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 67ec879..443bf86 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,10 @@ "permissions": { "defaultMode": "bypassPermissions" }, + "env": { + "GIT_TERMINAL_PROMPT": "0", + "GCM_INTERACTIVE": "Never" + }, "preferences": { "autoCompact": true, "verbose": false @@ -37,6 +41,11 @@ "type": "command", "command": "bash -c 'if [ -f \"${CLAUDE_PROJECT_DIR}/.claude/scripts/sync-memory.sh\" ]; then nohup bash \"${CLAUDE_PROJECT_DIR}/.claude/scripts/sync-memory.sh\" >/dev/null 2>&1 & fi; exit 0'", "timeout": 10 + }, + { + "type": "command", + "command": "bash -c 'if [ -f \"${CLAUDE_PROJECT_DIR}/.claude/scripts/setup-git-auth.sh\" ]; then nohup bash \"${CLAUDE_PROJECT_DIR}/.claude/scripts/setup-git-auth.sh\" >/dev/null 2>&1 & fi; exit 0'", + "timeout": 10 } ] }