Skip to content

Commit 792879f

Browse files
committed
fix(agent-display): reject runtime-leak displayName + use canonical resolver in DM frame (#292)
Live nova↔theo smoke surfaced a runtime-label leak: the §9 inline DM frame rendered as "@nova (openclaw (nova))". Root cause: Nova's User row stores botMetadata.displayName = "openclaw (nova)" (literally `<agentName> (<instanceId>)`), and the §9 chain trusted it. Two fixes: - resolveAgentDisplayLabel detects + rejects the leak pattern (case- insensitive, exact tuple only — curated labels like "Strategist (Aria)" are kept). Falls through to instanceId. - agentMentionService §9 frame replaces its inline chain with a call to resolveAgentDisplayLabel — single source of truth. Tests: 5 new leak-pattern cases + 7 baseline. 12/12 pass. A one-shot backfill on dev sweeps the contaminated User rows post-deploy. The resolver guard catches future regressions defensively.
1 parent b97c960 commit 792879f

3 files changed

Lines changed: 87 additions & 7 deletions

File tree

backend/__tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,54 @@ describe('resolveAgentDisplayLabel', () => {
5757
};
5858
expect(resolveAgentDisplayLabel(user)).toBe('pixel');
5959
});
60+
61+
// Leak-pattern detection — defensive guard for historical contamination.
62+
// Some path (likely a DM-creation pre-2026-05-04) wrote
63+
// `botMetadata.displayName = "openclaw (nova)"` (i.e. literally
64+
// `${agentName} (${instanceId})`), and that string then surfaced in pod
65+
// names + the §9 inline DM frame. The resolver now detects + rejects this
66+
// shape and falls through to instanceId.
67+
describe('leak-pattern detection', () => {
68+
it('rejects displayName === "<agentName> (<instanceId>)" and falls through to instanceId', () => {
69+
const user = {
70+
username: 'openclaw-nova',
71+
botMetadata: { displayName: 'openclaw (nova)', agentName: 'openclaw', instanceId: 'nova' },
72+
};
73+
expect(resolveAgentDisplayLabel(user)).toBe('nova');
74+
});
75+
76+
it('rejects bare displayName === agentName (e.g. just "openclaw")', () => {
77+
const user = {
78+
username: 'openclaw-aria',
79+
botMetadata: { displayName: 'openclaw', agentName: 'openclaw', instanceId: 'aria' },
80+
};
81+
expect(resolveAgentDisplayLabel(user)).toBe('aria');
82+
});
83+
84+
it('is case-insensitive — "OpenClaw (Pixel)" still rejected', () => {
85+
const user = {
86+
username: 'openclaw-pixel',
87+
botMetadata: { displayName: 'OpenClaw (Pixel)', agentName: 'openclaw', instanceId: 'pixel' },
88+
};
89+
expect(resolveAgentDisplayLabel(user)).toBe('pixel');
90+
});
91+
92+
it('keeps a curated displayName that happens to share the agentName prefix', () => {
93+
// "Strategist (Aria)" is curated — agentName prefix is incidental, not
94+
// the literal pattern. Keep it.
95+
const user = {
96+
username: 'openclaw-aria',
97+
botMetadata: { displayName: 'Strategist (Aria)', agentName: 'openclaw', instanceId: 'aria' },
98+
};
99+
expect(resolveAgentDisplayLabel(user)).toBe('Strategist (Aria)');
100+
});
101+
102+
it('keeps a normal curated label unchanged', () => {
103+
const user = {
104+
username: 'openclaw-nova',
105+
botMetadata: { displayName: 'Nova', agentName: 'openclaw', instanceId: 'nova' },
106+
};
107+
expect(resolveAgentDisplayLabel(user)).toBe('Nova');
108+
});
109+
});
60110
});

backend/services/agentIdentityService.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,29 @@ export function resolveAgentDisplayLabel(
7070
if (!user) return safeFallback;
7171
const meta = user.botMetadata;
7272
const display = meta?.displayName?.trim();
73-
if (display) return display;
74-
const instanceId = meta?.instanceId?.trim();
73+
const agentName = meta?.agentName?.trim() || '';
74+
const instanceId = meta?.instanceId?.trim() || '';
75+
// Leak-pattern detection. If displayName is literally `<agentName> (<instanceId>)`
76+
// (e.g. "openclaw (nova)"), it's the runtime label leaking through — some
77+
// historical writer formatted displayName as `${agentName} (${instanceId})`
78+
// instead of using the curated label. Treat these as if displayName were
79+
// missing and fall through to instanceId. Matches both the
80+
// `<runtime> (<instance>)` form and the bare `<runtime>` form
81+
// (e.g. displayName === 'openclaw'). Also catches the case where
82+
// displayName equals just instanceId — the chain below would render the
83+
// same thing, but we drop the redundant pass here.
84+
const leakedPattern = (
85+
!!display
86+
&& !!agentName
87+
&& (
88+
display.toLowerCase() === agentName.toLowerCase()
89+
|| (
90+
!!instanceId
91+
&& display.toLowerCase() === `${agentName.toLowerCase()} (${instanceId.toLowerCase()})`
92+
)
93+
)
94+
);
95+
if (display && !leakedPattern) return display;
7596
if (instanceId && instanceId !== 'default') return instanceId;
7697
if (user.username) return user.username;
7798
return safeFallback;

backend/services/agentMentionService.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ const Pod = require('../models/Pod');
1010
const User = require('../models/User');
1111
// eslint-disable-next-line global-require
1212
const chatSummarizerService = require('./chatSummarizerService');
13+
// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports
14+
const { resolveAgentDisplayLabel } = require('./agentIdentityService') as {
15+
resolveAgentDisplayLabel: (
16+
user: { username?: string; botMetadata?: { displayName?: string; instanceId?: string; agentName?: string } } | null | undefined,
17+
fallback?: string,
18+
) => string;
19+
};
1320

1421
const ChatSummarizerService = chatSummarizerService.constructor as {
1522
getLatestPodSummary: (podId: string) => Promise<unknown>;
@@ -747,15 +754,17 @@ const enqueueDmEvent = async ({
747754
// which §9 of ADR-012 demonstrated is not sufficient. Either re-apply the
748755
// frame in that surface or move framing into AgentEventService.enqueue
749756
// when it becomes a meaningful chokepoint.
757+
// resolveAgentDisplayLabel is the canonical chain (botMetadata.displayName
758+
// → instanceId → username → fallback) plus leak-pattern detection that
759+
// skips displayName values shaped like `<agentName> (<instanceId>)` (the
760+
// historical runtime-label leak that produced labels like
761+
// "openclaw (nova)"). Using it here keeps the §9 frame consistent with
762+
// every other display surface (pod inspector, chat author chips, etc).
750763
const senderMeta = sender?.botMetadata || {};
751764
const senderInstanceLabel = (senderMeta.instanceId && senderMeta.instanceId !== 'default')
752765
? senderMeta.instanceId
753766
: '';
754-
const senderDisplay = (senderMeta.displayName?.trim()
755-
|| senderInstanceLabel
756-
|| sender?.username
757-
|| username
758-
|| 'peer').trim();
767+
const senderDisplay = resolveAgentDisplayLabel(sender, sender?.username || username || 'peer').trim();
759768
// senderHandle filters 'default' the same way as senderDisplay above —
760769
// otherwise an agent on the literal 'default' instanceId would render as
761770
// "@default (DisplayName)", which is meaningless.

0 commit comments

Comments
 (0)