clawdex-mobile 5.1.3-internal.0 → 5.1.3-internal.1

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.
@@ -64,7 +64,9 @@ Important constraints:
64
64
  - GitHub resets public forwarded ports back to private whenever the codespace restarts
65
65
  - Keep bridge auth enabled and use Codespaces only for repos you trust, because public forwarded ports are internet-reachable
66
66
  - If the mobile app build sets `EXPO_PUBLIC_GITHUB_CLIENT_ID`, onboarding/settings can now sign in with GitHub, start the Codespace, and connect directly with the same OAuth token instead of copying `BRIDGE_AUTH_TOKEN`
67
+ - That same in-app GitHub sign-in now also bootstraps GitHub git auth inside the Codespace so `git clone`, `git push`, GitHub HTTPS remotes, and common `git@github.com:...` SSH-style remotes can reuse the app login without extra account setup
67
68
  - The same in-app GitHub flow can create a new Codespace. It prefers `<signed-in-user>/<EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME>`. If that repo does not exist yet, Clawdex automatically forks `EXPO_PUBLIC_GITHUB_CODESPACES_SOURCE_OWNER/<EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME>` into the signed-in user account and creates the Codespace from that fork
69
+ - Older saved GitHub Codespaces sessions may need one fresh sign-in from the app so the stored GitHub token includes repository access
68
70
 
69
71
  Manual recovery if port visibility does not update automatically:
70
72
 
@@ -89,6 +89,13 @@ ls -la .bridge.pid .bridge.log .env.secure
89
89
  npm run codespaces:bootstrap -- --no-start
90
90
  ```
91
91
 
92
+ ## Git push in a Codespace fails with `403` or permission denied
93
+
94
+ - The app now bootstraps GitHub HTTPS git credentials inside the Codespace after GitHub sign-in.
95
+ - If you signed in before this behavior shipped, reopen `GitHub Codespaces` in the app and sign in with GitHub once more so the saved token includes repository access.
96
+ - The bootstrap also rewrites common `git@github.com:...` and `ssh://git@github.com/...` remotes to HTTPS so they can use the same credential.
97
+ - After reconnecting, retry the clone/push from the app or from the Codespace shell.
98
+
92
99
  ## Voice transcription says no credentials were found
93
100
 
94
101
  - The bridge can transcribe with either `OPENAI_API_KEY`, `BRIDGE_CHATGPT_ACCESS_TOKEN`, or the same ChatGPT auth tokens already used for Codex login.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawdex-mobile",
3
- "version": "5.1.3-internal.0",
3
+ "version": "5.1.3-internal.1",
4
4
  "description": "Private-network mobile bridge and CLI for Codex and OpenCode",
