gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally — its config file keeps only opaque keychain:gcx:v2:...
handles — and exposes no plaintext fallback (no env var or config key selects a
file-backed store). With nothing owning org.freedesktop.secrets on the
headless EDaaS box, gcx login authenticates against Grafana successfully and
then dies writing its config:
error: Writing config — inspect keychain entry for "stack:citrix" field
"oauth-token": The name is not activatable
What
Adds services.headlessSecretService (home/secret-service.nix), enabled on work.nix: gnome-keyring as a systemd --user service, unlocking the login
keyring at start.
home-manager's own services.gnome-keyring does not fit a headless host:
it is WantedBy=graphical-session-pre.target, which never activates without a
desktop session, so the service would never start; and
it passes no --unlock, and writing to a locked collection blocks on a GUI
prompter (gcr) that does not exist here — the caller hangs rather than fails.
Only the secrets component is started. The ssh component is deliberately
off: it would claim SSH_AUTH_SOCK and displace services.ssh-agent, breaking
SSH auth and signed commits.
Unlock password, and the agenix seam
The password defaults to a random 32-byte value generated on first activation at $XDG_DATA_HOME/gnome-keyring/login-password (mode 0600, outside the
world-readable nix store). The passwordFile option supplies it from elsewhere
instead — once #55 lands, that is one line:
Note agenix cannot replace the keyring: gcx needs a D-Bus name, not a file, and
these tokens rotate (gcx writes refreshed tokens back), so the store must be
writable at runtime. agenix is the right home for the one static value, the
unlock password.
Security trade-off
Stated plainly in the module header: the keyring is encrypted at rest, but the
password unlocking it is readable by the same user on the same machine. That
protects the tokens from something reading the keyring file directly; it
protects them from nothing already running as this user. Same posture as the
existing ~/.jenkinsenv / ~/.splunkenv files, and the price of unattended
operation — systemd --user timers start with no human present to type a
passphrase.
Testing
nix flake check — all checks pass.
nix eval of the emmathorpe-edaas toplevel — evaluates clean.
Ran the nix-builtExecStart script directly (not a hand-rolled
equivalent): claims org.freedesktop.secrets, ssh-agent and both loaded
keys unaffected.
Killed the daemon and restarted it from that script; gcx config check
reports the citrix context valid and online (Grafana 13.2.0) and gcx datasources list returns live results — so the tokens survive a restart,
i.e. a reboot.
secret-tool store/lookup/clear round-trip, headless, no prompt.
Not applied to the running system yet — nixos-rebuild switch is left to the
reviewer.
## Why
gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally — its config file keeps only opaque `keychain:gcx:v2:...`
handles — and exposes no plaintext fallback (no env var or config key selects a
file-backed store). With nothing owning `org.freedesktop.secrets` on the
headless EDaaS box, `gcx login` authenticates against Grafana successfully and
then dies writing its config:
```
error: Writing config — inspect keychain entry for "stack:citrix" field
"oauth-token": The name is not activatable
```
## What
Adds `services.headlessSecretService` (`home/secret-service.nix`), enabled on
`work.nix`: gnome-keyring as a `systemd --user` service, unlocking the login
keyring at start.
home-manager's own `services.gnome-keyring` does not fit a headless host:
- it is `WantedBy=graphical-session-pre.target`, which never activates without a
desktop session, so the service would never start; and
- it passes no `--unlock`, and writing to a locked collection blocks on a GUI
prompter (gcr) that does not exist here — the caller hangs rather than fails.
Only the `secrets` component is started. The `ssh` component is deliberately
off: it would claim `SSH_AUTH_SOCK` and displace `services.ssh-agent`, breaking
SSH auth and signed commits.
## Unlock password, and the agenix seam
The password defaults to a random 32-byte value generated on first activation at
`$XDG_DATA_HOME/gnome-keyring/login-password` (mode 0600, outside the
world-readable nix store). The `passwordFile` option supplies it from elsewhere
instead — once #55 lands, that is one line:
```nix
services.headlessSecretService.passwordFile =
config.age.secrets.gnome-keyring-login.path;
```
Note agenix cannot replace the keyring: gcx needs a D-Bus name, not a file, and
these tokens rotate (gcx writes refreshed tokens back), so the store must be
writable at runtime. agenix is the right home for the one static value, the
unlock password.
## Security trade-off
Stated plainly in the module header: the keyring is encrypted at rest, but the
password unlocking it is readable by the same user on the same machine. That
protects the tokens from something reading the keyring file directly; it
protects them from nothing already running as this user. Same posture as the
existing `~/.jenkinsenv` / `~/.splunkenv` files, and the price of unattended
operation — `systemd --user` timers start with no human present to type a
passphrase.
## Testing
- `nix flake check` — all checks pass.
- `nix eval` of the `emmathorpe-edaas` toplevel — evaluates clean.
- Ran the **nix-built** `ExecStart` script directly (not a hand-rolled
equivalent): claims `org.freedesktop.secrets`, `ssh-agent` and both loaded
keys unaffected.
- Killed the daemon and restarted it from that script; `gcx config check`
reports the `citrix` context valid and online (Grafana 13.2.0) and
`gcx datasources list` returns live results — so the tokens survive a restart,
i.e. a reboot.
- `secret-tool` store/lookup/clear round-trip, headless, no prompt.
Not applied to the running system yet — `nixos-rebuild switch` is left to the
reviewer.
gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally -- its config file keeps only opaque `keychain:gcx:v2:...`
handles -- and exposes no plaintext fallback. With nothing owning
org.freedesktop.secrets on this headless WSL box, `gcx login` authenticates
against Grafana and then dies writing its config: "The name is not activatable".
Add services.headlessSecretService: gnome-keyring as a systemd --user service,
unlocking the login keyring at start. home-manager's own services.gnome-keyring
does not fit here on two counts -- it is WantedBy graphical-session-pre.target,
which never activates without a desktop session, and it passes no --unlock, so
writes would block on a GUI prompter that does not exist.
Only the secrets component is started. The ssh component is deliberately off: it
would claim SSH_AUTH_SOCK and displace services.ssh-agent, breaking SSH auth and
signed commits.
The unlock password defaults to a random one generated on first activation under
$XDG_DATA_HOME. The passwordFile option is the seam for supplying it from an
agenix secret instead, once that lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lyrathorpe
scheduled this pull request to auto merge when all checks succeed 2026-08-11 15:06:02 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
gcx stores its OAuth access and refresh tokens in the system keychain
unconditionally — its config file keeps only opaque
keychain:gcx:v2:...handles — and exposes no plaintext fallback (no env var or config key selects a
file-backed store). With nothing owning
org.freedesktop.secretson theheadless EDaaS box,
gcx loginauthenticates against Grafana successfully andthen dies writing its config:
What
Adds
services.headlessSecretService(home/secret-service.nix), enabled onwork.nix: gnome-keyring as asystemd --userservice, unlocking the loginkeyring at start.
home-manager's own
services.gnome-keyringdoes not fit a headless host:WantedBy=graphical-session-pre.target, which never activates without adesktop session, so the service would never start; and
--unlock, and writing to a locked collection blocks on a GUIprompter (gcr) that does not exist here — the caller hangs rather than fails.
Only the
secretscomponent is started. Thesshcomponent is deliberatelyoff: it would claim
SSH_AUTH_SOCKand displaceservices.ssh-agent, breakingSSH auth and signed commits.
Unlock password, and the agenix seam
The password defaults to a random 32-byte value generated on first activation at
$XDG_DATA_HOME/gnome-keyring/login-password(mode 0600, outside theworld-readable nix store). The
passwordFileoption supplies it from elsewhereinstead — once #55 lands, that is one line:
Note agenix cannot replace the keyring: gcx needs a D-Bus name, not a file, and
these tokens rotate (gcx writes refreshed tokens back), so the store must be
writable at runtime. agenix is the right home for the one static value, the
unlock password.
Security trade-off
Stated plainly in the module header: the keyring is encrypted at rest, but the
password unlocking it is readable by the same user on the same machine. That
protects the tokens from something reading the keyring file directly; it
protects them from nothing already running as this user. Same posture as the
existing
~/.jenkinsenv/~/.splunkenvfiles, and the price of unattendedoperation —
systemd --usertimers start with no human present to type apassphrase.
Testing
nix flake check— all checks pass.nix evalof theemmathorpe-edaastoplevel — evaluates clean.ExecStartscript directly (not a hand-rolledequivalent): claims
org.freedesktop.secrets,ssh-agentand both loadedkeys unaffected.
gcx config checkreports the
citrixcontext valid and online (Grafana 13.2.0) andgcx datasources listreturns live results — so the tokens survive a restart,i.e. a reboot.
secret-toolstore/lookup/clear round-trip, headless, no prompt.Not applied to the running system yet —
nixos-rebuild switchis left to thereviewer.