Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ DEBUG_COLORS=0 DEBUG=* ./awmg --config config.toml
## Environment Variables

- `GITHUB_PERSONAL_ACCESS_TOKEN` - GitHub auth
- `GITHUB_API_URL` - Explicit GitHub API endpoint (e.g., `https://copilot-api.mycompany.ghe.com`); used by proxy to set upstream target
- `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`
- `DOCKER_API_VERSION` - Set by querying Docker daemon's current API version; falls back to `1.44` for all architectures if detection fails
- `DEBUG` - Enable debug logging (e.g., `DEBUG=*`, `DEBUG=server:*,launcher:*`)
- `DEBUG_COLORS` - Control colored output (0 to disable, auto-disabled when piping)
Expand Down
9 changes: 8 additions & 1 deletion guards/github-guard/rust-guard/src/labels/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,14 +513,15 @@ pub fn extract_repo_info_from_search_query(query: &str) -> (String, String, Stri
(String::new(), String::new(), String::new())
}

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

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

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

None
}

Expand Down
41 changes: 41 additions & 0 deletions guards/github-guard/rust-guard/src/labels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3848,4 +3848,45 @@ mod tests {
"item path must use /items/ prefix for REST format"
);
}

#[test]
fn test_extract_repo_from_github_url_ghec() {
// GHEC tenant URLs (api.<tenant>.ghe.com)
assert_eq!(
helpers::extract_repo_from_github_url("https://api.mycompany.ghe.com/repos/owner/repo/issues"),
Some("owner/repo".to_string())
);
assert_eq!(
helpers::extract_repo_from_github_url("https://api.mycompany.ghe.com/repos/owner/repo"),
Some("owner/repo".to_string())
);
}

#[test]
fn test_extract_repo_from_github_url_ghes() {
// GHES URLs (host/api/v3/repos/...)
assert_eq!(
helpers::extract_repo_from_github_url("https://github.example.com/api/v3/repos/owner/repo/pulls"),
Some("owner/repo".to_string())
);
}

#[test]
fn test_extract_repo_from_github_url_standard() {
// Standard github.com API URLs
assert_eq!(
helpers::extract_repo_from_github_url("https://api.github.com/repos/octocat/Hello-World/issues"),
Some("octocat/Hello-World".to_string())
);
// Standard github.com HTML URLs
assert_eq!(
helpers::extract_repo_from_github_url("https://github.com/octocat/Hello-World"),
Some("octocat/Hello-World".to_string())
);
// No match
assert_eq!(
helpers::extract_repo_from_github_url("https://example.com/no-repos-path"),
None
);
}
}
14 changes: 12 additions & 2 deletions internal/cmd/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Local usage:
cmd.Flags().StringVarP(&proxyListen, "listen", "l", "127.0.0.1:8080", "Proxy listen address")
cmd.Flags().StringVar(&proxyLogDir, "log-dir", getDefaultLogDir(), "Log file directory")
cmd.Flags().StringVar(&proxyDIFCMode, "guards-mode", "filter", "DIFC enforcement mode: strict, filter, propagate")
cmd.Flags().StringVar(&proxyAPIURL, "github-api-url", proxy.DefaultGitHubAPIBase, "Upstream GitHub API URL")
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)")
cmd.Flags().BoolVar(&proxyTLS, "tls", false, "Enable HTTPS with auto-generated self-signed certificates")
cmd.Flags().StringVar(&proxyTLSDir, "tls-dir", "", "Directory for TLS certificates (default: <log-dir>/proxy-tls)")
cmd.Flags().StringSliceVar(&proxyTrustedBots, "trusted-bots", nil, "Additional trusted bot usernames (comma-separated, extends built-in list)")
Expand Down Expand Up @@ -144,12 +144,22 @@ func runProxy(cmd *cobra.Command, args []string) error {
logger.LogInfo("startup", "No fallback token — proxy will forward client Authorization headers")
}

