clawdex-mobile 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/npm-release.yml +18 -0
  3. package/AGENTS.md +3 -3
  4. package/README.md +104 -542
  5. package/apps/mobile/.env.example +1 -2
  6. package/apps/mobile/App.tsx +261 -68
  7. package/apps/mobile/app.json +31 -5
  8. package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
  9. package/apps/mobile/eas.json +30 -0
  10. package/apps/mobile/package.json +22 -21
  11. package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
  12. package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
  13. package/apps/mobile/src/api/chatMapping.ts +48 -8
  14. package/apps/mobile/src/api/client.ts +6 -0
  15. package/apps/mobile/src/api/types.ts +11 -0
  16. package/apps/mobile/src/api/ws.ts +52 -10
  17. package/apps/mobile/src/bridgeUrl.ts +105 -0
  18. package/apps/mobile/src/components/ActivityBar.tsx +32 -13
  19. package/apps/mobile/src/components/ChatHeader.tsx +3 -2
  20. package/apps/mobile/src/components/ChatInput.tsx +246 -91
  21. package/apps/mobile/src/components/ChatMessage.tsx +108 -4
  22. package/apps/mobile/src/config.ts +11 -29
  23. package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
  24. package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
  25. package/apps/mobile/src/screens/GitScreen.tsx +1 -1
  26. package/apps/mobile/src/screens/MainScreen.tsx +906 -268
  27. package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
  28. package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
  29. package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
  30. package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
  31. package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
  32. package/docs/app-review-notes.md +7 -2
  33. package/docs/eas-builds.md +91 -0
  34. package/docs/realtime-streaming-limitations.md +84 -0
  35. package/docs/setup-and-operations.md +239 -0
  36. package/docs/troubleshooting.md +121 -0
  37. package/docs/voice-transcription.md +87 -0
  38. package/package.json +8 -16
  39. package/scripts/setup-secure-dev.sh +122 -8
  40. package/scripts/setup-wizard.sh +342 -122
  41. package/scripts/start-bridge-secure.sh +7 -1
  42. package/scripts/sync-versions.js +63 -0
  43. package/services/rust-bridge/.env.example +1 -1
  44. package/services/rust-bridge/Cargo.lock +1104 -23
  45. package/services/rust-bridge/Cargo.toml +3 -1
  46. package/services/rust-bridge/package.json +1 -1
  47. package/services/rust-bridge/src/main.rs +587 -12
  48. package/apps/mobile/metro.config.js +0 -3
@@ -48,11 +48,12 @@ const DYNAMIC_TOOL_CALL_METHOD: &str = "item/tool/call";
48
48
  const ACCOUNT_CHATGPT_TOKENS_REFRESH_METHOD: &str = "account/chatgptAuthTokens/refresh";
49
49
  const MOBILE_ATTACHMENTS_DIR: &str = ".clawdex-mobile-attachments";
50
50
  const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
51
+ const DEFAULT_MAX_VOICE_TRANSCRIPTION_BYTES: usize = 100 * 1024 * 1024;
51
52
  const NOTIFICATION_REPLAY_BUFFER_SIZE: usize = 2_000;
52
53
  const NOTIFICATION_REPLAY_MAX_LIMIT: usize = 1_000;
53
54
  const WS_CLIENT_QUEUE_CAPACITY: usize = 256;
54
55
  const ROLLOUT_LIVE_SYNC_POLL_INTERVAL_MS: u64 = 900;
55
- const ROLLOUT_LIVE_SYNC_DISCOVERY_INTERVAL_TICKS: u64 = 6;
56
+ const ROLLOUT_LIVE_SYNC_DISCOVERY_INTERVAL_TICKS: u64 = 1;
56
57
  const ROLLOUT_LIVE_SYNC_MAX_TRACKED_FILES: usize = 64;
57
58
  const ROLLOUT_LIVE_SYNC_MAX_FILE_AGE: Duration = Duration::from_secs(60 * 60 * 24 * 2);
58
59
  const ROLLOUT_LIVE_SYNC_INITIAL_TAIL_BYTES: u64 = 64 * 1024;
@@ -71,6 +72,7 @@ struct BridgeConfig {
71
72
  allow_outside_root_cwd: bool,
72
73
  disable_terminal_exec: bool,
73
74
  terminal_allowed_commands: HashSet<String>,
75
+ show_pairing_qr: bool,
74
76
  }
75
77
 
