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:
parent
c68e39f604
commit
8adf9013a0
117 changed files with 16998 additions and 14540 deletions
|
|
@ -116,17 +116,20 @@ fn parse_args(argv: Vec<String>) -> Args {
|
|||
}
|
||||
|
||||
fn load_config(path: Option<&std::path::Path>) -> VoiceConfig {
|
||||
// The probe has no shell config stack; env is the resolved fallback
|
||||
// (config table still beats it, matching the pager's precedence).
|
||||
let env_base = std::env::var("GROK_XAI_API_BASE_URL").ok();
|
||||
if let Some(path) = path
|
||||
&& let Ok(raw) = std::fs::read_to_string(path)
|
||||
&& let Ok(table) = toml::from_str::<toml::Table>(&raw)
|
||||
{
|
||||
return VoiceConfig::from_config_table(&table);
|
||||
return VoiceConfig::from_config_table(&table, env_base.as_deref());
|
||||
}
|
||||
if let Ok(home) = std::env::var("GROK_HOME")
|
||||
&& let Ok(raw) = std::fs::read_to_string(PathBuf::from(home).join("config.toml"))
|
||||
&& let Ok(table) = toml::from_str::<toml::Table>(&raw)
|
||||
{
|
||||
return VoiceConfig::from_config_table(&table);
|
||||
return VoiceConfig::from_config_table(&table, env_base.as_deref());
|
||||
}
|
||||
if let Ok(raw) = std::fs::read_to_string(
|
||||
std::env::var("HOME")
|
||||
|
|
@ -135,9 +138,9 @@ fn load_config(path: Option<&std::path::Path>) -> VoiceConfig {
|
|||
.join(".grok/config.toml"),
|
||||
) && let Ok(table) = toml::from_str::<toml::Table>(&raw)
|
||||
{
|
||||
return VoiceConfig::from_config_table(&table);
|
||||
return VoiceConfig::from_config_table(&table, env_base.as_deref());
|
||||
}
|
||||
VoiceConfig::default()
|
||||
VoiceConfig::from_config_table(&toml::Table::new(), env_base.as_deref())
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
|
|
|
|||
|
|
@ -4,36 +4,26 @@ use crate::error::VoiceError;
|
|||
|
||||
/// Voice settings for the STT transport.
|
||||
///
|
||||
/// Parsed from optional `[voice]` in config (URL, language, sample rate,
|
||||
/// endpointing) plus pager-stamped request-identity fields. Availability is
|
||||
/// owned by the pager (`GROK_VOICE_MODE` / `[features] voice_mode` / remote);
|
||||
/// this table has no enable/disable knob.
|
||||
/// Prefer **https** `api_base` (same shape as chat). [`Self::stt_ws_url`] derives
|
||||
/// `wss://`. When `[voice].api_base` is unset, inherits
|
||||
/// `[endpoints].xai_api_base_url` so enterprise proxies need no second knob.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct VoiceConfig {
|
||||
/// HTTPS API root (or bare host). Bases may end in `/v1` or `/xai/v1`; the
|
||||
/// default STT path de-duplicates a leading `v1/` so both become `…/v1/stt`.
|
||||
pub api_base: String,
|
||||
pub stt_ws_path: String,
|
||||
/// Preferred STT language: a catalog code from [`crate::STT_LANGUAGES`], or
|
||||
/// the client-only sentinel `"auto"` (system locale). Resolved to a concrete
|
||||
/// API code via [`crate::language_for_api`] at connect time — never send the
|
||||
/// raw field when it may be `"auto"`.
|
||||
/// Preferred STT language (catalog code or `"auto"`). Resolved via
|
||||
/// [`crate::language_for_api`] at connect time.
|
||||
pub language: String,
|
||||
pub sample_rate: u32,
|
||||
pub stt_endpointing_ms: u32,
|
||||
pub stt_interim_results: bool,
|
||||
|
||||
/// Request-identity headers attached to every STT handshake so the backend
|
||||
/// can attribute and meter voice usage by client — mirroring the
|
||||
/// `x-grok-client-identifier` / `User-Agent` headers the sampler and imagine
|
||||
/// request paths send. These are **runtime identity, not user config**:
|
||||
/// `#[serde(skip)]` keeps them out of the parsed `[voice]` table (a user
|
||||
/// can't spoof them) and the pager fills them in after parsing. Empty →
|
||||
/// the corresponding header is omitted.
|
||||
///
|
||||
/// `x-grok-client-identifier` value (e.g. `"grok-shell"`).
|
||||
/// Pager-stamped request identity; not user config.
|
||||
#[serde(skip)]
|
||||
pub client_identifier: String,
|
||||
/// `User-Agent` value (e.g. `"grok-shell/1.2.3 (macos; aarch64)"`).
|
||||
#[serde(skip)]
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
|
@ -54,40 +44,74 @@ impl Default for VoiceConfig {
|
|||
}
|
||||
|
||||
impl VoiceConfig {
|
||||
/// Build the streaming-STT WebSocket URL.
|
||||
///
|
||||
/// Only TLS endpoints are allowed: an `https://` / `wss://` (or scheme-less)
|
||||
/// `api_base` maps to `wss://`. An `http://`/`ws://` `api_base` is rejected with a
|
||||
/// [`VoiceError::Config`] rather than silently downgrading, since the bearer
|
||||
/// token is sent as a header on this connection and must never traverse a
|
||||
/// plaintext socket.
|
||||
/// Streaming STT WebSocket URL. Rejects plaintext `http://` / `ws://`.
|
||||
pub fn stt_ws_url(&self) -> Result<String, VoiceError> {
|
||||
ws_url(&self.api_base, &self.stt_ws_path)
|
||||
}
|
||||
|
||||
/// Parse `[voice]` from the root of an effective config document.
|
||||
pub fn from_config_table(root: &toml::Table) -> Self {
|
||||
root.get("voice")
|
||||
.and_then(|v| v.clone().try_into().ok())
|
||||
.unwrap_or_default()
|
||||
/// `api_base`: non-empty `[voice].api_base`, else `[endpoints].xai_api_base_url`
|
||||
/// from `root`, else `resolved_endpoints_base`, else `https://api.x.ai`.
|
||||
///
|
||||
/// `resolved_endpoints_base` carries the caller's env / CLI overrides; it
|
||||
/// ranks below the raw table so config keeps beating env (shell precedence).
|
||||
pub fn from_config_table(root: &toml::Table, resolved_endpoints_base: Option<&str>) -> Self {
|
||||
let voice_table = root.get("voice").and_then(|v| v.as_table());
|
||||
let mut cfg: Self = voice_table
|
||||
.and_then(|t| toml::Value::Table(t.clone()).try_into().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read `[voice].api_base` from the raw table, not `cfg`: serde default
|
||||
// makes "unset" and an explicit `https://api.x.ai` indistinguishable.
|
||||
cfg.api_base = non_empty_str(
|
||||
voice_table
|
||||
.and_then(|t| t.get("api_base"))
|
||||
.and_then(|v| v.as_str()),
|
||||
)
|
||||
.or_else(|| {
|
||||
non_empty_str(
|
||||
root.get("endpoints")
|
||||
.and_then(|e| e.get("xai_api_base_url"))
|
||||
.and_then(|v| v.as_str()),
|
||||
)
|
||||
})
|
||||
.or_else(|| non_empty_str(resolved_endpoints_base))
|
||||
.map(|base| base.trim_end_matches('/').to_owned())
|
||||
.unwrap_or_else(|| Self::default().api_base);
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_str(s: Option<&str>) -> Option<&str> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// `strip_prefix` ignoring ASCII case: RFC 3986 schemes are case-insensitive,
|
||||
/// so `HTTP://` must hit the plaintext rejection and `HTTPS://` must work.
|
||||
fn strip_scheme<'a>(s: &'a str, scheme: &str) -> Option<&'a str> {
|
||||
s.get(..scheme.len())
|
||||
.filter(|p| p.eq_ignore_ascii_case(scheme))
|
||||
.map(|_| &s[scheme.len()..])
|
||||
}
|
||||
|
||||
fn ws_url(api_base: &str, path: &str) -> Result<String, VoiceError> {
|
||||
let base = api_base.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
if base.starts_with("http://") || base.starts_with("ws://") {
|
||||
let base = api_base.trim().trim_end_matches('/');
|
||||
let path = path.trim().trim_start_matches('/');
|
||||
if strip_scheme(base, "http://").is_some() || strip_scheme(base, "ws://").is_some() {
|
||||
return Err(VoiceError::Config(format!(
|
||||
"insecure voice api_base {api_base:?}: voice requires a TLS endpoint \
|
||||
(https:// / wss://). Refusing to send the bearer token over a \
|
||||
plaintext connection."
|
||||
)));
|
||||
}
|
||||
let host = base
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| base.strip_prefix("wss://"))
|
||||
let rest = strip_scheme(base, "https://")
|
||||
.or_else(|| strip_scheme(base, "wss://"))
|
||||
.unwrap_or(base);
|
||||
Ok(format!("wss://{host}/{path}"))
|
||||
// Default path `/v1/stt`; bases often end in `/v1` or `/xai/v1`.
|
||||
let path = match (rest.ends_with("/v1"), path.strip_prefix("v1/")) {
|
||||
(true, Some(rest_path)) => rest_path,
|
||||
_ => path,
|
||||
};
|
||||
Ok(format!("wss://{rest}/{path}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -96,87 +120,169 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn default_stt_ws_uses_wss() {
|
||||
let cfg = VoiceConfig::default();
|
||||
assert_eq!(
|
||||
VoiceConfig::default().stt_ws_url().unwrap(),
|
||||
"wss://api.x.ai/v1/stt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_less_and_wss_bases() {
|
||||
for base in ["api.x.ai", "wss://api.x.ai", "HTTPS://api.x.ai"] {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: base.into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://api.x.ai/v1/stt");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_base_dedupes_default_path() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "https://proxy.example.com/v1".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://proxy.example.com/v1/stt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_v1_base_preserves_prefix() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "https://proxy.example.com/xai/v1".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.stt_ws_url().unwrap(),
|
||||
"wss://proxy.example.com/xai/v1/stt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_plaintext_bases() {
|
||||
for base in [
|
||||
"http://localhost:8080",
|
||||
"ws://localhost:8080",
|
||||
"HTTP://localhost:8080",
|
||||
"Ws://localhost:8080",
|
||||
] {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: base.into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert!(matches!(cfg.stt_ws_url(), Err(VoiceError::Config(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherits_endpoints_when_voice_api_base_unset() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://proxy.example.com/xai/v1"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, None);
|
||||
assert_eq!(cfg.api_base, "https://proxy.example.com/xai/v1");
|
||||
assert_eq!(
|
||||
cfg.stt_ws_url().unwrap(),
|
||||
"wss://proxy.example.com/xai/v1/stt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_voice_api_base_still_inherits_endpoints() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://proxy.example.com/xai/v1"
|
||||
[voice]
|
||||
api_base = " "
|
||||
language = "fr"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, None);
|
||||
assert_eq!(cfg.api_base, "https://proxy.example.com/xai/v1");
|
||||
assert_eq!(cfg.language, "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_voice_api_base_without_endpoints_uses_default() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[voice]
|
||||
api_base = " "
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, None);
|
||||
assert_eq!(cfg.api_base, VoiceConfig::default().api_base);
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://api.x.ai/v1/stt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_less_api_base_uses_wss() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "api.x.ai".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
fn resolved_endpoints_base_used_when_table_has_none() {
|
||||
let cfg = VoiceConfig::from_config_table(
|
||||
&toml::Table::new(),
|
||||
Some("https://proxy.example.com/v1/"),
|
||||
);
|
||||
assert_eq!(cfg.api_base, "https://proxy.example.com/v1");
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://proxy.example.com/v1/stt");
|
||||
|
||||
// Whitespace-only resolved base falls through to the default.
|
||||
let cfg = VoiceConfig::from_config_table(&toml::Table::new(), Some(" "));
|
||||
assert_eq!(cfg.api_base, VoiceConfig::default().api_base);
|
||||
}
|
||||
|
||||
/// config.toml beats the env/CLI fallback (shell endpoints precedence).
|
||||
#[test]
|
||||
fn table_endpoints_beat_resolved_endpoints_base() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://config.example.com"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, Some("https://env.example.com"));
|
||||
assert_eq!(cfg.api_base, "https://config.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_api_base_overrides_endpoints() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[endpoints]
|
||||
xai_api_base_url = "https://proxy.example.com/xai/v1"
|
||||
[voice]
|
||||
api_base = "https://api.x.ai"
|
||||
language = "es"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, None);
|
||||
assert_eq!(cfg.api_base, "https://api.x.ai");
|
||||
assert_eq!(cfg.language, "es");
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://api.x.ai/v1/stt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wss_api_base_is_not_doubled() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "wss://api.x.ai".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert_eq!(cfg.stt_ws_url().unwrap(), "wss://api.x.ai/v1/stt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_api_base_is_rejected_not_downgraded() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "http://localhost:8080".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
let err = cfg.stt_ws_url().unwrap_err();
|
||||
assert!(matches!(err, VoiceError::Config(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_api_base_is_rejected() {
|
||||
let cfg = VoiceConfig {
|
||||
api_base: "ws://localhost:8080".into(),
|
||||
..VoiceConfig::default()
|
||||
};
|
||||
assert!(cfg.stt_ws_url().is_err());
|
||||
}
|
||||
|
||||
/// Legacy / unknown keys — including the removed local `enabled` opt-out —
|
||||
/// must be ignored without failing the parse (no `deny_unknown_fields`), so
|
||||
/// old configs still load (the key is now a silent no-op; the pager owns the
|
||||
/// voice gate — default on, remote kill switch / `GROK_VOICE_MODE`).
|
||||
#[test]
|
||||
fn ignores_additional_fields() {
|
||||
let raw = r#"
|
||||
fn ignores_unknown_and_identity_fields() {
|
||||
let table: toml::Table = toml::from_str(
|
||||
r#"
|
||||
[voice]
|
||||
enabled = false
|
||||
push_to_talk = true
|
||||
language = "es"
|
||||
"#;
|
||||
let table: toml::Table = toml::from_str(raw).unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table);
|
||||
// Known fields still apply; unknown/legacy keys are dropped silently.
|
||||
assert_eq!(cfg.language, "es");
|
||||
assert_eq!(cfg.sample_rate, 16_000);
|
||||
}
|
||||
|
||||
/// `client_identifier` / `user_agent` are `#[serde(skip)]` runtime identity,
|
||||
/// not user config: a value placed in `[voice]` must be ignored so a user
|
||||
/// can't spoof the attribution headers. The pager stamps them after parsing.
|
||||
#[test]
|
||||
fn identity_fields_are_not_parsed_from_config() {
|
||||
let raw = r#"
|
||||
[voice]
|
||||
client_identifier = "spoofed"
|
||||
user_agent = "malicious/9.9"
|
||||
language = "es"
|
||||
"#;
|
||||
let table: toml::Table = toml::from_str(raw).unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table);
|
||||
assert_eq!(cfg.language, "es", "ordinary fields still parse");
|
||||
assert!(
|
||||
cfg.client_identifier.is_empty(),
|
||||
"client_identifier must not be settable via config"
|
||||
);
|
||||
assert!(
|
||||
cfg.user_agent.is_empty(),
|
||||
"user_agent must not be settable via config"
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = VoiceConfig::from_config_table(&table, None);
|
||||
assert_eq!(cfg.language, "es");
|
||||
assert!(cfg.client_identifier.is_empty());
|
||||
assert!(cfg.user_agent.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue