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

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/README.md CHANGED
@@ -70,7 +70,7 @@ Notes:
70
70
  - Browser preview uses a second forwarded port (`8788` by default), so both ports need public visibility.
71
71
  - GitHub resets public forwarded ports back to private when a codespace restarts. Restarting the bridge reruns the visibility step.
72
72
  - If automatic visibility setup fails, run `gh codespace ports visibility 8787:public 8788:public`.
73
- - If the mobile app is built with `EXPO_PUBLIC_GITHUB_CLIENT_ID`, users can now tap `Use GitHub Codespaces` in onboarding/settings, sign in with GitHub, pick a Codespace, and connect without manually copying the bridge token.
73
+ - If the mobile app is built with `EXPO_PUBLIC_GITHUB_APP_CLIENT_ID` and `EXPO_PUBLIC_GITHUB_APP_SLUG`, users can now tap `Use GitHub Codespaces` in onboarding/settings, sign in with GitHub, approve the Claudex GitHub App for only the repositories they want, pick a Codespace, and connect without manually copying the bridge token.
74
74
  - The app can also create a new repo-backed Codespace directly. It prefers `<signed-in-user>/<EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME>` first. If that repo does not exist, it automatically forks `EXPO_PUBLIC_GITHUB_CODESPACES_SOURCE_OWNER/<EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME>` into the signed-in user account, then creates the Codespace there.
75
75
 
76
76
  This repo now also includes a Codespaces bootstrap flow. On Codespace start/resume, `.devcontainer/devcontainer.json` runs:
@@ -89,7 +89,7 @@ That pre-installs Codex and prebuilds the Rust bridge binary so the later startu
89
89
 
90
90
  The published npm package now includes that bootstrap script too, so a minimal Codespaces template repo can install `clawdex-mobile@latest` in `postCreateCommand` and call the packaged bootstrap without vendoring bridge source into the template itself.
91
91
 
92
- In Codespaces mode, the bootstrap also enables bridge-side GitHub bearer auth for the current `CODESPACE_NAME`, so the mobile app can authenticate with the same GitHub OAuth token it used to discover and start the Codespace.
92
+ In Codespaces mode, the bootstrap also enables bridge-side GitHub bearer auth for the current `CODESPACE_NAME`, so the mobile app can authenticate with the same GitHub App user token it used to discover and start the Codespace.
93
93
 
94
94
  ## OpenCode Setup
95
95
 
@@ -63,8 +63,10 @@ Important constraints:
63
63
  - Browser preview uses the preview port (`8788` by default), so that forwarded port must also be public
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
- - 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`
66
+ - If the mobile app build sets `EXPO_PUBLIC_GITHUB_APP_CLIENT_ID` and `EXPO_PUBLIC_GITHUB_APP_SLUG`, onboarding/settings can now sign in with GitHub, approve the Claudex GitHub App for only the repositories they want, start the Codespace, and connect directly with the same GitHub App user token instead of copying `BRIDGE_AUTH_TOKEN`
67
+ - That same in-app GitHub sign-in 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 App token and refresh token are updated
68
70
 
69
71
  Manual recovery if port visibility does not update automatically:
70
72
 
@@ -269,7 +271,8 @@ npm run teardown -- --yes
269
271
  | Variable | Purpose |
270
272
  |---|---|
271
273
  | `EXPO_PUBLIC_HOST_BRIDGE_TOKEN` | token used by local mobile dev builds |
272
- | `EXPO_PUBLIC_GITHUB_CLIENT_ID` | GitHub OAuth app client ID for in-app Codespaces sign-in |
274
+ | `EXPO_PUBLIC_GITHUB_APP_CLIENT_ID` | GitHub App client ID for in-app Codespaces sign-in |
275
+ | `EXPO_PUBLIC_GITHUB_APP_SLUG` | GitHub App slug used to open install/manage-access pages for repository selection |
273
276
  | `EXPO_PUBLIC_GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN` | forwarded port domain used to derive Codespaces bridge URLs (`app.github.dev` by default) |
274
277
  | `EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME` | repository name to sort matching Codespaces first in the in-app picker |
275
278
  | `EXPO_PUBLIC_GITHUB_CODESPACES_SOURCE_OWNER` | template/source repository owner used for automatic forking when the signed-in user does not have a same-name repo |
@@ -34,7 +34,7 @@ npm run stop:services
34
34
  ## Bridge auth errors (`401`, invalid token)
35
35
 
36
36
  - For the shipped mobile app, rescan the bridge QR or update the stored token in Settings.
37
- - For GitHub-auth Codespaces profiles, reopen `GitHub Codespaces` in the app and sign in with GitHub again if the OAuth token was revoked or expired.
37
+ - For GitHub-auth Codespaces profiles, reopen `GitHub Codespaces` in the app and sign in with GitHub again if the GitHub App token or refresh token was revoked or expired.
38
38
  - For a local dev build, also ensure `BRIDGE_AUTH_TOKEN` in `.env.secure` matches `EXPO_PUBLIC_HOST_BRIDGE_TOKEN` in `apps/mobile/.env`.
39
39
  - Restart the bridge after token changes.
40
40
  - On secure-launcher installs, `Settings > Bridge Maintenance > Restart bridge safely` can do that from the phone.
@@ -53,7 +53,8 @@ gh codespace ports visibility 8787:public 8788:public
53
53
 
54
54
  - If `gh` is unavailable in the codespace, use the Codespaces `Ports` panel and change both forwarded ports to `Public`.
55
55
  - Keep bridge auth enabled. Public forwarded ports without bridge auth are not a safe setup.
56
- - If GitHub direct sign-in is not showing in the app, confirm the build includes `EXPO_PUBLIC_GITHUB_CLIENT_ID`.
56
+ - If GitHub direct sign-in is not showing in the app, confirm the build includes `EXPO_PUBLIC_GITHUB_APP_CLIENT_ID` and `EXPO_PUBLIC_GITHUB_APP_SLUG`.
57
+ - If the app signs in but still cannot create a Codespace or clone/push inside it, reopen the GitHub App access step in the app and make sure the template repo and any target repos are selected for the installation.
57
58
  - If in-app Codespace creation forks or targets the wrong repo, check `EXPO_PUBLIC_GITHUB_CODESPACES_REPO_NAME`, `EXPO_PUBLIC_GITHUB_CODESPACES_SOURCE_OWNER`, and `EXPO_PUBLIC_GITHUB_CODESPACES_REPO_REF` in the mobile build env.
58
59
 
59
60
  ## GitHub Codespaces bootstrap did not start the bridge
@@ -89,6 +90,13 @@ ls -la .bridge.pid .bridge.log .env.secure
89
90
  npm run codespaces:bootstrap -- --no-start
90
91
  ```
91
92
 
93
+ ## Git push in a Codespace fails with `403` or permission denied
94
+
95
+ - The app now bootstraps GitHub HTTPS git credentials inside the Codespace after GitHub sign-in.
96
+ - 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.
97
+ - The bootstrap also rewrites common `git@github.com:...` and `ssh://git@github.com/...` remotes to HTTPS so they can use the same credential.
98
+ - After reconnecting, retry the clone/push from the app or from the Codespace shell.
99
+
92
100
  ## Voice transcription says no credentials were found
93
101
 
94
102
  - 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.2",
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.2"
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.2"
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,222 @@ 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_token_can_be_used_for_git_auth(&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 the required 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 github_token_can_be_used_for_git_auth(scopes: &[String]) -> bool {
489
+ scopes.is_empty() || github_scopes_allow_repo_access(scopes)
490
+ }
491
+
492
+ fn resolve_github_credentials_file_path() -> Result<PathBuf, BridgeError> {
493
+ let home = read_non_empty_env("HOME")
494
+ .ok_or_else(|| BridgeError::server("HOME is not set; cannot install GitHub auth"))?;
495
+ Ok(PathBuf::from(home)
496
+ .join(GITHUB_CREDENTIALS_DIR_NAME)
497
+ .join(GITHUB_CREDENTIALS_FILE_NAME))
498
+ }
499
+
500
+ async fn ensure_private_parent_dir(path: &Path) -> Result<(), BridgeError> {
501
+ let Some(parent) = path.parent() else {
502
+ return Err(BridgeError::server(
503
+ "failed to resolve GitHub credential directory",
504
+ ));
505
+ };
506
+ fs::create_dir_all(parent).await.map_err(|error| {
507
+ BridgeError::server(&format!("failed to create GitHub auth directory: {error}"))
508
+ })?;
509
+ #[cfg(unix)]
510
+ {
511
+ fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
512
+ .await
513
+ .map_err(|error| {
514
+ BridgeError::server(&format!(
515
+ "failed to secure GitHub auth directory permissions: {error}"
516
+ ))
517
+ })?;
518
+ }
519
+ Ok(())
520
+ }
521
+
522
+ async fn write_github_credentials_file(
523
+ credentials_file: &Path,
524
+ access_token: &str,
525
+ ) -> Result<(), BridgeError> {
526
+ let content = format!(
527
+ "https://x-access-token:{}@{}\n",
528
+ access_token.trim(),
529
+ GITHUB_HOST
530
+ );
531
+ fs::write(credentials_file, content)
532
+ .await
533
+ .map_err(|error| {
534
+ BridgeError::server(&format!("failed to write GitHub credentials: {error}"))
535
+ })?;
536
+ #[cfg(unix)]
537
+ {
538
+ fs::set_permissions(credentials_file, std::fs::Permissions::from_mode(0o600))
539
+ .await
540
+ .map_err(|error| {
541
+ BridgeError::server(&format!(
542
+ "failed to secure GitHub credential permissions: {error}"
543
+ ))
544
+ })?;
545
+ }
546
+ Ok(())
547
+ }
548
+
549
+ async fn configure_git_credential_store(
550
+ state: &Arc<AppState>,
551
+ credentials_file: &Path,
552
+ ) -> Result<(), BridgeError> {
553
+ let helper_value = format!("store --file {}", credentials_file.to_string_lossy());
554
+ let commands = vec![
555
+ vec![
556
+ "config".to_string(),
557
+ "--global".to_string(),
558
+ "--replace-all".to_string(),
559
+ "credential.helper".to_string(),
560
+ helper_value,
561
+ ],
562
+ vec![
563
+ "config".to_string(),
564
+ "--global".to_string(),
565
+ "--replace-all".to_string(),
566
+ "url.https://github.com/.insteadOf".to_string(),
567
+ "git@github.com:".to_string(),
568
+ ],
569
+ vec![
570
+ "config".to_string(),
571
+ "--global".to_string(),
572
+ "--replace-all".to_string(),
573
+ "url.https://github.com/.insteadOf".to_string(),
574
+ "ssh://git@github.com/".to_string(),
575
+ ],
576
+ ];
577
+
578
+ for args in commands {
579
+ let result = state
580
+ .terminal
581
+ .execute_binary("git", &args, state.config.workdir.clone(), None)
582
+ .await?;
583
+
584
+ if result.code != Some(0) {
585
+ return Err(BridgeError::server(
586
+ &(if !result.stderr.is_empty() {
587
+ result.stderr
588
+ } else if !result.stdout.is_empty() {
589
+ result.stdout
590
+ } else {
591
+ "failed to configure git credentials".to_string()
592
+ }),
593
+ ));
594
+ }
595
+ }
596
+
597
+ Ok(())
598
+ }
599
+
378
600
  #[derive(Clone)]
379
601
  struct AppState {
380
602
  config: Arc<BridgeConfig>,
@@ -5200,6 +5422,22 @@ struct GitQueryRequest {
5200
5422
  cwd: Option<String>,
5201
5423
  }
5202
5424
 
5425
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5426
+ #[serde(rename_all = "camelCase")]
5427
+ struct GitHubAuthInstallRequest {
5428
+ access_token: String,
5429
+ }
5430
+
5431
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5432
+ #[serde(rename_all = "camelCase")]
5433
+ struct GitHubAuthInstallResponse {
5434
+ installed: bool,
5435
+ host: String,
5436
+ login: String,
5437
+ scopes: Vec<String>,
5438
+ credential_file: String,
5439
+ }
5440
+
5203
5441
  #[derive(Debug, Clone, Default, Serialize, Deserialize)]
5204
5442
  #[serde(rename_all = "camelCase")]
5205
5443
  struct GitHistoryRequest {
@@ -6556,6 +6794,13 @@ async fn handle_bridge_method(
6556
6794
 
6557
6795
  Ok(result_value)
6558
6796
  }
6797
+ "bridge/github/auth/install" => {
6798
+ let request: GitHubAuthInstallRequest =
6799
+ serde_json::from_value(params.unwrap_or_else(|| json!({})))
6800
+ .map_err(|error| BridgeError::invalid_params(&error.to_string()))?;
6801
+ let result = install_github_git_auth(state, &request.access_token).await?;
6802
+ serde_json::to_value(result).map_err(|error| BridgeError::server(&error.to_string()))
6803
+ }
6559
6804
  "bridge/attachments/upload" => {
6560
6805
  let request: AttachmentUploadRequest =
6561
6806
  serde_json::from_value(params.unwrap_or_else(|| json!({})))
@@ -7085,7 +7330,27 @@ fn bridge_chatgpt_auth_cache() -> &'static StdRwLock<Option<BridgeChatGptAuthBun
7085
7330
  CACHE.get_or_init(|| StdRwLock::new(None))
7086
7331
  }
7087
7332
 
7333
+ #[cfg(test)]
7334
+ fn bridge_chatgpt_auth_cache_path_override() -> &'static StdRwLock<Option<PathBuf>> {
7335
+ static OVERRIDE: OnceLock<StdRwLock<Option<PathBuf>>> = OnceLock::new();
7336
+ OVERRIDE.get_or_init(|| StdRwLock::new(None))
7337
+ }
7338
+
7339
+ #[cfg(test)]
7340
+ fn set_bridge_chatgpt_auth_cache_path_override(path: Option<PathBuf>) {
7341
+ if let Ok(mut guard) = bridge_chatgpt_auth_cache_path_override().write() {
7342
+ *guard = path;
7343
+ }
7344
+ }
7345
+
7088
7346
  fn resolve_bridge_chatgpt_auth_cache_path() -> Option<PathBuf> {
7347
+ #[cfg(test)]
7348
+ if let Ok(guard) = bridge_chatgpt_auth_cache_path_override().read() {
7349
+ if let Some(path) = guard.clone() {
7350
+ return Some(path);
7351
+ }
7352
+ }
7353
+
7089
7354
  let workdir = read_non_empty_env("BRIDGE_WORKDIR").map(PathBuf::from)?;
7090
7355
  Some(workdir.join(BRIDGE_CHATGPT_AUTH_CACHE_FILE_NAME))
7091
7356
  }
@@ -11618,6 +11883,51 @@ fn normalize_path(path: &Path) -> PathBuf {
11618
11883
  mod tests {
11619
11884
  use super::*;
11620
11885
 
11886
+ fn bridge_chatgpt_auth_test_lock() -> &'static std::sync::Mutex<()> {
11887
+ static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
11888
+ LOCK.get_or_init(|| std::sync::Mutex::new(()))
11889
+ }
11890
+
11891
+ struct TestBridgeChatGptAuthCacheScope {
11892
+ _guard: std::sync::MutexGuard<'static, ()>,
11893
+ temp_dir: PathBuf,
11894
+ }
11895
+
11896
+ impl TestBridgeChatGptAuthCacheScope {
11897
+ fn new() -> Self {
11898
+ let guard = bridge_chatgpt_auth_test_lock()
11899
+ .lock()
11900
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
11901
+ clear_cached_bridge_chatgpt_auth();
11902
+
11903
+ let nonce = SystemTime::now()
11904
+ .duration_since(SystemTime::UNIX_EPOCH)
11905
+ .expect("valid time")
11906
+ .as_nanos();
11907
+ let temp_dir = env::temp_dir().join(format!(
11908
+ "clawdex-bridge-chatgpt-auth-test-{}-{nonce}",
11909
+ std::process::id()
11910
+ ));
11911
+ std::fs::create_dir_all(&temp_dir).expect("create auth cache test dir");
11912
+ set_bridge_chatgpt_auth_cache_path_override(Some(
11913
+ temp_dir.join(BRIDGE_CHATGPT_AUTH_CACHE_FILE_NAME),
11914
+ ));
11915
+
11916
+ Self {
11917
+ _guard: guard,
11918
+ temp_dir,
11919
+ }
11920
+ }
11921
+ }
11922
+
11923
+ impl Drop for TestBridgeChatGptAuthCacheScope {
11924
+ fn drop(&mut self) {
11925
+ clear_cached_bridge_chatgpt_auth();
11926
+ set_bridge_chatgpt_auth_cache_path_override(None);
11927
+ let _ = std::fs::remove_dir_all(&self.temp_dir);
11928
+ }
11929
+ }
11930
+
11621
11931
  async fn build_test_bridge(hub: Arc<ClientHub>) -> Arc<AppServerBridge> {
11622
11932
  let mut child = Command::new("cat")
11623
11933
  .stdin(Stdio::piped())
@@ -13618,6 +13928,7 @@ mod tests {
13618
13928
 
13619
13929
  #[tokio::test]
13620
13930
  async fn successful_chatgpt_auth_token_login_populates_bridge_auth_cache() {
13931
+ let _auth_cache_scope = TestBridgeChatGptAuthCacheScope::new();
13621
13932
  clear_cached_bridge_chatgpt_auth();
13622
13933
 
13623
13934
  let hub = Arc::new(ClientHub::new());
@@ -13665,6 +13976,7 @@ mod tests {
13665
13976
 
13666
13977
  #[tokio::test]
13667
13978
  async fn successful_account_logout_clears_cached_bridge_chatgpt_auth() {
13979
+ let _auth_cache_scope = TestBridgeChatGptAuthCacheScope::new();
13668
13980
  clear_cached_bridge_chatgpt_auth();
13669
13981
  cache_bridge_chatgpt_auth(BridgeChatGptAuthBundle {
13670
13982
  access_token: "cached-before-logout".to_string(),
@@ -14103,4 +14415,40 @@ mod tests {
14103
14415
 
14104
14416
  shutdown_test_backend(&state.backend).await;
14105
14417
  }
14418
+
14419
+ #[test]
14420
+ fn github_oauth_scope_header_parsing_is_trimmed_and_lowercased() {
14421
+ let scopes = parse_github_oauth_scopes(Some("codespace, repo, Read:User , public_repo"));
14422
+ assert_eq!(
14423
+ scopes,
14424
+ vec![
14425
+ "codespace".to_string(),
14426
+ "repo".to_string(),
14427
+ "read:user".to_string(),
14428
+ "public_repo".to_string()
14429
+ ]
14430
+ );
14431
+ }
14432
+
14433
+ #[test]
14434
+ fn github_repo_scope_check_accepts_repo_and_public_repo() {
14435
+ assert!(github_scopes_allow_repo_access(&["repo".to_string()]));
14436
+ assert!(github_scopes_allow_repo_access(
14437
+ &["public_repo".to_string()]
14438
+ ));
14439
+ assert!(!github_scopes_allow_repo_access(&[
14440
+ "codespace".to_string(),
14441
+ "read:user".to_string()
14442
+ ]));
14443
+ }
14444
+
14445
+ #[test]
14446
+ fn github_git_auth_accepts_github_app_user_tokens_without_scope_headers() {
14447
+ assert!(github_token_can_be_used_for_git_auth(&[]));
14448
+ assert!(github_token_can_be_used_for_git_auth(&["repo".to_string()]));
14449
+ assert!(!github_token_can_be_used_for_git_auth(&[
14450
+ "codespace".to_string(),
14451
+ "read:user".to_string()
14452
+ ]));
14453
+ }
14106
14454
  }