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
288 lines
9.2 KiB
Rust
288 lines
9.2 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::VoiceError;
|
|
|
|
/// Voice settings for the STT transport.
|
|
///
|
|
/// 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 (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,
|
|
|
|
/// Pager-stamped request identity; not user config.
|
|
#[serde(skip)]
|
|
pub client_identifier: String,
|
|
#[serde(skip)]
|
|
pub user_agent: String,
|
|
}
|
|
|
|
impl Default for VoiceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
api_base: "https://api.x.ai".into(),
|
|
stt_ws_path: "/v1/stt".into(),
|
|
language: "en".into(),
|
|
sample_rate: 16_000,
|
|
stt_endpointing_ms: 400,
|
|
stt_interim_results: true,
|
|
client_identifier: String::new(),
|
|
user_agent: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl VoiceConfig {
|
|
/// 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)
|
|
}
|
|
|
|
/// `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().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 rest = strip_scheme(base, "https://")
|
|
.or_else(|| strip_scheme(base, "wss://"))
|
|
.unwrap_or(base);
|
|
// 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)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_stt_ws_uses_wss() {
|
|
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 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 ignores_unknown_and_identity_fields() {
|
|
let table: toml::Table = toml::from_str(
|
|
r#"
|
|
[voice]
|
|
enabled = false
|
|
client_identifier = "spoofed"
|
|
user_agent = "malicious/9.9"
|
|
language = "es"
|
|
"#,
|
|
)
|
|
.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());
|
|
}
|
|
}
|