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:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue