Skip to content

feat(git): Add git grants and a streaming git relay - #156

Merged
vaind merged 36 commits into
ivan/git-remote-from-providerfrom
ivan/git-grants-relay-scm-97ba4a
Oct 22, 2026
Merged

vaind merged 36 commits into
ivan/git-remote-from-providerfrom
ivan/git-grants-relay-scm-97ba4a

Conversation

@vaind

@vaind vaind commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Coding-job pods need to git clone a 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 on sentry-scm, so what they share goes in a new scm.git module: 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]
Loading

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:

  1. verifies the grant, and checks the route and repository against it;
  2. looks up the repository and asks its provider for the clone URL;
  3. gets a token from Sentry and relays the request with it.

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

  • Grants (grant.py): HS256 JWTs, the format Sentry and Seer already sign viewer contexts with, naming an organization, a referrer, one operation (fetch or push), 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. The aud keeps other tokens signed with the same secret from passing as grants, and the kid header lets secrets rotate. Unknown claims or versions are refused, so an older verifier never reads a newer grant as allowing more. sign_grant refuses 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 (relay.py): maps the four smart-HTTP routes to fetch or push and 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 the Authorization header 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 use BlacklistAdapter to refuse internal addresses. Proxy, CA and .netrc settings aren't read from the environment, since through a proxy that check would see the proxy's address; ca_bundle replaces REQUESTS_CA_BUNDLE.
  • GitServer (server.py): runs the steps above, as RpcServer does for the RPC endpoint, and counts each request and its bytes when its response stream ends. Refusals and provider failures are also passed to emit_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 a GetToken callable, so Sentry's token getters now and a central token mint later plug in unchanged.

Not in this PR

  • Sentry's endpoint (CW-2222).
  • Honouring verify_ssl=False (CW-2224): certificates are always verified.
  • Git LFS, and caps on size, duration and concurrency (CW-2225).

Tests

  • Grants that are expired, tampered with, signed with another or a rotated secret, or malformed.
  • Every route and grant combination.
  • The relay against a local HTTPS server: headers and streamed bodies in each direction, a connection per request, cookies, redirects, timeouts, untrusted certificates, plain-http remotes, and no token in any error. The streaming and connection tests were checked to fail when the relay buffers, shares a session or leaves a connection open.
  • The server's check order and metrics.

The starting point was grant.py and parts of scm_git_endpoint.py in getsentry/sandbox-router#145.

🤖 Generated with Claude Code

vaind and others added 7 commits October 20, 2026 12:23
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>
@vaind
vaind requested a review from a team as a code owner October 20, 2026 14:12
Comment thread tests/unit/git_upstream.py Fixed
Comment thread src/scm/git/relay.py Outdated
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/scm/git/server.py Outdated
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>
@vaind
vaind marked this pull request as draft October 20, 2026 14:41
vaind and others added 6 commits October 20, 2026 16:43
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>
vaind and others added 9 commits October 20, 2026 17:14
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>
@vaind
vaind changed the base branch from main to ivan/git-remote-from-provider October 20, 2026 19:02
@vaind
vaind added this pull request to stack #159 October 20, 2026 19:03
@vaind
vaind marked this pull request as ready for review October 20, 2026 20:07

@cmanallen cmanallen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial review. I'll review again in the AM.

Comment thread src/scm/rpc/errors.py
Comment thread src/scm/git/relay.py
Comment thread src/scm/git/relay.py Outdated
Comment thread src/scm/git/grant.py
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to require now and always dependency inject it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/scm/git/grant.py Outdated
Comment thread src/scm/git/server.py Outdated
Comment thread src/scm/git/server.py Outdated
Comment thread src/scm/git/relay.py
vaind and others added 5 commits October 20, 2026 23:14
…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>
vaind added a commit to getsentry/sentry that referenced this pull request Oct 21, 2026
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).
vaind and others added 2 commits October 21, 2026 11:32
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 cmanallen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll may not be able to make another pass until tomorrow. Depends on how quick the feedback turnaround is.

Comment thread src/scm/git/grant.py Outdated
Comment thread src/scm/git/grant.py Outdated
Comment thread src/scm/git/grant.py
Comment thread src/scm/git/grant.py Outdated
Comment thread src/scm/git/grant.py Outdated
Comment thread src/scm/git/relay.py Outdated
Comment thread src/scm/git/relay.py Outdated
Comment thread src/scm/git/relay.py Outdated
Comment thread src/scm/git/relay.py
Comment thread src/scm/git/relay.py
vaind and others added 2 commits October 21, 2026 17:08
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>
vaind and others added 2 commits October 22, 2026 08:25
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>
Comment thread src/scm/git/types.py
Comment on lines +38 to +48
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."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/scm/git/server.py
Comment on lines +16 to +43
_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,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/scm/git/relay.py Outdated
) -> 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``

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A docstring is not a contract. If check_request must be called before entering this method we should enforce the request's correctness here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/scm/git/relay.py Outdated
)


def check_request(grant: Grant, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is better named map_request_to_operation or similar.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/scm/git/relay.py Outdated
Comment thread src/scm/git/relay.py
Comment thread src/scm/git/relay.py
Comment thread src/scm/git/grant.py
Comment on lines +20 to +21
GRANT_AUDIENCE = "scm-git-grant"
_ALGORITHM = "HS256"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these values aren't configurable why bother making them constants? They could be inlined into the function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@vaind
vaind merged commit ae34d08 into main Oct 22, 2026
14 checks passed
vaind added a commit that referenced this pull request Oct 22, 2026
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>
vaind added a commit that referenced this pull request Oct 22, 2026
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>
vaind added a commit that referenced this pull request Oct 22, 2026
…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>
@vaind
vaind deleted the ivan/git-grants-relay-scm-97ba4a branch October 22, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants