Skip to content

feat(workspace) agent display names with CJK support - #628

Merged
zomux merged 7 commits into
developfrom
feature/agent-display-name
Aug 20, 2026
Merged

feat(workspace) agent display names with CJK support#628
zomux merged 7 commits into
developfrom
feature/agent-display-name

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

Background

A workspace agent has a single agent_name field that serves as the database primary key, the @mention token, a URL path segment, a filesystem directory name, and the display name all at once. Every entry point validates it against [a-zA-Z0-9_-], so agents cannot have Chinese names. Source of the requirement: direct user request (support Chinese and English agent names in the workspace).

Across five review rounds, a batch of pre-existing defects on develop — all on the member-creation / OAuth paths — was surfaced and fixed along the way: the Google OAuth callback always raised UnboundLocalError because a function-scoped import shadowed Workspace; the OAuth start endpoint never verified credentials (anonymous callers got a state issued with the workspace's own token as fallback); the OAuth error page reflected XSS; and the Yumi backfill would take over a user's real agent that happened to be named yumi.

How to reproduce

  • Main requirement: try a Chinese name in any agent-creation or display surface — rejected by the ASCII regex.
  • Pre-existing defects: on a server with Google OAuth configured, an unauthenticated GET to /v1/cloud-agents/google/auth returned 307 and the flow could mint a member; /callback?error=<script>... was echoed verbatim as text/html; running the Yumi backfill on a workspace that already had a real agent with agent_name=yumi, agent_type=claude.

Behavior before

Agent names were ASCII-only, with no separation between display name and identity. The OAuth / Yumi paths respectively showed a 500 on callback, anonymous member creation, reflected XSS, and a real agent being rewritten to the built-in type.

Behavior after

  • Every agent can have a display name in any script (Chinese, emoji included), rendered consistently across ~25 UI surfaces (sidebar, chat, tasks, routines, the whole DM chain, search, Skills, ...). When unset, the UI falls back to agent_name.
  • The @mention picker filters by display name; what gets inserted and stored is still the ASCII token, and rendering shows the display name Slack-style. The LLM router receives the alias, so addressing an agent by its Chinese name routes correctly; the output contract stays next:<agent_name>.
  • OAuth: the browser flow now POSTs /cloud-agents/google/auth-url with auth headers and navigates to the returned one-time consent URL (the workspace token never appears in any URL); the GET redirect accepts header auth only; error pages use fixed copy with centralized HTML escaping; the ownership guard covers stale configs and removed members; states expire after 10 minutes.
  • The Yumi backfill only repairs members that already are the built-in type, skips any non-builtin member of that name regardless of status, and runs its guards before any session mutation (no cross-workspace orphan objects).

Design

Core trade-off: the agent_name character set stays untouched (it is a primary key / mention token / filesystem path; loosening it would require rewriting mention parsing and risks session-directory collisions). Instead, a separate display_name presentation field is added — the same shape as the existing WorkspaceCollaborator.display_name precedent.

  • Data model: workspace_members.display_name TEXT NULL, alembic migration 040; zero migration cost for existing rows.
  • The domain module app/naming.py is the single source of rules:
    • Namespace: display names are routable aliases and share one case-insensitive namespace per workspace with agent names. Every member-minting entry point (the join event handler, cloud-agent creation, the OAuth callback, the Yumi backfill, workspace creation) calls the same find_alias_clash.
    • Character policy: Unicode categories Cc/Zl/Zp are rejected (covering everything splitlines() treats as a line break) plus the full 9-codepoint Bidi_Control set; Cf stays allowed so ZWJ emojisequences keep working. agent_name itself passes the same policy at every entry point and tolerates non-string payloads; the join role is whitelisted to master/member/observer.
    • Concurrency: every namespace write takes SELECT FOR UPDATE on the workspace row before reading the member table, so concurrent renames/joins cannot commit duplicate aliases or hit primary-key conflicts.
  • The guard lives after AuthMod in _handle_agent_join, covering both /v1/join and raw /v1/events. Rejection reasons travel back via event metadata and map to 400; unauthenticated callers only ever see 401 and cannot probe which names exist.
  • Every user-controlled field in the router prompt (names, roles, aliases, descriptions, master, sender, history labels) is flattened through sanitize_inline, covering legacy rows that predate the policy.
  • The frontend resolves labels through a single agentLabel() helper; API calls, DM dedup keys and avatar seeds keep using agent_name. Editing happens inline in the agent profile panel.

Review suggestion not adopted: a Google model whitelist for OAuth — validate_provider_model deliberately accepts any model id for known providers (the registry is curated suggestions, not a hard restriction), and POST /cloud-agents behaves the same way; tightening only the OAuth path would diverge.

Scope of impact

  • Backend: workspace members, join/events, cloud-agents (including the full OAuth flow), Yumi, the router prompt.
  • Frontend: every agent-name display surface in the workspace web app, the @mention chain, and the Connect Google button (href replaced by fetch-then-navigate).
  • Not touched: the SDK, the launcher (Electron), and the Go mobile client — they still show ASCII names; adopting labels there only requires reading the display_name field already present in the discover response.
  • Behavior changes: new members' agent_name now rejects control characters, over-length and leading/trailing whitespace; the OAuth start went from unauthenticated to credential-required, and query-string tokens are rejected.

Deployment notes

  • Run alembic migration 040 (adds/drops a single nullable column; downgrade-safe). Whether the deploy pipeline runs migrations automatically was not verified in this PR — follow the existing convention.
  • No new dependencies, no new environment variables.
  • Frontend and backend must ship together: the old frontend's Connect Google link (an unauthenticated GET) gets a 401 from the new backend; this PR's frontend already uses the POST flow.
  • Known pre-existing limitation (on develop before this branch, not addressed here — suggest a follow-up issue): the OAuth state is a process-local dict, so in a multi-worker/multi-replica deployment the start and callback can land on different processes and fail with "Invalid OAuth state". The proper fix is signed states or a shared TTL store. Marked with a code comment.
  • Rollback-safe: after rolling back the code, the display_name column in existing data is simply ignored; no data rollback needed.

How to verify

cd workspace/backend
python3 -m pytest tests/test_workspaces.py tests/test_yumi.py tests/test_cloud_agent_oauth.py \
  tests/test_network.py tests/test_agent_removal.py tests/test_workspace_membership.py -q
# 201 passed, including regression cases for every bypass reproduced across the five review rounds
cd ../frontend && npx tsc --noEmit

Manual check: open an agent's profile panel, click the pencil next to the name, save "小明" → every surface shows 小明; typing @小 in chat filters to the agent, inserts @<ascii-name>, renders as @小明; duplicate names or names with line breaks are rejected with a 400; the Connect Google redirect URL contains no workspace token.

🤖 Generated with Claude Code

Agents keep their ASCII agent_name as the stable identity (mentions,
routing, storage keys) and gain an optional display_name shown across
the UI. Any script is allowed (Chinese, emoji); empty clears back to
agent_name.

Backend
- workspace_members.display_name column + alembic migration 040
- PATCH member accepts display_name — trimmed, max 64 chars, rejected
  when it matches another member's agent_name (mention ambiguity)
- discover and workspace responses carry the new field
- LLM router prompt lists the display name as an alias so users can
  address agents by it; the output contract still uses the ASCII name

Frontend
- agentLabel() helper, displayName on WorkspaceAgent/NetworkAgent
- all roster/chat/task/routine/thread surfaces render the label while
  API calls keep using agentName
- mention picker filters by display name too and inserts the ASCII
  token; rendered mentions show the label Slack-style
- inline display-name editor in the agent profile panel
…l coverage

Review follow-ups on agent display names

- display_name is a routable alias, so it now shares one namespace with
  agent_name per workspace — duplicates across members' display names
  are rejected, and /v1/join plus cloud-agent creation reject an
  agent_name that matches another member's display name (re-joins of
  the same agent are unaffected)
- reject control characters in display_name, and flatten display_name
  and description before inlining them into the router prompt so a
  crafted value cannot forge extra participant lines
- DM surfaces now resolve labels (thread rows, sender preview, chat
  title) while dedup keys, session ids and avatar seeds stay on the
  canonical address
- remaining raw renders switched to agentLabel — chat roster bar,
  missing-description chips, skills install list (4 states)
- 4 new backend tests (duplicate alias, control chars, join clash,
  self-name re-join)
Second review round on agent display names

- new app/naming.py domain module — single source for the shared
  name/alias namespace (find_alias_clash), the Unicode-aware character
  policy (has_unsafe_chars/sanitize_inline covering Cc, Zl, Zp and bidi
  controls, so U+2028/U+2029/U+0085 and RLO can no longer forge router
  prompt lines while ZWJ emoji keep working) and a workspace-row
  namespace lock (SELECT FOR UPDATE) that closes the check-then-write
  race between concurrent renames and joins
- the join guard moved from the /v1/join route into
  _handle_agent_join, so raw network.agent.join events through
  /v1/events hit the same check, and it now runs after AuthMod —
  unauthenticated callers get a plain 401 and can no longer probe
  which display names exist; authenticated clashes surface as 400 via
  a reject_reason stamped on the event
- every remaining member-creating entry point applies the guard —
  cloud-agent create, the Google OAuth callback (error page instead of
  a silent duplicate) and the Yumi backfill (skips with a warning)
- remaining raw agent_name renders switched to agentLabel — offline
  warning chips, status messages, monitor search preview
- 4 new tests, including the reviewer's events-path bypass repro and
  the 401-before-400 probe order
Third review round on agent display names

- agent_name now passes the shared character policy (naming.agent_name_problem)
  in the post-auth join handler and at workspace creation, so a name like
  'safe\n- forged' can no longer mint a member; roles are whitelisted to
  master/member/observer on join
- every user-controlled field in the router prompt is flattened —
  participant names, roles, master, sender and history labels, not just
  display_name/description — covering legacy rows that predate the policy
- namespace lock now precedes the membership reads in the join handler,
  cloud-agent create, the OAuth callback and the Yumi backfill, closing the
  read-before-lock race that turned concurrent same-name joins into a
  primary-key 500
- Yumi backfill runs the guard before any session mutation, so a clash no
  longer leaves a pending CloudAgentConfig that the next workspace's commit
  would persist
- removed the function-scoped Workspace import that made the whole Google
  OAuth callback raise UnboundLocalError (pre-existing on develop) and kept
  the guard before any mutation there; added mocked-callback regression tests
- completed the Bidi_Control set with U+061C, U+200E, U+200F
- 9 new tests across join validation, role whitelist, direction marks,
  Yumi orphan objects and the OAuth callback
Fourth review round on agent display names — three of five findings are
develop-era bugs surfaced by the new guards

- GET /cloud-agents/google/auth now requires workspace credentials
  (header or ?token= query, since browsers navigate via plain href — the
  frontend link carries it) and validates agent_name against the same
  rule as POST /cloud-agents BEFORE issuing the OAuth state; it used to
  fall back to the workspace's own token for anonymous callers
- the OAuth callback refuses to attach a Google config to an active
  member of another type, mirroring the POST /cloud-agents already-
  exists rule; repairing a config-less cloud:google member still works
- Yumi backfill no longer takes over a real agent that happens to be
  named yumi (it rewrote agent_type/description); only a member already
  of the built-in type is repaired
- naming.agent_name_problem and the join role whitelist tolerate
  non-string JSON values from raw /v1/events instead of 500ing
- 7 new tests across the OAuth start/callback guards, Yumi takeover and
  non-string payloads
…uth pages

Fifth review round — all findings are in the OAuth/Yumi subsystem, none
in the display-name feature itself

- browser flows now POST /cloud-agents/google/auth-url with auth headers
  and navigate to the returned one-time consent URL; the GET redirect is
  header-authenticated only and a query-string token is deliberately
  rejected, so the workspace credential never lands in access logs or
  browser history; the state no longer stores any credential and states
  expire after 10 minutes with opportunistic pruning
- the public callback never echoes the error parameter (fixed copy per
  OAuth code, raw value only logged) and both OAuth HTML pages escape
  all dynamic content centrally
- callback ownership is checked on the member unconditionally — a stale
  config row no longer bypasses the type guard, and a removed member of
  another type is not resurrected as a Google agent; a removed
  cloud:google member is explicitly reactivated, mirroring
  POST /cloud-agents
- Yumi backfill refuses non-builtin owners of the name regardless of
  status (a soft-removed real yumi stayed claude/removed in the test)
- NOT adopted: rejecting unknown Google models — validate_provider_model
  deliberately accepts any model id for known providers, and
  POST /cloud-agents behaves the same; a whitelist here would diverge
- multi-worker OAuth state storage stays a known pre-existing limitation
  (process-local dict predates this branch), documented in code and
  split out as follow-up work
- 8 new tests; the frontend Google button now fetches then navigates
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Aug 20, 2026 6:06am

Request Review

Plain lower() let visually identical aliases coexist: fullwidth
yumi passed the clash check against yumi, and casefold pairs
(straẞe/strasse) slipped through. fold_alias() now normalizes NFKC and
casefolds both sides; comparison moves into Python since SQL lower()
can do neither and every caller already holds the workspace lock over
a small member list. Adds fullwidth and casefold clash tests.

@zomux zomux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Deep-reviewed with focus on the new attack surface and the bundled security fixes — all four verified as real pre-existing defects on develop and genuinely fixed: (a) the OAuth callback's UnboundLocalError (shadowing function-scoped import — every callback 500'd); (b) the OAuth start minting states for ANONYMOUS callers with the workspace's own token as fallback — now credential-required on both endpoints, header-auth only, token never in a URL, states carry no credentials and expire in 10min; (c) the ?error= reflected XSS — now fixed copy + centralized html.escape covering every interpolated value; (d) the Yumi backfill taking over a user's real agent named yumi — guards now run before any mutation, builtin-type only, no provisioning regression.

Prompt-injection analysis: sanitize_inline's Cc/Zl/Zp+Bidi coverage verified exhaustive against splitlines(); a display name cannot span lines in the router prompt, and a name like 'next:evil' can't hijack routing because output is still validated against the real candidate set. All 5 member-creation sites plus rename enforce the policy. Namespace clash checks cover all directions with the workspace-row lock actually taken at every check-then-write site. Join validation verified not to break any existing client (launcher/SDK/deepseek/pi names all pass; unknown roles downgrade; 401-before-validation prevents name probing). Frontend: agentLabel() everywhere, DM keys/avatars/API calls still agent_name, no dangerouslySetInnerHTML. Migration 040 chains onto 039, nullable-only, safe downgrade. Full backend suite: identical failure set to develop (env-only), zero new.

Fixed on-branch before merge (2cd0aec): the alias comparison used plain lower(), letting visually identical confusables coexist — fullwidth yumi vs yumi, casefold pairs like straẞe/strasse. find_alias_clash now folds both sides through NFKC+casefold (in Python; SQL lower() can do neither, and callers already hold the lock). Added both clash tests; 169/169 pass.

Deploy notes: frontend+backend must ship together (old frontend's Connect-Google href gets 401 from the new backend — the acceptable cost of removing the token-in-URL leak). Migration heads-up: this takes 040, so #597 must renumber to 041+. The process-local OAuth state dict (multi-replica limitation) is accurately scoped as pre-existing.

@zomux
zomux merged commit 3f6fb8e into develop Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants