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.
- package/.github/workflows/ci.yml +4 -3
- package/.github/workflows/npm-release.yml +62 -2
- package/.github/workflows/pages.yml +41 -0
- package/AGENTS.md +263 -110
- package/README.md +15 -4
- 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 +14 -13
- package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
- package/apps/mobile/src/api/__tests__/client.test.ts +587 -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 +321 -36
- package/apps/mobile/src/api/rateLimits.ts +143 -0
- package/apps/mobile/src/api/types.ts +107 -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 +812 -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 +4239 -1237
- package/apps/mobile/src/screens/OnboardingScreen.tsx +924 -310
- 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/bin/clawdex.js +7 -6
- package/codex-rust-bridge +0 -0
- package/codex-rust-bridge.exe +0 -0
- package/docs/codex-app-server-cli-gap-tracker.md +14 -5
- package/docs/privacy-policy.md +54 -0
- package/docs/setup-and-operations.md +21 -15
- package/docs/terms-of-service.md +33 -0
- package/docs/troubleshooting.md +15 -19
- package/package.json +6 -5
- package/scripts/bridge-binary.js +194 -0
- package/scripts/setup-wizard.sh +17 -186
- package/scripts/start-bridge-secure.js +240 -0
- package/scripts/start-bridge-secure.sh +1 -40
- 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 +517 -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,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(¤t_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(¤t_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
|
|
3843
|
-
|
|
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
|
-
.
|
|
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"));
|
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>
|