Git Clone Authentication for Private Repositories
- Uses 'git -c http.extraHeader=Authorization: Basic <base64>' for authentication - Token is encoded as 'base64("x-access-token:TOKEN")' following GitHub's Basic Auth format - Token is never stored in URL, git config, or project metadata - 'GIT_TERMINAL_PROMPT=0' prevents interactive credential prompts - Remote URL displayed in UI always has auth info stripped for security
keyboardstaff committed
Feb 3, 2026 at 06:10 UTC
e2824cd6e904ac86fcc7f57e21ee6468d8f44f02
5 files changed
+64
-14
python/api/projects.py
+2
-1
@@ -57,6 +57,7 @@ class Projects(ApiHandler):
57
if project is None:
58
raise Exception("Project data is required")
59
git_url = project.get("git_url", "")
60
+ git_token = project.get("git_token", "")
61
if not git_url:
62
raise Exception("Git URL is required")
63
@@ -72,7 +73,7 @@ class Projects(ApiHandler):
73
74
try:
75
data = projects.BasicProjectData(**project)
75
- name = projects.clone_git_project(project["name"], git_url, data)
76
+ name = projects.clone_git_project(project["name"], git_url, git_token, data)
77
78
# Success notification
79
NotificationManager.send_notification(
python/helpers/git.py
+41
-9
@@ -1,8 +1,25 @@
1
from git import Repo
2
from datetime import datetime
3
import os
4
+import subprocess
5
+import base64
6
+from urllib.parse import urlparse, urlunparse
7
from python.helpers import files
8
9
+
10
+def strip_auth_from_url(url: str) -> str:
11
+ """Remove any authentication info from URL."""
12
+ if not url:
13
+ return url
14
+ parsed = urlparse(url)
15
+ if not parsed.hostname:
16
+ return url
17
+ clean_netloc = parsed.hostname
18
+ if parsed.port:
19
+ clean_netloc += f":{parsed.port}"
20
+ return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', ''))
21
+
22
+
23
def get_git_info():
24
# Get the current working directory (assuming the repo is in the same folder as the script)
25
repo_path = files.get_base_dir()
@@ -57,13 +74,28 @@ def get_version():
74
return "unknown"
75
76
60
-def clone_repo(url: str, dest: str, progress_callback=None):
61
- """Clone a git repository to destination."""
62
- class Progress:
63
- def __call__(self, op_code, cur_count, max_count=None, message=''):
64
- if progress_callback and max_count:
65
- progress_callback(cur_count, max_count, message)
66
- return Repo.clone_from(url, dest, progress=Progress() if progress_callback else None)
77
+def clone_repo(url: str, dest: str, token: str | None = None):
78
+ """Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config)."""
79
+ cmd = ['git']
80
+
81
+ if token:
82
+ # GitHub Git HTTP requires Basic Auth, not Bearer
83
+ auth_string = f"x-access-token:{token}"
84
+ auth_base64 = base64.b64encode(auth_string.encode()).decode()
85
+ cmd.extend(['-c', f'http.extraHeader=Authorization: Basic {auth_base64}'])
86
+
87
+ cmd.extend(['clone', '--progress', '--', url, dest])
88
+
89
+ env = os.environ.copy()
90
+ env['GIT_TERMINAL_PROMPT'] = '0'
91
+
92
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env)
93
+
94
+ if result.returncode != 0:
95
+ error_msg = result.stderr.strip() or result.stdout.strip() or 'Unknown error'
96
+ raise Exception(f"Git clone failed: {error_msg}")
97
+
98
+ return Repo(dest)
99
100
101
# Files to ignore when checking dirty status (A0 project metadata)
@@ -77,11 +109,11 @@ def get_repo_status(repo_path: str) -> dict:
109
if repo.bare:
110
return {"is_git_repo": False, "error": "Repository is bare"}
111
80
- # Remote URL
112
+ # Remote URL (always strip auth info for security)
113
remote_url = ""
114
try:
115
if repo.remotes:
84
- remote_url = repo.remotes.origin.url
116
+ remote_url = strip_auth_from_url(repo.remotes.origin.url)
117
except Exception:
118
pass
119
python/helpers/projects.py
+8
-4
@@ -88,8 +88,8 @@ def create_project(name: str, data: BasicProjectData):
88
return name
89
90
91
-def clone_git_project(name: str, git_url: str, data: BasicProjectData):
92
- """Clone a git repository as a new A0 project."""
91
+def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData):
92
+ """Clone a git repository as a new A0 project. Token is used only for cloning via http header."""
93
from python.helpers import git
94
95
abs_path = files.create_dir_safe(
@@ -98,10 +98,14 @@ def clone_git_project(name: str, git_url: str, data: BasicProjectData):
98
actual_name = files.basename(abs_path)
99
100
try:
101
- git.clone_repo(git_url, abs_path)
101
+ # Clone with token via http.extraHeader (token never in URL or git config)
102
+ git.clone_repo(git_url, abs_path, token=git_token)
103
+
104
+ # Store clean URL only (in case user provided URL with auth)
105
+ clean_url = git.strip_auth_from_url(git_url)
106
create_project_meta_folders(actual_name)
107
data = _normalizeBasicData(data)
104
- data["git_url"] = git_url
108
+ data["git_url"] = clean_url
109
save_project_header(actual_name, data)
110
return actual_name
111
except Exception as e:
webui/components/projects/project-create.html
+11
@@ -25,6 +25,17 @@
25
placeholder="https://github.com/user/repo.git">
26
</div>
27
28
+ <template x-if="$store.projects.selectedProject.git_url && $store.projects.selectedProject.git_url.trim()">
29
+ <div class="projects-form-group">
30
+ <label class="projects-form-label">Access Token (optional)</label>
31
+ <span class="projects-form-description">For private repositories. Token is used only for cloning and will not be stored.</span>
32
+ <input class="projects-form-input" type="password"
33
+ x-model="$store.projects.selectedProject.git_token"
34
+ :disabled="$store.projects.selectedProject._cloning"
35
+ placeholder="ghp_xxxx or glpat-xxxx">
36
+ </div>
37
+ </template>
38
+
39
<div class="buttons-right">
40
<button type="button" class="button cancel"
41
@click="$store.projects.cancelCreate()"
webui/components/projects/projects-store.js
+2
@@ -139,6 +139,7 @@ const model = {
139
title: project.title,
140
color: project.color,
141
git_url: project.git_url,
142
+ git_token: project.git_token || "",
143
},
144
});
145
@@ -386,6 +387,7 @@ const model = {
387
description: "",
388
color: "",
389
git_url: "",
390
+ git_token: "",
391
};
392
},
393