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

111
internal/config/config.go Normal file
View File

@@ -0,0 +1,111 @@
// Package config parses the per-channel binding files at
// <profile>/channels/<name>.json. Plexum's channel.Registry already
// parses the {agent_id, plugin} core; this package additionally pulls
// out the plugin-specific `fabric` extension block describing which
// Fabric (guild, channelId) the Plexum channel name maps to.
package config
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// PluginName is what manifest.json declares as the plugin name; we
// only consume channels/*.json entries with this value in `plugin`.
const PluginName = "plexum-fabric-channel"
// FabricBinding is one Plexum-channel ↔ Fabric-channel mapping. Built
// by Load from channels/<name>.json files.
type FabricBinding struct {
// PlexumChannelName: the basename of channels/<name>.json (no .json).
// Also matches the ChannelContract.Name the plugin manifest advertises.
PlexumChannelName string
// AgentID this channel routes to (also recorded in Plexum's channel
// registry; we re-read here for plugin-internal convenience).
AgentID string
// FabricGuildNodeID — which Fabric guild owns the channel.
FabricGuildNodeID string
// FabricChannelID — the channel id within that guild.
FabricChannelID string
}
// On-disk shape; we ignore fields outside the `fabric` block.
type wireConfig struct {
AgentID string `json:"agent_id"`
Plugin string `json:"plugin"`
Fabric struct {
GuildNodeID string `json:"guild_node_id"`
ChannelID string `json:"channel_id"`
} `json:"fabric"`
}
// Load returns all FabricBindings discovered under channelsDir. Files
// whose `plugin` field doesn't match PluginName are silently skipped
// (other channel plugins coexist in the same dir). Files with our
// plugin name but missing fabric.{guild_node_id, channel_id} are
// errors — we won't silently route nowhere.
func Load(channelsDir string) ([]FabricBinding, error) {
entries, err := os.ReadDir(channelsDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("fabric/config: read %s: %w", channelsDir, err)
}
var out []FabricBinding
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(channelsDir, e.Name())
raw, rerr := os.ReadFile(path)
if rerr != nil {
return nil, fmt.Errorf("fabric/config: read %s: %w", path, rerr)
}
var w wireConfig
if jerr := json.Unmarshal(raw, &w); jerr != nil {
return nil, fmt.Errorf("fabric/config: parse %s: %w", path, jerr)
}
if w.Plugin != PluginName {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
if w.AgentID == "" {
return nil, fmt.Errorf("fabric/config: %s missing agent_id", path)
}
if w.Fabric.GuildNodeID == "" || w.Fabric.ChannelID == "" {
return nil, fmt.Errorf("fabric/config: %s missing fabric.{guild_node_id, channel_id}", path)
}
out = append(out, FabricBinding{
PlexumChannelName: name,
AgentID: w.AgentID,
FabricGuildNodeID: w.Fabric.GuildNodeID,
FabricChannelID: w.Fabric.ChannelID,
})
}
return out, nil
}
// ByFabricChannel indexes bindings by (guild_node_id, channel_id) for
// fast inbound lookup. Plugin builds this map once at startup.
type ByFabricChannel map[string]*FabricBinding
// Key composes the index key.
func Key(guildNodeID, channelID string) string {
return guildNodeID + "/" + channelID
}
// Index returns a ready-to-query ByFabricChannel.
func Index(bindings []FabricBinding) ByFabricChannel {
out := make(ByFabricChannel, len(bindings))
for i := range bindings {
b := bindings[i]
out[Key(b.FabricGuildNodeID, b.FabricChannelID)] = &b
}
return out
}

View File

