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
4 changed files with 501 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -12,4 +12,4 @@ pub use args::{
SystemSubcommand,
};
pub use output::JsonResponse;
pub use runner::HeadlessRunner;
pub use runner::{HeadlessRunner, GRPC_TOKEN_ENV};
+223 -50
View File
@@ -5,6 +5,7 @@ use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nuncio_store::{DatabaseEngine, DatabaseError};
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use crate::args::{
@@ -14,6 +15,40 @@ use crate::args::{
use crate::output::{format_json, format_json_error, format_json_error_with_info};
/// Environment variable that supplies the gRPC bearer token directly, bypassing
/// the OS keyring entirely.
///
/// The keyring remains the default and only this client-side lookup honours the
/// override: `nunciod` always mints and owns the authoritative token, so the
/// daemon must never accept one from its environment.
///
/// The override exists because the keyring item's ACL is bound to the process
/// that created it (`nunciod`). A *different* binary reading the same item — the
/// CLI — makes the platform keychain ask the logged-in user for consent, which
/// nothing can answer without an interactive desktop session.
pub const GRPC_TOKEN_ENV: &str = "NUNCIO_GRPC_TOKEN";
/// Upper bound on how long a keyring read may block before the CLI gives up and
/// reports an actionable error. Generous enough that a human can answer a
/// keychain consent prompt, short enough that an unattended run always
/// terminates instead of stalling forever.
const KEYRING_READ_TIMEOUT: Duration = Duration::from_secs(20);
/// Normalizes a raw environment value into a usable token override.
///
/// Surrounding whitespace is stripped (shell heredocs and `env` files routinely
/// add a trailing newline) and an empty value is treated as "not set", so
/// `NUNCIO_GRPC_TOKEN=` cannot silently authenticate with an empty token.
fn normalize_token_override(raw: Option<String>) -> Option<String> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Reads the gRPC bearer token override from the process environment.
fn grpc_token_from_env() -> Option<String> {
normalize_token_override(std::env::var(GRPC_TOKEN_ENV).ok())
}
/// Parses a `--imap-mode`/`--smtp-mode` CLI string into `nuncio_core::TlsMode`.
/// Rejects anything else rather than silently defaulting, since silently
/// falling back to e.g. `Plain` for a typo'd mode would be a serious
@@ -326,7 +361,9 @@ pub enum RunnerError {
/// `nuncio.v1.Accounts` APIs, authenticated by a bearer token read from an
/// injected [`SecretManager`] — production code uses
/// [`SecretManager::production`] (the real OS keyring), while tests inject
/// [`SecretManager::mock`] so no test ever touches the real vault.
/// [`SecretManager::mock`] so no test ever touches the real vault. Setting
/// [`GRPC_TOKEN_ENV`] overrides that lookup entirely, which is how the CLI is
/// driven from a non-interactive session.
///
/// `account add` persists through the daemon so the account (and its
/// password, stored ONLY in the daemon's OS keyring vault) survives past
@@ -337,6 +374,10 @@ pub struct HeadlessRunner {
db: DatabaseEngine,
secrets: Arc<SecretManager>,
grpc_addr: String,
// When present, the bearer token to send instead of consulting `secrets`.
// Resolved once at construction so a single CLI invocation cannot change
// which credential it authenticates with halfway through.
token_override: Option<String>,
// Kept only to hold the ephemeral database's backing directory open for
// the runner's lifetime: dropping it would unlink the directory out from
// under `db` while the pool may still need to open new connections.
@@ -347,10 +388,13 @@ impl HeadlessRunner {
/// Initialize a new `HeadlessRunner` with an ephemeral database, the
/// real OS keyring vault ([`SecretManager::production`]), and the gRPC
/// daemon address resolved from [`nuncio_proto::grpc_addr_from_env`].
/// [`GRPC_TOKEN_ENV`], when set, supplies the bearer token instead of the
/// keyring.
pub async fn ephemeral() -> Result<Self, RunnerError> {
Self::ephemeral_with(
Self::ephemeral_with_token_override(
Arc::new(SecretManager::production()),
nuncio_proto::grpc_addr_from_env(),
grpc_token_from_env(),
)
.await
}
@@ -362,9 +406,28 @@ impl HeadlessRunner {
/// `system status`: pass a [`SecretManager::mock`]-backed instance
/// (never the real OS keyring) and the address of a test-local gRPC
/// server.
/// The ambient [`GRPC_TOKEN_ENV`] override is deliberately NOT consulted
/// here, so a variable that happens to be exported in a developer's shell
/// can never change what an injected-vault test authenticates with; tests
/// that want the override pass it explicitly to
/// [`Self::ephemeral_with_token_override`].
pub async fn ephemeral_with(
secrets: Arc<SecretManager>,
grpc_addr: String,
) -> Result<Self, RunnerError> {
Self::ephemeral_with_token_override(secrets, grpc_addr, None).await
}
/// Initialize a new `HeadlessRunner` with an ephemeral database, an
/// explicit secret vault + gRPC daemon address, and an explicit bearer
/// token override.
///
/// `token_override`, when `Some`, is used verbatim as the `authorization:
/// Bearer` credential and the vault is never consulted for it.
pub async fn ephemeral_with_token_override(
secrets: Arc<SecretManager>,
grpc_addr: String,
token_override: Option<String>,
) -> Result<Self, RunnerError> {
let (db, db_dir) = DatabaseEngine::connect_ephemeral()
.await
@@ -376,9 +439,59 @@ impl HeadlessRunner {
secrets,
_db_dir: db_dir,
grpc_addr,
token_override: normalize_token_override(token_override),
})
}
/// Resolves the gRPC bearer token, preferring an explicit override over the
/// OS keyring.
///
/// The keyring read runs on a detached OS thread behind a bounded timeout
/// rather than inline. On macOS a keychain item whose ACL does not trust
/// this binary makes the read block on a GUI consent prompt that never
/// arrives in a headless session, and that call cannot be cancelled — so
/// the timeout must be able to abandon the thread. A plain
/// `std::thread::spawn` is used instead of `spawn_blocking` precisely
/// because Tokio joins its blocking pool during runtime shutdown, which
/// would reintroduce the hang at process exit; a detached thread is reaped
/// when the process ends.
///
/// The returned error never carries token material — only the vault's own
/// failure description, which is keyed by account name, not secret value.
async fn resolve_grpc_token(&self) -> Result<String, String> {
if let Some(token) = &self.token_override {
return Ok(token.clone());
}
let secrets = Arc::clone(&self.secrets);
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::spawn(move || {
let resolved = secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map(hex::encode)
.map_err(|e| e.to_string());
// A closed receiver means the timeout already fired; nothing to do.
let _ = tx.send(resolved);
});
match tokio::time::timeout(KEYRING_READ_TIMEOUT, rx).await {
Ok(Ok(Ok(token))) => Ok(token),
Ok(Ok(Err(e))) => Err(format!("failed to read gRPC bearer token from vault: {e}")),
Ok(Err(_)) => {
Err("gRPC bearer token lookup ended without producing a result".to_string())
}
Err(_) => Err(format!(
"timed out after {}s reading the gRPC bearer token from the OS keyring: the \
keyring is most likely waiting on an interactive access-consent prompt that \
cannot be answered in this session (the token belongs to nunciod, so another \
binary reading it needs consent). Run this command from a logged-in desktop \
session, or set {GRPC_TOKEN_ENV} to the daemon's bearer token to bypass the \
keyring entirely",
KEYRING_READ_TIMEOUT.as_secs()
)),
}
}
/// Access the underlying `EventBus`.
#[allow(dead_code)]
pub fn event_bus(&self) -> &EventBus {
@@ -1412,17 +1525,13 @@ impl HeadlessRunner {
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Export` service at `self.grpc_addr`, shared by
/// [`Self::handle_mail_export`].
async fn connect_export_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedExportClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_export(&self.grpc_addr, &token)
.await
@@ -1524,17 +1633,13 @@ impl HeadlessRunner {
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Audit` service at `self.grpc_addr`, shared by
/// [`Self::handle_audit_list`] / [`Self::handle_audit_verify`].
async fn connect_audit_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedAuditClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_audit(&self.grpc_addr, &token)
.await
@@ -1994,17 +2099,13 @@ impl HeadlessRunner {
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Filters` service at `self.grpc_addr`, shared by every
/// `filter` handler above.
async fn connect_filters_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedFiltersClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_filters(&self.grpc_addr, &token)
.await
@@ -2459,51 +2560,39 @@ impl HeadlessRunner {
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Accounts` service at `self.grpc_addr`, shared by
/// [`Self::handle_add_account`] and [`Self::handle_accounts_list`].
async fn connect_accounts_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedAccountsClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_accounts(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Mail` service at `self.grpc_addr`, shared by every
/// `mail`/`folder` read-path handler above.
async fn connect_mail_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedMailClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_mail(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Calendar` service at `self.grpc_addr`, shared by
/// [`Self::handle_cal_list`] and [`Self::handle_cal_sync`].
async fn connect_calendar_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedCalendarClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_calendar(&self.grpc_addr, &token)
.await
@@ -2612,17 +2701,13 @@ impl HeadlessRunner {
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
/// `nuncio.v1.Contacts` service at `self.grpc_addr`, shared by every
/// `contact` handler above.
async fn connect_contacts_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedContactsClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
nuncio_proto::client::connect_contacts(&self.grpc_addr, &token)
.await
@@ -2991,16 +3076,12 @@ impl HeadlessRunner {
}
}
/// Reads the gRPC bearer token from the injected vault, connects to the
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]), connects to the
/// daemon over gRPC, and calls `GetStatus`. Returns the full live health
/// response on success, or a human-readable error string describing
/// exactly what failed (vault, connection, or the RPC itself).
async fn query_daemon_status(&self) -> Result<nuncio_proto::v1::GetStatusResponse, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
let token = self.resolve_grpc_token().await?;
let mut client = nuncio_proto::client::connect_system(&self.grpc_addr, &token)
.await
@@ -3039,6 +3120,98 @@ fn sync_state_label(state: i32) -> &'static str {
mod tests {
use super::*;
#[test]
fn normalize_token_override_trims_and_rejects_blank_values() {
assert_eq!(
normalize_token_override(Some(" deadbeef\n".to_string())),
Some("deadbeef".to_string())
);
assert_eq!(normalize_token_override(Some(String::new())), None);
assert_eq!(normalize_token_override(Some(" ".to_string())), None);
assert_eq!(normalize_token_override(None), None);
}
/// The env override must be picked up when set and must leave the runner on
/// the keyring path when it is unset or blank.
#[test]
fn grpc_token_from_env_reads_the_override_only_when_it_is_non_empty() {
std::env::remove_var(GRPC_TOKEN_ENV);
assert_eq!(grpc_token_from_env(), None);
std::env::set_var(GRPC_TOKEN_ENV, "");
assert_eq!(grpc_token_from_env(), None);
std::env::set_var(GRPC_TOKEN_ENV, " c0ffee ");
assert_eq!(grpc_token_from_env(), Some("c0ffee".to_string()));
std::env::remove_var(GRPC_TOKEN_ENV);
}
/// An injected override must be used verbatim and must never reach the
/// vault -- proven here by giving the runner a vault whose token is
/// necessarily different from the override.
#[tokio::test]
async fn resolve_grpc_token_prefers_the_override_over_the_vault() {
let secrets = Arc::new(SecretManager::mock());
let vault_token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints a token"),
);
let runner = HeadlessRunner::ephemeral_with_token_override(
Arc::clone(&secrets),
"127.0.0.1:1".to_string(),
Some(" override-token ".to_string()),
)
.await
.expect("runner init");
let resolved = runner
.resolve_grpc_token()
.await
.expect("override resolves without touching the vault");
assert_eq!(resolved, "override-token");
assert_ne!(resolved, vault_token);
}
/// With no override the vault remains the source of the token.
#[tokio::test]
async fn resolve_grpc_token_falls_back_to_the_vault_without_an_override() {
let secrets = Arc::new(SecretManager::mock());
let vault_token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints a token"),
);
let runner =
HeadlessRunner::ephemeral_with(Arc::clone(&secrets), "127.0.0.1:1".to_string())
.await
.expect("runner init");
assert_eq!(
runner
.resolve_grpc_token()
.await
.expect("vault resolves the token"),
vault_token
);
}
/// `ephemeral_with` must ignore an ambient override so an exported variable
/// in a developer's shell cannot silently change what tests authenticate
/// with.
#[tokio::test]
async fn ephemeral_with_ignores_an_ambient_env_override() {
let secrets = Arc::new(SecretManager::mock());
let runner =
HeadlessRunner::ephemeral_with(Arc::clone(&secrets), "127.0.0.1:1".to_string())
.await
.expect("runner init");
assert_eq!(runner.token_override, None);
}
#[test]
fn parse_tls_mode_accepts_all_valid_modes_and_rejects_garbage() {
assert_eq!(
@@ -0,0 +1,249 @@
//! Headless bearer-token resolution for the reference CLI.
//!
//! `nunciod` mints the gRPC bearer token into the OS keyring, so the keyring
//! item's access control list trusts the *daemon* binary. Any other binary
//! reading that item -- the CLI -- is a different program, and platform
//! keychains answer that by asking the logged-in user for consent. Nothing can
//! answer that prompt from CI, an ssh session, or a background shell, so the
//! read blocks forever. `NUNCIO_GRPC_TOKEN` is the explicit, opt-in escape
//! hatch; the keyring stays the default.
//!
//! These tests boot the real daemon gRPC server on an ephemeral loopback port
//! (mock vault, no real keychain, no live network) and drive the real
//! `HeadlessRunner` against it to prove:
//!
//! - an override authenticates and the vault is never consulted for it;
//! - with no override the keyring path is unchanged and still authenticates;
//! - a wrong override fails honestly and the token never reaches the output
//! or the telemetry.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::{Arc, Mutex, OnceLock};
use nuncio_cli::{Commands, HeadlessRunner, SystemSubcommand};
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use tokio::net::TcpListener;
use tracing::field::{Field, Visit};
use tracing::span::Attributes;
use tracing::{Event, Id, Subscriber};
use tracing_subscriber::layer::Context as LayerContext;
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan;
/// A bearer token that is unmistakable if it ever leaks into output or logs.
const SENTINEL_TOKEN: &str = "SENTINELheadlessCliBearerTokenVvWw001";
/// Boots the real daemon gRPC server on an ephemeral loopback port,
/// authenticated by `token`. Returns the address; the temp dir is kept alive
/// by the returned guard.
async fn boot_daemon(secrets: Arc<SecretManager>, token: String) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let (db, db_dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
// The temp dir must outlive the server, so it is moved into the task.
let _db_dir = db_dir;
let _ = nunciod::grpc::serve_on_listener(
listener,
Arc::new(EventBus::new()),
Arc::new(db),
filter_engine,
secrets,
token,
)
.await;
});
addr
}
/// Runs `system status` through the reference CLI against `addr`.
async fn run_status(runner: &HeadlessRunner) -> String {
runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Status,
},
true,
)
.await
}
/// An explicitly supplied token must authenticate on its own, with the runner's
/// vault holding a *different* token -- which is only possible if the override
/// short-circuits the keyring entirely.
#[tokio::test]
async fn env_override_authenticates_without_consulting_the_vault() {
let daemon_secrets = Arc::new(SecretManager::mock());
let token = hex::encode(
daemon_secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints gRPC bearer token"),
);
let addr = boot_daemon(Arc::clone(&daemon_secrets), token.clone()).await;
// A vault the CLI could only fail with: an independent mock mints an
// unrelated token.
let unrelated_vault = Arc::new(SecretManager::mock());
let unrelated_token = hex::encode(
unrelated_vault
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints gRPC bearer token"),
);
assert_ne!(unrelated_token, token);
let runner = HeadlessRunner::ephemeral_with_token_override(
unrelated_vault,
addr.to_string(),
Some(token),
)
.await
.expect("cli headless runner initializes");
let out = run_status(&runner).await;
assert!(
out.contains(r#""status":"ok""#),
"override should authenticate, got: {out}"
);
}
/// With no override the CLI must behave exactly as before: read the token from
/// the injected vault and authenticate with it.
#[tokio::test]
async fn keyring_path_is_unchanged_when_no_override_is_set() {
let secrets = Arc::new(SecretManager::mock());
let token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints gRPC bearer token"),
);
let addr = boot_daemon(Arc::clone(&secrets), token).await;
let runner = HeadlessRunner::ephemeral_with(secrets, addr.to_string())
.await
.expect("cli headless runner initializes");
let out = run_status(&runner).await;
assert!(
out.contains(r#""status":"ok""#),
"vault-resolved token should authenticate, got: {out}"
);
}
// ---- Telemetry capture: the token may never appear in logs. ----
#[derive(Default)]
struct Sink(Mutex<Vec<String>>);
impl Sink {
fn push_all(&self, values: Vec<String>) {
if let Ok(mut guard) = self.0.lock() {
guard.extend(values);
}
}
fn snapshot(&self) -> Vec<String> {
self.0.lock().map(|g| g.clone()).unwrap_or_default()
}
}
struct FieldVisitor(Vec<String>);
impl Visit for FieldVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.0.push(format!("{}={}", field.name(), value));
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.0.push(format!("{}={:?}", field.name(), value));
}
}
struct CaptureLayer(Arc<Sink>);
impl<S> tracing_subscriber::Layer<S> for CaptureLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: LayerContext<'_, S>) {
let mut visitor = FieldVisitor(vec![format!("span={}", attrs.metadata().name())]);
attrs.record(&mut visitor);
self.0.push_all(visitor.0);
}
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
let mut visitor = FieldVisitor(vec![format!("event={}", event.metadata().target())]);
event.record(&mut visitor);
self.0.push_all(visitor.0);
}
}
/// One process-wide capturing subscriber, installed once: a global subscriber
/// can only be set once per test binary and every test here shares it.
fn capture_sink() -> Arc<Sink> {
static SINK: OnceLock<Arc<Sink>> = OnceLock::new();
Arc::clone(SINK.get_or_init(|| {
let sink = Arc::new(Sink::default());
let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&sink)));
let _ = tracing::subscriber::set_global_default(subscriber);
sink
}))
}
/// A wrong override must fail honestly rather than hang or fabricate a status,
/// and the token must never reach the command output or the telemetry.
#[tokio::test]
async fn rejected_override_never_echoes_the_token_to_output_or_logs() {
let sink = capture_sink();
let daemon_secrets = Arc::new(SecretManager::mock());
let token = hex::encode(
daemon_secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mock vault mints gRPC bearer token"),
);
let addr = boot_daemon(Arc::clone(&daemon_secrets), token).await;
let runner = HeadlessRunner::ephemeral_with_token_override(
Arc::new(SecretManager::mock()),
addr.to_string(),
Some(SENTINEL_TOKEN.to_string()),
)
.await
.expect("cli headless runner initializes");
let out = run_status(&runner).await;
assert!(
out.contains(r#""status":"error""#),
"a wrong token must be rejected, got: {out}"
);
assert!(
!out.contains(SENTINEL_TOKEN),
"the bearer token must never be echoed in command output: {out}"
);
// Proves the capture is live, so the absence assertion below cannot pass
// by capturing nothing at all.
tracing::info!(marker = "headless-cli-token-capture-live", "capture probe");
let captured = sink.snapshot();
assert!(
captured
.iter()
.any(|line| line.contains("headless-cli-token-capture-live")),
"the capturing subscriber must be recording telemetry"
);
for line in &captured {
assert!(
!line.contains(SENTINEL_TOKEN),
"the bearer token leaked into telemetry: {line}"
);
}
}
+28
View File
@@ -51,6 +51,34 @@ cargo run -p nunciod # gRPC on 127.0.0.1:9420
cargo run -p nuncio-cli -- system status
```
### Driving the CLI headlessly
`nunciod` mints the gRPC bearer token into the OS keyring, so the keychain
item's ACL trusts **`nunciod`**. `nuncio-cli` is a different binary, so reading
the same item makes macOS ask the logged-in user for consent (*"nuncio-cli
wants to access key … in your keychain"*). Interactively you click Allow. In CI,
over ssh, or in a background shell there is nobody to click it — the read
blocks, and before this was fixed the CLI hung with no output at all.
Set `NUNCIO_GRPC_TOKEN` to bypass the keyring for the CLI only:
```bash
NUNCIO_GRPC_TOKEN=<token> cargo run -p nuncio-cli -- system status
```
When it is set and non-empty the CLI uses it verbatim and never touches the
keyring. When it is unset, behaviour is unchanged: the token still comes from
the keyring, now behind a bounded timeout that reports an actionable error
instead of stalling forever. The token is the daemon's hex-encoded
`grpc-bearer-token` keychain item; read it once from a session that can answer
the prompt (`security find-generic-password -s mx.nuncio.vault -a
grpc-bearer-token -w`) and pass it through your own secret store. It is a
credential: never commit it, never log it, never echo it into a shell history
file.
The daemon itself is unaffected — it *creates* the item rather than reading one
another binary owns, so its own startup raises no consent prompt.
Schema migrations were removed: databases are **created, never migrated**. A
store from before that change errors on read rather than upgrading. Deleting it
is correct — accounts and credentials live in the OS keyring and survive; cached