Skip to content

Commit 814e524

Browse files
authored
feat: GHEC tenant support for proxy and guard URL parsing (#2481)
## Summary Adds auto-derivation of GitHub API URL from environment variables for GHEC/GHES tenants, and fixes the Rust guard URL parser to handle non-github.com URLs. ## Changes ### Proxy: Environment-based API URL resolution The proxy `--github-api-url` flag now auto-resolves from environment when not explicitly set: 1. `GITHUB_API_URL` — explicit API endpoint 2. `GITHUB_SERVER_URL` — auto-derived: - `*.ghe.com` → `copilot-api.*.ghe.com` (GHEC) - other hosts → `<host>/api/v3` (GHES) - `github.com` → `api.github.com` 3. Default: `https://api.github.com` This matches the derivation pattern used by GitHub Agentic Workflows' `deriveCopilotApiTarget()`. ### Rust Guard: Generic URL parsing `extract_repo_from_github_url()` now handles GHEC/GHES URLs by looking for `/repos/<owner>/<repo>` generically in the URL path, instead of only matching `api.github.com` prefixes. ### Tests - 10 Go unit tests for `DeriveGitHubAPIURL()` and `deriveAPIFromServerURL()` - 3 Rust unit tests for GHEC/GHES/standard URL extraction <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.
2 parents 80771b0 + 909ff10 commit 814e524

6 files changed

Lines changed: 237 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@ DEBUG_COLORS=0 DEBUG=* ./awmg --config config.toml
367367
## Environment Variables
368368

369369
- `GITHUB_PERSONAL_ACCESS_TOKEN` - GitHub auth
370+
- `GITHUB_API_URL` - Explicit GitHub API endpoint (e.g., `https://copilot-api.mycompany.ghe.com`); used by proxy to set upstream target
371+
- `GITHUB_SERVER_URL` - GitHub server URL; proxy auto-derives API endpoint: `*.ghe.com``copilot-api.*.ghe.com`, GHES → `<host>/api/v3`, `github.com``api.github.com`
370372
- `DOCKER_API_VERSION` - Set by querying Docker daemon's current API version; falls back to `1.44` for all architectures if detection fails
371373
- `DEBUG` - Enable debug logging (e.g., `DEBUG=*`, `DEBUG=server:*,launcher:*`)
372374
- `DEBUG_COLORS` - Control colored output (0 to disable, auto-disabled when piping)

guards/github-guard/rust-guard/src/labels/helpers.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,14 +513,15 @@ pub fn extract_repo_info_from_search_query(query: &str) -> (String, String, Stri
513513
(String::new(), String::new(), String::new())
514514
}
515515

516-
fn extract_repo_from_github_url(url: &str) -> Option<String> {
516+
pub(crate) fn extract_repo_from_github_url(url: &str) -> Option<String> {
517517
let parse_owner_repo = |path: &str| {
518518
let mut parts = path.split('/').filter(|segment| !segment.is_empty());
519519
let owner = parts.next()?;
520520
let repo = parts.next()?;
521521
Some(format!("{}/{}", owner, repo))
522522
};
523523

524+
// Fast path for well-known github.com URLs
524525
if let Some(path) = url
525526
.strip_prefix("https://api.github.com/repos/")
526527
.or_else(|| url.strip_prefix("http://api.github.com/repos/"))
@@ -530,6 +531,12 @@ fn extract_repo_from_github_url(url: &str) -> Option<String> {
530531
return parse_owner_repo(path);
531532
}
532533

534+
// Generic path: handle GHEC (api.*.ghe.com) and GHES (*/api/v3/repos/*)
535+
// by looking for /repos/<owner>/<repo> in the URL path.
536+
if let Some(pos) = url.find("/repos/") {
537+
return parse_owner_repo(&url[pos + 7..]);
538+
}
539+
533540
None
534541
}
535542

guards/github-guard/rust-guard/src/labels/mod.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3848,4 +3848,45 @@ mod tests {
38483848
"item path must use /items/ prefix for REST format"
38493849
);
38503850
}
3851+
3852+
#[test]
3853+
fn test_extract_repo_from_github_url_ghec() {
3854+
// GHEC tenant URLs (api.<tenant>.ghe.com)
3855+
assert_eq!(
3856+
helpers::extract_repo_from_github_url("https://api.mycompany.ghe.com/repos/owner/repo/issues"),
3857+
Some("owner/repo".to_string())
3858+
);
3859+
assert_eq!(
3860+
helpers::extract_repo_from_github_url("https://api.mycompany.ghe.com/repos/owner/repo"),
3861+
Some("owner/repo".to_string())
3862+
);
3863+
}
3864+
3865+
#[test]
3866+
fn test_extract_repo_from_github_url_ghes() {
3867+
// GHES URLs (host/api/v3/repos/...)
3868+
assert_eq!(
3869+
helpers::extract_repo_from_github_url("https://github.example.com/api/v3/repos/owner/repo/pulls"),
3870+
Some("owner/repo".to_string())
3871+
);
3872+
}
3873+
3874+
#[test]
3875+
fn test_extract_repo_from_github_url_standard() {
3876+
// Standard github.com API URLs
3877+
assert_eq!(
3878+
helpers::extract_repo_from_github_url("https://api.github.com/repos/octocat/Hello-World/issues"),
3879+
Some("octocat/Hello-World".to_string())
3880+
);
3881+
// Standard github.com HTML URLs
3882+
assert_eq!(
3883+
helpers::extract_repo_from_github_url("https://github.com/octocat/Hello-World"),
3884+
Some("octocat/Hello-World".to_string())
3885+
);
3886+
// No match
3887+
assert_eq!(
3888+
helpers::extract_repo_from_github_url("https://example.com/no-repos-path"),
3889+
None
3890+
);
3891+
}
38513892
}