@@ -0,0 +1,92 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoadHappyPath(t *testing.T) {
dir := t.TempDir()
write(t, dir, "team-x.json", `{
"agent_id": "alice",
"plugin": "plexum-fabric-channel",
"fabric": {"guild_node_id": "gn_1", "channel_id": "ch_x"}
}`)
write(t, dir, "team-y.json", `{
"agent_id": "bob",
"plugin": "plexum-fabric-channel",
"fabric": {"guild_node_id": "gn_2", "channel_id": "ch_y"}
}`)
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len = %d", len(got))
}
}
func TestLoadSkipsOtherPlugins(t *testing.T) {
dir := t.TempDir()
write(t, dir, "mine.json", `{"agent_id":"a","plugin":"plexum-fabric-channel","fabric":{"guild_node_id":"g","channel_id":"c"}}`)
write(t, dir, "other.json", `{"agent_id":"a","plugin":"another-plugin"}`)
write(t, dir, "no-plugin.json", `{"agent_id":"a"}`)
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].PlexumChannelName != "mine" {
t.Errorf("got = %+v", got)
}
}
func TestLoadErrorsOnMissingFabricFields(t *testing.T) {
dir := t.TempDir()
write(t, dir, "broken.json", `{"agent_id":"a","plugin":"plexum-fabric-channel"}`)
_, err := Load(dir)
if err == nil || !strings.Contains(err.Error(), "fabric") {
t.Errorf("err = %v", err)
}
}
func TestLoadErrorsOnMissingAgentID(t *testing.T) {
dir := t.TempDir()
write(t, dir, "broken.json", `{"plugin":"plexum-fabric-channel","fabric":{"guild_node_id":"g","channel_id":"c"}}`)
_, err := Load(dir)
if err == nil || !strings.Contains(err.Error(), "agent_id") {
t.Errorf("err = %v", err)
}
}
func TestLoadMissingDirEmpty(t *testing.T) {
got, err := Load(filepath.Join(t.TempDir(), "nope"))
if err != nil || got != nil {
t.Errorf("missing dir: err=%v got=%v", err, got)
}
}
func TestIndex(t *testing.T) {
bindings := []FabricBinding{
{PlexumChannelName: "a", AgentID: "u", FabricGuildNodeID: "g1", FabricChannelID: "c1"},
{PlexumChannelName: "b", AgentID: "u", FabricGuildNodeID: "g1", FabricChannelID: "c2"},
}
idx := Index(bindings)
if idx[Key("g1", "c1")] == nil || idx[Key("g1", "c1")].PlexumChannelName != "a" {
t.Errorf("idx miss for c1")
}
if idx[Key("g1", "c2")] == nil || idx[Key("g1", "c2")].PlexumChannelName != "b" {
t.Errorf("idx miss for c2")
}
if idx[Key("g1", "ghost")] != nil {
t.Errorf("ghost entry")
}
}

274
internal/fabric/client.go Normal file
View File

