feat: add bw-run.sh credential sourcing layer [#442]

Replaces the --env-file ~/.creds/*.env pattern with Bitwarden-sourced
credentials. Each agent identity stores its own API keys in a BW
collection; bw-run.sh fetches them at runtime and passes to CLI
containers via a temp env file (cleaned up on exit).

Usage: bw-run.sh <agent> <service> <image> [args...]

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-08-13 08:59:24 -05:00
parent 2c9e5e9c0e
commit 8d352aa7d5
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# bw-run.sh — run a CLI command with credentials sourced from Bitwarden.
#
# Replaces the --env-file ~/.creds/<service>.env pattern. Each agent identity
# stores its own API keys/passwords in a Bitwarden collection. This script
# fetches the credential, writes it to a temp env file, runs the CLI, and
# cleans up — no credential material persists on disk.
#
# Usage:
# bw-run.sh <agent> <service> <image> [args...]
#
# Examples:
# bw-run.sh vp-techops redmine git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me -p 55
# bw-run.sh vp-techops discourse git.knownelement.com/reachableceo/discourse-cli:latest ls -c vp-techops
# bw-run.sh reachableceo redmine git.knownelement.com/reachableceo/redmine-cli:latest whoami
#
# Prerequisites:
# - BW_CLIENTID and BW_CLIENTSECRET exported (or in ~/.config/bw/env)
# - Or BW_SESSION exported from a prior `bw unlock`
#
# Bitwarden item convention:
# Collection: <agent-name> (or "shared" for cross-agent creds)
# Item name: "<agent-name> <Service>" (e.g., "vp-techops Redmine")
# Fields: URL, USERNAME, PASSWORD, API_KEY, and custom fields as needed
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC1091
source "$HERE/lib/common.sh"
###############################################################################
print_usage() {
sed -n '2,26p' "$0"
exit 0
}
if [ $# -lt 3 ] || [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
print_usage
fi
AGENT="$1"
SERVICE="$2"
IMAGE="$3"
shift 3
ITEM_NAME="${AGENT} ${SERVICE}"
###############################################################################
# Ensure Bitwarden session is active
###############################################################################
ensure_bw_session() {
# If BW_SESSION is already set, trust it
if [ -n "${BW_SESSION:-}" ]; then
return 0
fi
# Try machine-to-machine auth via client credentials
if [ -n "${BW_CLIENTID:-}" ] && [ -n "${BW_CLIENTSECRET:-}" ]; then
log_info "Authenticating to Bitwarden via API key..."
if bw login --apikey >/dev/null 2>&1; then
export BW_SESSION
BW_SESSION=$(bw unlock --raw 2>/dev/null || true)
if [ -z "$BW_SESSION" ]; then
die "Bitwarden unlock failed after API-key login."
fi
return 0
fi
fi
# Try reading from the bw env file
local bw_env="${HOME}/.config/bw/env"
if [ -f "$bw_env" ]; then
# shellcheck disable=SC1090
. "$bw_env"
if [ -n "${BW_CLIENTID:-}" ] && [ -n "${BW_CLIENTSECRET:-}" ]; then
log_info "Authenticating to Bitwarden via env file..."
bw login --apikey >/dev/null 2>&1 || true
export BW_SESSION
BW_SESSION=$(bw unlock --raw 2>/dev/null || true)
if [ -n "$BW_SESSION" ]; then
return 0
fi
fi
fi
die "No Bitwarden session. Set BW_SESSION or BW_CLIENTID/BW_CLIENTSECRET."
}
###############################################################################
ensure_bw_session
log_info "Fetching credential: ${ITEM_NAME}"
# Fetch the Bitwarden item and extract fields into env vars
RAW_JSON=$(bw get item "$ITEM_NAME" 2>/dev/null) || die "Bitwarden item not found: ${ITEM_NAME}"
# Build env file content from the JSON response
ENV_CONTENT=$(echo "$RAW_JSON" | jq -r '
def safename(f): f | gsub("[^A-Za-z0-9_]"; "_") | ascii_upcase;
.login as $login |
# Standard fields
(
if $login.uris[0].uri then "URL=\($login.uris[0].uri)\n" else "" end
) +
(
if $login.username then "USERNAME=\($login.username)\n" else "" end
) +
(
if $login.password then "PASSWORD=\($login.password)\n" else "" end
) +
# Map known field names to common env var patterns
(
.fields[]? |
if .name == "API_KEY" or .name == "api_key" or .name == "apikey" then
"API_KEY=\(.value)\n"
elif .name == "token" or .name == "TOKEN" then
"TOKEN=\(.value)\n"
else
"\(safename(.name))=\(.value)\n"
end
) +
# Also emit REDMINE_API_KEY / DISCOURSE_API_KEY etc. from the password field
# (many CLI tools expect the key in the password field)
""
') || die "Failed to parse Bitwarden item JSON."
if [ -z "$ENV_CONTENT" ]; then
die "No credential fields found in Bitwarden item: ${ITEM_NAME}"
fi
# Also check if the password field IS the API key (common for CLI creds)
# and add a service-specific key env var based on the service name
SERVICE_UPPER=$(echo "$SERVICE" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
API_KEY_VAR="${SERVICE_UPPER}_API_KEY"
# If the service-specific key var isn't already in ENV_CONTENT, try adding it from password
if ! echo "$ENV_CONTENT" | grep -q "^${API_KEY_VAR}="; then
PASSWORD_VAL=$(echo "$RAW_JSON" | jq -r '.login.password // empty')
if [ -n "$PASSWORD_VAL" ]; then
ENV_CONTENT="${API_KEY_VAR}=${PASSWORD_VAL}
${ENV_CONTENT}"
fi
fi
# Write to temp file, cleaned up on exit
ENVFILE=$(mktemp "/tmp/bw-${AGENT}-${SERVICE}-XXXXXX.env")
trap 'rm -f "$ENVFILE"' EXIT
chmod 600 "$ENVFILE"
printf '%s' "$ENV_CONTENT" > "$ENVFILE"
log_ok "Credential sourced. Running: ${IMAGE} $*"
# Run the CLI with BW-sourced credentials
docker run --rm --env-file "$ENVFILE" "$IMAGE" "$@"