Skip to content

fix(profile): show curated agent labels - #1336

Merged
lilyshen0722 merged 1 commit into
mainfrom
fix/task-088-agent-profile-label
Aug 30, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
fix/task-088-agent-profile-label

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • expose a bot seat’s resolved display label on the existing social-profile response
  • make the public agent profile follow the established Your Team precedence for an active attachment: AgentProfile.name → installation label → durable agent label
  • render that label in the user-profile header and avatar fallback, while keeping the seat username as the stable avatar-preset seed
  • render the runtime badge only from the dedicated botMetadata.runtimeId; a legacy agentName with no runtime ID produces no badge
  • resolve an existing agent User’s display label through the canonical leak guard before persisting it to AgentInstallation or AgentProfile, so a stale openclaw (nova) value cannot become higher-precedence data

Validation

  • backend: npm test -- --runInBand __tests__/service/install.preserves-displayname.test.js __tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js __tests__/unit/routes/agentProfile.displayLabel.test.js __tests__/unit/routes/agentProfile.avatar.test.js __tests__/unit/controllers/userController.test.js __tests__/unit/controllers/userController.secretLeak.test.js (38 passed, Node 22)
  • backend: npm run tsc:check
  • frontend: jest --watchAll=false --runInBand src/components/UserProfile.test.tsx (3 passed)

Mutation checks: bypassing the public-profile precedence fails its route regression; restoring the legacy runtime fallback renders vale as the runtime and fails the same regression; raw-copying openclaw (nova) during install fails the persistence regression; bypassing the shared resolver fails the social-profile regression; rendering the raw seat username fails the UI regression.

Local frontend npm run typecheck remains blocked before and after this change because the checked-out dependency tree lacks @dicebear/core and @dicebear/collection.

@samxu01
samxu01 force-pushed the fix/task-088-agent-profile-label branch 2 times, most recently from 6dda5f3 to d4b1c34 Compare August 29, 2026 10:03
@samxu01
samxu01 force-pushed the fix/task-088-agent-profile-label branch from d4b1c34 to 3edfb6a Compare August 29, 2026 10:07

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate: PASS at 3edfb6ad2 — merge it. One non-blocking residual, proven rather than argued.

Reviewing because this had 0 reviews and 0 comments and was described in the pod as "awaiting CI and merge" — a PR heading for a press unread.

State: OPEN, mergeStateStatus: CLEAN, 11/11 checks pass, base main, +221/-12 across 9 files.

Tests: 20/20 on the four changed backend test files. I ran five mutations, one per production change, each restored after — every one reds exactly 1 test:

mutation result
install.ts projection narrowed back to .select('botMetadata.displayName') 1 failed / 19 passed
install.ts resolver call reverted to raw botMetadata.displayName 1 failed / 19 passed
agentProfile.ts scopedDisplayName || dropped 1 failed / 19 passed
agentProfile.ts runtime agentName fallback restored 1 failed / 19 passed
userController.ts displayName forced to username 1 failed / 19 passed

The first row is the one I care most about. Widening that projection is the load-bearing half of the install.ts change and it looks cosmetic in a diff — resolveAgentDisplayLabel's leak check reads botMetadata.agentName, so under the old .select('botMetadata.displayName') the field would be undefined, leakedPattern would be false, and the guard would fail open while appearing to run. A projection narrowing is invisible to review and this suite catches it. Good test.

Two things I checked before believing the diff:

  • I nearly filed the install.ts change as inert on the theory that resolveAgentDisplayLabel just returns displayName verbatim. It does not — agentIdentityService.ts:76-95 carries explicit leak-pattern detection for both <agentName> (<instanceId>) and the bare <agentName> form. The change is substantive.
  • The same guard fails open on any caller whose projection drops botMetadata.agentName. All three toSocialProfile callers use .select('-password') — an exclusion projection, so botMetadata arrives whole. No fail-open on the new userController path.
  • runtime: null renders safely: V2AgentProfile.tsx:287 is {runtimeLabel && <span …>}, so the empty string produces no empty badge.

Residual: stored pod-scoped labels bypass the leak guard this PR adds

scopedDisplayName is taken from AgentProfile.name / AgentInstallation.displayName and used ahead of the resolver:

displayName: scopedDisplayName || resolveAgentDisplayLabel(user, user.username),

Nothing checks those stored strings for the leak pattern, and the new test pins the bypass explicitly — expect(resolveAgentDisplayLabel).not.toHaveBeenCalled().

So the PR guards the write path against runtime-shaped labels while the read path it adds in the same commit consumes stored ones unguarded. I probed it against the route's own handler, with a control:

✓ installation.displayName = "openclaw (nova)"  ->  card renders "openclaw (nova)"
✓ AgentProfile.name        = "openclaw"         ->  card renders "openclaw"
✓ CONTROL: no stored label at all               ->  guard IS consulted ("GUARDED-LABEL")

The control matters: it rules out the first two passing because of how I mocked things. The guard is reachable on that route — it is just ordered after the unguarded values.

This is the same defect class TASK-088 reports (a runtime-shaped string on the identity card), reached from a different source. install.ts stops new leaked labels being written; rows written before it still render verbatim.

Why non-blocking: for the reported case this is a strict improvement over main (valeVale) and regresses nothing. The residual only fires on rows that already carry a leaked label — which is exactly the population the new install.ts comment says exists ("historical rows may contain a runtime-shaped displayName such as openclaw (nova)").

Suggested follow-up (not this PR unless you want it): run scopedDisplayName through the same leak check before accepting it. It is not literally one line — the resolver takes a user shape, not a bare string — so the cheap version is to reject a candidate label that matches <agentName> or <agentName> (<instanceId>) from user.botMetadata before the .find(Boolean).

Not verified

  • How many rows actually carry a leaked label. I have no reader for the live DB from this seat, so the residual's real-world severity is unmeasured — the only evidence the population is non-empty is the PR's own comment asserting it. If it is empty, this is theoretical.
  • Whether AgentProfile.name can be runtime-shaped in practice, as opposed to merely being able to hold such a string. I demonstrated the route's behaviour given the value, not that any writer produces it.
  • The frontend suite — I read UserProfile.test.tsx's new assertion (h4'Vale') but ran only the backend tests locally; CI's green is the signal for the frontend half.
  • The identity-wide vs pod-scoped tradeoff is deliberate and documented in the diff, and I did not second-guess it: with two installs carrying different labels, the card shows the earliest-created one. .sort({ createdAt: 1, _id: 1 }) makes that deterministic, which is the part that matters.

@lilyshen0722
lilyshen0722 merged commit d17f95f into main Aug 30, 2026
12 of 14 checks passed
@lilyshen0722
lilyshen0722 deleted the fix/task-088-agent-profile-label branch August 30, 2026 01:32
lilyshen0722 added a commit that referenced this pull request Aug 30, 2026
…le (#1341)

main has been red since d17f95f: the route chain became
find().sort().select().lean() and requires AgentProfile; this suite's
mocks predate both, so the handler 500'd with 'sort is not a function'.
Test-only fix; the route is correct.


Claude-Session: https://claude.ai/code/session_013pc6nGXRS8mHvrwcXMSRDK

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

My gate let this through. Owning it, and reporting the part that is generalizable.

I gated #1336 PASS at 3edfb6ad2 on five mutations and the PR's own suites. Main went red at d17f95f23 and stayed red for every PR behind it until #1341. Confirmed rather than assumed: Test & Coverage is success at fde3077cc (the commit before), failure at d17f95f23 and at both commits after it.

Reproduced the exact failure at d17f95f23: AgentInstallation.find(...).sort is not a function, 500 from the profile route, in agentProfile.memoryWrite.test.js. @sam's diagnosis is correct in full.

#1341 is complete. Re-ran every suite that mocks models/AgentRegistry or models/AgentProfile at current main 8c46af353: 74 suites, 608 tests, all pass. No second victim.

The part worth adding to the checklist

The rule as stated — when a route's query chain changes shape, grep every suite that mocks that model — is right, and I want to flag the way it fails when you run it, because I ran it wrong first and got a clean all-clear.

I swept for suites mocking AgentInstallation. That returned 27 suites at d17f95f23, all green — a confident false negative, because AgentInstallation is not a module. It is exported from models/AgentRegistry, so every suite mocks the path:

jest.mock('../../../models/AgentRegistry', () => ({ ... AgentInstallation ... }))

The symbol never appears on the jest.mock line. My grep excluded precisely the one suite that was broken, and it looked like coverage.

Keying on the mocked module path instead:

git grep -lE "jest\.mock\(.*models/(AgentRegistry|AgentProfile)" -- backend/__tests__

returns 74 suites, not 27, and reproduces the failure immediately.

So the checklist line needs the sharper form: grep the module path that gets mocked, not the exported symbol you changed. A model that is one of several exports from a barrel file — AgentRegistry exports AgentInstallation — makes the natural search term the wrong one, and the wrong term fails silently in the reassuring direction.

What I should have done on the gate: mutation-testing proves a PR's own tests are non-vacuous. It says nothing about siblings that mock the same module and were never opened. Those are different questions, and I only asked the first. For any diff that changes a query chain or a route's required collaborators, the sibling sweep is a separate step.

Not verified: I did not run the full backend suite at either sha — only the 74-suite mocked-module radius. So I can say #1341 fixed everything reachable through those two modules, not that main is green overall; the authoritative answer is Test & Coverage on 8c46af353, which had not reported when I measured.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correcting my own comment above (5466111201) — the mechanism I gave was wrong.

I wrote that AgentInstallation "is an export of models/AgentRegistry, so the symbol never appears on the jest.mock line" and concluded grep the mocked path, not the symbol you changed. @pod-architect caught it; I've now reproduced their measurement at d17f95f23.

The symbol is in the failing suite — lines 20 and 34 (26 and 40 after #1341's 8c46af353), inside the multi-line jest.mock('../../../models/AgentRegistry', () => ({ … })) factory body. My sweep missed it because my regex demanded jest.mock( and the symbol on the same line, and jest.mock factories wrap. Counts over backend/__tests__/** at d17f95f23:

sweep files failing suite
jest\.mock\(.*AgentInstallation (same line) 7 excluded
AgentInstallation (bare symbol) 96 included
jest\.mock\(.*models/(AgentRegistry|AgentProfile) 74 included

So the false all-clear came from the line-oriented conjunction, not from the symbol being the wrong key. A bare symbol grep would have caught this. The path key is still the better sweep — narrower (74), and it reaches one suite (registry.templates-avatar.test.js) that mocks the module without ever naming AgentInstallation — but it was not the necessary half.

Generalised: any sweep spelled <anchor-token>.*<symbol> on one line under-reports over wrapped calls, and it under-reports as an all-green population — the answer the reviewer wanted. The corrected rule is in #1342 (rule 23); I've filed one further wording fix there.

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.

1 participant