// Package identity manages the per-agent Fabric API key registry at // /fabric-identity.json. Format mirrors openclaw's // fabric-identity.json so existing operator muscle memory transfers: // // { // "agents": { // "": { // "fabric_api_key": "fak_...", // "fabric_user_id": "u_...", // optional, recorded on register // "fabric_email": "...", // optional // "enabled": true // } // } // } // // `plexum-fabric-register` writes here; the plugin reads from here at // startup (and rereads on SIGHUP — future work). package identity import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "sync" ) // FileName is the basename under /. const FileName = "fabric-identity.json" // Entry is one agent's identity binding. type Entry struct { FabricAPIKey string `json:"fabric_api_key"` FabricUserID string `json:"fabric_user_id,omitempty"` FabricEmail string `json:"fabric_email,omitempty"` Enabled bool `json:"enabled"` } // Registry wraps the JSON file. Thread-safe. type Registry struct { mu sync.Mutex path string data map[string]*Entry } // Open loads (or creates an empty) registry at the given absolute path. func Open(path string) (*Registry, error) { r := &Registry{path: path, data: map[string]*Entry{}} raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return r, nil } return nil, fmt.Errorf("identity: read %s: %w", path, err) } if len(raw) == 0 { return r, nil } var wire struct { Agents map[string]*Entry `json:"agents"` } if err := json.Unmarshal(raw, &wire); err != nil { return nil, fmt.Errorf("identity: parse %s: %w", path, err) } if wire.Agents != nil { r.data = wire.Agents } return r, nil } // Lookup returns the entry for agentID (nil if missing). func (r *Registry) Lookup(agentID string) *Entry { r.mu.Lock() defer r.mu.Unlock() return r.data[agentID] } // Set inserts/replaces the entry for agentID. Does NOT persist. func (r *Registry) Set(agentID string, e *Entry) { r.mu.Lock() defer r.mu.Unlock() r.data[agentID] = e } // Delete removes agentID; returns true iff it was present. func (r *Registry) Delete(agentID string) bool { r.mu.Lock() defer r.mu.Unlock() if _, ok := r.data[agentID]; !ok { return false } delete(r.data, agentID) return true } // AgentIDs returns the sorted list of registered agent ids. func (r *Registry) AgentIDs() []string { r.mu.Lock() defer r.mu.Unlock() out := make([]string, 0, len(r.data)) for k := range r.data { out = append(out, k) } sort.Strings(out) return out } // EnabledEntries returns a copy of (agentID, entry) for entries with // Enabled=true. Plugin uses this to decide which agents to bring up. func (r *Registry) EnabledEntries() map[string]*Entry { r.mu.Lock() defer r.mu.Unlock() out := map[string]*Entry{} for k, v := range r.data { if v != nil && v.Enabled { copyE := *v out[k] = ©E } } return out } // Save atomically writes the registry (tmp+rename, 0600 — API keys live // here, treat as secrets). func (r *Registry) Save() error { r.mu.Lock() defer r.mu.Unlock() if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { return fmt.Errorf("identity: mkdir: %w", err) } payload := struct { Agents map[string]*Entry `json:"agents"` }{Agents: r.data} data, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } tmp := r.path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("identity: write tmp: %w", err) } return os.Rename(tmp, r.path) } // DefaultPath returns the canonical path under PLEXUM_PROFILE_ROOT or // ~/.plexum if the env var isn't set. func DefaultPath() string { root := os.Getenv("PLEXUM_PROFILE_ROOT") if root == "" { home, _ := os.UserHomeDir() root = filepath.Join(home, ".plexum") } return filepath.Join(root, FileName) }