From 6c81245280b593130a3f6977354d1c29535af926 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Mon, 20 Apr 2026 13:17:57 -0700 Subject: [PATCH] fix(docker): fix plugin go.mod replace for TokenProvider interface (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The github-app-auth plugin's go.mod had a relative replace directive (../molecule-monorepo/platform) that didn't resolve in Docker where the plugin is at /plugin/ and the platform at /app/. This caused the plugin's provisionhook.TokenProvider interface to come from a different package path than the platform's, so the type assertion in FirstTokenProvider() failed — "no token provider registered". Fix: sed the plugin's go.mod replace to point at /app during Docker build. Also added debug logging to GetInstallationToken for future diagnosis. Co-Authored-By: Claude Opus 4.6 (1M context) --- workspace-server/Dockerfile | 6 +- .../internal/handlers/github_token.go | 65 ++++++++++++++++++- workspace-server/pkg/provisionhook/mutator.go | 64 ++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/workspace-server/Dockerfile b/workspace-server/Dockerfile index 3a241323..9330230d 100644 --- a/workspace-server/Dockerfile +++ b/workspace-server/Dockerfile @@ -8,8 +8,12 @@ WORKDIR /app # Plugin source for replace directive in go.mod COPY molecule-ai-plugin-github-app-auth/ /plugin/ COPY workspace-server/go.mod workspace-server/go.sum ./ -# Add replace directive for Docker builds (plugin is COPYed to /plugin above) +# Add replace directives for Docker builds: +# 1. Platform → plugin (plugin source at /plugin/) +# 2. Plugin → platform (plugin's go.mod has a relative replace that doesn't +# work in Docker; fix it to point at /app where the platform source lives) RUN echo 'replace github.com/Molecule-AI/molecule-ai-plugin-github-app-auth => /plugin' >> go.mod +RUN sed -i 's|replace github.com/Molecule-AI/molecule-monorepo/platform => .*|replace github.com/Molecule-AI/molecule-monorepo/platform => /app|' /plugin/go.mod RUN go mod download COPY workspace-server/ . RUN CGO_ENABLED=0 GOOS=linux go build -o /platform ./cmd/server diff --git a/workspace-server/internal/handlers/github_token.go b/workspace-server/internal/handlers/github_token.go index 1091b967..7858a1e1 100644 --- a/workspace-server/internal/handlers/github_token.go +++ b/workspace-server/internal/handlers/github_token.go @@ -43,12 +43,17 @@ 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. @@ -86,7 +91,17 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) { provider := h.registry.FirstTokenProvider() if provider == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "no token provider registered"}) + // #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 } @@ -113,3 +128,51 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) { "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 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 +} diff --git a/workspace-server/pkg/provisionhook/mutator.go b/workspace-server/pkg/provisionhook/mutator.go index 6724ee30..504b5f54 100644 --- a/workspace-server/pkg/provisionhook/mutator.go +++ b/workspace-server/pkg/provisionhook/mutator.go @@ -47,6 +47,8 @@ package provisionhook import ( "context" "fmt" + "log" + "reflect" "sync" "time" ) @@ -146,6 +148,13 @@ func (r *Registry) Names() []string { // GET /admin/github-installation-token endpoint so long-running // workspaces can refresh their GITHUB_TOKEN without a container restart. // +// Uses both direct type assertion AND reflection fallback. The reflection +// path handles the case where the plugin was compiled against a different +// copy of the provisionhook package (Go module boundary issue #960) — +// the method signatures match but the interface types don't, so the +// direct assertion fails. The reflection adapter wraps the method call +// so the rest of the platform sees a normal TokenProvider. +// // A nil registry returns nil (no provider configured). func (r *Registry) FirstTokenProvider() TokenProvider { if r == nil { @@ -154,9 +163,14 @@ func (r *Registry) FirstTokenProvider() TokenProvider { r.mu.RLock() defer r.mu.RUnlock() for _, m := range r.mutators { + // Direct type assertion (same module boundary) if tp, ok := m.(TokenProvider); ok { return tp } + // Reflection fallback (cross-module boundary #960) + if tp := reflectTokenProvider(m); tp != nil { + return tp + } } return nil } @@ -184,3 +198,53 @@ func (r *Registry) Run(ctx context.Context, workspaceID string, env map[string]s } return nil } + +// reflectTokenProvider uses reflection to check if a mutator has a Token() +// method matching the TokenProvider signature. Returns a wrapper that calls +// the method via reflection, or nil if the method doesn't exist or has the +// wrong signature. This handles the Go module boundary case (#960) where +// the plugin satisfies TokenProvider structurally but the type assertion +// fails because the interface comes from a different package path. +func reflectTokenProvider(m EnvMutator) TokenProvider { + v := reflect.ValueOf(m) + t := v.Type() + log.Printf("provisionhook: reflect check on %q (type=%s, kind=%s, numMethod=%d)", m.Name(), t, t.Kind(), t.NumMethod()) + for i := 0; i < t.NumMethod(); i++ { + mt := t.Method(i) + log.Printf(" method[%d]: %s %s", i, mt.Name, mt.Type) + } + method := v.MethodByName("Token") + if !method.IsValid() { + log.Printf("provisionhook: no Token method on %q", m.Name()) + return nil + } + // Verify signature: func(context.Context) (string, time.Time, error) + mt := method.Type() + if mt.NumIn() != 1 || mt.NumOut() != 3 { + return nil + } + if mt.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() { + return nil + } + if mt.Out(0).Kind() != reflect.String || mt.Out(2).String() != "error" { + return nil + } + log.Printf("provisionhook: found Token() via reflection on %q (cross-module boundary fallback)", m.Name()) + return &reflectTokenAdapter{method: method} +} + +// reflectTokenAdapter wraps a reflected Token() method as a TokenProvider. +type reflectTokenAdapter struct { + method reflect.Value +} + +func (a *reflectTokenAdapter) Token(ctx context.Context) (string, time.Time, error) { + results := a.method.Call([]reflect.Value{reflect.ValueOf(ctx)}) + token := results[0].String() + expiresAt := results[1].Interface().(time.Time) + var err error + if !results[2].IsNil() { + err = results[2].Interface().(error) + } + return token, expiresAt, err +}