package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "unicode/utf8" "github.com/kandev/kandev/internal/githubauth" ) type githubBrokerResolveRequest struct { Lease string `json:"lease"` TaskID string `json:"task_id"` SessionID string `json:"session_id"` RepositoryID string `json:"repository_id"` Owner string `json:"owner"` Repo string `json:"repo"` Host string `json:"host"` Path string `json:"path,omitempty"` ProviderID string `json:"provider_id,omitempty"` ParentProviderID string `json:"parent_provider_id,omitempty"` } type githubBrokerCredential struct { Username string `json:"username"` Password string `json:"password"` } type githubCredentialBrokerClient struct { endpoint string request githubBrokerResolveRequest http *http.Client } func newGitHubCredentialBrokerClient( getenv func(string) string, httpClient *http.Client, ) (*githubCredentialBrokerClient, error) { request := githubBrokerResolveRequest{ Lease: strings.TrimSpace(getenv(githubauth.CredentialLeaseEnv)), TaskID: strings.TrimSpace(getenv(githubauth.CredentialTaskIDEnv)), SessionID: strings.TrimSpace(getenv(githubauth.CredentialSessionIDEnv)), RepositoryID: strings.TrimSpace(getenv(githubauth.CredentialRepositoryEnv)), Owner: strings.TrimSpace(getenv(githubauth.CredentialOwnerEnv)), Repo: strings.TrimSuffix(strings.TrimSpace(getenv(githubauth.CredentialRepoEnv)), ".git"), Host: strings.ToLower(strings.TrimSpace(getenv(githubauth.CredentialHostEnv))), } endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv)) for name, value := range map[string]string{ "broker URL": endpoint, "lease": request.Lease, "task": request.TaskID, "session": request.SessionID, "repository": request.RepositoryID, "owner": request.Owner, "repo": request.Repo, "host": request.Host, } { if value == "" { return nil, fmt.Errorf("GitHub credential %s is not configured", name) } } parsed, err := url.ParseRequestURI(endpoint) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, fmt.Errorf("GitHub credential broker URL is invalid") } if httpClient == nil { httpClient = &http.Client{Timeout: 30 * time.Second} } return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil } // maxBrokerErrorBodyBytes caps the broker error body appended to a resolve // failure. Broker error bodies are code/message JSON and carry no credential // (success bodies, which do carry the token, are never passed through this // path), but the cap and control-char stripping stay regardless. const maxBrokerErrorBodyBytes = 512 // formatBrokerErrorBody reads a bounded, sanitized broker error body suitable // for appending to an error message, e.g. ": {\"error\":\"...\"}" — or "" when // the body is empty after sanitizing. func formatBrokerErrorBody(body io.Reader) string { raw, _ := io.ReadAll(io.LimitReader(body, maxBrokerErrorBodyBytes)) for !utf8.Valid(raw) && len(raw) > 0 { raw = raw[:len(raw)-1] } var sanitized strings.Builder for _, r := range string(raw) { if r < 0x20 { continue } sanitized.WriteRune(r) } trimmed := strings.TrimSpace(sanitized.String()) if trimmed == "" { return "" } return ": " + trimmed } func (c *githubCredentialBrokerClient) resolve(ctx context.Context) (*githubBrokerCredential, error) { body, err := json.Marshal(c.request) if err != nil { return nil, fmt.Errorf("encode GitHub credential request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create GitHub credential request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("resolve GitHub credential: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("resolve GitHub credential: broker returned HTTP %d%s", resp.StatusCode, formatBrokerErrorBody(resp.Body)) } var credential githubBrokerCredential if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&credential); err != nil { return nil, fmt.Errorf("decode GitHub credential: %w", err) } if credential.Username == "" || credential.Password == "" { return nil, fmt.Errorf("resolve GitHub credential: broker returned an empty credential") } return &credential, nil } func runGitHubCredentialHelper( ctx context.Context, args []string, stdin io.Reader, stdout io.Writer, getenv func(string) string, httpClient *http.Client, ) error { if len(args) != 1 { return fmt.Errorf("usage: agentctl git-credential ") } switch args[0] { case "store", "erase": return nil case "get": default: return fmt.Errorf("unsupported git credential operation %q", args[0]) } input, err := readGitCredentialInput(stdin) if err != nil { return err } client, err := newGitHubCredentialBrokerClientForInput(getenv, httpClient, input) if err != nil { return err } request, err := gitCredentialRequestForInput(input, client.request) if err != nil { return err } client.request = request credential, err := client.resolve(ctx) if err != nil { return err } _, err = fmt.Fprintf(stdout, "username=%s\npassword=%s\n\n", credential.Username, credential.Password) return err } func newGitHubCredentialBrokerClientForInput( getenv func(string) string, httpClient *http.Client, input map[string]string, ) (*githubCredentialBrokerClient, error) { rawScopes := strings.TrimSpace(getenv(githubauth.CredentialScopesEnv)) if rawScopes == "" { return newGitHubCredentialBrokerClient(getenv, httpClient) } var scopes []githubBrokerResolveRequest if err := json.Unmarshal([]byte(rawScopes), &scopes); err != nil { return nil, fmt.Errorf("GitHub credential scopes are invalid: %w", err) } for _, scope := range scopes { request, err := gitCredentialRequestForInput(input, scope) if err == nil { return newGitHubCredentialBrokerClientForRequest(getenv, httpClient, request) } } return nil, fmt.Errorf("git repository does not match any credential lease scope") } func newGitHubCredentialBrokerClientForRequest( getenv func(string) string, httpClient *http.Client, request githubBrokerResolveRequest, ) (*githubCredentialBrokerClient, error) { endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv)) for name, value := range map[string]string{ "broker URL": endpoint, "lease": request.Lease, "task": request.TaskID, "session": request.SessionID, "repository": request.RepositoryID, "owner": request.Owner, "repo": request.Repo, "host": request.Host, } { if strings.TrimSpace(value) == "" { return nil, fmt.Errorf("GitHub credential %s is not configured", name) } } parsed, err := url.ParseRequestURI(endpoint) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, fmt.Errorf("GitHub credential broker URL is invalid") } if httpClient == nil { httpClient = &http.Client{Timeout: 30 * time.Second} } return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil } func readGitCredentialInput(input io.Reader) (map[string]string, error) { values := make(map[string]string) scanner := bufio.NewScanner(io.LimitReader(input, 64<<10)) for scanner.Scan() { line := scanner.Text() if line == "" { break } key, value, ok := strings.Cut(line, "=") if ok { values[key] = value } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("read git credential request: %w", err) } return values, nil } // gitCredentialRequestForInput validates the Git credential helper request // against its issued lease scope, then carries the issued repository path // through to the broker. Git uses owner/repo fields on this compatibility // endpoint, so Repo retains any provider namespace after the first segment. func gitCredentialRequestForInput(input map[string]string, scope githubBrokerResolveRequest) (githubBrokerResolveRequest, error) { protocol := strings.TrimSpace(input["protocol"]) if !strings.EqualFold(protocol, "https") { return githubBrokerResolveRequest{}, fmt.Errorf("git credential protocol %q is not supported", protocol) } host := strings.TrimSpace(input["host"]) if host == "" || !strings.EqualFold(host, scope.Host) { return githubBrokerResolveRequest{}, fmt.Errorf("git credential host does not match credential lease scope") } path, err := url.PathUnescape(strings.TrimSpace(input["path"])) if err != nil { return githubBrokerResolveRequest{}, fmt.Errorf("decode git credential path: %w", err) } path = "/" + strings.TrimLeft(path, "/") if path == "/" { return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope") } if scope.Path != "" { // The scope path comes from a clone URL and keeps its ".git" suffix, // while the gh CLI shim asks for a bare "//". Compare the // canonical spelling so both reach the same lease. The broker compares // the same way, so the exact issued path is what travels onward. if githubauth.CanonicalCredentialPath(path) != githubauth.CanonicalCredentialPath(scope.Path) { return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope") } path = scope.Path } else { legacyPath := strings.TrimSuffix(strings.Trim(path, "/"), ".git") owner, repo, found := strings.Cut(legacyPath, "/") if !found || owner == "" || repo == "" || legacyPath != scope.Owner+"/"+scope.Repo { return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope") } scope.Owner = owner scope.Repo = repo } scope.Path = path return scope, nil }