fix(git-gate): bound daemon client sessions #114
+3
-1
@@ -31,6 +31,7 @@
|
||||
# 9099 egress (mitmproxy, pipelock's upstream — not externally
|
||||
# addressed by the agent)
|
||||
# 9418 git-gate (git-daemon)
|
||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
||||
# 9100 supervise (MCP HTTP)
|
||||
|
||||
# Stage 1: pipelock binary. The upstream pipelock image is a
|
||||
@@ -81,6 +82,7 @@ COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
|
||||
@@ -97,7 +99,7 @@ RUN mkdir -p \
|
||||
|
||||
# Documentation only — the compose renderer publishes whichever
|
||||
# subset the bottle uses.
|
||||
EXPOSE 8888 9099 9418 9100
|
||||
EXPOSE 8888 9099 9418 9420 9100
|
||||
|
||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||
|
||||
@@ -68,7 +68,7 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
# empty when the agent has no prompt — claude-code reads it
|
||||
# via --append-system-prompt-file only when non-empty.
|
||||
prompt_file: Path
|
||||
# Inner Plans for the four bundle daemons. The same shape the
|
||||
# Inner Plans for the sidecar bundle daemons. The same shape the
|
||||
# docker backend uses — same `.prepare()` calls produced
|
||||
# them — but our launch step doesn't populate the
|
||||
# docker-specific network fields (internal_network,
|
||||
|
||||
@@ -45,7 +45,6 @@ from ..docker.git_gate import (
|
||||
GIT_GATE_CREDS_DIR_IN_CONTAINER,
|
||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
GIT_GATE_PORT as _GIT_GATE_PORT,
|
||||
)
|
||||
from ..docker.pipelock import (
|
||||
BUNDLE_LOCAL_PIPELOCK_URL,
|
||||
@@ -77,6 +76,7 @@ _SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines"
|
||||
# them up post-start. Pipelock's port is an env-overridable string
|
||||
# in docker.pipelock; coerce to int here.
|
||||
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
|
||||
_GIT_HTTP_PORT = 9420
|
||||
_SUPERVISE_PORT = SUPERVISE_PORT
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ def launch(
|
||||
agent_git_gate_host = ""
|
||||
if plan.git_gate_plan.upstreams:
|
||||
git_gate_host_port = _bundle.bundle_host_port(
|
||||
plan.slug, _GIT_GATE_PORT, host_ip=loopback_ip,
|
||||
plan.slug, _GIT_HTTP_PORT, host_ip=loopback_ip,
|
||||
)
|
||||
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
||||
agent_supervise_url = ""
|
||||
@@ -190,10 +190,11 @@ def launch(
|
||||
# otherwise claude's HTTPS_PROXY catches direct calls to
|
||||
# the supervise URL (`http://<alias>:<port>/`) and proxies
|
||||
# them through egress, which has no route for the alias
|
||||
# and rejects with "Failed to connect". The git-gate URL
|
||||
# uses git://, not affected by HTTP_PROXY, so the alias
|
||||
# only has to be in NO_PROXY for the MCP / supervise
|
||||
# path. Append rather than overwrite so prepare.py's
|
||||
# and rejects with "Failed to connect". The smolmachines
|
||||
# git-gate URL uses smart HTTP, so it also has to bypass
|
||||
# the agent's HTTP_PROXY and go straight to the host-
|
||||
# published git HTTP endpoint. Append rather than overwrite
|
||||
# so prepare.py's
|
||||
# `localhost,127.0.0.1` baseline stays in place.
|
||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||
guest_env = {
|
||||
@@ -203,7 +204,7 @@ def launch(
|
||||
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
|
||||
}
|
||||
if agent_git_gate_host:
|
||||
guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}"
|
||||
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
|
||||
if agent_supervise_url:
|
||||
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
|
||||
plan = dataclasses.replace(
|
||||
@@ -305,10 +306,10 @@ def _bundle_launch_spec(
|
||||
Daemons in the CSV:
|
||||
- egress + pipelock are always present (pipelock is the
|
||||
agent's first hop; egress is its upstream).
|
||||
- git-gate is conditional on plan.git_gate_plan.upstreams.
|
||||
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
|
||||
- supervise is conditional on plan.supervise_plan.
|
||||
|
||||
Env + volumes are the union of the four daemons' needs, with
|
||||
Env + volumes are the union of the sidecar daemons' needs, with
|
||||
daemon-private values only (HTTPS_PROXY is scoped to the
|
||||
egress process by egress_entrypoint.sh — see PRD 0024's bundle
|
||||
bind-address PR)."""
|
||||
@@ -353,7 +354,7 @@ def _bundle_launch_spec(
|
||||
extra_hosts: list[str] = []
|
||||
gp = plan.git_gate_plan
|
||||
if gp.upstreams:
|
||||
daemons.append("git-gate")
|
||||
daemons += ["git-gate", "git-http"]
|
||||
volumes += [
|
||||
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER, True),
|
||||
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER, True),
|
||||
@@ -395,7 +396,7 @@ def _bundle_launch_spec(
|
||||
else:
|
||||
ports_to_publish = [_PIPELOCK_PORT]
|
||||
if gp.upstreams:
|
||||
ports_to_publish.append(_GIT_GATE_PORT)
|
||||
ports_to_publish.append(_GIT_HTTP_PORT)
|
||||
if sp is not None:
|
||||
ports_to_publish.append(_SUPERVISE_PORT)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Three concerns, all about git in the agent:
|
||||
Differs from `backend.docker.provision.git` in one address detail:
|
||||
the TSI-allowlisted guest can only reach the bundle's pinned IP
|
||||
(no DNS resolver in the /32 allowlist), so the insteadOf URLs
|
||||
are `git://<bundle_ip>:<port>/<name>.git` rather than the
|
||||
are `http://<bundle_ip>:<port>/<name>.git` rather than the
|
||||
docker backend's `git://git-gate/<name>.git`. The render itself
|
||||
is the shared `git_gate_render_gitconfig` on the platform-neutral
|
||||
git_gate module."""
|
||||
@@ -82,12 +82,14 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
|
||||
if not bottle.git:
|
||||
return
|
||||
|
||||
# `127.0.0.1:<host port>` form: the bundle's git-gate port
|
||||
# is published on host loopback at launch time so the
|
||||
# smolvm guest (which can only reach macOS networking via
|
||||
# `<loopback alias>:<host port>` form: the bundle's git-gate
|
||||
# HTTP port is published on host loopback at launch time so
|
||||
# the smolvm guest (which can only reach macOS networking via
|
||||
# TSI, not the docker bridge IP) can dial it. launch.py
|
||||
# populates `plan.agent_git_gate_host` after bundle bringup.
|
||||
content = git_gate_render_gitconfig(bottle.git, plan.agent_git_gate_host)
|
||||
content = git_gate_render_gitconfig(
|
||||
bottle.git, plan.agent_git_gate_host, scheme="http",
|
||||
)
|
||||
|
||||
guest_gitconfig = f"{_guest_home()}/.gitconfig"
|
||||
# Stage the file under the plan's stage_dir so `machine cp`
|
||||
|
||||
+10
-3
@@ -41,6 +41,10 @@ from .manifest import Bottle, GitEntry
|
||||
# Short network alias for git-gate inside the sidecar bundle. The
|
||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||
GIT_GATE_HOSTNAME = "git-gate"
|
||||
# Bound half-open git client sessions. If an agent/tool runner is
|
||||
# interrupted during push, git daemon should reap the receive-pack
|
||||
# child instead of keeping the gate wedged indefinitely.
|
||||
GIT_GATE_DAEMON_TIMEOUT_SECS = 15
|
||||
|
didericis-codex marked this conversation as resolved
Outdated
|
||||
|
||||
|
||||
def _empty_str_map() -> dict[str, str]:
|
||||
@@ -142,13 +146,13 @@ def git_gate_aggregate_extra_hosts(
|
||||
|
||||
|
||||
def git_gate_render_gitconfig(
|
||||
entries: tuple[GitEntry, ...], gate_host: str
|
||||
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||
) -> str:
|
||||
"""Render the agent's ~/.gitconfig content for git-gate
|
||||
`insteadOf` rewrites. Pure host-side, no docker / smolvm;
|
||||
exposed for tests + reuse across backends.
|
||||
|
||||
`gate_host` is the part of the URL between `git://` and the
|
||||
`gate_host` is the part of the URL between `<scheme>://` and the
|
||||
repo path — backends differ here:
|
||||
- docker: `git-gate` (the short network alias)
|
||||
- smolmachines: `<bundle_ip>:<port>` (no DNS in the
|
||||
@@ -165,7 +169,7 @@ def git_gate_render_gitconfig(
|
||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||
]
|
||||
for entry in entries:
|
||||
out.append(f'[url "git://{gate_host}/{entry.Name}.git"]\n')
|
||||
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
|
||||
port = (
|
||||
@@ -233,6 +237,7 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
" git -C \"$repo\" config git-gate.identityFile \"$keyfile\"",
|
||||
" git -C \"$repo\" config git-gate.knownHosts \"$hostsfile\"",
|
||||
" git -C \"$repo\" config receive.denyCurrentBranch ignore",
|
||||
" git -C \"$repo\" config http.receivepack true",
|
||||
" install -m 755 /etc/git-gate/pre-receive \"$repo/hooks/pre-receive\"",
|
||||
"}",
|
||||
"",
|
||||
@@ -247,6 +252,8 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"",
|
||||
"exec git daemon \\",
|
||||
" --reuseaddr \\",
|
||||
f" --timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
||||
f" --init-timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
||||
" --base-path=/git \\",
|
||||
" --export-all \\",
|
||||
" --enable=receive-pack \\",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tiny smart-HTTP wrapper for git-gate repos.
|
||||
|
||||
Used by the smolmachines backend where `git://` push traffic over the
|
||||
host-published Docker port can hang before receive-pack reaches hooks.
|
||||
The wrapper serves the same `/git/*.git` bare repos through
|
||||
`git http-backend`, so pre-receive and upstream forwarding remain the
|
||||
git-gate enforcement point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
DEFAULT_PORT = 9420
|
||||
|
||||
|
||||
class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
|
didericis
commented
Would prefer we not roll our own git http handler/seems like we should be using either something already build into git (does it not have an inbuilt tool to do this) or some very lightweight, standard tool. That being said, this isn't very big/complicated, so it may actually be better than something that introduces a bunch of complex features we don't need or a supply chain attack. Would prefer we not roll our own git http handler/seems like we should be using either something already build into git (does it not have an inbuilt tool to do this) or some very lightweight, standard tool.
That being said, this isn't very big/complicated, so it may actually be better than something that introduces a bunch of complex features we don't need or a supply chain attack.
|
||||
server_version = "bot-bottle-git-http/1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._run_backend()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._run_backend()
|
||||
|
||||
def _run_backend(self) -> None:
|
||||
parsed = urlsplit(self.path)
|
||||
if self._is_upload_pack(parsed.path, parsed.query):
|
||||
repo_dir = self._repo_dir(parsed.path)
|
||||
if repo_dir is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
hook_path = os.environ.get(
|
||||
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
|
||||
)
|
||||
hook = subprocess.run(
|
||||
[hook_path, "upload-pack",
|
||||
str(repo_dir), self.client_address[0], self.client_address[0]],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if hook.returncode != 0:
|
||||
self.send_response(403)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(hook.stderr or hook.stdout)
|
||||
return
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"),
|
||||
"GIT_HTTP_EXPORT_ALL": "1",
|
||||
"REQUEST_METHOD": self.command,
|
||||
"PATH_INFO": parsed.path,
|
||||
"QUERY_STRING": parsed.query,
|
||||
"CONTENT_TYPE": self.headers.get("content-type", ""),
|
||||
"CONTENT_LENGTH": self.headers.get("content-length", "0"),
|
||||
"REMOTE_ADDR": self.client_address[0],
|
||||
"REMOTE_PORT": str(self.client_address[1]),
|
||||
"REMOTE_USER": "",
|
||||
"SERVER_NAME": self.server.server_name,
|
||||
"SERVER_PORT": str(self.server.server_port),
|
||||
"SERVER_PROTOCOL": self.request_version,
|
||||
})
|
||||
for header, variable in (
|
||||
("accept", "HTTP_ACCEPT"),
|
||||
("content-encoding", "HTTP_CONTENT_ENCODING"),
|
||||
("git-protocol", "HTTP_GIT_PROTOCOL"),
|
||||
("user-agent", "HTTP_USER_AGENT"),
|
||||
):
|
||||
value = self.headers.get(header)
|
||||
if value:
|
||||
env[variable] = value
|
||||
length = int(self.headers.get("content-length", "0") or "0")
|
||||
body = self.rfile.read(length) if length else b""
|
||||
proc = subprocess.run(
|
||||
["git", "http-backend"],
|
||||
input=body,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self._write_cgi_response(proc.stdout)
|
||||
|
||||
def _repo_dir(self, path: str) -> Path | None:
|
||||
root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve()
|
||||
relative = path.lstrip("/").split(".git", 1)[0] + ".git"
|
||||
candidate = (root / relative).resolve()
|
||||
if root not in (candidate, *candidate.parents):
|
||||
return None
|
||||
if not candidate.is_dir():
|
||||
return None
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def _is_upload_pack(path: str, query: str) -> bool:
|
||||
if path.endswith("/git-upload-pack"):
|
||||
return True
|
||||
if path.endswith("/info/refs"):
|
||||
return any(
|
||||
pair == "service=git-upload-pack"
|
||||
for pair in query.split("&")
|
||||
)
|
||||
return False
|
||||
|
||||
def _write_cgi_response(self, raw: bytes) -> None:
|
||||
head, sep, body = raw.partition(b"\r\n\r\n")
|
||||
line_sep = b"\r\n"
|
||||
if not sep:
|
||||
head, sep, body = raw.partition(b"\n\n")
|
||||
line_sep = b"\n"
|
||||
status = 200
|
||||
headers: list[tuple[str, str]] = []
|
||||
for line in head.split(line_sep):
|
||||
if not line:
|
||||
continue
|
||||
key, _, value = line.decode("latin1").partition(":")
|
||||
value = value.strip()
|
||||
if key.lower() == "status":
|
||||
status = int(value.split()[0])
|
||||
else:
|
||||
headers.append((key, value))
|
||||
self.send_response(status)
|
||||
for key, value in headers:
|
||||
self.send_header(key, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
sys.stdout.write(fmt % args + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT)))
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler)
|
||||
sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n")
|
||||
sys.stdout.flush()
|
||||
server.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -20,7 +20,7 @@ sick daemon."
|
||||
|
||||
Daemon subset is env-driven. The compose renderer narrows it via
|
||||
`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that
|
||||
don't use git-gate or supervise. Default: all four.
|
||||
don't use git-gate or supervise. Default: all daemons.
|
||||
|
||||
Stdlib-only by design — adding supervisord/s6/runit for four
|
||||
daemons is heavier than this script.
|
||||
@@ -98,6 +98,7 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
|
||||
"--listen", "0.0.0.0:8888"),
|
||||
),
|
||||
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
|
||||
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
|
||||
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ These tests target `egress_addon_core` — the host-importable
|
||||
half of the addon. The mitmproxy hook wrapper in
|
||||
`egress_addon.py` is container-only and is not exercised here."""
|
||||
|
||||
import http.server
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bot_bottle.egress_addon_core import (
|
||||
Decision,
|
||||
@@ -326,5 +333,88 @@ class TestIsGitPushRequest(unittest.TestCase):
|
||||
self.assertFalse(is_git_push_request("/", ""))
|
||||
|
||||
|
||||
class TestGitPushBlockFailFast(unittest.TestCase):
|
||||
def test_real_git_push_fails_fast_when_egress_blocks_receive_pack(self):
|
||||
"""A real git client should see egress's HTTPS-push 403 and exit.
|
||||
|
||||
The local server stands in for the egress proxy response after
|
||||
CONNECT/TLS interception; git smart-HTTP uses the same paths over
|
||||
plain HTTP here, which keeps this regression test hermetic.
|
||||
"""
|
||||
|
||||
seen_paths: list[str] = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self._handle()
|
||||
|
||||
def do_POST(self):
|
||||
self._handle()
|
||||
|
||||
def _handle(self):
|
||||
parsed = urlsplit(self.path)
|
||||
seen_paths.append(self.path)
|
||||
if is_git_push_request(parsed.path, parsed.query):
|
||||
body = (
|
||||
b"egress: git push over HTTPS is not supported; "
|
||||
b"use the bottle.git SSH path (gitleaks-scanned by "
|
||||
b"git-gate's pre-receive hook)."
|
||||
)
|
||||
self.send_response(403)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _fmt, *_args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = Path(tmp) / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=repo, check=True,
|
||||
capture_output=True, text=True)
|
||||
subprocess.run(["git", "config", "user.name", "test"],
|
||||
cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.invalid"],
|
||||
cwd=repo, check=True)
|
||||
(repo / "README.md").write_text("test\n")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "test"],
|
||||
cwd=repo, check=True, capture_output=True, text=True)
|
||||
remote = f"http://127.0.0.1:{server.server_port}/owner/repo.git"
|
||||
subprocess.run(["git", "remote", "add", "origin", remote],
|
||||
cwd=repo, check=True)
|
||||
|
||||
started = time.monotonic()
|
||||
result = subprocess.run(
|
||||
["git", "push", "origin", "HEAD:refs/heads/main"],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertLess(elapsed, 5)
|
||||
self.assertTrue(
|
||||
any("service=git-receive-pack" in p for p in seen_paths),
|
||||
f"git did not request receive-pack capabilities; saw {seen_paths!r}",
|
||||
)
|
||||
self.assertIn("403", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -170,7 +170,12 @@ class TestEntrypointRender(unittest.TestCase):
|
||||
# Daemon line is what keeps PID 1 alive.
|
||||
self.assertIn("exec git daemon", script)
|
||||
self.assertIn("--enable=receive-pack", script)
|
||||
self.assertIn("--timeout=15", script)
|
||||
self.assertIn("--init-timeout=15", script)
|
||||
self.assertIn("--base-path=/git", script)
|
||||
# Smart HTTP receive-pack uses the same bare repos and hooks
|
||||
# as git-daemon, so repos must opt in to HTTP pushes too.
|
||||
self.assertIn("http.receivepack true", script)
|
||||
# The access-hook is what makes fetch a mirror operation
|
||||
# against the upstream (PRD 0008 v1.1).
|
||||
self.assertIn("--access-hook=/etc/git-gate/access-hook", script)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Unit: smart-HTTP git-gate wrapper."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from bot_bottle.git_http_backend import GitHttpHandler
|
||||
|
||||
|
||||
class TestGitHttpBackend(unittest.TestCase):
|
||||
def test_real_git_push_reaches_bare_repo(self):
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
bare = root / "repo.git"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)],
|
||||
check=True, capture_output=True, text=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(bare), "config", "http.receivepack", "true"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
old_root = os.environ.get("GIT_PROJECT_ROOT")
|
||||
os.environ["GIT_PROJECT_ROOT"] = str(root)
|
||||
self.addCleanup(self._restore_env, old_root)
|
||||
old_hook = os.environ.get("GIT_GATE_ACCESS_HOOK")
|
||||
hook = root / "access-hook"
|
||||
hook.write_text("#!/bin/sh\nexit 0\n")
|
||||
hook.chmod(0o700)
|
||||
os.environ["GIT_GATE_ACCESS_HOOK"] = str(hook)
|
||||
self.addCleanup(self._restore_hook, old_hook)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
|
||||
work = root / "work"
|
||||
work.mkdir()
|
||||
subprocess.run(["git", "init"], cwd=work, check=True,
|
||||
capture_output=True, text=True)
|
||||
subprocess.run(["git", "config", "user.name", "test"],
|
||||
cwd=work, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "test@example.invalid"],
|
||||
cwd=work, check=True)
|
||||
(work / "README.md").write_text("test\n")
|
||||
subprocess.run(["git", "add", "README.md"], cwd=work, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "init"], cwd=work,
|
||||
check=True, capture_output=True, text=True)
|
||||
|
||||
url = f"http://127.0.0.1:{server.server_port}/repo.git"
|
||||
subprocess.run(
|
||||
["git", "push", url, "HEAD:refs/heads/main"],
|
||||
cwd=work,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
pushed = subprocess.check_output(
|
||||
["git", "-C", str(bare), "rev-parse", "refs/heads/main"],
|
||||
text=True,
|
||||
).strip()
|
||||
head = subprocess.check_output(
|
||||
["git", "-C", str(work), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
).strip()
|
||||
self.assertEqual(head, pushed)
|
||||
subprocess.run(
|
||||
["git", "-C", str(bare), "symbolic-ref", "HEAD", "refs/heads/main"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
clone = root / "clone"
|
||||
subprocess.run(
|
||||
["git", "clone", url, str(clone)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
cloned = subprocess.check_output(
|
||||
["git", "-C", str(clone), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
).strip()
|
||||
self.assertEqual(head, cloned)
|
||||
|
||||
def test_post_forwards_git_cgi_headers(self):
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "repo.git").mkdir()
|
||||
|
||||
old_root = os.environ.get("GIT_PROJECT_ROOT")
|
||||
os.environ["GIT_PROJECT_ROOT"] = str(root)
|
||||
self.addCleanup(self._restore_env, old_root)
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
|
||||
backend_response = (
|
||||
b"Status: 200 OK\r\n"
|
||||
b"Content-Type: application/x-git-upload-pack-result\r\n"
|
||||
b"\r\n"
|
||||
b"0000"
|
||||
)
|
||||
calls = [
|
||||
subprocess.CompletedProcess(["hook"], 0, b"", b""),
|
||||
subprocess.CompletedProcess(["git"], 0, backend_response, b""),
|
||||
]
|
||||
with mock.patch(
|
||||
"bot_bottle.git_http_backend.subprocess.run",
|
||||
side_effect=calls,
|
||||
) as run:
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{server.server_port}"
|
||||
"/repo.git/git-upload-pack",
|
||||
data=b"compressed",
|
||||
headers={
|
||||
"Accept": "application/x-git-upload-pack-result",
|
||||
"Content-Encoding": "gzip",
|
||||
"Content-Type": "application/x-git-upload-pack-request",
|
||||
"Git-Protocol": "version=2",
|
||||
"User-Agent": "git/test",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
self.assertEqual(200, response.status)
|
||||
self.assertEqual(b"0000", response.read())
|
||||
|
||||
env = run.call_args_list[1].kwargs["env"]
|
||||
self.assertEqual("gzip", env["HTTP_CONTENT_ENCODING"])
|
||||
self.assertEqual("version=2", env["HTTP_GIT_PROTOCOL"])
|
||||
self.assertEqual(
|
||||
"application/x-git-upload-pack-result",
|
||||
env["HTTP_ACCEPT"],
|
||||
)
|
||||
self.assertEqual("git/test", env["HTTP_USER_AGENT"])
|
||||
|
||||
@staticmethod
|
||||
def _restore_env(value: str | None) -> None:
|
||||
if value is None:
|
||||
os.environ.pop("GIT_PROJECT_ROOT", None)
|
||||
else:
|
||||
os.environ["GIT_PROJECT_ROOT"] = value
|
||||
|
||||
@staticmethod
|
||||
def _restore_hook(value: str | None) -> None:
|
||||
if value is None:
|
||||
os.environ.pop("GIT_GATE_ACCESS_HOOK", None)
|
||||
else:
|
||||
os.environ["GIT_GATE_ACCESS_HOOK"] = value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,6 +60,15 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
||||
'[url "git://192.168.20.2:9418/bot-bottle.git"]', out,
|
||||
)
|
||||
|
||||
def test_scheme_can_be_http_for_smolmachines(self):
|
||||
bottle = fixture_with_git().bottles["dev"]
|
||||
out = git_gate_render_gitconfig(
|
||||
bottle.git, "127.0.0.16:57001", scheme="http",
|
||||
)
|
||||
self.assertIn(
|
||||
'[url "http://127.0.0.16:57001/bot-bottle.git"]', out,
|
||||
)
|
||||
|
||||
def test_ip_upstream_also_rewrites_logical_remote_key(self):
|
||||
m = Manifest.from_json_obj({
|
||||
"bottles": {"dev": {"git": {"remotes": {
|
||||
|
||||
@@ -50,15 +50,15 @@ class TestEnvForDaemon(unittest.TestCase):
|
||||
env = _env_for_daemon("pipelock", self._BASE)
|
||||
self.assertNotIn("EGRESS_TOKEN_0", env)
|
||||
self.assertNotIn("EGRESS_TOKEN_1", env)
|
||||
# Non-token bundle env stays — supervise / git-gate / the
|
||||
# Non-token bundle env stays — supervise / git-gate / git-http / the
|
||||
# upstream proxy URL are all load-bearing for other
|
||||
# daemons.
|
||||
self.assertEqual("/usr/bin", env["PATH"])
|
||||
self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"])
|
||||
self.assertEqual("9100", env["SUPERVISE_PORT"])
|
||||
|
||||
def test_git_gate_and_supervise_also_lose_egress_tokens(self):
|
||||
for name in ("git-gate", "supervise"):
|
||||
def test_git_daemons_and_supervise_also_lose_egress_tokens(self):
|
||||
for name in ("git-gate", "git-http", "supervise"):
|
||||
env = _env_for_daemon(name, self._BASE)
|
||||
self.assertNotIn("EGRESS_TOKEN_0", env)
|
||||
self.assertNotIn("EGRESS_TOKEN_1", env)
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -23,9 +24,10 @@ from bot_bottle.backend.smolmachines.provision import (
|
||||
skills as _skills,
|
||||
supervise as _supervise,
|
||||
)
|
||||
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
||||
from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import GitEntry, Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
@@ -447,9 +449,9 @@ class TestProvisionGit(unittest.TestCase):
|
||||
|
||||
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
|
||||
# Smolmachines's TSI-allowlisted guest dials git-gate via
|
||||
# `127.0.0.1:<host port>` — the bundle's git-gate port is
|
||||
# published on host loopback at launch time, and the plan
|
||||
# carries the discovered host port (here mocked to 9418).
|
||||
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
|
||||
# git HTTP port is published on host loopback at launch
|
||||
# time, and the plan carries the discovered host port.
|
||||
plan = _plan(
|
||||
git=[GitEntry(
|
||||
Name="bot-bottle",
|
||||
@@ -472,13 +474,41 @@ class TestProvisionGit(unittest.TestCase):
|
||||
self.assertEqual(self.stage, staged_path.parent)
|
||||
content = staged_path.read_text()
|
||||
self.assertIn(
|
||||
'[url "git://127.0.0.1:9418/bot-bottle.git"]', content,
|
||||
'[url "http://127.0.0.1:9418/bot-bottle.git"]', content,
|
||||
)
|
||||
self.assertIn(
|
||||
"\tinsteadOf = ssh://git@host/repo.git", content,
|
||||
)
|
||||
|
||||
|
||||
class TestBundleLaunchSpec(unittest.TestCase):
|
||||
def test_git_gate_uses_http_daemon_for_smolmachines(self):
|
||||
plan = _plan()
|
||||
plan = replace(
|
||||
plan,
|
||||
git_gate_plan=replace(
|
||||
plan.git_gate_plan,
|
||||
upstreams=(GitGateUpstream(
|
||||
name="bot-bottle",
|
||||
upstream_url="ssh://git@host/repo.git",
|
||||
upstream_host="host",
|
||||
upstream_port="22",
|
||||
identity_file="/tmp/key",
|
||||
known_host_key="",
|
||||
),),
|
||||
),
|
||||
)
|
||||
|
||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||
|
||||
self.assertEqual(
|
||||
"egress,pipelock,git-gate,git-http",
|
||||
spec.daemons_csv,
|
||||
)
|
||||
self.assertIn(9420, spec.ports_to_publish)
|
||||
self.assertNotIn(9418, spec.ports_to_publish)
|
||||
|
||||
|
||||
class TestProvisionGitUser(unittest.TestCase):
|
||||
"""`_provision_git_user` runs `git config --global` inside the
|
||||
guest as the node user with HOME forced via `smolvm -e`
|
||||
|
||||
Reference in New Issue
Block a user
Seems a bit long... can we reduce to 15s (weigh pros and cons of this)