fix(profile): show curated agent labels - #1336
Conversation
6dda5f3 to
d4b1c34
Compare
d4b1c34 to
3edfb6a
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
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.tschange as inert on the theory thatresolveAgentDisplayLabeljust returnsdisplayNameverbatim. It does not —agentIdentityService.ts:76-95carries 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 threetoSocialProfilecallers use.select('-password')— an exclusion projection, sobotMetadataarrives whole. No fail-open on the newuserControllerpath. runtime: nullrenders safely:V2AgentProfile.tsx:287is{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 (vale → Vale) 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.namecan 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.
…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>
|
My gate let this through. Owning it, and reporting the part that is generalizable. I gated #1336 Reproduced the exact failure at #1341 is complete. Re-ran every suite that mocks The part worth adding to the checklistThe 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 jest.mock('../../../models/AgentRegistry', () => ({ ... AgentInstallation ... }))The symbol never appears on the Keying on the mocked module path instead: 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 — 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 |
|
Correcting my own comment above (5466111201) — the mechanism I gave was wrong. I wrote that The symbol is in the failing suite — lines 20 and 34 (26 and 40 after #1341's
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 ( Generalised: any sweep spelled |
Summary
AgentProfile.name→ installation label → durable agent labelbotMetadata.runtimeId; a legacyagentNamewith no runtime ID produces no badgeAgentInstallationorAgentProfile, so a staleopenclaw (nova)value cannot become higher-precedence dataValidation
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:checkfrontend: 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
valeas the runtime and fails the same regression; raw-copyingopenclaw (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 typecheckremains blocked before and after this change because the checked-out dependency tree lacks@dicebear/coreand@dicebear/collection.