fix: casefold repo-binding keys to avoid spurious cross-repo mismatch

GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).
This commit is contained in:
Adam Moussa 2026-06-29 12:10:44 -04:00
parent d880cdabb3
commit ebeb2663fd
No known key found for this signature in database
2 changed files with 12 additions and 4 deletions

View file

@ -28,15 +28,20 @@ class GitHubAuthError(Exception):
def repo_cache_key(repo: Any) -> str | None: def repo_cache_key(repo: Any) -> str | None:
"""Normalize a repo dict/string to ``owner/name`` (None when unknown).""" """Normalize a repo dict/string to a casefolded ``owner/name`` (None if unknown).
GitHub owner/name are case-insensitive, so the key is casefolded on both the
write (binding) and read (compare) sides to keep ``Org/Repo`` and ``org/repo``
a single repo and avoid spurious cross-repo mismatches.
"""
if isinstance(repo, str): if isinstance(repo, str):
cleaned = repo.strip() cleaned = repo.strip()
return cleaned or None return cleaned.casefold() or None
if isinstance(repo, Mapping): if isinstance(repo, Mapping):
owner = repo.get("owner") owner = repo.get("owner")
name = repo.get("name") name = repo.get("name")
if isinstance(owner, str) and isinstance(name, str) and owner and name: if isinstance(owner, str) and isinstance(name, str) and owner and name:
return f"{owner}/{name}" return f"{owner}/{name}".casefold()
return None return None

View file

@ -19,6 +19,7 @@ from deepagents.backends.protocol import (
) )
from langgraph.config import get_config from langgraph.config import get_config
from .github_token import repo_cache_key
from .sandbox import create_sandbox from .sandbox import create_sandbox
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -167,7 +168,9 @@ async def get_bound_repo_from_metadata(thread_id: str) -> str | None:
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
return None return None
bound_repo = metadata.get("bound_repo") bound_repo = metadata.get("bound_repo")
return bound_repo if isinstance(bound_repo, str) and bound_repo else None # Casefold on read so legacy metadata written before normalization (e.g.
# ``Org/Repo``) still compares equal to the casefolded current repo key.
return repo_cache_key(bound_repo) if isinstance(bound_repo, str) and bound_repo else None
def clear_sandbox_backend(thread_id: str) -> None: def clear_sandbox_backend(thread_id: str) -> None: