diff --git a/agent-team/agent_team/dispatcher.py b/agent-team/agent_team/dispatcher.py index aa78b3a..dc84dcd 100644 --- a/agent-team/agent_team/dispatcher.py +++ b/agent-team/agent_team/dispatcher.py @@ -604,21 +604,27 @@ def app_branch_pusher(token_provider: Any, *, _run: Any = None) -> BranchPusher: token = token_provider.token() # Plain remote — the token is NEVER in the URL/argv/stored origin. remote = f"https://github.com/{owner}/{repo}.git" - # Auth is injected via http.extraHeader through git's GIT_CONFIG_* env - # vars (NOT argv) on the network steps (clone + push). Env is visible - # only to the same uid via /proc//environ, never via ps argv. The - # header key is SCOPED to the github.com host + # GitHub's git smart-HTTP transport authenticates an installation token via + # BASIC auth (username ``x-access-token``), NOT Bearer — Bearer is the REST + # API form and git rejects it ("could not read Username" → exit 128). Encode + # ``x-access-token:`` and inject it as an Authorization header through + # git's GIT_CONFIG_* env vars (NOT argv) on the network steps (clone + push). + # Env is visible only to the same uid via /proc//environ, never via ps + # argv. The header key is SCOPED to the github.com host # (``http.https://github.com/.extraHeader``, the GitHub-Actions checkout - # pattern) so git never sends the Authorization header to any other host - # it might be redirected to. + # pattern) so git never sends the Authorization header to any other host it + # might be redirected to. + basic = base64.b64encode(f"x-access-token:{token}".encode()).decode("ascii") auth_env = { "GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "http.https://github.com/.extraHeader", - "GIT_CONFIG_VALUE_0": f"Authorization: Bearer {token}", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}", } def _scrub(s: str) -> str: - return s.replace(token, "***") + # Scrub BOTH the raw token and the base64 credential blob (which decodes + # to the token) so neither can survive in any surfaced error message. + return s.replace(token, "***").replace(basic, "***") with tempfile.TemporaryDirectory() as tmp: tmpdir = Path(tmp) diff --git a/agent-team/tests/test_dispatcher.py b/agent-team/tests/test_dispatcher.py index 67df394..ebfb8ba 100644 --- a/agent-team/tests/test_dispatcher.py +++ b/agent-team/tests/test_dispatcher.py @@ -374,12 +374,18 @@ def test_app_branch_pusher_keeps_token_out_of_argv_and_uses_plain_remote() -> No for arg in call["cmd"]: assert "ghs_TESTTOKEN" not in arg - # Auth rides in GIT_CONFIG_* env on the network steps only (clone + push). + # Auth rides in GIT_CONFIG_* env on the network steps only (clone + push), as + # BASIC auth (username x-access-token) — git smart-HTTP rejects Bearer. + expected_basic = "Authorization: Basic " + base64.b64encode( + b"x-access-token:ghs_TESTTOKEN" + ).decode("ascii") clone_env = recorded[0]["env"] assert clone_env["GIT_CONFIG_KEY_0"] == "http.https://github.com/.extraHeader" - assert clone_env["GIT_CONFIG_VALUE_0"] == "Authorization: Bearer ghs_TESTTOKEN" + assert clone_env["GIT_CONFIG_VALUE_0"] == expected_basic push_env = recorded[-1]["env"] - assert push_env["GIT_CONFIG_VALUE_0"] == "Authorization: Bearer ghs_TESTTOKEN" + assert push_env["GIT_CONFIG_VALUE_0"] == expected_basic + # The raw token never appears literally in the auth header (it is base64'd). + assert "ghs_TESTTOKEN" not in clone_env["GIT_CONFIG_VALUE_0"] # The non-network steps (checkout/apply/commit) carry no auth env. for call in recorded[1:4]: assert call["env"] is None