molecule-core/workspace-server/internal/handlers/github_token.go
molecule-ai[bot] 732f65e8e1 fix(go): replace $1 literal with resp.Body.Close() in 7 files (#1247)
PR #1229 sed command had no capture groups but used $1 in the
replacement, committing the literal string "defer func() { _ = \$1 }()"
instead of "defer func() { _ = resp.Body.Close() }()". Go does not
compile — $1 is not a valid identifier.

Fixed with: sed -i 's/defer func() { _ = \$1 }()/defer func() { _ = resp.Body.Close() }()/g'

Affected (all on origin/staging):
  workspace-server/cmd/server/cp_config.go
  workspace-server/internal/handlers/a2a_proxy.go
  workspace-server/internal/handlers/github_token.go
  workspace-server/internal/handlers/traces.go
  workspace-server/internal/handlers/transcript.go
  workspace-server/internal/middleware/session_auth.go
  workspace-server/internal/provisioner/cp_provisioner.go (3 occurrences)

Closes: #1245

Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 03:18:21 +00:00

179 lines
6.8 KiB
Go

// Package handlers — GitHub App installation-token refresh endpoint.
//
// GET /admin/github-installation-token returns a fresh GitHub App
// installation token on demand. Long-running workspace containers use
// this as a git credential helper and for explicit `gh auth` re-runs
// so they never operate with an expired GH_TOKEN.
//
// # Why this endpoint?
//
// The github-app-auth plugin (PR #506) injects GH_TOKEN + GITHUB_TOKEN
// into a workspace container's env at provision time. Those tokens are
// GitHub App installation tokens with a fixed ~60 min TTL. The plugin
// keeps a server-side in-process cache and proactively refreshes it
// 5 min before expiry, but the workspace env is set once at container
// start and never updated — so any workspace alive >60 min ends up with
// an expired token (issue #547).
//
// The fix is:
//
// 1. Platform side (this file): expose GET /admin/github-installation-token.
// The handler delegates to the registered TokenProvider (typically the
// github-app-auth plugin), whose cache is always fresh. Gated behind
// AdminAuth — any valid workspace bearer token can call it.
//
// 2. Workspace side: a shell credential helper
// (workspace/scripts/molecule-git-token-helper.sh) configured
// as the git credential helper. git calls it on every push/fetch;
// it hits this endpoint and emits the fresh token to stdout. A 30-min
// cron also runs `gh auth login --with-token` using the same helper.
//
// # Approach chosen
//
// Option B (pre-flight/on-demand): workspaces poll for a token when
// they need one (credential helper callback). This is preferable over a
// background goroutine pusher (Option A) because:
//
// - The plugin already maintains its own refresh cache — there is no
// token to refresh on the platform side.
// - Pushing a new token into running containers requires docker exec /
// env mutation, which the architecture explicitly rejects (see issue
// #547 "Alternatives considered").
// - On-demand is pull-based, stateless, and trivially testable.
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// GitHubTokenHandler serves GET /admin/github-installation-token.
type GitHubTokenHandler struct {
registry *provisionhook.Registry
}
// NewGitHubTokenHandler constructs the handler. registry may be nil when
// no GitHub App plugin is registered (dev / self-hosted deployments).
func NewGitHubTokenHandler(reg *provisionhook.Registry) *GitHubTokenHandler {
return &GitHubTokenHandler{registry: reg}
}
// GetInstallationToken handles GET /admin/github-installation-token.
//
// Returns:
//
// 200 {"token": "ghs_...", "expires_at": "2026-04-17T22:50:00Z"}
// 404 {"error": "no GitHub App configured"} — GITHUB_APP_ID not set
// 404 {"error": "no token provider registered"} — plugin loaded but
// doesn't implement TokenProvider
// 500 {"error": "token refresh failed"} — provider returned error
//
// The 404 vs 403 distinction is intentional: a 404 means the feature is
// simply not configured, not that the caller is forbidden. This matches
// the pattern used by GET /admin/workspaces/:id/test-token.
//
// Callers must retry with exponential back-off on 500 — a transient
// upstream GitHub API error should not permanently block git operations.
func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
if h.registry == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no GitHub App configured"})
return
}
provider := h.registry.FirstTokenProvider()
if provider == nil {
// #960/#1101: Plugin's TokenProvider interface fails due to Go module
// boundary. Fall back to direct App token generation using env vars.
// TODO: refactor into a platform-level CredentialRefreshHook (#1101)
log.Printf("[github] no TokenProvider in registry — using env-based fallback")
token, expiresAt, err := generateAppInstallationToken()
if err != nil {
log.Printf("[github] fallback token generation failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
return
}
token, expiresAt, err := provider.Token(c.Request.Context())
if err != nil {
log.Printf("[github] token refresh failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
return
}
if token == "" {
log.Printf("[github] token provider returned empty token")
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed: empty token"})
return
}
// Never log the token itself.
log.Printf("[github] served fresh installation token (expires %s, TTL %.0fs)",
expiresAt.Format(time.RFC3339),
time.Until(expiresAt).Seconds())
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.UTC().Format(time.RFC3339),
})
}
// generateAppInstallationToken generates a GitHub App installation token
// directly from env vars. Temporary fallback for #960 (Go module boundary
// prevents plugin TokenProvider from matching). Tracked for refactor in #1101.
func generateAppInstallationToken() (string, time.Time, error) {
appID, _ := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
installID, _ := strconv.ParseInt(os.Getenv("GITHUB_APP_INSTALLATION_ID"), 10, 64)
keyFile := os.Getenv("GITHUB_APP_PRIVATE_KEY_FILE")
if appID == 0 || installID == 0 || keyFile == "" {
return "", time.Time{}, fmt.Errorf("GITHUB_APP_ID/INSTALLATION_ID/PRIVATE_KEY_FILE required")
}
keyPEM, err := os.ReadFile(keyFile)
if err != nil {
return "", time.Time{}, fmt.Errorf("read key: %w", err)
}
rsaKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyPEM)
if err != nil {
return "", time.Time{}, fmt.Errorf("parse key: %w", err)
}
now := time.Now()
signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iat": now.Add(-60 * time.Second).Unix(),
"exp": now.Add(10 * time.Minute).Unix(),
"iss": appID,
}).SignedString(rsaKey)
if err != nil {
return "", time.Time{}, fmt.Errorf("sign JWT: %w", err)
}
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
req.Header.Set("Authorization", "Bearer "+signed)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", time.Time{}, err
}
defer func() { _ = resp.Body.Close() }()
var result struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", time.Time{}, err
}
if result.Token == "" {
return "", time.Time{}, fmt.Errorf("empty token (status %d)", resp.StatusCode)
}
return result.Token, result.ExpiresAt, nil
}