Repository navigation
feat(git): Add git grants and a streaming git relay - #156
Conversation
The git relay needs the same not-found, inactive and organization checks on a repository, but it gets a token instead of a provider, so it cannot go through initialize_provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coding-job sandboxes need to clone and push without holding provider credentials. Seer and Sentry both depend on sentry-scm, so the pieces both sides of the git endpoint share live here: Seer signs grants with scm.git.grant, and Sentry's view hands each request to GitServer, which verifies the grant and checks the route and repository against it before it looks up the repository or asks for a token. The relay streams both bodies without parsing them and reuses a keep-alive connection per provider host, because a new connection per request cost 0.4-0.8s per clone in the prototype (getsentry/sandbox-router#145). It blocks cookies, never follows redirects, and drops requests' exceptions so the Authorization header cannot reach an error report. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay mounted a plain-http adapter so tests could reach a local server, and requests mounts one by default anyway, so nothing stopped a token from going out in cleartext if a remote ever had an http URL. The relay now refuses non-https remotes, git_remote refuses a web_base_url that is not https, and the tests serve TLS with a throwaway certificate instead. A GitHub Enterprise repository without a web_base_url is bad repository data, not an unsupported provider, so it gets its own error like MalformedExternalId. GitLab now uses web_base_url when Sentry sets it, because only that keeps the scheme and a path prefix; until then it falls back to the host in the external id. cryptography becomes an explicit dev dependency because the tests now use it directly; it was only locked through pyjwt[crypto]. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
requests' SSLError is a kind of ConnectionError, so a provider whose TLS certificate could not be verified was reported as "Could not connect", which points at the network rather than the certificate. The relay now says the host's certificate could not be verified. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A GitHub Enterprise or self-managed GitLab host is whatever the customer configured, and the relay connected wherever it resolved, including internal addresses. Sentry's API clients refuse those through BlacklistAdapter, which the library cannot import, so the relay now takes an adapter for Sentry's endpoint to pass in. Without one it keeps its plain pooled adapter. The Grant docstring also claimed the repository list covers git dependencies without a new format; that holds only for repositories Sentry has connected for the organization. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host in a GitLab repository's external id is only the instance's hostname, so the remote built from it guessed https and lost the path prefix of an instance served under one. getsentry/sentry#128164 makes Sentry fill web_base_url for GitLab from the integration's instance URL, so GitLab now requires it the way GitHub Enterprise does, and the fallback goes. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The git relay reads provider responses with urllib3's chunked streaming, and a GitHub Enterprise or GitLab host is whatever a customer configured. Before urllib3 2.8.0 such a host could make that reader buffer without limit (GHSA-vxq7-64xx-v4gw), so the floor is declared here, where consumers inherit it, rather than only in this repo's lock. The test server's TLS context now also refuses TLS 1.0 and 1.1, which CodeQL flagged. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f21a127. Configure here.
GitServer rewrote its own 401s to 403 so git would not ask a sandboxed client for credentials, but a 401 from the provider, from an expired or rejected token, still went through unchanged. That is Sentry's credential failing, not the client's, so it becomes a 502 with a plain-text reason, and the upstream response is closed. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sentry and Seer already sign the tokens that cross between them as HS256 JWTs (viewer contexts), and the HMAC rpcsignature scheme the first version copied is the deprecated one. A grant is now a JWT: its fields are claims next to exp and iat, an aud of scm-git-grant keeps other tokens signed with the same secret, such as viewer contexts, from passing as grants, and a kid header names the signing secret the way viewer contexts do, so secrets can still rotate. The claims still go through the strict Grant struct, so unknown fields and versions are refused as before. PyJWT becomes a runtime dependency; Sentry and Seer both use it already. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From a simplification review of the PR: - GitServer requires a relay. Its default was the plain adapter that connects to internal addresses, which the docstring warned against. - GitServer drops emit_error. Nothing on the git path raises UnhandledException, so it never ran; other exceptions reach the caller, which can answer them as it does elsewhere (RestrictedIPAddress as ApiHostError in Sentry). - One require_route() serves check_request and the relay's backstop. - Content-Length accepted digits like "²", which int() refuses, and "٣", which it reads as 3; it now accepts ASCII digits only. The tests share their repository and grant builders, use closing() for relays, and send requests through one helper per file. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From a second review of the PR: - urllib3 ignores ECONNRESET and EPIPE while sending a request body, taking them for the provider closing the connection, so a client that reset mid-push left the relay waiting out the 300s read timeout, then reported as a provider 504 (or as "could not connect"). A failed read of the client's body now raises ClientClosedRequest, which urllib3 passes on at once. - A GET is sent upstream with no body. A view passing its request stream for every method sent info/refs as an empty chunked request. - An empty secret would let anyone sign a grant, since HMAC accepts an empty key. Signing, verifying and building a GitServer refuse one. - Exceptions that are not SCM errors still reach the caller unchanged, but are now counted as failed requests. - The Readable docstring no longer suggests a Django request, which reads a chunked body as empty. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sentry stores a GitLab repository's display name (name_with_namespace) as its name, so "GitLab.org / GitLab FOSS" became a clone URL of GitLab.org/GitLabFOSS.git, which GitLab refuses. The real path is only in Sentry's repo.config["path"]. Repository gains an optional path, carried through the RPC serialization (absent from older servers, so it defaults to None), and git_remote builds GitLab URLs from it. Without one it refuses with missing_repository_path rather than guessing from the name. Sentry's fetch_repository needs to fill it in. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitServer counted a relayed request as succeeded when the provider's headers arrived, so a clone that broke halfway, or that the client gave up on, was never recorded as failed. The response stream is now counted when it ends: succeeded when it is read to the end, failed with stream_interrupted when the provider's stream breaks, and failed with client_closed_request when it is closed early. Bytes sent each way are counted at the same point, tagged by operation and provider. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tream The relay now gives up on a request when the client's body fails, so the fake upstream can see end of stream partway through a chunked body. That is the client going away, which the server already ignores, rather than a chunk-size line to parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
requests reads HTTPS_PROXY and friends from the environment by default. Through such a proxy, an adapter that refuses internal addresses, like Sentry's BlacklistAdapter, checks the proxy's address rather than the provider's, so a customer-configured host pointing inside the network would get past it. The relay now sets trust_env to False, which also drops REQUESTS_CA_BUNDLE and .netrc, and takes an explicit ca_bundle instead. Refs CW-2221 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitLabProvider built file, commit and merge request links from Sentry's
name for the repository with its spaces removed, but that name is
GitLab's display name ("GitLab.org / GitLab FOSS"), which differs from
the path (gitlab-org/gitlab-foss) in more than spaces. The links now use
Repository.path, which getsentry/sentry#128198 fills in, and refuse with
missing_repository_path without it. API calls address projects by id
and are unaffected.
Refs CW-2221
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server closed a relay stream in two places with the same getattr dance, and four server tests built the same mock relay by hand. One helper each keeps the next change to either from touching only some copies. The relay's request body moves out of the session.request call so the call reads as a list of arguments rather than a conditional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pooled session shared across organizations needed a blocked cookie jar and relied on urllib3 never returning a half-read connection, and each is one more thing to get right for every change to the relay. The handshakes pooling saved were measured in the prototype over a slow local link; from Sentry's servers to the providers they should cost little. With a session per request, nothing one organization's request leaves on a connection can reach another's. The adapter argument becomes a factory, since an adapter instance holds its own pool. Sentry can pass the BlacklistAdapter class as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clone URLs lived in a match on provider_name in scm.git.remote, apart from the providers that already build every other URL for a repository, and the two had drifted: the GitLab provider took its host from the external ID while the remote took it from web_base_url. A GetGitRemoteProtocol puts the clone URL and basic-auth username next to the web-link protocols, and GitServer builds the provider with initialize_provider to ask for it. A provider without the capability, such as Cursor Origin's, is refused. Both providers now fall back to the repository's web_base_url when built without one. Sentry's fetch_provider builds GitHubProvider for GitHub Enterprise without it, which pointed web links at github.com and would have sent a GitHub Enterprise token there. GitLab keeps the external ID's host when Sentry sends no web_base_url, and uses the instance's full URL, with any path prefix, when it does. The relay's https check becomes a coded error, since a self-managed instance served over http is a configuration to refuse rather than a bug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Origin's docs (cursor.com/docs/api/origin, "Git HTTPS authentication") describe the same basic-auth scheme as GitHub: an installation token as the password with x-access-token as the username, on origin.cursor.com rather than the web UI's host. Its git endpoint refuses a bearer header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay-scm-97ba4a # Conflicts: # tests/unit/provider/test_gitlab.py
It was pulled out of initialize_provider so GitServer could check a repository without building its provider. GitServer now builds the provider to ask it for the git remote, through initialize_provider, so nothing else calls it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cmanallen
left a comment
There was a problem hiding this comment.
Initial review. I'll review again in the AM.
| return jwt.encode(claims, secret, algorithm=_ALGORITHM, headers={"kid": _key_id(secret)}) | ||
|
|
||
|
|
||
| def verify_grant(secrets: list[str], token: str, *, now: float | None = None) -> Grant: |
There was a problem hiding this comment.
Any reason not to require now and always dependency inject it?
There was a problem hiding this comment.
I kept it optional. Every production caller wants the wall clock, and making now required puts time.time() at each Sentry and Seer call site, where passing milliseconds or a stale value would quietly mint bad grants. The grant tests now pin now everywhere, since the new iat check would otherwise break them once the real clock passed their fixed NOW. Happy to make it required if you'd rather.
🤖 Addressed by Claude Code
…reaming Review on #156 found that the relay's response generator never ran its cleanup when GitServer closed it unread, as it does with a 401, so the provider connection stayed open. And with redirects off, requests still read a redirect's whole body into memory to prepare Response.next, so a provider could make the relay buffer as much as it liked. Also from review: - GitServer keeps its own status map instead of adding git codes to the RPC one, with an explicit status for every code it answers; unknown codes are 500s. - A missing token is git_token_unavailable (502), not provider_not_found. - Any requests exception is replaced, not just the three expected ones, so none can carry the token. - verify_grant refuses a grant issued more than a minute in the future. - Failed requests that reached the provider keep their operation and provider tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng the relay HTTPAdapter.send re-raises the urllib3 errors it does not translate, and those are not RequestExceptions, so they reached the caller as raw urllib3 errors instead of a 502. Credentials that are not Latin-1 failed inside requests with a UnicodeEncodeError whose .object is the credentials themselves. The relay now catches urllib3's HTTPError with requests' own, and refuses non-ASCII credentials before building the request, with a message that leaves them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…git remote It has one since Origin repositories got a git remote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scm-platform's new git relay ([getsentry/scm-platform#156](getsentry/scm-platform#156)) clones repositories, and for GitLab it builds the clone URL as `<web_base_url>/<path>.git`. Sentry doesn't give it a usable path today: for GitLab, the repository's `name` is the project's display name, such as `GitLab.org / GitLab FOSS`, not its path, `gitlab-org/gitlab-foss`. A clone URL built from the display name points nowhere, so the relay refuses GitLab repositories with `missing_repository_path`. The real path is already stored in the repository's config (`config["path"]`, written when the repository is linked). `fetch_repository` now copies it into the `path` key that scm-platform's `Repository` gained for this. GitLab repositories linked without a path in their config get `path: None`. Other providers get no `path` key, since their `name` already is the path. The repository dict is built through `cast(Repository, ...)`, so this works with Sentry's currently pinned sentry-scm, which doesn't declare `path` yet. This is independent of #128164, which sets `web_base_url` for GitLab in the same function; the relay needs both before it can clone from GitLab. Storing the path as the GitLab repository's `name` instead, which would make this key unnecessary, is tracked separately: related to [SCM-293](https://linear.app/getsentry/issue/SCM-293).
git_route_not_allowed covered three unrelated refusals: a request that is not a git route at all, a grant used for the other operation, and a grant that does not name the repository. git_invalid_grant lumped expiry, which is expected for a long job, with forgery and misconfigured secrets. And a provider rejecting Sentry's token was reported as resource_bad_gateway, the same code as a malformed provider response, though it means the integration's credentials are broken. Metrics tagged with these codes could not tell any of them apart. A request that is not a git route is now a 404, since it is a path the endpoint does not serve rather than one the grant forbids. The messages git shows are unchanged. None of these codes has been released, so they change without a deprecation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitServer passes a provider's own non-2xx response on to git, so the client sees the provider's message, e.g. GitHub's "Repository not found." for a repository the installation cannot see. It counted that response as request.succeeded, though git got nothing it asked for. It is now request.failed with the code provider_error. A redirect counts too: the relay neither follows it nor passes its Location on, so git cannot follow it either. Every count of a relayed request now carries the provider's status, failures included, so a dashboard can split provider_error by status and see what status a stream had when it broke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cmanallen
left a comment
There was a problem hiding this comment.
I'll may not be able to make another pass until tomorrow. Depends on how quick the feedback turnaround is.
Refusals and provider failures are now passed to an emit_error callback, as RpcServer does, so the server reports them itself instead of relying on every client to report the message it is shown. A client closing its request is counted but not reported, since there is nothing to fix. For the same reason, provider failures no longer name the provider's URL or host: the client has no use for the address. PyJWT now checks iat, with the clock skew as leeway, instead of a hand-written check that refused floats, which are valid iat values. The rest makes the module easier to read: the route table and check_request move into relay.py, one-off helpers are inlined, the git errors share a GitSCMCodedError base so callers can catch them together, Grant.repositories is a list, and the byte metrics are named request_size and response_size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The messages GitServer reports no longer name the provider's address, and emit_error got only the exception, so an event in Sentry could not say which organization, repository, provider or host a failure was about. The provider and host are resolved inside handle, where the caller cannot see them. emit_error now also gets them as tags, each once it is known, so a bad grant reports only the requested repository while a relay failure reports all of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scm-platform threads a referrer through every action, so Sentry can tell callers apart in metrics and, later, in per-caller limits and permissions. RpcServer already takes one, from the unsigned X-Referrer header, defaulting to "shared". Git requests carry none, so Sentry could not tell a coding job's clone from Seer's own fetch or push. The grant is the only thing a git request carries from its signer, so the referrer goes there, and GitServer tags its counts and reported errors with it. It is required rather than defaulting to "shared": Seer is the only signer, and a grant that does not say who it is for would defeat the point. Grant refuses unknown fields and versions, so adding a field after the first release would need grant version 2. This is unreleased, so adding it now costs nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The credential proxy sends the grant on in the X-Scm-Git-Grant request header, and sandbox-router's PUT /grant refuses a grant longer than 4096 characters. A grant grows with its repositories, so once submodules or dependencies are added Seer could sign one the router refuses, and the job would fail there with nothing pointing back to the limit. Refused in sign_grant, it fails in the signer's tests instead. GRANT_MAX_LENGTH matches the router's limit, which is a conservative pick rather than a measured ceiling. verify_grant does not check it: a longer grant would already have been refused on the way in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| organization_id: int | ||
| referrer: Referrer | ||
| """Who the grant is for, e.g. a coding job or Seer itself, as ``referrer`` is elsewhere in this library. | ||
| Sentry tags the git endpoint's metrics with it. Seer sets it, because git requests carry nothing else that | ||
| tells callers apart.""" | ||
| operation: GitOperation | ||
| repositories: list[RepositoryId] | ||
| expires_at: int | ||
| """Unix time in seconds. Every grant expires.""" | ||
| version: Literal[1] = GRANT_VERSION | ||
| """The format version. A verifier refuses a version it does not know.""" |
There was a problem hiding this comment.
Because of HTTP header limits, consider abbreviating these fields. For example oid for organization_id, op for operation, etc. The repositories field is arbitrary size so this limit is possible to hit regardless but its typical to shorten field names in JWTs. Up to you.
There was a problem hiding this comment.
I'd keep the names. Each appears once per grant, so shortening all of them saves about 70 characters after base64, roughly one repository's worth out of the 4096 limit. A grant grows with its repositories, about 50 characters each for a typical path, so the names don't move how many repositories fit, and the full names keep a decoded grant readable when debugging.
🤖 Addressed by Claude Code
| _STATUS: dict[ErrorCode, int] = { | ||
| # The client's own request is malformed, or is not a git request this endpoint serves. | ||
| "rpc_malformed_request_headers": 400, | ||
| "git_route_not_supported": 404, | ||
| # The grant does not allow the request. | ||
| "git_invalid_grant": 403, | ||
| "git_grant_expired": 403, | ||
| "git_operation_not_granted": 403, | ||
| "git_repository_not_granted": 403, | ||
| # The grant allows it, but there is no repository to serve. | ||
| "repository_not_found": 404, | ||
| "repository_inactive": 404, | ||
| "repository_organization_mismatch": 404, | ||
| "provider_not_found": 404, | ||
| "git_provider_not_supported": 404, | ||
| "client_closed_request": 499, | ||
| # Sentry's record of the repository is incomplete. | ||
| "malformed_external_id": 500, | ||
| "missing_repository_path": 500, | ||
| # The provider cannot be reached safely, or failed. | ||
| "git_remote_not_https": 502, | ||
| "git_token_rejected": 502, | ||
| "git_token_unavailable": 502, | ||
| "resource_bad_gateway": 502, | ||
| "resource_certificate_invalid": 502, | ||
| "resource_service_unavailable": 503, | ||
| "resource_gateway_timeout": 504, | ||
| } |
There was a problem hiding this comment.
How much does this map matter? Since git is the client we only need to honor its contract. If git doesn't care about a status code we could simplify this. This isn't a blocking request but it would simplify the file for later viewers.
There was a problem hiding this comment.
For git itself, only one thing matters: never a 401, which makes git ask for credentials. Any other error status just fails the command and shows our text body as remote: .... The separate codes are there for the people and tools around it: access logs and dashboards can tell a refused grant (403) from a missing repository (404) or a provider failure (502), and a caller deciding whether to retry can treat 503/504 as transient and 403 as final. So I'd keep the map, but it could collapse to one refusal status and one failure status if you'd rather have fewer lines.
🤖 Addressed by Claude Code
| ) -> RelayResponse: | ||
| """Send one request to ``remote`` and return the provider's response as it arrives. | ||
|
|
||
| ``route`` is the path below ``<repo>.git/``. Check the request against a grant with ``check_request`` |
There was a problem hiding this comment.
A docstring is not a contract. If check_request must be called before entering this method we should enforce the request's correctness here.
There was a problem hiding this comment.
The relay can't check the grant itself: it never sees one, since GitServer verifies the grant a layer up and passes only the remote and the token. What the relay does enforce in forward, whatever the caller did, is what keeps the token safe: an https remote and one of the four git routes. In 7c8dd13 the docstring says that instead of reading like a precondition, and names GitServer as the one that authorizes the request first.
🤖 Addressed by Claude Code
| ) | ||
|
|
||
|
|
||
| def check_request(grant: Grant, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: |
There was a problem hiding this comment.
This is better named map_request_to_operation or similar.
There was a problem hiding this comment.
Renamed to authorize_request in 7c8dd13. It returns the operation, but it also refuses the request unless the grant allows the operation and the repository, so a name that only says "map" would undersell it. The docstring now leads with both.
🤖 Addressed by Claude Code
| GRANT_AUDIENCE = "scm-git-grant" | ||
| _ALGORITHM = "HS256" |
There was a problem hiding this comment.
If these values aren't configurable why bother making them constants? They could be inlined into the function.
There was a problem hiding this comment.
Inlined the clock skew, which had one use, as PyJWT's leeway with its comment (7c8dd13). I kept the other three, because each is used in two places that must agree: GRANT_AUDIENCE and _ALGORITHM by both signing and verifying, and _JWT_CLAIMS as the claims PyJWT requires and the ones kept out of the Grant.
🤖 Addressed by Claude Code
- check_request is now authorize_request: it returns the request's operation but also refuses what the grant does not allow, which "check" undersold. - GitRelay.forward's docstring no longer reads as a precondition. The relay enforces what keeps the token safe (an https remote, a git route) and knows nothing of grants; GitServer authorizes first. - _UpstreamStream catches Exception rather than BaseException; StopIteration, the normal end of the body, is an Exception. - The clock skew, used once, is inlined as PyJWT's leeway. - Tests cover what coverage showed missing: empty chunks from the provider, closing the stream when the body ends, breaks or is closed unread, and a POST with Content-Length 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitLab web links are built from Sentry's repository name with its spaces
removed. That name is GitLab's display name ("GitLab.org / GitLab
FOSS"), not its path (`gitlab-org/gitlab-foss`), so most of the links
are wrong. The git endpoint for coding jobs
([CW-2221](https://linear.app/getsentry/issue/CW-2221/git-grants-and-relay-in-scm-platform))
needs the path for clone URLs too.
First of three stacked PRs, before #158 and
#156.
## Approach
- `Repository` gains an optional `path`, which Sentry fills for GitLab
from `repo.config["path"]`
([getsentry/sentry#128198](getsentry/sentry#128198)).
The RPC payload carries it, and a payload from an older server without
it reads as `None`.
- `GitLabProvider.web_repository_path` returns `path`, and raises
`missing_repository_path` without it. API calls use project ids and
don't change.
## Changes existing behaviour
Without a `path`, GitLab's link methods raise `missing_repository_path`
instead of returning a wrong link, so getsentry/sentry#128198 must be
deployed before Sentry or Seer takes a release with this.
## Tests
Links use the path and refuse without one; `path` round-trips through
the RPC payload and reads as `None` when absent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sentry's git endpoint for coding jobs ([CW-2221](https://linear.app/getsentry/issue/CW-2221/git-grants-and-relay-in-scm-platform)) relays git to a repository's provider with a token. git's protocol is the same everywhere; what differs per provider is the clone URL and the basic-auth username that goes with a token. The providers already build a repository's other URLs, so this makes the clone URL a provider capability too. Second of three stacked PRs, after #157 and before #156. ## Approach 1. **Providers fall back to the repository's `web_base_url`** (first commit). Sentry builds `GitHubProvider` for GitHub Enterprise without one, so its links pointed at github.com ([SCM-204](https://linear.app/getsentry/issue/SCM-204)), and a clone URL would have sent the token there. `GitLabProvider` now prefers `web_base_url` over the external ID's host, so an instance served under a path prefix ([getsentry/sentry#128164](getsentry/sentry#128164)) gets correct links. 2. **`GetGitRemoteProtocol.get_git_remote() -> GitRemote`:** | Provider | Clone URL | Username | |---|---|---| | GitHub, GitHub Enterprise | `<web_base_url>/<owner>/<repo>.git` | `x-access-token` | | GitLab | `<web_base_url>/<path>.git` | `oauth2` | | Cursor Origin | `https://origin.cursor.com/<owner>/<repo>.git` | `x-access-token` | Origin's values come from [its docs](https://cursor.com/docs/api/origin); its git endpoint refuses a bearer header. The caller supplies the token. Per `AGENTS.md`, the action, `FakeProvider` and a `get-git-remote` command in both CLIs come with it. ## Tests `get_git_remote` for each provider (including a GitLab instance with a port or a path prefix, and a repository without a path), the fallbacks through web links, the action, and the RPC client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rs (#161) Stacked on #156. Part of CW-2221. The unit tests run the relay against a canned local server. None of them shows that a real `git` client gets through `GitServer` to GitHub or GitLab, with git's own request sequence, protocol v2, chunked pushes, and how git shows our refusals. ## Approach `bin/git-server` stands in for Sentry's git endpoint. It looks up the repository, signs grants and supplies the token. The real `GitServer` and `GitRelay` do the rest. - **Credentials:** the same ones `bin/github-server` and `bin/gitlab-server` read, from `.credentials`, env vars or flags. It loads those scripts to reuse their token handling. - **Serve mode:** `bin/git-server github --repository OWNER/REPO` serves the repository and prints a fetch grant, a push grant, and the `git clone` and `git push` commands that use them. - **Check mode:** `--check` serves on a free port, runs git against it, and exits non-zero if any check fails. Nothing is prompted and the user's git config isn't used, so it can run in CI once there are credentials for a test repository. It checks that: - `ls-remote` and a shallow clone work with a fetch grant - a push with a fetch grant, and a grant for another repository, are refused with git's `remote:` message - a token the provider rejects fails without git asking for credentials - a repository the token can't see fails with the provider's own message, e.g. GitHub's `Repository not found.`, which the relay passes on - **`--push`:** also pushes a 256 KiB commit to a new branch and then deletes it. The commit is larger than `http.postBuffer`, so git sends it chunked. This writes to the repository, so it's opt-in. ## Testing - All checks, including `--push`, pass in GitLab mode against a local HTTPS `git http-backend` with Basic auth. The backend received the push chunked, and the test branch was deleted afterwards. - Serve mode: cloned with the grant it printed. - GitHub: all checks, including `--push`, pass against an internal test repository through a GitHub App installation token. The test branch was deleted afterwards. - GitHub Enterprise: checked that the remote URL and username are right, but not run against a live instance. - GitLab: not yet run against a live instance. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Coding-job pods need to
git clonea branch, and Seer needs to fetch and push, without either holding a provider credential (Git transport for coding jobs, CW-2221). Both sides of Sentry's new git endpoint depend onsentry-scm, so what they share goes in a newscm.gitmodule: Seer signs grants with it, and Sentry's endpoint (CW-2222) verifies them and relays with it.Third of three stacked PRs, after #157 and #158; this diff is only
scm.git.Flow
flowchart LR pod[git client] -->|git request| proxy[Credential proxy] proxy -->|same request + grant| server["Sentry git endpoint (GitServer)"] server -->|same request + token| provider[GitHub, GitLab or Origin]The pod holds neither the grant nor a token: the credential proxy adds the grant (getsentry/sandbox-router#153, getsentry/sandbox-router#154), and the endpoint swaps it for a token. The response streams back the same way.
For each request,
GitServer:A request the grant doesn't allow is refused at step 1, so it never reaches the database, a credential or the provider. Refusals are plain text and never a 401, because git asks for credentials on a 401.
Approach
grant.py): HS256 JWTs, the format Sentry and Seer already sign viewer contexts with, naming an organization, a referrer, one operation (fetchorpush),RepositoryIds, an expiry and a version. The referrer says who the grant is for, e.g. a coding job or Seer itself, and tags the endpoint's metrics, since git requests carry nothing else that tells callers apart. Theaudkeeps other tokens signed with the same secret from passing as grants, and thekidheader lets secrets rotate. Unknown claims or versions are refused, so an older verifier never reads a newer grant as allowing more.sign_grantrefuses a grant over 4096 characters (GRANT_MAX_LENGTH), the limit getsentry/sandbox-router#153 enforces because the grant travels in a request header, so an oversized grant fails in Seer's tests rather than at the router. Adds PyJWT.relay.py): maps the four smart-HTTP routes tofetchorpushand refuses anything else (authorize_request), then streams both bodies without buffering or parsing them, passes only git's own headers, accepts only https remotes, never follows redirects, and drops requests' exceptions so theAuthorizationheader can't reach an error report. Each request gets its own connection, so nothing one organization leaves on a connection, such as a cookie or an unread response, reaches another's. The caller passes an adapter factory, so Sentry can useBlacklistAdapterto refuse internal addresses. Proxy, CA and.netrcsettings aren't read from the environment, since through a proxy that check would see the proxy's address;ca_bundlereplacesREQUESTS_CA_BUNDLE.GitServer(server.py): runs the steps above, asRpcServerdoes for the RPC endpoint, and counts each request and its bytes when its response stream ends. Refusals and provider failures are also passed toemit_error, with what the request had resolved (organization, repository, operation, provider, remote host) as tags, since the message the client sees leaves out where the request was going. The token comes from aGetTokencallable, so Sentry's token getters now and a central token mint later plug in unchanged.Not in this PR
verify_ssl=False(CW-2224): certificates are always verified.Tests
The starting point was
grant.pyand parts ofscm_git_endpoint.pyin getsentry/sandbox-router#145.🤖 Generated with Claude Code