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-sessionmust run from a real login session (SSH, tmux, screen). Running undersudo -u someoneelseor 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-passis the convention. - Entries written under
dbus-run-sessionare 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(): canaryStore/Retrieve/Deleteall 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, andbitbucket.NewReleaseProviderall 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-keyringlibrary's behaviour against a platform keychain. That's already covered by the unit tests inpkg/credentials/keychain/(viakeyring.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¶
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.
Related¶
- Manual credential testing โ the scenarios this guide unblocks.
docs/components/credentials.mdโ architecture reference forBackend,RegisterBackend, and the stub/memory/go-keyring implementations.pkg/credentials/credtestโ source for the in-memory backend and its test helper.