5
5
  "keywords": [
6
6
  "codex",
@@ -149,7 +149,7 @@ dependencies = [
149
149
 
150
150
  [[package]]
151
151
  name = "codex-rust-bridge"
152
- version = "5.1.3-internal.0"
152
+ version = "5.1.3-internal.1"
153
153
  dependencies = [
154
154
  "axum",
155
155
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codex-rust-bridge"
3
- version = "5.1.3-internal.0"
3
+ version = "5.1.3-internal.1"
4
4
  edition = "2021"
5
5
 
6
6
  [dependencies]
@@ -1,3 +1,5 @@
1
+ #[cfg(unix)]
2
+ use std::os::unix::fs::PermissionsExt;
1
3
  use std::{
2
4
  collections::{HashMap, HashSet, VecDeque},
3
5
  env,
@@ -86,6 +88,10 @@ const BROWSER_PREVIEW_HTML_REWRITE_LIMIT_BYTES: usize = 4 * 1024 * 1024;
86
88
  const BROWSER_PREVIEW_DISCOVERY_HTTP_TIMEOUT: Duration = Duration::from_millis(500);
87
89
  const GITHUB_CODESPACES_AUTH_CACHE_TTL: Duration = Duration::from_secs(60 * 5);
88
90
  const GITHUB_CODESPACES_API_VERSION: &str = "2022-11-28";
91
+ const GITHUB_API_URL: &str = "https://api.github.com";
92
+ const GITHUB_HOST: &str = "github.com";
93
+ const GITHUB_CREDENTIALS_DIR_NAME: &str = ".clawdex";
94
+ const GITHUB_CREDENTIALS_FILE_NAME: &str = "github-credentials";
89
95
 
90
96
  #[derive(Debug, Clone)]
91
97
  struct GitHubCodespacesAuthConfig {
@@ -375,6 +381,218 @@ impl GitHubCodespacesAuthService {
375
381
  }
376
382
  }
377
383
 
384
+ #[derive(Debug, Clone)]
385
+ struct GitHubViewer {
386
+ login: String,
387
+ scopes: Vec<String>,
388
+ }
389
+
390
+ async fn install_github_git_auth(
391
+ state: &Arc<AppState>,
392
+ access_token: &str,
393
+ ) -> Result<GitHubAuthInstallResponse, BridgeError> {
394
+ let viewer = fetch_github_viewer(state, access_token).await?;
395
+ if !github_scopes_allow_repo_access(&viewer.scopes) {
396
+ return Err(BridgeError::forbidden(
397
+ "github_repo_scope_required",
398
+ "GitHub repository access is required. Sign in again from the app and approve repository access.",
399
+ ));
400
+ }
401
+
402
+ let credentials_file = resolve_github_credentials_file_path()?;
403
+ ensure_private_parent_dir(&credentials_file).await?;
404
+ write_github_credentials_file(&credentials_file, access_token).await?;
405
+ configure_git_credential_store(state, &credentials_file).await?;
406
+
407
+ Ok(GitHubAuthInstallResponse {
408
+ installed: true,
409
+ host: GITHUB_HOST.to_string(),
410
+ login: viewer.login,
411
+ scopes: viewer.scopes,
412
+ credential_file: credentials_file.to_string_lossy().to_string(),
413
+ })
414
+ }
415
+
416
+ async fn fetch_github_viewer(
417
+ state: &Arc<AppState>,
418
+ access_token: &str,
419
+ ) -> Result<GitHubViewer, BridgeError> {
420
+ let trimmed = access_token.trim();
421
+ if trimmed.is_empty() {
422
+ return Err(BridgeError::invalid_params("accessToken must not be empty"));
423
+ }
424
+
425
+ let api_url = state
426
+ .config
427
+ .github_codespaces_auth
428
+ .as_ref()
429
+ .map(|config| config.api_url.as_str())
430
+ .unwrap_or(GITHUB_API_URL);
431
+ let http = HttpClient::builder()
432
+ .user_agent("clawdex-rust-bridge")
433
+ .build()
434
+ .map_err(|error| {
435
+ BridgeError::server(&format!("failed to build GitHub auth client: {error}"))
436
+ })?;
437
+ let response = http
438
+ .get(format!("{api_url}/user"))
439
+ .header("accept", "application/vnd.github+json")
440
+ .header("x-github-api-version", GITHUB_CODESPACES_API_VERSION)
441
+ .bearer_auth(trimmed)
442
+ .send()
443
+ .await
444
+ .map_err(|error| BridgeError::server(&format!("GitHub auth check failed: {error}")))?;
445
+
446
+ if !response.status().is_success() {
447
+ let status = response.status();
448
+ let body = response.text().await.unwrap_or_default();
449
+ let message = if let Ok(value) = serde_json::from_str::<Value>(&body) {
450
+ read_string(value.get("message"))
451
+ .unwrap_or_else(|| format!("GitHub auth check failed ({status})"))
452
+ } else {
453
+ format!("GitHub auth check failed ({status})")
454
+ };
455
+ return Err(BridgeError::server(&message));
456
+ }
457
+
458
+ let scopes = parse_github_oauth_scopes(
459
+ response
460
+ .headers()
461
+ .get("x-oauth-scopes")
462
+ .and_then(|value| value.to_str().ok()),
463
+ );
464
+ let payload = response.json::<Value>().await.map_err(|error| {
465
+ BridgeError::server(&format!("failed to parse GitHub user response: {error}"))
466
+ })?;
467
+ let login = read_string(payload.get("login"))
468
+ .ok_or_else(|| BridgeError::server("GitHub auth check returned an invalid user payload"))?;
469
+
470
+ Ok(GitHubViewer { login, scopes })
471
+ }
472
+
473
+ fn parse_github_oauth_scopes(header: Option<&str>) -> Vec<String> {
474
+ header
475
+ .unwrap_or_default()
476
+ .split(',')
477
+ .map(|value| value.trim().to_lowercase())
478
+ .filter(|value| !value.is_empty())
479
+ .collect()
480
+ }
481
+
482
+ fn github_scopes_allow_repo_access(scopes: &[String]) -> bool {
483
+ scopes
484
+ .iter()
485
+ .any(|scope| scope == "repo" || scope == "public_repo")
486
+ }
487
+
488
+ fn resolve_github_credentials_file_path() -> Result<PathBuf, BridgeError> {
489
+ let home = read_non_empty_env("HOME")
490
+ .ok_or_else(|| BridgeError::server("HOME is not set; cannot install GitHub auth"))?;
491
+ Ok(PathBuf::from(home)
492
+ .join(GITHUB_CREDENTIALS_DIR_NAME)
493
+ .join(GITHUB_CREDENTIALS_FILE_NAME))
494
+ }
495
+
496
+ async fn ensure_private_parent_dir(path: &Path) -> Result<(), BridgeError> {
497
+ let Some(parent) = path.parent() else {
498
+ return Err(BridgeError::server(
499
+ "failed to resolve GitHub credential directory",
500
+ ));
501
+ };
502
+ fs::create_dir_all(parent).await.map_err(|error| {
503
+ BridgeError::server(&format!("failed to create GitHub auth directory: {error}"))
504
+ })?;
505
+ #[cfg(unix)]
506
+ {
507
+ fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
508
+ .await
509
+ .map_err(|error| {
510
+ BridgeError::server(&format!(
511
+ "failed to secure GitHub auth directory permissions: {error}"
512
+ ))
513
+ })?;
514
+ }
515
+ Ok(())
516
+ }
517
+
518
+ async fn write_github_credentials_file(
519
+ credentials_file: &Path,
520
+ access_token: &str,
521
+ ) -> Result<(), BridgeError> {
522
+ let content = format!(
523
+ "https://x-access-token:{}@{}\n",
524
+ access_token.trim(),
525
+ GITHUB_HOST
526
+ );
527
+ fs::write(credentials_file, content)
528
+ .await
529
+ .map_err(|error| {
530
+ BridgeError::server(&format!("failed to write GitHub credentials: {error}"))
531
+ })?;
532
+ #[cfg(unix)]
533
+ {
534
+ fs::set_permissions(credentials_file, std::fs::Permissions::from_mode(0o600))
535
+ .await
536
+ .map_err(|error| {
537
+ BridgeError::server(&format!(
538
+ "failed to secure GitHub credential permissions: {error}"
539
+ ))
540
+ })?;
541
+ }
542
+ Ok(())
543
+ }
544
+
545
+ async fn configure_git_credential_store(
546
+ state: &Arc<AppState>,
547
+ credentials_file: &Path,
548
+ ) -> Result<(), BridgeError> {
549
+ let helper_value = format!("store --file {}", credentials_file.to_string_lossy());
550
+ let commands = vec![
551
+ vec![
552
+ "config".to_string(),
553
+ "--global".to_string(),
554
+ "--replace-all".to_string(),
555
+ "credential.helper".to_string(),
556
+ helper_value,
557
+ ],
558
+ vec![
559
+ "config".to_string(),
560
+ "--global".to_string(),
561
+ "--replace-all".to_string(),
562
+ "url.https://github.com/.insteadOf".to_string(),
563
+ "git@github.com:".to_string(),
564
+ ],
565
+ vec![
566
+ "config".to_string(),
567
+ "--global".to_string(),
568
+ "--replace-all".to_string(),
569
+ "url.https://github.com/.insteadOf".to_string(),
570
+ "ssh://git@github.com/".to_string(),
571
+ ],
572
+ ];
573
+
574
+ for args in commands {
575
+ let result = state
576
+ .terminal
577
+ .execute_binary("git", &args, state.config.workdir.clone(), None)
578
+ .await?;
579
+
580
+ if result.code != Some(0) {
581
+ return Err(BridgeError::server(
582
+ &(if !result.stderr.is_empty() {
583
+ result.stderr
584
+ } else if !result.stdout.is_empty() {
585
+ result.stdout
586
+ } else {
587
+ "failed to configure git credentials".to_string()
588
+ }),
589
+ ));
590
+ }
591
+ }
592
+
593
+ Ok(())
594
+ }
595
+
378
596
  #[derive(Clone)]
379
597
  struct AppState {
380
598
  config: Arc<BridgeConfig>,
@@ -5200,6 +5418,22 @@ struct GitQueryRequest {
5200
5418
  cwd: Option<String>,
5201
5419
  }
5202
5420
 
5421
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5422
+ #[serde(rename_all = "camelCase")]
5423
+ struct GitHubAuthInstallRequest {
5424
+ access_token: String,
5425
+ }
5426
+
5427
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5428
+ #[serde(rename_all = "camelCase")]
5429
+ struct GitHubAuthInstallResponse {
5430
+ installed: bool,
5431
+ host: String,
5432
+ login: String,
5433
+ scopes: Vec<String>,
5434
+ credential_file: String,
5435
+ }
5436
+
5203
5437
  #[derive(Debug, Clone, Default, Serialize, Deserialize)]
5204
5438
  #[serde(rename_all = "camelCase")]
5205
5439
  struct GitHistoryRequest {
@@ -6556,6 +6790,13 @@ async fn handle_bridge_method(
6556
6790
 
6557
6791
  Ok(result_value)
6558
6792
  }
6793
+ "bridge/github/auth/install" => {
6794
+ let request: GitHubAuthInstallRequest =
6795
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
6796
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
6797
+ let result = install_github_git_auth(state, &request.access_token).await?;
6798
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
6799
+ }
6559
6800
  "bridge/attachments/upload" => {
6560
6801
  let request: AttachmentUploadRequest =
6561
6802
  serde_json::from_value(params.unwrap_or_else(|| json!({})))
@@ -14103,4 +14344,30 @@ mod tests {
14103
14344
 
14104
14345
  shutdown_test_backend(&state.backend).await;
14105
14346
  }
14347
+
14348
+ #[test]
14349
+ fn github_oauth_scope_header_parsing_is_trimmed_and_lowercased() {
14350
+ let scopes = parse_github_oauth_scopes(Some("codespace, repo, Read:User , public_repo"));
14351
+ assert_eq!(
14352
+ scopes,
14353
+ vec![
14354
+ "codespace".to_string(),
14355
+ "repo".to_string(),
14356
+ "read:user".to_string(),
14357
+ "public_repo".to_string()
14358
+ ]
14359
+ );
14360
+ }
14361
+
14362
+ #[test]
14363
+ fn github_repo_scope_check_accepts_repo_and_public_repo() {
14364
+ assert!(github_scopes_allow_repo_access(&["repo".to_string()]));
14365
+ assert!(github_scopes_allow_repo_access(
14366
+ &["public_repo".to_string()]
14367
+ ));
14368
+ assert!(!github_scopes_allow_repo_access(&[
14369
+ "codespace".to_string(),
14370
+ "read:user".to_string()
14371
+ ]));
14372
+ }
14106
14373
  }