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:
h z
2026-05-31 15:13:34 +01:00
commit f8d43ae70e
11 changed files with 1331 additions and 0 deletions

View 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] = &copyE
}
}
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)
}