fix(config): make the persistent telemetry off a floor and a run-scoped off harmless
Two adversary findings against the shipped telemetry build, both about the same confusion: the resolver had one flag for "a human said no" and used it for two jobs it cannot do at once. `--telemetry true` beat `telemetry = false` in the config file, and the dispatcher then forwarded the resolved `true` as `CODEWHALE_TELEMETRY=true`, which outranked the child's own copy of that same file. The first-run notice and docs/TELEMETRY.md both advertise `codewhale config set telemetry false` as the *permanent* off switch; any wrapper script, alias, or agent harness passing the flag re-enabled a user who had used it. The persisted value is now a floor, like the environment one. Re-enabling is writing the same durable register the off was written in. `telemetry_explicit_off` — the flag that authorizes the destructive opt-out wipe — was set by `--telemetry false` and `CODEWHALE_TELEMETRY=0` as well. So the recipe docs/AGENT_RUNTIME.md prescribes for a single command deleted a consenting user's install id and truncated their own dry-run records, every time. It now means only "the config file says false", which is the one signal that is durable, is the user's own, and is re-asserted on the next run. That narrowing exposes a distinction the child process could not draw for itself: the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false` on every ordinary run, so a declared operator kill switch and the shipped default arrive identically. `CODEWHALE_TELEMETRY_FLOOR` is the dispatcher stating which one it is, and `telemetry_floor_in_force()` reads the statement where it exists and the raw environment where it does not. The first-run notice is the caller that needs it.
This commit is contained in:
+78
-19
@@ -3313,19 +3313,35 @@ impl ConfigToml {
|
||||
.or(env.telemetry)
|
||||
.or(self.telemetry)
|
||||
.unwrap_or(false);
|
||||
// Off is sticky: an explicit env "off", or an env value we could not
|
||||
// parse, forces off regardless of CLI flag or config file. A kill
|
||||
// switch that a later flag can re-enable is not a kill switch, and a
|
||||
// typo in `CODEWHALE_TELEMETRY` must never resolve to "on".
|
||||
let telemetry =
|
||||
telemetry_allowed && env.telemetry != Some(false) && !env.telemetry_env_invalid;
|
||||
// Distinguish "the user said no" from "nobody said anything". Only the
|
||||
// former is an answer; the unset default must never be read as one.
|
||||
let telemetry_explicit_off = cli.telemetry == Some(false)
|
||||
|| env.telemetry == Some(false)
|
||||
|| (cli.telemetry.is_none()
|
||||
&& env.telemetry.is_none()
|
||||
&& self.telemetry == Some(false));
|
||||
// `telemetry = false` written to the config file is the off switch the
|
||||
// first-run notice and `docs/TELEMETRY.md` both advertise as the
|
||||
// *persistent* one, so it is a floor and not merely the last term of a
|
||||
// precedence chain. Before this it lost to `--telemetry true`, and the
|
||||
// dispatcher then laundered that per-run flag into the child's
|
||||
// `CODEWHALE_TELEMETRY`, where it also outranked the child's own copy
|
||||
// of the same file: any wrapper script, alias, or agent harness that
|
||||
// passed the flag silently re-enabled a user who had turned telemetry
|
||||
// off. Re-enabling is `codewhale config set telemetry true`, which is
|
||||
// the same durable register the off was written in.
|
||||
let telemetry_persisted_off = self.telemetry == Some(false);
|
||||
// Off is sticky: an explicit env "off", an env value we could not
|
||||
// parse, or a floor declared by the dispatcher forces off regardless of
|
||||
// CLI flag or config file. A kill switch that a later flag can
|
||||
// re-enable is not a kill switch, and a typo in `CODEWHALE_TELEMETRY`
|
||||
// must never resolve to "on".
|
||||
let telemetry = telemetry_allowed
|
||||
&& env.telemetry != Some(false)
|
||||
&& !env.telemetry_env_invalid
|
||||
&& !env.telemetry_floor
|
||||
&& !telemetry_persisted_off;
|
||||
// Only a *persisted* off is an answer. `--telemetry false` and
|
||||
// `CODEWHALE_TELEMETRY=0` are run-scoped kill switches: they must stop
|
||||
// this run without deleting the identity and buffered events of a user
|
||||
// who never revoked consent — the dispatcher forwards a resolved
|
||||
// `false` on every ordinary run, so treating an environment "off" as an
|
||||
// answer would also make the default state indistinguishable from a
|
||||
// revocation.
|
||||
let telemetry_explicit_off = telemetry_persisted_off;
|
||||
let telemetry_endpoint = env
|
||||
.telemetry_endpoint
|
||||
.clone()
|
||||
@@ -3378,6 +3394,39 @@ fn merge_project_provider_config(target: &mut ProviderConfigToml, source: &Provi
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatcher's statement to the TUI child about *why* telemetry is off.
|
||||
///
|
||||
/// Private to the `codewhale` → `codewhale-tui` hop, in the same spirit as
|
||||
/// `DEEPSEEK_API_KEY_SOURCE`. Set to `1`/`0` on every delegated run.
|
||||
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
|
||||
|
||||
/// Whether an environment-level kill switch forces telemetry off here.
|
||||
///
|
||||
/// A floor is *not* the same as "telemetry resolved to false": off is the
|
||||
/// default, and the dispatcher forwards a resolved `CODEWHALE_TELEMETRY=false`
|
||||
/// on every ordinary run, so a child reading only that value cannot tell an
|
||||
/// operator's declared kill switch from the shipped default. That distinction
|
||||
/// matters exactly once — the first-run notice must not ask a question whose
|
||||
/// answer this environment overrides — so the dispatcher states it outright in
|
||||
/// [`TELEMETRY_FLOOR_ENV`] and the child believes the statement.
|
||||
///
|
||||
/// With no statement (a directly launched `codewhale-tui`) the raw environment
|
||||
/// is read instead, where an explicit "off" or an unreadable value is a floor.
|
||||
#[must_use]
|
||||
pub fn telemetry_floor_in_force() -> bool {
|
||||
if let Ok(raw) = std::env::var(TELEMETRY_FLOOR_ENV)
|
||||
&& let Ok(declared) = parse_bool(&raw)
|
||||
{
|
||||
return declared;
|
||||
}
|
||||
let Ok(raw) =
|
||||
std::env::var("CODEWHALE_TELEMETRY").or_else(|_| std::env::var("DEEPSEEK_TELEMETRY"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!matches!(parse_bool(&raw), Ok(true))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_approval_policy_is_allowed(current: Option<&str>, project: &str) -> bool {
|
||||
let Some(project_rank) = approval_policy_rank(project) else {
|
||||
@@ -4692,13 +4741,16 @@ pub struct ResolvedRuntimeOptions {
|
||||
pub output_mode: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
pub telemetry: bool,
|
||||
/// A human explicitly turned telemetry off — via `--telemetry false`,
|
||||
/// `CODEWHALE_TELEMETRY=0`, or `telemetry = false` in the config file.
|
||||
/// A human wrote `telemetry = false` into the config file.
|
||||
///
|
||||
/// This is *not* the same as [`Self::telemetry`] being `false`, which is
|
||||
/// also the value when nobody has said anything at all. Consumers that
|
||||
/// treat an answer differently from a default must read this flag rather
|
||||
/// than infer intent from the resolved boolean.
|
||||
/// This is the *persistent* opt-out, and it is deliberately narrower than
|
||||
/// "telemetry resolved to false". `false` is also the value when nobody has
|
||||
/// said anything at all, and it is what the dispatcher forwards to the TUI
|
||||
/// on every ordinary run; a consumer that reads those as a revocation would
|
||||
/// destroy the identity and buffered events of a consenting user who merely
|
||||
/// set `CODEWHALE_TELEMETRY=0` for one command. Run-scoped kill switches
|
||||
/// (`--telemetry false`, the environment variable) stop the run and leave
|
||||
/// every byte on disk alone; only this flag authorizes the wipe.
|
||||
pub telemetry_explicit_off: bool,
|
||||
/// Where a telemetry batch would be sent, if telemetry were on.
|
||||
///
|
||||
@@ -6366,6 +6418,11 @@ struct EnvRuntimeOverrides {
|
||||
/// resolve to "on", so this forces telemetry off the same way an explicit
|
||||
/// `false` does.
|
||||
telemetry_env_invalid: bool,
|
||||
/// An environment-level kill switch is in force for this process.
|
||||
///
|
||||
/// See [`telemetry_floor_in_force`] for what sets it and why the dispatcher
|
||||
/// has to state it rather than let the child infer it.
|
||||
telemetry_floor: bool,
|
||||
/// `CODEWHALE_TELEMETRY_ENDPOINT`/`DEEPSEEK_TELEMETRY_ENDPOINT`. Overrides
|
||||
/// the config file. A workspace `.env` cannot reach this — the dotenv
|
||||
/// allowlist admits only built-in provider credential names.
|
||||
@@ -6438,6 +6495,7 @@ impl EnvRuntimeOverrides {
|
||||
fn load() -> Self {
|
||||
let (provider, provider_source) = Self::load_provider();
|
||||
let (telemetry, telemetry_env_invalid) = Self::load_telemetry();
|
||||
let telemetry_floor = telemetry_floor_in_force();
|
||||
Self {
|
||||
provider,
|
||||
provider_source,
|
||||
@@ -6494,6 +6552,7 @@ impl EnvRuntimeOverrides {
|
||||
.ok(),
|
||||
telemetry,
|
||||
telemetry_env_invalid,
|
||||
telemetry_floor,
|
||||
telemetry_endpoint: std::env::var("CODEWHALE_TELEMETRY_ENDPOINT")
|
||||
.or_else(|_| std::env::var("DEEPSEEK_TELEMETRY_ENDPOINT"))
|
||||
.ok()
|
||||
|
||||
+140
-6
@@ -7918,6 +7918,7 @@ max_spawn_depth = 1
|
||||
struct TelemetryEnvGuard {
|
||||
codewhale: Option<OsString>,
|
||||
deepseek: Option<OsString>,
|
||||
floor: Option<OsString>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
@@ -7927,12 +7928,14 @@ impl TelemetryEnvGuard {
|
||||
let guard = Self {
|
||||
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
|
||||
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
|
||||
floor: env::var_os(TELEMETRY_FLOOR_ENV),
|
||||
_lock: lock,
|
||||
};
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
}
|
||||
guard
|
||||
}
|
||||
@@ -7943,6 +7946,22 @@ impl TelemetryEnvGuard {
|
||||
env::set_var("CODEWHALE_TELEMETRY", value);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_floor(&self, value: &str) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::set_var(TELEMETRY_FLOOR_ENV, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
// Safety: test-only environment mutation guarded by the module mutex.
|
||||
unsafe {
|
||||
env::remove_var("CODEWHALE_TELEMETRY");
|
||||
env::remove_var("DEEPSEEK_TELEMETRY");
|
||||
env::remove_var(TELEMETRY_FLOOR_ENV);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TelemetryEnvGuard {
|
||||
@@ -7957,6 +7976,10 @@ impl Drop for TelemetryEnvGuard {
|
||||
Some(value) => env::set_var("DEEPSEEK_TELEMETRY", value),
|
||||
None => env::remove_var("DEEPSEEK_TELEMETRY"),
|
||||
}
|
||||
match self.floor.take() {
|
||||
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
|
||||
None => env::remove_var(TELEMETRY_FLOOR_ENV),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7980,7 +8003,117 @@ fn env_telemetry_off_is_a_floor_over_cli_on() {
|
||||
// `--telemetry true` must not be able to climb back over an explicit
|
||||
// `CODEWHALE_TELEMETRY=0`. Off is a floor, not one more precedence rung.
|
||||
assert!(!resolved.telemetry);
|
||||
// …but the environment is a run-scoped switch, not a revocation. See
|
||||
// `a_run_scoped_off_is_a_kill_switch_and_not_a_revocation`.
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_telemetry_off_is_a_floor_over_cli_on() {
|
||||
// Regression: `--telemetry true` used to beat `telemetry = false` in the
|
||||
// config file, and the dispatcher then forwarded the resolved `true` as
|
||||
// `CODEWHALE_TELEMETRY=true`, which also outranked the child's own copy of
|
||||
// that file. Any wrapper script, alias, or agent harness passing the flag
|
||||
// silently re-enabled a user who had turned telemetry off through the one
|
||||
// switch the first-run notice advertises as permanent.
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
|
||||
let config = ConfigToml {
|
||||
telemetry: Some(false),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
let cli = CliRuntimeOverrides {
|
||||
telemetry: Some(true),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
|
||||
let resolved = config.resolve_runtime_options(&cli);
|
||||
assert!(!resolved.telemetry);
|
||||
// And it stays an answer, so the run re-asserts the tombstone.
|
||||
assert!(resolved.telemetry_explicit_off);
|
||||
|
||||
// An environment "on" loses to it as well: re-enabling is writing the
|
||||
// durable register the off was written in.
|
||||
guard.set("1");
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(resolved.telemetry_explicit_off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_scoped_off_is_a_kill_switch_and_not_a_revocation() {
|
||||
// Regression: an explicit `CODEWHALE_TELEMETRY=0` marked the run as an
|
||||
// *answer*, so the telemetry crate took its destructive opt-out branch —
|
||||
// deleting the install id and truncating the user's own dry-run records —
|
||||
// on a recipe the runtime docs prescribe for one command. Worse, the
|
||||
// dispatcher forwards a resolved `false` on every ordinary run, so the
|
||||
// shipped default was indistinguishable from a revocation.
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
|
||||
for value in ["0", "false", "off", "disabled", "no"] {
|
||||
guard.set(value);
|
||||
let config = ConfigToml {
|
||||
telemetry: Some(true),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert!(!resolved.telemetry, "{value} must stop the run");
|
||||
assert!(
|
||||
!resolved.telemetry_explicit_off,
|
||||
"{value} must not read as a revocation"
|
||||
);
|
||||
}
|
||||
|
||||
// The same for the per-run flag, with the environment back to silent.
|
||||
guard.clear();
|
||||
let cli = CliRuntimeOverrides {
|
||||
telemetry: Some(false),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
let resolved = ConfigToml {
|
||||
telemetry: Some(true),
|
||||
..ConfigToml::default()
|
||||
}
|
||||
.resolve_runtime_options(&cli);
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dispatcher_states_the_floor_rather_than_letting_the_child_infer_it() {
|
||||
// The child cannot tell an operator's declared kill switch from the
|
||||
// shipped default: both arrive as `CODEWHALE_TELEMETRY=false`. So the
|
||||
// dispatcher states it, and the statement outranks the inference — which
|
||||
// is what lets the first-run notice refuse to ask under a real floor while
|
||||
// still asking on an ordinary first run.
|
||||
let guard = TelemetryEnvGuard::take();
|
||||
assert!(!telemetry_floor_in_force());
|
||||
|
||||
guard.set("0");
|
||||
assert!(telemetry_floor_in_force());
|
||||
|
||||
// A forwarded resolved `false` with the dispatcher saying "no floor" is
|
||||
// the ordinary first run.
|
||||
guard.set("false");
|
||||
guard.set_floor("0");
|
||||
assert!(!telemetry_floor_in_force());
|
||||
|
||||
// A declared floor holds even where the value alone would not show it.
|
||||
guard.set("true");
|
||||
guard.set_floor("1");
|
||||
assert!(telemetry_floor_in_force());
|
||||
let resolved = ConfigToml {
|
||||
telemetry: Some(true),
|
||||
..ConfigToml::default()
|
||||
}
|
||||
.resolve_runtime_options(&CliRuntimeOverrides::default());
|
||||
assert!(!resolved.telemetry);
|
||||
|
||||
// An unreadable value is a floor: a typo in a kill switch never resolves
|
||||
// to "on".
|
||||
guard.clear();
|
||||
guard.set("maybe");
|
||||
assert!(telemetry_floor_in_force());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -8039,24 +8172,25 @@ fn telemetry_explicit_off_distinguishes_an_answer_from_the_default() {
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(resolved.telemetry_explicit_off);
|
||||
|
||||
// A CLI `--telemetry true` overrides the file's `false`, so the file is no
|
||||
// longer the answer and telemetry is on.
|
||||
// A CLI `--telemetry true` does not override the file's `false`: the
|
||||
// persistent switch is a floor, and the answer stands.
|
||||
let cli = CliRuntimeOverrides {
|
||||
telemetry: Some(true),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
let resolved = config.resolve_runtime_options(&cli);
|
||||
assert!(resolved.telemetry);
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(resolved.telemetry_explicit_off);
|
||||
|
||||
// A CLI `--telemetry false` is an answer.
|
||||
// A CLI `--telemetry false` stops the run without being an answer: it is
|
||||
// scoped to the run, and a run-scoped switch must not delete state.
|
||||
let cli = CliRuntimeOverrides {
|
||||
telemetry: Some(false),
|
||||
..CliRuntimeOverrides::default()
|
||||
};
|
||||
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
|
||||
assert!(!resolved.telemetry);
|
||||
assert!(resolved.telemetry_explicit_off);
|
||||
assert!(!resolved.telemetry_explicit_off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user