molecule-core/workspace-server/pkg/provisionhook/mutator.go
Hongming Wang 03e913db75 feat(#1957): wire gh-identity plugin into workspace-server
Ships the monorepo side of molecule-core#1957 (agent identity collapse).
Companion to molecule-ai-plugin-gh-identity (new repo, merged-and-tagged
separately).

Changes:
- manifest.json: add gh-identity plugin to Tier 1 registry
- workspace-server/go.mod: require github.com/Molecule-AI/molecule-ai-plugin-gh-identity
- cmd/server/main.go: build a shared provisionhook.Registry, register
  gh-identity first (always), then github-app-auth (gated on GITHUB_APP_ID)
- workspace_provision.go: propagate workspace.Role into
  env["MOLECULE_AGENT_ROLE"] before calling the mutator chain, so the
  gh-identity plugin can see which agent is booting
- provisionhook/mutator.go: add Registry.Mutators() accessor so
  individual-plugin registries can be merged onto a shared one at boot

Boot log gains a line like:
  env-mutator chain: [gh-identity github-app-auth]

Effect per workspace:
- env contains MOLECULE_AGENT_ROLE, MOLECULE_OWNER, MOLECULE_ATTRIBUTION_BADGE,
  MOLECULE_GH_WRAPPER_B64, MOLECULE_GH_WRAPPER_SHA
- Each workspace template's install.sh can decode + install the wrapper at
  /usr/local/bin/gh, intercepting @me assignment and prepending agent
  attribution on PR/issue creates

Does not break existing workspaces — absent workspace.role, the plugin is
a no-op. Absent install.sh updates in each template, the env vars are
simply unused.

Follow-up template PRs (hermes, claude-code, langgraph, etc.) each add
~15 lines to install.sh to decode + install the wrapper.

Ref: #1957

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:01:41 +00:00

266 lines
9.5 KiB
Go

// Package provisionhook is the public extension point that lets external
// plugins mutate the env map a workspace container will boot with, just
// before the provisioner calls Start(cfg).
//
// The package lives under pkg/ (not internal/) because plugins import it
// from outside this Go module. Anything outside pkg/ is core-only.
//
// # Why this exists
//
// Auth providers (GitHub App tokens, GitLab tokens, Bitbucket app
// passwords, internal PAT vaults), secret managers (Vault, AWS Secrets
// Manager, GCP Secret Manager), per-tenant config injectors, and
// observability sidecars all want to write env vars into the workspace
// container before it starts. Each is an OPTIONAL concern that only some
// deployments need. Hardcoding any of them in the platform binary
// violates the "core stays small, capabilities are plugins" principle
// (CEO 2026-04-16, after the monorepo → 44 sub-repos split).
//
// # Plugin shape
//
// A plugin implements EnvMutator and registers an instance with a
// Registry at platform startup. The provisioner calls Run(...) on the
// registry before each workspace container starts.
//
// Plugins live in their own Go modules + repos (e.g.
// github.com/Molecule-AI/molecule-ai-plugin-github-app-auth). Each
// plugin ships its own cmd/server/main.go that imports core's startup
// function + registers the plugin's mutator. Operators deploy the
// plugin binary instead of core's vanilla cmd/server when they want
// the plugin's behaviour.
//
// # Failure handling
//
// MutateEnv returning a non-nil error aborts the provision (workspace
// is marked 'failed', container never starts). Plugins should fail open
// on transient external-service errors (log + return nil) so a flaky
// upstream doesn't block agent provisioning. Reserve errors for hard
// config bugs that the operator must fix.
//
// # Concurrency
//
// Registry is safe for concurrent registration + execution. MutateEnv
// implementations should be safe to call from goroutines (the
// provisioner runs each workspace's provision in its own goroutine).
package provisionhook
import (
"context"
"fmt"
"log"
"reflect"
"sync"
"time"
)
// EnvMutator is implemented by plugins that want to inject env vars
// into a workspace container at provision time.
//
// - Name returns a stable identifier for logging / metrics. Should
// match the plugin's repo / module name (e.g. "github-app-auth").
// - MutateEnv receives the workspace ID, the create payload, and a
// mutable env map. It can read existing values, add new ones, or
// overwrite as needed. Mutations are visible to subsequent
// mutators in the chain (registration order).
type EnvMutator interface {
Name() string
MutateEnv(ctx context.Context, workspaceID string, env map[string]string) error
}
// TokenProvider is an optional interface that EnvMutator implementations
// may also satisfy. When a mutator implements TokenProvider the platform
// can serve GET /admin/github-installation-token, allowing long-running
// workspaces to fetch a fresh GitHub token without restarting.
//
// # Why a separate interface?
//
// EnvMutator.MutateEnv is called once at provision time and writes into
// an env map. Calling it again just to read the current token would be
// semantically wrong and potentially unsafe (the env map is a live
// workspace struct). TokenProvider cleanly separates "what do I inject
// at boot?" from "what is the live token right now?".
//
// # Plugin contract
//
// Token must return the current valid token and the time at which it
// will expire. If the plugin's internal cache is past its refresh
// threshold it must block until a new token is obtained before
// returning. Token should never return an expired token — callers rely
// on this guarantee and do not do their own expiry check.
//
// Returning a non-nil error causes the HTTP handler to respond 500 and
// log "[github] token refresh failed: <err>". The workspace will retry
// on its next credential-helper invocation.
type TokenProvider interface {
Token(ctx context.Context) (token string, expiresAt time.Time, err error)
}
// Registry holds the ordered list of EnvMutator instances the
// provisioner runs before each workspace boot. Safe for concurrent
// registration + execution.
type Registry struct {
mu sync.RWMutex
mutators []EnvMutator
}
// NewRegistry returns an empty registry. The platform creates one at
// startup; plugins call Register on it.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds a mutator to the chain. Mutators run in registration
// order. Registering the same instance twice is allowed (it'll run
// twice) — the registry doesn't dedupe; that's the caller's
// responsibility if dedup matters.
func (r *Registry) Register(m EnvMutator) {
if m == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.mutators = append(r.mutators, m)
}
// Len reports how many mutators are registered. Used by the platform's
// boot log so operators can see which extension hooks are wired.
func (r *Registry) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.mutators)
}
// Names returns the names of registered mutators in registration order.
// Used by the boot log so operators can grep for which plugins are
// active.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, len(r.mutators))
for i, m := range r.mutators {
names[i] = m.Name()
}
return names
}
// Mutators returns a copy of the registered mutators in registration
// order. Used when multiple plugins build their own registries and need
// to merge onto a shared one at boot. Returns a copy so callers can't
// mutate internal state.
func (r *Registry) Mutators() []EnvMutator {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]EnvMutator, len(r.mutators))
copy(out, r.mutators)
return out
}
// FirstTokenProvider returns the first registered mutator that also
// implements TokenProvider, or nil if none do. Used to back the
// 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 {
return nil
}
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
}
// Run calls every registered mutator in order. The first one to return
// a non-nil error aborts the chain — subsequent mutators do NOT run,
// and the error is returned to the caller (which marks the workspace
// failed).
//
// A nil registry is a no-op (returns nil) so the provisioner doesn't
// have to nil-check before calling.
func (r *Registry) Run(ctx context.Context, workspaceID string, env map[string]string) error {
if r == nil {
return nil
}
r.mu.RLock()
mutators := make([]EnvMutator, len(r.mutators))
copy(mutators, r.mutators)
r.mu.RUnlock()
for _, m := range mutators {
if err := m.MutateEnv(ctx, workspaceID, env); err != nil {
return fmt.Errorf("provisionhook %q: %w", m.Name(), err)
}
}
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
}