Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: report `/ready` as failed with dwell on hub connect failure
- Refresh OIDC token for the Grok agent in the shell
- ACP terminal output recorder
- Cross-platform provider auth commands in the shell
- Default `/resume` to Grok sessions with a hint for hidden external sessions
- Resume sessions by title with `--resume`
- Limit app-builder archive size
- Data-driven tag labels for slash commands
- Doctor fixes for tmux
- Custom provider gateways and subprocess environment policy in the shell
- `/tutorial` — opt-in onboarding tour of Grok Build
- Soft and required CLI version checks in the shell
- Privacy banner env overrides survive live settings updates
- Add remote flag to override the image-edit model
- Return profile fields from auth info even when the access token is expired
- Add edit control on queued prompt rows
- Keep fail-closed policy when clearing orphans with no team
- Setting to disable the Ctrl+Space/F8 voice shortcut
- Pass `--raw` to pw-record so Linux dictation works on older PipeWire
- Validate git URLs when adding marketplace entries
- Stop shipping stale tool-doc parameter and tool names
- Re-point dashboard attach after `/fork` only when the parent was attached
- Surface Grok Computer media-generation results as file-path chunks
- Clear web background-task tray on kill and keep the task description
- Show privacy upsell banner in agent view until acted on
- Add tools-server client callback surface
- Protect persistent global hook sources

Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -735,6 +735,9 @@ auto_update = true
"worktree_type",
"session_registry",
"minimum_version",
"maximum_version",
"required_minimum_version",
"required_maximum_version",
];
/// Assert that every `CliConfig` `Option<T>` field NOT in `present` is
/// absent from `table`.

View file

@ -40,6 +40,26 @@ pub fn resolve_search_tools_enabled(
)
}
/// Parse `[shell_environment_policy]` from the merged effective config, or `None`
/// when unset or unparseable (the child then inherits the full environment). This
/// is the authoritative parse; the `Config` field of the same name only feeds the
/// unrecognized-key scan.
pub fn resolve_shell_env_policy(
effective_cfg: Option<&TomlValue>,
) -> Option<xai_grok_tools::util::ShellEnvironmentPolicy> {
let value = effective_cfg?.get("shell_environment_policy")?.clone();
match value.try_into::<xai_grok_tools::util::ShellEnvironmentPolicy>() {
Ok(policy) => Some(policy),
Err(error) => {
tracing::warn!(
%error,
"failed to parse [shell_environment_policy]; inheriting the full environment"
);
None
}
}
}
/// Pure precedence for [`resolve_search_tools_enabled`] (tiers injected so it is
/// unit-testable without env/disk): requirement (org policy) wins outright — even
/// over the user `DISABLE_*` master kill-switch — then the master forces off,
@ -704,3 +724,42 @@ mod tests {
));
}
}
#[cfg(test)]
mod shell_env_policy_tests {
use super::*;
use xai_grok_tools::util::{EnvironmentVariablePattern, ShellEnvironmentPolicyInherit};
#[test]
fn resolve_shell_env_policy_absent_parsed_typo_and_typed_error() {
// Absent table → None (child inherits the full environment).
let empty: TomlValue = toml::from_str("").unwrap();
assert!(resolve_shell_env_policy(Some(&empty)).is_none());
assert!(resolve_shell_env_policy(None).is_none());
// A well-formed table parses through.
let cfg: TomlValue =
toml::from_str("[shell_environment_policy]\ninherit = \"core\"\nexclude = [\"FOO\"]\n")
.unwrap();
let policy = resolve_shell_env_policy(Some(&cfg)).expect("policy parses");
assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::Core);
assert_eq!(
policy.exclude,
vec![EnvironmentVariablePattern::new_case_insensitive("FOO")]
);
// An unknown sub-key is ignored; the known keys still apply (the
// load-time scan warns on the typo).
let typo: TomlValue =
toml::from_str("[shell_environment_policy]\ninherit = \"none\"\ninhert = \"core\"\n")
.unwrap();
let policy = resolve_shell_env_policy(Some(&typo)).expect("known keys still parse");
assert_eq!(policy.inherit, ShellEnvironmentPolicyInherit::None);
// A wrong-typed known key fails to parse → None (full environment,
// logged), not a spawn abort.
let bad: TomlValue =
toml::from_str("[shell_environment_policy]\nexclude = \"not-an-array\"\n").unwrap();
assert!(resolve_shell_env_policy(Some(&bad)).is_none());
}
}

View file

