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