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.
- package/docs/setup-and-operations.md +2 -0
- package/docs/troubleshooting.md +7 -0
- package/package.json +1 -1
- package/services/rust-bridge/Cargo.lock +1 -1
- package/services/rust-bridge/Cargo.toml +1 -1
- package/services/rust-bridge/src/main.rs +267 -0
- package/vendor/bridge-binaries/darwin-arm64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/darwin-x64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-arm64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-armv7l/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/linux-x64/codex-rust-bridge +0 -0
- package/vendor/bridge-binaries/win32-x64/codex-rust-bridge.exe +0 -0
|
@@ -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
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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,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
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|