@@ -0,0 +1,274 @@
// Package fabric is a thin Go port of Fabric.OpenclawPlugin's
// fabric-client.ts — Center auth + Guild REST. v0.1 covers what
// F-1 needs (auth/login, refresh, me/guilds, postMessage, listChannels,
// listMessages, channelMembers); the canvas / commands / sub-discussion
// surfaces arrive in later phases as their tools land.
package fabric
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// Session is what /auth/agent/login returns.
type Session struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
User SessionUser `json:"user"`
Guilds []GuildInfo `json:"guilds"`
GuildAccessTokens []GuildAccessToken `json:"guildAccessTokens"`
}
// SessionUser is the user metadata baked into the session.
type SessionUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// GuildInfo describes one guild this user belongs to.
type GuildInfo struct {
NodeID string `json:"nodeId"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Status string `json:"status"`
Purpose *string `json:"purpose,omitempty"`
}
// GuildAccessToken pairs a per-guild short-lived JWT with the guild node.
type GuildAccessToken struct {
GuildNodeID string `json:"guildNodeId"`
Token string `json:"token"`
}
// Client is a thin wrapper around net/http.Client.
type Client struct {
CenterAPIBase string // e.g. "http://localhost:7001/api"
HTTP *http.Client
}
// New constructs a Client with a sensible default http client (30s timeout).
func New(centerAPIBase string) *Client {
return &Client{
CenterAPIBase: centerAPIBase,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// ---- low-level helpers ----
func (c *Client) do(ctx context.Context, method, url string, auth string, body any, extraHeaders map[string]string) ([]byte, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("fabric: marshal: %w", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, reader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("content-type", "application/json")
}
if auth != "" {
req.Header.Set("authorization", "Bearer "+auth)
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("fabric: %s %s: %w", method, url, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("fabric: %s %s -> %d: %s", method, url, resp.StatusCode, string(raw))
}
return raw, nil
}
func (c *Client) postJSON(ctx context.Context, url string, body any, auth string, out any) error {
raw, err := c.do(ctx, http.MethodPost, url, auth, body, nil)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
func (c *Client) getJSON(ctx context.Context, url, auth string, out any) error {
raw, err := c.do(ctx, http.MethodGet, url, auth, nil, nil)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
// ---- Center: auth ----
// AgentLogin exchanges an API key for a fresh session + guild tokens.
func (c *Client) AgentLogin(ctx context.Context, apiKey string) (*Session, error) {
var s Session
if err := c.postJSON(ctx, c.CenterAPIBase+"/auth/agent/login",
map[string]string{"apiKey": apiKey}, "", &s); err != nil {
return nil, err
}
return &s, nil
}
// Refresh trades a refresh token for a fresh access token (guild
// tokens are re-fetched separately via MeGuilds).
func (c *Client) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
var out RefreshResponse
if err := c.postJSON(ctx, c.CenterAPIBase+"/auth/refresh",
map[string]string{"refreshToken": refreshToken}, "", &out); err != nil {
return nil, err
}
return &out, nil
}
// RefreshResponse is the shape returned by /auth/refresh.
type RefreshResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
// MeGuilds returns the calling user's guild list + fresh per-guild tokens.
func (c *Client) MeGuilds(ctx context.Context, accessToken string) (*MeGuildsResponse, error) {
var out MeGuildsResponse
if err := c.getJSON(ctx, c.CenterAPIBase+"/auth/me/guilds", accessToken, &out); err != nil {
return nil, err
}
return &out, nil
}
// MeGuildsResponse subset of Session.
type MeGuildsResponse struct {
Guilds []GuildInfo `json:"guilds"`
GuildAccessTokens []GuildAccessToken `json:"guildAccessTokens"`
}
// ---- Guild: messaging ----
// PostMessage posts plain content to a channel as authorUserID.
func (c *Client) PostMessage(ctx context.Context, guildEndpoint, guildToken, channelID, content, authorUserID string) error {
_, err := c.do(ctx, http.MethodPost,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/messages",
guildToken,
map[string]string{"content": content, "authorUserId": authorUserID},
nil)
return err
}
// ChannelMembers lists members of a channel.
type ChannelMember struct {
UserID string `json:"userId"`
Bypass bool `json:"bypass,omitempty"`
}
func (c *Client) ChannelMembers(ctx context.Context, guildEndpoint, guildToken, channelID string) ([]ChannelMember, error) {
var out []ChannelMember
if err := c.getJSON(ctx,
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/members",
guildToken, &out); err != nil {
return nil, err
}
return out, nil
}
// ---- Guild: channel discovery + history ----
// Channel is the wire shape returned by /api/channels list/get.
type Channel struct {
ID string `json:"id"`
GuildID string `json:"guildId"`
Name string `json:"name"`
XType string `json:"xType"`
Kind string `json:"kind"`
IsPublic bool `json:"isPublic"`
Closed bool `json:"closed"`
LastSeq int `json:"lastSeq"`
CreatedAt string `json:"createdAt"`
Purpose *string `json:"purpose,omitempty"`
}
// ListChannels lists all channels in a guild visible to the calling user.
func (c *Client) ListChannels(ctx context.Context, guildEndpoint, guildToken, guildNodeID string) ([]Channel, error) {
var out []Channel
u := guildEndpoint + "/api/channels?guildId=" + url.QueryEscape(guildNodeID)
if err := c.getJSON(ctx, u, guildToken, &out); err != nil {
return nil, err
}
return out, nil
}
// Message is the wire shape of one message in history.
type Message struct {
MessageID string `json:"messageId"`
Seq int `json:"seq"`
Content string `json:"content"`
AuthorUserID string `json:"authorUserId"`
CreatedAt string `json:"createdAt"`
EditedAt *string `json:"editedAt"`
DeletedAt *string `json:"deletedAt"`
IsDeleted bool `json:"isDeleted"`
}
// MessagePage wraps a window of messages + pagination metadata.
type MessagePage struct {
Items []Message `json:"items"`
Page struct {
SeqFrom int `json:"seqFrom"`
SeqTo int `json:"seqTo"`
Limit int `json:"limit"`
Returned int `json:"returned"`
HasMore bool `json:"hasMore"`
NextExpectedSeq int `json:"nextExpectedSeq"`
HighestCommittedSeq int `json:"highestCommittedSeq"`
} `json:"page"`
}
// ListMessages fetches a window of messages by seq.
func (c *Client) ListMessages(ctx context.Context, guildEndpoint, guildToken, channelID string, opts ListMessagesOpts) (*MessagePage, error) {
qs := url.Values{}
if opts.SeqFrom > 0 {
qs.Set("seq_from", fmt.Sprint(opts.SeqFrom))
}
if opts.SeqTo > 0 {
qs.Set("seq_to", fmt.Sprint(opts.SeqTo))
}
if opts.Limit > 0 {
qs.Set("limit", fmt.Sprint(opts.Limit))
}
u := guildEndpoint + "/api/channels/" + url.PathEscape(channelID) + "/messages"
if encoded := qs.Encode(); encoded != "" {
u += "?" + encoded
}
var out MessagePage
if err := c.getJSON(ctx, u, guildToken, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListMessagesOpts is the optional paging window.
type ListMessagesOpts struct {
SeqFrom int
SeqTo int
Limit int
}

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)
}

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