Skip to content

feat: GitHub Enterprise Server (GHES) support#362

Open
raghavpillai wants to merge 3 commits intoColeMurray:mainfrom
raghavpillai:feat/ghes-support
Open

feat: GitHub Enterprise Server (GHES) support#362
raghavpillai wants to merge 3 commits intoColeMurray:mainfrom
raghavpillai:feat/ghes-support

Conversation

@raghavpillai
Copy link
Copy Markdown

@raghavpillai raghavpillai commented Mar 16, 2026

Summary

Closes #360.

Replaces all hardcoded github.com / api.github.com URLs with a hostname derived from GITHUB_HOSTNAME. Set it to your GHES hostname and everything else adjusts — API base, OAuth endpoints, git remote URLs, noreply emails, avatar URLs.

This PR is standalone and based on main — no dependency on the Daytona provider PR.

Changes

packages/control-plane

  • New github-urls.tsresolveGitHubUrls(hostname?) returns { apiBase, webBase, gitHost, noreplyDomain, isEnterprise } from a single hostname
  • github-app.ts: apiBase threaded through installation token generation and repo listing
  • github.ts: OAuth token exchange, user info, email fetch, noreply generation all use resolved URLs
  • github-provider.ts: PR creation, branch listing, repo access, git push spec, label/reviewer endpoints
  • participant-service.ts: avatar URL uses configured hostname
  • shared.ts (createRouteSourceControlProvider): resolves URLs and applies GHES_TUNNEL_URL override for private VPC setups
  • types.ts: GITHUB_HOSTNAME and GHES_TUNNEL_URL added to Env

packages/github-bot

  • github-auth.ts: resolveApiBase() exported for installation tokens and permission checks
  • handlers.ts: imports resolveApiBase from github-auth, reaction API URLs use resolved base, apiBase flows through caller gating
  • types.ts: GITHUB_HOSTNAME added to Env

packages/linear-bot

  • types.ts: GITHUB_HOSTNAME added to Env
  • webhook-handler.ts: uses env.GITHUB_HOSTNAME for resolved URLs

packages/modal-infra

  • github_app.py: resolve_api_base() for token generation
  • bridge.py: noreply email fallback uses GITHUB_HOSTNAME
  • manager.py: VCS_HOST reads GITHUB_HOSTNAME instead of hardcoding github.com
  • image_builder.py: git ls-remote URLs use resolved host

packages/web

  • auth.ts: NextAuth GitHub provider splits browser-side URLs (user hits GHES directly for OAuth) from server-side URLs (Worker goes through GHES_TUNNEL_URL for token exchange and API calls)

Terraform

  • github_hostname, ghes_tunnel_url, web_app_custom_url variables
  • Wired into control plane, github-bot, and linear-bot worker bindings
  • locals.tf: web_app_url respects web_app_custom_url override

Private VPC pattern

GHES is usually not publicly accessible. GHES_TUNNEL_URL points to a reverse proxy (Cloudflare Tunnel, bore, etc.) that the control plane uses for server-side GitHub API calls. The user's browser still goes directly to GHES for OAuth authorize (since the user is on the private network).

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR adds comprehensive GitHub Enterprise Server (GHES) support by replacing all hardcoded github.com / api.github.com URLs with a hostname derived from the GITHUB_HOSTNAME environment variable, along with a GHES_TUNNEL_URL mechanism for private VPC deployments.

  • Core abstraction: New resolveGitHubUrls() in packages/control-plane/src/github-urls.ts centralizes URL derivation (apiBase, webBase, gitHost, noreplyDomain, isEnterprise) from a single hostname
  • Control plane: All GitHub API calls, OAuth token exchange, noreply emails, git push URLs, and avatar URLs now use resolved URLs with tunnel proxy support via GHES_TUNNEL_URL
  • GitHub bot: API base URL is resolved from GITHUB_HOSTNAME, but Terraform does not wire GITHUB_HOSTNAME into the worker bindings, so the bot will always default to github.com
  • Web auth: NextAuth GitHub provider splits browser-side (user hits GHES directly for OAuth) from server-side (Worker routes through tunnel for token exchange and API calls)
  • Data plane (Modal + Daytona): Python helpers mirror the same hostname resolution for installation tokens, noreply emails, git remote URLs, and VCS_HOST
  • Terraform: New github_hostname, ghes_tunnel_url, web_app_custom_url variables wired into control plane and web app bindings
  • Also included: Daytona sandbox provider integration (new DaytonaClient, DaytonaSandboxProvider, daytona-infra package) as an alternative to Modal

