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.
- package/.github/workflows/pages.yml +41 -0
- package/AGENTS.md +263 -110
- package/README.md +1 -1
- package/apps/mobile/.env.example +2 -2
- package/apps/mobile/App.tsx +175 -14
- package/apps/mobile/app.json +27 -9
- package/apps/mobile/eas.json +14 -4
- package/apps/mobile/package.json +13 -13
- package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
- package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
- package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
- package/apps/mobile/src/api/account.ts +47 -0
- package/apps/mobile/src/api/chatMapping.ts +435 -18
- package/apps/mobile/src/api/client.ts +296 -36
- package/apps/mobile/src/api/rateLimits.ts +143 -0
- package/apps/mobile/src/api/types.ts +106 -0
- package/apps/mobile/src/api/ws.ts +10 -1
- package/apps/mobile/src/components/ChatHeader.tsx +12 -12
- package/apps/mobile/src/components/ChatInput.tsx +154 -88
- package/apps/mobile/src/components/ChatMessage.tsx +548 -93
- package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
- package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
- package/apps/mobile/src/components/ToolBlock.tsx +17 -15
- package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
- package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
- package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
- package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
- package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
- package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
- package/apps/mobile/src/components/chat-input-layout.ts +59 -0
- package/apps/mobile/src/components/chatImageSource.ts +86 -0
- package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
- package/apps/mobile/src/components/voiceWaveform.ts +46 -0
- package/apps/mobile/src/config.ts +9 -2
- package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
- package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
- package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
- package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
- package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
- package/apps/mobile/src/navigation/drawerChats.ts +9 -0
- package/apps/mobile/src/screens/GitScreen.tsx +2 -0
- package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
- package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
- package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
- package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
- package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
- package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
- package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
- package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
- package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
- package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
- package/apps/mobile/src/screens/agentThreads.ts +167 -0
- package/apps/mobile/src/screens/planCardState.ts +40 -0
- package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
- package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
- package/apps/mobile/src/theme.ts +6 -12
- package/docs/codex-app-server-cli-gap-tracker.md +14 -5
- package/docs/privacy-policy.md +54 -0
- package/docs/setup-and-operations.md +4 -3
- package/docs/terms-of-service.md +33 -0
- package/package.json +3 -3
- package/services/mac-bridge/package.json +6 -6
- package/services/rust-bridge/Cargo.lock +56 -47
- package/services/rust-bridge/Cargo.toml +1 -1
- package/services/rust-bridge/package.json +1 -1
- package/services/rust-bridge/src/main.rs +507 -9
- package/site/index.html +54 -0
- package/site/privacy/index.html +80 -0
- package/site/styles.css +135 -0
- package/site/support/index.html +51 -0
- 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::{
|
|
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(¤t_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(¤t_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
|
|
3843
|
-
|
|
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
|
-
.
|
|
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"));
|
package/site/index.html
ADDED
|
@@ -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>
|