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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/npm-release.yml +18 -0
- package/AGENTS.md +3 -3
- package/README.md +104 -542
- package/apps/mobile/.env.example +1 -2
- package/apps/mobile/App.tsx +261 -68
- package/apps/mobile/app.json +31 -5
- package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
- package/apps/mobile/eas.json +30 -0
- package/apps/mobile/package.json +22 -21
- package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
- package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
- package/apps/mobile/src/api/chatMapping.ts +48 -8
- package/apps/mobile/src/api/client.ts +6 -0
- package/apps/mobile/src/api/types.ts +11 -0
- package/apps/mobile/src/api/ws.ts +52 -10
- package/apps/mobile/src/bridgeUrl.ts +105 -0
- package/apps/mobile/src/components/ActivityBar.tsx +32 -13
- package/apps/mobile/src/components/ChatHeader.tsx +3 -2
- package/apps/mobile/src/components/ChatInput.tsx +246 -91
- package/apps/mobile/src/components/ChatMessage.tsx +108 -4
- package/apps/mobile/src/config.ts +11 -29
- package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
- package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
- package/apps/mobile/src/screens/GitScreen.tsx +1 -1
- package/apps/mobile/src/screens/MainScreen.tsx +906 -268
- package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
- package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
- package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
- package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
- package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
- package/docs/app-review-notes.md +7 -2
- package/docs/eas-builds.md +91 -0
- package/docs/realtime-streaming-limitations.md +84 -0
- package/docs/setup-and-operations.md +239 -0
- package/docs/troubleshooting.md +121 -0
- package/docs/voice-transcription.md +87 -0
- package/package.json +8 -16
- package/scripts/setup-secure-dev.sh +122 -8
- package/scripts/setup-wizard.sh +342 -122
- package/scripts/start-bridge-secure.sh +7 -1
- package/scripts/sync-versions.js +63 -0
- package/services/rust-bridge/.env.example +1 -1
- package/services/rust-bridge/Cargo.lock +1104 -23
- package/services/rust-bridge/Cargo.toml +3 -1
- package/services/rust-bridge/package.json +1 -1
- package/services/rust-bridge/src/main.rs +587 -12
- 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 =
|
|
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
|
|
1059
|
+
include_for_live_sync = rollout_originator_allowed(meta_originator.as_deref());
|
|
1056
1060
|
thread_id = Some(meta_thread_id);
|
|
1057
|
-
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, ¶ms)
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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", ¶ms)
|
|
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", ¶ms)
|
|
3902
|
+
.expect("complete status");
|
|
3903
|
+
assert_eq!(completed["status"], "completed");
|
|
3904
|
+
|
|
3905
|
+
let failed = build_rollout_thread_status_notification("codex/event/task_failed", ¶ms)
|
|
3906
|
+
.expect("failed status");
|
|
3907
|
+
assert_eq!(failed["status"], "failed");
|
|
3908
|
+
|
|
3909
|
+
let interrupted =
|
|
3910
|
+
build_rollout_thread_status_notification("codex/event/task_interrupted", ¶ms)
|
|
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
|
+
¶ms
|
|
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();
|