Skip to content

Testing the keychain on a headless host

The keychain backend relies on a reachable OS credential store โ€” macOS Keychain, Windows Credential Manager, or a freedesktop Secret Service provider on Linux. On a headless Linux server (dev box, SSH-only host, CI runner), the session bus is often present but no Secret Service is registered, so credentials.Probe() correctly returns false and the setup wizard hides the keychain option. Scenarios 1, 2, and 5 in Manual credential testing all depend on the probe passing.

This guide gives engineers three ways to unblock themselves, in order of how close each one is to the real thing.

Option Real go-keyring round-trip? Install required Best for
1. GNOME Keyring + dbus-run-session Yes gnome-keyring, libsecret-tools Pre-release verification on a dev server
2. Containerised Secret Service Yes Docker or Podman Cross-distro testing; hermetic isolation
3. In-memory backend swap No (exercises GTB code paths) None Fast iteration; CI runners without D-Bus

Option 1 โ€” GNOME Keyring with dbus-run-session

Spawns a transient session bus, runs an unlocked gnome-keyring-daemon inside it, and runs your test command against the live Secret Service. When the outer shell exits, the bus and keyring are discarded โ€” no persistent state.

Prerequisites

sudo apt-get install -y gnome-keyring libsecret-tools dbus-user-session
# Fedora: sudo dnf install -y gnome-keyring libsecret dbus-daemon
# Arch:   sudo pacman -S gnome-keyring libsecret dbus

One-off test

Use this for a single scripted command โ€” the daemon starts, your command runs, everything is torn down:

dbus-run-session -- bash -c '
  # Bootstrap a login keyring with a throwaway password and start the
  # Secret Service component. --login creates the keyring (if missing)
  # AND unlocks it in one step โ€” avoids the gcr-prompter dialog that
  # --start or a bare --unlock would trigger on a fresh environment.
  # --daemonize backgrounds the daemon and prints env vars to stdout,
  # which eval captures into the current shell so subsequent commands
  # see GNOME_KEYRING_CONTROL / SSH_AUTH_SOCK.
  eval "$(printf "test-pass\n" | \
    gnome-keyring-daemon --daemonize --login --components=secrets)"

  # Sanity check: write/read a canary entry.
  printf "canary-value" | \
    secret-tool store --label="canary" service test account canary
  secret-tool lookup service test account canary
  # โ†’ canary-value

  # Now run the e2e binary against the live keyring.
  go run ./cmd/e2e init ai --dir /tmp/e2etest
'

Why --login and not --unlock?

On modern gnome-keyring (42+), --start and --unlock are mutually exclusive, and --unlock alone requires an existing keyring file โ€” a fresh dbus-run-session has none. Using --unlock in that state triggers the graphical gcr-prompter to ask for a creation password, which fails on a headless host with Gtk-WARNING: cannot open display. --login bypasses the prompter entirely by reading the password directly from stdin.

Interactive session

When you want to work through the wizard prompts manually, keep the subshell alive:

dbus-run-session -- bash -l
# --- inside the subshell ---
eval "$(printf "test-pass\n" | \
  gnome-keyring-daemon --daemonize --login --components=secrets)"

# Verify the Secret Service is live
printf "" | secret-tool store --label="canary" service test account canary && \
  secret-tool lookup service test account canary   # โ†’ (empty line)

# Now run any scenario from manual-credentials.md
go run ./cmd/e2e init ai --dir /tmp/e2etest

# When done:
exit

Caveats

  • dbus-run-session must run from a real login session (SSH, tmux, screen). Running under sudo -u someoneelse or from a daemon context often fails with "Failed to open connection to bus".
  • The daemon runs with the password you gave on stdin. Don't reuse a production password; a throwaway string like test-pass is the convention.
  • Entries written under dbus-run-session are NOT visible from the same user's normal login session โ€” they live in a separate transient keyring. That's the point: nothing leaks between test runs.

Option 2 โ€” Containerised Secret Service

A throwaway container with gnome-keyring installed, running a dedicated Secret Service instance. Useful when:

  • You want to test against a specific distro or library version.
  • Your host's systemd security policy blocks dbus-run-session.
  • You want guaranteed isolation between runs.
docker run --rm -it \
  -v "$PWD":/src \
  -w /src \
  ubuntu:24.04 \
  bash -c '
    apt-get update -qq && apt-get install -y --no-install-recommends \
      golang-go gnome-keyring libsecret-tools dbus ca-certificates git >/dev/null

    dbus-run-session -- bash -c "
      eval \"\$(printf \"test-pass\n\" | \
        gnome-keyring-daemon --daemonize --login --components=secrets)\"
      go run ./cmd/e2e init ai --dir /tmp/e2etest
    "
  '

For interactive use, replace the final bash -c \"โ€ฆ\" with bash and drive the scenarios from the container shell.

Option 3 โ€” In-memory backend swap

The pkg/credentials/credtest.MemoryBackend satisfies credentials.Backend and reports Available() == true, so the wizard thinks a real keychain is present. Everything GTB does downstream โ€” storage-mode selector, Probe() round-trip, config writes, resolver cascade โ€” runs unchanged.

What this covers:

  • Wizard UI: the "OS keychain" option appears, wizard walks through the keychain branch.
  • credentials.Probe(): canary Store/Retrieve/Delete all succeed against the map.
  • Config writes: {provider}.api.keychain: <tool>/<account> lands in config, no literal value on disk.
  • Resolver: pkg/chat.New, pkg/vcs.ResolveToken, and bitbucket.NewReleaseProvider all resolve through the backend.
  • Bitbucket JSON-blob corrupt/incomplete abort.
  • Regulated-build stripping via rm cmd/e2e/keychain.go.

What this does NOT cover:

  • The actual go-keyring library's behaviour against a platform keychain. That's already covered by the unit tests in pkg/credentials/keychain/ (via keyring.MockInit()) and should be verified with Option 1 on a desktop before a release.

Swap in the memory backend

Replace cmd/e2e/keychain.go with:

package main

// Developer-only: activates the in-memory backend so the e2e binary
// can be tested on a host that lacks a real Secret Service provider.
// See docs/development/testing/headless-keychain-testing.md.
//
// Do NOT commit this form โ€” cmd/e2e ships with the real backend so
// CI exercises the full go-keyring path.

import (
    "gitlab.com/phpboyscout/go-tool-base/pkg/credentials"
    "gitlab.com/phpboyscout/go-tool-base/pkg/credentials/credtest"
)

//nolint:gochecknoinits // side-effect registration for headless test runs
func init() {
    credentials.RegisterBackend(&credtest.MemoryBackend{})
}

Rebuild and run:

go build -o bin/e2e ./cmd/e2e

./bin/e2e init ai --dir /tmp/e2etest
# โ†’ "OS keychain" appears
# โ†’ wizard writes anthropic.api.keychain to config
# โ†’ secret lives in the in-process map, discarded on exit

# Run Scenario 2's resolver snippet with the same swap in place to
# verify the whole cascade.

Restore before committing

git checkout -- cmd/e2e/keychain.go
git diff cmd/e2e/keychain.go   # should be empty

Never commit the swapped version โ€” cmd/e2e must ship with the real backend so the Gherkin suite in CI exercises the full go-keyring path.