@ -1,3 +1,4 @@
use semver::Version;
use toml::Value as TomlValue;
/// Machine-readable channel name derived from the GCS stable pointer cache.
@ -28,110 +29,484 @@ pub fn channel_name_from_cache() -> Option<&'static str> {
})
}
/// Read the minimum-version floor from one TOML layer.
pub fn minimum_version_from_toml(root: &TomlValue) -> Option<String> {
root.get("cli")?
.get("minimum_version")?
.as_str()
.map(str::to_owned)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VersionKnob {
Minimum,
Maximum,
RequiredMinimum,
RequiredMaximum,
}
/// Semver-max across candidates. Fails closed on any unparseable input so a
/// typo in one layer can't silently disable enforcement.
pub fn pick_max_minimum_version(
candidates: &[&str],
) -> Result<Option<String>, (String, semver::Error)> {
let mut best: Option<semver::Version> = None;
for raw in candidates {
let parsed = semver::Version::parse(raw).map_err(|e| ((*raw).to_string(), e))?;
match best.as_ref() {
Some(cur) if cur >= &parsed => {}
_ => best = Some(parsed),
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Bound {
Floor,
Ceiling,
}
impl VersionKnob {
pub(crate) fn toml_key(self) -> &'static str {
match self {
VersionKnob::Minimum => "minimum_version",
VersionKnob::Maximum => "maximum_version",
VersionKnob::RequiredMinimum => "required_minimum_version",
VersionKnob::RequiredMaximum => "required_maximum_version",
}
}
Ok(best.map(|v| v.to_string()))
}
/// Effective `cli.minimum_version`: semver-max across all layers so managed
/// floors can't be lowered by user/project pins.
pub fn resolve_minimum_version() -> Result<Option<String>, (String, semver::Error)> {
let layers = match crate::config::ConfigLayers::load() {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "minimum_version: failed to load config layers");
return Ok(None);
pub(crate) fn env_var(self) -> &'static str {
match self {
VersionKnob::Minimum => "GROK_MINIMUM_VERSION",
VersionKnob::Maximum => "GROK_MAXIMUM_VERSION",
VersionKnob::RequiredMinimum => "GROK_REQUIRED_MINIMUM_VERSION",
VersionKnob::RequiredMaximum => "GROK_REQUIRED_MAXIMUM_VERSION",
}
};
resolve_minimum_version_from_layers(&layers)
}
fn bound(self) -> Bound {
match self {
VersionKnob::Minimum | VersionKnob::RequiredMinimum => Bound::Floor,
VersionKnob::Maximum | VersionKnob::RequiredMaximum => Bound::Ceiling,
}
}
}
/// Semver-max of `cli.minimum_version` across every layer (incl. the macOS MDM
/// floor) so a managed floor can't be lowered by a user/project pin. Split from
/// the disk load so the layer set can be injected in tests.
fn resolve_minimum_version_from_layers(
fn cli_version_from_toml(root: &TomlValue, key: &str) -> Option<String> {
root.get("cli")?.get(key)?.as_str().map(str::to_owned)
}
fn env_version(var: &str) -> Option<String> {
std::env::var(var).ok()
}
/// `cli.<key>` across the config layers. `managed_only` excludes the user's own
/// `config.toml` so a user-set bound can't count as organization policy.
fn version_candidates(
layers: &crate::config::ConfigLayers,
) -> Result<Option<String>, (String, semver::Error)> {
let candidates: Vec<String> = [
minimum_version_from_toml(&layers.system_managed),
minimum_version_from_toml(&layers.managed),
minimum_version_from_toml(&layers.user),
key: &str,
managed_only: bool,
) -> Vec<String> {
[
cli_version_from_toml(&layers.system_managed, key),
cli_version_from_toml(&layers.managed, key),
(!managed_only)
.then(|| cli_version_from_toml(&layers.user, key))
.flatten(),
layers
.user_requirements
.as_ref()
.and_then(minimum_version_from_toml),
.and_then(|l| cli_version_from_toml(l, key)),
layers
.system_requirements
.as_ref()
.and_then(minimum_version_from_toml),
.and_then(|l| cli_version_from_toml(l, key)),
layers
.mdm_requirements
.as_ref()
.and_then(minimum_version_from_toml),
.and_then(|l| cli_version_from_toml(l, key)),
]
.into_iter()
.flatten()
.collect();
.collect()
}
let refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
pick_max_minimum_version(&refs)
fn fold_bound(raws: Vec<String>, knob: VersionKnob) -> Option<Version> {
let mut best: Option<Version> = None;
for raw in raws {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
match Version::parse(trimmed) {
Ok(v) => {
best = Some(match (best, knob.bound()) {
(None, _) => v,
(Some(cur), Bound::Floor) => cur.max(v),
(Some(cur), Bound::Ceiling) => cur.min(v),
});
}
Err(source) => tracing::warn!(
knob = knob.toml_key(),
value = %trimmed,
error = %source,
"ignoring invalid version bound"
),
}
}
best
}
/// Env joins the same extreme as the layers, so it can only tighten a managed bound.
fn resolve_version_bound<E: Fn(&str) -> Option<String>>(
layers: &crate::config::ConfigLayers,
env: &E,
knob: VersionKnob,
) -> Option<Version> {
let mut raws = version_candidates(layers, knob.toml_key(), false);
raws.extend(env(knob.env_var()));
fold_bound(raws, knob)
}
/// Org-deployed layers only (no `user` layer, no env).
fn resolve_version_bound_managed(
layers: &crate::config::ConfigLayers,
knob: VersionKnob,
) -> Option<Version> {
fold_bound(version_candidates(layers, knob.toml_key(), true), knob)
}
/// The four resolved version bounds: soft `minimum`/`maximum` steer the updater;
/// hard `required_*` gate startup.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct VersionPolicy {
pub minimum: Option<Version>,
pub maximum: Option<Version>,
pub required_minimum: Option<Version>,
pub required_maximum: Option<Version>,
}
impl VersionPolicy {
/// Resolve from config layers and env; every knob fails open.
pub fn resolve() -> Self {
let layers = crate::config::ConfigLayers::load().unwrap_or_else(|e| {
tracing::warn!(error = %e, "version policy: config layers failed to load; using env overrides only");
crate::config::ConfigLayers::default()
});
Self::from_layers(&layers, &env_version)
}
fn from_layers<E: Fn(&str) -> Option<String>>(
layers: &crate::config::ConfigLayers,
env: &E,
) -> Self {
let get = |knob| resolve_version_bound(layers, env, knob);
let minimum = get(VersionKnob::Minimum);
let maximum = get(VersionKnob::Maximum);
let mut required_minimum = get(VersionKnob::RequiredMinimum);
let mut required_maximum = get(VersionKnob::RequiredMaximum);
// A contradictory required range means a user/env bound crossed it. Managed
// policy is authoritative, so fall back to the managed-only bounds wholesale;
// a purely managed contradiction still fails open below.
if let (Some(lo), Some(hi)) = (&required_minimum, &required_maximum)
&& lo > hi
{
required_minimum = resolve_version_bound_managed(layers, VersionKnob::RequiredMinimum);
required_maximum = resolve_version_bound_managed(layers, VersionKnob::RequiredMaximum);
}
if let (Some(lo), Some(hi)) = (&minimum, &maximum)
&& lo > hi
{
tracing::warn!(%lo, %hi, "minimum_version exceeds maximum_version; updates will be skipped");
}
Self {
minimum,
maximum,
required_minimum,
required_maximum,
}
}
/// An unsatisfiable required range is ignored (fail-open).
pub fn has_contradictory_required_range(&self) -> bool {
matches!(
(&self.required_minimum, &self.required_maximum),
(Some(lo), Some(hi)) if lo > hi
)
}
/// `None` on a contradictory range, so the fail-open guard lives in one place.
fn effective_required_minimum(&self) -> Option<&Version> {
(!self.has_contradictory_required_range())
.then_some(self.required_minimum.as_ref())
.flatten()
}
fn effective_required_maximum(&self) -> Option<&Version> {
(!self.has_contradictory_required_range())
.then_some(self.required_maximum.as_ref())
.flatten()
}
/// Shared clamp core: cap at the ceilings, then the hard `required_minimum`
/// last so it wins over a lower ceiling.
fn clamp_version(&self, mut v: Version) -> Version {
if let Some(c) = &self.maximum
&& v > *c
{
v = c.clone();
}
if let Some(hi) = self.effective_required_maximum()
&& v > *hi
{
v = hi.clone();
}
if let Some(lo) = self.effective_required_minimum()
&& v < *lo
{
v = lo.clone();
}
v
}
/// Clamp then skip; the single place that ordering lives. `None` means an
/// anti-downgrade skip.
pub fn resolve_target(&self, latest: &str) -> Option<String> {
let target = self.clamp(latest);
(!self.skips_update_target(&target)).then_some(target)
}
/// Clamp `target` into range. An unparseable target resolves to the lowest
/// in-range version when a hard floor applies, else passes through unchanged.
fn clamp(&self, target: &str) -> String {
match Version::parse(target) {
Ok(v) => self.clamp_version(v).to_string(),
Err(_) if self.effective_required_minimum().is_some() => {
self.clamp_version(Version::new(0, 0, 0)).to_string()
}
Err(_) => target.to_string(),
}
}
/// Anti-downgrade: skip a target below the soft `minimum`. Never clamps up.
fn skips_update_target(&self, target: &str) -> bool {
matches!(
(&self.minimum, Version::parse(target)),
(Some(min), Ok(t)) if t < *min
)
}
/// Lowest version an explicit `--version` pin may install, always agreeing
/// with [`clamp`](Self::clamp). Only the hard `required_minimum` blocks a pin.
pub fn installable_floor(&self) -> Option<Version> {
self.effective_required_minimum()?;
Some(self.clamp_version(Version::new(0, 0, 0)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_max_minimum_version_picks_max_and_fails_closed_on_typos() {
assert_eq!(
pick_max_minimum_version(&["0.1.200", "0.1.100"])
.unwrap()
.as_deref(),
Some("0.1.200")
);
let (bad, _) = pick_max_minimum_version(&["not-a-version", "0.1.150"]).unwrap_err();
assert_eq!(bad, "not-a-version");
fn no_env(_: &str) -> Option<String> {
None
}
fn layers(managed: &str, user: &str, mdm: &str) -> crate::config::ConfigLayers {
let parse = |s: &str| {
if s.is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str(s).unwrap()
}
};
crate::config::ConfigLayers {
system_managed: TomlValue::Table(Default::default()),
managed: parse(managed),
user: parse(user),
user_requirements: None,
system_requirements: None,
mdm_requirements: if mdm.is_empty() {
None
} else {
Some(parse(mdm))
},
..Default::default()
}
}
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn minimum_version_includes_the_mdm_layer() {
// The MDM floor must win the semver-max so a managed minimum can't be
// lowered by a user pin.
let layers = crate::config::ConfigLayers {
system_managed: TomlValue::Table(Default::default()),
managed: TomlValue::Table(Default::default()),
user: toml::from_str("[cli]\nminimum_version = \"0.1.100\"\n").unwrap(),
user_requirements: None,
system_requirements: None,
mdm_requirements: Some(
toml::from_str("[cli]\nminimum_version = \"0.1.200\"\n").unwrap(),
),
..Default::default()
fn floor_is_semver_max_ceiling_is_semver_min_across_layers() {
let l = layers(
"[cli]\nminimum_version = \"0.1.100\"\nmaximum_version = \"0.2.150\"\n",
"[cli]\nminimum_version = \"0.1.50\"\nmaximum_version = \"0.2.130\"\n",
"[cli]\nminimum_version = \"0.1.200\"\nmaximum_version = \"0.2.140\"\n",
);
let p = VersionPolicy::from_layers(&l, &no_env);
assert_eq!(p.minimum, Some(v("0.1.200")));
assert_eq!(p.maximum, Some(v("0.2.130")));
}
#[test]
fn env_tightens_but_cannot_loosen() {
let l = layers(
"[cli]\nminimum_version = \"0.2.100\"\nmaximum_version = \"0.2.200\"\n",
"",
"",
);
let tighten = |var: &str| match var {
"GROK_MINIMUM_VERSION" => Some("0.2.150".to_string()),
"GROK_MAXIMUM_VERSION" => Some("0.2.180".to_string()),
_ => None,
};
let p = VersionPolicy::from_layers(&l, &tighten);
assert_eq!(p.minimum, Some(v("0.2.150")));
assert_eq!(p.maximum, Some(v("0.2.180")));
let loosen = |var: &str| match var {
"GROK_MINIMUM_VERSION" => Some("0.2.1".to_string()),
"GROK_MAXIMUM_VERSION" => Some("0.2.999".to_string()),
_ => None,
};
let p = VersionPolicy::from_layers(&l, &loosen);
assert_eq!(p.minimum, Some(v("0.2.100")));
assert_eq!(p.maximum, Some(v("0.2.200")));
}
#[test]
fn every_knob_fails_open_on_an_invalid_value() {
let l = layers(
"[cli]\nminimum_version = \"nope\"\nmaximum_version = \"bad\"\n\
required_minimum_version = \"junk\"\nrequired_maximum_version = \"0.2.150\"\n",
"",
"",
);
let p = VersionPolicy::from_layers(&l, &no_env);
assert_eq!(p.minimum, None);
assert_eq!(p.maximum, None);
assert_eq!(p.required_minimum, None);
assert_eq!(p.required_maximum, Some(v("0.2.150")));
}
#[test]
fn a_user_bound_cannot_cancel_a_managed_hard_bound() {
// Managed floor; an env ceiling below it would make the range
// contradictory and naively drop both. The managed floor must survive.
let l = layers("[cli]\nrequired_minimum_version = \"0.2.100\"\n", "", "");
let low_ceiling =
|var: &str| (var == "GROK_REQUIRED_MAXIMUM_VERSION").then(|| "0.2.50".to_string());
let p = VersionPolicy::from_layers(&l, &low_ceiling);
assert_eq!(p.required_minimum, Some(v("0.2.100")));
assert_eq!(p.required_maximum, None);
// Symmetric: a user floor can't cancel a managed ceiling.
let l = layers("[cli]\nrequired_maximum_version = \"0.2.100\"\n", "", "");
let high_floor =
|var: &str| (var == "GROK_REQUIRED_MINIMUM_VERSION").then(|| "0.2.200".to_string());
let p = VersionPolicy::from_layers(&l, &high_floor);
assert_eq!(p.required_maximum, Some(v("0.2.100")));
assert_eq!(p.required_minimum, None);
// Tightening BOTH sides into a contradiction must not drop the managed floor.
let l = layers("[cli]\nrequired_minimum_version = \"0.2.100\"\n", "", "");
let both = |var: &str| match var {
"GROK_REQUIRED_MINIMUM_VERSION" => Some("99.0.0".to_string()),
"GROK_REQUIRED_MAXIMUM_VERSION" => Some("0.0.1".to_string()),
_ => None,
};
let p = VersionPolicy::from_layers(&l, &both);
assert_eq!(p.required_minimum, Some(v("0.2.100")));
assert_eq!(p.required_maximum, None);
assert!(!p.has_contradictory_required_range());
// A purely managed contradiction still fails open (ignored, not reverted).
let l = layers(
"[cli]\nrequired_minimum_version = \"0.3.0\"\nrequired_maximum_version = \"0.2.0\"\n",
"",
"",
);
let p = VersionPolicy::from_layers(&l, &no_env);
assert!(p.has_contradictory_required_range());
}
fn pol(
min: Option<&str>,
max: Option<&str>,
rmin: Option<&str>,
rmax: Option<&str>,
) -> VersionPolicy {
VersionPolicy {
minimum: min.map(v),
maximum: max.map(v),
required_minimum: rmin.map(v),
required_maximum: rmax.map(v),
}
}
#[test]
fn soft_minimum_skips_a_downgrade_but_never_clamps_up() {
let p = pol(Some("0.2.100"), None, None, None);
assert!(p.skips_update_target("0.2.50"));
assert_eq!(p.clamp("0.2.50"), "0.2.50");
assert!(!p.skips_update_target("0.2.100"));
assert!(!p.skips_update_target("dev"));
assert!(!pol(None, None, None, None).skips_update_target("0.0.1"));
assert_eq!(p.installable_floor(), None);
}
#[test]
fn clamp_caps_at_ceilings_and_the_hard_floor_wins() {
assert_eq!(pol(None, None, None, None).clamp("0.2.200"), "0.2.200");
assert_eq!(
resolve_minimum_version_from_layers(&layers)
.unwrap()
.as_deref(),
Some("0.1.200"),
pol(None, Some("0.2.150"), None, None).clamp("0.2.200"),
"0.2.150"
);
assert_eq!(
pol(None, None, None, Some("0.2.150")).clamp("0.2.200"),
"0.2.150"
);
// Hard floor wins over a lower soft ceiling.
assert_eq!(
pol(None, Some("0.2.100"), Some("0.2.180"), None).clamp("0.2.50"),
"0.2.180"
);
// Contradictory hard range is ignored (fail open).
assert_eq!(
pol(None, None, Some("0.3.0"), Some("0.2.0")).clamp("0.2.120"),
"0.2.120"
);
// Unparseable target: floored to the hard minimum, else passed through.
assert_eq!(
pol(None, None, Some("0.2.100"), None).clamp("dev"),
"0.2.100"
);
assert_eq!(pol(None, None, None, None).clamp("dev"), "dev");
}
#[test]
fn resolve_target_clamps_then_skips() {
assert_eq!(
pol(None, None, None, None).resolve_target("0.2.200"),
Some("0.2.200".into())
);
assert_eq!(
pol(Some("0.2.100"), None, None, None).resolve_target("0.2.50"),
None
);
assert_eq!(
pol(None, Some("0.2.150"), None, None).resolve_target("0.2.200"),
Some("0.2.150".into())
);
// max < min clamps below the floor, then the skip catches the clamped
// value. This is the ordering every updater path depends on.
assert_eq!(
pol(Some("0.2.100"), Some("0.2.50"), None, None).resolve_target("0.2.200"),
None
);
}
#[test]
fn installable_floor_tracks_only_the_hard_minimum() {
assert_eq!(
pol(None, None, Some("0.2.120"), None).installable_floor(),
Some(v("0.2.120"))
);
// Contradictory hard range is ignored, so there is no floor.
assert_eq!(
pol(None, None, Some("0.3.0"), Some("0.2.0")).installable_floor(),
None
);
}
#[test]
fn whitespace_and_empty_values_are_ignored() {
let l = layers("[cli]\nminimum_version = \" \"\n", "", "");
let p = VersionPolicy::from_layers(&l, &no_env);
assert_eq!(p.minimum, None);
}
}

View file

@ -265,6 +265,12 @@ pub async fn set_voice_stt_language(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.voice_stt_language = Some(value)).await
}
/// Persist `[ui].voice_keybind_enabled` via `update_config`. When `false` the
/// Ctrl+Space / F8 voice chord is ignored (`/voice` still works).
pub async fn set_voice_keybind_enabled(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.voice_keybind_enabled = Some(value)).await
}
/// Persist `[ui].default_selected_permission` via `update_config`. Value is
/// one of the canonical strings from `DEFAULT_SELECTED_PERMISSION_CHOICES`
/// (`default` | `allow_once` | `allow_always` | `reject`); `default` is the

View file

@ -112,6 +112,85 @@ pub fn resolve_tips_from_disk(
crate::util::tips::pick_and_advance(&all, grok_home)
}
/// Parse `[slash_command_tags]` from a TOML value into a name → tag map.
/// Only string values are kept; non-string entries are ignored.
fn slash_command_tags_from_toml(root: &TomlValue) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
if let Some(TomlValue::Table(table)) = root.get("slash_command_tags") {
for (name, value) in table {
if let Some(tag) = value.as_str() {
out.insert(name.clone(), tag.to_string());
}
}
}
out
}
/// Parse a `GROK_SLASH_COMMAND_TAGS` payload (a JSON object of string→string)
/// into a name → tag map. `None`/empty → empty; malformed → warn + empty. Split
/// from env-reading so the parse is unit-testable without mutating process env.
fn parse_slash_command_tags_json(raw: Option<&str>) -> std::collections::HashMap<String, String> {
// Unset or empty/whitespace-only is the normal "no override" state, not an
// error — only real, non-empty input is parsed (and warned on failure).
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return std::collections::HashMap::new();
};
match serde_json::from_str::<std::collections::BTreeMap<String, String>>(raw) {
Ok(map) => map.into_iter().collect(),
Err(e) => {
tracing::warn!(
error = %e,
"ignoring malformed GROK_SLASH_COMMAND_TAGS; expected a JSON object of string values"
);
std::collections::HashMap::new()
}
}
}
/// Read per-command tags from the `GROK_SLASH_COMMAND_TAGS` env var. Unset →
/// empty; malformed → warn + empty.
fn slash_command_tags_from_env() -> std::collections::HashMap<String, String> {
parse_slash_command_tags_json(std::env::var("GROK_SLASH_COMMAND_TAGS").ok().as_deref())
}
/// Pure per-key merge of the three tag sources. Precedence lowest → highest:
/// remote (base) → local `[slash_command_tags]` → env. Every key from every
/// layer survives; higher layers override per key. Pure so precedence is
/// unit-testable without touching process env.
fn merge_command_tags(
remote: Option<&std::collections::BTreeMap<String, String>>,
local: std::collections::HashMap<String, String>,
env: std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
let mut out: std::collections::HashMap<String, String> = remote
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
out.extend(local); // local overrides remote
out.extend(env); // env overrides local
out
}
/// Env-injectable core of [`resolve_slash_command_tags`]: remote → local
/// `[slash_command_tags]` → `env` (highest). Takes the env map explicitly so the
/// TOML-extraction + merge composition is hermetically testable (no process env).
fn resolve_slash_command_tags_with_env(
effective_config: &TomlValue,
remote: Option<&std::collections::BTreeMap<String, String>>,
env: std::collections::HashMap<String, String>,
) -> std::collections::HashMap<String, String> {
merge_command_tags(remote, slash_command_tags_from_toml(effective_config), env)
}
/// Resolve per-command slash-dropdown tags. Precedence lowest → highest: remote
/// settings (base) → local `[slash_command_tags]` → `GROK_SLASH_COMMAND_TAGS`
/// env var (wins). Empty/missing everywhere → empty map.
pub fn resolve_slash_command_tags(
effective_config: &TomlValue,
remote: Option<&std::collections::BTreeMap<String, String>>,
) -> std::collections::HashMap<String, String> {
resolve_slash_command_tags_with_env(effective_config, remote, slash_command_tags_from_env())
}
/// Read `[cli] channel` from config.toml.
/// Returns `None` when absent (falls through to remote settings).
pub fn channel_from_toml_opt(root: &TomlValue) -> Option<String> {
@ -177,4 +256,143 @@ mod tests {
let s: RemoteSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.tips, Some(vec!["a".to_string(), "b".to_string()]));
}
// Hermetic: drive the resolver through `_with_env` with an EXPLICIT env map
// so ambient `GROK_SLASH_COMMAND_TAGS` can't affect these assertions.
#[test]
fn resolve_slash_command_tags_local_overrides_remote_per_key() {
let mut remote = std::collections::BTreeMap::new();
remote.insert("workflows".to_string(), "beta".to_string());
remote.insert("model".to_string(), "remote-only".to_string());
let local: TomlValue =
toml::from_str("[slash_command_tags]\nworkflows = \"new\"\nplan = \"local-only\"\n")
.unwrap();
let resolved = resolve_slash_command_tags_with_env(
&local,
Some(&remote),
std::collections::HashMap::new(),
);
// Local wins per key.
assert_eq!(resolved.get("workflows").map(String::as_str), Some("new"));
// Remote-only key passes through.
assert_eq!(
resolved.get("model").map(String::as_str),
Some("remote-only")
);
// Local-only key is added.
assert_eq!(resolved.get("plan").map(String::as_str), Some("local-only"));
assert_eq!(resolved.len(), 3);
}
#[test]
fn resolve_slash_command_tags_missing_is_empty_and_remote_passes_through() {
let empty = TomlValue::Table(toml::map::Map::new());
assert!(
resolve_slash_command_tags_with_env(&empty, None, std::collections::HashMap::new())
.is_empty()
);
let mut remote = std::collections::BTreeMap::new();
remote.insert("commit".to_string(), "new".to_string());
let resolved = resolve_slash_command_tags_with_env(
&empty,
Some(&remote),
std::collections::HashMap::new(),
);
assert_eq!(resolved.get("commit").map(String::as_str), Some("new"));
assert_eq!(resolved.len(), 1);
}
// Env wins through the public composition — proven hermetically via `_with_env`
// (no process-env mutation).
#[test]
fn resolve_slash_command_tags_env_overrides_local_and_remote() {
let mut remote = std::collections::BTreeMap::new();
remote.insert("workflows".to_string(), "remote".to_string());
let local: TomlValue =
toml::from_str("[slash_command_tags]\nworkflows = \"local\"\n").unwrap();
let mut env = std::collections::HashMap::new();
env.insert("workflows".to_string(), "env".to_string());
let resolved = resolve_slash_command_tags_with_env(&local, Some(&remote), env);
assert_eq!(resolved.get("workflows").map(String::as_str), Some("env"));
assert_eq!(resolved.len(), 1);
}
#[test]
fn remote_settings_slash_command_tags_absent_and_malformed() {
// Absent → None.
let s: RemoteSettings = serde_json::from_str("{}").unwrap();
assert_eq!(s.slash_command_tags, None);
// Malformed (array instead of map) → tolerated as None, whole parse ok.
let s: RemoteSettings =
serde_json::from_str(r#"{"slash_command_tags": ["oops"]}"#).unwrap();
assert_eq!(s.slash_command_tags, None);
// Well-formed map parses.
let s: RemoteSettings =
serde_json::from_str(r#"{"slash_command_tags": {"commit": "new"}}"#).unwrap();
assert_eq!(
s.slash_command_tags
.as_ref()
.and_then(|m| m.get("commit"))
.map(String::as_str),
Some("new")
);
}
#[test]
fn merge_command_tags_env_beats_local_beats_remote_per_key() {
let mut remote = std::collections::BTreeMap::new();
remote.insert("a".to_string(), "remote-a".to_string());
remote.insert("b".to_string(), "remote-b".to_string());
remote.insert("r".to_string(), "remote-only".to_string());
let mut local = std::collections::HashMap::new();
local.insert("a".to_string(), "local-a".to_string());
local.insert("b".to_string(), "local-b".to_string());
local.insert("l".to_string(), "local-only".to_string());
let mut env = std::collections::HashMap::new();
env.insert("a".to_string(), "env-a".to_string());
env.insert("e".to_string(), "env-only".to_string());
let merged = merge_command_tags(Some(&remote), local, env);
assert_eq!(merged.get("a").map(String::as_str), Some("env-a")); // env > local > remote
assert_eq!(merged.get("b").map(String::as_str), Some("local-b")); // local > remote (no env)
assert_eq!(merged.get("r").map(String::as_str), Some("remote-only")); // remote-only survives
assert_eq!(merged.get("l").map(String::as_str), Some("local-only")); // local-only survives
assert_eq!(merged.get("e").map(String::as_str), Some("env-only")); // env-only survives
assert_eq!(merged.len(), 5);
// All sources empty → empty map.
assert!(
merge_command_tags(
None,
std::collections::HashMap::new(),
std::collections::HashMap::new()
)
.is_empty()
);
}
#[test]
fn parse_slash_command_tags_json_handles_none_valid_and_malformed() {
// Unset → empty (no warn).
assert!(parse_slash_command_tags_json(None).is_empty());
// Empty / whitespace-only is the normal "no override" state → empty (no warn).
assert!(parse_slash_command_tags_json(Some("")).is_empty());
assert!(parse_slash_command_tags_json(Some(" ")).is_empty());
// Valid JSON object of string→string → parsed.
let parsed = parse_slash_command_tags_json(Some(r#"{"commit":"new","plan":"beta"}"#));
assert_eq!(parsed.get("commit").map(String::as_str), Some("new"));
assert_eq!(parsed.get("plan").map(String::as_str), Some("beta"));
assert_eq!(parsed.len(), 2);
// Array instead of object → empty (tolerated).
assert!(parse_slash_command_tags_json(Some(r#"["oops"]"#)).is_empty());
// Non-string value → whole parse fails → empty (only string values kept).
assert!(parse_slash_command_tags_json(Some(r#"{"commit": 3}"#)).is_empty());
// Not JSON → empty.
assert!(parse_slash_command_tags_json(Some("garbage")).is_empty());
}
}

View file

@ -2,7 +2,9 @@
use std::path::{Path, PathBuf};
use xai_grok_config::resolve_global_hook_sources;
use xai_grok_hooks::discovery::HookSource;
use xai_grok_hooks::error::HookError;
/// Owned paths for hook sources. Callers borrow via `as_sources()`.
pub struct HookSourcePaths {
@ -31,65 +33,68 @@ fn path_to_source(p: &Path) -> HookSource<'_> {
}
}
/// Build hook source paths for global (`~/`) and project (`<git_root>/`) scopes.
/// Callers gate project sources on trust via `as_sources(trusted)`.
fn include_claude_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) -> bool {
compat.claude.hooks
&& !crate::claude_import::is_claude_import_marked_with_log("discover_hook_source_paths")
}
fn include_cursor_hooks(compat: &xai_grok_tools::types::compat::CompatConfig) -> bool {
compat.cursor.hooks
}
/// Global + project hook source paths. Registry file is never a discovery
/// source; Claude/Cursor globals are appended when gates are on.
pub fn discover_hook_source_paths(
git_root: Option<&Path>,
compat: &xai_grok_tools::types::compat::CompatConfig,
) -> HookSourcePaths {
// Compat gate: skip .claude hook sources when disabled.
let skip_claude_compat = !compat.claude.hooks;
// Phase 2 cutoff: if the user has imported, skip .claude/settings.json
// sources. Native .grok/hooks/ directories are still scanned (they hold
// any hooks that were imported by /import-claude).
let skip_claude = skip_claude_compat
|| crate::claude_import::is_claude_import_marked_with_log("discover_hook_source_paths");
// Compat gate: skip Cursor hook sources when disabled.
let skip_cursor = !compat.cursor.hooks;
let home = dirs::home_dir();
// user_grok_home() is None when no home resolves, so inspect lists the same
// sources a live session loads, instead of a cwd-relative .grok.
let grok = xai_grok_config::user_grok_home();
let mut global = Vec::new();
let home = dirs::home_dir();
let include_claude = include_claude_hooks(compat);
let include_cursor = include_cursor_hooks(compat);
if !skip_claude && let Some(ref h) = home {
global.push(h.join(".claude").join("settings.json"));
global.push(h.join(".claude").join("settings.local.json"));
}
if let Some(ref grok) = grok {
global.push(grok.join("hooks"));
}
// Soft hooks-paths I/O keeps fixed slots; hard resolve omits Grok globals.
let mut global: Vec<PathBuf> =
match resolve_global_hook_sources(grok.as_deref(), /* reject_symlinks */ false) {
Ok(resolved) => {
if let Some(e) = &resolved.configured_error {
tracing::warn!(
error = %e,
"hooks-paths unreadable; retaining fixed Grok hook discovery sources only"
);
}
resolved
.discovery_sources()
.map(|s| s.path.clone())
.collect()
}
Err(e) => {
tracing::warn!(
error = %e,
"global hook source resolve hard-failed; omitting Grok global sources"
);
Vec::new()
}
};
let custom_paths: Vec<PathBuf> = grok
.as_ref()
.and_then(|g| std::fs::read_to_string(g.join("hooks-paths")).ok())
.map(|content| {
content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| PathBuf::from(l.trim()))
.collect()
})
.unwrap_or_default();
global.extend(custom_paths);
if let Some(ref h) = home
&& !skip_cursor
{
global.push(h.join(".cursor").join("hooks.json"));
if let Some(h) = home.as_deref() {
if include_claude {
global.push(h.join(".claude").join("settings.json"));
global.push(h.join(".claude").join("settings.local.json"));
}
if include_cursor {
global.push(h.join(".cursor").join("hooks.json"));
}
}
let mut project = Vec::new();
if let Some(root) = git_root {
if !skip_claude {
if include_claude {
project.push(root.join(".claude").join("settings.json"));
project.push(root.join(".claude").join("settings.local.json"));
}
project.push(root.join(".grok").join("hooks"));
if !skip_cursor {
if include_cursor {
project.push(root.join(".cursor").join("hooks.json"));
}
}
@ -99,18 +104,12 @@ pub fn discover_hook_source_paths(
/// Single load entry point: build compat-aware sources, gate project sources on
/// trust, then load. Every session-startup and mid-session reload site routes
/// through here so the source policy stays in one place. `discover_hook_source_paths`
/// and `HookSourcePaths::as_sources` stay public for the `inspect` path (which
/// enumerates sources with all vendors on) and the unit tests that assert on the
/// raw source lists.
/// through here so the source policy stays in one place.
pub fn discover_hooks(
git_root: Option<&Path>,
compat: &xai_grok_tools::types::compat::CompatConfig,
trusted: bool,
) -> (
xai_grok_hooks::discovery::HookRegistry,
Vec<xai_grok_hooks::error::HookError>,
) {
) -> (xai_grok_hooks::discovery::HookRegistry, Vec<HookError>) {
let source_paths = discover_hook_source_paths(git_root, compat);
let (global_sources, project_sources) = source_paths.as_sources(trusted);
xai_grok_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources)

View file

@ -52,6 +52,62 @@ impl Drop for AbortOnDrop {
}
}
/// Expand a leading `~` to the home directory; other paths pass through.
pub(crate) fn expand_home(s: &str) -> std::path::PathBuf {
if let Some(stripped) = s.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if s == "~"
&& let Some(home) = dirs::home_dir()
{
return home;
}
std::path::PathBuf::from(s)
}
#[cfg(test)]
mod expand_home_tests {
use super::expand_home;
#[test]
fn passthrough_for_absolute_path() {
assert_eq!(
expand_home("/abs/path"),
std::path::PathBuf::from("/abs/path")
);
}
#[test]
fn passthrough_for_relative_path() {
assert_eq!(
expand_home("rel/path"),
std::path::PathBuf::from("rel/path")
);
}
#[test]
fn bare_tilde() {
let home = dirs::home_dir().expect("home_dir required for this test");
assert_eq!(expand_home("~"), home);
}
#[test]
fn tilde_slash() {
let home = dirs::home_dir().expect("home_dir required for this test");
assert_eq!(expand_home("~/foo/bar"), home.join("foo/bar"));
}
#[test]
fn does_not_handle_user_tilde() {
// `~bob/path` is treated as a literal relative path.
assert_eq!(
expand_home("~bob/path"),
std::path::PathBuf::from("~bob/path")
);
}
}
#[cfg(test)]
mod is_user_instruction_path_tests {
use super::is_user_instruction_path;