clawdex-mobile 2.0.1 → 4.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 (81) hide show
  1. package/.github/workflows/ci.yml +4 -3
  2. package/.github/workflows/npm-release.yml +62 -2
  3. package/.github/workflows/pages.yml +41 -0
  4. package/AGENTS.md +263 -110
  5. package/README.md +15 -4
  6. package/apps/mobile/.env.example +2 -2
  7. package/apps/mobile/App.tsx +175 -14
  8. package/apps/mobile/app.json +27 -9
  9. package/apps/mobile/eas.json +14 -4
  10. package/apps/mobile/package.json +14 -13
  11. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  12. package/apps/mobile/src/api/__tests__/client.test.ts +587 -6
  13. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  14. package/apps/mobile/src/api/account.ts +47 -0
  15. package/apps/mobile/src/api/chatMapping.ts +435 -18
  16. package/apps/mobile/src/api/client.ts +321 -36
  17. package/apps/mobile/src/api/rateLimits.ts +143 -0
  18. package/apps/mobile/src/api/types.ts +107 -0
  19. package/apps/mobile/src/api/ws.ts +10 -1
  20. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  21. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  22. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  23. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  24. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  25. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  26. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  27. package/apps/mobile/src/components/WorkspacePickerModal.tsx +812 -0
  28. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  29. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  30. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  31. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  32. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  33. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  34. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  35. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  36. package/apps/mobile/src/config.ts +9 -2
  37. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  38. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  39. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  40. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  41. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  42. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  43. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/MainScreen.tsx +4239 -1237
  45. package/apps/mobile/src/screens/OnboardingScreen.tsx +924 -310
  46. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  47. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  48. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  49. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  50. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  51. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  52. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  53. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  54. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  55. package/apps/mobile/src/screens/planCardState.ts +40 -0
  56. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  57. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  58. package/apps/mobile/src/theme.ts +6 -12
  59. package/bin/clawdex.js +7 -6
  60. package/codex-rust-bridge +0 -0
  61. package/codex-rust-bridge.exe +0 -0
  62. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  63. package/docs/privacy-policy.md +54 -0
  64. package/docs/setup-and-operations.md +21 -15
  65. package/docs/terms-of-service.md +33 -0
  66. package/docs/troubleshooting.md +15 -19
  67. package/package.json +6 -5
  68. package/scripts/bridge-binary.js +194 -0
  69. package/scripts/setup-wizard.sh +17 -186
  70. package/scripts/start-bridge-secure.js +240 -0
  71. package/scripts/start-bridge-secure.sh +1 -40
  72. package/services/mac-bridge/package.json +6 -6
  73. package/services/rust-bridge/Cargo.lock +56 -47
  74. package/services/rust-bridge/Cargo.toml +1 -1
  75. package/services/rust-bridge/package.json +1 -1
  76. package/services/rust-bridge/src/main.rs +517 -9
  77. package/site/index.html +54 -0
  78. package/site/privacy/index.html +80 -0
  79. package/site/styles.css +135 -0
  80. package/site/support/index.html +51 -0
  81. 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,56 @@ 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
+ updated_at: Option<u64>,
1911
+ }
1912
+
1913
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1914
+ #[serde(rename_all = "camelCase")]
1915
+ struct WorkspaceListResponse {
1916
+ bridge_root: String,
1917
+ allow_outside_root_cwd: bool,
1918
+ workspaces: Vec<WorkspaceSummary>,
1919
+ }
1920
+
1921
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
1922
+ #[serde(rename_all = "camelCase")]
1923
+ struct FileSystemListRequest {
1924
+ path: Option<String>,
1925
+ include_hidden: Option<bool>,
1926
+ directories_only: Option<bool>,
1927
+ }
1928
+
1929
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1930
+ #[serde(rename_all = "camelCase")]
1931
+ struct FileSystemEntry {
1932
+ name: String,
1933
+ path: String,
1934
+ kind: String,
1935
+ hidden: bool,
1936
+ selectable: bool,
1937
+ is_git_repo: bool,
1938
+ }
1939
+
1940
+ #[derive(Debug, Clone, Serialize, Deserialize)]
1941
+ #[serde(rename_all = "camelCase")]
1942
+ struct FileSystemListResponse {
1943
+ bridge_root: String,
1944
+ path: String,
1945
+ parent_path: Option<String>,
1946
+ entries: Vec<FileSystemEntry>,
1947
+ }
1948
+
1867
1949
  #[derive(Debug, Clone, Serialize, Deserialize)]
1868
1950
  #[serde(rename_all = "camelCase")]
1869
1951
  struct AttachmentUploadResponse {
@@ -1959,6 +2041,12 @@ struct RpcQuery {
1959
2041
  token: Option<String>,
1960
2042
  }
1961
2043
 
2044
+ #[derive(Debug, Deserialize)]
2045
+ struct LocalImageQuery {
2046
+ path: String,
2047
+ token: Option<String>,
2048
+ }
2049
+
1962
2050
  #[tokio::main]
1963
2051
  async fn main() {
1964
2052
  let config = match BridgeConfig::from_env() {
@@ -2014,6 +2102,7 @@ async fn main() {
2014
2102
  let app = Router::new()
2015
2103
  .route("/rpc", get(ws_handler))
2016
2104
  .route("/health", get(health_handler))
2105
+ .route("/local-image", get(local_image_handler))
2017
2106
  .with_state(state);
2018
2107
 
2019
2108
  let bind_addr = format!("{}:{}", config.host, config.port);
@@ -2042,6 +2131,120 @@ async fn health_handler(State(state): State<Arc<AppState>>) -> Json<Value> {
2042
2131
  }))
2043
2132
  }
2044
2133
 
2134
+ async fn local_image_handler(
2135
+ State(state): State<Arc<AppState>>,
2136
+ headers: HeaderMap,
2137
+ Query(query): Query<LocalImageQuery>,
2138
+ ) -> Response {
2139
+ if !state.config.is_authorized(&headers, query.token.as_deref()) {
2140
+ return (
2141
+ StatusCode::UNAUTHORIZED,
2142
+ Json(json!({
2143
+ "error": "unauthorized",
2144
+ "message": "Missing or invalid bridge token"
2145
+ })),
2146
+ )
2147
+ .into_response();
2148
+ }
2149
+
2150
+ let path = match resolve_local_image_path(&query.path) {
2151
+ Ok(path) => path,
2152
+ Err(message) => {
2153
+ return (
2154
+ StatusCode::BAD_REQUEST,
2155
+ Json(json!({
2156
+ "error": "invalid_path",
2157
+ "message": message,
2158
+ })),
2159
+ )
2160
+ .into_response();
2161
+ }
2162
+ };
2163
+
2164
+ let canonical = match fs::canonicalize(&path).await {
2165
+ Ok(path) => normalize_path(&path),
2166
+ Err(_) => {
2167
+ return (
2168
+ StatusCode::NOT_FOUND,
2169
+ Json(json!({
2170
+ "error": "not_found",
2171
+ "message": "Image file not found"
2172
+ })),
2173
+ )
2174
+ .into_response();
2175
+ }
2176
+ };
2177
+
2178
+ let metadata = match fs::metadata(&canonical).await {
2179
+ Ok(metadata) => metadata,
2180
+ Err(_) => {
2181
+ return (
2182
+ StatusCode::NOT_FOUND,
2183
+ Json(json!({
2184
+ "error": "not_found",
2185
+ "message": "Image file not found"
2186
+ })),
2187
+ )
2188
+ .into_response();
2189
+ }
2190
+ };
2191
+
2192
+ if !metadata.is_file() {
2193
+ return (
2194
+ StatusCode::BAD_REQUEST,
2195
+ Json(json!({
2196
+ "error": "invalid_path",
2197
+ "message": "Image path must reference a file"
2198
+ })),
2199
+ )
2200
+ .into_response();
2201
+ }
2202
+
2203
+ let content_type = match infer_image_content_type_from_path(&canonical) {
2204
+ Some(content_type) => content_type,
2205
+ None => {
2206
+ return (
2207
+ StatusCode::UNSUPPORTED_MEDIA_TYPE,
2208
+ Json(json!({
2209
+ "error": "unsupported_media_type",
2210
+ "message": "Only image files can be served through /local-image"
2211
+ })),
2212
+ )
2213
+ .into_response();
2214
+ }
2215
+ };
2216
+
2217
+ let bytes = match fs::read(&canonical).await {
2218
+ Ok(bytes) => bytes,
2219
+ Err(error) => {
2220
+ return (
2221
+ StatusCode::INTERNAL_SERVER_ERROR,
2222
+ Json(json!({
2223
+ "error": "read_failed",
2224
+ "message": format!("Failed to read image file: {error}")
2225
+ })),
2226
+ )
2227
+ .into_response();
2228
+ }
2229
+ };
2230
+
2231
+ Response::builder()
2232
+ .status(StatusCode::OK)
2233
+ .header(CONTENT_TYPE, content_type)
2234
+ .header(CACHE_CONTROL, "no-store")
2235
+ .body(Body::from(bytes))
2236
+ .unwrap_or_else(|error| {
2237
+ (
2238
+ StatusCode::INTERNAL_SERVER_ERROR,
2239
+ Json(json!({
2240
+ "error": "response_failed",
2241
+ "message": format!("Failed to build image response: {error}")
2242
+ })),
2243
+ )
2244
+ .into_response()
2245
+ })
2246
+ }
2247
+
2045
2248
  async fn ws_handler(
2046
2249
  ws: WebSocketUpgrade,
2047
2250
  State(state): State<Arc<AppState>>,
@@ -2269,6 +2472,20 @@ async fn handle_bridge_method(
2269
2472
  "latestEventId": state.hub.latest_event_id(),
2270
2473
  }))
