feat: Phase F-1 — Plexum-fabric-channel-plugin foundation
Ports the foundation of Fabric.OpenclawPlugin to a native Plexum channel plugin (Go). F-2+ phases (socket.io inbound, wakeup gate, tools, presence, etc.) follow. Layout: internal/identity/ — fabric-identity.json registry (agent → API key) internal/fabric/ — REST client (Center auth + Guild messaging) internal/config/ — channels/<name>.json fabric extension parser cmd/plexum-fabric-register/ — agent registration CLI cmd/plexum-fabric-channel-plugin/— Plexum SDK plugin entry scripts/install.sh — build + install + manifest generator Plugin behavior (F-1): - Reads <profile>/channels/*.json, filters plugin=plexum-fabric-channel, builds (plexum-channel-name → fabric channel-id) index - Validates each bound agent's API key against Center at init (warmSessions); logs warning but doesn't refuse init on bad keys - `send` MCP tool: POST plain text to the bound Fabric channel as the agent user; selects guild endpoint+token from cached session - Manifest channels[] is generated by install.sh from current channels/*.json — re-run with --reset-manifest after adding bindings - Plugin-private config at <profile>/plugins/plexum-fabric-channel/config.json (center_api_base, default http://localhost:7001/api) Live smoke verified: - plexum-fabric-register against running Fabric Center (port 7001): validated fak_..., wrote identity file with user_id + email captured Tests: identity (5) + config (6) = 11 unit tests. F-2 will hook socket.io for inbound + wakeup gating + token refresh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
157
internal/identity/identity.go
Normal file
157
internal/identity/identity.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Package identity manages the per-agent Fabric API key registry at
|
||||
// <profile>/fabric-identity.json. Format mirrors openclaw's
|
||||
// fabric-identity.json so existing operator muscle memory transfers:
|
||||
//
|
||||
// {
|
||||
// "agents": {
|
||||
// "<plexum-agent-id>": {
|
||||
// "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 <profile>/.
|
||||
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)
|
||||
}
|
||||
88
internal/identity/identity_test.go
Normal file
88
internal/identity/identity_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenMissingFileEmpty(t *testing.T) {
|
||||
r, err := Open(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.AgentIDs()) != 0 {
|
||||
t.Errorf("expected empty registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSaveReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "id.json")
|
||||
r, _ := Open(path)
|
||||
r.Set("alice", &Entry{FabricAPIKey: "fak_alice", FabricEmail: "a@x", Enabled: true})
|
||||
r.Set("bob", &Entry{FabricAPIKey: "fak_bob", Enabled: false})
|
||||
if err := r.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := r2.Lookup("alice")
|
||||
if a == nil || a.FabricAPIKey != "fak_alice" || !a.Enabled {
|
||||
t.Errorf("alice = %+v", a)
|
||||
}
|
||||
b := r2.Lookup("bob")
|
||||
if b == nil || b.Enabled {
|
||||
t.Errorf("bob = %+v", b)
|
||||
}
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("perms = %o, want 0600", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabledEntriesFiltersDisabled(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "id.json")
|
||||
r, _ := Open(path)
|
||||
r.Set("a", &Entry{FabricAPIKey: "x", Enabled: true})
|
||||
r.Set("b", &Entry{FabricAPIKey: "y", Enabled: false})
|
||||
r.Set("c", &Entry{FabricAPIKey: "z", Enabled: true})
|
||||
out := r.EnabledEntries()
|
||||
if len(out) != 2 || out["a"] == nil || out["c"] == nil {
|
||||
t.Errorf("EnabledEntries = %+v", out)
|
||||
}
|
||||
if out["b"] != nil {
|
||||
t.Errorf("disabled should be filtered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
r, _ := Open(filepath.Join(t.TempDir(), "id.json"))
|
||||
r.Set("a", &Entry{FabricAPIKey: "x", Enabled: true})
|
||||
if !r.Delete("a") {
|
||||
t.Errorf("delete present should return true")
|
||||
}
|
||||
if r.Delete("a") {
|
||||
t.Errorf("delete missing should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentIDsSorted(t *testing.T) {
|
||||
r, _ := Open(filepath.Join(t.TempDir(), "id.json"))
|
||||
for _, k := range []string{"z", "a", "m"} {
|
||||
r.Set(k, &Entry{FabricAPIKey: "x", Enabled: true})
|
||||
}
|
||||
ids := r.AgentIDs()
|
||||
want := []string{"a", "m", "z"}
|
||||
for i := range want {
|
||||
if ids[i] != want[i] {
|
||||
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user