Fixes molecule-core#1957: agent identity collapse where all agents share one GitHub PAT and their writes attribute to the CEO. This plugin takes the pragmatic "wrap, don't multiply identities" path: - Injects MOLECULE_AGENT_ROLE / OWNER / ATTRIBUTION_BADGE per workspace - Ships a shell wrapper for `gh` that: * prepends an attribution badge to issue/PR bodies on publish * rewrites --assignee @me to the role's designated human owner * emits an NDJSON audit log to /var/log/molecule-gh.ndjson - Wrapper is shipped as base64 env var; each workspace template's install.sh decodes and writes it to /usr/local/bin/gh Scales where GitHub Apps / machine users don't: adding a new agent role is one entry in config.yaml, not a GitHub UI roundtrip per role. See README + known-issues.md for the v2-architecture migration plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
102 lines
3.1 KiB
Go
102 lines
3.1 KiB
Go
// Package ghidentity implements the workspace-server plugin that injects
|
|
// per-agent attribution env vars into workspace containers.
|
|
//
|
|
// See repo README for the "why" (molecule-core#1957 agent-identity
|
|
// collapse). This package contains the wiring; the behavioural logic
|
|
// lives in wrapper.sh which is shipped to the workspace via env.
|
|
package ghidentity
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Config maps agent roles (set per-workspace via MOLECULE_AGENT_ROLE)
|
|
// to the human GitHub user who "owns" that role for purposes of @me
|
|
// rewriting. A single default entry covers roles not explicitly listed.
|
|
//
|
|
// Config is loaded once at platform boot from
|
|
// $MOLECULE_GH_IDENTITY_CONFIG_FILE. Missing file → use the DefaultOwner
|
|
// for all roles; plugin still works, just with blanket attribution.
|
|
type Config struct {
|
|
Roles map[string]RoleConfig `yaml:"roles"`
|
|
}
|
|
|
|
// RoleConfig defines the per-role settings. Today: just the owner.
|
|
// Future fields (capability overrides, rate limits, per-role repo
|
|
// allowlists) slot in here without breaking the surface.
|
|
type RoleConfig struct {
|
|
Owner string `yaml:"owner"`
|
|
}
|
|
|
|
// LoadConfig reads a YAML config file. Missing file is not an error —
|
|
// returns a Config with an empty Roles map and the caller falls through
|
|
// to DefaultOwner.
|
|
func LoadConfig(path string) (*Config, error) {
|
|
if path == "" {
|
|
return &Config{Roles: map[string]RoleConfig{}}, nil
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &Config{Roles: map[string]RoleConfig{}}, nil
|
|
}
|
|
return nil, fmt.Errorf("read config %q: %w", path, err)
|
|
}
|
|
var cfg Config
|
|
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config %q: %w", path, err)
|
|
}
|
|
if cfg.Roles == nil {
|
|
cfg.Roles = map[string]RoleConfig{}
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// ResolveOwner picks the GitHub owner for the given role. Unknown roles
|
|
// fall through to the "default" entry; if neither is set, returns "" so
|
|
// the wrapper strips --assignee @me entirely (correct behavior — better
|
|
// than assigning to the wrong person).
|
|
//
|
|
// Lookup is case-insensitive against the sanitized role form. The
|
|
// yaml config writer might use "PMM-Lead", "pmm-lead", or "Pmm-Lead"
|
|
// interchangeably — we accept all three by lower-casing both sides.
|
|
// "default" is treated literally (reserved key).
|
|
func (c *Config) ResolveOwner(role string) string {
|
|
needle := strings.ToLower(role)
|
|
for k, rc := range c.Roles {
|
|
if k == "default" {
|
|
continue
|
|
}
|
|
if strings.ToLower(k) == needle && rc.Owner != "" {
|
|
return rc.Owner
|
|
}
|
|
}
|
|
if rc, ok := c.Roles["default"]; ok {
|
|
return rc.Owner
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SanitizeRole normalizes a role string for use in env vars / badges.
|
|
// Strips whitespace, upper-cases the first letter of each hyphen-
|
|
// separated segment so arbitrary user input (" pmm-lead ") becomes a
|
|
// predictable string ("PMM-Lead") visible in attribution badges.
|
|
func SanitizeRole(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(raw, "-")
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|