// Resolve GitHub API URL: flag → env vars → default
apiURL := proxyAPIURL
if apiURL == "" {
apiURL = proxy.DeriveGitHubAPIURL()
}
if apiURL == "" {
apiURL = proxy.DefaultGitHubAPIBase
}
logger.LogInfo("startup", "Upstream GitHub API URL: %s", apiURL)

// Create the proxy server
proxySrv, err := proxy.New(ctx, proxy.Config{
WasmPath: proxyGuardWasm,
Policy: proxyPolicy,
GitHubToken: token,
GitHubAPIURL: proxyAPIURL,
GitHubAPIURL: apiURL,
DIFCMode: proxyDIFCMode,
TrustedBots: proxyTrustedBots,
})
Expand Down
54 changes: 54 additions & 0 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"

Expand All @@ -31,6 +33,58 @@ const (
ghHostPathPrefix = "/api/v3"
)

// DeriveGitHubAPIURL resolves the upstream GitHub API URL from environment
// variables. Priority order:
// 1. GITHUB_API_URL — explicit API endpoint (e.g. https://copilot-api.mycompany.ghe.com)
// 2. GITHUB_SERVER_URL — auto-derive API endpoint from server URL:
// - https://mycompany.ghe.com → https://copilot-api.mycompany.ghe.com
// - https://github.mycompany.com → https://github.mycompany.com/api/v3
// - https://github.com → https://api.github.com
// 3. Returns empty string if no env vars are set (caller uses DefaultGitHubAPIBase)
func DeriveGitHubAPIURL() string {
if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" {
logProxy.Printf("GitHub API URL from GITHUB_API_URL: %s", apiURL)
return apiURL
}
if serverURL := os.Getenv("GITHUB_SERVER_URL"); serverURL != "" {
derived := deriveAPIFromServerURL(serverURL)
if derived != "" {
logProxy.Printf("GitHub API URL derived from GITHUB_SERVER_URL=%s: %s", serverURL, derived)
return derived
}
}
return ""
}

// deriveAPIFromServerURL converts a GITHUB_SERVER_URL to the corresponding API endpoint.
// GHEC tenants (*.ghe.com): https://tenant.ghe.com → https://copilot-api.tenant.ghe.com
// GitHub.com: https://github.com → https://api.github.com
// GHES (all others): https://github.example.com → https://github.example.com/api/v3
func deriveAPIFromServerURL(serverURL string) string {
parsed, err := url.Parse(strings.TrimRight(serverURL, "/"))
if err != nil || parsed.Host == "" {
return ""
}

// Use Hostname() (not Host) so that an optional port does not interfere
// with the suffix / equality checks below.
hostname := strings.ToLower(parsed.Hostname())

switch {
case hostname == "github.com" || hostname == "www.github.com":
return DefaultGitHubAPIBase
case strings.HasSuffix(hostname, ".ghe.com"):
// GHEC tenant: copilot-api.<subdomain>.ghe.com (re-add port when present)
if port := parsed.Port(); port != "" {
return fmt.Sprintf("%s://copilot-api.%s:%s", parsed.Scheme, hostname, port)
}
return fmt.Sprintf("%s://copilot-api.%s", parsed.Scheme, hostname)
default:
// GHES: <host>/api/v3 (parsed.Host retains the port, if any)
return fmt.Sprintf("%s://%s/api/v3", parsed.Scheme, parsed.Host)
}
Comment on lines +59 to +85

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

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

For the GHES case this derives the upstream base as /api/v3. Elsewhere the proxy forwards GraphQL requests to a hard-coded "/graphql" path; with an /api/v3 base this becomes /api/v3/graphql, but GHES GraphQL is typically served under /api/graphql (and the proxy already recognizes "/api/graphql" as a GraphQL path). To avoid GHES GraphQL requests breaking when using the auto-derived URL, consider either (a) keeping the base at the host root and adding /api/v3 only for REST requests, or (b) making the forwarded GraphQL path configurable/derived separately (e.g., /graphql for github.com/GHEC vs /api/graphql for GHES).

Copilot uses AI. Check for mistakes.
}