76
78
  impl BridgeConfig {
@@ -105,6 +107,7 @@ impl BridgeConfig {
105
107
  let allow_outside_root_cwd =
106
108
  parse_bool_env_with_default("BRIDGE_ALLOW_OUTSIDE_ROOT_CWD", true);
107
109
  let disable_terminal_exec = parse_bool_env("BRIDGE_DISABLE_TERMINAL_EXEC");
110
+ let show_pairing_qr = parse_bool_env_with_default("BRIDGE_SHOW_PAIRING_QR", true);
108
111
 
109
112
  let terminal_allowed_commands = parse_csv_env(
110
113
  "BRIDGE_TERMINAL_ALLOWED_COMMANDS",
@@ -123,6 +126,7 @@ impl BridgeConfig {
123
126
  allow_outside_root_cwd,
124
127
  disable_terminal_exec,
125
128
  terminal_allowed_commands,
129
+ show_pairing_qr,
126
130
  })
127
131
  }
128
132
 
@@ -1052,9 +1056,9 @@ impl RolloutTrackedFile {
1052
1056
  let mut include_for_live_sync = false;
1053
1057
 
1054
1058
  if let Some((meta_thread_id, meta_originator)) = read_rollout_session_meta(&path).await? {
1055
- include_for_live_sync = meta_originator == "codex_cli_rs";
1059
+ include_for_live_sync = rollout_originator_allowed(meta_originator.as_deref());
1056
1060
  thread_id = Some(meta_thread_id);
1057
- originator = Some(meta_originator);
1061
+ originator = meta_originator;
1058
1062
  }
1059
1063
 
1060
1064
  let offset = metadata
@@ -1142,6 +1146,12 @@ impl RolloutTrackedFile {
1142
1146
  }
1143
1147
 
1144
1148
  if let Some((method, params)) = self.to_notification(trimmed) {
1149
+ if let Some(status_payload) =
1150
+ build_rollout_thread_status_notification(&method, &params)
1151
+ {
1152
+ hub.broadcast_notification("thread/status/changed", status_payload)
1153
+ .await;
1154
+ }
1145
1155
  hub.broadcast_notification(&method, params).await;
1146
1156
  }
1147
1157
  }
@@ -1173,10 +1183,12 @@ impl RolloutTrackedFile {
1173
1183
  let payload = parsed_object.get("payload")?.as_object()?;
1174
1184
 
1175
1185
  if record_type == "session_meta" {
1176
- self.thread_id = read_string(payload.get("id")).or_else(|| self.thread_id.clone());
1186
+ self.thread_id =
1187
+ extract_rollout_thread_id(payload, true).or_else(|| self.thread_id.clone());
1177
1188
  self.originator =
1178
1189
  read_string(payload.get("originator")).or_else(|| self.originator.clone());
1179
- self.include_for_live_sync = self.originator.as_deref() == Some("codex_cli_rs");
1190
+ self.include_for_live_sync =
1191
+ self.thread_id.is_some() && rollout_originator_allowed(self.originator.as_deref());
1180
1192
  return None;
1181
1193
  }
1182
1194
 
@@ -1184,6 +1196,10 @@ impl RolloutTrackedFile {
1184
1196
  return None;
1185
1197
  }
1186
1198
 
1199
+ if let Some(payload_thread_id) = extract_rollout_thread_id(payload, false) {
1200
+ self.thread_id = Some(payload_thread_id);
1201
+ }
1202
+
1187
1203
  let thread_id = self.thread_id.as_deref()?;
1188
1204
  if record_type == "event_msg" {
1189
1205
  return build_rollout_event_msg_notification(payload, thread_id, timestamp.as_deref());
@@ -1216,7 +1232,10 @@ fn spawn_rollout_live_sync(hub: Arc<ClientHub>) {
1216
1232
  ticker.tick().await;
1217
1233
  state.tick = state.tick.wrapping_add(1);
1218
1234
 
1219
- if state.tick % ROLLOUT_LIVE_SYNC_DISCOVERY_INTERVAL_TICKS == 1 {
1235
+ if should_run_rollout_discovery_tick(
1236
+ state.tick,
1237
+ ROLLOUT_LIVE_SYNC_DISCOVERY_INTERVAL_TICKS,
1238
+ ) {
1220
1239
  if let Err(error) =
1221
1240
  rollout_live_sync_discover_files(&sessions_root, &mut state).await
1222
1241
  {
@@ -1358,7 +1377,7 @@ fn is_rollout_file_path(path: &Path) -> bool {
1358
1377
 
1359
1378
  async fn read_rollout_session_meta(
1360
1379
  path: &Path,
1361
- ) -> Result<Option<(String, String)>, std::io::Error> {
1380
+ ) -> Result<Option<(String, Option<String>)>, std::io::Error> {
1362
1381
  let file = match fs::File::open(path).await {
1363
1382
  Ok(file) => file,
1364
1383
  Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -1389,24 +1408,98 @@ async fn read_rollout_session_meta(
1389
1408
  None => return Ok(None),
1390
1409
  };
1391
1410
 
1392
- let thread_id = match read_string(payload.get("id")) {
1411
+ let thread_id = match extract_rollout_thread_id(payload, true) {
1393
1412
  Some(id) => id,
1394
1413
  None => return Ok(None),
1395
1414
  };
1396
- let originator = match read_string(payload.get("originator")) {
1397
- Some(originator) => originator,
1398
- None => return Ok(None),
1399
- };
1415
+ let originator = read_string(payload.get("originator"));
1400
1416
 
1401
1417
  Ok(Some((thread_id, originator)))
1402
1418
  }
1403
1419
 
1420
+ fn extract_rollout_thread_id(
1421
+ payload: &serde_json::Map<String, Value>,
1422
+ allow_session_id_fallback: bool,
1423
+ ) -> Option<String> {
1424
+ let source = payload.get("source").and_then(Value::as_object);
1425
+ let source_subagent = source
1426
+ .and_then(|value| value.get("subagent"))
1427
+ .and_then(Value::as_object);
1428
+ let source_thread_spawn = source_subagent
1429
+ .and_then(|value| value.get("thread_spawn"))
1430
+ .and_then(Value::as_object);
1431
+
1432
+ read_string(payload.get("thread_id"))
1433
+ .or_else(|| read_string(payload.get("threadId")))
1434
+ .or_else(|| read_string(payload.get("conversation_id")))
1435
+ .or_else(|| read_string(payload.get("conversationId")))
1436
+ .or_else(|| source.and_then(|value| read_string(value.get("thread_id"))))
1437
+ .or_else(|| source.and_then(|value| read_string(value.get("threadId"))))
1438
+ .or_else(|| source.and_then(|value| read_string(value.get("conversation_id"))))
1439
+ .or_else(|| source.and_then(|value| read_string(value.get("conversationId"))))
1440
+ .or_else(|| source.and_then(|value| read_string(value.get("parent_thread_id"))))
1441
+ .or_else(|| source.and_then(|value| read_string(value.get("parentThreadId"))))
1442
+ .or_else(|| {
1443
+ source_thread_spawn.and_then(|value| read_string(value.get("parent_thread_id")))
1444
+ })
1445
+ .or_else(|| {
1446
+ if allow_session_id_fallback {
1447
+ read_string(payload.get("id"))
1448
+ } else {
1449
+ None
1450
+ }
1451
+ })
1452
+ }
1453
+
1404
1454
  fn hash_rollout_line(line: &str) -> u64 {
1405
1455
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
1406
1456
  line.hash(&mut hasher);
1407
1457
  hasher.finish()
1408
1458
  }
1409
1459
 
1460
+ fn should_run_rollout_discovery_tick(tick: u64, interval_ticks: u64) -> bool {
1461
+ if interval_ticks <= 1 {
1462
+ return true;
1463
+ }
1464
+
1465
+ tick == 1 || tick % interval_ticks == 0
1466
+ }
1467
+
1468
+ fn rollout_originator_allowed(originator: Option<&str>) -> bool {
1469
+ match originator {
1470
+ Some(value) => {
1471
+ let normalized = value.to_ascii_lowercase();
1472
+ normalized.contains("codex") || normalized.contains("clawdex")
1473
+ }
1474
+ None => true,
1475
+ }
1476
+ }
1477
+
1478
+ fn build_rollout_thread_status_notification(method: &str, params: &Value) -> Option<Value> {
1479
+ let codex_event_type = method.strip_prefix("codex/event/")?;
1480
+ let status = match codex_event_type {
1481
+ "task_started" | "taskstarted" => "running",
1482
+ "task_complete" | "taskcomplete" => "completed",
1483
+ "task_failed" | "taskfailed" | "turn_failed" | "turnfailed" => "failed",
1484
+ "task_interrupted" | "taskinterrupted" | "turn_aborted" | "turnaborted" => "interrupted",
1485
+ _ => return None,
1486
+ };
1487
+
1488
+ let msg = params
1489
+ .as_object()
1490
+ .and_then(|value| value.get("msg"))
1491
+ .and_then(Value::as_object)?;
1492
+ let thread_id =
1493
+ read_string(msg.get("thread_id")).or_else(|| read_string(msg.get("threadId")))?;
1494
+
1495
+ Some(json!({
1496
+ "threadId": thread_id,
1497
+ "thread_id": thread_id,
1498
+ "status": status,
1499
+ "source": "rollout_live_sync",
1500
+ }))
1501
+ }
1502
+
1410
1503
  fn build_rollout_event_msg_notification(
1411
1504
  payload: &serde_json::Map<String, Value>,
1412
1505
  thread_id: &str,
@@ -1781,6 +1874,21 @@ struct AttachmentUploadResponse {
1781
1874
  kind: String,
1782
1875
  }
1783
1876
 
1877
+ #[derive(Debug, Deserialize)]
1878
+ #[serde(rename_all = "camelCase")]
1879
+ struct VoiceTranscribeRequest {
1880
+ data_base64: String,
1881
+ prompt: Option<String>,
1882
+ file_name: Option<String>,
1883
+ mime_type: Option<String>,
1884
+ }
1885
+
1886
+ #[derive(Debug, Serialize)]
1887
+ #[serde(rename_all = "camelCase")]
1888
+ struct VoiceTranscribeResponse {
1889
+ text: String,
1890
+ }
1891
+
1784
1892
  #[derive(Debug, Clone, Serialize, Deserialize)]
1785
1893
  #[serde(rename_all = "camelCase")]
1786
1894
  struct PendingApproval {
@@ -1918,6 +2026,7 @@ async fn main() {
1918
2026
  };
1919
2027
 
1920
2028
  println!("rust-bridge listening on {bind_addr}");
2029
+ maybe_print_pairing_qr(&config);
1921
2030
 
1922
2031
  if let Err(error) = axum::serve(listener, app).await {
1923
2032
  eprintln!("server error: {error}");
@@ -2413,12 +2522,186 @@ async fn handle_bridge_method(
2413
2522
  "request": user_input_request,
2414
2523
  }))
2415
2524
  }
2525
+ "bridge/voice/transcribe" => {
2526
+ let request: VoiceTranscribeRequest =
2527
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
2528
+ .map_err(|e| BridgeError::invalid_params(&e.to_string()))?;
2529
+ transcribe_voice(request).await
2530
+ }
2416
2531
  _ => Err(BridgeError::method_not_found(&format!(
2417
2532
  "Unknown bridge method: {method}"
2418
2533
  ))),
2419
2534
  }
2420
2535
  }
2421
2536
 
2537
+ async fn transcribe_voice(request: VoiceTranscribeRequest) -> Result<Value, BridgeError> {
2538
+ let max_voice_transcription_bytes = resolve_max_voice_transcription_bytes();
2539
+ let estimated_size = estimate_base64_decoded_size(&request.data_base64)?;
2540
+ if estimated_size > max_voice_transcription_bytes {
2541
+ return Err(BridgeError::invalid_params(&format!(
2542
+ "audio payload exceeds max size of {max_voice_transcription_bytes} bytes",
2543
+ )));
2544
+ }
2545
+
2546
+ let audio_bytes = decode_base64_payload(&request.data_base64)?;
2547
+
2548
+ // Minimum ~16KB — roughly 0.5s at 16kHz 16-bit mono.
2549
+ if audio_bytes.len() < 16_000 {
2550
+ return Err(BridgeError::invalid_params(
2551
+ "audio payload too short (minimum ~0.5 seconds required)",
2552
+ ));
2553
+ }
2554
+ if audio_bytes.len() > max_voice_transcription_bytes {
2555
+ return Err(BridgeError::invalid_params(&format!(
2556
+ "audio payload exceeds max size of {max_voice_transcription_bytes} bytes",
2557
+ )));
2558
+ }
2559
+
2560
+ // Resolve auth: env vars first, then ~/.codex/auth.json.
2561
+ let (endpoint, bearer_token, include_model) = resolve_transcription_auth()?;
2562
+ let normalized_mime_type = normalize_transcription_mime_type(request.mime_type.as_deref());
2563
+ let normalized_file_name =
2564
+ normalize_transcription_file_name(request.file_name.as_deref(), &normalized_mime_type);
2565
+
2566
+ let file_part = reqwest::multipart::Part::bytes(audio_bytes)
2567
+ .file_name(normalized_file_name)
2568
+ .mime_str(&normalized_mime_type)
2569
+ .map_err(|e| BridgeError::server(&e.to_string()))?;
2570
+
2571
+ let mut form = reqwest::multipart::Form::new().part("file", file_part);
2572
+
2573
+ if include_model {
2574
+ form = form.text("model", "gpt-4o-transcribe");
2575
+ }
2576
+
2577
+ if let Some(prompt) = request.prompt {
2578
+ let trimmed = prompt.trim().to_string();
2579
+ if !trimmed.is_empty() {
2580
+ form = form.text("prompt", trimmed);
2581
+ }
2582
+ }
2583
+
2584
+ let client = reqwest::Client::new();
2585
+ let response = client
2586
+ .post(&endpoint)
2587
+ .bearer_auth(&bearer_token)
2588
+ .multipart(form)
2589
+ .send()
2590
+ .await
2591
+ .map_err(|e| BridgeError::server(&e.to_string()))?;
2592
+
2593
+ if !response.status().is_success() {
2594
+ let status = response.status().as_u16();
2595
+ let body = response
2596
+ .text()
2597
+ .await
2598
+ .unwrap_or_else(|_| "<unreadable>".to_string());
2599
+ return Err(BridgeError {
2600
+ code: -32000,
2601
+ message: format!("transcription API returned HTTP {status}"),
2602
+ data: Some(json!({ "status": status, "body": body })),
2603
+ });
2604
+ }
2605
+
2606
+ let body: Value = response
2607
+ .json()
2608
+ .await
2609
+ .map_err(|e| BridgeError::server(&e.to_string()))?;
2610
+
2611
+ let text = body["text"].as_str().unwrap_or("").to_string();
2612
+
2613
+ Ok(serde_json::to_value(VoiceTranscribeResponse { text })
2614
+ .map_err(|e| BridgeError::server(&e.to_string()))?)
2615
+ }
2616
+
2617
+ fn resolve_transcription_auth() -> Result<(String, String, bool), BridgeError> {
2618
+ // Path 1: OPENAI_API_KEY env var → OpenAI direct API.
2619
+ if let Some(api_key) = read_non_empty_env("OPENAI_API_KEY") {
2620
+ return Ok((
2621
+ "https://api.openai.com/v1/audio/transcriptions".to_string(),
2622
+ api_key,
2623
+ true,
2624
+ ));
2625
+ }
2626
+
2627
+ // Path 2: BRIDGE_CHATGPT_ACCESS_TOKEN env var → ChatGPT backend.
2628
+ if let Some(access_token) = read_non_empty_env("BRIDGE_CHATGPT_ACCESS_TOKEN") {
2629
+ return Ok((
2630
+ "https://chatgpt.com/backend-api/transcribe".to_string(),
2631
+ access_token,
2632
+ false,
2633
+ ));
2634
+ }
2635
+
2636
+ // Fall back to ~/.codex/auth.json.
2637
+ let auth_path = resolve_codex_auth_json_path();
2638
+ if let Some(path) = auth_path {
2639
+ if let Ok(contents) = std::fs::read_to_string(&path) {
2640
+ if let Ok(auth) = serde_json::from_str::<Value>(&contents) {
2641
+ // Check for OPENAI_API_KEY field.
2642
+ if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) {
2643
+ let trimmed = key.trim();
2644
+ if !trimmed.is_empty() {
2645
+ return Ok((
2646
+ "https://api.openai.com/v1/audio/transcriptions".to_string(),
2647
+ trimmed.to_string(),
2648
+ true,
2649
+ ));
2650
+ }
2651
+ }
2652
+
2653
+ // Check for chatgpt auth mode with access_token.
2654
+ let is_chatgpt_mode = auth
2655
+ .get("auth_mode")
2656
+ .and_then(|v| v.as_str())
2657
+ .map(|m| m == "chatgpt")
2658
+ .unwrap_or(false);
2659
+
2660
+ if is_chatgpt_mode {
2661
+ if let Some(token) = auth
2662
+ .get("tokens")
2663
+ .and_then(|t| t.get("access_token"))
2664
+ .and_then(|v| v.as_str())
2665
+ {
2666
+ let trimmed = token.trim();
2667
+ if !trimmed.is_empty() {
2668
+ return Ok((
2669
+ "https://chatgpt.com/backend-api/transcribe".to_string(),
2670
+ trimmed.to_string(),
2671
+ false,
2672
+ ));
2673
+ }
2674
+ }
2675
+ }
2676
+ }
2677
+ }
2678
+ }
2679
+
2680
+ Err(BridgeError {
2681
+ code: -32002,
2682
+ message:
2683
+ "no transcription credentials found: set OPENAI_API_KEY or BRIDGE_CHATGPT_ACCESS_TOKEN"
2684
+ .to_string(),
2685
+ data: None,
2686
+ })
2687
+ }
2688
+
2689
+ fn resolve_codex_auth_json_path() -> Option<PathBuf> {
2690
+ if let Some(codex_home) = read_non_empty_env("CODEX_HOME") {
2691
+ let path = PathBuf::from(codex_home).join("auth.json");
2692
+ if path.is_file() {
2693
+ return Some(path);
2694
+ }
2695
+ }
2696
+ let home = read_non_empty_env("HOME")?;
2697
+ let path = PathBuf::from(home).join(".codex").join("auth.json");
2698
+ if path.is_file() {
2699
+ Some(path)
2700
+ } else {
2701
+ None
2702
+ }
2703
+ }
2704
+
2422
2705
  async fn send_rpc_error(
2423
2706
  state: &Arc<AppState>,
2424
2707
  client_id: u64,
@@ -2460,6 +2743,90 @@ fn resolve_bridge_workdir(raw_workdir: PathBuf) -> Result<PathBuf, String> {
2460
2743
  Ok(normalize_path(&canonical))
2461
2744
  }
2462
2745
 
2746
+ fn is_unspecified_bind_host(host: &str) -> bool {
2747
+ matches!(
2748
+ host.trim().to_ascii_lowercase().as_str(),
2749
+ "0.0.0.0" | "::" | "[::]"
2750
+ )
2751
+ }
2752
+
2753
+ fn format_host_for_url(host: &str) -> String {
2754
+ let trimmed = host.trim();
2755
+ if trimmed.contains(':') && !trimmed.starts_with('[') && !trimmed.ends_with(']') {
2756
+ return format!("[{}]", trimmed);
2757
+ }
2758
+ trimmed.to_string()
2759
+ }
2760
+
2761
+ fn build_pairing_payload(config: &BridgeConfig) -> Option<String> {
2762
+ if is_unspecified_bind_host(&config.host) {
2763
+ return None;
2764
+ }
2765
+
2766
+ let bridge_token = config.auth_token.clone()?;
2767
+ let bridge_url = format!(
2768
+ "http://{}:{}",
2769
+ format_host_for_url(&config.host),
2770
+ config.port
2771
+ );
2772
+
2773
+ Some(
2774
+ json!({
2775
+ "type": "clawdex-bridge-pair",
2776
+ "bridgeUrl": bridge_url,
2777
+ "bridgeToken": bridge_token,
2778
+ })
2779
+ .to_string(),
2780
+ )
2781
+ }
2782
+
2783
+ fn build_token_only_pairing_payload(config: &BridgeConfig) -> Option<String> {
2784
+ let bridge_token = config.auth_token.clone()?;
2785
+
2786
+ Some(
2787
+ json!({
2788
+ "type": "clawdex-bridge-token",
2789
+ "bridgeToken": bridge_token,
2790
+ })
2791
+ .to_string(),
2792
+ )
2793
+ }
2794
+
2795
+ fn maybe_print_pairing_qr(config: &BridgeConfig) {
2796
+ if !config.show_pairing_qr {
2797
+ return;
2798
+ }
2799
+
2800
+ if let Some(payload) = build_pairing_payload(config) {
2801
+ println!();
2802
+ println!("Bridge pairing QR (scan from mobile onboarding):");
2803
+ if let Err(error) = qr2term::print_qr(payload.as_bytes()) {
2804
+ eprintln!("failed to render pairing QR: {error}");
2805
+ return;
2806
+ }
2807
+ println!("QR contains bridge URL + token for one-tap onboarding.");
2808
+ println!();
2809
+ return;
2810
+ }
2811
+
2812
+ let Some(payload) = build_token_only_pairing_payload(config) else {
2813
+ eprintln!("bridge token QR skipped because BRIDGE_AUTH_TOKEN is not set");
2814
+ return;
2815
+ };
2816
+
2817
+ println!();
2818
+ println!("Bridge token QR fallback (scan from mobile onboarding):");
2819
+ if let Err(error) = qr2term::print_qr(payload.as_bytes()) {
2820
+ eprintln!("failed to render pairing QR: {error}");
2821
+ return;
2822
+ }
2823
+ println!(
2824
+ "Full pairing QR unavailable because BRIDGE_HOST={} is a bind address. Enter URL manually in onboarding.",
2825
+ config.host
2826
+ );
2827
+ println!();
2828
+ }
2829
+
2463
2830
  fn parse_bool_env(name: &str) -> bool {
2464
2831
  env::var(name)
2465
2832
  .map(|v| v.trim().eq_ignore_ascii_case("true"))
@@ -2488,6 +2855,13 @@ fn read_non_empty_env(name: &str) -> Option<String> {
2488
2855
  .filter(|value| !value.is_empty())
2489
2856
  }
2490
2857
 
2858
+ fn resolve_max_voice_transcription_bytes() -> usize {
2859
+ read_non_empty_env("BRIDGE_MAX_VOICE_TRANSCRIPTION_BYTES")
2860
+ .and_then(|value| value.parse::<usize>().ok())
2861
+ .filter(|value| *value > 0)
2862
+ .unwrap_or(DEFAULT_MAX_VOICE_TRANSCRIPTION_BYTES)
2863
+ }
2864
+
2491
2865
  fn constant_time_eq(left: &str, right: &str) -> bool {
2492
2866
  let left_bytes = left.as_bytes();
2493
2867
  let right_bytes = right.as_bytes();
@@ -2897,6 +3271,62 @@ fn decode_base64_payload(raw: &str) -> Result<Vec<u8>, BridgeError> {
2897
3271
  })
2898
3272
  }
2899
3273
 
3274
+ fn normalize_transcription_mime_type(raw_mime_type: Option<&str>) -> String {
3275
+ let Some(raw_mime_type) = raw_mime_type
3276
+ .map(str::trim)
3277
+ .filter(|value| !value.is_empty())
3278
+ else {
3279
+ return "audio/wav".to_string();
3280
+ };
3281
+
3282
+ let base_mime = raw_mime_type
3283
+ .split(';')
3284
+ .next()
3285
+ .map(str::trim)
3286
+ .unwrap_or("")
3287
+ .to_ascii_lowercase();
3288
+
3289
+ match base_mime.as_str() {
3290
+ "audio/wav" | "audio/x-wav" | "audio/wave" => "audio/wav".to_string(),
3291
+ "audio/mp4" => "audio/mp4".to_string(),
3292
+ "audio/m4a" | "audio/x-m4a" => "audio/m4a".to_string(),
3293
+ "audio/aac" => "audio/aac".to_string(),
3294
+ "audio/mpeg" | "audio/mp3" | "audio/mpga" => "audio/mpeg".to_string(),
3295
+ "audio/webm" => "audio/webm".to_string(),
3296
+ "audio/ogg" => "audio/ogg".to_string(),
3297
+ "audio/flac" | "audio/x-flac" => "audio/flac".to_string(),
3298
+ _ => "audio/wav".to_string(),
3299
+ }
3300
+ }
3301
+
3302
+ fn normalize_transcription_file_name(raw_name: Option<&str>, mime_type: &str) -> String {
3303
+ let mut file_name = raw_name
3304
+ .map(str::trim)
3305
+ .filter(|value| !value.is_empty())
3306
+ .map(sanitize_filename)
3307
+ .unwrap_or_else(|| "audio".to_string());
3308
+
3309
+ if !file_name.contains('.') {
3310
+ file_name.push('.');
3311
+ file_name.push_str(infer_transcription_extension_from_mime(mime_type));
3312
+ }
3313
+
3314
+ file_name
3315
+ }
3316
+
3317
+ fn infer_transcription_extension_from_mime(mime_type: &str) -> &'static str {
3318
+ match mime_type {
3319
+ "audio/wav" => "wav",
3320
+ "audio/mp4" | "audio/m4a" => "m4a",
3321
+ "audio/aac" => "aac",
3322
+ "audio/mpeg" => "mp3",
3323
+ "audio/webm" => "webm",
3324
+ "audio/ogg" => "ogg",
3325
+ "audio/flac" => "flac",
3326
+ _ => "wav",
3327
+ }
3328
+ }
3329
+
2900
3330
  fn normalize_attachment_kind(kind: Option<&str>, mime_type: Option<&str>) -> &'static str {
2901
3331
  let normalized = kind
2902
3332
  .map(str::trim)
@@ -3088,6 +3518,7 @@ mod tests {
3088
3518
  allow_outside_root_cwd: false,
3089
3519
  disable_terminal_exec: true,
3090
3520
  terminal_allowed_commands: HashSet::new(),
3521
+ show_pairing_qr: false,
3091
3522
  });
3092
3523
 
3093
3524
  let hub = Arc::new(ClientHub::new());
@@ -3433,6 +3864,69 @@ mod tests {
3433
3864
  .is_none());
3434
3865
  }
3435
3866
 
3867
+ #[test]
3868
+ fn extract_rollout_thread_id_prefers_parent_thread_id_from_source() {
3869
+ let payload = json!({
3870
+ "id": "session-123",
3871
+ "source": {
3872
+ "subagent": {
3873
+ "thread_spawn": {
3874
+ "parent_thread_id": "thread-parent"
3875
+ }
3876
+ }
3877
+ }
3878
+ });
3879
+ let payload_object = payload.as_object().expect("payload object");
3880
+
3881
+ assert_eq!(
3882
+ extract_rollout_thread_id(payload_object, true),
3883
+ Some("thread-parent".to_string())
3884
+ );
3885
+ }
3886
+
3887
+ #[test]
3888
+ fn rollout_thread_status_notification_maps_task_lifecycle_events() {
3889
+ let params = json!({
3890
+ "msg": {
3891
+ "thread_id": "thread-1"
3892
+ }
3893
+ });
3894
+
3895
+ let running = build_rollout_thread_status_notification("codex/event/task_started", &params)
3896
+ .expect("running status");
3897
+ assert_eq!(running["threadId"], "thread-1");
3898
+ assert_eq!(running["status"], "running");
3899
+
3900
+ let completed =
3901
+ build_rollout_thread_status_notification("codex/event/task_complete", &params)
3902
+ .expect("complete status");
3903
+ assert_eq!(completed["status"], "completed");
3904
+
3905
+ let failed = build_rollout_thread_status_notification("codex/event/task_failed", &params)
3906
+ .expect("failed status");
3907
+ assert_eq!(failed["status"], "failed");
3908
+
3909
+ let interrupted =
3910
+ build_rollout_thread_status_notification("codex/event/task_interrupted", &params)
3911
+ .expect("interrupted status");
3912
+ assert_eq!(interrupted["status"], "interrupted");
3913
+
3914
+ assert!(build_rollout_thread_status_notification(
3915
+ "codex/event/agent_message_delta",
3916
+ &params
3917
+ )
3918
+ .is_none());
3919
+ }
3920
+
3921
+ #[test]
3922
+ fn rollout_originator_filter_allows_codex_and_clawdex_origins() {
3923
+ assert!(rollout_originator_allowed(Some("codex_cli_rs")));
3924
+ assert!(rollout_originator_allowed(Some(
3925
+ "clawdex-mobile-rust-bridge"
3926
+ )));
3927
+ assert!(!rollout_originator_allowed(Some("some_other_originator")));
3928
+ }
3929
+
3436
3930
  #[test]
3437
3931
  fn rollout_response_item_mapping_builds_exec_command_and_mcp_notifications() {
3438
3932
  let exec_command = build_rollout_response_item_notification(
@@ -3510,6 +4004,21 @@ mod tests {
3510
4004
  assert_eq!(extract_rollout_search_query(&json!({})), None);
3511
4005
  }
3512
4006
 
4007
+ #[test]
4008
+ fn rollout_discovery_tick_scheduler_handles_one_tick_interval() {
4009
+ assert!(should_run_rollout_discovery_tick(1, 1));
4010
+ assert!(should_run_rollout_discovery_tick(10, 1));
4011
+ assert!(should_run_rollout_discovery_tick(5, 0));
4012
+ }
4013
+
4014
+ #[test]
4015
+ fn rollout_discovery_tick_scheduler_handles_multi_tick_intervals() {
4016
+ assert!(should_run_rollout_discovery_tick(1, 3));
4017
+ assert!(!should_run_rollout_discovery_tick(2, 3));
4018
+ assert!(should_run_rollout_discovery_tick(3, 3));
4019
+ assert!(should_run_rollout_discovery_tick(6, 3));
4020
+ }
4021
+
3513
4022
  #[test]
3514
4023
  fn parse_user_input_questions_filters_invalid_entries_and_maps_options() {
3515
4024
  let questions = parse_user_input_questions(Some(&json!([
@@ -3693,6 +4202,71 @@ mod tests {
3693
4202
  assert_eq!(infer_extension_from_mime(Some("application/zip")), None);
3694
4203
  }
3695
4204
 
4205
+ #[test]
4206
+ fn transcription_mime_normalization_accepts_known_values_and_falls_back() {
4207
+ assert_eq!(
4208
+ normalize_transcription_mime_type(Some(" audio/MP4 ")),
4209
+ "audio/mp4".to_string()
4210
+ );
4211
+ assert_eq!(
4212
+ normalize_transcription_mime_type(Some("audio/webm;codecs=opus")),
4213
+ "audio/webm".to_string()
4214
+ );
4215
+ assert_eq!(
4216
+ normalize_transcription_mime_type(Some("audio/mpga")),
4217
+ "audio/mpeg".to_string()
4218
+ );
4219
+ assert_eq!(
4220
+ normalize_transcription_mime_type(Some("application/octet-stream")),
4221
+ "audio/wav".to_string()
4222
+ );
4223
+ assert_eq!(
4224
+ normalize_transcription_mime_type(None),
4225
+ "audio/wav".to_string()
4226
+ );
4227
+ }
4228
+
4229
+ #[test]
4230
+ fn voice_transcribe_request_deserializes_legacy_and_extended_shapes() {
4231
+ let legacy: VoiceTranscribeRequest = serde_json::from_value(json!({
4232
+ "dataBase64": "YQ==",
4233
+ "prompt": "hello"
4234
+ }))
4235
+ .expect("deserialize legacy request shape");
4236
+ assert_eq!(legacy.data_base64, "YQ==");
4237
+ assert_eq!(legacy.prompt.as_deref(), Some("hello"));
4238
+ assert!(legacy.file_name.is_none());
4239
+ assert!(legacy.mime_type.is_none());
4240
+
4241
+ let extended: VoiceTranscribeRequest = serde_json::from_value(json!({
4242
+ "dataBase64": "YQ==",
4243
+ "prompt": "hello",
4244
+ "fileName": "audio.m4a",
4245
+ "mimeType": "audio/mp4"
4246
+ }))
4247
+ .expect("deserialize extended request shape");
4248
+ assert_eq!(extended.data_base64, "YQ==");
4249
+ assert_eq!(extended.prompt.as_deref(), Some("hello"));
4250
+ assert_eq!(extended.file_name.as_deref(), Some("audio.m4a"));
4251
+ assert_eq!(extended.mime_type.as_deref(), Some("audio/mp4"));
4252
+ }
4253
+
4254
+ #[test]
4255
+ fn transcription_file_name_normalization_sanitizes_and_sets_extension() {
4256
+ assert_eq!(
4257
+ normalize_transcription_file_name(Some("../voice note"), "audio/mp4"),
4258
+ "voice_note.m4a".to_string()
4259
+ );
4260
+ assert_eq!(
4261
+ normalize_transcription_file_name(None, "audio/wav"),
4262
+ "audio.wav".to_string()
4263
+ );
4264
+ assert_eq!(
4265
+ normalize_transcription_file_name(Some("meeting"), "audio/webm"),
4266
+ "meeting.webm".to_string()
4267
+ );
4268
+ }
4269
+
3696
4270
  #[test]
3697
4271
  fn disallowed_control_character_detection_flags_shell_metacharacters() {
3698
4272
  assert!(!contains_disallowed_control_chars("git status"));
@@ -3733,6 +4307,7 @@ mod tests {
3733
4307
  allow_outside_root_cwd: false,
3734
4308
  disable_terminal_exec: false,
3735
4309
  terminal_allowed_commands: HashSet::new(),
4310
+ show_pairing_qr: false,
3736
4311
  };
3737
4312
 
3738
4313
  let mut headers = HeaderMap::new();