Root cause: the github-app-auth plugin injects GH_TOKEN + GITHUB_TOKEN
into each workspace container's env at provision time (EnvMutator). Those
are GitHub App installation tokens with a fixed ~60 min TTL. The plugin
has an in-process cache that proactively refreshes 5 min before expiry —
but the workspace env is set once at container start and never updated.
Any workspace alive >60 min ends up with an expired token.
Fix (Option B — on-demand endpoint):
pkg/provisionhook:
- Add TokenProvider interface: Token(ctx) (token, expiresAt, error)
Lives in pkg/ (public) so the github-app-auth plugin can implement it.
- Add Registry.FirstTokenProvider() — discovers the first mutator that
also satisfies TokenProvider via interface assertion. Safe under
concurrent reads (existing RWMutex).
platform/internal/handlers/github_token.go:
- New GitHubTokenHandler serving GET /admin/github-installation-token
- Delegates to the registered TokenProvider (plugin cache — always fresh)
- 404 if no GitHub App configured, 500 + [github] prefix log on error
- Never logs the token itself
platform/internal/handlers/workspace.go:
- Add TokenRegistry() getter so the router can wire the handler without
coupling to WorkspaceHandler internals
platform/internal/router/router.go:
- Register GET /admin/github-installation-token under AdminAuth
workspace-template/:
- scripts/molecule-git-token-helper.sh — git credential helper; calls
the platform endpoint on every push/fetch; falls through to next
helper (operator PAT) if platform unreachable
- entrypoint.sh — configure the credential helper at startup
Why Option B over Option A (background goroutine):
- The plugin already has its own cache refresh; nothing to refresh here.
- Pushing env updates into running containers requires docker exec, which
the architecture explicitly rejects (issue #547 "Alternatives").
- Pull-based is stateless, trivially testable, zero extra goroutines.
Closes #547
Co-authored-by: Molecule AI DevOps Engineer <devops-engineer@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
116 lines
4.4 KiB
Go
116 lines
4.4 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-template/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 (
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// 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 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no token provider registered"})
|
|
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),
|
|
})
|
|
}
|