Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7dc3e60
context: Define Claude hook trace propagation plan
davidabram Oct 17, 2026
3962da5
context: Record Claude hook trace propagation evidence
davidabram Oct 17, 2026
9d58e86
runtime: Admit Claude hooks to managed trace export
davidabram Oct 17, 2026
c3e67d2
telemetry: Parent Claude hook spans on propagated trace context
davidabram Oct 17, 2026
d67518b
observability: Export Claude hook operation child spans
davidabram Oct 17, 2026
62fbe5a
context: Record Claude hook telemetry verification
davidabram Oct 17, 2026
368f926
docs: verify live Claude-to-SCE trace hierarchy in Dash0
davidabram Oct 17, 2026
f5b97f8
runtime: Harden concurrent hook telemetry and diagnose fallbacks
davidabram Oct 19, 2026
23e9806
runtime: Harden auth DB compatibility and Claude hook telemetry
davidabram Oct 19, 2026
1f44caf
runtime: Keep hooks and setup from prompting for auth DB credentials
davidabram Oct 19, 2026
daaa925
runtime: Use flock for the macOS auth DB compatibility lock
davidabram Oct 19, 2026
de2aedf
auth-db: Coordinate concurrent writer lifetimes
davidabram Oct 19, 2026
dbdb4e8
doctor: Report managed telemetry health and auth recovery steps
davidabram Oct 19, 2026
92ff51f
auth-db: Classify auth storage failures with typed causes
davidabram Oct 19, 2026
9d0caa8
auth: Normalize whitespace in login storage guidance assertions
davidabram Oct 19, 2026
464907a
auth-db: Serialize Turso cold opens after legacy WAL writes
davidabram Oct 19, 2026
78bada7
tests: Harden auth and telemetry integration coverage
davidabram Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ opentelemetry = { version = "=0.33.1", default-features = false, features = ["tr
async-trait = "0.1"
opentelemetry_sdk = { version = "=0.33.1", default-features = false, features = ["trace", "logs"] }

[target.'cfg(unix)'.dependencies]
libc = "0.2.186"

[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["rt-tokio-crypto-rust"] }

[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1", features = ["keychain"] }
security-framework = "3"

[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = "1"
Expand Down
102 changes: 77 additions & 25 deletions cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::time::Duration;
use crate::services;
use services::app_support::{self, RunOutcome};
use services::error::CliError;
use services::managed_telemetry::trace_export::ManagedSpanKind;
use services::managed_telemetry::RuntimeTelemetry;
use services::observability::traits::{
Logger as LoggerTrait, NoopTelemetry, Telemetry as TelemetryTrait,
Expand All @@ -15,6 +16,9 @@ const MIN_WORKER_THREADS: usize = 2;
const RUNTIME_SHUTDOWN_GRACE: Duration = Duration::from_millis(500);
const REPEATED_COMMAND_DISPATCH_ERROR: &str =
"Command lifecycle telemetry attempted to execute command dispatch more than once";
const CLAUDE_ONLY_HOOK_SUBCOMMANDS: [&str; 2] = ["claude-mutation-scope", "claude-model-state"];
const SHARED_CLAUDE_HOOK_SUBCOMMANDS: [&str; 2] = ["diff-trace", "conversation-trace"];
const CLAUDE_HOOK_CALLER_MARKERS: [&str; 2] = ["CLAUDECODE", "CLAUDE_PROJECT_DIR"];

struct StartupContext {
observability_config: services::config::ResolvedObservabilityRuntimeConfig,
Expand Down Expand Up @@ -275,7 +279,7 @@ where
StderrW: Write,
{
let args = args.into_iter().collect::<Vec<_>>();
let managed_allowed = !is_hook_invocation(&args);
let span_kind = managed_span_kind(&args, is_claude_hook_caller());
let startup = perform_dependency_check(dependency_check)
.and_then(|()| build_startup_context())
.and_then(initialize_runtime);
Expand All @@ -293,24 +297,36 @@ where
);
}
};
runtime.telemetry = RuntimeTelemetry::resolve(managed_allowed).await;
runtime.telemetry = RuntimeTelemetry::resolve(span_kind, &runtime.logger).await;
let rendered = run_command_lifecycle(args, &runtime, stdout, stderr).await;
runtime.telemetry.shutdown().await;
app_support::exit_code_for(&rendered)
}

fn is_hook_invocation(args: &[String]) -> bool {
fn managed_span_kind(args: &[String], claude_hook_caller: bool) -> Option<ManagedSpanKind> {
let mut words = args
.iter()
.skip(1)
.map(String::as_str)
.filter(|arg| !arg.starts_with('-'));
match (words.next(), words.next()) {
(Some(first), _) if first == services::hooks::NAME => true,
(Some("policy"), Some("bash")) => true,
(Some(first), Some("telemetry-headers")) if first == services::auth_command::NAME => true,
_ => false,
}
let claude_hook = match (words.next(), words.next()) {
(Some(first), Some(subcommand)) if first == services::hooks::NAME => {
CLAUDE_ONLY_HOOK_SUBCOMMANDS.contains(&subcommand)
|| (claude_hook_caller && SHARED_CLAUDE_HOOK_SUBCOMMANDS.contains(&subcommand))
}
(Some(first), _) if first == services::hooks::NAME => false,
(Some(first), _) if first == services::setup::NAME => false,
(Some(first), Some("bash")) if first == services::bash_policy::NAME => claude_hook_caller,
(Some(first), Some("telemetry-headers")) if first == services::auth_command::NAME => false,
_ => return Some(ManagedSpanKind::Command),
};
claude_hook.then_some(ManagedSpanKind::ClaudeHook)
}

fn is_claude_hook_caller() -> bool {
CLAUDE_HOOK_CALLER_MARKERS
.iter()
.all(|marker| std::env::var_os(marker).is_some())
}

fn perform_dependency_check<F: FnOnce() -> anyhow::Result<()>>(
Expand Down Expand Up @@ -924,10 +940,24 @@ pub(crate) mod tests {
}
let runtime = build_runtime().unwrap();
runtime.block_on(async {
let keychain = RuntimeTelemetry::resolve_with(stalled_keychain_lookup()).await;
let (keychain, fallback) =
RuntimeTelemetry::resolve_with(stalled_keychain_lookup(), ManagedSpanKind::Command)
.await;
assert!(matches!(keychain, RuntimeTelemetry::Noop(_)));
let database = RuntimeTelemetry::resolve_with(stalled_blocking_database_lookup()).await;
assert_eq!(
fallback.map(|fallback| fallback.reason),
Some(services::managed_telemetry::TelemetryFallbackReason::LookupTimeout)
);
let (database, fallback) = RuntimeTelemetry::resolve_with(
stalled_blocking_database_lookup(),
ManagedSpanKind::ClaudeHook,
)
.await;
assert!(matches!(database, RuntimeTelemetry::Noop(_)));
assert_eq!(
fallback.map(|fallback| fallback.reason),
Some(services::managed_telemetry::TelemetryFallbackReason::LookupTimeout)
);
});
finish_runtime(runtime);
}
Expand All @@ -951,6 +981,7 @@ pub(crate) mod tests {
let result = runtime.block_on(services::auth_command::telemetry_headers_with(
Some(repo.path().to_path_buf()),
false,
true,
|_: &std::path::Path| Some("repository-a".to_string()),
|_: String| stalled_keychain_lookup(),
));
Expand Down Expand Up @@ -1073,24 +1104,45 @@ pub(crate) mod tests {
}

#[test]
fn hook_invocations_never_resolve_managed_telemetry() {
let hook = ["sce", "hooks", "pre-commit"].map(String::from);
let flagged_hook = ["sce", "--verbose", "hooks", "pre-commit"].map(String::from);
let other = ["sce", "config", "show"].map(String::from);
let policy = ["sce", "policy", "bash"].map(String::from);
let headers = ["sce", "auth", "telemetry-headers"].map(String::from);
let auth_login = ["sce", "auth", "login"].map(String::from);
assert!(is_hook_invocation(&policy));
assert!(is_hook_invocation(&headers));
assert!(!is_hook_invocation(&auth_login));
assert!(is_hook_invocation(&hook));
assert!(is_hook_invocation(&flagged_hook));
assert!(!is_hook_invocation(&other));
fn only_claude_hook_invocations_are_admitted_to_managed_hook_telemetry() {
const HOOK: Option<ManagedSpanKind> = Some(ManagedSpanKind::ClaudeHook);
const COMMAND: Option<ManagedSpanKind> = Some(ManagedSpanKind::Command);
let cases: [(&[&str], bool, Option<ManagedSpanKind>); 20] = [
(&["sce", "hooks", "claude-mutation-scope"], false, HOOK),
(&["sce", "hooks", "claude-model-state"], false, HOOK),
(&["sce", "hooks", "diff-trace"], true, HOOK),
(&["sce", "hooks", "diff-trace"], false, None),
(&["sce", "hooks", "conversation-trace"], true, HOOK),
(&["sce", "hooks", "conversation-trace"], false, None),
(&["sce", "policy", "bash"], true, HOOK),
(&["sce", "policy", "bash"], false, None),
(&["sce", "hooks", "pre-commit"], true, None),
(&["sce", "--verbose", "hooks", "codex"], true, None),
(&["sce", "hooks", "codex-mutation-scope"], true, None),
(&["sce", "hooks", "opencode-mutation-scope"], true, None),
(&["sce", "hooks", "pi-mutation-scope"], true, None),
(&["sce", "hooks"], true, None),
(&["sce", "auth", "telemetry-headers"], true, None),
(&["sce", "setup"], true, None),
(&["sce", "setup", "--claude"], true, None),
(&["sce", "auth", "login"], true, COMMAND),
(&["sce", "config", "show"], false, COMMAND),
(&["sce", "doctor"], true, COMMAND),
];
for (args, claude_caller, expected) in cases {
let args = args.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
managed_span_kind(&args, claude_caller),
expected,
"{args:?} claude_caller={claude_caller}"
);
}
}

#[tokio::test]
async fn disallowed_managed_telemetry_resolves_to_noop_without_credential_lookup() {
let telemetry = RuntimeTelemetry::resolve(false).await;
let telemetry =
RuntimeTelemetry::resolve(None, &services::observability::traits::NoopLogger).await;
assert!(matches!(telemetry, RuntimeTelemetry::Noop(_)));
}
}
96 changes: 96 additions & 0 deletions cli/src/app/doctor_reconciliation_cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde_json::Value;

use super::run_with_dependency_check_and_streams;
use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb;
use crate::services::db::encryption_key::AUTH_DB_ENCRYPTION_KEY_ENV;
use crate::services::default_paths::agent_trace_db_path_for_repository_at;
use crate::services::repository_identity::resolve::resolve_repository_identity;

Expand All @@ -15,6 +16,10 @@ const STATE_FILE: &str = "ref-maintenance.json";
const LOCK_FILE: &str = "mutation-cursor.lock";
const RECONCILIATION_DETAIL_PREFIX: &str = "Snapshot ref reconciliation";

const AUTH_DB_TEST_SECRET: &str = "doctor-cli-test-secret";
const UNREADABLE_AUTH_DB: &[u8] = b"not an encrypted auth database";
const FAR_FUTURE_READINESS: &[u8] = br#"{"expires_at_unix_seconds":4102444800}"#;

const EXIT_SUCCESS: &str = "success";
const EXIT_FAILURE: &str = "failure";

Expand Down Expand Up @@ -138,6 +143,10 @@ impl Sandbox {
}

fn run(&self, args: &[&str]) -> CliRun {
self.run_with_env(args, &[])
}

fn run_with_env(&self, args: &[&str], envs: &[(&str, &str)]) -> CliRun {
let out = self.dir.path().join(format!("out-{}", args.join("-")));
std::fs::create_dir_all(&out).expect("out dir");
let output = Command::new(std::env::current_exe().expect("current exe"))
Expand All @@ -151,6 +160,8 @@ impl Sandbox {
.env("XDG_CACHE_HOME", self.dir.path().join("cache"))
.env("NO_COLOR", "1")
.env_remove("SCE_CONFIG_FILE")
.env_remove(AUTH_DB_ENCRYPTION_KEY_ENV)
.envs(envs.iter().copied())
.output()
.expect("child runs");
assert!(
Expand Down Expand Up @@ -594,3 +605,88 @@ async fn doctor_fix_recovers_from_every_initial_maintenance_state_condition() {
);
}
}

fn global_state_warnings(report: &Value) -> Vec<Value> {
report["problems"]
.as_array()
.expect("problems array")
.iter()
.filter(|problem| problem["category"] == "global_state" && problem["severity"] == "warning")
.cloned()
.collect()
}

#[tokio::test(flavor = "multi_thread")]
async fn doctor_on_fresh_state_reports_unconfigured_telemetry_without_a_warning() {
let sandbox = Sandbox::new("https://example.invalid/org/repo-a.git");
let auth_dir = sandbox.state_root().join("sce");

let report = sandbox
.run_with_env(
&["doctor", "--format", "json"],
&[(AUTH_DB_ENCRYPTION_KEY_ENV, "")],
)
.json();

assert_eq!(report["managed_telemetry"]["state"], "not_configured");
let warnings = global_state_warnings(&report);
assert!(warnings.is_empty(), "{warnings:#?}");
assert!(!auth_dir.join("auth.db").exists());
assert!(!auth_dir.join("telemetry-ready.json").exists());
}

#[tokio::test(flavor = "multi_thread")]
async fn doctor_fix_reports_unreadable_telemetry_credentials_without_auth_side_effects() {
let sandbox = Sandbox::new("https://example.invalid/org/repo-a.git");
let auth_dir = sandbox.state_root().join("sce");
std::fs::create_dir_all(&auth_dir).expect("auth dir");
let auth_db = auth_dir.join("auth.db");
let readiness = auth_dir.join("telemetry-ready.json");
std::fs::write(&auth_db, UNREADABLE_AUTH_DB).expect("auth DB");
std::fs::write(&readiness, FAR_FUTURE_READINESS).expect("readiness");
let env = [(AUTH_DB_ENCRYPTION_KEY_ENV, AUTH_DB_TEST_SECRET)];

let fix = sandbox.run_with_env(&["doctor", "--fix", "--format", "json"], &env);
let report = fix.json();

assert_eq!(
report["managed_telemetry"],
serde_json::json!({
"state": "disabled",
"reason": "database_error",
"kind": "managed_telemetry_database_error",
"severity": "warning",
"next_action": "manual_steps",
}),
"{}",
fix.stdout
);
let warnings = global_state_warnings(&report);
assert_eq!(warnings.len(), 1, "{}", fix.stdout);
assert_eq!(warnings[0]["fixability"], "manual_only");
assert_eq!(warnings[0]["remediation"]["next_action"], "manual_steps");
let summary = warnings[0]["summary"].as_str().expect("summary");
let fixes = report["fix_results"]
.as_array()
.expect("fix_results array")
.iter()
.filter(|entry| {
entry["category"] == "global_state"
&& entry["detail"]
.as_str()
.is_some_and(|detail| detail.starts_with(summary))
})
.collect::<Vec<_>>();
assert_eq!(fixes.len(), 1, "{}", fix.stdout);
assert_eq!(fixes[0]["outcome"], "manual");
assert_eq!(
std::fs::read(&auth_db).expect("auth DB kept"),
UNREADABLE_AUTH_DB
);
assert_eq!(
std::fs::read(&readiness).expect("readiness kept"),
FAR_FUTURE_READINESS
);
assert!(!fix.stdout.contains(AUTH_DB_TEST_SECRET));
assert!(!fix.stderr.contains(AUTH_DB_TEST_SECRET));
}
Loading
Loading