From 03c1b9d10500b7789e69b25ec03687c792c08f65 Mon Sep 17 00:00:00 2001 From: Matthew Ryan Dillon Date: Sat, 3 May 2025 16:15:49 -0400 Subject: [PATCH] bootstrap project --- .gitignore | 28 +++++ LICENSE | 24 +++++ README.md | 22 ++++ go.mod | 3 + main.go | 171 +++++++++++++++++++++++++++++++ main_test.go | 128 +++++++++++++++++++++++ st.thermokar.copilot-proxy.plist | 25 +++++ 7 files changed, 401 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go create mode 100644 st.thermokar.copilot-proxy.plist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e4c92d --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +copilot-proxy +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# End of https://www.toptal.com/developers/gitignore/api/go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c32dd18 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7c2725 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# github copilot proxy server + +a proxy server for github copilot, handling token management and request forwarding + +## requirements +- go 1.20 or later +- a valid github pat set in the `GITHUB_AUTH_TOKEN` environment variable + +## usage +```bash +go build -o copilot-proxy +GITHUB_AUTH_TOKEN=... ./copilot-proxy +``` + +## launchctl + +```bash +cp st.thermokar.copilot-proxy.plist ~/Library/LaunchAgents/st.thermokar.copilot-proxy.plist +# edit as necessary +# log out and then back in +launchctl list | rg st.thermokar.copilot-proxy +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bea190b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.thermokar.st/thermokarst/copilot-proxy + +go 1.20 diff --git a/main.go b/main.go new file mode 100644 index 0000000..0743156 --- /dev/null +++ b/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + "time" +) + +const ( + tokenExchangeURL = "https://api.github.com/copilot_internal/v2/token" + defaultOpenAIEndpoint = "https://api.githubcopilot.com" + userAgent = "curl/8.7.1" +) + +type TokenResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` +} + +type ProxyServer struct { + authToken string + copilotToken string + tokenExpiry time.Time + proxyEndpoint string + mutex sync.RWMutex + client *http.Client +} + +func NewProxyServer(authToken string) *ProxyServer { + return &ProxyServer{ + authToken: authToken, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (p *ProxyServer) refreshTokenIfNeeded() error { + p.mutex.RLock() + tokenValid := p.copilotToken != "" && time.Now().Before(p.tokenExpiry) + p.mutex.RUnlock() + + if tokenValid { + return nil + } + + p.mutex.Lock() + defer p.mutex.Unlock() + + if p.copilotToken != "" && time.Now().Before(p.tokenExpiry) { + return nil + } + + req, err := http.NewRequest("GET", tokenExchangeURL, nil) + if err != nil { + return fmt.Errorf("failed to create token exchange request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("token %s", p.authToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("token exchange request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp TokenResponse + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read token response body: %w", err) + } + + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to parse token response: %w", err) + } + + p.copilotToken = tokenResp.Token + p.tokenExpiry = time.Unix(tokenResp.ExpiresAt, 0) + p.proxyEndpoint = defaultOpenAIEndpoint + + log.Printf("token refreshed, valid until: %v", p.tokenExpiry) + return nil +} + +func (p *ProxyServer) proxyHandler(w http.ResponseWriter, r *http.Request) { + if err := p.refreshTokenIfNeeded(); err != nil { + log.Printf("error refreshing token: %v", err) + http.Error(w, "failed to authenticate with copilot", http.StatusInternalServerError) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error reading request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + targetURL := p.proxyEndpoint + r.URL.Path + if p.proxyEndpoint == "" { + log.Printf("warning: proxy endpoint is empty, using default openai endpoint") + targetURL = defaultOpenAIEndpoint + r.URL.Path + } + + if r.URL.RawQuery != "" { + targetURL += "?" + r.URL.RawQuery + } + + log.Printf("proxying request to: %s", targetURL) + proxyReq, err := http.NewRequest(r.Method, targetURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "error creating proxy request", http.StatusInternalServerError) + return + } + + proxyReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.copilotToken)) + proxyReq.Header.Set("Copilot-Integration-Id", "vscode-chat") + proxyReq.Header.Set("Editor-Version", "Neovim/0.6.1") + + resp, err := p.client.Do(proxyReq) + if err != nil { + log.Printf("error forwarding request to copilot: %v", err) + http.Error(w, "error forwarding request to copilot", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + w.WriteHeader(resp.StatusCode) + + if _, err := io.Copy(w, resp.Body); err != nil { + log.Printf("error copying response body: %v", err) + } +} + +func main() { + authToken := os.Getenv("GITHUB_AUTH_TOKEN") + if authToken == "" { + log.Fatal("GITHUB_AUTH_TOKEN environment variable is required") + } + + proxy := NewProxyServer(authToken) + + http.HandleFunc("/", proxy.proxyHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("starting proxy server on port %s", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("failed to start server: %v", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..54b6f86 --- /dev/null +++ b/main_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +var mockTokenResponse = TokenResponse{ + Token: "mock-token", + ExpiresAt: time.Now().Add(1 * time.Hour).Unix(), +} + +func TestNewProxyServer(t *testing.T) { + authToken := "test-auth-token" + proxy := NewProxyServer(authToken) + + if proxy.authToken != authToken { + t.Errorf("Expected authToken to be %s, got %s", authToken, proxy.authToken) + } + if proxy.client == nil { + t.Error("Expected http.Client to be initialized") + } +} + +func TestRefreshTokenIfNeeded_ValidToken(t *testing.T) { + proxy := NewProxyServer("test-auth-token") + proxy.proxyEndpoint = "http://mock-endpoint.com" + proxy.copilotToken = mockTokenResponse.Token + + proxy.client = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) *http.Response { + expectedURL := proxy.proxyEndpoint + actualURL := req.URL.String() + + // Normalize both URLs by trimming trailing slashes + expectedURL = strings.TrimRight(expectedURL, "/") + actualURL = strings.TrimRight(actualURL, "/") + + if actualURL != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, actualURL) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("mock-response")), + Header: make(http.Header), + } + }), + } + proxy.tokenExpiry = time.Now().Add(1 * time.Hour) + + err := proxy.refreshTokenIfNeeded() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestRefreshTokenIfNeeded_ExpiredToken(t *testing.T) { + proxy := NewProxyServer("test-auth-token") + proxy.copilotToken = "" + proxy.tokenExpiry = time.Now().Add(-1 * time.Hour) + + proxy.client = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) *http.Response { + if req.URL.String() != tokenExchangeURL { + t.Errorf("Expected URL %s, got %s", tokenExchangeURL, req.URL.String()) + } + respBody, _ := json.Marshal(mockTokenResponse) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(respBody)), + Header: make(http.Header), + } + }), + } + + err := proxy.refreshTokenIfNeeded() + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if proxy.copilotToken != mockTokenResponse.Token { + t.Errorf("Expected token to be %s, got %s", mockTokenResponse.Token, proxy.copilotToken) + } +} + +func TestProxyHandler(t *testing.T) { + proxy := NewProxyServer("test-auth-token") + proxy.proxyEndpoint = "http://mock-endpoint.com" + proxy.copilotToken = mockTokenResponse.Token + proxy.tokenExpiry = time.Now().Add(1 * time.Hour) + + proxy.client = &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) *http.Response { + expectedURL := strings.TrimRight(proxy.proxyEndpoint, "/") + actualURL := strings.TrimRight(req.URL.String(), "/") + + if actualURL != expectedURL { + t.Errorf("Expected URL %s, got %s", expectedURL, actualURL) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("mock-response")), + Header: make(http.Header), + } + }), + } + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test-body")) + w := httptest.NewRecorder() + + proxy.proxyHandler(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } +} + +type roundTripperFunc func(req *http.Request) *http.Response + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} diff --git a/st.thermokar.copilot-proxy.plist b/st.thermokar.copilot-proxy.plist new file mode 100644 index 0000000..4653f7c --- /dev/null +++ b/st.thermokar.copilot-proxy.plist @@ -0,0 +1,25 @@ + + + + + Label + st.thermokar.copilot-proxy + ProgramArguments + + copilot-proxy + + EnvironmentVariables + + GITHUB_AUTH_TOKEN + your-github-auth-token + + RunAtLoad + + KeepAlive + + StandardOutPath + /log/copilot-proxy.log + StandardErrorPath + /log/copilot-proxy.err + +