Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -1414,11 +1414,79 @@ pub fn session_registry_from_toml_opt(root: &TomlValue) -> Option<bool> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Overrides `[cli] session_registry`; usable before `~/.grok/config.toml` exists.
|
||||
pub const SESSION_REGISTRY_ENV_VAR: &str = "GROK_SESSION_REGISTRY";
|
||||
|
||||
pub fn session_registry_from_env_opt() -> Option<bool> {
|
||||
xai_grok_config::env_bool(SESSION_REGISTRY_ENV_VAR)
|
||||
}
|
||||
|
||||
/// Where a local session-registry override came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RegistrySource {
|
||||
/// [`SESSION_REGISTRY_ENV_VAR`].
|
||||
Env,
|
||||
/// `[cli] session_registry` in config.toml.
|
||||
ConfigToml,
|
||||
}
|
||||
|
||||
impl RegistrySource {
|
||||
/// The user-facing name of this source, for diagnostics.
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
RegistrySource::Env => SESSION_REGISTRY_ENV_VAR,
|
||||
RegistrySource::ConfigToml => "[cli] session_registry",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Env var, then `[cli] session_registry`; `None` defers to remote settings.
|
||||
pub fn session_registry_local_override_sourced(
|
||||
root: Option<&TomlValue>,
|
||||
) -> Option<(bool, RegistrySource)> {
|
||||
if let Some(v) = session_registry_from_env_opt() {
|
||||
return Some((v, RegistrySource::Env));
|
||||
}
|
||||
root.and_then(session_registry_from_toml_opt)
|
||||
.map(|v| (v, RegistrySource::ConfigToml))
|
||||
}
|
||||
|
||||
pub fn session_registry_local_override(root: Option<&TomlValue>) -> Option<bool> {
|
||||
session_registry_local_override_sourced(root).map(|(v, _)| v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Env beats config.toml; unrecognized env defers; both absent defers to remote.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn session_registry_local_override_precedence() {
|
||||
let toml_true: TomlValue = toml::from_str("[cli]\nsession_registry = true").unwrap();
|
||||
{
|
||||
let _g = xai_grok_test_support::EnvGuard::set(SESSION_REGISTRY_ENV_VAR, "false");
|
||||
assert_eq!(
|
||||
session_registry_local_override_sourced(Some(&toml_true)),
|
||||
Some((false, RegistrySource::Env)),
|
||||
"env wins and reports itself as the source"
|
||||
);
|
||||
}
|
||||
{
|
||||
let _g = xai_grok_test_support::EnvGuard::set(SESSION_REGISTRY_ENV_VAR, "bogus");
|
||||
assert_eq!(
|
||||
session_registry_local_override_sourced(Some(&toml_true)),
|
||||
Some((true, RegistrySource::ConfigToml)),
|
||||
"unrecognized env values defer to config.toml"
|
||||
);
|
||||
}
|
||||
{
|
||||
let _g = xai_grok_test_support::EnvGuard::unset(SESSION_REGISTRY_ENV_VAR);
|
||||
assert_eq!(session_registry_local_override_sourced(None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_defined_at_checks_raw_key_presence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -30,5 +30,5 @@ pub use tips::*;
|
|||
pub use worktree::*;
|
||||
pub use xai_grok_config_types::{
|
||||
CampaignOverride, ContextualHintsRemote, DisplayRefreshSettings, DoomLoopRecoverySettings,
|
||||
GoalRoleModel, RemoteSettings,
|
||||
GoalRoleModel, RemoteSettings, WorktreeAutoGcSettings, WorktreeKindMaxAge,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ pub async fn set_page_flip_on_send(value: bool) -> Result<()> {
|
|||
update_config(|cfg| cfg.ui.page_flip_on_send = Some(value)).await
|
||||
}
|
||||
|
||||
/// Persist `[ui].combine_queued_prompts` via `update_config`.
|
||||
pub async fn set_combine_queued_prompts(value: bool) -> Result<()> {
|
||||
update_config(|cfg| cfg.ui.combine_queued_prompts = Some(value)).await
|
||||
}
|
||||
|
||||
/// Persist `[ui].simple_mode` via `update_config`. Same `Option<bool>`
|
||||
/// shape as `show_timestamps`.
|
||||
pub async fn set_simple_mode(value: bool) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,42 @@ pub fn use_leader_sync() -> bool {
|
|||
use_leader_from_toml(&root)
|
||||
}
|
||||
|
||||
/// Parse `[worktree.auto_gc]` (per-field tolerant via [`WorktreeAutoGcSettings`]).
|
||||
pub fn worktree_auto_gc_from_toml(root: &TomlValue) -> super::WorktreeAutoGcSettings {
|
||||
root.get("worktree")
|
||||
.and_then(|w| w.get("auto_gc"))
|
||||
// toml::Value only deserializes by value (no &Value Deserializer).
|
||||
.and_then(|v| super::WorktreeAutoGcSettings::deserialize(v.clone()).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve: env > local TOML > remote > defaults (clamped). Platform age policy
|
||||
/// is applied later in `maybe_auto_gc`.
|
||||
pub fn resolve_worktree_auto_gc(
|
||||
raw_config: &TomlValue,
|
||||
remote: Option<&RemoteSettings>,
|
||||
) -> xai_fast_worktree::ResolvedWorktreeAutoGc {
|
||||
let local = worktree_auto_gc_from_toml(raw_config);
|
||||
resolve_worktree_auto_gc_from_settings(
|
||||
Some(&local),
|
||||
remote.and_then(|r| r.worktree_auto_gc.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Same layering with already-parsed settings.
|
||||
pub fn resolve_worktree_auto_gc_from_settings(
|
||||
local: Option<&super::WorktreeAutoGcSettings>,
|
||||
remote: Option<&super::WorktreeAutoGcSettings>,
|
||||
) -> xai_fast_worktree::ResolvedWorktreeAutoGc {
|
||||
use xai_grok_workspace::worktree::worktree_auto_gc_layer_from_settings;
|
||||
let local_layer = local.map(worktree_auto_gc_layer_from_settings);
|
||||
let remote_layer = remote.map(worktree_auto_gc_layer_from_settings);
|
||||
xai_fast_worktree::resolve_worktree_auto_gc_from_layers(
|
||||
local_layer.as_ref(),
|
||||
remote_layer.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RemoteSettings;
|
||||
|
|
@ -375,5 +411,392 @@ worktree_type = "invalid"
|
|||
assert!(!resolve_restore_code(&root, Some(&remote)));
|
||||
}
|
||||
|
||||
// === minimum_version tests ===
|
||||
// === worktree auto_gc resolve tests ===
|
||||
|
||||
use crate::util::config::WorktreeAutoGcSettings;
|
||||
use serial_test::serial;
|
||||
|
||||
fn clear_auto_gc_env() {
|
||||
unsafe {
|
||||
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC);
|
||||
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC_DRY_RUN);
|
||||
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC_MAX_AGE);
|
||||
std::env::remove_var(xai_fast_worktree::ENV_AUTO_GC_REBUILD);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_defaults_enabled() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert!(p.enabled);
|
||||
assert_eq!(p.max_age_secs, xai_fast_worktree::DEFAULT_MAX_AGE_SECS);
|
||||
assert_eq!(
|
||||
p.min_interval_secs,
|
||||
xai_fast_worktree::DEFAULT_MIN_INTERVAL_SECS
|
||||
);
|
||||
assert!(!p.dry_run);
|
||||
assert_eq!(
|
||||
p.include_orphan_snapshots,
|
||||
cfg!(target_os = "linux"),
|
||||
"orphan default is platform-gated"
|
||||
);
|
||||
assert!(!p.include_rebuild, "rebuild off by default");
|
||||
assert_eq!(
|
||||
p.rebuild_min_interval_secs,
|
||||
xai_fast_worktree::DEFAULT_REBUILD_MIN_INTERVAL_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_rebuild_env_and_toml() {
|
||||
clear_auto_gc_env();
|
||||
unsafe { std::env::set_var(xai_fast_worktree::ENV_AUTO_GC_REBUILD, "1") };
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert!(p.include_rebuild, "env REBUILD=1 enables rebuild");
|
||||
clear_auto_gc_env();
|
||||
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
include_rebuild = true
|
||||
rebuild_min_interval_secs = 120
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert!(p.include_rebuild);
|
||||
assert_eq!(p.rebuild_min_interval_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_env_disabled_wins_over_remote_and_local() {
|
||||
clear_auto_gc_env();
|
||||
unsafe { std::env::set_var(xai_fast_worktree::ENV_AUTO_GC, "0") };
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert!(!p.enabled, "env kill must win over remote/local enabled");
|
||||
clear_auto_gc_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_remote_enabled_false_disables() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
enabled: Some(false),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert!(!p.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_local_wins_over_remote_ttl_and_env_dry_run_wins() {
|
||||
clear_auto_gc_env();
|
||||
unsafe { std::env::set_var(xai_fast_worktree::ENV_AUTO_GC_DRY_RUN, "1") };
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
max_age_secs = 7200
|
||||
min_interval_secs = 120
|
||||
dry_run = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
max_age_secs: Some(86400),
|
||||
min_interval_secs: Some(3600),
|
||||
dry_run: Some(false),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert_eq!(p.max_age_secs, 7200, "local TOML wins over remote TTL");
|
||||
assert_eq!(p.min_interval_secs, 120, "local interval wins");
|
||||
assert!(
|
||||
p.dry_run,
|
||||
"env dry-run wins over remote/local dry_run=false"
|
||||
);
|
||||
clear_auto_gc_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_remote_ttl_clamped() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
max_age_secs: Some(1),
|
||||
min_interval_secs: Some(1),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert_eq!(p.max_age_secs, xai_fast_worktree::MAX_AGE_SECS_MIN);
|
||||
assert_eq!(
|
||||
p.min_interval_secs,
|
||||
xai_fast_worktree::MIN_INTERVAL_SECS_MIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_partial_remote_falls_through() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert!(p.enabled);
|
||||
assert_eq!(p.max_age_secs, xai_fast_worktree::DEFAULT_MAX_AGE_SECS);
|
||||
assert_eq!(
|
||||
p.min_interval_secs,
|
||||
xai_fast_worktree::DEFAULT_MIN_INTERVAL_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_local_enabled_false_over_remote_true() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert!(!p.enabled, "local enabled=false must beat remote true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_local_dry_run_over_remote() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
dry_run = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
dry_run: Some(false),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
assert!(p.dry_run, "local dry_run must beat remote false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_upper_clamp_via_resolve() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
max_age_secs = 999999999
|
||||
min_interval_secs = 999999999
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert_eq!(p.max_age_secs, xai_fast_worktree::MAX_AGE_SECS_MAX);
|
||||
assert_eq!(
|
||||
p.min_interval_secs,
|
||||
xai_fast_worktree::MIN_INTERVAL_SECS_MAX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn worktree_auto_gc_toml_bad_field_keeps_enabled_false() {
|
||||
// Typo/wrong type next to enabled=false must not drop the kill-switch.
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
enabled = false
|
||||
max_age_secs = "not-a-number"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = worktree_auto_gc_from_toml(&root);
|
||||
assert_eq!(s.enabled, Some(false));
|
||||
assert_eq!(s.max_age_secs, None);
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert!(!p.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_defaults_manual_never() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str("").unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert_eq!(
|
||||
p.max_age_by_kind
|
||||
.get(&xai_fast_worktree::WorktreeKind::Manual),
|
||||
Some(&None),
|
||||
"product default: manual never age-expires"
|
||||
);
|
||||
assert!(
|
||||
!p.max_age_by_kind
|
||||
.contains_key(&xai_fast_worktree::WorktreeKind::Session),
|
||||
"session uses default max_age_secs, not an explicit map entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_kind_map_local_wins_over_remote() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc.max_age_by_kind]
|
||||
subagent = 7200
|
||||
manual = "never"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let remote = RemoteSettings {
|
||||
worktree_auto_gc: Some(WorktreeAutoGcSettings {
|
||||
max_age_by_kind: Some(
|
||||
[
|
||||
(
|
||||
"subagent".into(),
|
||||
crate::util::config::WorktreeKindMaxAge::Secs(86400),
|
||||
),
|
||||
(
|
||||
"manual".into(),
|
||||
crate::util::config::WorktreeKindMaxAge::Secs(3600),
|
||||
),
|
||||
(
|
||||
"pool".into(),
|
||||
crate::util::config::WorktreeKindMaxAge::Secs(172800),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let p = resolve_worktree_auto_gc(&root, Some(&remote));
|
||||
use xai_fast_worktree::WorktreeKind;
|
||||
assert_eq!(
|
||||
p.max_age_by_kind.get(&WorktreeKind::Subagent),
|
||||
Some(&Some(7200)),
|
||||
"local kind TTL wins"
|
||||
);
|
||||
assert_eq!(
|
||||
p.max_age_by_kind.get(&WorktreeKind::Manual),
|
||||
Some(&None),
|
||||
"local manual=never wins over remote expire"
|
||||
);
|
||||
assert_eq!(
|
||||
p.max_age_by_kind.get(&WorktreeKind::Pool),
|
||||
Some(&Some(172800)),
|
||||
"remote-only kind entry kept"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn resolve_worktree_auto_gc_env_max_age() {
|
||||
clear_auto_gc_env();
|
||||
unsafe { std::env::set_var(xai_fast_worktree::ENV_AUTO_GC_MAX_AGE, "7200") };
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
max_age_secs = 86400
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert_eq!(p.max_age_secs, 7200, "env MAX_AGE wins over TOML");
|
||||
clear_auto_gc_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn worktree_auto_gc_toml_kind_map_parses_never() {
|
||||
clear_auto_gc_env();
|
||||
let root: TomlValue = toml::from_str(
|
||||
r#"
|
||||
[worktree.auto_gc]
|
||||
max_age_secs = 604800
|
||||
[worktree.auto_gc.max_age_by_kind]
|
||||
subagent = 86400
|
||||
manual = "never"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = worktree_auto_gc_from_toml(&root);
|
||||
let map = s.max_age_by_kind.as_ref().expect("kind map present");
|
||||
assert_eq!(
|
||||
map.get("subagent"),
|
||||
Some(&crate::util::config::WorktreeKindMaxAge::Secs(86400))
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("manual"),
|
||||
Some(&crate::util::config::WorktreeKindMaxAge::Never)
|
||||
);
|
||||
let p = resolve_worktree_auto_gc(&root, None);
|
||||
assert_eq!(
|
||||
p.max_age_by_kind
|
||||
.get(&xai_fast_worktree::WorktreeKind::Subagent),
|
||||
Some(&Some(86400))
|
||||
);
|
||||
assert_eq!(
|
||||
p.max_age_by_kind
|
||||
.get(&xai_fast_worktree::WorktreeKind::Manual),
|
||||
Some(&None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod config;
|
||||
pub mod grok_auth_credentials;
|
||||
pub mod hooks;
|
||||
pub(crate) mod subprocess;
|
||||
pub(crate) mod user_identity;
|
||||
|
||||
// The foundation utilities live in `xai-grok-shell-base` (upstream of this
|
||||
// crate so they build in parallel). Re-exported at the original paths so
|
||||
|
|
|
|||
367
crates/codegen/xai-grok-shell/src/util/subprocess.rs
Normal file
367
crates/codegen/xai-grok-shell/src/util/subprocess.rs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
//! Shared subprocess helpers: a TTY-detached async runner with a wall-clock
|
||||
//! timeout and concurrent pipe draining, plus the hermetic `git` binary path.
|
||||
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
use xai_tty_utils::ProcessGroup;
|
||||
|
||||
const MAX_CAPTURE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
const READ_CHUNK_SIZE: usize = 8192;
|
||||
|
||||
/// Quiet lull ending the post-exit drain (backgrounded grandchildren may hold
|
||||
/// the pipes open).
|
||||
const POST_EXIT_QUIET: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Hard cap on the whole post-exit drain (`POST_EXIT_QUIET` is per-chunk, so a
|
||||
/// grandchild that keeps writing could otherwise reach the deadline).
|
||||
const POST_EXIT_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Grace between SIGTERM and SIGKILL when tearing down a timed-out process
|
||||
/// group, so a signal-aware child can exit cleanly before it is force-killed.
|
||||
const TERM_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Resolve the `git` binary: `GIT_BIN_PATH` (Bazel's hermetic-git data dep;
|
||||
/// runfiles-relative, so resolved against the cwd) or bare `git` on `PATH`.
|
||||
pub(crate) fn git_bin() -> OsString {
|
||||
let Some(raw) = env::var_os("GIT_BIN_PATH") else {
|
||||
return OsString::from("git");
|
||||
};
|
||||
let path = PathBuf::from(&raw);
|
||||
if path.is_relative() {
|
||||
match env::current_dir() {
|
||||
Ok(cwd) => cwd.join(&path).into_os_string(),
|
||||
Err(_) => raw,
|
||||
}
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/// A `sh -c <script>` command: the portable shell escape hatch shared by the
|
||||
/// identity and auth providers.
|
||||
pub(crate) fn sh_c(script: &str) -> Command {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", script]);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Whether the command text may appear in spawn-failure/timeout logs.
|
||||
/// `Redacted` (the safe default) keeps it out for commands whose text may
|
||||
/// embed secrets; `Shown` includes it for diagnostics.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum CommandLog<'a> {
|
||||
Redacted,
|
||||
Shown(&'a str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommandLog<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match *self {
|
||||
Self::Shown(cmd) => f.write_str(cmd),
|
||||
Self::Redacted => f.write_str("<redacted>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RunOptions<'a> {
|
||||
pub label: &'a str,
|
||||
pub command_log: CommandLog<'a>,
|
||||
}
|
||||
|
||||
/// Why [`run_detached_with_timeout`] produced no `Output`. A nonzero exit is
|
||||
/// not one of these; it comes back as `Ok`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RunError {
|
||||
SpawnFailed,
|
||||
TimedOut,
|
||||
WaitFailed,
|
||||
}
|
||||
|
||||
/// Run `cmd` detached from the TTY with a wall-clock `timeout`, killing the
|
||||
/// whole process group on timeout and capping capture per stream. A nonzero
|
||||
/// exit comes back as `Ok`; [`RunError`] covers the no-output cases.
|
||||
pub(crate) async fn run_detached_with_timeout(
|
||||
mut cmd: Command,
|
||||
timeout: Duration,
|
||||
opts: RunOptions<'_>,
|
||||
) -> Result<Output, RunError> {
|
||||
let RunOptions { label, command_log } = opts;
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
// Pipe stderr; inherit would corrupt the TUI alternate screen.
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
xai_grok_tools::util::detach_command(&mut cmd);
|
||||
cmd.envs(xai_grok_tools::util::pager_env());
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
warn!(error = %e, label, cmd = %command_log, "failed to start the command");
|
||||
return Err(RunError::SpawnFailed);
|
||||
}
|
||||
};
|
||||
|
||||
// Enroll the child's process group so a timeout kill reaches grandchildren;
|
||||
// on failure only the direct child is killed.
|
||||
let group = ProcessGroup::new()
|
||||
.and_then(|mut group| group.attach(&child).map(|()| group))
|
||||
.inspect_err(|e| {
|
||||
debug!(error = %e, label, "process-group enrollment failed; a timeout kill covers the direct child only");
|
||||
})
|
||||
.ok();
|
||||
|
||||
let stdout_rx = child.stdout.take().map(spawn_pipe_reader);
|
||||
let stderr_rx = child.stderr.take().map(spawn_pipe_reader);
|
||||
|
||||
let status = match tokio::time::timeout(timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => status,
|
||||
Ok(Err(e)) => {
|
||||
warn!(error = %e, label, "error while waiting for the command");
|
||||
terminate_child(&mut child, group.as_ref()).await;
|
||||
return Err(RunError::WaitFailed);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
warn!(
|
||||
label,
|
||||
cmd = %command_log,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"command timed out; terminating"
|
||||
);
|
||||
terminate_child(&mut child, group.as_ref()).await;
|
||||
return Err(RunError::TimedOut);
|
||||
}
|
||||
};
|
||||
|
||||
// Both streams drain concurrently under one budget measured fresh from the
|
||||
// child's exit, so a near-deadline exit still captures its buffered output
|
||||
// in full.
|
||||
let drain_deadline = Instant::now() + POST_EXIT_BUDGET;
|
||||
let (stdout, stderr) = tokio::join!(
|
||||
drain_reader(stdout_rx, drain_deadline),
|
||||
drain_reader(stderr_rx, drain_deadline),
|
||||
);
|
||||
if !status.success() {
|
||||
// Debug, not warn: some callers run secret-bearing commands (auth
|
||||
// tokens, resolved identities), so stderr stays out of routine logs;
|
||||
// callers that need louder reporting inspect the returned stderr.
|
||||
debug!(status = %status, label, "command exited with a nonzero status");
|
||||
}
|
||||
Ok(Output {
|
||||
status,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tear down a timed-out or errored run: SIGTERM the process group, wait
|
||||
/// [`TERM_GRACE`], then SIGKILL survivors. Always reaps the direct child too,
|
||||
/// so an absent group signal can't leave it unwaited.
|
||||
async fn terminate_child(child: &mut Child, group: Option<&ProcessGroup>) {
|
||||
if let Some(group) = group {
|
||||
let _ = group.terminate();
|
||||
let _ = tokio::time::timeout(TERM_GRACE, child.wait()).await;
|
||||
let _ = group.kill();
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
// Bounded reap: a wedged (uninterruptible) child must not park the runner;
|
||||
// `kill_on_drop` reaps it later if this wait gives up.
|
||||
let _ = tokio::time::timeout(TERM_GRACE, child.wait()).await;
|
||||
}
|
||||
|
||||
/// Stream a child pipe over an unbounded channel, capped at
|
||||
/// [`MAX_CAPTURE_BYTES`]; a closed receiver (the drain gave up) stops the
|
||||
/// reader so it never parks in `read()` forever.
|
||||
fn spawn_pipe_reader<R>(mut pipe: R) -> mpsc::UnboundedReceiver<Vec<u8>>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
let mut sent = 0usize;
|
||||
let mut chunk = [0u8; READ_CHUNK_SIZE];
|
||||
loop {
|
||||
let n = tokio::select! {
|
||||
biased;
|
||||
() = tx.closed() => break,
|
||||
result = pipe.read(&mut chunk) => match result {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => n,
|
||||
},
|
||||
};
|
||||
let keep = n.min(MAX_CAPTURE_BYTES.saturating_sub(sent));
|
||||
if keep > 0 {
|
||||
if tx.send(chunk[..keep].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
sent += keep;
|
||||
}
|
||||
// Past the cap: keep reading to drain the pipe (never block the child),
|
||||
// discarding the excess.
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
async fn drain_reader(
|
||||
rx: Option<mpsc::UnboundedReceiver<Vec<u8>>>,
|
||||
drain_deadline: Instant,
|
||||
) -> Vec<u8> {
|
||||
let Some(mut rx) = rx else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let Some(remaining) = drain_deadline.checked_duration_since(Instant::now()) else {
|
||||
break;
|
||||
};
|
||||
let wait = POST_EXIT_QUIET.min(remaining);
|
||||
match tokio::time::timeout(wait, rx.recv()).await {
|
||||
Ok(Some(chunk)) => buf.extend_from_slice(&chunk),
|
||||
// A closed channel is EOF; an elapsed wait is a quiet lull (a
|
||||
// grandchild may still hold the pipe).
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str) -> Command {
|
||||
sh_c(script)
|
||||
}
|
||||
|
||||
fn opts(label: &str) -> RunOptions<'_> {
|
||||
RunOptions {
|
||||
label,
|
||||
command_log: CommandLog::Redacted,
|
||||
}
|
||||
}
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_stderr_is_streamed_and_capped() {
|
||||
let out = run_detached_with_timeout(
|
||||
sh("yes 0123456789abcdef | head -c 2097152 >&2; echo done"),
|
||||
TIMEOUT,
|
||||
opts("test large stderr"),
|
||||
)
|
||||
.await
|
||||
.expect("must complete without hitting the timeout");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "done");
|
||||
assert_eq!(out.stderr.len(), MAX_CAPTURE_BYTES);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backgrounded_grandchild_does_not_block_the_drain() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("sleep 5 & echo hi"),
|
||||
TIMEOUT,
|
||||
opts("test grandchild drain"),
|
||||
)
|
||||
.await
|
||||
.expect("must return promptly, not at the grandchild's exit");
|
||||
assert!(out.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(3),
|
||||
"drain must not wait for the grandchild (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonzero_exit_returns_output_to_caller() {
|
||||
let out = run_detached_with_timeout(
|
||||
sh("echo partial; echo diagnostics >&2; exit 3"),
|
||||
TIMEOUT,
|
||||
opts("test nonzero"),
|
||||
)
|
||||
.await
|
||||
.expect("nonzero exit is the caller's call, not a runner failure");
|
||||
assert_eq!(out.status.code(), Some(3));
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "partial");
|
||||
assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "diagnostics");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_with_grandchild_reports_timeout_promptly() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("sleep 30 & sleep 30"),
|
||||
Duration::from_secs(1),
|
||||
opts("test group kill"),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(out, Err(RunError::TimedOut)));
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"group kill must not wait for the grandchild (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_escalates_past_a_sigterm_trap() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("trap '' TERM; while :; do sleep 0.2; done"),
|
||||
Duration::from_secs(1),
|
||||
opts("test sigterm trap"),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(out, Err(RunError::TimedOut)));
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"escalation must bound teardown of a SIGTERM-ignoring child (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_failure_reports_spawn_error() {
|
||||
let cmd = Command::new("/nonexistent/grok-test-binary");
|
||||
let out = run_detached_with_timeout(cmd, TIMEOUT, opts("test spawn failure")).await;
|
||||
assert!(matches!(out, Err(RunError::SpawnFailed)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_exit_budget_is_shared_across_streams() {
|
||||
let start = Instant::now();
|
||||
let out = run_detached_with_timeout(
|
||||
sh("(i=0; while [ $i -lt 100 ]; do echo out; echo err >&2; sleep 0.1; i=$((i+1)); done) & echo hi"),
|
||||
TIMEOUT,
|
||||
opts("test shared drain budget"),
|
||||
)
|
||||
.await
|
||||
.expect("must return at the shared budget, not the timeout");
|
||||
assert!(out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains("hi"));
|
||||
assert!(
|
||||
start.elapsed() < POST_EXIT_BUDGET + Duration::from_millis(1500),
|
||||
"drain must be bounded by ONE shared budget (took {:?})",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
904
crates/codegen/xai-grok-shell/src/util/user_identity.rs
Normal file
904
crates/codegen/xai-grok-shell/src/util/user_identity.rs
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
//! Resolves and caches the author identity for feedback submissions
|
||||
//! ([`crate::agent::config::FeedbackUserConfig`]).
|
||||
|
||||
use crate::agent::config::FeedbackUserConfig;
|
||||
use crate::util::subprocess::CommandLog;
|
||||
use crate::util::subprocess::RunOptions;
|
||||
use crate::util::subprocess::git_bin;
|
||||
use crate::util::subprocess::run_detached_with_timeout;
|
||||
use crate::util::subprocess::sh_c;
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::warn;
|
||||
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
const MAX_VALUE_LEN: usize = 256;
|
||||
|
||||
/// Incomplete resolutions retry after this; complete ones never expire.
|
||||
const INCOMPLETE_RESULT_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedUserIdentity {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl ResolvedUserIdentity {
|
||||
fn slot(&self, field: IdentityField) -> &Option<String> {
|
||||
match field {
|
||||
IdentityField::Name => &self.name,
|
||||
IdentityField::Email => &self.email,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot_mut(&mut self, field: IdentityField) -> &mut Option<String> {
|
||||
match field {
|
||||
IdentityField::Name => &mut self.name,
|
||||
IdentityField::Email => &mut self.email,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn is_empty(&self) -> bool {
|
||||
self.name.is_none() && self.email.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct IdentityCommandOutput {
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
/// Trims and bounds length ([`MAX_VALUE_LEN`]); the server handles
|
||||
/// character-level sanitization.
|
||||
fn normalize_value(s: impl AsRef<str>) -> Option<String> {
|
||||
let trimmed = s.as_ref().trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if trimmed.len() > MAX_VALUE_LEN {
|
||||
warn!(
|
||||
len = trimmed.len(),
|
||||
"feedback identity: resolved value exceeds the length limit; ignoring it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
|
||||
fn env_value(var: &str) -> Option<String> {
|
||||
env::var(var).ok().and_then(normalize_value)
|
||||
}
|
||||
|
||||
/// A literal source value (usually `$VAR` / `${VAR}` already expanded at load).
|
||||
/// A residual `$` is skipped as a likely-unset variable. This over-rejects a
|
||||
/// rare genuine `$` so an unset variable is never emitted.
|
||||
fn literal_value(value: &str) -> Option<String> {
|
||||
let value = normalize_value(value)?;
|
||||
if value.contains('$') {
|
||||
warn!("feedback identity: ignoring a source value with an unresolved variable");
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn os_user() -> Option<String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SUDO_USER can appear in an ordinary shell; trust it only when the
|
||||
// effective user ID is root.
|
||||
if nix::unistd::geteuid().is_root()
|
||||
&& let Some(user) = env_value("SUDO_USER")
|
||||
{
|
||||
return Some(user);
|
||||
}
|
||||
if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid())
|
||||
&& let Some(name) = normalize_value(&user.name)
|
||||
{
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
env_value("USER")
|
||||
.or_else(|| env_value("LOGNAME"))
|
||||
.or_else(|| env_value("USERNAME"))
|
||||
}
|
||||
|
||||
/// Runs `os_user` on a blocking thread with a timeout: the user lookup can
|
||||
/// block (a directory service) and runs under the cache lock, so a stall must
|
||||
/// not hold up feedback submissions. On timeout we stop waiting; the thread
|
||||
/// finishes in the background holding no lock, and the pool reclaims it.
|
||||
async fn resolve_os_user() -> Option<String> {
|
||||
match tokio::time::timeout(COMMAND_TIMEOUT, spawn_blocking(os_user)).await {
|
||||
Ok(Ok(name)) => name,
|
||||
Ok(Err(join_error)) => {
|
||||
warn!(error = %join_error, "feedback identity: os_user lookup task failed");
|
||||
None
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
warn!("feedback identity: os_user lookup timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn git_global_email() -> Option<String> {
|
||||
let mut cmd = Command::new(git_bin());
|
||||
// `--global` only: a repo-local `.git/config` in a cloned repo could
|
||||
// otherwise supply an attacker-controlled email.
|
||||
cmd.args(["config", "--global", "user.email"]);
|
||||
let output = run_detached_with_timeout(
|
||||
cmd,
|
||||
COMMAND_TIMEOUT,
|
||||
RunOptions {
|
||||
label: "git config --global user.email",
|
||||
command_log: CommandLog::Redacted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
normalize_value(String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// `None` on any failure; callers fall back to the declarative sources.
|
||||
async fn run_identity_command(command: &str) -> Option<ResolvedUserIdentity> {
|
||||
let cmd = sh_c(command);
|
||||
let output = run_detached_with_timeout(
|
||||
cmd,
|
||||
COMMAND_TIMEOUT,
|
||||
RunOptions {
|
||||
label: "feedback identity command",
|
||||
command_log: CommandLog::Redacted,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match serde_json::from_str::<IdentityCommandOutput>(stdout.trim()) {
|
||||
Ok(parsed) => Some(ResolvedUserIdentity {
|
||||
name: parsed.name.and_then(normalize_value),
|
||||
// Require the same address shape as the declarative sources.
|
||||
email: parsed
|
||||
.email
|
||||
.and_then(normalize_value)
|
||||
.filter(|email| IdentityField::Email.accepts(email)),
|
||||
}),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "feedback identity: command output is not the expected JSON");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SourceToken {
|
||||
OsUser,
|
||||
GitEmail,
|
||||
/// A literal value, usually produced by `$VAR` / `${VAR}` expansion at
|
||||
/// config load; used as-is, subject to per-field validation.
|
||||
Literal(String),
|
||||
}
|
||||
|
||||
impl SourceToken {
|
||||
fn parse(input: &str) -> Option<Self> {
|
||||
match input.trim() {
|
||||
"" => None,
|
||||
"os_user" => Some(Self::OsUser),
|
||||
"git_email" => Some(Self::GitEmail),
|
||||
other => Some(Self::Literal(other.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A log-safe label: keyword tokens by name, literals redacted so a
|
||||
/// configured literal value never reaches the logs.
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OsUser => "os_user",
|
||||
Self::GitEmail => "git_email",
|
||||
Self::Literal(_) => "<literal>",
|
||||
}
|
||||
}
|
||||
|
||||
/// The one field this token may populate, or `None` if it works for any.
|
||||
fn restricted_field(&self) -> Option<IdentityField> {
|
||||
match self {
|
||||
Self::GitEmail => Some(IdentityField::Email),
|
||||
Self::OsUser | Self::Literal(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::OsUser => resolve_os_user().await,
|
||||
Self::GitEmail => git_global_email().await,
|
||||
Self::Literal(value) => literal_value(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
|
||||
enum IdentityField {
|
||||
Name,
|
||||
Email,
|
||||
}
|
||||
|
||||
impl IdentityField {
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Name => "name",
|
||||
Self::Email => "email",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a resolved value is valid for this field. Emails must contain
|
||||
/// `@`; names have no shape requirement.
|
||||
fn accepts(self, value: &str) -> bool {
|
||||
match self {
|
||||
Self::Name => true,
|
||||
Self::Email => value.contains('@'),
|
||||
}
|
||||
}
|
||||
|
||||
fn sources(self, cfg: &FeedbackUserConfig) -> &[String] {
|
||||
match self {
|
||||
Self::Name => &cfg.name,
|
||||
Self::Email => &cfg.email,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the config declares a source that could still fill this field
|
||||
/// later. `email_domain` is excluded: a derived `<name>@<domain>` depends
|
||||
/// only on the resolved name, so it won't change on retry.
|
||||
fn can_produce(self, cfg: &FeedbackUserConfig) -> bool {
|
||||
!self.sources(cfg).is_empty() || cfg.command.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The first resolved source wins; misplaced or invalid values are logged and skipped.
|
||||
async fn resolve_sources(entries: &[String], field: IdentityField) -> Option<String> {
|
||||
for entry in entries {
|
||||
let Some(token) = SourceToken::parse(entry) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(restricted_field) = token.restricted_field()
|
||||
&& restricted_field != field
|
||||
{
|
||||
warn!(
|
||||
token = %token.label(),
|
||||
field = field.key(),
|
||||
"feedback identity: ignoring a source token that only applies to the `{}` list",
|
||||
restricted_field.key()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = token.resolve().await {
|
||||
// Skip a value invalid for this field, such as a bare username in
|
||||
// the email list.
|
||||
if !field.accepts(&value) {
|
||||
warn!(
|
||||
token = %token.label(),
|
||||
field = field.key(),
|
||||
"feedback identity: ignoring a value that is not valid for this field"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds `<name>@<domain>`, used only after every declared email source fails.
|
||||
fn derive_email(name: &str, email_domain: Option<&str>) -> Option<String> {
|
||||
if name.contains('@') || name.contains(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
// Tolerate the admin typo `email_domain = "@example.com"`.
|
||||
let domain = email_domain?.trim();
|
||||
let domain = domain.strip_prefix('@').unwrap_or(domain);
|
||||
if domain.is_empty()
|
||||
|| domain.contains('@')
|
||||
// Reject an unset `$VAR` left verbatim by config-load expansion (matches
|
||||
// `literal_value`), so a derived address never embeds an unresolved var.
|
||||
|| domain.contains('$')
|
||||
|| domain.contains(char::is_whitespace)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
normalize_value(format!("{name}@{domain}"))
|
||||
}
|
||||
|
||||
/// May run subprocesses; production callers go through [`cached_identity`].
|
||||
async fn resolve_identity(cfg: &FeedbackUserConfig) -> ResolvedUserIdentity {
|
||||
// The command is admin-provided config: trimmed only, never length-checked,
|
||||
// so long or multi-line commands still run. Its output is bounded below.
|
||||
let mut identity = match cfg
|
||||
.command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|command| !command.is_empty())
|
||||
{
|
||||
Some(command) => run_identity_command(command).await.unwrap_or_default(),
|
||||
None => ResolvedUserIdentity::default(),
|
||||
};
|
||||
|
||||
for field in IdentityField::iter() {
|
||||
if identity.slot(field).is_none() {
|
||||
let resolved = resolve_sources(field.sources(cfg), field).await;
|
||||
*identity.slot_mut(field) = resolved;
|
||||
}
|
||||
}
|
||||
if identity.email.is_none()
|
||||
&& let Some(name) = identity.name.as_deref()
|
||||
{
|
||||
identity.email = derive_email(name, cfg.email_domain.as_deref());
|
||||
}
|
||||
identity
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
cfg: FeedbackUserConfig,
|
||||
identity: ResolvedUserIdentity,
|
||||
resolved_at: Instant,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
/// Whether every field this config can populate has a value. Complete
|
||||
/// entries never expire; incomplete ones are retried after
|
||||
/// [`INCOMPLETE_RESULT_TTL`] so a source that wasn't ready (git, a slow
|
||||
/// command) can fill in later.
|
||||
fn is_complete(&self) -> bool {
|
||||
IdentityField::iter()
|
||||
.all(|field| self.identity.slot(field).is_some() || !field.can_produce(&self.cfg))
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-slot cache keyed on the config; incomplete results expire after
|
||||
/// [`INCOMPLETE_RESULT_TTL`].
|
||||
pub(crate) struct IdentityCache {
|
||||
slot: Mutex<Option<CacheEntry>>,
|
||||
}
|
||||
|
||||
impl IdentityCache {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {
|
||||
slot: Mutex::const_new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the lock across resolution so concurrent submissions run a slow
|
||||
/// command once, not once each. The tradeoff: submissions serialize, and a
|
||||
/// first-time resolution can take up to the combined source timeouts. The
|
||||
/// pre-warm at session spawn keeps this off the interactive path.
|
||||
pub(crate) async fn get(
|
||||
&self,
|
||||
cfg: Option<&FeedbackUserConfig>,
|
||||
) -> Option<ResolvedUserIdentity> {
|
||||
self.get_at(cfg, Instant::now()).await
|
||||
}
|
||||
|
||||
async fn get_at(
|
||||
&self,
|
||||
cfg: Option<&FeedbackUserConfig>,
|
||||
now: Instant,
|
||||
) -> Option<ResolvedUserIdentity> {
|
||||
let cfg = cfg?;
|
||||
let mut slot = self.slot.lock().await;
|
||||
if let Some(entry) = slot.as_ref()
|
||||
&& entry.cfg == *cfg
|
||||
&& (entry.is_complete()
|
||||
|| now.saturating_duration_since(entry.resolved_at) < INCOMPLETE_RESULT_TTL)
|
||||
{
|
||||
return Some(entry.identity.clone());
|
||||
}
|
||||
let mut identity = resolve_identity(cfg).await;
|
||||
// A same-config re-resolution is a retry to fill missing fields, not to
|
||||
// drop resolved ones: keep any field a prior attempt resolved when this
|
||||
// attempt's source transiently fails (an `os_user` stall, a flaky git
|
||||
// or command).
|
||||
if let Some(prev) = slot.as_ref().filter(|entry| entry.cfg == *cfg) {
|
||||
for field in IdentityField::iter() {
|
||||
if identity.slot(field).is_none()
|
||||
&& let Some(value) = prev.identity.slot(field)
|
||||
{
|
||||
*identity.slot_mut(field) = Some(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
*slot = Some(CacheEntry {
|
||||
cfg: cfg.clone(),
|
||||
identity: identity.clone(),
|
||||
resolved_at: now,
|
||||
});
|
||||
Some(identity)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for IdentityCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
static PROCESS_CACHE: IdentityCache = IdentityCache::new();
|
||||
|
||||
pub(crate) async fn cached_identity(
|
||||
cfg: Option<&FeedbackUserConfig>,
|
||||
) -> Option<ResolvedUserIdentity> {
|
||||
PROCESS_CACHE.get(cfg).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use xai_grok_test_support::env::EnvGuard;
|
||||
|
||||
fn cfg() -> FeedbackUserConfig {
|
||||
FeedbackUserConfig::default()
|
||||
}
|
||||
|
||||
/// Runs the async resolver on a throwaway current-thread runtime so unit
|
||||
/// tests can stay synchronous.
|
||||
fn resolve(cfg: FeedbackUserConfig) -> ResolvedUserIdentity {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(resolve_identity(&cfg))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn os_user_resolves_to_current_user() {
|
||||
let _no_sudo = EnvGuard::unset("SUDO_USER");
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["os_user".into()],
|
||||
..cfg()
|
||||
});
|
||||
let name = resolved.name.expect("os_user must resolve on CI/dev");
|
||||
assert!(!name.trim().is_empty());
|
||||
assert!(resolved.email.is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn os_user_ignores_sudo_user_when_not_root() {
|
||||
if nix::unistd::geteuid().is_root() {
|
||||
// Under root the SUDO_USER branch is the correct one.
|
||||
return;
|
||||
}
|
||||
let _sudo = EnvGuard::set("SUDO_USER", "realuser");
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["os_user".into()],
|
||||
..cfg()
|
||||
});
|
||||
let name = resolved.name.expect("os_user must still resolve");
|
||||
assert_ne!(
|
||||
name, "realuser",
|
||||
"a lingering SUDO_USER must not be trusted without a root euid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_resolvable_literal_wins() {
|
||||
// Empty, unexpanded-`$`, and oversized entries are skipped; the first
|
||||
// usable, trimmed value wins.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec![
|
||||
"".into(),
|
||||
"$UNSET".into(),
|
||||
"x".repeat(300),
|
||||
" alice ".into(),
|
||||
],
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name.as_deref(), Some("alice"));
|
||||
|
||||
// A list with no usable entry resolves to nothing.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["x".repeat(300)],
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn git_email_resolves_from_global_git_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let gitconfig = dir.path().join("gitconfig");
|
||||
std::fs::write(&gitconfig, "[user]\n\temail = admin@example.com\n").unwrap();
|
||||
let _global = EnvGuard::set("GIT_CONFIG_GLOBAL", &gitconfig);
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
email: vec!["git_email".into()],
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email.as_deref(), Some("admin@example.com"));
|
||||
assert_eq!(resolved.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn git_email_is_invalid_in_name_list() {
|
||||
// Point git at a resolvable config so the rejection can't pass vacuously.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let gitconfig = dir.path().join("gitconfig");
|
||||
std::fs::write(&gitconfig, "[user]\n\temail = admin@example.com\n").unwrap();
|
||||
let _global = EnvGuard::set("GIT_CONFIG_GLOBAL", &gitconfig);
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["git_email".into()],
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_address_value_is_rejected_from_the_email_list() {
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
email: vec!["alice".into()],
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_domain_derives_only_when_email_sources_fail() {
|
||||
// No usable email source: derive from name@domain.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
email: vec!["$UNSET_EMAIL".into()],
|
||||
email_domain: Some("example.com".into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email.as_deref(), Some("carol@example.com"));
|
||||
|
||||
// A resolved email source wins; domain derivation must not override it.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
email: vec!["carol@corp.example".into()],
|
||||
email_domain: Some("example.com".into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email.as_deref(), Some("carol@corp.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_domain_derivation_guards() {
|
||||
// No username at all: nothing to derive from.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
email_domain: Some("example.com".into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email, None);
|
||||
assert!(resolved.is_empty());
|
||||
|
||||
// Leading `@` in the domain (admin typo) is tolerated.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
email_domain: Some("@example.com".into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email.as_deref(), Some("carol@example.com"));
|
||||
|
||||
// A username containing `@` or whitespace derives nothing.
|
||||
for bad_user in ["carol@corp", "carol jones"] {
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec![bad_user.into()],
|
||||
email_domain: Some("example.com".into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email, None, "name {bad_user:?}");
|
||||
}
|
||||
|
||||
// A domain with whitespace, a non-leading `@`, or an unresolved `$VAR`
|
||||
// derives nothing.
|
||||
for bad_domain in ["example com", "corp@example.com", "@@example.com", "$CORP"] {
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
email_domain: Some(bad_domain.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email, None, "email_domain {bad_domain:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-line and >[`MAX_VALUE_LEN`]-byte commands must reach the shell,
|
||||
/// not be rejected for exceeding the length limit.
|
||||
#[test]
|
||||
fn multi_line_and_long_commands_still_run() {
|
||||
let multi_line = "true\necho '{\"name\": \"multi-line\"}'";
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
command: Some(multi_line.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name.as_deref(), Some("multi-line"));
|
||||
|
||||
let long = format!("{}echo '{{\"name\": \"long-cmd\"}}'", "true && ".repeat(40));
|
||||
assert!(long.len() > MAX_VALUE_LEN);
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
command: Some(long),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name.as_deref(), Some("long-cmd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_wins_then_falls_back_per_field() {
|
||||
// A command's emitted fields win over the declarative sources.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["os_user".into()],
|
||||
command: Some(r#"echo '{"name": "from-cmd", "email": "cmd@example.com"}'"#.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name.as_deref(), Some("from-cmd"));
|
||||
assert_eq!(resolved.email.as_deref(), Some("cmd@example.com"));
|
||||
|
||||
// A field the command omits falls back to the declarative source.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
email: vec!["fallback@example.com".into()],
|
||||
command: Some(r#"echo '{"name": "cmd-user"}'"#.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.name.as_deref(), Some("cmd-user"));
|
||||
assert_eq!(resolved.email.as_deref(), Some("fallback@example.com"));
|
||||
|
||||
// A command-emitted email without `@` is rejected like a literal one, so
|
||||
// resolution falls back to the declarative email source.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
email: vec!["fallback@example.com".into()],
|
||||
command: Some(r#"echo '{"name": "cmd-user", "email": "not-an-email"}'"#.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(resolved.email.as_deref(), Some("fallback@example.com"));
|
||||
|
||||
// Whitespace-only command fields count as unresolved too.
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
command: Some(r#"echo '{"name": " ", "email": ""}'"#.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_failure_falls_back_to_declarative_sources() {
|
||||
for command in [
|
||||
"/nonexistent/grok-identity-binary", // command not found: sh exits 127
|
||||
"exit 3", // nonzero exit
|
||||
"echo not-json", // unparseable stdout
|
||||
] {
|
||||
let resolved = resolve(FeedbackUserConfig {
|
||||
name: vec!["decl-user".into()],
|
||||
command: Some(command.into()),
|
||||
..cfg()
|
||||
});
|
||||
assert_eq!(
|
||||
resolved.name.as_deref(),
|
||||
Some("decl-user"),
|
||||
"command {command:?} must fall back to declarative sources"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_resolves_once_per_config_and_reresolves_on_change() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let counter = dir.path().join("runs");
|
||||
let command = format!(
|
||||
"echo run >> {}; echo '{{\"name\": \"cached-user\"}}'",
|
||||
counter.display()
|
||||
);
|
||||
let cache = IdentityCache::new();
|
||||
|
||||
assert_eq!(cache.get(None).await, None, "no opt-in, no identity");
|
||||
|
||||
let cfg1 = FeedbackUserConfig {
|
||||
command: Some(command.clone()),
|
||||
..cfg()
|
||||
};
|
||||
let first = cache.get(Some(&cfg1)).await.unwrap();
|
||||
assert_eq!(first.name.as_deref(), Some("cached-user"));
|
||||
let second = cache.get(Some(&cfg1)).await.unwrap();
|
||||
assert_eq!(second, first);
|
||||
let runs = std::fs::read_to_string(&counter).unwrap();
|
||||
assert_eq!(runs.lines().count(), 1, "same config must resolve once");
|
||||
|
||||
let cfg2 = FeedbackUserConfig {
|
||||
command: Some(command),
|
||||
email_domain: Some("example.com".into()),
|
||||
..cfg()
|
||||
};
|
||||
let third = cache.get(Some(&cfg2)).await.unwrap();
|
||||
assert_eq!(third.email.as_deref(), Some("cached-user@example.com"));
|
||||
let runs = std::fs::read_to_string(&counter).unwrap();
|
||||
assert_eq!(runs.lines().count(), 2, "changed config must re-resolve");
|
||||
}
|
||||
|
||||
/// Command emitting `first_json` on its first run and `then_json` after,
|
||||
/// appending one line per run to the returned counter file.
|
||||
fn first_then_command(
|
||||
dir: &std::path::Path,
|
||||
first_json: &str,
|
||||
then_json: &str,
|
||||
) -> (String, std::path::PathBuf) {
|
||||
let counter = dir.join("runs");
|
||||
let marker = dir.join("ready");
|
||||
let command = format!(
|
||||
"echo run >> {c}; if [ -f {p} ]; then echo '{then_json}'; else touch {p}; echo '{first_json}'; fi",
|
||||
c = counter.display(),
|
||||
p = marker.display()
|
||||
);
|
||||
(command, counter)
|
||||
}
|
||||
|
||||
fn run_count(counter: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(counter).unwrap().lines().count()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_incomplete_results_expire_after_ttl() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// First run resolves only the username; later runs resolve both.
|
||||
let (command, counter) = first_then_command(
|
||||
dir.path(),
|
||||
r#"{"name": "u1"}"#,
|
||||
r#"{"name": "u1", "email": "u1@example.com"}"#,
|
||||
);
|
||||
let cache = IdentityCache::new();
|
||||
let cfg1 = FeedbackUserConfig {
|
||||
command: Some(command),
|
||||
..cfg()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let first = cache.get_at(Some(&cfg1), t0).await.unwrap();
|
||||
assert_eq!(first.name.as_deref(), Some("u1"));
|
||||
assert_eq!(first.email, None, "first run leaves email unresolved");
|
||||
|
||||
let second = cache
|
||||
.get_at(Some(&cfg1), t0 + Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second, first);
|
||||
assert_eq!(
|
||||
run_count(&counter),
|
||||
1,
|
||||
"incomplete result cached within TTL"
|
||||
);
|
||||
|
||||
let third = cache
|
||||
.get_at(
|
||||
Some(&cfg1),
|
||||
t0 + INCOMPLETE_RESULT_TTL + Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
third.email.as_deref(),
|
||||
Some("u1@example.com"),
|
||||
"an incomplete resolution must expire, not pin the unresolved field"
|
||||
);
|
||||
assert_eq!(run_count(&counter), 2);
|
||||
|
||||
let fourth = cache
|
||||
.get_at(
|
||||
Some(&cfg1),
|
||||
t0 + INCOMPLETE_RESULT_TTL * 3 + Duration::from_secs(2),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fourth, third);
|
||||
assert_eq!(run_count(&counter), 2, "complete results do not expire");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_retry_keeps_a_previously_resolved_field() {
|
||||
// First run resolves only the name; the post-TTL retry resolves only the
|
||||
// email. The retry must not drop the name the first run already resolved.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (command, counter) = first_then_command(
|
||||
dir.path(),
|
||||
r#"{"name": "u1"}"#,
|
||||
r#"{"email": "u1@example.com"}"#,
|
||||
);
|
||||
let cache = IdentityCache::new();
|
||||
let cfg = FeedbackUserConfig {
|
||||
command: Some(command),
|
||||
..cfg()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let first = cache.get_at(Some(&cfg), t0).await.unwrap();
|
||||
assert_eq!(first.name.as_deref(), Some("u1"));
|
||||
assert_eq!(first.email, None);
|
||||
|
||||
let second = cache
|
||||
.get_at(
|
||||
Some(&cfg),
|
||||
t0 + INCOMPLETE_RESULT_TTL + Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second.name.as_deref(),
|
||||
Some("u1"),
|
||||
"a transient retry failure must not drop the already-resolved name"
|
||||
);
|
||||
assert_eq!(second.email.as_deref(), Some("u1@example.com"));
|
||||
assert_eq!(run_count(&counter), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_only_config_settles_but_pending_email_source_stays_incomplete() {
|
||||
// A name-only config can never fill `email`, so a name-resolved entry is
|
||||
// complete and won't be retried on TTL.
|
||||
let complete = CacheEntry {
|
||||
cfg: FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
..cfg()
|
||||
},
|
||||
identity: ResolvedUserIdentity {
|
||||
name: Some("carol".into()),
|
||||
email: None,
|
||||
},
|
||||
resolved_at: Instant::now(),
|
||||
};
|
||||
assert!(complete.is_complete());
|
||||
|
||||
// With an email source declared but unresolved, the entry stays
|
||||
// incomplete and will be retried after the TTL.
|
||||
let incomplete = CacheEntry {
|
||||
cfg: FeedbackUserConfig {
|
||||
name: vec!["carol".into()],
|
||||
email: vec!["git_email".into()],
|
||||
..cfg()
|
||||
},
|
||||
identity: ResolvedUserIdentity {
|
||||
name: Some("carol".into()),
|
||||
email: None,
|
||||
},
|
||||
resolved_at: Instant::now(),
|
||||
};
|
||||
assert!(!incomplete.is_complete());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_single_flights_concurrent_resolution() {
|
||||
// Two concurrent submissions must run the slow command once, not once
|
||||
// each: the cache holds its lock across resolution so the work isn't
|
||||
// duplicated.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let counter = dir.path().join("runs");
|
||||
let command = format!(
|
||||
"echo run >> {}; sleep 0.2; echo '{{\"name\": \"once\", \"email\": \"once@example.com\"}}'",
|
||||
counter.display()
|
||||
);
|
||||
let cache = IdentityCache::new();
|
||||
let cfg = FeedbackUserConfig {
|
||||
command: Some(command),
|
||||
..cfg()
|
||||
};
|
||||
|
||||
let (first, second) = tokio::join!(cache.get(Some(&cfg)), cache.get(Some(&cfg)));
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first.name.as_deref(), Some("once"));
|
||||
assert_eq!(first, second.unwrap());
|
||||
assert_eq!(
|
||||
run_count(&counter),
|
||||
1,
|
||||
"concurrent submissions resolve once"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue