Skip to content
28 changes: 28 additions & 0 deletions workspace/backend/alembic/versions/040_add_member_display_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""Add workspace_members.display_name — user-set label for an agent.

Revision ID: 040
Revises: 039
Create Date: 2026-08-18

Any script is allowed (Chinese, emoji, ...). agent_name remains the ASCII
identity used for @mentions, routing and storage keys; UIs fall back to it
when display_name is null.
"""

from alembic import op
import sqlalchemy as sa


revision = "040"
down_revision = "039"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column("workspace_members", sa.Column("display_name", sa.Text(), nullable=True))


def downgrade() -> None:
op.drop_column("workspace_members", "display_name")
3 changes: 3 additions & 0 deletions workspace/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ class WorkspaceMember(Base):

workspace_id = Column(UUID(as_uuid=False), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
agent_name = Column(Text, nullable=False)
# Free-form label shown in UIs (any script, incl. CJK); agent_name stays
# the ASCII identity used for mentions, routing and storage keys.
display_name = Column(Text, nullable=True)
role = Column(Text, default="member") # master | member | observer
agent_type = Column(Text, nullable=True) # "claude", "openclaw", etc.
server_host = Column(Text, nullable=True) # hostname/IP where agent runs
Expand Down
77 changes: 71 additions & 6 deletions workspace/backend/app/mods/workspace_mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,32 @@ async def _handle_agent_join(event: Event, ctx: PipelineContext) -> Optional[Eve
import uuid as _uuid
from app.models import WorkspaceMember

from app import naming

db = ctx.extra["db"]
workspace = ctx.extra["workspace"]
agent_name = event.payload.get("agent_name") if event.payload else None
if not agent_name:
logger.warning("workspace_mod: agent.join missing agent_name in payload")
return None

# agent_name is inserted verbatim into router prompts and participant
# lists — apply the shared character policy here (post-auth), covering
# /v1/join and raw /v1/events alike.
name_problem = naming.agent_name_problem(agent_name)
if name_problem:
logger.info(
"workspace_mod: refused join in %s — %s", workspace.id, name_problem,
)
event.metadata["reject_reason"] = "invalid_agent_name"
event.metadata["reject_detail"] = f"Invalid agent name: {name_problem}"
raise EventRejected("workspace_mod", "invalid_agent_name")

# Take the namespace lock BEFORE reading membership: two concurrent joins
# of the same name could otherwise both see existing=None and collide on
# the primary key instead of the second one rotating the session.
naming.lock_member_namespace(db, workspace.id)

existing = db.execute(
select(WorkspaceMember).where(
WorkspaceMember.workspace_id == workspace.id,
Expand Down Expand Up @@ -109,7 +128,31 @@ async def _handle_agent_join(event: Event, ctx: PipelineContext) -> Optional[Eve
agent_name, workspace.id,
)
else:
# New member: its agent_name enters the shared name/alias namespace,
# so it must not equal another member's display_name. Runs after
# AuthMod, under the namespace lock taken above.
clash = naming.find_alias_clash(
db, workspace.id, agent_name, exclude_agent=agent_name,
)
if clash:
logger.info(
"workspace_mod: refused join of %s in %s — clashes with display name of %s",
agent_name, workspace.id, clash,
)
event.metadata["reject_reason"] = "display_name_conflict"
event.metadata["reject_detail"] = (
f"Agent name '{agent_name}' conflicts with the display name "
f"of member '{clash}'"
)
raise EventRejected("workspace_mod", "display_name_conflict")

# Role is caller-supplied (raw /v1/events can claim anything,
# including non-strings that would make the frozenset lookup throw) —
# whitelist it so it can't smuggle text into prompts or grant an
# unknown role.
role = event.payload.get("role", "member")
if not isinstance(role, str) or role not in naming.ALLOWED_ROLES:
role = "member"
member = WorkspaceMember(
workspace_id=workspace.id,
agent_name=agent_name,
Expand All @@ -129,7 +172,7 @@ async def _handle_agent_join(event: Event, ctx: PipelineContext) -> Optional[Eve

# Enrich event metadata with resolved info + session_id so the
# router returns it to the joining client.
event.metadata["role"] = existing.role if existing else event.payload.get("role", "member")
event.metadata["role"] = existing.role if existing else role
event.metadata["network_id"] = str(workspace.id)
event.metadata["session_id"] = new_session_id
return event
Expand Down Expand Up @@ -682,6 +725,18 @@ def _master_targets(event, channel, mentions: List[str]) -> List[str]:
return [master]


def _prompt_inline(text: str) -> str:
"""Flatten user-controlled text for safe inline use in the router prompt.

Display names and descriptions come from users; control characters or
Unicode line/paragraph separators in them could forge extra
participant/instruction lines. Delegates to the shared Unicode-aware
sanitizer (Cc/Zl/Zp + bidi controls → spaces).
"""
from app.naming import sanitize_inline
return sanitize_inline(text)


_ROUTER_PROMPT = """\
You are a conversation router for a multi-agent workspace. Decide which \
agent should respond next to the LATEST message. Use judgment — read the \
Expand Down Expand Up @@ -836,7 +891,7 @@ async def _route_with_llm(
else:
label = source
text = (payload.get("content") or "")[:500] # Truncate long messages
history_lines.append(f"[{label}] {text}")
history_lines.append(f"[{_prompt_inline(label)}] {text}")

history = "\n".join(history_lines) if history_lines else "(no prior messages)"

Expand All @@ -861,21 +916,31 @@ async def _route_with_llm(
if members.get(n) and _member_is_online(members[n])
}
candidate_names = [n for n in participant_names if n in online_set] if online_set else participant_names
# Every field below is user-controlled at some entry point (legacy rows
# predate the join-time character policy), so flatten them all — a value
# must never span lines or the prompt structure can be forged.
participant_lines = []
for name in candidate_names:
m = members.get(name)
role = m.role if m else "member"
desc = m.description if m and m.description else ""
line = f" - {name} (role: {role})"
role = _prompt_inline(str(m.role)) if m and m.role else "member"
desc = _prompt_inline(m.description) if m and m.description else ""
line = f" - {_prompt_inline(name)} (role: {role})"
# Users may address an agent by its display name ("小明, 帮我看下")
# rather than its ASCII agent name — give the router the alias. The
# output contract stays next:<agent_name>.
alias = _prompt_inline(m.display_name) if m and m.display_name else ""
if alias and alias != name:
line += f" (also known as: {alias})"
if desc:
line += f" — {desc}"
participant_lines.append(line)
participants_str = "\n".join(participant_lines) if participant_lines else " (none)"

master = channel.master_agent or "(none)"
master = _prompt_inline(channel.master_agent) or "(none)"
sender = new_event.source
if sender.startswith("openagents:"):
sender = sender[len("openagents:"):]
sender = _prompt_inline(sender)

content = (new_event.payload or {}).get("content", "")[:500]

Expand Down
128 changes: 128 additions & 0 deletions workspace/backend/app/naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Workspace member naming domain.

Agent names and display names share ONE namespace per workspace: display
names are routable aliases (the LLM router and the @mention picker resolve
them), so any writer of either field must go through these helpers — the
member PATCH, the agent-join event handler, cloud-agent creation, the OAuth
callback and the Yumi backfill.
"""

import unicodedata
from typing import Optional

from sqlalchemy import select

MAX_DISPLAY_NAME_LENGTH = 64
MAX_AGENT_NAME_LENGTH = 64

# Cc = control chars (covers \n, \r, \t, \x85, DEL and the \x1c-\x1e file
# separators), Zl/Zp = Unicode line/paragraph separators (U+2028, U+2029).
# Together these cover everything str.splitlines() treats as a line break, so
# a display name can never span lines in the router prompt. Cf (format chars)
# stays allowed so emoji ZWJ sequences keep working — except the bidi
# controls, which can visually reorder surrounding text.
_BANNED_CATEGORIES = {"Cc", "Zl", "Zp"}
# The full Unicode Bidi_Control set.
_BIDI_CONTROLS = frozenset(
"\u061c" # ALM
"\u200e\u200f" # LRM RLM
"\u202a\u202b\u202c\u202d\u202e" # LRE RLE PDF LRO RLO
"\u2066\u2067\u2068\u2069" # LRI RLI FSI PDI
)


def _unsafe(ch: str) -> bool:
return unicodedata.category(ch) in _BANNED_CATEGORIES or ch in _BIDI_CONTROLS


def has_unsafe_chars(text: str) -> bool:
"""True if text contains control/line-separator/bidi-control characters."""
return any(_unsafe(c) for c in text)


# Roles an agent may claim on join; anything else downgrades to "member".
ALLOWED_ROLES = frozenset({"master", "member", "observer"})


def agent_name_problem(name: str) -> Optional[str]:
"""Reason an agent name can't enter the shared namespace, or None if fine.

agent_name is inserted verbatim into router prompts and participant
lists, so it gets the same character policy as display names. Called from
every post-auth entry point that can mint a member (join handler,
workspace creation).
"""
if not isinstance(name, str):
# Raw /v1/events payloads are unvalidated JSON — a number or list
# here must be a clean rejection, not an AttributeError 500.
return "agent name must be a string"
if not name or not name.strip():
return "empty agent name"
if name != name.strip():
return "leading or trailing whitespace in agent name"
if len(name) > MAX_AGENT_NAME_LENGTH:
return f"agent name longer than {MAX_AGENT_NAME_LENGTH} characters"
if has_unsafe_chars(name):
return "agent name contains control or line-separator characters"
return None


def sanitize_inline(text: Optional[str]) -> str:
"""Flatten user-controlled text for single-line prompt use.

Every unsafe character becomes a space, so a crafted display name or
description cannot forge extra participant/instruction lines.
"""
return "".join(" " if _unsafe(c) else c for c in (text or "")).strip()


def lock_member_namespace(db, workspace_id) -> None:
"""Serialize concurrent namespace writers on the workspace row.

Every check-then-write of agent_name/display_name must take this lock
first, otherwise two concurrent writers can both pass the clash check and
commit duplicate aliases. SELECT ... FOR UPDATE on PostgreSQL; a no-op on
SQLite, whose single-writer model serializes anyway.
"""
from app.models import Workspace
db.execute(
select(Workspace.id).where(Workspace.id == workspace_id).with_for_update()
).first()


def fold_alias(text: str) -> str:
"""Canonical form for namespace comparison.

NFKC collapses compatibility forms — fullwidth yumi becomes yumi — and
casefold() handles the case pairs lower() misses (ẞ → ss, İ). Plain SQL
lower() does neither, which let visually identical aliases coexist. Both
sides of every namespace comparison must go through this.
"""
return unicodedata.normalize("NFKC", text).casefold()


def find_alias_clash(db, workspace_id, name: str, exclude_agent: Optional[str] = None) -> Optional[str]:
"""Return the agent_name of a member whose agent_name OR display_name
equals `name` under fold_alias(), or None. `exclude_agent` skips the
member being edited / re-joining itself.

Comparison happens in Python rather than SQL: the databases' lower() has
no NFKC/casefold, and every caller already holds lock_member_namespace()
over a member list that is small by construction.
"""
from app.models import WorkspaceMember
target = fold_alias(name)
rows = db.execute(
select(WorkspaceMember.agent_name, WorkspaceMember.display_name).where(
WorkspaceMember.workspace_id == workspace_id,
)
).all()
for agent_name, display_name in rows:
if exclude_agent is not None and agent_name == exclude_agent:
continue
if fold_alias(agent_name) == target:
return agent_name
if display_name and fold_alias(display_name) == target:
return agent_name
return None
Loading
Loading