fix(cli): let the CLI take its gRPC token from the environment #420

Merged
KofTwentyTwo merged 1 commits from fix/headless-cli-token into dev 2026-08-15 00:53:59 +00:00
KofTwentyTwo commented 2026-08-15 00:47:47 +00:00 (Migrated from github.com)

Closes #419.

The bug

nunciod boots fine — mints its keys, serves gRPC on 127.0.0.1:9420, port
confirmed listening — but nuncio-cli system status hangs indefinitely and
prints nothing
.

The mechanism

It is a macOS keychain ACL problem, not a broken keyring. A direct
security find-generic-password returns immediately and the nuncio entries
exist. nunciod created the grpc-bearer-token item, so that item's ACL
trusts nunciod. nuncio-cli is a different binary, so reading the same
item raises a "nuncio-cli wants to access key … in your keychain" consent
prompt. Interactively a human clicks Allow. In CI, over ssh, on a headless box,
or in a background shell there is nobody to click it — the blocking read never
returns and the CLI stalls with no output and no error. That made the reference
client unusable headlessly, and blocked the CLI-driven offline E2E story from
running anywhere without a logged-in desktop session.

The fix

1. Opt-in override. NUNCIO_GRPC_TOKEN, when set and non-empty, is used
verbatim as the bearer credential and the keyring is never touched. When unset,
behaviour is exactly as before. The keyring remains the default, and only
the client honours the override — nunciod always mints and owns the
authoritative token, so the daemon must never accept one from its environment.
The value is trimmed, and an empty value is treated as "not set" so
NUNCIO_GRPC_TOKEN= cannot silently authenticate with an empty token.

2. It can no longer hang silently. Surfacing the underlying error was not
enough here: the failure mode is not an error, it is a call that never returns.
So the keyring read is bounded — it runs on a detached OS thread behind a 20s
tokio::time::timeout that reports what is blocking and how to bypass it. It is
a plain std::thread::spawn rather than spawn_blocking on purpose: Tokio
joins its blocking pool during runtime shutdown, so a stuck keychain call would
simply move the hang to process exit. A detached thread is reaped when the
process ends. 20s is generous enough for a human to answer a real prompt and
short enough that an unattended run always terminates. Nothing is papered over
with unwrap_or_default().

3. One resolver. The eight identical copies of the token-resolution block
collapse onto a single resolve_grpc_token, so no call site can drift.

Security

The token is never logged, echoed, or included in an error message — the timeout
and vault errors name the account and the remedy, never the secret. Nothing is
written to disk or SQLite. The existing no-secrets canary still passes, and a
new test drives the rejected-override path with a sentinel token and asserts it
appears neither in the command output nor anywhere in captured telemetry.

Tests

New crates/nunciod/tests/headless_cli_token_test.rs boots the real daemon gRPC
server on an ephemeral loopback port (mock vault, no real keychain, no live
network) and proves, through the real HeadlessRunner:

  • an override authenticates while the runner's vault holds a different token —
    only possible if the override short-circuits the keyring;
  • with no override the keyring path is unchanged and still authenticates;
  • a wrong override is rejected honestly and the token leaks into neither output
    nor logs.

Unit tests cover override normalization (trimming, empty-is-unset), env reading,
override-beats-vault, vault-when-unset, and that ephemeral_with ignores an
ambient env override so a variable exported in a developer's shell cannot change
what an injected-vault test authenticates with.

nunciod itself

Checked, not assumed: the daemon creates the item rather than reading one
another binary owns, so its startup raises no consent prompt, and a keyring
failure there already surfaces as an honest startup error rather than a hang.
Its read is not bounded by a timeout — noted rather than fixed, to keep this
change scoped.

Gate

cargo fmt --all -- --check, cargo check-all, cargo test-all all green;
RUST_TEST_THREADS=2 cargo test-all run three times with zero FAILED lines.

Closes #419. ## The bug `nunciod` boots fine — mints its keys, serves gRPC on `127.0.0.1:9420`, port confirmed listening — but `nuncio-cli system status` **hangs indefinitely and prints nothing**. ## The mechanism It is a macOS keychain **ACL** problem, not a broken keyring. A direct `security find-generic-password` returns immediately and the `nuncio` entries exist. `nunciod` **created** the `grpc-bearer-token` item, so that item's ACL trusts `nunciod`. `nuncio-cli` is a **different binary**, so reading the same item raises a *"nuncio-cli wants to access key … in your keychain"* consent prompt. Interactively a human clicks Allow. In CI, over ssh, on a headless box, or in a background shell there is nobody to click it — the blocking read never returns and the CLI stalls with no output and no error. That made the reference client unusable headlessly, and blocked the CLI-driven offline E2E story from running anywhere without a logged-in desktop session. ## The fix **1. Opt-in override.** `NUNCIO_GRPC_TOKEN`, when set and non-empty, is used verbatim as the bearer credential and the keyring is never touched. When unset, behaviour is exactly as before. **The keyring remains the default**, and only the client honours the override — `nunciod` always mints and owns the authoritative token, so the daemon must never accept one from its environment. The value is trimmed, and an empty value is treated as "not set" so `NUNCIO_GRPC_TOKEN=` cannot silently authenticate with an empty token. **2. It can no longer hang silently.** Surfacing the underlying error was *not* enough here: the failure mode is not an error, it is a call that never returns. So the keyring read is bounded — it runs on a detached OS thread behind a 20s `tokio::time::timeout` that reports what is blocking and how to bypass it. It is a plain `std::thread::spawn` rather than `spawn_blocking` on purpose: Tokio joins its blocking pool during runtime shutdown, so a stuck keychain call would simply move the hang to process exit. A detached thread is reaped when the process ends. 20s is generous enough for a human to answer a real prompt and short enough that an unattended run always terminates. Nothing is papered over with `unwrap_or_default()`. **3. One resolver.** The eight identical copies of the token-resolution block collapse onto a single `resolve_grpc_token`, so no call site can drift. ## Security The token is never logged, echoed, or included in an error message — the timeout and vault errors name the account and the remedy, never the secret. Nothing is written to disk or SQLite. The existing no-secrets canary still passes, and a new test drives the rejected-override path with a sentinel token and asserts it appears neither in the command output nor anywhere in captured telemetry. ## Tests New `crates/nunciod/tests/headless_cli_token_test.rs` boots the real daemon gRPC server on an ephemeral loopback port (mock vault, no real keychain, no live network) and proves, through the real `HeadlessRunner`: - an override authenticates while the runner's vault holds a *different* token — only possible if the override short-circuits the keyring; - with no override the keyring path is unchanged and still authenticates; - a wrong override is rejected honestly and the token leaks into neither output nor logs. Unit tests cover override normalization (trimming, empty-is-unset), env reading, override-beats-vault, vault-when-unset, and that `ephemeral_with` ignores an ambient env override so a variable exported in a developer's shell cannot change what an injected-vault test authenticates with. ## `nunciod` itself Checked, not assumed: the daemon *creates* the item rather than reading one another binary owns, so its startup raises no consent prompt, and a keyring failure there already surfaces as an honest startup error rather than a hang. Its read is not bounded by a timeout — noted rather than fixed, to keep this change scoped. ## Gate `cargo fmt --all -- --check`, `cargo check-all`, `cargo test-all` all green; `RUST_TEST_THREADS=2 cargo test-all` run three times with zero `FAILED` lines.
Sign in to join this conversation.