Antigravity Now Saves OAuth Tokens to the OS Keyring — Keeping Auth Alive in Headless Runs
In v2.2.1, refreshed OAuth tokens are saved to the OS keyring automatically. Pleasant on the desktop, but on headless scheduled runs the vault itself may not exist and auth quietly breaks. We design explicit backend selection, a safe file fallback, and per-location liveness checks.
On my desktop, one login keeps Antigravity quiet for days. Yet the server-side scheduled job wired to the same account kept losing its authentication every few hours and running on empty. That asymmetry puzzled me for a while.
The cause was neither permissions nor the network. It was where the token gets stored. In v2.2.1, refreshed OAuth tokens are saved to the OS keyring automatically. On the desktop this cuts re-authentication dramatically. But a headless server with no terminal and no display often has no such keyring standing up at all. The save fails silently, the next run can't find the token, and the whole dance starts over. That loop was the real meaning of "breaks every few hours."
As an indie developer running several Dolice Labs sites unattended, this kind of "won't reproduce on the desktop" bug is the one I dread most. It never shows up where I can watch it, so I had no lead until I re-read the logs from the angle of storage location. This article hands you that angle from the start, walking from how the vault works to how to design for headless runs.
What the OS keyring actually is
First, let's pin down what "keyring" concretely means per OS. Leave this fuzzy and every fix becomes guesswork.
OS
The actual vault
Access precondition
macOS
Keychain (the login keychain)
An unlocked login session
Windows
Credential Locker
An interactive user logon session
Linux (desktop)
Secret Service (gnome-keyring / KWallet)
A D-Bus session and an unlocked keyring daemon
Linux (headless)
Usually nonexistent
No D-Bus session, no Secret Service responder
The key point: macOS and Windows tie the vault tightly to "the interactive user's session." A shell you merely SSH'd into, or a resident service that starts without passing a login screen, may not reach the unlocked keychain even under the same username.
Linux desktops share a spec called Secret Service, with gnome-keyring or KWallet acting as its responder. Which means the reverse is also true: in an environment with no responder, the save request has nowhere to go. Headless servers and containers are exactly that case.
The moment the keyring disappears on headless
Let's decompose the symptom. When auth breaks on an unattended scheduled run, this is the order of events inside:
The agent refreshes the token (this part succeeds).
It tries to save the refreshed token to the OS keyring.
No keyring (Secret Service responder) exists, so the save fails.
That failure is not treated as fatal, and the run proceeds anyway.
On the next start it reads the keyring — which of course holds nothing.
No token is found, so it re-acquires, or drops into a re-login prompt in an environment with no terminal to answer it.
The nasty part is step 4, "proceeds anyway." A failed save is often just a warning and never reaches the exit code. This is the same quiet failure I covered in when Antigravity CLI stalls on a 401 during unattended runs: "recorded as success, but empty inside." If that one was about the token's lifespan, this one is about the token's address.
Start by confirming whether your runtime has a vault responder at all.
#!/usr/bin/env bash# Goal: decide whether Secret Service (the keyring responder) actually answers.# Settle "present / absent" first, or everything downstream becomes guesswork.set -uo pipefailhas_secret_service() { # With no D-Bus session, there is no responder, full stop. [ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ] || return 1 command -v dbus-send >/dev/null 2>&1 || return 1 # Ask whether org.freedesktop.secrets exists on the bus. dbus-send --session --dest=org.freedesktop.DBus \ --type=method_call --print-reply \ /org/freedesktop/DBus org.freedesktop.DBus.ListNames 2>/dev/null \ | grep -q "org.freedesktop.secrets"}if has_secret_service; then echo "[keyring] Secret Service present: OS keyring is usable"else echo "[keyring] Secret Service absent: switching to a headless storage strategy"fi
Run this check before the real work. Rather than noticing after a save fails, establish whether the vault exists, then choose the path. That way the later branch rests on fact, not on a hunch.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to pinpoint the baffling case where auth works on the desktop but only breaks on a server's scheduled run, by looking at where the token is stored
✦You'll take home a concrete implementation that pins the keyring backend explicitly and falls back safely to a permission-hardened file when no vault exists
✦You'll gain a decision rule, drawn from real operations, for whether to sync tokens across machines when running agents on several boxes
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Automation that leans on "maybe there's a vault, maybe not" collapses each time the environment shifts slightly. For headless runs, don't defer to an implicit default — nail down the storage backend you use.
Most auth tooling lets you pick the storage backend via an environment variable or a config file. The option names may vary by version, so confirm the real key with antigravity --help or the docs, then declare it per environment like this.
#!/usr/bin/env bash# Goal: fix the storage backend to exactly one per environment.# Stop relying on "it happens to work"; always hold which path a save takes.set -euo pipefailCRED_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/antigravity"mkdir -p "$CRED_DIR"chmod 700 "$CRED_DIR"if has_secret_service; then # Desktop-equivalent: let the OS keyring handle it. export ANTIGRAVITY_CRED_BACKEND="keyring"else # Headless: pin to a permission-hardened file. export ANTIGRAVITY_CRED_BACKEND="file" export ANTIGRAVITY_CRED_FILE="$CRED_DIR/credentials.json"fiecho "[keyring] backend=${ANTIGRAVITY_CRED_BACKEND}"
Here has_secret_service is the function from the previous section. Variable names like ANTIGRAVITY_CRED_BACKEND are illustrative — swap in whatever env var your tool actually reads. The design intent is unchanged: decide the save path uniquely at runtime and log it. A token stored somewhere you can't name will always stretch out a later investigation.
Design the file fallback to be safe
When the keyring is unusable on headless and you naively write the token to a plaintext file, permissions become the next problem. Even under the home directory, a loose umask can leave it readable by the same group. Here we design not just for "works," but for "unreadable by others."
#!/usr/bin/env bash# Goal: on file fallback, drop permissions to 0600 as part of the write.# "It wrote" and "others can't read it" are separate guarantees. Ensure both at once.set -euo pipefailwrite_credentials() { local dest="$1" payload="$2" local tmp tmp="$(mktemp "${dest}.XXXXXX")" # temp file in the same directory chmod 600 "$tmp" # tighten before writing contents printf '%s' "$payload" > "$tmp" mv -f "$tmp" "$dest" # atomic swap (no reader sees a half state) chmod 600 "$dest"}# Verify: right after saving, always confirm permissions are what you expect.verify_permissions() { local f="$1" local mode mode="$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f") if [ "$mode" != "600" ]; then echo "[keyring] permissions are not 600 (currently ${mode}). Aborting." >&2 return 1 fi echo "[keyring] confirmed permissions 600"}
Three notes on intent. First, tightening permissions happens before the contents are written. chmod after writing opens a brief window where others can read it. Second, mktemp plus mv -f swaps atomically, so a reader that arrives mid-refresh never grabs a half-written, broken JSON. Third, always confirm 0600 with stat afterward. I once skipped that check and later found — with a cold sweat — that a CI container with a loose umask had left the file group-readable.
If plaintext bothers you, add a layer that encrypts with a machine-specific key before saving. But that just nests the same problem — where do you keep that key — so I prefer to guard first with 0600 and a least-privilege directory (0700), then add encryption only if truly needed. For rotating the key itself, the overlap-window approach from rotating keys without stopping an unattended agent applies directly.
Deliberately standing up a keyring on headless Linux
The file fallback is the baseline, but when you genuinely must use Secret Service — say, an existing toolchain was built assuming a keyring — you can stand up a responder even on headless. Whether you use it is a separate matter; knowing the option widens your judgment.
#!/usr/bin/env bash# Goal: on headless Linux, start an ephemeral D-Bus session and an unlocked# gnome-keyring, then run the job inside that shell.# Caveat: passing the unlock passphrase becomes a new secret to manage.set -euo pipefailrun_with_keyring() { local passphrase="$1"; shift dbus-run-session -- bash -c ' printf "%s" "'"$passphrase"'" \ | gnome-keyring-daemon --unlock --components=secrets >/dev/null 2>&1 exec "$@" ' _ "$@"}# Usage: pass the passphrase safely (from an env var, etc.); never hard-code it.# run_with_keyring "$KEYRING_PASSPHRASE" antigravity run my-task
To be honest, this trades convenience for a new secret: the unlock passphrase. A mechanism meant to protect the token now spawns another secret. Whether that nesting is worth it depends on how much keyring-dependent tooling you already have. For a fresh automation built from scratch, I take the simplicity of the file fallback. On a site heavily invested in keyring already, this launcher bridges the gap with a shallower migration wound.
Verify token liveness per storage location
Once you've chosen a storage strategy, the last step is to confirm — before the real work — that a live token actually sits there. Different backends verify differently, so split the check per path.
#!/usr/bin/env bash# Goal: verify token presence and freshness before the real work, per backend.# Re-validate "I think it's saved" as fact on every single run.set -uo pipefailpreflight_credentials() { case "${ANTIGRAVITY_CRED_BACKEND:-}" in keyring) # Keyring path: defer to the tool's auth-status command. antigravity auth status >/dev/null 2>&1 ;; file) local f="${ANTIGRAVITY_CRED_FILE:?}" [ -s "$f" ] || return 1 # non-empty verify_permissions "$f" || return 1 # permissions are 0600 # If the expiry is readable, also check the remaining time isn't too short. ;; *) echo "[keyring] backend unset. Fix the path first." >&2 return 2 ;; esac}if ! preflight_credentials; then echo "[keyring] auth preflight failed. Exiting before the real work." >&2 exit 78 # fail explicitly as EX_CONFIGfiecho "[keyring] auth preflight OK"
This "insert one light check right before the real work" shape is a foundation for unattended operation well beyond auth. For a more general take on preflight, see checking just once that Antigravity CLI truly responds right before a scheduled run. Folding the auth check in as one line item of that preflight keeps failure isolation clean.
In my own setup I also leave slack: if the token has under 10 minutes of life left, I refresh ahead of the real work. Entering a long job with a token right at the boundary means it expires mid-run and drops to a 401. Five minutes is often enough, but my job processes several sites in sequence and a single one can take close to 10 minutes, so I set the margin to match. Derive that number by working back from your job's longest run time.
Should you sync tokens across machines?
Finally, the design call I wrestle with most. When you run three boxes on the same account — a desktop at hand, a resident server, and CI — it's tempting to gather tokens in one place and sync them. But I've settled on not syncing.
Two reasons. One, the sync mechanism itself becomes a new attack surface and a new point of failure; if the pipeline that hands tokens around breaks, all boxes go silent at once. Two, refresh-token handling: using the same refresh token from several places at once can invalidate one when another refreshes, and they end up kicking each other in a race.
Instead, each machine holds its own independent authentication, and each completes its token in its own vault (keyring on the desktop, file on the server and CI). More boxes means more initial-login effort, but for unattended operation I'd rather have the independence where one box's failure doesn't spread. This call can shift with your environment, so read it as merely where I stand today.
Your next action
First, on the environment currently running unattended, run this article's first snippet (has_secret_service) just once. It should print "present" on the desktop and "absent" on the server. That single-line difference may be the whole answer to "why only the server loses auth." From there you can step toward a design that pins the save path explicitly.
The storage-location lens, once you hold it, clearly changes how you read auth logs. I'm still tuning mine as I operate, but if it trims even a little of the detour for someone wrestling with the same quiet failure, I'll be glad. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.