clawdex-mobile 2.0.1 → 3.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 (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +1 -1
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -13,11 +13,15 @@ use std::{
13
13
  };
14
14
 
15
15
  use axum::{
16
+ body::Body,
16
17
  extract::{
17
18
  ws::{Message, WebSocket, WebSocketUpgrade},
18
19
  Query, State,
19
20
  },
20
- http::{HeaderMap, StatusCode},
21
+ http::{
22
+ header::{CACHE_CONTROL, CONTENT_TYPE},
23
+ HeaderMap, StatusCode,
24
+ },
21
25
  response::{IntoResponse, Response},
22
26
  routing::get,
23
27
  Json, Router,
@@ -598,6 +602,37 @@ impl AppServerBridge {
598
602
  Ok(())
599
603
  }
600
604
 
605
+ async fn request_internal(&self, method: &str, params: Option<Value>) -> Result<Value, String> {
606
+ let internal_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
607
+ let (tx, rx) = oneshot::channel::<Result<Value, String>>();
608
+ self.internal_waiters.lock().await.insert(internal_id, tx);
609
+
610
+ let mut payload = json!({
611
+ "id": internal_id,
612
+ "method": method,
613
+ });
614
+ if let Some(params) = params {
615
+ payload["params"] = params;
616
+ }
617
+
618
+ if let Err(error) = self.write_json(payload).await {
619
+ self.internal_waiters.lock().await.remove(&internal_id);
620
+ return Err(format!(
621
+ "failed forwarding internal request to app-server: {error}"
622
+ ));
623
+ }
624
+
625
+ match timeout(Duration::from_secs(20), rx).await {
626
+ Ok(Ok(Ok(result))) => Ok(result),
627
+ Ok(Ok(Err(message))) => Err(message),
628
+ Ok(Err(_)) => Err("internal app-server waiter dropped".to_string()),
629
+ Err(_) => {
630
+ self.internal_waiters.lock().await.remove(&internal_id);
631
+ Err(format!("internal app-server request timed out: {method}"))
632
+ }
633
+ }
634
+ }
635
+
601
636
  async fn list_pending_approvals(&self) -> Vec<PendingApproval> {
602
637
  let mut approvals = self
603
638
  .pending_approvals
@@ -1506,10 +1541,7 @@ fn build_rollout_event_msg_notification(
1506
1541
  timestamp: Option<&str>,
1507
1542
  ) -> Option<(String, Value)> {
1508
1543
  let raw_type = read_string(payload.get("type"))?;
1509
- if matches!(
1510
- raw_type.as_str(),
1511
- "token_count" | "user_message" | "context_compacted"
1512
- ) {
1544
+ if matches!(raw_type.as_str(), "user_message" | "context_compacted") {
1513
1545
  return None;
1514
1546
  }
1515
1547
 
@@ -1864,6 +1896,55 @@ struct AttachmentUploadRequest {
1864
1896
  kind: Option<String>,
1865
1897
  }
1866
1898
 
1899
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1900
+ #[serde(rename_all = "camelCase")]
1901
+ struct WorkspaceListRequest {
1902
+ limit: Option<usize>,
1903
+ }
1904
+
1905
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1906
+ #[serde(rename_all = "camelCase")]
1907
+ struct WorkspaceSummary {
1908
+ path: String,
1909
+ chat_count: usize,
1910
+ }
1911
+
1912
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1913
+ #[serde(rename_all = "camelCase")]
1914
+ struct WorkspaceListResponse {
1915
+ bridge_root: String,
1916
+ allow_outside_root_cwd: bool,
1917
+ workspaces: Vec<WorkspaceSummary>,
1918
+ }
1919
+
1920
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1921
+ #[serde(rename_all = "camelCase")]
1922
+ struct FileSystemListRequest {
1923
+ path: Option<String>,
1924
+ include_hidden: Option<bool>,
1925
+ directories_only: Option<bool>,
1926
+ }
1927
+
1928
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1929
+ #[serde(rename_all = "camelCase")]
1930
+ struct FileSystemEntry {
1931
+ name: String,
1932
+ path: String,
1933
+ kind: String,
1934
+ hidden: bool,
1935
+ selectable: bool,
1936
+ is_git_repo: bool,
1937
+ }
1938
+
1939
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1940
+ #[serde(rename_all = "camelCase")]
1941
+ struct FileSystemListResponse {
1942
+ bridge_root: String,
1943
+ path: String,
1944
+ parent_path: Option<String>,
1945
+ entries: Vec<FileSystemEntry>,
1946
+ }
1947
+
1867
1948
  #[derive(Debug, Clone, Serialize, Deserialize)]
1868
1949
  #[serde(rename_all = "camelCase")]
1869
1950
  struct AttachmentUploadResponse {
@@ -1959,6 +2040,12 @@ struct RpcQuery {
1959
2040
  token: Option<String>,
1960
2041
  }
1961
2042
 
2043
+ #[derive(Debug, Deserialize)]
2044
+ struct LocalImageQuery {
2045
+ path: String,
2046
+ token: Option<String>,
2047
+ }
2048
+
1962
2049
  #[tokio::main]
1963
2050
  async fn main() {
1964
2051
  let config = match BridgeConfig::from_env() {
@@ -2014,6 +2101,7 @@ async fn main() {
2014
2101
  let app = Router::new()
2015
2102
  .route("/rpc", get(ws_handler))
2016
2103
  .route("/health", get(health_handler))
2104
+ .route("/local-image", get(local_image_handler))
2017
2105
  .with_state(state);
2018
2106
 
2019
2107
  let bind_addr = format!("{}:{}", config.host, config.port);
@@ -2042,6 +2130,120 @@ async fn health_handler(State(state): State<Arc<AppState>>) -> Json<Value> {
2042
2130
  }))
2043
2131
  }
2044
2132
 
2133
+ async fn local_image_handler(
2134
+ State(state): State<Arc<AppState>>,
2135
+ headers: HeaderMap,
2136
+ Query(query): Query<LocalImageQuery>,
2137
+ ) -> Response {
2138
+ if !state.config.is_authorized(&headers, query.token.as_deref()) {
2139
+ return (
2140
+ StatusCode::UNAUTHORIZED,
2141
+ Json(json!({
2142
+ "error": "unauthorized",
2143
+ "message": "Missing or invalid bridge token"
2144
+ })),
2145
+ )
2146
+ .into_response();
2147
+ }
2148
+
2149
+ let path = match resolve_local_image_path(&query.path) {
2150
+ Ok(path) => path,
2151
+ Err(message) => {
2152
+ return (
2153
+ StatusCode::BAD_REQUEST,
2154
+ Json(json!({
2155
+ "error": "invalid_path",
2156
+ "message": message,
2157
+ })),
2158
+ )
2159
+ .into_response();
2160
+ }
2161
+ };
2162
+
2163
+ let canonical = match fs::canonicalize(&path).await {
2164
+ Ok(path) => normalize_path(&path),
2165
+ Err(_) => {
2166
+ return (
2167
+ StatusCode::NOT_FOUND,
2168
+ Json(json!({
2169
+ "error": "not_found",
2170
+ "message": "Image file not found"
2171
+ })),
2172
+ )
2173
+ .into_response();
2174
+ }
2175
+ };
2176
+
2177
+ let metadata = match fs::metadata(&canonical).await {
2178
+ Ok(metadata) => metadata,
2179
+ Err(_) => {
2180
+ return (
2181
+ StatusCode::NOT_FOUND,
2182
+ Json(json!({
2183
+ "error": "not_found",
2184
+ "message": "Image file not found"
2185
+ })),
2186
+ )
2187
+ .into_response();
2188
+ }
2189
+ };
2190
+
2191
+ if !metadata.is_file() {
2192
+ return (
2193
+ StatusCode::BAD_REQUEST,
2194
+ Json(json!({
2195
+ "error": "invalid_path",
2196
+ "message": "Image path must reference a file"
2197
+ })),
2198
+ )
2199
+ .into_response();
2200
+ }
2201
+
2202
+ let content_type = match infer_image_content_type_from_path(&canonical) {
2203
+ Some(content_type) => content_type,
2204
+ None => {
2205
+ return (
2206
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
2207
+ Json(json!({
2208
+ "error": "unsupported_media_type",
2209
+ "message": "Only image files can be served through /local-image"
2210
+ })),
2211
+ )
2212
+ .into_response();
2213
+ }
2214
+ };
2215
+
2216
+ let bytes = match fs::read(&canonical).await {
2217
+ Ok(bytes) => bytes,
2218
+ Err(error) => {
2219
+ return (
2220
+ StatusCode::INTERNAL_SERVER_ERROR,
2221
+ Json(json!({
2222
+ "error": "read_failed",
2223
+ "message": format!("Failed to read image file: {error}")
2224
+ })),
2225
+ )
2226
+ .into_response();
2227
+ }
2228
+ };
2229
+
2230
+ Response::builder()
2231
+ .status(StatusCode::OK)
2232
+ .header(CONTENT_TYPE, content_type)
2233
+ .header(CACHE_CONTROL, "no-store")
2234
+ .body(Body::from(bytes))
2235
+ .unwrap_or_else(|error| {
2236
+ (
2237
+ StatusCode::INTERNAL_SERVER_ERROR,
2238
+ Json(json!({
2239
+ "error": "response_failed",
2240
+ "message": format!("Failed to build image response: {error}")
2241
+ })),
2242
+ )
2243
+ .into_response()
2244
+ })
2245
+ }
2246
+
2045
2247
  async fn ws_handler(
2046
2248
  ws: WebSocketUpgrade,
2047
2249
  State(state): State<Arc<AppState>>,
@@ -2269,6 +2471,20 @@ async fn handle_bridge_method(
2269
2471
  "latestEventId": state.hub.latest_event_id(),
2270
2472
  }))
2271
2473
  }
2474
+ "bridge/workspaces/list" => {
2475
+ let request: WorkspaceListRequest =
2476
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
2477
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
2478
+ let result = list_workspace_roots(state, request).await?;
2479
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
2480
+ }
2481
+ "bridge/fs/list" => {
2482
+ let request: FileSystemListRequest =
2483
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
2484
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
2485
+ let result = list_filesystem_entries(state, request).await?;
2486
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
2487
+ }
2272
2488
  "bridge/terminal/exec" => {
2273
2489
  let request: TerminalExecRequest =
2274
2490
  serde_json::from_value(params.unwrap_or_else(|| json!({})))
@@ -2534,6 +2750,157 @@ async fn handle_bridge_method(
2534
2750
  }
2535
2751
  }
2536
2752
 
2753
+ async fn list_workspace_roots(
2754
+ state: &Arc<AppState>,
2755
+ request: WorkspaceListRequest,
2756
+ ) -> Result<WorkspaceListResponse, BridgeError> {
2757
+ let limit = request.limit.unwrap_or(200).clamp(1, 1000);
2758
+ let result = state
2759
+ .app_server
2760
+ .request_internal(
2761
+ "thread/list",
2762
+ Some(json!({
2763
+ "cursor": Value::Null,
2764
+ "limit": limit,
2765
+ "sortKey": Value::Null,
2766
+ "modelProviders": Value::Null,
2767
+ "sourceKinds": ["cli", "vscode", "exec", "appServer", "unknown"],
2768
+ "archived": false,
2769
+ "cwd": Value::Null,
2770
+ })),
2771
+ )
2772
+ .await
2773
+ .map_err(|error| BridgeError::server(&error))?;
2774
+
2775
+ let entries = result
2776
+ .get("data")
2777
+ .and_then(Value::as_array)
2778
+ .cloned()
2779
+ .unwrap_or_default();
2780
+
2781
+ let mut workspaces_by_path: HashMap<String, (usize, u64)> = HashMap::new();
2782
+
2783
+ for entry in entries {
2784
+ let Some(object) = entry.as_object() else {
2785
+ continue;
2786
+ };
2787
+
2788
+ let Some(raw_cwd) = read_string(object.get("cwd")) else {
2789
+ continue;
2790
+ };
2791
+
2792
+ let Some(canonical_path) =
2793
+ normalize_existing_directory(&state.config.workdir, raw_cwd.as_str()).await
2794
+ else {
2795
+ continue;
2796
+ };
2797
+
2798
+ let workspace_path = path_to_string(&canonical_path);
2799
+ let updated_at = parse_internal_id(object.get("updatedAt")).unwrap_or(0);
2800
+ let workspace_entry = workspaces_by_path
2801
+ .entry(workspace_path)
2802
+ .or_insert((0, updated_at));
2803
+ workspace_entry.0 += 1;
2804
+ workspace_entry.1 = workspace_entry.1.max(updated_at);
2805
+ }
2806
+
2807
+ let mut workspaces = workspaces_by_path
2808
+ .into_iter()
2809
+ .map(|(path, (chat_count, updated_at))| (WorkspaceSummary { path, chat_count }, updated_at))
2810
+ .collect::<Vec<_>>();
2811
+
2812
+ workspaces.sort_by(|(left, left_updated_at), (right, right_updated_at)| {
2813
+ right_updated_at
2814
+ .cmp(left_updated_at)
2815
+ .then_with(|| left.path.cmp(&right.path))
2816
+ });
2817
+
2818
+ Ok(WorkspaceListResponse {
2819
+ bridge_root: path_to_string(&state.config.workdir),
2820
+ allow_outside_root_cwd: state.config.allow_outside_root_cwd,
2821
+ workspaces: workspaces
2822
+ .into_iter()
2823
+ .map(|(workspace, _)| workspace)
2824
+ .collect(),
2825
+ })
2826
+ }
2827
+
2828
+ async fn list_filesystem_entries(
2829
+ state: &Arc<AppState>,
2830
+ request: FileSystemListRequest,
2831
+ ) -> Result<FileSystemListResponse, BridgeError> {
2832
+ let include_hidden = request.include_hidden.unwrap_or(false);
2833
+ let directories_only = request.directories_only.unwrap_or(true);
2834
+ let current_path =
2835
+ resolve_browsable_directory(&state.config.workdir, request.path.as_deref()).await?;
2836
+
2837
+ let mut read_dir = fs::read_dir(&current_path)
2838
+ .await
2839
+ .map_err(|error| BridgeError::server(&format!("failed to read directory: {error}")))?;
2840
+ let mut entries = Vec::new();
2841
+
2842
+ while let Some(entry) = read_dir
2843
+ .next_entry()
2844
+ .await
2845
+ .map_err(|error| BridgeError::server(&format!("failed to read directory entry: {error}")))?
2846
+ {
2847
+ let name = entry.file_name().to_string_lossy().to_string();
2848
+ if name.is_empty() {
2849
+ continue;
2850
+ }
2851
+
2852
+ let hidden = name.starts_with('.');
2853
+ if hidden && !include_hidden {
2854
+ continue;
2855
+ }
2856
+
2857
+ let entry_path = normalize_path(&entry.path());
2858
+ let metadata = match fs::metadata(&entry_path).await {
2859
+ Ok(metadata) => metadata,
2860
+ Err(_) => continue,
2861
+ };
2862
+
2863
+ let is_directory = metadata.is_dir();
2864
+ if directories_only && !is_directory {
2865
+ continue;
2866
+ }
2867
+
2868
+ let kind = if is_directory { "directory" } else { "file" }.to_string();
2869
+ let is_git_repo = if is_directory {
2870
+ fs::metadata(entry_path.join(".git")).await.is_ok()
2871
+ } else {
2872
+ false
2873
+ };
2874
+
2875
+ entries.push(FileSystemEntry {
2876
+ name,
2877
+ path: path_to_string(&entry_path),
2878
+ kind,
2879
+ hidden,
2880
+ selectable: is_directory,
2881
+ is_git_repo,
2882
+ });
2883
+ }
2884
+
2885
+ entries.sort_by(|left, right| {
2886
+ right.selectable.cmp(&left.selectable).then_with(|| {
2887
+ left.name
2888
+ .to_ascii_lowercase()
2889
+ .cmp(&right.name.to_ascii_lowercase())
2890
+ .then_with(|| left.name.cmp(&right.name))
2891
+ })
2892
+ });
2893
+
2894
+ let parent_path = current_path.parent().map(path_to_string);
2895
+
2896
+ Ok(FileSystemListResponse {
2897
+ bridge_root: path_to_string(&state.config.workdir),
2898
+ path: path_to_string(&current_path),
2899
+ parent_path,
2900
+ entries,
2901
+ })
2902
+ }
2903
+
2537
2904
  async fn transcribe_voice(request: VoiceTranscribeRequest) -> Result<Value, BridgeError> {
2538
2905
  let max_voice_transcription_bytes = resolve_max_voice_transcription_bytes();
2539
2906
  let estimated_size = estimate_base64_decoded_size(&request.data_base64)?;
@@ -2743,6 +3110,71 @@ fn resolve_bridge_workdir(raw_workdir: PathBuf) -> Result<PathBuf, String> {
2743
3110
  Ok(normalize_path(&canonical))
2744
3111
  }
2745
3112
 
3113
+ fn path_to_string(path: &Path) -> String {
3114
+ path.to_string_lossy().to_string()
3115
+ }
3116
+
3117
+ async fn normalize_existing_directory(base: &Path, raw_path: &str) -> Option<PathBuf> {
3118
+ let trimmed = raw_path.trim();
3119
+ if trimmed.is_empty() {
3120
+ return None;
3121
+ }
3122
+
3123
+ let candidate = PathBuf::from(trimmed);
3124
+ let resolved = if candidate.is_absolute() {
3125
+ candidate
3126
+ } else {
3127
+ base.join(candidate)
3128
+ };
3129
+
3130
+ let canonical = fs::canonicalize(&resolved).await.ok()?;
3131
+ let metadata = fs::metadata(&canonical).await.ok()?;
3132
+ if !metadata.is_dir() {
3133
+ return None;
3134
+ }
3135
+
3136
+ Some(normalize_path(&canonical))
3137
+ }
3138
+
3139
+ async fn resolve_browsable_directory(
3140
+ base: &Path,
3141
+ raw_path: Option<&str>,
3142
+ ) -> Result<PathBuf, BridgeError> {
3143
+ let trimmed = raw_path.map(str::trim).unwrap_or("");
3144
+ let candidate = if trimmed.is_empty() {
3145
+ base.to_path_buf()
3146
+ } else {
3147
+ let requested = PathBuf::from(trimmed);
3148
+ if requested.is_absolute() {
3149
+ requested
3150
+ } else {
3151
+ base.join(requested)
3152
+ }
3153
+ };
3154
+
3155
+ let canonical = fs::canonicalize(&candidate).await.map_err(|error| {
3156
+ BridgeError::invalid_params(&format!(
3157
+ "workspace directory is invalid or inaccessible ({}): {error}",
3158
+ candidate.to_string_lossy()
3159
+ ))
3160
+ })?;
3161
+
3162
+ let metadata = fs::metadata(&canonical).await.map_err(|error| {
3163
+ BridgeError::server(&format!(
3164
+ "failed to inspect workspace directory ({}): {error}",
3165
+ canonical.to_string_lossy()
3166
+ ))
3167
+ })?;
3168
+
3169
+ if !metadata.is_dir() {
3170
+ return Err(BridgeError::invalid_params(
3171
+ "workspace directory must point to a folder",
3172
+ ));
3173
+ }
3174
+
3175
+ Ok(normalize_path(&canonical))
3176
+ }
3177
+
2746
3178
  fn is_unspecified_bind_host(host: &str) -> bool {
2747
3179
  matches!(
2748
3180
  host.trim().to_ascii_lowercase().as_str(),
@@ -3453,6 +3885,33 @@ fn now_iso() -> String {
3453
3885
  Utc::now().to_rfc3339()
3454
3886
  }
3455
3887
 
3888
+ fn resolve_local_image_path(raw_path: &str) -> Result<PathBuf, &'static str> {
3889
+ let trimmed = raw_path.trim();
3890
+ if trimmed.is_empty() {
3891
+ return Err("Image path is required");
3892
+ }
3893
+
3894
+ let path = PathBuf::from(trimmed);
3895
+ if !path.is_absolute() {
3896
+ return Err("Image path must be absolute");
3897
+ }
3898
+
3899
+ Ok(normalize_path(&path))
3900
+ }
3901
+
3902
+ fn infer_image_content_type_from_path(path: &Path) -> Option<&'static str> {
3903
+ let extension = path.extension()?.to_str()?.trim().to_ascii_lowercase();
3904
+ match extension.as_str() {
3905
+ "png" => Some("image/png"),
3906
+ "jpg" | "jpeg" => Some("image/jpeg"),
3907
+ "gif" => Some("image/gif"),
3908
+ "webp" => Some("image/webp"),
3909
+ "heic" => Some("image/heic"),
3910
+ "heif" => Some("image/heif"),
3911
+ _ => None,
3912
+ }
3913
+ }
3914
+
3456
3915
  fn normalize_path(path: &Path) -> PathBuf {
3457
3916
  let mut normalized = PathBuf::new();
3458
3917
 
@@ -3839,18 +4298,29 @@ mod tests {
3839
4298
  }
3840
4299
 
3841
4300
  #[test]
3842
- fn rollout_event_msg_mapping_ignores_noise_events() {
3843
- assert!(build_rollout_event_msg_notification(
4301
+ fn rollout_event_msg_mapping_forwards_token_count_events() {
4302
+ let token_count = build_rollout_event_msg_notification(
3844
4303
  json!({
3845
4304
  "type": "token_count",
3846
- "info": {}
4305
+ "info": {
4306
+ "model_context_window": 200000
4307
+ }
3847
4308
  })
3848
4309
  .as_object()
3849
4310
  .expect("event payload object"),
3850
4311
  "thread-1",
3851
4312
  None,
3852
4313
  )
3853
- .is_none());
4314
+ .expect("token count notification");
4315
+
4316
+ assert_eq!(token_count.0, "codex/event/token_count");
4317
+ assert_eq!(token_count.1["msg"]["type"], "token_count");
4318
+ assert_eq!(token_count.1["msg"]["thread_id"], "thread-1");
4319
+ assert_eq!(token_count.1["msg"]["info"]["model_context_window"], 200000);
4320
+ }
4321
+
4322
+ #[test]
4323
+ fn rollout_event_msg_mapping_ignores_noise_events() {
3854
4324
  assert!(build_rollout_event_msg_notification(
3855
4325
  json!({
3856
4326
  "type": "user_message",
@@ -4286,6 +4756,34 @@ mod tests {
4286
4756
  );
4287
4757
  }
4288
4758
 
4759
+ #[test]
4760
+ fn resolve_local_image_path_requires_absolute_paths() {
4761
+ assert_eq!(
4762
+ resolve_local_image_path("/tmp/../tmp/example.png").unwrap(),
4763
+ PathBuf::from("/tmp/example.png")
4764
+ );
4765
+ assert_eq!(
4766
+ resolve_local_image_path("relative/example.png").unwrap_err(),
4767
+ "Image path must be absolute"
4768
+ );
4769
+ }
4770
+
4771
+ #[test]
4772
+ fn infer_image_content_type_from_path_supports_common_extensions() {
4773
+ assert_eq!(
4774
+ infer_image_content_type_from_path(Path::new("/tmp/example.png")),
4775
+ Some("image/png")
4776
+ );
4777
+ assert_eq!(
4778
+ infer_image_content_type_from_path(Path::new("/tmp/example.JPG")),
4779
+ Some("image/jpeg")
4780
+ );
4781
+ assert_eq!(
4782
+ infer_image_content_type_from_path(Path::new("/tmp/example.txt")),
4783
+ None
4784
+ );
4785
+ }
4786
+
4289
4787
  #[test]
4290
4788
  fn constant_time_eq_handles_equal_and_different_strings() {
4291
4789
  assert!(constant_time_eq("secret-token", "secret-token"));
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Clawdex Mobile Support</title>
7
+ <meta
8
+ name="description"
9
+ content="Support, privacy policy, and terms for Clawdex Mobile."
10
+ />
11
+ <link rel="stylesheet" href="./styles.css" />
12
+ </head>
13
+ <body>
14
+ <main class="shell">
15
+ <section class="hero">
16
+ <p class="eyebrow">Clawdex Mobile</p>
17
+ <h1>Support and legal information for App Store review and users.</h1>
18
+ <p>
19
+ Clawdex Mobile is a companion app for a user-hosted bridge running on infrastructure you
20
+ control. Use the links below for support, privacy, and terms information.
21
+ </p>
22
+ <nav class="nav" aria-label="Primary">
23
+ <a class="cta" href="./support/">Open support</a>
24
+ <a class="cta secondary" href="./privacy/">Privacy policy</a>
25
+ <a class="cta secondary" href="./terms/">Terms of service</a>
26
+ </nav>
27
+ </section>
28
+ <section class="grid">
29
+ <article class="card">
30
+ <h2>Support</h2>
31
+ <p>
32
+ Primary support contact: <a href="mailto:contact@arkaledge.com">contact@arkaledge.com</a>
33
+ </p>
34
+ <p>Technical bug reports can also be filed on GitHub if email is not required.</p>
35
+ </article>
36
+ <article class="card">
37
+ <h2>Privacy</h2>
38
+ <p>
39
+ Read how Clawdex Mobile processes connection details, prompts, attachments, and
40
+ optional voice input.
41
+ </p>
42
+ </article>
43
+ <article class="card">
44
+ <h2>Terms</h2>
45
+ <p>
46
+ Review acceptable use, credential handling, operational risk, and service
47
+ availability terms.
48
+ </p>
49
+ </article>
50
+ </section>
51
+ <p class="meta">Last updated: March 11, 2026</p>
52
+ </main>
53
+ </body>
54
+ </html>