molecule-ai-plugin-gh-identity/internal/ghidentity/mutator_test.go
Hongming Wang 4fd5ac7be3
Some checks failed
CI / Shellcheck + wrapper tests (push) Failing after 9s
CI / Go build + test + vet (push) Failing after 13m36s
feat(plugin): gh-identity — per-agent attribution via env injection + gh wrapper
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>
2026-04-23 20:38:45 -07:00

120 lines
3.5 KiB
Go

package ghidentity
import (
"context"
"encoding/base64"
"strings"
"testing"
)
func TestMutateEnv_NoRoleIsNoOp(t *testing.T) {
m := &Mutator{Config: &Config{Roles: map[string]RoleConfig{
"default": {Owner: "hongming"},
}}}
env := map[string]string{}
if err := m.MutateEnv(context.Background(), "ws-abc", env); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(env) != 0 {
t.Errorf("expected no mutation without role, got %v", env)
}
}
func TestMutateEnv_InjectsAllFields(t *testing.T) {
m := &Mutator{Config: &Config{Roles: map[string]RoleConfig{
"PMM-Lead": {Owner: "hongming"},
}}}
env := map[string]string{"MOLECULE_AGENT_ROLE": "pmm-lead"}
if err := m.MutateEnv(context.Background(), "ws-abcdef01-foo", env); err != nil {
t.Fatalf("unexpected err: %v", err)
}
// Role sanitized
if got := env["MOLECULE_AGENT_ROLE"]; got != "Pmm-Lead" {
t.Errorf("expected Pmm-Lead, got %q", got)
}
// Owner resolved via case-insensitive lookup: config key "PMM-Lead"
// matches sanitized role "Pmm-Lead" because we lower-case both sides.
if got := env["MOLECULE_OWNER"]; got != "hongming" {
t.Errorf("expected owner=hongming, got %q", got)
}
// Workspace id passed through
if env["MOLECULE_WORKSPACE_ID"] != "ws-abcdef01-foo" {
t.Errorf("workspace id not set")
}
// Badge contains role + short id
if !strings.Contains(env["MOLECULE_ATTRIBUTION_BADGE"], "Pmm-Lead") {
t.Errorf("badge missing role: %q", env["MOLECULE_ATTRIBUTION_BADGE"])
}
if !strings.Contains(env["MOLECULE_ATTRIBUTION_BADGE"], "ws-abcdef01") {
t.Errorf("badge missing short id: %q", env["MOLECULE_ATTRIBUTION_BADGE"])
}
// Wrapper base64 decodes back to wrapper.sh
b64 := env["MOLECULE_GH_WRAPPER_B64"]
if b64 == "" {
t.Fatal("wrapper b64 not set")
}
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !strings.Contains(string(decoded), "#!/usr/bin/env bash") {
t.Errorf("wrapper decode mismatch")
}
// Wrapper sha is short hex
if len(env["MOLECULE_GH_WRAPPER_SHA"]) != 12 {
t.Errorf("wrapper sha length: %d", len(env["MOLECULE_GH_WRAPPER_SHA"]))
}
}
func TestMutateEnv_NilMapErrors(t *testing.T) {
m := &Mutator{}
if err := m.MutateEnv(context.Background(), "ws-1", nil); err == nil {
t.Fatal("expected error on nil env map")
}
}
func TestResolveOwner_FallbackChain(t *testing.T) {
cfg := &Config{Roles: map[string]RoleConfig{
"PMM": {Owner: "alice"},
"default": {Owner: "bob"},
}}
cases := []struct {
role, want string
}{
{"PMM", "alice"},
{"pmm", "alice"}, // case-insensitive
{"Pmm", "alice"}, // case-insensitive (sanitized form)
{"unknown-role", "bob"},
{"", "bob"},
}
for _, c := range cases {
if got := cfg.ResolveOwner(c.role); got != c.want {
t.Errorf("role=%q: got %q want %q", c.role, got, c.want)
}
}
}
func TestResolveOwner_NoDefaultReturnsEmpty(t *testing.T) {
cfg := &Config{Roles: map[string]RoleConfig{
"PMM": {Owner: "alice"},
}}
if got := cfg.ResolveOwner("unknown"); got != "" {
t.Errorf("expected empty for unknown role without default, got %q", got)
}
}
func TestSanitizeRole(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{" pmm-lead ", "Pmm-Lead"},
{"ResearchLead", "ResearchLead"},
{"multi-part-role", "Multi-Part-Role"},
{" -starts-with-dash", "-Starts-With-Dash"}, // edge: preserved
}
for _, c := range cases {
if got := SanitizeRole(c.in); got != c.want {
t.Errorf("in=%q: got %q want %q", c.in, got, c.want)
}
}
}