Setup-keyring.sh
Jump to navigation
Jump to search
setup-keyring.sh
#!/usr/bin/env bash
#
# setup-keyring.sh -- provision a Secret Service credential store for GitHub
# Copilot CLI on a headless Ubuntu box (no desktop session).
#
# Copilot CLI stores its OAuth token in whatever owns the D-Bus name
# org.freedesktop.secrets. Its bundled native module (prebuilds/linux-x64/
# runtime.node) speaks that protocol directly over D-Bus via zbus -- it does
# NOT link libsecret -- so any conforming provider satisfies it. On a GUI
# desktop that provider is gnome-keyring, started and unlocked by the
# graphical login. Over plain SSH nothing starts it, the name goes unowned,
# and the CLI reports "System vault not available" and offers to fall back to
# storing the token in plaintext in the config file.
#
# Target verified against: Ubuntu 24.04.5 LTS, SSH/tty session, systemd user
# bus live at $XDG_RUNTIME_DIR/bus.
#
# Usage:
# ./setup-keyring.sh system # root: apt packages + linger (run once per host)
# ./setup-keyring.sh user # you: create + unlock keyring (once per reboot)
# ./setup-keyring.sh hook # you: install the shell rc helper (once)
# ./setup-keyring.sh verify # check that the whole chain works
# ./setup-keyring.sh all # system + hook + user + verify
#
set -euo pipefail
TARGET_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ENV_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.env"
HOOK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.sh"
# Pull in the GStreamer/GTK media stack that gcr4 recommends? Almost certainly
# not on a headless box: it is 19 extra packages that only matter for rendering
# a graphical unlock prompt we will never show.
NO_RECOMMENDS=1
# libpam-gnome-keyring auto-unlocks the keyring using your login password, but
# only when PAM actually sees a password. SSH public-key auth never supplies
# one, so this is off by default. Turn it on only if you SSH in with a password.
WANT_PAM=0
say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
# ---------------------------------------------------------------------------
# 1. System packages + lingering (requires root)
# ---------------------------------------------------------------------------
#
# gnome-keyring the daemon itself; owns org.freedesktop.secrets. The only
# package here Copilot CLI genuinely requires.
# libsecret-tools provides secret-tool, used by do_verify to prove the store
# works. Drop it if you don't want the self-test.
# libsecret-1-0 a hard dependency of libsecret-tools; named explicitly only
# so the intent is visible. Copilot CLI never loads it.
#
# Everything else in the install is pulled in as a hard dependency (gcr, gcr4,
# p11-kit, pinentry-gnome3, and GTK4 via gcr4 -- unavoidable, gcr4 links it).
#
do_system() {
[[ $EUID -eq 0 ]] || die "run this section as root: sudo $0 system"
local pkgs=(gnome-keyring libsecret-1-0 libsecret-tools)
local flags=(-y)
(( NO_RECOMMENDS )) && flags+=(--no-install-recommends)
(( WANT_PAM )) && pkgs+=(libpam-gnome-keyring)
say "Installing: ${pkgs[*]}"
DEBIAN_FRONTEND=noninteractive apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install "${flags[@]}" "${pkgs[@]}"
# Without lingering, systemd tears down the user slice when the last SSH
# session closes -- taking the keyring daemon and the user bus with it. The
# VS Code server outlives individual SSH sessions, so it would be left
# pointing at a bus that no longer has a secrets provider.
say "Enabling lingering for $TARGET_USER"
loginctl enable-linger "$TARGET_USER"
if (( WANT_PAM )); then
warn "PAM module installed but NOT wired up. To enable, add to /etc/pam.d/sshd:"
warn " auth optional pam_gnome_keyring.so"
warn " session optional pam_gnome_keyring.so auto_start"
warn "Only effective for password-based SSH logins."
fi
say "System setup done. Now run (as $TARGET_USER, NOT root): $0 user"
}
# ---------------------------------------------------------------------------
# helper: is something currently answering on org.freedesktop.secrets?
# ---------------------------------------------------------------------------
secrets_up() {
[[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]] || return 1
local out
out=$(gdbus call --session \
--dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner \
org.freedesktop.secrets 2>/dev/null) || return 1
[[ $out == *true* ]]
}
# ---------------------------------------------------------------------------
# 2. Create and unlock the keyring (as your own user, once per reboot)
# ---------------------------------------------------------------------------
do_user() {
[[ $EUID -ne 0 ]] || die "run this section as yourself, not root"
command -v gnome-keyring-daemon >/dev/null || die "gnome-keyring not installed; run: sudo $0 system"
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
die "No D-Bus session bus. Expected $XDG_RUNTIME_DIR/bus -- log in via SSH normally, or wrap with: dbus-run-session -- $0 user"
fi
if secrets_up; then
say "Secret Service already running and reachable. Nothing to do."
return 0
fi
local first_run=0
[[ -e "$HOME/.local/share/keyrings/login.keyring" ]] || first_run=1
if (( first_run )); then
say "No login keyring exists yet -- it will be CREATED with the password you type now."
say "Store that password somewhere you can retrieve it; it encrypts the keyring at rest."
else
say "Unlocking existing login keyring."
fi
local pw
read -r -s -p "Keyring password: " pw; echo
if (( first_run )); then
local pw2
read -r -s -p "Confirm password: " pw2; echo
[[ "$pw" == "$pw2" ]] || die "Passwords did not match."
fi
[[ -n "$pw" ]] || warn "Empty password: the keyring will auto-unlock, but is then no better protected than a plaintext file."
mkdir -p "$(dirname "$ENV_FILE")"
# --components=secrets deliberately excludes the ssh agent, so this will not
# hijack SSH_AUTH_SOCK and break your existing agent forwarding.
# The password is read from stdin; EOF terminates it.
printf '%s' "$pw" | gnome-keyring-daemon --daemonize --unlock --components=secrets \
> "$ENV_FILE" 2>/dev/null || die "gnome-keyring-daemon failed to start"
unset pw pw2
chmod 600 "$ENV_FILE"
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
secrets_up || die "Daemon started but org.freedesktop.secrets is still unowned. Check: busctl --user list | grep secret"
say "Secret Service is up. Env cached at $ENV_FILE"
say "Because lingering is on, this survives SSH disconnects -- redo only after a reboot."
warn "Reload the VS Code window now so the Copilot CLI re-checks for a credential store."
}
# ---------------------------------------------------------------------------
# 3. Shell hook -- re-export the daemon env in new shells, warn if locked
# ---------------------------------------------------------------------------
do_hook() {
mkdir -p "$(dirname "$HOOK_FILE")"
cat > "$HOOK_FILE" <<'HOOK'
# Copilot CLI credential store (gnome-keyring over D-Bus Secret Service).
# Sourced from ~/.bashrc; see ~/.copilot/setup-keyring.sh
_copilot_keyring_env="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.env"
[ -r "$_copilot_keyring_env" ] && . "$_copilot_keyring_env" 2>/dev/null
export GNOME_KEYRING_CONTROL
copilot-keyring-unlock() { "$HOME/.copilot/setup-keyring.sh" user; }
copilot-keyring-status() {
if gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner \
org.freedesktop.secrets 2>/dev/null | grep -q true
then echo "keyring: unlocked"; else echo "keyring: LOCKED -- run copilot-keyring-unlock"; fi
}
# Interactive login shells only: nudge, don't nag or block.
case $- in *i*)
if [ -z "${COPILOT_KEYRING_QUIET:-}" ] && ! gdbus call --session \
--dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner org.freedesktop.secrets \
2>/dev/null | grep -q true
then
printf '\033[1;33m[!]\033[0m Copilot keyring locked. Run: copilot-keyring-unlock\n' >&2
fi
;;
esac
unset _copilot_keyring_env
HOOK
local rc="$HOME/.bashrc" line=". \"\${XDG_CONFIG_HOME:-\$HOME/.config}/copilot-keyring.sh\""
if ! grep -qF 'copilot-keyring.sh' "$rc" 2>/dev/null; then
printf '\n# GitHub Copilot CLI credential store\n%s\n' "$line" >> "$rc"
say "Appended hook to $rc"
else
say "Hook already present in $rc"
fi
say "Wrote $HOOK_FILE"
}
# ---------------------------------------------------------------------------
# 4. Verify the full chain
# ---------------------------------------------------------------------------
do_verify() {
local fail=0
for b in gnome-keyring-daemon secret-tool; do
if command -v "$b" >/dev/null; then say "found $b"; else warn "MISSING $b"; fail=1; fi
done
[[ "$(loginctl show-user "$TARGET_USER" -p Linger --value 2>/dev/null)" == "yes" ]] \
&& say "lingering enabled" || { warn "lingering NOT enabled"; fail=1; }
if secrets_up; then say "org.freedesktop.secrets has an owner"; else
warn "no Secret Service on the bus -- run: $0 user"; fail=1
fi
# Round-trip a throwaway secret through libsecret, which is exactly the path
# Copilot CLI uses. This also proves the 'default' collection alias resolves.
if command -v secret-tool >/dev/null && secrets_up; then
if printf 'roundtrip-ok' | secret-tool store --label='copilot-keyring-selftest' \
service copilot-selftest account probe 2>/dev/null \
&& [[ "$(secret-tool lookup service copilot-selftest account probe 2>/dev/null)" == "roundtrip-ok" ]]
then
say "store/lookup round-trip OK"
secret-tool clear service copilot-selftest account probe 2>/dev/null || true
else
warn "round-trip FAILED -- keyring may be locked or the default collection alias is unset"
fail=1
fi
fi
(( fail )) && { warn "verification incomplete"; return 1; }
say "All good. Run 'copilot' and re-authenticate; the token will land in the keyring."
}
case "${1:-all}" in
system) do_system ;;
user) do_user ;;
hook) do_hook ;;
verify) do_verify ;;
all)
if [[ $EUID -eq 0 ]]; then do_system
else
command -v gnome-keyring-daemon >/dev/null || die "run 'sudo $0 system' first"
do_hook; do_user; do_verify
fi
;;
*) die "usage: $0 {system|user|hook|verify|all}" ;;
esac