Synced from monorepo

Changes:
- grok-shell: request workspaces:read/write OAuth2 scopes
- security: fix SSRF bypass via HTTP redirect in hook runner
- fix(grok-build): enterprise STT WSS URL + API-key voice bearer
- Harden identity-change purge and sync-marker invariants
- sandbox + workspace-server: delete the legacy ready-file arm
- Show billing URL when browser cannot open
- fix(pager): show folder-trust UI in minimal mode
- fix(pager): drain task_backgrounded before no-wait headless exit
- grok-agent-sdk: stop SDK-spawned agents from staging self-updates they can never adopt
- Split settings_modal into directory module
- Delegate VS Code SSH file links
- grok-shell: release the workspace session binding when a session is removed
- keep skills reachable when their name collides with a client builtin
- Preserve semantic link targets
This commit is contained in:
grokkybara[bot] 2026-07-16 20:27:30 +01:00
commit 8adf9013a0
117 changed files with 16998 additions and 14540 deletions

View file

@ -32,22 +32,23 @@ pub use campaigns::{
};
pub use loader::{
CampaignsState, ConfigLayers, MANAGED_CONFIG_FILENAME, ManagedConfigLayer,
apply_version_overrides_with_registered, campaigns_application_disabled, campaigns_state_path,
deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml, load_config_file,
load_dismissed_ids_from_home, load_effective_config_disk_only, load_from_disk,
load_managed_config, load_system_managed_config, load_toml_file, managed_config_layers,
managed_config_layers_at, toml_error_detail,
REQUIREMENTS_FILENAME, apply_version_overrides_with_registered, campaigns_application_disabled,
campaigns_state_path, deep_merge_toml, expand_env_vars_in_string, expand_env_vars_in_toml,
load_config_file, load_dismissed_ids_from_home, load_effective_config_disk_only,
load_from_disk, load_managed_config, load_system_managed_config, load_toml_file,
managed_config_layers, managed_config_layers_at, toml_error_detail,
};
pub use macos_managed::MDM_REQUIREMENTS_SOURCE;
pub use managed_cache::{
ServingIdentity, SyncMarker, is_managed_config_hard_stale_for, is_managed_config_stale_for,
managed_config_identity_changed, managed_deployment_id, managed_policy_compromised_for,
mark_managed_config_synced,
MANAGED_CONFIG_CACHE_FILE, ServingIdentity, SyncMarker, confirmed_team_switch,
confirmed_team_switch_at, is_managed_config_hard_stale_for, is_managed_config_stale_for,
managed_config_identity_changed_at, managed_deployment_id, managed_policy_compromised_for,
mark_managed_config_synced, mark_managed_config_synced_at, normalize_identity,
};
pub use paths::{
claude_managed_settings_path, claude_managed_settings_probe_path, decode_cwd_from_dirname,
default_grok_home, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application, grok_home,
sessions_cwd_dir, system_config_dir, user_grok_home,
default_grok_home, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application,
grok_application_in, grok_home, sessions_cwd_dir, system_config_dir, user_grok_home,
};
pub use validation::{
RequirementsError, RequirementsLayer, RequirementsSource, fail_closed_flag_from_str,

View file

@ -87,6 +87,9 @@ pub fn load_from_disk() -> std::io::Result<toml::Value> {
/// Managed config filename, shared by the loaders in this module.
pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml";
/// Requirements (cloud-cache) filename — the sibling server-synced artifact.
pub const REQUIREMENTS_FILENAME: &str = "requirements.toml";
pub fn load_managed_config() -> std::io::Result<toml::Value> {
load_user_config_layer(user_grok_home().as_deref(), MANAGED_CONFIG_FILENAME)
}

View file

@ -10,8 +10,9 @@ use std::path::Path;
use crate::paths::user_grok_home;
/// Sync marker; staleness keys on this, not mtimes.
const MANAGED_CONFIG_CACHE_FILE: &str = "managed_config_cache.json";
/// Sync marker; staleness keys on this, not mtimes. Public so removal code can name it
/// apart from the policy artifacts (removed last).
pub const MANAGED_CONFIG_CACHE_FILE: &str = "managed_config_cache.json";
/// The on-disk marker: unsigned, detects only deletion / identity change, not
/// in-place edits (see the module doc).
@ -84,10 +85,11 @@ fn managed_deployment_id_at(home: &Path, key_fingerprint: &str) -> Option<String
if cache.key_fingerprint.as_deref() != Some(key_fingerprint) {
return None;
}
cache.principal.filter(|p| !p.trim().is_empty())
normalize_identity(cache.principal.as_deref())
}
fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
/// [`mark_managed_config_synced`] for an explicit `home` (apply-lock holder: same dir as lock).
pub fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
let SyncMarker {
principal,
had_managed_config,
@ -101,11 +103,12 @@ fn mark_managed_config_synced_at(home: &Path, marker: SyncMarker<'_>) {
.ok();
let cache = ManagedConfigCache {
synced_at,
principal: principal.map(str::to_owned),
// What THIS sync served, not on-disk presence — a confirmed switch already evicted any prior files.
// Blank → None: marker must never record "unknown" as a tenant.
principal: normalize_identity(principal),
// What THIS sync served (not on-disk); switch already evicted priors.
had_managed_config,
had_requirements,
key_fingerprint: key_fingerprint.map(str::to_owned),
key_fingerprint: normalize_identity(key_fingerprint),
fail_closed,
};
match serde_json::to_string(&cache) {
@ -146,21 +149,10 @@ fn read_managed_config_cache(home: &Path) -> Option<ManagedConfigCache> {
}
}
/// A confirmed identity switch vs the marker — both sides of a dimension present and differing (team id or fingerprint).
/// Callers evict prior artifacts on true; a missing marker / `None` / pre-upgrade never counts (first sync / signed-out / legacy never evict).
/// A blank/whitespace value on either side of either dimension (principal or key fingerprint)
/// is "unknown", not a distinct tenant — a malformed `auth.json` parse blip must not confirm a
/// switch and shed a real tenant's policy.
pub fn managed_config_identity_changed(
new_principal: Option<&str>,
new_key_fingerprint: Option<&str>,
) -> bool {
user_grok_home().is_some_and(|home| {
managed_config_identity_changed_at(&home, new_principal, new_key_fingerprint)
})
}
fn managed_config_identity_changed_at(
/// Confirmed identity switch vs the marker (both sides of a dimension known and differing).
/// Missing marker / blank / pre-upgrade never counts. Callers evict prior artifacts on true.
/// Takes the apply-lock holder's `home` (same dir as the lock).
pub fn managed_config_identity_changed_at(
home: &Path,
new_principal: Option<&str>,
new_key_fingerprint: Option<&str>,
@ -168,41 +160,78 @@ fn managed_config_identity_changed_at(
let Some(cache) = read_managed_config_cache(home) else {
return false;
};
let principal_changed = matches!(
(cache.principal.as_deref(), new_principal),
(Some(old), Some(new))
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
);
let key_changed = matches!(
(cache.key_fingerprint.as_deref(), new_key_fingerprint),
(Some(old), Some(new))
if !old.trim().is_empty() && !new.trim().is_empty() && old != new
);
principal_changed || key_changed
confirmed_switch(cache.principal.as_deref(), new_principal).is_some()
|| confirmed_switch(cache.key_fingerprint.as_deref(), new_key_fingerprint).is_some()
}
/// Present non-blank value, else `None` (blank/whitespace is "unknown", not a tenant). Untrimmed.
fn known(value: Option<&str>) -> Option<&str> {
value.filter(|v| !v.trim().is_empty())
}
/// [`known`] then trim — the one normalization for storing or deriving an identity
/// (whitespace is not identity). Shared with the shell's identity derivation.
pub fn normalize_identity(value: Option<&str>) -> Option<String> {
known(value).map(|v| v.trim().to_owned())
}
/// Both sides known and differing after trim (older markers may be untrimmed). Returns recorded value.
fn confirmed_switch<'a>(recorded: Option<&'a str>, current: Option<&str>) -> Option<&'a str> {
match (known(recorded), known(current)) {
(Some(old), Some(new)) if old.trim() != new.trim() => Some(old),
_ => None,
}
}
/// Offline tenant-purge detector: confirmed team switch vs marker → evicted principal.
/// Key-scoped markers never confirm (key owns the machine's policy, not the team).
pub fn confirmed_team_switch(new_team_id: &str) -> Option<String> {
user_grok_home().and_then(|home| confirmed_team_switch_at(&home, new_team_id))
}
/// [`confirmed_team_switch`] for an explicit `home` (purge-lock holder: same dir as delete).
pub fn confirmed_team_switch_at(home: &Path, new_team_id: &str) -> Option<String> {
let cache = read_managed_config_cache(home)?;
if known(cache.key_fingerprint.as_deref()).is_some() {
return None;
}
confirmed_switch(cache.principal.as_deref(), Some(new_team_id)).map(str::to_owned)
}
/// True when an artifact the marker recorded serving is now absent. Only served artifacts count, so a config-less
/// principal (or legacy marker) isn't misread as stale. Detects deletion, not edits.
fn cache_missing_required_artifact(cache: &ManagedConfigCache, home: &Path) -> bool {
(cache.had_requirements && !home.join("requirements.toml").exists())
|| (cache.had_managed_config && !home.join("managed_config.toml").exists())
(cache.had_requirements && !home.join(crate::loader::REQUIREMENTS_FILENAME).exists())
|| (cache.had_managed_config && !home.join(crate::loader::MANAGED_CONFIG_FILENAME).exists())
}
/// Whether the cached principal differs from the team serving now — the team dimension only.
/// Deploy-key identity is verified by fingerprint ([`cache_key_fingerprint_mismatch`]); `None` never fires.
/// Trim-aware (same rule as marker write): whitespace alone is not a mismatch.
fn cache_identity_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
match identity {
ServingIdentity::Team(team_id) => cache.principal.as_deref() != Some(team_id.as_str()),
ServingIdentity::Team(team_id) => match (
known(cache.principal.as_deref()),
known(Some(team_id.as_str())),
) {
// Both blank → no team to compare.
(None, None) => false,
// Both known → trim-compare.
(Some(a), Some(b)) => a.trim() != b.trim(),
// One-sided: treat as mismatch (first install / cleared principal field).
_ => true,
},
ServingIdentity::DeploymentKey { .. } | ServingIdentity::None => false,
}
}
/// Whether the configured deployment key differs from the cache's, by one-way fingerprint (never the raw key) —
/// the only identity verifiable offline. A pre-upgrade marker (no fingerprint) never fires; only a *changed* key.
/// Trim-aware; both sides must be known (unlike the team principal path).
fn cache_key_fingerprint_mismatch(cache: &ManagedConfigCache, identity: &ServingIdentity) -> bool {
match identity {
ServingIdentity::DeploymentKey { fingerprint } => {
matches!(cache.key_fingerprint.as_deref(), Some(recorded) if recorded != fingerprint)
confirmed_switch(cache.key_fingerprint.as_deref(), Some(fingerprint.as_str())).is_some()
}
ServingIdentity::Team(_) | ServingIdentity::None => false,
}

View file

@ -1010,3 +1010,140 @@ fn gate_retries_once_on_a_compromised_verdict() {
assert!(refused);
assert_eq!(evals, 1);
}
/// The offline purge detector: fires only on a marker-recorded TEAM switch, returning the
/// evicted principal; a key-scoped marker means the key owns the machine's policy, so a
/// team mismatch (even with live config unreadable/blipping) must never confirm.
#[test]
fn confirmed_team_switch_scopes_to_marker() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
// No marker → no switch (first run / signed-out).
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
// Team marker A → B confirms and reports the evicted principal; same team doesn't.
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some("team-a"),
had_managed_config: true,
had_requirements: true,
key_fingerprint: None,
fail_closed: true,
},
);
assert_eq!(
confirmed_team_switch_at(home, "team-b").as_deref(),
Some("team-a")
);
assert_eq!(confirmed_team_switch_at(home, "team-a"), None);
// Key-scoped marker (dk-synced): a differing team NEVER confirms — the regression
// shape is a dk machine with a team user signed in and config resolution blipping.
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some("dk-deployment-1"),
had_managed_config: true,
had_requirements: true,
key_fingerprint: Some("fp-1"),
fail_closed: true,
},
);
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
}
/// Blank identity values normalize to `None` at the marker WRITE, so no reader can
/// treat "unknown" as a distinct tenant (the detectors' blank guards stay as
/// defense in depth).
#[test]
fn marker_write_normalizes_blank_identities() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some(" "),
had_managed_config: true,
had_requirements: true,
key_fingerprint: Some(""),
fail_closed: true,
},
);
let cache = read_managed_config_cache(home).expect("marker written");
assert_eq!(
cache.principal, None,
"blank principal must not be recorded"
);
assert_eq!(
cache.key_fingerprint, None,
"blank fingerprint must not be recorded"
);
// And a blank-recorded marker can't confirm a switch.
assert_eq!(confirmed_team_switch_at(home, "team-b"), None);
}
/// Identity values are stored TRIMMED at the marker write, so a marker can never
/// differ from a live value by surrounding whitespace alone.
#[test]
fn marker_write_trims_identity_values() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
mark_managed_config_synced_at(
home,
SyncMarker {
principal: Some(" team-a "),
had_managed_config: true,
had_requirements: true,
key_fingerprint: Some(" fp-1 "),
fail_closed: false,
},
);
let cache = read_managed_config_cache(home).expect("marker written");
assert_eq!(cache.principal.as_deref(), Some("team-a"));
assert_eq!(cache.key_fingerprint.as_deref(), Some("fp-1"));
}
/// The one home of the blank + trim rules ([`known`] + `confirmed_switch`): both sides
/// known and differing on their trimmed forms, else `None`.
#[test]
fn confirmed_switch_requires_two_known_differing_sides() {
assert_eq!(confirmed_switch(Some("a"), Some("b")), Some("a"));
assert_eq!(confirmed_switch(Some("a"), Some("a")), None);
assert_eq!(confirmed_switch(Some(" "), Some("b")), None);
assert_eq!(confirmed_switch(Some("a"), Some("")), None);
assert_eq!(confirmed_switch(None, Some("b")), None);
assert_eq!(confirmed_switch(Some("a"), None), None);
assert_eq!(confirmed_switch(None, None), None);
// Whitespace is not identity: a marker written untrimmed by an older build must
// not read as a tenant switch against the same (trimmed) value...
assert_eq!(confirmed_switch(Some("team-a "), Some("team-a")), None);
assert_eq!(confirmed_switch(Some("team-a"), Some("team-a ")), None);
// ...while genuinely different trimmed values still switch (the recorded value
// is returned verbatim for logging).
assert_eq!(
confirmed_switch(Some(" team-a "), Some("team-b")),
Some(" team-a ")
);
}
/// Staleness identity compare is trim-aware (sibling of marker write normalize).
#[test]
fn cache_identity_mismatch_ignores_whitespace_only_diffs() {
let cache = ManagedConfigCache {
principal: Some("team-a".into()),
..Default::default()
};
assert!(
!cache_identity_mismatch(&cache, &team(" team-a ")),
"whitespace-only team id diff must not hard-stale"
);
assert!(
cache_identity_mismatch(&cache, &team("team-b")),
"a real team switch must still mismatch"
);
// One-sided known still mismatches (first install / cleared marker fields).
let empty = ManagedConfigCache::default();
assert!(cache_identity_mismatch(&empty, &team("team-a")));
}

View file

@ -59,8 +59,13 @@ pub fn user_grok_home() -> Option<PathBuf> {
/// Canonical grok application path: `$GROK_HOME/bin/grok` (Unix) or `grok.exe` (Windows).
pub fn grok_application() -> PathBuf {
grok_application_in(&grok_home())
}
/// [`grok_application`] under an explicit home instead of `$GROK_HOME`.
pub fn grok_application_in(home: &std::path::Path) -> PathBuf {
let name = if cfg!(windows) { "grok.exe" } else { "grok" };
grok_home().join("bin").join(name)
home.join("bin").join(name)
}
/// System-wide config directory: `/etc/grok/` on Unix, `None` on Windows.