2271
2474
  }
2475
+ "bridge/workspaces/list" => {
2476
+ let request: WorkspaceListRequest =
2477
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
2478
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
2479
+ let result = list_workspace_roots(state, request).await?;
2480
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
2481
+ }
2482
+ "bridge/fs/list" => {
2483
+ let request: FileSystemListRequest =
2484
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
2485
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
2486
+ let result = list_filesystem_entries(state, request).await?;
2487
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
2488
+ }
2272
2489
  "bridge/terminal/exec" => {
2273
2490
  let request: TerminalExecRequest =
2274
2491
  serde_json::from_value(params.unwrap_or_else(|| json!({})))
@@ -2534,6 +2751,166 @@ async fn handle_bridge_method(
2534
2751
  }
2535
2752
  }
2536
2753
 
2754
+ async fn list_workspace_roots(
2755
+ state: &Arc<AppState>,
2756
+ request: WorkspaceListRequest,
2757
+ ) -> Result<WorkspaceListResponse, BridgeError> {
2758
+ let limit = request.limit.unwrap_or(200).clamp(1, 1000);
2759
+ let result = state
2760
+ .app_server
2761
+ .request_internal(
2762
+ "thread/list",
2763
+ Some(json!({
2764
+ "cursor": Value::Null,
2765
+ "limit": limit,
2766
+ "sortKey": Value::Null,
2767
+ "modelProviders": Value::Null,
2768
+ "sourceKinds": ["cli", "vscode", "exec", "appServer", "unknown"],
2769
+ "archived": false,
2770
+ "cwd": Value::Null,
2771
+ })),
2772
+ )
2773
+ .await
2774
+ .map_err(|error| BridgeError::server(&error))?;
2775
+
2776
+ let entries = result
2777
+ .get("data")
2778
+ .and_then(Value::as_array)
2779
+ .cloned()
2780
+ .unwrap_or_default();
2781
+
2782
+ let mut workspaces_by_path: HashMap<String, (usize, u64)> = HashMap::new();
2783
+
2784
+ for entry in entries {
2785
+ let Some(object) = entry.as_object() else {
2786
+ continue;
2787
+ };
2788
+
2789
+ let Some(raw_cwd) = read_string(object.get("cwd")) else {
2790
+ continue;
2791
+ };
2792
+
2793
+ let Some(canonical_path) =
2794
+ normalize_existing_directory(&state.config.workdir, raw_cwd.as_str()).await
2795
+ else {
2796
+ continue;
2797
+ };
2798
+
2799
+ let workspace_path = path_to_string(&canonical_path);
2800
+ let updated_at = parse_internal_id(object.get("updatedAt")).unwrap_or(0);
2801
+ let workspace_entry = workspaces_by_path
2802
+ .entry(workspace_path)
2803
+ .or_insert((0, updated_at));
2804
+ workspace_entry.0 += 1;
2805
+ workspace_entry.1 = workspace_entry.1.max(updated_at);
2806
+ }
2807
+
2808
+ let mut workspaces = workspaces_by_path
2809
+ .into_iter()
2810
+ .map(|(path, (chat_count, updated_at))| {
2811
+ (
2812
+ WorkspaceSummary {
2813
+ path,
2814
+ chat_count,
2815
+ updated_at: (updated_at > 0).then_some(updated_at),
2816
+ },
2817
+ updated_at,
2818
+ )
2819
+ })
2820
+ .collect::<Vec<_>>();
2821
+
2822
+ workspaces.sort_by(|(left, left_updated_at), (right, right_updated_at)| {
2823
+ right_updated_at
2824
+ .cmp(left_updated_at)
2825
+ .then_with(|| left.path.cmp(&right.path))
2826
+ });
2827
+
2828
+ Ok(WorkspaceListResponse {
2829
+ bridge_root: path_to_string(&state.config.workdir),
2830
+ allow_outside_root_cwd: state.config.allow_outside_root_cwd,
2831
+ workspaces: workspaces
2832
+ .into_iter()
2833
+ .map(|(workspace, _)| workspace)
2834
+ .collect(),
2835
+ })
2836
+ }
2837
+
2838
+ async fn list_filesystem_entries(
2839
+ state: &Arc<AppState>,
2840
+ request: FileSystemListRequest,
2841
+ ) -> Result<FileSystemListResponse, BridgeError> {
2842
+ let include_hidden = request.include_hidden.unwrap_or(false);
2843
+ let directories_only = request.directories_only.unwrap_or(true);
2844
+ let current_path =
2845
+ resolve_browsable_directory(&state.config.workdir, request.path.as_deref()).await?;
2846
+
2847
+ let mut read_dir = fs::read_dir(&current_path)
2848
+ .await
2849
+ .map_err(|error| BridgeError::server(&format!("failed to read directory: {error}")))?;
2850
+ let mut entries = Vec::new();
2851
+
2852
+ while let Some(entry) = read_dir
2853
+ .next_entry()
2854
+ .await
2855
+ .map_err(|error| BridgeError::server(&format!("failed to read directory entry: {error}")))?
2856
+ {
2857
+ let name = entry.file_name().to_string_lossy().to_string();
2858
+ if name.is_empty() {
2859
+ continue;
2860
+ }
2861
+
2862
+ let hidden = name.starts_with('.');
2863
+ if hidden && !include_hidden {
2864
+ continue;
2865
+ }
2866
+
2867
+ let entry_path = normalize_path(&entry.path());
2868
+ let metadata = match fs::metadata(&entry_path).await {
2869
+ Ok(metadata) => metadata,
2870
+ Err(_) => continue,
2871
+ };
2872
+
2873
+ let is_directory = metadata.is_dir();
2874
+ if directories_only && !is_directory {
2875
+ continue;
2876
+ }
2877
+
2878
+ let kind = if is_directory { "directory" } else { "file" }.to_string();
2879
+ let is_git_repo = if is_directory {
2880
+ fs::metadata(entry_path.join(".git")).await.is_ok()
2881
+ } else {
2882
+ false
2883
+ };
2884
+
2885
+ entries.push(FileSystemEntry {
2886
+ name,
2887
+ path: path_to_string(&entry_path),
2888
+ kind,
2889
+ hidden,
2890
+ selectable: is_directory,
2891
+ is_git_repo,
2892
+ });
2893
+ }
2894
+
2895
+ entries.sort_by(|left, right| {
2896
+ right.selectable.cmp(&left.selectable).then_with(|| {
2897
+ left.name
2898
+ .to_ascii_lowercase()
2899
+ .cmp(&right.name.to_ascii_lowercase())
2900
+ .then_with(|| left.name.cmp(&right.name))
2901
+ })
2902
+ });
2903
+
2904
+ let parent_path = current_path.parent().map(path_to_string);
2905
+
2906
+ Ok(FileSystemListResponse {
2907
+ bridge_root: path_to_string(&state.config.workdir),
2908
+ path: path_to_string(&current_path),
2909
+ parent_path,
2910
+ entries,
2911
+ })
2912
+ }
2913
+
2537
2914
  async fn transcribe_voice(request: VoiceTranscribeRequest) -> Result<Value, BridgeError> {
2538
2915
  let max_voice_transcription_bytes = resolve_max_voice_transcription_bytes();
2539
2916
  let estimated_size = estimate_base64_decoded_size(&request.data_base64)?;
@@ -2743,6 +3120,71 @@ fn resolve_bridge_workdir(raw_workdir: PathBuf) -> Result<PathBuf, String> {
2743
3120
  Ok(normalize_path(&canonical))
2744
3121
  }
2745
3122
 
3123
+ fn path_to_string(path: &Path) -> String {
3124
+ path.to_string_lossy().to_string()
3125
+ }
3126
+
3127
+ async fn normalize_existing_directory(base: &Path, raw_path: &str) -> Option<PathBuf> {
3128
+ let trimmed = raw_path.trim();
3129
+ if trimmed.is_empty() {
3130
+ return None;
3131
+ }
3132
+
3133
+ let candidate = PathBuf::from(trimmed);
3134
+ let resolved = if candidate.is_absolute() {
3135
+ candidate
3136
+ } else {
3137
+ base.join(candidate)
3138
+ };
3139
+
3140
+ let canonical = fs::canonicalize(&resolved).await.ok()?;
3141
+ let metadata = fs::metadata(&canonical).await.ok()?;
3142
+ if !metadata.is_dir() {
3143
+ return None;
3144
+ }
3145
+
3146
+ Some(normalize_path(&canonical))
3147
+ }
3148
+
3149
+ async fn resolve_browsable_directory(
3150
+ base: &Path,
3151
+ raw_path: Option<&str>,
3152
+ ) -> Result<PathBuf, BridgeError> {
3153
+ let trimmed = raw_path.map(str::trim).unwrap_or("");
3154
+ let candidate = if trimmed.is_empty() {
3155
+ base.to_path_buf()
3156
+ } else {
3157
+ let requested = PathBuf::from(trimmed);
3158
+ if requested.is_absolute() {
3159
+ requested
3160
+ } else {
3161
+ base.join(requested)
3162
+ }
3163
+ };
3164
+
3165
+ let canonical = fs::canonicalize(&candidate).await.map_err(|error| {
3166
+ BridgeError::invalid_params(&format!(
3167
+ "workspace directory is invalid or inaccessible ({}): {error}",
3168
+ candidate.to_string_lossy()
3169
+ ))
3170
+ })?;
3171
+
3172
+ let metadata = fs::metadata(&canonical).await.map_err(|error| {
3173
+ BridgeError::server(&format!(
3174
+ "failed to inspect workspace directory ({}): {error}",
3175
+ canonical.to_string_lossy()
3176
+ ))
3177
+ })?;
3178
+
3179
+ if !metadata.is_dir() {
3180
+ return Err(BridgeError::invalid_params(
3181
+ "workspace directory must point to a folder",
3182
+ ));
3183
+ }
3184
+
3185
+ Ok(normalize_path(&canonical))
3186
+ }
3187
+
2746
3188
  fn is_unspecified_bind_host(host: &str) -> bool {
2747
3189
  matches!(
2748
3190
  host.trim().to_ascii_lowercase().as_str(),
@@ -3453,6 +3895,33 @@ fn now_iso() -> String {
3453
3895
  Utc::now().to_rfc3339()
3454
3896
  }
3455
3897
 
3898
+ fn resolve_local_image_path(raw_path: &str) -> Result<PathBuf, &'static str> {
3899
+ let trimmed = raw_path.trim();
3900
+ if trimmed.is_empty() {
3901
+ return Err("Image path is required");
3902
+ }
3903
+
3904
+ let path = PathBuf::from(trimmed);
3905
+ if !path.is_absolute() {
3906
+ return Err("Image path must be absolute");
3907
+ }
3908
+
3909
+ Ok(normalize_path(&path))
3910
+ }
3911
+
3912
+ fn infer_image_content_type_from_path(path: &Path) -> Option<&'static str> {
3913
+ let extension = path.extension()?.to_str()?.trim().to_ascii_lowercase();
3914
+ match extension.as_str() {
3915
+ "png" => Some("image/png"),
3916
+ "jpg" | "jpeg" => Some("image/jpeg"),
3917
+ "gif" => Some("image/gif"),
3918
+ "webp" => Some("image/webp"),
3919
+ "heic" => Some("image/heic"),
3920
+ "heif" => Some("image/heif"),
3921
+ _ => None,
3922
+ }
3923
+ }
3924
+
3456
3925
  fn normalize_path(path: &Path) -> PathBuf {
3457
3926
  let mut normalized = PathBuf::new();
3458
3927
 
@@ -3839,18 +4308,29 @@ mod tests {
3839
4308
  }
3840
4309
 
3841
4310
  #[test]
3842
- fn rollout_event_msg_mapping_ignores_noise_events() {
3843
- assert!(build_rollout_event_msg_notification(
4311
+ fn rollout_event_msg_mapping_forwards_token_count_events() {
4312
+ let token_count = build_rollout_event_msg_notification(
3844
4313
  json!({
3845
4314
  "type": "token_count",
3846
- "info": {}
4315
+ "info": {
4316
+ "model_context_window": 200000
4317
+ }
3847
4318
  })
3848
4319
  .as_object()
3849
4320
  .expect("event payload object"),
3850
4321
  "thread-1",
3851
4322
  None,
3852
4323
  )
3853
- .is_none());
4324
+ .expect("token count notification");
4325
+
4326
+ assert_eq!(token_count.0, "codex/event/token_count");
4327
+ assert_eq!(token_count.1["msg"]["type"], "token_count");
4328
+ assert_eq!(token_count.1["msg"]["thread_id"], "thread-1");
4329
+ assert_eq!(token_count.1["msg"]["info"]["model_context_window"], 200000);
4330
+ }
4331
+
4332
+ #[test]
4333
+ fn rollout_event_msg_mapping_ignores_noise_events() {
3854
4334
  assert!(build_rollout_event_msg_notification(
3855
4335
  json!({
3856
4336
  "type": "user_message",
@@ -4286,6 +4766,34 @@ mod tests {
4286
4766
  );
4287
4767
  }
4288
4768
 
4769
+ #[test]
4770
+ fn resolve_local_image_path_requires_absolute_paths() {
4771
+ assert_eq!(
4772
+ resolve_local_image_path("/tmp/../tmp/example.png").unwrap(),
4773
+ PathBuf::from("/tmp/example.png")
4774
+ );
4775
+ assert_eq!(
4776
+ resolve_local_image_path("relative/example.png").unwrap_err(),
4777
+ "Image path must be absolute"
4778
+ );
4779
+ }
4780
+
4781
+ #[test]
4782
+ fn infer_image_content_type_from_path_supports_common_extensions() {
4783
+ assert_eq!(
4784
+ infer_image_content_type_from_path(Path::new("/tmp/example.png")),
4785
+ Some("image/png")
4786
+ );
4787
+ assert_eq!(
4788
+ infer_image_content_type_from_path(Path::new("/tmp/example.JPG")),
4789
+ Some("image/jpeg")
4790
+ );
4791
+ assert_eq!(
4792
+ infer_image_content_type_from_path(Path::new("/tmp/example.txt")),
4793
+ None
4794
+ );
4795
+ }
4796
+
4289
4797
  #[test]
4290
4798
  fn constant_time_eq_handles_equal_and_different_strings() {
4291
4799
  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>