internal/cmd/proxy.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Local usage:
9292
cmd.Flags().StringVarP(&proxyListen, "listen", "l", "127.0.0.1:8080", "Proxy listen address")
9393
cmd.Flags().StringVar(&proxyLogDir, "log-dir", getDefaultLogDir(), "Log file directory")
9494
cmd.Flags().StringVar(&proxyDIFCMode, "guards-mode", "filter", "DIFC enforcement mode: strict, filter, propagate")
95-
cmd.Flags().StringVar(&proxyAPIURL, "github-api-url", proxy.DefaultGitHubAPIBase, "Upstream GitHub API URL")
95+
cmd.Flags().StringVar(&proxyAPIURL, "github-api-url", "", "Upstream GitHub API URL (default: auto-derived from GITHUB_API_URL or GITHUB_SERVER_URL, falls back to https://api.github.com)")
9696
cmd.Flags().BoolVar(&proxyTLS, "tls", false, "Enable HTTPS with auto-generated self-signed certificates")
9797
cmd.Flags().StringVar(&proxyTLSDir, "tls-dir", "", "Directory for TLS certificates (default: <log-dir>/proxy-tls)")
9898
cmd.Flags().StringSliceVar(&proxyTrustedBots, "trusted-bots", nil, "Additional trusted bot usernames (comma-separated, extends built-in list)")
@@ -144,12 +144,22 @@ func runProxy(cmd *cobra.Command, args []string) error {
144144
logger.LogInfo("startup", "No fallback token — proxy will forward client Authorization headers")
145145
}
146146

147+
// Resolve GitHub API URL: flag → env vars → default
148+
apiURL := proxyAPIURL
149+
if apiURL == "" {
150+
apiURL = proxy.DeriveGitHubAPIURL()
151+
}
152+
if apiURL == "" {
153+
apiURL = proxy.DefaultGitHubAPIBase
154+
}
155+
logger.LogInfo("startup", "Upstream GitHub API URL: %s", apiURL)
156+
147157
// Create the proxy server
148158
proxySrv, err := proxy.New(ctx, proxy.Config{
149159
WasmPath: proxyGuardWasm,
150160
Policy: proxyPolicy,
151161
GitHubToken: token,
152-
GitHubAPIURL: proxyAPIURL,
162+
GitHubAPIURL: apiURL,
153163
DIFCMode: proxyDIFCMode,
154164
TrustedBots: proxyTrustedBots,
155165
})

internal/proxy/proxy.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"io"
1313
"log"
1414
"net/http"
15+
"net/url"
16+
"os"
1517
"strings"
1618
"time"
1719

@@ -31,6 +33,58 @@ const (
3133
ghHostPathPrefix = "/api/v3"
3234
)
3335

36+
// DeriveGitHubAPIURL resolves the upstream GitHub API URL from environment
37+
// variables. Priority order:
38+
// 1. GITHUB_API_URL — explicit API endpoint (e.g. https://copilot-api.mycompany.ghe.com)
39+
// 2. GITHUB_SERVER_URL — auto-derive API endpoint from server URL:
40+
// - https://mycompany.ghe.com → https://copilot-api.mycompany.ghe.com
41+
// - https://github.mycompany.com → https://github.mycompany.com/api/v3
42+
// - https://github.com → https://api.github.com
43+
// 3. Returns empty string if no env vars are set (caller uses DefaultGitHubAPIBase)
44+
func DeriveGitHubAPIURL() string {
45+
if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" {
46+
logProxy.Printf("GitHub API URL from GITHUB_API_URL: %s", apiURL)
47+
return apiURL
48+
}
49+
if serverURL := os.Getenv("GITHUB_SERVER_URL"); serverURL != "" {
50+
derived := deriveAPIFromServerURL(serverURL)
51+
if derived != "" {
52+
logProxy.Printf("GitHub API URL derived from GITHUB_SERVER_URL=%s: %s", serverURL, derived)
53+
return derived
54+
}
55+
}
56+
return ""
57+
}
58+
59+
// deriveAPIFromServerURL converts a GITHUB_SERVER_URL to the corresponding API endpoint.
60+
// GHEC tenants (*.ghe.com): https://tenant.ghe.com → https://copilot-api.tenant.ghe.com
61+
// GitHub.com: https://github.com → https://api.github.com
62+
// GHES (all others): https://github.example.com → https://github.example.com/api/v3
63+
func deriveAPIFromServerURL(serverURL string) string {
64+
parsed, err := url.Parse(strings.TrimRight(serverURL, "/"))
65+
if err != nil || parsed.Host == "" {
66+
return ""
67+
}
68+
69+
// Use Hostname() (not Host) so that an optional port does not interfere
70+
// with the suffix / equality checks below.
71+
hostname := strings.ToLower(parsed.Hostname())
72+
73+
switch {
74+
case hostname == "github.com" || hostname == "www.github.com":
75+
return DefaultGitHubAPIBase
76+
case strings.HasSuffix(hostname, ".ghe.com"):
77+
// GHEC tenant: copilot-api.<subdomain>.ghe.com (re-add port when present)
78+
if port := parsed.Port(); port != "" {
79+
return fmt.Sprintf("%s://copilot-api.%s:%s", parsed.Scheme, hostname, port)
80+
}
81+
return fmt.Sprintf("%s://copilot-api.%s", parsed.Scheme, hostname)
82+
default:
83+
// GHES: <host>/api/v3 (parsed.Host retains the port, if any)
84+
return fmt.Sprintf("%s://%s/api/v3", parsed.Scheme, parsed.Host)
85+
}
86+
}
87+
3488
// Server is a filtering HTTP forward proxy for the GitHub REST/GraphQL API.
3589
// It loads the same WASM guard used by the MCP gateway and runs the 6-phase
3690
// DIFC pipeline on every proxied response.

internal/proxy/proxy_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"io"
55
"net/http"
66
"net/http/httptest"
7+
"os"
78
"testing"
89

910
"github.com/github/gh-aw-mcpg/internal/difc"
@@ -844,3 +845,122 @@ func TestUnwrapSingleObject(t *testing.T) {
844845
})
845846
}
846847
}
848+
849+
func TestDeriveAPIFromServerURL(t *testing.T) {
850+
tests := []struct {
851+
name string
852+
serverURL string
853+
expected string
854+
}{
855+
{
856+
name: "github.com returns default",
857+
serverURL: "https://github.com",
858+
expected: DefaultGitHubAPIBase,
859+
},
860+
{
861+
name: "github.com with trailing slash",
862+
serverURL: "https://github.com/",
863+
expected: DefaultGitHubAPIBase,
864+
},
865+
{
866+
name: "GHEC tenant derives copilot-api subdomain",
867+
serverURL: "https://mycompany.ghe.com",
868+
expected: "https://copilot-api.mycompany.ghe.com",
869+
},
870+
{
871+
name: "GHEC tenant with trailing slash",
872+
serverURL: "https://mycompany.ghe.com/",
873+
expected: "https://copilot-api.mycompany.ghe.com",
874+
},
875+
{
876+
name: "GHES uses /api/v3 path",
877+
serverURL: "https://github.mycompany.com",
878+
expected: "https://github.mycompany.com/api/v3",
879+
},
880+
{
881+
name: "GHEC tenant with port",
882+
serverURL: "https://mycompany.ghe.com:8443",
883+
expected: "https://copilot-api.mycompany.ghe.com:8443",
884+
},
885+
{
886+
name: "GHES with port",
887+
serverURL: "https://github.example.com:8443",
888+
expected: "https://github.example.com:8443/api/v3",
889+
},
890+
{
891+
name: "empty string",
892+
serverURL: "",
893+
expected: "",
894+
},
895+
{
896+
name: "invalid URL",
897+
serverURL: "not-a-url",
898+
expected: "",
899+
},
900+
}
901+
902+
for _, tt := range tests {
903+
t.Run(tt.name, func(t *testing.T) {
904+
result := deriveAPIFromServerURL(tt.serverURL)
905+
assert.Equal(t, tt.expected, result)
906+
})
907+
}
908+
}
909+
910+
func TestDeriveGitHubAPIURL(t *testing.T) {
911+
tests := []struct {
912+
name string
913+
envVars map[string]string
914+
expected string
915+
}{
916+
{
917+
name: "no env vars returns empty",
918+
envVars: map[string]string{},
919+
expected: "",
920+
},
921+
{
922+
name: "GITHUB_API_URL takes priority",
923+
envVars: map[string]string{"GITHUB_API_URL": "https://api.custom.ghe.com", "GITHUB_SERVER_URL": "https://other.ghe.com"},
924+
expected: "https://api.custom.ghe.com",
925+
},
926+
{
927+
name: "GITHUB_SERVER_URL auto-derives GHEC",
928+
envVars: map[string]string{"GITHUB_SERVER_URL": "https://mycompany.ghe.com"},
929+
expected: "https://copilot-api.mycompany.ghe.com",
930+
},
931+
{
932+
name: "GITHUB_SERVER_URL auto-derives GHES",
933+
envVars: map[string]string{"GITHUB_SERVER_URL": "https://github.example.com"},
934+
expected: "https://github.example.com/api/v3",
935+
},
936+
}
937+
938+
for _, tt := range tests {
939+
t.Run(tt.name, func(t *testing.T) {
940+
// Save and clear relevant env vars
941+
savedAPI, hadAPI := os.LookupEnv("GITHUB_API_URL")
942+
savedServer, hadServer := os.LookupEnv("GITHUB_SERVER_URL")
943+
os.Unsetenv("GITHUB_API_URL")
944+
os.Unsetenv("GITHUB_SERVER_URL")
945+
defer func() {
946+
if hadAPI {
947+
_ = os.Setenv("GITHUB_API_URL", savedAPI)
948+
} else {
949+
_ = os.Unsetenv("GITHUB_API_URL")
950+
}
951+
if hadServer {
952+
_ = os.Setenv("GITHUB_SERVER_URL", savedServer)
953+
} else {
954+
_ = os.Unsetenv("GITHUB_SERVER_URL")
955+
}
956+
}()
957+
958+
for k, v := range tt.envVars {
959+
os.Setenv(k, v)
960+
}
961+
962+
result := DeriveGitHubAPIURL()
963+
assert.Equal(t, tt.expected, result)
964+
})
965+
}
966+
}

0 commit comments

Comments
 (0)