// Server is a filtering HTTP forward proxy for the GitHub REST/GraphQL API.
// It loads the same WASM guard used by the MCP gateway and runs the 6-phase
// DIFC pipeline on every proxied response.
Expand Down
120 changes: 120 additions & 0 deletions internal/proxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/github/gh-aw-mcpg/internal/difc"
Expand Down Expand Up @@ -844,3 +845,122 @@ func TestUnwrapSingleObject(t *testing.T) {
})
}
}

func TestDeriveAPIFromServerURL(t *testing.T) {
tests := []struct {
name string
serverURL string
expected string
}{
{
name: "github.com returns default",
serverURL: "https://github.com",
expected: DefaultGitHubAPIBase,
},
{
name: "github.com with trailing slash",
serverURL: "https://github.com/",
expected: DefaultGitHubAPIBase,
},
{
name: "GHEC tenant derives copilot-api subdomain",
serverURL: "https://mycompany.ghe.com",
expected: "https://copilot-api.mycompany.ghe.com",
},
{
name: "GHEC tenant with trailing slash",
serverURL: "https://mycompany.ghe.com/",
expected: "https://copilot-api.mycompany.ghe.com",
},
{
name: "GHES uses /api/v3 path",
serverURL: "https://github.mycompany.com",
expected: "https://github.mycompany.com/api/v3",
},
{
name: "GHEC tenant with port",
serverURL: "https://mycompany.ghe.com:8443",
expected: "https://copilot-api.mycompany.ghe.com:8443",
},
{
name: "GHES with port",
serverURL: "https://github.example.com:8443",
expected: "https://github.example.com:8443/api/v3",
},
{
name: "empty string",
serverURL: "",
expected: "",
},
{
name: "invalid URL",
serverURL: "not-a-url",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := deriveAPIFromServerURL(tt.serverURL)
assert.Equal(t, tt.expected, result)
})
}
}

func TestDeriveGitHubAPIURL(t *testing.T) {
tests := []struct {
name string
envVars map[string]string
expected string
}{
{
name: "no env vars returns empty",
envVars: map[string]string{},
expected: "",
},
{
name: "GITHUB_API_URL takes priority",
envVars: map[string]string{"GITHUB_API_URL": "https://api.custom.ghe.com", "GITHUB_SERVER_URL": "https://other.ghe.com"},
expected: "https://api.custom.ghe.com",
},
{
name: "GITHUB_SERVER_URL auto-derives GHEC",
envVars: map[string]string{"GITHUB_SERVER_URL": "https://mycompany.ghe.com"},
expected: "https://copilot-api.mycompany.ghe.com",
},
{
name: "GITHUB_SERVER_URL auto-derives GHES",
envVars: map[string]string{"GITHUB_SERVER_URL": "https://github.example.com"},
expected: "https://github.example.com/api/v3",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save and clear relevant env vars
savedAPI, hadAPI := os.LookupEnv("GITHUB_API_URL")
savedServer, hadServer := os.LookupEnv("GITHUB_SERVER_URL")
os.Unsetenv("GITHUB_API_URL")
os.Unsetenv("GITHUB_SERVER_URL")
defer func() {
if hadAPI {
_ = os.Setenv("GITHUB_API_URL", savedAPI)
} else {
_ = os.Unsetenv("GITHUB_API_URL")
}
if hadServer {
_ = os.Setenv("GITHUB_SERVER_URL", savedServer)
} else {
_ = os.Unsetenv("GITHUB_SERVER_URL")
}
}()

for k, v := range tt.envVars {
os.Setenv(k, v)
}

result := DeriveGitHubAPIURL()
assert.Equal(t, tt.expected, result)
})
}
}
Loading