diff --git a/workspace-server/internal/handlers/agent_card_reconcile.go b/workspace-server/internal/handlers/agent_card_reconcile.go new file mode 100644 index 000000000..b5fac314f --- /dev/null +++ b/workspace-server/internal/handlers/agent_card_reconcile.go @@ -0,0 +1,113 @@ +package handlers + +import "encoding/json" + +// agent_card_reconcile.go — server-side repair for the fleet-wide +// agent-card identity gap. +// +// Root cause: the runtime builds its AgentCard from config.name +// (workspace/main.py:198), and config.name is read from the +// CP-regenerated /configs/config.yaml whose `name:` field is the raw +// workspace UUID — NOT the friendly name the operator sees. The friendly +// name IS captured: POST /workspaces and PATCH /workspaces/:id (the +// canvas Details tab) write it to the trusted workspaces.name DB column. +// But /registry/register stores the runtime-supplied card verbatim +// (registry.go: `agent_card = EXCLUDED.agent_card`), so the stored card +// served at /.well-known/agent-card.json and returned to peers via +// agent_card_url ends up with name = UUID, description = "", role = null. +// +// Fix shape (deliberately minimal, no contract weakening): when the +// runtime-supplied card's `name` is empty or equals the workspace UUID +// (the placeholder the runtime had no better value for), the PLATFORM — +// not the agent — substitutes the friendly value from the trusted +// workspaces row. Identity stays platform-controlled: the agent never +// gains the ability to self-set its own name/role; the platform sources +// it from the operator-controlled DB column. We only ever FILL gaps +// (empty / UUID-placeholder); a card that already carries a real +// friendly name is never downgraded. +// +// list_peers / the /registry/:id/peers endpoint already resolve display +// names from workspaces.name directly (discovery.go / mcp_tools.go +// `SELECT w.id, w.name, ...`), so peer_name in delivered message tags +// was already correct — this fix closes the remaining surface: the +// agent_card blob itself (canvas Agent Card / Skills view, peer +// agent_card_url fetches, the well-known card). +// +// description / role degrade discovery the same way: an empty +// description and null role give peers nothing to reason about. We +// default description from the (now reconciled) name when blank and +// role from workspaces.role when the operator set one. + +// reconcileAgentCardIdentity patches identity gaps in a runtime-supplied +// agent card from the trusted workspace DB row. It returns the +// (possibly rewritten) card bytes and whether anything changed. On any +// failure (malformed JSON, nothing to fill) it returns the input bytes +// unchanged with changed=false so the caller can store them verbatim — +// this is strictly no-worse-than-before, never a regression. +// +// Pure function: no DB / HTTP / globals, so it is exhaustively +// unit-testable (agent_card_reconcile_test.go) without booting the +// handler or a sqlmock. +func reconcileAgentCardIdentity(card json.RawMessage, workspaceID, dbName, dbRole string) (json.RawMessage, bool) { + var m map[string]any + if err := json.Unmarshal(card, &m); err != nil || m == nil { + // Malformed card — not this function's job to reject it (the + // upsert stores it as-is and downstream readers handle bad + // JSON). Return verbatim so byte-for-byte behaviour is + // preserved on the failure path. + return card, false + } + + changed := false + + // name: fill only when empty or the UUID placeholder. A dbName that + // is itself the UUID is a placeholder row (registry.go INSERT seeds + // name = id before the canvas sets a friendly one) — not a friendly + // name, so it is not an eligible source. + cardName, _ := m["name"].(string) + if (cardName == "" || cardName == workspaceID) && + dbName != "" && dbName != workspaceID { + m["name"] = dbName + changed = true + } + + // description: when blank, default to the (reconciled) name so peers + // and the canvas Agent Card view have a non-empty human label + // instead of "". Mirrors the runtime's own + // `config.description or config.name` fallback (main.py:199) but + // applied to the registry copy where the runtime's fallback was the + // UUID. + if desc, _ := m["description"].(string); desc == "" { + if n, _ := m["name"].(string); n != "" && n != workspaceID { + m["description"] = n + changed = true + } + } + + // role: surface the operator-set workspaces.role when the card + // carries none. Discovery (peer_role) and the canvas Role row read + // workspaces.role directly; this just makes the standalone card + // self-describing too. Never overwrite a role the card already has. + if dbRole != "" { + if r, ok := m["role"].(string); !ok || r == "" { + m["role"] = dbRole + changed = true + } + } + + if !changed { + // No-op: return the original bytes untouched so callers that + // compare/store get byte-identical input (re-marshalling would + // reorder keys for no reason). + return card, false + } + + out, err := json.Marshal(m) + if err != nil { + // Re-marshal of a map we just unmarshalled should never fail; + // if it somehow does, fall back to the verbatim input rather + // than storing nothing. + return card, false + } + return out, true +} diff --git a/workspace-server/internal/handlers/agent_card_reconcile_test.go b/workspace-server/internal/handlers/agent_card_reconcile_test.go new file mode 100644 index 000000000..75d5976b6 --- /dev/null +++ b/workspace-server/internal/handlers/agent_card_reconcile_test.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "encoding/json" + "testing" +) + +// TestReconcileAgentCardIdentity covers the server-side backfill that +// repairs the fleet-wide agent-card identity gap (internal#XXX): the +// runtime POSTs /registry/register with agent_card.name = the workspace +// UUID (because the CP-regenerated /configs/config.yaml sets name: ) +// while the trusted workspaces.name DB column — the value the canvas +// Details tab shows and lets the operator edit — holds the friendly +// name ("Claude Code Agent"). The platform reconciles them from the DB +// row (NOT from the agent — identity stays platform-controlled, not +// self-mutable). +func TestReconcileAgentCardIdentity(t *testing.T) { + const wsID = "3b81321b-1ec7-488c-96f7-72c42a968da6" + + tests := []struct { + name string + card string + dbName string + dbRole string + wantName string + wantDesc string + wantRole string + wantChanged bool + }{ + { + name: "name is the workspace UUID — backfill from DB", + card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":"","capabilities":{"streaming":true}}`, + dbName: "Claude Code Agent", + dbRole: "", + wantName: "Claude Code Agent", + wantDesc: "Claude Code Agent", + wantRole: "", + wantChanged: true, + }, + { + name: "empty name — backfill from DB", + card: `{"name":"","description":"x"}`, + dbName: "ops-agent", + dbRole: "sre", + wantName: "ops-agent", + wantDesc: "x", + wantRole: "sre", + wantChanged: true, + }, + { + name: "role null in card, DB has role — backfill role only", + card: `{"name":"Reviewer","description":"Senior reviewer"}`, + dbName: "Reviewer", + dbRole: "code-reviewer", + wantName: "Reviewer", + wantDesc: "Senior reviewer", + wantRole: "code-reviewer", + wantChanged: true, + }, + { + name: "card already has a real friendly name — do NOT clobber it", + // A richer card (e.g. an external channel agent) must win; + // the platform only fills gaps, never downgrades. + card: `{"name":"Claude Code (channel)","description":"Local Claude Code session bridged","role":"assistant"}`, + dbName: "hongming-pc", + dbRole: "operator", + wantName: "Claude Code (channel)", + wantDesc: "Local Claude Code session bridged", + wantRole: "assistant", + wantChanged: false, + }, + { + name: "no DB name available — leave UUID name untouched (no worse than before)", + card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":""}`, + dbName: "", + dbRole: "", + wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6", + wantDesc: "", + wantRole: "", + wantChanged: false, + }, + { + name: "dbName equals UUID (placeholder row) — not a friendly name, leave untouched", + card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6"}`, + dbName: "3b81321b-1ec7-488c-96f7-72c42a968da6", + dbRole: "", + wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6", + wantDesc: "", + wantRole: "", + wantChanged: false, + }, + { + name: "malformed card JSON — return unchanged, no panic", + card: `{not json`, + dbName: "Claude Code Agent", + dbRole: "", + wantChanged: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, changed := reconcileAgentCardIdentity( + json.RawMessage(tc.card), wsID, tc.dbName, tc.dbRole, + ) + if changed != tc.wantChanged { + t.Fatalf("changed = %v, want %v", changed, tc.wantChanged) + } + if !tc.wantChanged { + // Unchanged path must return the input bytes verbatim. + if string(out) != tc.card { + t.Fatalf("unchanged path mutated bytes:\n got %s\n want %s", out, tc.card) + } + return + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("output not valid JSON: %v (%s)", err, out) + } + if g, _ := got["name"].(string); g != tc.wantName { + t.Errorf("name = %q, want %q", g, tc.wantName) + } + if g, _ := got["description"].(string); g != tc.wantDesc { + t.Errorf("description = %q, want %q", g, tc.wantDesc) + } + if tc.wantRole != "" { + if g, _ := got["role"].(string); g != tc.wantRole { + t.Errorf("role = %q, want %q", g, tc.wantRole) + } + } + }) + } +} + +// TestReconcileAgentCardIdentity_PreservesOtherFields ensures the +// reconcile is a minimal in-place patch — capabilities, version, +// skills and any unknown future fields survive untouched. +func TestReconcileAgentCardIdentity_PreservesOtherFields(t *testing.T) { + card := `{"name":"ws-uuid","description":"","version":"1.0.0",` + + `"capabilities":{"streaming":true,"pushNotifications":true},` + + `"skills":[{"id":"a","name":"a"}],"configuration_status":"ready"}` + out, changed := reconcileAgentCardIdentity( + json.RawMessage(card), "ws-uuid", "Friendly Name", "", + ) + if !changed { + t.Fatal("expected changed = true") + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got["version"] != "1.0.0" { + t.Errorf("version not preserved: %v", got["version"]) + } + if got["configuration_status"] != "ready" { + t.Errorf("configuration_status not preserved: %v", got["configuration_status"]) + } + caps, ok := got["capabilities"].(map[string]any) + if !ok || caps["streaming"] != true { + t.Errorf("capabilities not preserved: %v", got["capabilities"]) + } + skills, ok := got["skills"].([]any) + if !ok || len(skills) != 1 { + t.Errorf("skills not preserved: %v", got["skills"]) + } +} diff --git a/workspace-server/internal/handlers/registry.go b/workspace-server/internal/handlers/registry.go index 65a853058..48b9c9285 100644 --- a/workspace-server/internal/handlers/registry.go +++ b/workspace-server/internal/handlers/registry.go @@ -327,7 +327,33 @@ func (h *RegistryHandler) Register(c *gin.Context) { } } - agentCardStr := string(payload.AgentCard) + // Reconcile the runtime-supplied card's identity fields against the + // trusted workspaces row before storing. The runtime builds its card + // from config.name, which the CP-regenerated /configs/config.yaml + // sets to the workspace UUID — so without this the stored card + // served at /.well-known/agent-card.json and returned to peers via + // agent_card_url has name = UUID, description = "", role = null even + // though the operator-controlled workspaces.name holds the friendly + // name the canvas shows. We only FILL gaps from the DB (never + // downgrade a card that already carries a real name); identity stays + // platform-controlled — the agent cannot self-set these. Best-effort: + // a lookup failure leaves the card exactly as the runtime sent it + // (no-worse-than-before). See agent_card_reconcile.go. + reconciledCard := payload.AgentCard + { + var dbName, dbRole sql.NullString + if qErr := db.DB.QueryRowContext(ctx, + `SELECT name, role FROM workspaces WHERE id = $1`, payload.ID, + ).Scan(&dbName, &dbRole); qErr == nil { + if rc, did := reconcileAgentCardIdentity( + payload.AgentCard, payload.ID, dbName.String, dbRole.String, + ); did { + reconciledCard = rc + log.Printf("Registry register: reconciled agent_card identity for %s from workspaces row", payload.ID) + } + } + } + agentCardStr := string(reconciledCard) // urlForUpsert: poll-mode workspaces don't need a URL. Empty input // becomes NULL via sql.NullString so the row's URL stays clean (the @@ -413,10 +439,12 @@ func (h *RegistryHandler) Register(c *gin.Context) { } } - // Broadcast WORKSPACE_ONLINE + // Broadcast WORKSPACE_ONLINE — use the reconciled card so the canvas + // Agent Card view live-updates with the friendly name, matching what + // was just persisted (not the runtime's raw UUID-name card). if err := h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOnline), payload.ID, map[string]interface{}{ "url": cachedURL, - "agent_card": payload.AgentCard, + "agent_card": reconciledCard, "delivery_mode": effectiveMode, }); err != nil { log.Printf("Registry broadcast error: %v", err)