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
1018 lines
42 KiB
Rust
1018 lines
42 KiB
Rust
//! Sync `managed_config.toml` + `requirements.toml` from the deployment-config endpoint per principal.
|
|
//! Overwritten per fetch, evicted on a confirmed identity switch, cleared on logout — so config never leaks across principals.
|
|
|
|
mod response;
|
|
|
|
use crate::auth::GrokAuth;
|
|
pub use response::ManagedConfigError;
|
|
use response::{ApplyOutcome, ManagedConfigResponse, ManagedConfigSource, verify_signed_envelope};
|
|
|
|
/// Server-synced policy artifacts. Excludes the sync marker ([`remove_managed_config_files`]
|
|
/// removes that last, only on full success).
|
|
pub const MANAGED_ARTIFACT_FILES: [&str; 3] = [
|
|
xai_grok_config::MANAGED_CONFIG_FILENAME,
|
|
xai_grok_config::REQUIREMENTS_FILENAME,
|
|
xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE,
|
|
];
|
|
|
|
/// Delete server-synced files then the marker (never `config.toml`).
|
|
fn remove_managed_config_files(home: &std::path::Path) {
|
|
let mut artifacts_removed = true;
|
|
for name in MANAGED_ARTIFACT_FILES {
|
|
artifacts_removed &= remove_synced_file(home, name, "removed managed config file");
|
|
}
|
|
// Marker last, only on full success: crash/error leaves the detector armed for the next start.
|
|
if artifacts_removed {
|
|
remove_synced_file(
|
|
home,
|
|
xai_grok_config::MANAGED_CONFIG_CACHE_FILE,
|
|
"removed managed config file",
|
|
);
|
|
}
|
|
// Best-effort sweep of mid-write `.tmp` leftovers (a concurrent writer's temp may go too —
|
|
// its rename fails and self-heals).
|
|
let atomic_write_tmp_prefixes = [
|
|
format!("{}.", xai_grok_config::MANAGED_CONFIG_CACHE_FILE),
|
|
format!(
|
|
"{}.",
|
|
xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE
|
|
),
|
|
];
|
|
if let Ok(entries) = std::fs::read_dir(home) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
let is_write_tmp = name.ends_with(".tmp")
|
|
&& atomic_write_tmp_prefixes
|
|
.iter()
|
|
.any(|prefix| name.starts_with(prefix.as_str()));
|
|
if is_write_tmp {
|
|
let _ = std::fs::remove_file(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns whether the path is gone (removed or already absent); `false` = removal failed.
|
|
fn remove_synced_file(home: &std::path::Path, name: &str, why: &str) -> bool {
|
|
let path = home.join(name);
|
|
match remove_managed_path(&path) {
|
|
Ok(true) => {
|
|
tracing::info!(file = %path.display(), "{why}");
|
|
true
|
|
}
|
|
Ok(false) => true,
|
|
Err(e) => {
|
|
tracing::warn!(file = %path.display(), error = %e, "failed to remove managed config file");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clear a directory squatting where a managed file is about to be WRITTEN — the atomic
|
|
/// rename would fail onto it forever, permanently blocking the self-heal. Best-effort:
|
|
/// the write's own error surfaces if clearing fails.
|
|
fn clear_squatting_dir(path: &std::path::Path) {
|
|
if std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir())
|
|
&& let Err(e) = remove_managed_path(path)
|
|
{
|
|
tracing::warn!(error = %e, "failed to clear a directory squatting at a managed config path");
|
|
}
|
|
}
|
|
|
|
/// Remove whatever occupies a managed artifact path — a squatting DIRECTORY too, else a
|
|
/// dir-squat would block removal and rewrite forever. Only ever called with the fixed
|
|
/// managed artifact/marker/sidecar names. `Ok(true)` = removed; `Ok(false)` = already absent.
|
|
fn remove_managed_path(path: &std::path::Path) -> std::io::Result<bool> {
|
|
let is_dir = std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir());
|
|
let result = if is_dir {
|
|
std::fs::remove_dir_all(path)
|
|
} else {
|
|
std::fs::remove_file(path)
|
|
};
|
|
match result {
|
|
Ok(()) => Ok(true),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// A team principal is eligible to fetch only if non-expired (an expired token
|
|
/// would just 401).
|
|
fn eligible_team_principal(auth: GrokAuth) -> Option<GrokAuth> {
|
|
(auth.is_team_principal() && !crate::auth::is_expired(&auth)).then_some(auth)
|
|
}
|
|
|
|
/// The eligible team principal in `auth.json`, or `None`. Single-team: managed
|
|
/// config is a grok.com feature with one grok.com auth.
|
|
fn read_active_team_auth() -> Option<GrokAuth> {
|
|
let home = crate::util::grok_home::grok_home();
|
|
let store = crate::auth::read_auth_json(&home.join("auth.json")).ok()?;
|
|
let team = store.values().find(|a| a.is_team_principal())?.clone();
|
|
eligible_team_principal(team)
|
|
}
|
|
|
|
pub(crate) fn has_active_team_auth() -> bool {
|
|
read_active_team_auth().is_some()
|
|
}
|
|
|
|
/// Whether any team principal is signed in, **ignoring expiry** (a cold-start
|
|
/// expired token is not a logout). `Err` = `auth.json` unreadable: callers must
|
|
/// NOT treat that as a logout — it would wipe enforced policy on a read blip.
|
|
fn team_principal_signed_in() -> std::io::Result<bool> {
|
|
let home = crate::util::grok_home::grok_home();
|
|
match crate::auth::read_auth_json(&home.join("auth.json")) {
|
|
Ok(store) => Ok(store.values().any(|a| a.is_team_principal())),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Clear the synced files when no principal could own them: no deployment key
|
|
/// configured and no team signed in (logout). A configured deployment key keeps
|
|
/// its files (original "never auto-deletes" behavior). Runs at startup and on
|
|
/// logout; best-effort.
|
|
pub fn clear_orphan() {
|
|
if resolve_deployment_key().is_some() {
|
|
return;
|
|
}
|
|
match team_principal_signed_in() {
|
|
Ok(true) => return,
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "auth.json unreadable; keeping managed config until it recovers");
|
|
return;
|
|
}
|
|
}
|
|
let home = crate::util::grok_home::grok_home();
|
|
let Some(_lock) = try_lock_managed_config(&home) else {
|
|
return; // another process is syncing; retry next call
|
|
};
|
|
remove_managed_config_files(&home);
|
|
}
|
|
|
|
/// Best-effort cross-process lock serializing apply/remove of the managed-config
|
|
/// files (TUI tick vs `grok login` vs prefetch). `None` on contention — the
|
|
/// caller skips and retries next cycle.
|
|
fn try_lock_managed_config(home: &std::path::Path) -> Option<std::fs::File> {
|
|
use fs2::FileExt;
|
|
let file = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(false)
|
|
.open(home.join("managed_config.lock"))
|
|
.ok()?;
|
|
file.try_lock_exclusive().ok()?;
|
|
Some(file)
|
|
}
|
|
|
|
/// Retry budget for a sync, pairing the attempt count with a wall-clock cap.
|
|
#[derive(Clone, Copy)]
|
|
enum SyncBudget {
|
|
/// Background loop and explicit `grok setup`; runs retries to completion.
|
|
Standard,
|
|
/// Post-login sync; capped because login latency is user-visible.
|
|
Login,
|
|
/// Session-start refresh; capped so startup never stalls.
|
|
SessionStart,
|
|
}
|
|
|
|
impl SyncBudget {
|
|
/// Total fetch attempts (first try included) for transient failures.
|
|
fn max_attempts(self) -> u32 {
|
|
match self {
|
|
Self::Standard => 5,
|
|
Self::Login | Self::SessionStart => 2,
|
|
}
|
|
}
|
|
|
|
/// Wall-clock cap, or `None` to let retries run to completion.
|
|
fn deadline(self) -> Option<std::time::Duration> {
|
|
match self {
|
|
Self::Standard => None,
|
|
Self::Login => Some(std::time::Duration::from_secs(15)),
|
|
Self::SessionStart => Some(std::time::Duration::from_secs(8)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Budget for the pre-heal `auth()` refresh, so a degraded network can't stall startup;
|
|
/// on timeout the heal proceeds with no refreshed override.
|
|
const SESSION_START_AUTH_DEADLINE: std::time::Duration = std::time::Duration::from_secs(8);
|
|
|
|
/// One retry of the gate purge's lock ([`purge_prior_tenant_on_identity_change`]): a routine
|
|
/// concurrent apply shouldn't become a session-start refusal, but a wedged holder can't stall start.
|
|
const PURGE_LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
|
|
|
|
/// Exponential backoff for retry `attempt` (caller guarantees `attempt >= 1`).
|
|
/// Base is 1s; `GROK_DEPLOYMENT_CONFIG_BACKOFF_MS` overrides it for tests.
|
|
fn retry_backoff(attempt: u32) -> std::time::Duration {
|
|
let base = std::env::var("GROK_DEPLOYMENT_CONFIG_BACKOFF_MS")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.unwrap_or(1000);
|
|
std::time::Duration::from_millis(base << attempt.saturating_sub(1))
|
|
}
|
|
|
|
/// Fetch the managed-config response, retrying transient (network / connection
|
|
/// interruption / 5xx) failures with exponential backoff. Auth errors fail
|
|
/// immediately, mapped via `source` so the message names the rejected credential.
|
|
///
|
|
/// Routes the whole once-fetch (send + body read + decode) through `crate::http::send_with_retry_escaping_pool`,
|
|
/// so a body-phase interruption is retried (not just the send) and the final attempt escapes a
|
|
/// poisoned pool on a fresh connection (see that helper for the escape policy).
|
|
async fn fetch_managed_config(
|
|
url: &str,
|
|
token: &str,
|
|
source: ManagedConfigSource,
|
|
max_attempts: u32,
|
|
) -> Result<ManagedConfigResponse, ManagedConfigError> {
|
|
crate::http::send_with_retry_escaping_pool(
|
|
move |client: reqwest::Client| async move {
|
|
fetch_managed_config_once(&client, url, token, source).await
|
|
},
|
|
max_attempts,
|
|
|e: &ManagedConfigError| e.is_retryable(),
|
|
|attempt| tokio::time::sleep(retry_backoff(attempt)),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Persist a fetched response under `home`, converging disk to the served set: served
|
|
/// artifacts are overwritten, unserved ones removed — a leftover must not keep enforcing
|
|
/// a withdrawn policy or trip the signed absence check. Returns whether anything changed.
|
|
fn apply_managed_config(
|
|
home: &std::path::Path,
|
|
body: &ManagedConfigResponse,
|
|
) -> std::io::Result<bool> {
|
|
use crate::util::config::atomic_write_string;
|
|
|
|
let artifacts = [
|
|
(
|
|
xai_grok_config::MANAGED_CONFIG_FILENAME,
|
|
body.managed_config.as_deref(),
|
|
),
|
|
(
|
|
xai_grok_config::REQUIREMENTS_FILENAME,
|
|
body.requirements.as_deref(),
|
|
),
|
|
];
|
|
|
|
let mut changed = false;
|
|
let mut first_err: Option<std::io::Error> = None;
|
|
for (name, content) in artifacts {
|
|
let path = home.join(name);
|
|
match content.filter(|s| !s.is_empty()) {
|
|
Some(content) => {
|
|
clear_squatting_dir(&path);
|
|
match atomic_write_string(&path, content) {
|
|
Ok(()) => changed = true,
|
|
Err(e) => {
|
|
first_err.get_or_insert(e);
|
|
}
|
|
}
|
|
}
|
|
None => match remove_managed_path(&path) {
|
|
Ok(true) => {
|
|
tracing::info!("removed managed config artifact the server no longer serves");
|
|
changed = true;
|
|
}
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
first_err.get_or_insert(e);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
tracing::info!("managed config refreshed from server");
|
|
}
|
|
match first_err {
|
|
Some(e) => Err(e),
|
|
None => Ok(changed),
|
|
}
|
|
}
|
|
|
|
/// Map a classified transport failure to a `ManagedConfigError`. Split out from [`map_send_error`]
|
|
/// so the mapping (and its retryability) is unit-testable by constructing `TransportFailure` directly.
|
|
fn map_transport_failure(failure: crate::http::TransportFailure) -> ManagedConfigError {
|
|
use crate::http::TransportFailureKind;
|
|
match failure.kind {
|
|
TransportFailureKind::Unreachable => ManagedConfigError::Network(failure.detail),
|
|
TransportFailureKind::Interrupted => {
|
|
ManagedConfigError::ConnectionInterrupted(failure.detail)
|
|
}
|
|
// A builder/redirect failure is a client-side defect, not a bad server response: terminal.
|
|
TransportFailureKind::Permanent => ManagedConfigError::RequestFailed(failure.detail),
|
|
}
|
|
}
|
|
|
|
/// Map a `reqwest` send failure to a `ManagedConfigError` via the shared `xai-grok-http` classifier.
|
|
fn map_send_error(e: &reqwest::Error) -> ManagedConfigError {
|
|
map_transport_failure(crate::http::TransportFailure::classify(e))
|
|
}
|
|
|
|
async fn fetch_managed_config_once(
|
|
client: &reqwest::Client,
|
|
url: &str,
|
|
token: &str,
|
|
source: ManagedConfigSource,
|
|
) -> Result<ManagedConfigResponse, ManagedConfigError> {
|
|
let resp = match client
|
|
.get(url)
|
|
.header("Authorization", format!("Bearer {}", token))
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) if r.status().is_success() => r,
|
|
Ok(r) => {
|
|
let status = r.status().as_u16();
|
|
tracing::debug!(status, "managed config fetch failed");
|
|
return Err(if status == 401 || status == 403 {
|
|
source.auth_rejected_error()
|
|
} else {
|
|
ManagedConfigError::ServerError { status }
|
|
});
|
|
}
|
|
Err(e) => {
|
|
let err = map_send_error(&e);
|
|
tracing::debug!(error = %err, "managed config fetch error");
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
// Split the body read from the decode so the FAILING OPERATION disambiguates transport from
|
|
// payload: reqwest tags both a mid-body connection drop and malformed JSON as `Kind::Decode`
|
|
// from `json()`, so reading raw `bytes()` first (any error there is an in-flight transport
|
|
// interruption, retryable) then `from_slice` (any error there is a malformed payload, terminal)
|
|
// avoids fragile error-kind/source inspection.
|
|
let bytes = match resp.bytes().await {
|
|
Ok(b) => b,
|
|
// A body-read failure is an in-flight transport interruption, so it is retryable.
|
|
Err(e) => {
|
|
return Err(ManagedConfigError::ConnectionInterrupted(
|
|
crate::http::error_cause_chain(&e),
|
|
));
|
|
}
|
|
};
|
|
serde_json::from_slice::<ManagedConfigResponse>(&bytes)
|
|
.map_err(|e| ManagedConfigError::InvalidResponse(e.to_string()))
|
|
}
|
|
|
|
/// Override with `GROK_DEPLOYMENT_CONFIG_REFRESH_INTERVAL_SECS`. Clamped to
|
|
/// >= 1s: `tokio::time::interval` panics on a zero period.
|
|
fn managed_config_sync_interval() -> std::time::Duration {
|
|
if let Ok(s) = std::env::var("GROK_DEPLOYMENT_CONFIG_REFRESH_INTERVAL_SECS")
|
|
&& let Ok(secs) = s.parse::<u64>()
|
|
{
|
|
return std::time::Duration::from_secs(secs.max(1));
|
|
}
|
|
std::time::Duration::from_secs(5 * 60)
|
|
}
|
|
|
|
/// Periodically sync managed config in the background. Best-effort.
|
|
pub(crate) fn spawn_sync(cancel: tokio_util::sync::CancellationToken) {
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(managed_config_sync_interval());
|
|
interval.tick().await; // skip immediate first tick
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = cancel.cancelled() => break,
|
|
_ = interval.tick() => {}
|
|
}
|
|
|
|
// Clear a logged-out team's files before deciding to fetch, so
|
|
// stale enforced policy never outlives the tick.
|
|
clear_orphan();
|
|
|
|
if !crate::config::is_managed_config_stale_for(¤t_serving_identity())
|
|
|| !is_fetch_enabled()
|
|
{
|
|
continue;
|
|
}
|
|
|
|
match sync().await {
|
|
Ok(true) => tracing::info!("background managed config sync: updated"),
|
|
Ok(false) => {}
|
|
Err(e) => tracing::debug!("background managed config sync failed: {e}"),
|
|
}
|
|
}
|
|
|
|
tracing::debug!("managed config sync task stopped");
|
|
});
|
|
}
|
|
|
|
/// Deployment id reported for `deployment_key` on chat requests, credential
|
|
/// snapshots, and OTel: the **server** GrokBuildDeployment UUID (the id
|
|
/// server-side dashboards filter on) when the managed-config sync marker was
|
|
/// written by this same key (fingerprint match), else UUIDv5 of the key.
|
|
/// `None` key (team/OAuth) → `None`, never a stale marker value.
|
|
pub fn resolve_deployment_id(deployment_key: Option<&str>) -> Option<String> {
|
|
let key = deployment_key.filter(|k| !k.is_empty())?;
|
|
crate::config::managed_deployment_id(&deployment_key_fingerprint(key))
|
|
.or_else(|| Some(crate::agent::config::deployment_id_from_key(key)))
|
|
}
|
|
|
|
/// Resolve deployment key from `GROK_DEPLOYMENT_KEY` env var, then config files.
|
|
pub fn resolve_deployment_key() -> Option<String> {
|
|
let config_val = crate::config::load_effective_config()
|
|
.map_err(|e| tracing::warn!("failed to load config files for deployment key: {e}"))
|
|
.ok()
|
|
.and_then(|root| {
|
|
root.get("endpoints")?
|
|
.get("deployment_key")?
|
|
.as_str()
|
|
.map(|s| s.to_owned())
|
|
});
|
|
crate::agent::config::resolve_string_flag(
|
|
None,
|
|
"GROK_DEPLOYMENT_KEY",
|
|
config_val.as_deref(),
|
|
None,
|
|
)
|
|
.map(|r| r.value)
|
|
}
|
|
|
|
/// One-way blake3 fingerprint of a deployment key — the deploy-key identity (see [`crate::config::ServingIdentity`]).
|
|
/// Deterministic so the same key matches its marker; the raw key is never written to disk.
|
|
fn deployment_key_fingerprint(key: &str) -> String {
|
|
blake3::hash(key.as_bytes()).to_hex().to_string()
|
|
}
|
|
|
|
/// Whether managed config fetching is enabled (env > config.toml > default true).
|
|
/// Callers doing auto-fetch should check this; explicit user actions (grok setup) skip it.
|
|
pub fn is_fetch_enabled() -> bool {
|
|
if let Some(v) = crate::agent::config::env_bool("GROK_MANAGED_CONFIG") {
|
|
return v;
|
|
}
|
|
crate::config::load_effective_config()
|
|
.ok()
|
|
.and_then(|cfg| cfg.get("features")?.get("managed_config")?.as_bool())
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
/// Fetch managed config + requirements and write to `~/.grok/`, trying the
|
|
/// deployment key first, then a signed-in team. `Ok(false)` when neither applies.
|
|
pub async fn sync() -> Result<bool, ManagedConfigError> {
|
|
Ok(sync_with_budget(SyncBudget::Standard, None).await?.wrote)
|
|
}
|
|
|
|
struct SyncOutcome {
|
|
wrote: bool,
|
|
/// Server returned a config row for the consulted principal (independent of apply).
|
|
served: bool,
|
|
/// Apply persisted nothing and recorded no marker — see [`ApplyOutcome::Skipped`].
|
|
skipped: bool,
|
|
/// Credential consulted (team vs deployment wording for callers).
|
|
source: Option<ManagedConfigSource>,
|
|
/// Verification active and envelope rejected — nothing persisted.
|
|
signature_rejected: bool,
|
|
}
|
|
|
|
impl SyncOutcome {
|
|
/// Reports only what callers render; marker identity fields live in [`apply_fetched`].
|
|
fn from_fetch(
|
|
body: &ManagedConfigResponse,
|
|
source: ManagedConfigSource,
|
|
outcome: &ApplyOutcome,
|
|
) -> Self {
|
|
Self {
|
|
wrote: outcome.wrote(),
|
|
served: body.config_exists(),
|
|
skipped: outcome.skipped(),
|
|
source: Some(source),
|
|
signature_rejected: outcome.signature_rejected(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs a sync under `budget`'s deadline, returning `None` when the deadline
|
|
/// elapses first.
|
|
async fn sync_bounded(
|
|
budget: SyncBudget,
|
|
team_override: Option<GrokAuth>,
|
|
) -> Option<Result<SyncOutcome, ManagedConfigError>> {
|
|
let sync = sync_with_budget(budget, team_override);
|
|
match budget.deadline() {
|
|
Some(deadline) => tokio::time::timeout(deadline, sync).await.ok(),
|
|
None => Some(sync.await),
|
|
}
|
|
}
|
|
|
|
/// A server response paired with the credential that fetched it.
|
|
enum FetchedConfig {
|
|
DeploymentKey {
|
|
key: String,
|
|
body: ManagedConfigResponse,
|
|
},
|
|
Team {
|
|
auth: Box<GrokAuth>,
|
|
body: ManagedConfigResponse,
|
|
},
|
|
/// No deployment key configured and no eligible team signed in.
|
|
NoPrincipal,
|
|
}
|
|
|
|
/// Fetches the configuration for the current principal without touching disk:
|
|
/// the deployment key first, then a signed-in team. The installing sync and the
|
|
/// read-only `grok setup --json` both build on this.
|
|
async fn fetch_for_principal(
|
|
budget: SyncBudget,
|
|
team_override: Option<GrokAuth>,
|
|
) -> Result<FetchedConfig, ManagedConfigError> {
|
|
let max_attempts = budget.max_attempts();
|
|
// Resolve from the merged config (managed_config_url > cli_chat_proxy_base_url,
|
|
// including the enterprise single-endpoint derivation) so endpoint overrides
|
|
// are honored and the bearer isn't sent to the public default.
|
|
let url =
|
|
crate::agent::config::EndpointsConfig::from_effective_config().resolve_managed_config_url();
|
|
|
|
let team_auth = team_override.or_else(read_active_team_auth);
|
|
|
|
if let Some(dk) = resolve_deployment_key() {
|
|
let source = ManagedConfigSource::DeploymentKey;
|
|
match fetch_managed_config(&url, &dk, source, max_attempts).await {
|
|
// A rejected dk (stale env/config) must not starve a valid team
|
|
// sign-in: fall through. Network/5xx do NOT — same unreachable
|
|
// server, double the latency for nothing.
|
|
Err(ManagedConfigError::DeploymentKeyRejected) if team_auth.is_some() => {
|
|
tracing::warn!("deployment key rejected; falling back to the team session token");
|
|
}
|
|
Err(e) => return Err(e),
|
|
// Fall through to the team only when the dk has no config row: an apply
|
|
// converges disk to the served set, and the empty dk body must not delete
|
|
// the team's files. Gate on row existence, not content (which can serve empty).
|
|
Ok(body) if !body.config_exists() && team_auth.is_some() => {
|
|
tracing::debug!("deployment key has no config; trying the team principal");
|
|
}
|
|
Ok(body) => return Ok(FetchedConfig::DeploymentKey { key: dk, body }),
|
|
}
|
|
}
|
|
|
|
// The proxy resolves the team from the principal and returns its config.
|
|
if let Some(auth) = team_auth {
|
|
let body = fetch_managed_config(
|
|
&url,
|
|
&auth.key,
|
|
ManagedConfigSource::TeamOauth,
|
|
max_attempts,
|
|
)
|
|
.await?;
|
|
return Ok(FetchedConfig::Team {
|
|
auth: Box::new(auth),
|
|
body,
|
|
});
|
|
}
|
|
|
|
Ok(FetchedConfig::NoPrincipal)
|
|
}
|
|
|
|
/// `team_override` pins a specific team principal (the just-authenticated one,
|
|
/// post-login) instead of re-deriving the team from `auth.json`; `None` uses
|
|
/// [`read_active_team_auth`]. Marker is written under the lock by [`apply_fetched`].
|
|
async fn sync_with_budget(
|
|
budget: SyncBudget,
|
|
team_override: Option<GrokAuth>,
|
|
) -> Result<SyncOutcome, ManagedConfigError> {
|
|
match fetch_for_principal(budget, team_override).await? {
|
|
FetchedConfig::DeploymentKey { key, body } => {
|
|
let source = ManagedConfigSource::DeploymentKey;
|
|
let fingerprint = deployment_key_fingerprint(&key);
|
|
let outcome = apply_fetched(
|
|
&body,
|
|
source,
|
|
body.deployment_id.as_deref(),
|
|
Some(&fingerprint),
|
|
)?;
|
|
Ok(SyncOutcome::from_fetch(&body, source, &outcome))
|
|
}
|
|
FetchedConfig::Team { auth, body } => {
|
|
let source = ManagedConfigSource::TeamOauth;
|
|
// Team identity is bound via principal (team id), not a key fingerprint.
|
|
let outcome = apply_fetched(&body, source, auth.team_id.as_deref(), None)?;
|
|
Ok(SyncOutcome::from_fetch(&body, source, &outcome))
|
|
}
|
|
FetchedConfig::NoPrincipal => Ok(SyncOutcome {
|
|
wrote: false,
|
|
served: false,
|
|
skipped: false,
|
|
source: None,
|
|
signature_rejected: false,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Apply under the cross-process lock (`Skipped` if contended — holder's sync supersedes).
|
|
/// `new_principal` / `new_key_fingerprint` are the serving identity for pre-write eviction.
|
|
fn apply_fetched(
|
|
body: &ManagedConfigResponse,
|
|
source: ManagedConfigSource,
|
|
new_principal: Option<&str>,
|
|
new_key_fingerprint: Option<&str>,
|
|
) -> std::io::Result<ApplyOutcome> {
|
|
// Verify before lock/persist: prior trusted policy survives a bad fetch. Pure so a
|
|
// lock-skip never reports Applied for an envelope that would have failed.
|
|
let verified = if xai_grok_config::signed_policy::verification_active() {
|
|
match verify_signed_envelope(body, active_team_id_any_expiry().as_deref()) {
|
|
Ok(verified) => Some(verified),
|
|
Err(e) => {
|
|
tracing::warn!("managed config signature rejected; not persisting: {e}");
|
|
return Ok(ApplyOutcome::SignatureRejected);
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let signed_deployment_id = verified
|
|
.as_ref()
|
|
.and_then(|v| v.payload.deployment_id.clone());
|
|
let home = crate::util::grok_home::grok_home();
|
|
let Some(_lock) = try_lock_managed_config(&home) else {
|
|
tracing::debug!("managed config locked by another process; skipping apply");
|
|
return Ok(ApplyOutcome::Skipped);
|
|
};
|
|
// Credential may have vanished mid-fetch (logout → clear_orphan); don't restore it.
|
|
if !credential_present(source) {
|
|
tracing::info!("credential gone since fetch started; skipping apply");
|
|
return Ok(ApplyOutcome::Skipped);
|
|
}
|
|
// Confirmed switch: evict first so omitted artifacts from the prior principal don't stick.
|
|
// Same locked `home` as the flock + marker write (no re-resolve).
|
|
if crate::config::managed_config_identity_changed_at(&home, new_principal, new_key_fingerprint)
|
|
{
|
|
evict_prior_managed_config(&home);
|
|
}
|
|
let wrote = apply_managed_config(&home, body)?;
|
|
// Sidecar after policy files so a present sidecar covers the final set; clear dir squats
|
|
// that would fail the atomic rename forever.
|
|
if let Some(verified) = verified {
|
|
clear_squatting_dir(&home.join(xai_grok_config::signed_policy::SIGNATURE_SIDECAR_FILE));
|
|
xai_grok_config::signed_policy::write_sidecar(&home, &verified.sidecar)?;
|
|
}
|
|
// Marker last, still under the lock: written post-release, a concurrent purge could
|
|
// delete the files it describes. A squatting dir would fail the atomic rename forever.
|
|
clear_squatting_dir(&home.join(xai_grok_config::MANAGED_CONFIG_CACHE_FILE));
|
|
crate::config::mark_managed_config_synced_at(
|
|
&home,
|
|
crate::config::SyncMarker {
|
|
// DK: prefer verified payload deployment id (signed-empty only has it there).
|
|
// Team: always the serving team — a deployment-signed envelope must not rebind it.
|
|
principal: if new_key_fingerprint.is_some() {
|
|
signed_deployment_id.as_deref().or(new_principal)
|
|
} else {
|
|
new_principal
|
|
},
|
|
had_managed_config: body.has_managed_config(),
|
|
had_requirements: body.has_requirements(),
|
|
key_fingerprint: new_key_fingerprint,
|
|
fail_closed: body.requirements_fail_closed(),
|
|
},
|
|
);
|
|
Ok(ApplyOutcome::Applied { wrote })
|
|
}
|
|
|
|
/// Evict the prior principal's policy artifacts on a confirmed switch; this apply then
|
|
/// writes the new set and rebinds the marker. Includes the sidecar — a verification-inactive
|
|
/// build must not leave the prior tenant's sidecar to read foreign-bound on a signing build.
|
|
fn evict_prior_managed_config(home: &std::path::Path) {
|
|
for name in MANAGED_ARTIFACT_FILES {
|
|
remove_synced_file(home, name, "evicted prior principal's artifact");
|
|
}
|
|
}
|
|
|
|
/// Whether the credential a fetch used is still present. Mirrors the
|
|
/// expiry-agnostic, fail-safe checks `clear_orphan` uses (an unreadable
|
|
/// `auth.json` keeps, not drops).
|
|
fn credential_present(source: ManagedConfigSource) -> bool {
|
|
match source {
|
|
ManagedConfigSource::DeploymentKey => resolve_deployment_key().is_some(),
|
|
ManagedConfigSource::TeamOauth => team_principal_signed_in().unwrap_or(true),
|
|
}
|
|
}
|
|
|
|
/// Outcome of [`post_login_sync`], for the CLI to render. The
|
|
/// TUI/agent path ignores it (the sync is best-effort and detached there).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ManagedConfigSync {
|
|
/// No eligible principal, fetch disabled, or nothing due — no fetch made.
|
|
Skipped,
|
|
/// New config was written. `is_team` lets the caller word the confirmation
|
|
/// (team vs deployment).
|
|
Updated { is_team: bool },
|
|
/// Fetch ran; nothing new to write.
|
|
NoChange,
|
|
/// Fetch failed or timed out (already logged); the background loop retries.
|
|
Failed,
|
|
}
|
|
|
|
/// Post-login hook for `grok login` and the ACP/TUI authenticate flow: clear any
|
|
/// orphaned files, then fetch the new principal's config immediately rather than
|
|
/// waiting for the background tick. `authenticated` pins the just-logged-in
|
|
/// principal (`None` = on-disk team). Latency-bounded by [`SyncBudget::Login`];
|
|
/// failures are logged, not propagated (the background loop retries).
|
|
pub async fn post_login_sync(authenticated: Option<GrokAuth>) -> ManagedConfigSync {
|
|
clear_orphan();
|
|
if !is_fetch_enabled() {
|
|
return ManagedConfigSync::Skipped;
|
|
}
|
|
// The just-authenticated team, else the on-disk one — reused for the gate
|
|
// and the sync (one auth.json read). With no team, only sync if due anyway.
|
|
let team = authenticated
|
|
.and_then(eligible_team_principal)
|
|
.or_else(read_active_team_auth);
|
|
if team.is_none() && !crate::config::is_managed_config_stale_for(¤t_serving_identity()) {
|
|
return ManagedConfigSync::Skipped;
|
|
}
|
|
match sync_bounded(SyncBudget::Login, team).await {
|
|
// Nothing was persisted for a rejected envelope — that's a failure to
|
|
// report, not "no change" (the gate may refuse the next session).
|
|
Some(Ok(SyncOutcome {
|
|
signature_rejected: true,
|
|
..
|
|
})) => {
|
|
tracing::warn!("post-login managed config sync: server envelope rejected");
|
|
ManagedConfigSync::Failed
|
|
}
|
|
Some(Ok(SyncOutcome {
|
|
wrote: true,
|
|
source,
|
|
..
|
|
})) => {
|
|
tracing::info!("post-login managed config sync: updated");
|
|
ManagedConfigSync::Updated {
|
|
is_team: source == Some(ManagedConfigSource::TeamOauth),
|
|
}
|
|
}
|
|
Some(Ok(_)) => ManagedConfigSync::NoChange,
|
|
Some(Err(e)) => {
|
|
tracing::debug!("post-login managed config sync failed: {e}");
|
|
ManagedConfigSync::Failed
|
|
}
|
|
None => {
|
|
tracing::debug!("post-login managed config sync timed out");
|
|
ManagedConfigSync::Failed
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a credential exists that `grok setup` could install config for.
|
|
pub fn has_principal() -> bool {
|
|
resolve_deployment_key().is_some() || read_active_team_auth().is_some()
|
|
}
|
|
|
|
/// Whether a managed identity owns this machine, IGNORING token expiry (unlike [`has_principal`]) so an
|
|
/// expired/backdated `auth.json` can't disarm the gate. Unreadable → present (fail-safe; the gate ANDs this
|
|
/// with [`crate::config::managed_policy_compromised_for`], which a personal user never satisfies).
|
|
fn managed_principal_present() -> bool {
|
|
resolve_deployment_key().is_some() || team_principal_signed_in().unwrap_or(true)
|
|
}
|
|
|
|
/// The serving identity for an optional team id: a configured deployment key always
|
|
/// wins (keyed on its fingerprint), else the team, else none. The two public views
|
|
/// differ only in how the team id is resolved (expiry-filtered vs expiry-ignoring).
|
|
fn serving_identity_from(team_id: Option<String>) -> crate::config::ServingIdentity {
|
|
use crate::config::ServingIdentity;
|
|
if let Some(key) = resolve_deployment_key() {
|
|
return ServingIdentity::DeploymentKey {
|
|
fingerprint: deployment_key_fingerprint(&key),
|
|
};
|
|
}
|
|
// Blank = unknown; trimmed (same rule as the marker write) so whitespace isn't identity.
|
|
match crate::config::normalize_identity(team_id.as_deref()) {
|
|
Some(team_id) => ServingIdentity::Team(team_id),
|
|
None => ServingIdentity::None,
|
|
}
|
|
}
|
|
|
|
/// The identity to check the cache against for whoever serves now: a configured deployment key wins
|
|
/// (else the active team, else none).
|
|
pub fn current_serving_identity() -> crate::config::ServingIdentity {
|
|
serving_identity_from(read_active_team_auth().and_then(|a| a.team_id))
|
|
}
|
|
|
|
/// The client's team_id, IGNORING token expiry (the binding must survive the cold-start
|
|
/// expired window). Must NOT special-case a configured deployment key — that would
|
|
/// disable envelope binding for a real team user. Used at fetch time to bind the envelope.
|
|
pub fn active_team_id_any_expiry() -> Option<String> {
|
|
let home = crate::util::grok_home::grok_home();
|
|
let store = crate::auth::read_auth_json(&home.join("auth.json")).ok()?;
|
|
store
|
|
.values()
|
|
.find(|a| a.is_team_principal())
|
|
// Blank → None, trimmed: a malformed/padded auth.json team_id must read as the SAME
|
|
// identity everywhere it feeds — the gate, the tenant-switch purge, and the envelope
|
|
// binding (an untrimmed id here would fail `check_fetch_identity` against a trimmed
|
|
// signed payload forever).
|
|
.and_then(|a| crate::config::normalize_identity(a.team_id.as_deref()))
|
|
}
|
|
|
|
/// Like [`current_serving_identity`] but IGNORING token expiry, for the enforcement gate:
|
|
/// a backdated `auth.json` must not resolve the team to `None` and relax the identity
|
|
/// checks. The refetch path stays expiry-filtered (a stray refetch is harmless).
|
|
fn current_serving_identity_any_expiry() -> crate::config::ServingIdentity {
|
|
serving_identity_from(active_team_id_any_expiry())
|
|
}
|
|
|
|
/// Best-effort session-start refresh: a bounded token refresh, then a bounded refetch only when the cache is
|
|
/// hard-stale. NEVER fails the session — on failure it continues on cached / OS-protected policy.
|
|
pub async fn ensure_managed_policy_present(
|
|
auth_manager: &std::sync::Arc<crate::auth::AuthManager>,
|
|
) {
|
|
// Gated on fetch-enabled, not `cfg!(test)` — that would diverge test behavior from production.
|
|
if !is_fetch_enabled() {
|
|
return;
|
|
}
|
|
// Cheap disk-only gates before any network token refresh, so the boot path doesn't pay
|
|
// an `auth()` in the common cases. A personal user (no deploy key, and no team in
|
|
// `auth.json` even ignoring expiry) skips entirely; a usable identity whose cache isn't
|
|
// hard-stale also skips. Only an expired-but-refreshable team token (identity reads
|
|
// `None` before the refresh) or a hard-stale cache falls through to `auth()` below.
|
|
// `auth.json` unreadable (`Err`) is NOT treated as "no principal" — that would skip
|
|
// enforcement on a transient read blip.
|
|
if resolve_deployment_key().is_none() && matches!(team_principal_signed_in(), Ok(false)) {
|
|
return;
|
|
}
|
|
let identity = current_serving_identity();
|
|
if !matches!(identity, crate::config::ServingIdentity::None)
|
|
&& !crate::config::is_managed_config_hard_stale_for(&identity)
|
|
{
|
|
return;
|
|
}
|
|
// Refresh before the heal so an expired-but-refreshable team token isn't dropped by
|
|
// the expiry filter. Bounded; deploy-key machines have no OAuth (auth() → None).
|
|
let team = tokio::time::timeout(SESSION_START_AUTH_DEADLINE, auth_manager.auth())
|
|
.await
|
|
.ok()
|
|
.and_then(Result::ok)
|
|
.filter(GrokAuth::is_team_principal);
|
|
if !has_principal() {
|
|
return;
|
|
}
|
|
if !crate::config::is_managed_config_hard_stale_for(¤t_serving_identity()) {
|
|
return;
|
|
}
|
|
match sync_bounded(SyncBudget::SessionStart, team).await {
|
|
Some(Ok(_)) => {}
|
|
Some(Err(e)) => tracing::warn!("session-start managed policy refresh failed: {e}"),
|
|
None => tracing::warn!("session-start managed policy refresh timed out"),
|
|
}
|
|
}
|
|
|
|
/// Shown when a managed principal's enforced policy is missing/substituted and the refetch couldn't restore it.
|
|
const MANAGED_POLICY_MISSING_MSG: &str = "Managed policy is required for this account but is \
|
|
missing or could not be verified, and could not be restored from the server.\nThis check needs \
|
|
network access: reconnect and start again. If you can't reconnect, contact your administrator.";
|
|
|
|
/// Fail-closed session-start gate for managed principals. On a confirmed offline team
|
|
/// switch, first purges the prior team's artifacts ([`purge_prior_tenant_on_identity_change`]).
|
|
/// Without a signing key the user-writable marker is best-effort; root/MDM/signed cache
|
|
/// are the non-forgeable layers. Recovery: reconnect / `grok setup`; ceasing to serve
|
|
/// `fail_closed` rolls back.
|
|
pub fn managed_policy_gate() -> Result<(), String> {
|
|
// Lib unit tests skip: bootstrap would hit the host's real marker/auth. Pure decision
|
|
// is unit-tested; integration tests exercise this path.
|
|
if cfg!(test) {
|
|
return Ok(());
|
|
}
|
|
// Purge first so an offline team switch isn't misread as a substituted cache.
|
|
purge_prior_tenant_on_identity_change();
|
|
managed_policy_gate_decision(
|
|
managed_principal_present(),
|
|
// Expiry-ignoring: a backdated auth.json must not resolve Team→None and relax binding.
|
|
crate::config::managed_policy_compromised_for(¤t_serving_identity_any_expiry()),
|
|
)
|
|
}
|
|
|
|
/// Purge prior team (A) artifacts on a confirmed offline team switch so the gate admits
|
|
/// team B. Detector is marker-scoped ([`crate::config::confirmed_team_switch`]): key-scoped
|
|
/// markers never purge here; config.toml blips are not switches. Under the managed-config
|
|
/// lock (one retry on contention, else skip like [`clear_orphan`]); a skip may refuse one
|
|
/// signed-build start until the next purge.
|
|
fn purge_prior_tenant_on_identity_change() {
|
|
let crate::config::ServingIdentity::Team(team_id) = current_serving_identity_any_expiry()
|
|
else {
|
|
return;
|
|
};
|
|
// Same home for pre-check, lock, detector, and delete.
|
|
let home = crate::util::grok_home::grok_home();
|
|
// Unlocked pre-check: common no-switch start takes no lock; re-check under lock before delete.
|
|
if crate::config::confirmed_team_switch_at(&home, &team_id).is_none() {
|
|
return;
|
|
}
|
|
let Some(_lock) = try_lock_managed_config(&home).or_else(|| {
|
|
std::thread::sleep(PURGE_LOCK_RETRY_DELAY);
|
|
try_lock_managed_config(&home)
|
|
}) else {
|
|
return; // mid-apply/remove; holder owns the transition
|
|
};
|
|
if let Some(evicted) = crate::config::confirmed_team_switch_at(&home, &team_id) {
|
|
tracing::warn!(
|
|
team_id = %team_id,
|
|
evicted_principal = %evicted,
|
|
"identity changed; purging the prior tenant's managed config"
|
|
);
|
|
remove_managed_config_files(&home);
|
|
}
|
|
}
|
|
|
|
/// Pure decision behind [`managed_policy_gate`]: fail closed only when a managed principal is active AND its policy is compromised.
|
|
fn managed_policy_gate_decision(
|
|
managed_principal_present: bool,
|
|
policy_compromised: bool,
|
|
) -> Result<(), String> {
|
|
if managed_principal_present && policy_compromised {
|
|
return Err(MANAGED_POLICY_MISSING_MSG.to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Outcome of the `grok setup` sync. The caller renders it — CLI presentation
|
|
/// and exit codes stay out of the library.
|
|
#[derive(Debug)]
|
|
pub enum SetupOutcome {
|
|
/// Config was written to `~/.grok`.
|
|
Installed,
|
|
/// The principal is valid but the server has no config for it.
|
|
NothingConfigured,
|
|
/// Nothing persisted by THIS run (another process held the apply lock, or the credential
|
|
/// vanished mid-fetch); re-running converges.
|
|
Skipped,
|
|
/// The fetch failed.
|
|
Failed(ManagedConfigError),
|
|
}
|
|
|
|
/// Result of `grok setup --json`: what the server serves for the current
|
|
/// principal, verbatim. `managed_config` may embed the enforced deployment key,
|
|
/// exactly as `grok setup` would write it to disk.
|
|
#[derive(Debug, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SetupReport {
|
|
/// The credential that served: `"deploymentKey"` or `"teamOauth"`, or
|
|
/// `None` when no principal was available.
|
|
pub source: Option<&'static str>,
|
|
/// Whether the server has a configuration for the principal.
|
|
pub configured: bool,
|
|
pub deployment_id: Option<String>,
|
|
pub team_id: Option<String>,
|
|
/// TOML documents exactly as `grok setup` would install them.
|
|
pub managed_config: Option<String>,
|
|
pub requirements: Option<String>,
|
|
pub fail_closed: bool,
|
|
}
|
|
|
|
/// Fetches the report behind `grok setup --json` without writing anything:
|
|
/// no artifacts, no signature sidecar, no sync marker.
|
|
pub async fn fetch_setup_report() -> Result<SetupReport, ManagedConfigError> {
|
|
let (source, body) = match fetch_for_principal(SyncBudget::Standard, None).await? {
|
|
FetchedConfig::DeploymentKey { body, .. } => (Some("deploymentKey"), body),
|
|
FetchedConfig::Team { body, .. } => (Some("teamOauth"), body),
|
|
FetchedConfig::NoPrincipal => (None, ManagedConfigResponse::default()),
|
|
};
|
|
// Match the installer's trust decision: a payload `grok setup` would refuse
|
|
// is reported as an error, not printed as installable config.
|
|
if source.is_some()
|
|
&& xai_grok_config::signed_policy::verification_active()
|
|
&& let Err(e) = verify_signed_envelope(&body, active_team_id_any_expiry().as_deref())
|
|
{
|
|
tracing::warn!("managed config signature rejected: {e}");
|
|
return Err(ManagedConfigError::SignatureRejected);
|
|
}
|
|
Ok(SetupReport {
|
|
source,
|
|
configured: body.config_exists(),
|
|
fail_closed: body.requirements_fail_closed(),
|
|
deployment_id: body.deployment_id,
|
|
team_id: body.team_id,
|
|
managed_config: body.managed_config,
|
|
requirements: body.requirements,
|
|
})
|
|
}
|
|
|
|
/// Run the `grok setup` sync for the current principal. The caller must check
|
|
/// [`has_principal`] first and render the no-principal guidance.
|
|
pub async fn run_setup() -> SetupOutcome {
|
|
match sync_with_budget(SyncBudget::Standard, None).await {
|
|
// A rejected envelope persisted nothing — reporting Installed would mask a
|
|
// fetch the gate is about to refuse.
|
|
Ok(SyncOutcome {
|
|
signature_rejected: true,
|
|
..
|
|
}) => SetupOutcome::Failed(ManagedConfigError::SignatureRejected),
|
|
// A skip persisted nothing: not Installed (this run wrote nothing) nor NothingConfigured
|
|
// (the server does have config).
|
|
Ok(SyncOutcome { skipped: true, .. }) => SetupOutcome::Skipped,
|
|
// `served` (not `wrote`) so an unchanged re-fetch isn't reported as "no config".
|
|
Ok(SyncOutcome { served: true, .. }) => SetupOutcome::Installed,
|
|
Ok(_) => SetupOutcome::NothingConfigured,
|
|
Err(e) => SetupOutcome::Failed(e),
|
|
}
|
|
}
|
|
|
|
// Tests in a sibling file (they dwarf the module) but a child module, for private access.
|
|
#[cfg(test)]
|
|
#[path = "managed_config/tests.rs"]
|
|
mod tests;
|