Files
codex-app-server/cloud/repos.py
T
host bed3fc0dff Project repo_url: worker can run tasks from any git repo
Projects gain repo_url (validated: https:// or git@ only, no
userinfo/password, no file:// or local paths; old DBs get the
column via idempotent ALTER in both SQLite and Postgres init).
Worker resolves a project's repo_url to a local checkout under
--repos-dir (default ./repos) before dispatch: clone if absent,
verify remote origin matches and fetch if present, never force
reset/clean, never push (push needs write credentials the
control plane does not store). Resolve failure fails the task
with a clear error, never completed. Docker workers need
repos_dir under a same-path extra mount (e.g.
/var/lib/codex-app-server/repos).
Also: driver-missing test is now mocked instead of depending
on the host really having no psycopg driver installed.
Deploy package: Caddyfile template exposing only /webhooks/*
and /oauth/* (tasks/projects/admin stay 403) plus a
webhook-secret generator script that never prints the secret.
Tests: host 27 pass, cloud 286 pass (8 skipped are
environment-dependent real-Postgres tests).
2026-10-26 04:16:11 +08:00

151 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""项目级仓库解析:把项目的 repo_url 变成 worker 可派发的本地检出。
零第三方依赖,只用 Python 标准库;不改 host.py 的 worktree 逻辑,
不存任何仓库凭证、不推远端。
- validate_repo_url:API(POST /projects)与 worker 共用的同一套
校验,绝不另写第二份校验逻辑让两者漂移。只接受:
https:// 开头(且不含 userinfo,即不含用户名/密码),或
git@ 开头(git@host:path 形)。file://、ssh://、本地路径、
带密码的 URL 一律拒绝。
- checkout_dir:按 project_id 的安全字符命名,防路径穿越。
- resolve_repo:目录不存在时 git clone;已存在时校验它是 git
仓库且 remote origin 与 repo_url 一致(不一致时报错,绝不强制
改 remote),一致时 git fetch origin(失败明确报错,不静默用
旧代码);fetch 成功后不自动 checkout/pull、不 git reset/clean,
工作树脏不脏由 host.py 的 worktree 逻辑处理。
- 私有仓库 clone 失败时错误文本写明:需要仓库访问凭证,控制面
不存仓库凭证——绝不尝试用任何 key 去 clone。
"""
import os
import re
import subprocess
from urllib.parse import urlparse
__all__ = [
"RepoError",
"validate_repo_url",
"checkout_dir",
"resolve_repo",
]
class RepoError(ValueError):
"""仓库解析失败(校验、clone、remote 不一致、fetch 失败)。"""
_GIT_SCP_RE = re.compile(r"^git@[^/:@\s]+:[^@\s]+$")
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]")
def validate_repo_url(repo_url):
"""校验 repo_url,合法时返回原值,否则抛 ValueError。
拒绝:非字符串、空串、含空白/控制字符、非 https:// 与 git@ 开头、
https:// 带 userinfo(username 或 password 任一存在即拒绝,
含 https://user@host/ 这种只有用户名无密码的也拒绝,因为
userinfo 本身就是凭证载体)、https:// 无 host、git@ 形不合规。
"""
if not isinstance(repo_url, str):
raise ValueError("repo_url 必须是非空字符串")
# 含空白/控制字符的 URL 直接拒绝,不静默 strip:strip 会把
# “https://…\n”这种畸形输入洗成合法 URL 存进库,git 调用时
# 再出难以定位的问题;调用方应传干净的 URL。
if not repo_url or any(ch.isspace() for ch in repo_url):
raise ValueError("repo_url 必须是非空字符串且不得含空白字符")
url = repo_url
if url.startswith("https://"):
parsed = urlparse(url)
if parsed.username is not None or parsed.password is not None:
raise ValueError(
"repo_url 不得含 userinfo(用户名/密码): %s" % url)
if not parsed.hostname:
raise ValueError("repo_url 缺少 host: %s" % url)
return url
if url.startswith("git@"):
if not _GIT_SCP_RE.match(url):
raise ValueError("git@ 形 repo_url 不合法: %s" % url)
return url
raise ValueError(
"repo_url 只接受 https:// 或 git@ 开头,"
"不接受 file://、ssh://、本地路径: %s" % url)
def checkout_dir(repos_dir, project_id):
"""按 project_id 的安全字符命名检出目录,防路径穿越。
project_id 本身是 hex,仍做安全字符过滤;过滤后为空时报错。
返回的绝对路径一定在 repos_dir 之下,否则报错(防御性双检)。
"""
raw = str(project_id)
safe = _SAFE_NAME_RE.sub("-", raw).strip(".")
if not safe or safe in (".", ".."):
raise RepoError("project_id 无法映射为安全的检出目录名: %r" % raw)
base = os.path.abspath(repos_dir)
path = os.path.join(base, safe)
if os.path.commonpath([base, path]) != base:
raise RepoError("检出目录越出 repos_dir,拒绝: %s" % path)
return path
def _default_runner(cmd):
return subprocess.run(cmd, capture_output=True, text=True)
def _run(runner, cmd):
"""调 runner:优先只传 cmd,假 runner 只接受 cmd 时即走此路;
接受 (cmd, timeout) 的 runner 也兼容(TypeError 时回退)。"""
try:
return runner(cmd)
except TypeError:
return runner(cmd, timeout=None)
def resolve_repo(repo_url, repos_dir, project_id, runner=None):
"""把 repo_url 解析为本地检出路径,返回该路径。
目录不存在:git clone repo_url 到该目录;clone 失败抛 RepoError,
文本含“需要仓库访问凭证,控制面不存仓库凭证”的提示(私有仓
库、仓库不存在、无权访问都走同一文案,不猜测、不尝试凭证)。
目录已存在:须是 git 仓库(.git 目录或文件存在)且 remote
origin 的 URL 与 repo_url 一致;不是 git 仓库、取不到 origin、
origin 不一致时都抛 RepoError,绝不强制改 remote、不覆盖。
一致时 git fetch origin:失败抛 RepoError,不静默用旧代码;
成功后不 checkout/pull/reset/clean,工作树由 host.py 处理。
"""
url = validate_repo_url(repo_url)
run = runner if runner is not None else _default_runner
target = checkout_dir(repos_dir, project_id)
os.makedirs(repos_dir, exist_ok=True)
if not os.path.exists(target):
result = _run(run, ["git", "clone", url, target])
if getattr(result, "returncode", 1) != 0:
err = (getattr(result, "stderr", "") or "").strip()
raise RepoError(
"git clone 失败: %s;若为私有仓库,需要仓库访问凭证,"
"控制面不存仓库凭证,请先在可访问该仓库的机器上完成"
"检出或改用公开仓库" % (err or url))
return target
git_path = os.path.join(target, ".git")
if not (os.path.isdir(git_path) or os.path.isfile(git_path)):
raise RepoError(
"检出目录已存在但不是 git 仓库,拒绝覆盖: %s" % target)
result = _run(run, ["git", "-C", target, "remote", "get-url", "origin"])
if getattr(result, "returncode", 1) != 0:
raise RepoError(
"检出目录 %s 取不到 remote origin,拒绝派发" % target)
origin = (getattr(result, "stdout", "") or "").strip()
if origin != url:
raise RepoError(
"检出目录 %s 的 remote origin(%s)与项目 repo_url(%s)"
"不一致,拒绝派发(绝不强制改 remote)"
% (target, origin, url))
fetched = _run(run, ["git", "-C", target, "fetch", "origin"])
if getattr(fetched, "returncode", 1) != 0:
err = (getattr(fetched, "stderr", "") or "").strip()
raise RepoError(
"git fetch origin 失败,拒绝用旧代码派发: %s"
% (err or target))
return target