nunciod boots fine — mints its keys, serves gRPC on 127.0.0.1:9420, port
confirmed listening — but nuncio-cli system statushangs 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. nunciodcreated 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.
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.
Closes #419.
The bug
nunciodboots fine — mints its keys, serves gRPC on127.0.0.1:9420, portconfirmed listening — but
nuncio-cli system statushangs indefinitely andprints nothing.
The mechanism
It is a macOS keychain ACL problem, not a broken keyring. A direct
security find-generic-passwordreturns immediately and thenuncioentriesexist.
nunciodcreated thegrpc-bearer-tokenitem, so that item's ACLtrusts
nunciod.nuncio-cliis a different binary, so reading the sameitem 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 usedverbatim 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 —
nunciodalways mints and owns theauthoritative 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::timeoutthat reports what is blocking and how to bypass it. It isa plain
std::thread::spawnrather thanspawn_blockingon purpose: Tokiojoins 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.rsboots the real daemon gRPCserver on an ephemeral loopback port (mock vault, no real keychain, no live
network) and proves, through the real
HeadlessRunner:only possible if the override short-circuits the keyring;
nor logs.
Unit tests cover override normalization (trimming, empty-is-unset), env reading,
override-beats-vault, vault-when-unset, and that
ephemeral_withignores anambient env override so a variable exported in a developer's shell cannot change
what an injected-vault test authenticates with.
nuncioditselfChecked, 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-allall green;RUST_TEST_THREADS=2 cargo test-allrun three times with zeroFAILEDlines.