Comments Outside Diff (1)

  1. terraform/environments/production/workers-github.tf, line 43-47 (link)

    Missing GITHUB_HOSTNAME binding for github-bot worker

    The github-bot TypeScript code reads env.GITHUB_HOSTNAME to resolve the API base URL (see handlers.ts:129), but the Terraform configuration never wires this variable into the worker. As a result, env.GITHUB_HOSTNAME will always be undefined in the github-bot, causing it to default to github.com even when GHES is configured.

    The control-plane worker (workers-control-plane.tf:63) correctly passes GITHUB_HOSTNAME, but this worker does not. You likely need to add it to plain_text_bindings:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: terraform/environments/production/workers-github.tf
    Line: 43-47
    
    Comment:
    **Missing `GITHUB_HOSTNAME` binding for github-bot worker**
    
    The github-bot TypeScript code reads `env.GITHUB_HOSTNAME` to resolve the API base URL (see `handlers.ts:129`), but the Terraform configuration never wires this variable into the worker. As a result, `env.GITHUB_HOSTNAME` will always be `undefined` in the github-bot, causing it to default to `github.com` even when GHES is configured.
    
    The control-plane worker (`workers-control-plane.tf:63`) correctly passes `GITHUB_HOSTNAME`, but this worker does not. You likely need to add it to `plain_text_bindings`:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: terraform/environments/production/workers-github.tf
Line: 43-47

Comment:
**Missing `GITHUB_HOSTNAME` binding for github-bot worker**

The github-bot TypeScript code reads `env.GITHUB_HOSTNAME` to resolve the API base URL (see `handlers.ts:129`), but the Terraform configuration never wires this variable into the worker. As a result, `env.GITHUB_HOSTNAME` will always be `undefined` in the github-bot, causing it to default to `github.com` even when GHES is configured.

The control-plane worker (`workers-control-plane.tf:63`) correctly passes `GITHUB_HOSTNAME`, but this worker does not. You likely need to add it to `plain_text_bindings`:

```suggestion
  plain_text_bindings = [
    { name = "DEPLOYMENT_NAME", value = var.deployment_name },
    { name = "DEFAULT_MODEL", value = "anthropic/claude-haiku-4-5" },
    { name = "GITHUB_BOT_USERNAME", value = var.github_bot_username },
    { name = "GITHUB_HOSTNAME", value = var.github_hostname },
  ]
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/github-bot/src/github-auth.ts
Line: 9-13

Comment:
**Unused `resolveApiBase` function (dead code)**

This `resolveApiBase` function is defined here but never called within `github-auth.ts`. An identical copy exists in `handlers.ts` (line 15) which is the one actually used. This dead code will cause lint warnings and adds confusion about which version is the "canonical" one. Consider removing it from this file, or better yet, exporting it from one location and importing it in both.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/web/src/lib/auth.ts
Line: 87-88

Comment:
**Potential crash if `emails` array is empty**

If the `/user/emails` endpoint returns an empty array, `emails[0]` is `undefined` and accessing `.email` on it will throw a `TypeError` at runtime. While GitHub typically returns at least one email, GHES instances may behave differently.

```suggestion
                const emails: { email: string; primary: boolean }[] = await emailsRes.json();
                const chosen = emails.find((e) => e.primary) ?? emails[0];
                if (chosen) profile.email = chosen.email;
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: c27d845

Comment on lines +9 to 13
/** Resolve the GitHub API base URL from a hostname (supports GHES). */
function resolveApiBase(hostname?: string): string {
const host = (hostname || "github.com").toLowerCase().replace(/\/+$/, "");
return host === "github.com" ? "https://api.github.com" : `https://${host}/api/v3`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unused resolveApiBase function (dead code)

This resolveApiBase function is defined here but never called within github-auth.ts. An identical copy exists in handlers.ts (line 15) which is the one actually used. This dead code will cause lint warnings and adds confusion about which version is the "canonical" one. Consider removing it from this file, or better yet, exporting it from one location and importing it in both.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/github-bot/src/github-auth.ts
Line: 9-13

Comment:
**Unused `resolveApiBase` function (dead code)**

This `resolveApiBase` function is defined here but never called within `github-auth.ts`. An identical copy exists in `handlers.ts` (line 15) which is the one actually used. This dead code will cause lint warnings and adds confusion about which version is the "canonical" one. Consider removing it from this file, or better yet, exporting it from one location and importing it in both.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +87 to +88
const emails: { email: string; primary: boolean }[] = await emailsRes.json();
profile.email = (emails.find((e) => e.primary) ?? emails[0]).email;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Potential crash if emails array is empty

If the /user/emails endpoint returns an empty array, emails[0] is undefined and accessing .email on it will throw a TypeError at runtime. While GitHub typically returns at least one email, GHES instances may behave differently.

Suggested change
const emails: { email: string; primary: boolean }[] = await emailsRes.json();
profile.email = (emails.find((e) => e.primary) ?? emails[0]).email;
const emails: { email: string; primary: boolean }[] = await emailsRes.json();
const chosen = emails.find((e) => e.primary) ?? emails[0];
if (chosen) profile.email = chosen.email;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/web/src/lib/auth.ts
Line: 87-88

Comment:
**Potential crash if `emails` array is empty**

If the `/user/emails` endpoint returns an empty array, `emails[0]` is `undefined` and accessing `.email` on it will throw a `TypeError` at runtime. While GitHub typically returns at least one email, GHES instances may behave differently.

```suggestion
                const emails: { email: string; primary: boolean }[] = await emailsRes.json();
                const chosen = emails.find((e) => e.primary) ?? emails[0];
                if (chosen) profile.email = chosen.email;
```

How can I resolve this? If you propose a fix, please make it concise.

@ColeMurray
Copy link
Copy Markdown
Owner

please separate out the daytona changes

- Add github-urls utility to centralize GitHub.com vs GHES URL resolution
- Parameterize API/web/git/noreply URLs across control-plane, bots, modal-infra, web, and linear-bot
- Thread apiBase/urls/GITHUB_HOSTNAME/GHES_TUNNEL_URL through configs and env
- Update providers and handlers to use resolved URLs instead of hardcoded github.com
- Add GHES tunnel proxy support for Workers via GHES_TUNNEL_URL
- Adjust tests to include apiBase and update Terraform to expose new env vars
- Misc: deprecate hardcoded GITHUB_API_BASE constant and adapt avatar/git URLs for GHES
@raghavpillai
Copy link
Copy Markdown
Author

please separate out the daytona changes

Done

Copy link
Copy Markdown
Owner

@ColeMurray ColeMurray left a comment

Choose a reason for hiding this comment

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

[Automated Review] PR #362 — feat: GitHub Enterprise Server (GHES) support

Files changed: 27 | +335 / -63

Summary

Replaces all hardcoded github.com / api.github.com URLs across the stack with a hostname-configurable abstraction backed by GITHUB_HOSTNAME. A new resolveGitHubUrls() in the control plane derives API base, web base, git host, and noreply domain from a single hostname. Per-package equivalents handle github-bot and Python modal-infra. Terraform wires the new github_hostname and ghes_tunnel_url variables to all workers. The web auth layer splits browser-side (user-reachable GHES) from server-side (tunnel-proxied) endpoints. Well-structured change that methodically touches all necessary layers.

Note: The diff includes unrelated changes merged from main (command palette #367, next.js bump #366). This review focuses only on the GHES commits.

Positive Feedback

  1. Clean central abstractionresolveGitHubUrls() returns a well-typed GitHubUrls interface that makes it impossible to miss a dimension (API, web, git host, noreply). Single-hostname-in, all-URLs-out is elegant.
  2. Smart browser/server split in web auth — Separation of browser-side URLs (user hits GHES for OAuth redirect) from server-side URLs (tunnel-proxied for token exchange) correctly handles private VPC topology.
  3. Thorough Terraform wiring — All worker types plus the Cloudflare web worker get the new variables. The web_app_custom_url override is a nice touch.

See inline comments for specific feedback.

: baseUrls;
return createSourceControlProvider({
provider,
github: {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Automated Review] Suggestion: Consolidate tunnel URL rewriting logic.

This GHES_TUNNEL_URL/api/v3 rewriting pattern is duplicated here and in getGitHubAppConfig() (github-app.ts). Both independently resolve the same tunnel-aware API base.

This is fragile — if the tunnel URL format changes, both sites need updating. Since getGitHubAppConfig() already bakes apiBase into the config (including tunnel logic), this second derivation here is redundant for the appConfig path.

Consider either:

  1. Having resolveGitHubUrls() accept an optional tunnelUrl parameter so there's a single derivation site, or
  2. Extracting a small helper like resolveGitHubApiBase(hostname?, tunnelUrl?) that both sites call.

const urls = resolveGitHubUrls(env.GITHUB_HOSTNAME);
// When GHES is behind a private VPC, use the tunnel proxy for API calls
const apiBase = env.GHES_TUNNEL_URL
? `${env.GHES_TUNNEL_URL.replace(/\/+$/, "")}/api/v3`
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Automated Review] Suggestion: Guard tunnel rewrite with isEnterprise.

If GHES_TUNNEL_URL is set but GITHUB_HOSTNAME is absent (or "github.com"), this code still appends /api/v3 to the tunnel URL. That would be incorrect for github.com, where the API lives at api.github.com, not github.com/api/v3.

This is likely a misconfiguration scenario, but a defensive guard would prevent subtle breakage:

const apiBase = urls.isEnterprise && env.GHES_TUNNEL_URL
  ? `${env.GHES_TUNNEL_URL.replace(/\/+$/, "")}/api/v3`
  : urls.apiBase;

Also see comment on shared.ts — this same rewriting logic is duplicated there.

noreplyDomain: isEnterprise ? `users.noreply.${host}` : "users.noreply.github.com",
isEnterprise,
};
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Automated Review] Suggestion: Add unit tests for resolveGitHubUrls().

This function is the foundation of the entire GHES feature, but it has no dedicated unit tests. Given the branching logic (enterprise vs. github.com, noreply domain format, API base derivation), a small test file would be valuable. Suggested test cases:

  • undefined / "" / "github.com" → standard github.com URLs
  • "github.example.com" → GHES URLs with /api/v3, isEnterprise: true
  • "GITHUB.EXAMPLE.COM/" → case normalization + trailing slash stripping
  • Verify all returned fields (apiBase, webBase, gitHost, noreplyDomain, isEnterprise)


/** Resolve the GitHub API base URL from a hostname (supports GHES). */
export function resolveApiBase(hostname?: string): string {
const host = (hostname || "github.com").toLowerCase().replace(/\/+$/, "");
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Automated Review] Nit: Use resolveApiBase() as fallback in private functions.

getInstallationToken() (line 79) and checkSenderPermission() (line 119) use apiBase ?? "https://api.github.com" as their fallback instead of calling the resolveApiBase() function defined right here. If a future caller omits apiBase, the fallback silently targets github.com regardless of GITHUB_HOSTNAME.

Suggested:

const base = apiBase ?? resolveApiBase();

Note: resolveApiBase() doesn't read the env in the github-bot (unlike the Python version), so this would still default to github.com — but it centralizes the logic.

if (!login) return undefined;
if (provider === "github") return `https://github.com/${login}.png`;
if (provider === "github") {
const host = (githubHostname || "github.com").toLowerCase().replace(/\/+$/, "");
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Automated Review] Nit: Reuse resolveGitHubUrls() instead of inline normalization.

The hostname normalization logic here ((githubHostname || "github.com").toLowerCase().replace(...)) duplicates what resolveGitHubUrls() already does. Using:

const { hostname } = resolveGitHubUrls(githubHostname);
return `https://${hostname}/${login}.png`;

would be more consistent with the rest of this PR's approach.

@ColeMurray
Copy link
Copy Markdown
Owner

[Automated Review] Architectural Assessment

The inline comments addressed localized code quality. This follow-up evaluates whether the PR's overall design is the right approach.


1. resolveGitHubUrls() is in the wrong package

This is the central issue. The function lives in packages/control-plane/src/github-urls.ts, but 5 packages need the same logic:

Package What it does instead
control-plane Uses resolveGitHubUrls() (the canonical one)
github-bot Rolls its own resolveApiBase() — same logic, different interface
linear-bot Inlines (env.GITHUB_HOSTNAME || "github.com").toLowerCase().replace(...)
web Inlines the same hostname normalization + ternary for API base
modal-infra (Python) Has its own resolve_api_base() (Python can't share TS — expected)

The repo already has @open-inspect/shared specifically for cross-package types and utilities — every TS package depends on it. resolveGitHubUrls() belongs there. Instead, the PR creates 4 independent TypeScript implementations of "hostname → GitHub URLs."

2. Hostname normalization is copy-pasted 7 times

The exact pattern (hostname || "github.com").toLowerCase().replace(/\/+$/, "") (or the Python equivalent) appears in 7 locations. Four of those are TypeScript packages sharing a common dependency graph designed for exactly this kind of reuse:

  • control-plane/src/github-urls.ts
  • control-plane/src/session/participant-service.ts
  • github-bot/src/github-auth.ts
  • linear-bot/src/webhook-handler.ts
  • modal-infra/src/auth/github_app.py
  • modal-infra/src/sandbox/bridge.py
  • modal-infra/src/scheduler/image_builder.py

3. Tunnel URL rewriting logic is triplicated

The GHES_TUNNEL_URLtunnelUrl + "/api/v3" rewrite appears in 3 places:

  • github-app.ts:644-645
  • routes/shared.ts:74-75
  • web/src/lib/auth.ts:41-42

If the path convention ever changes, three files across two packages need updating. This should be a single function call — resolveGitHubUrls() could accept an optional tunnelUrl parameter to centralize this.

4. No hostname input validation

None of the 7 normalization sites validate that the input is actually a hostname. If someone configures GITHUB_HOSTNAME=https://github.example.com (with protocol — a plausible mistake), the result is https://https://github.example.com/api/v3. The function should strip a protocol prefix or throw a clear error.

5. GITHUB_API_BASE constant is dead code

The PR marks it @deprecated but no consumer references it in the PR branch. Dead exports should be removed, not annotated.

6. Missing tests

resolveGitHubUrls() is the single most important function in this PR — every API call, OAuth flow, git push, and avatar URL depends on it — and it has zero tests. The only test changes add apiBase: "https://api.github.com" to existing assertions. No test verifies the GHES path actually works.

7. getAvatarUrl() ignores the PR's own abstraction

participant-service.ts:67 inlines hostname normalization instead of calling resolveGitHubUrls(), which is defined in the same package. If the author's own code doesn't use the abstraction, the abstraction's coverage is incomplete.


Recommended changes

  1. Move resolveGitHubUrls() to @open-inspect/shared, with an optional tunnelUrl parameter to handle tunnel rewriting in one place
  2. Delete resolveApiBase() from github-bot — import from shared
  3. Replace inline hostname normalization in linear-bot, web, and participant-service with the shared function
  4. Add hostname validation (strip or reject protocol prefixes)
  5. Add unit tests for the core function (GHES vs github.com, edge cases, tunnel URL)
  6. Remove the dead GITHUB_API_BASE export

This would consolidate 4 independent TS implementations into 1 shared function (Python duplication is expected and acceptable).

Verdict

The feature objective is sound, the browser/server URL split for web auth is well-thought-out, and the Terraform wiring is thorough. But the core abstraction lives in the wrong package, and the resulting duplication across 4 TS packages undermines the value of having @open-inspect/shared. Request changes before merge.

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.

feat: GitHub Enterprise Server (GHES) support

2 participants