diff --git a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js index 3260fe67..435ce7f2 100644 --- a/backend/__tests__/unit/controllers/authController.guideIdentity.test.js +++ b/backend/__tests__/unit/controllers/authController.guideIdentity.test.js @@ -45,25 +45,35 @@ jest.mock('../../../services/agentMessageService', () => ({ postMessage: (...args) => mockPostMessage(...args), })); +const { createHash } = require('crypto'); const authController = require('../../../controllers/authController'); +// The per-user-agent identity convention (Sam, 2026-08-13): short, stable, +// and OPAQUE — the raw userId must not survive into any identity tier. +const conventionId = (userId) => `u${createHash('sha256').update(String(userId)).digest('hex').slice(0, 10)}`; + describe('createDefaultWorkspacePod guide identity fork (2026-08-13)', () => { beforeEach(() => jest.clearAllMocks()); - test('installs the guide under a userId-derived instanceId, never default', async () => { + test('installs the guide under the convention instanceId — short, opaque, never default', async () => { await authController.createDefaultWorkspacePod('User-1'); + const expected = conventionId('User-1'); const [filter, update] = mockInstallUpsert.mock.calls[0]; - expect(filter.instanceId).toBe('uuser-1'); - expect(update.$setOnInsert.instanceId).toBe('uuser-1'); + expect(filter.instanceId).toBe(expected); + expect(update.$setOnInsert.instanceId).toBe(expected); expect(filter.instanceId).not.toBe('default'); + // Convention shape: u + 10 hex. Neither the raw userId nor its lowercase + // form may appear in the identity. + expect(filter.instanceId).toMatch(/^u[a-f0-9]{10}$/); + expect(filter.instanceId).not.toContain('user-1'); expect(mockGetOrCreateAgentUser).toHaveBeenCalledWith('guide', expect.objectContaining({ - instanceId: 'uuser-1', + instanceId: expected, })); expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ agentName: 'guide', - instanceId: 'uuser-1', + instanceId: expected, })); }); @@ -72,7 +82,7 @@ describe('createDefaultWorkspacePod guide identity fork (2026-08-13)', () => { await authController.createDefaultWorkspacePod('bbbb2222'); const ids = mockInstallUpsert.mock.calls.map(([filter]) => filter.instanceId); - expect(ids).toEqual(['uaaaa1111', 'ubbbb2222']); + expect(ids).toEqual([conventionId('aaaa1111'), conventionId('bbbb2222')]); expect(new Set(ids).size).toBe(2); }); }); diff --git a/backend/controllers/authController.ts b/backend/controllers/authController.ts index 68cdafe4..a6fe70e9 100644 --- a/backend/controllers/authController.ts +++ b/backend/controllers/authController.ts @@ -168,11 +168,21 @@ const createDefaultWorkspacePod = async (userId: any) => { // ADR-003 keys the memory envelope by (agentName, instanceId) — so a // shared instanceId would give every user's Guide ONE memory doc: user // A's "my repo is X" surfacing in user B's workspace. Same leak class - // as the 2026-07-03 BYO incident, caught pre-data this time. The - // userId-derived instanceId forks memory, AgentRuns, caps, and the bot - // User row per user; displayName stays "Guide" everywhere (per-user - // guides never share a pod, so no render collision exists). - const guideInstanceId = `u${String(userId).toLowerCase()}`; + // as the 2026-07-03 BYO incident, caught pre-data this time. + // + // CONVENTION (Sam, 2026-08-13 — this is the per-user-agent identity + // rule, not a guide special case): `u` + first 10 hex of + // sha256(userId). Short enough to live in a mention handle, stable and + // derivable at signup with no lookup, and OPAQUE — the raw ObjectId + // must not survive into any identity tier (the first cut used + // u verbatim and leaked it through instanceId, username, and + // the collision-suffixed displayName; fleet review measured all + // three). 10 hex = 40 bits; the (agentName, podId, instanceId) upsert + // plus per-user uniqueness makes the astronomically-unlikely collision + // a visible install conflict, not a silent identity merge. + // eslint-disable-next-line global-require + const { createHash } = require('crypto'); + const guideInstanceId = `u${createHash('sha256').update(String(userId)).digest('hex').slice(0, 10)}`; await AgentInstallation.findOneAndUpdate( { agentName: guideApp.agentName, podId: pod._id, instanceId: guideInstanceId }, diff --git a/backend/scripts/migrate-guide-identity-convention.ts b/backend/scripts/migrate-guide-identity-convention.ts new file mode 100644 index 00000000..1d53a13c --- /dev/null +++ b/backend/scripts/migrate-guide-identity-convention.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/* + * Normalize Guide identities to the per-user-agent convention: + * instanceId = `u` + sha256(userId).slice(0, 10). + * + * Two legacy forms exist: + * 1. long form `u<24-hex-userId>` — the first per-user fork (#923) embedded + * the raw ObjectId into every identity tier (instanceId, username, + * collision-suffixed displayName). Sam rejected it as a convention the + * night it shipped; fleet review had independently measured the leak. + * 2. shared 'default' — pre-#923 installs, if any remain. + * + * For each active guide install in either form: + * owner = installedBy (fallback pod.createdBy) → newInstanceId per the + * convention → ensure the per-user bot User → swap pod membership from the + * old bot user to the new → re-key the installation → MOVE the old + * identity's memory envelope to the new key (same owner, same single-user + * provenance — this is a rename, not the mixed-user quarantine case; a + * 'default' envelope, unattributable by construction, is still quarantined + * never moved). + * + * Old bot User rows are never deleted (ADR-001); they leave pod membership + * only. Idempotent: convention-form installs are skipped. + * + * Usage: + * ts-node backend/scripts/migrate-guide-identity-convention.ts # apply + * ts-node backend/scripts/migrate-guide-identity-convention.ts --dry # report + */ + +import { createHash } from 'crypto'; +import mongoose from 'mongoose'; +import Pod from '../models/Pod'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { AgentInstallation } = require('../models/AgentRegistry'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const AgentMemory = require('../models/AgentMemory'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const AgentIdentityService = require('../services/agentIdentityService').default + // eslint-disable-next-line @typescript-eslint/no-require-imports + || require('../services/agentIdentityService'); + +const DRY = process.argv.includes('--dry'); + +export const conventionInstanceId = (userId: string): string => + `u${createHash('sha256').update(String(userId)).digest('hex').slice(0, 10)}`; + +const isConventionForm = (instanceId: string): boolean => /^u[a-f0-9]{10}$/.test(instanceId); +const isLongForm = (instanceId: string): boolean => /^u[a-f0-9]{24}$/.test(instanceId); + +async function main(): Promise { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error('MONGO_URI is required'); + await mongoose.connect(uri); + + const installs = await AgentInstallation.find({ agentName: 'guide', status: 'active' }); + console.log(`[guide-convention] ${installs.length} active guide install(s)`); + + for (const install of installs) { + const oldInstanceId = String(install.instanceId || 'default'); + if (isConventionForm(oldInstanceId)) { + console.log(`[guide-convention] pod=${install.podId} already convention (${oldInstanceId}) — skip`); + continue; + } + if (!isLongForm(oldInstanceId) && oldInstanceId !== 'default') { + console.warn(`[guide-convention] pod=${install.podId} unrecognized instanceId '${oldInstanceId}' — SKIPPING, inspect manually`); + continue; + } + + const pod = await Pod.findById(install.podId); + const owner = install.installedBy?.toString() || pod?.createdBy?.toString(); + if (!owner) { + console.warn(`[guide-convention] SKIP install ${install._id}: no owner resolvable`); + continue; + } + const newInstanceId = conventionInstanceId(owner); + console.log(`[guide-convention] pod=${install.podId} ${oldInstanceId} → ${newInstanceId}${DRY ? ' (dry)' : ''}`); + if (DRY) continue; + + const oldBot = await AgentIdentityService.getOrCreateAgentUser('guide', { + instanceId: oldInstanceId, + displayName: 'Guide', + }); + const newBot = await AgentIdentityService.getOrCreateAgentUser('guide', { + instanceId: newInstanceId, + displayName: 'Guide', + }); + + if (pod && oldBot?._id) { + await Pod.updateOne({ _id: pod._id }, { $pull: { members: oldBot._id } }); + } + if (pod && newBot?._id) { + await Pod.updateOne( + { _id: pod._id, members: { $ne: newBot._id } }, + { $push: { members: newBot._id } }, + ); + } + + // Memory: a long-form envelope is single-owner by construction — rename + // it to the new key. A 'default' envelope is a mixed-user artifact — + // quarantine, never move (same rule as the fork migration). + if (isLongForm(oldInstanceId)) { + const envelope = await AgentMemory.findOne({ agentName: 'guide', instanceId: oldInstanceId }); + if (envelope) { + envelope.instanceId = newInstanceId; + await envelope.save(); + console.log(`[guide-convention] memory envelope moved → ${newInstanceId}`); + } + } else { + const shared = await AgentMemory.findOne({ agentName: 'guide', instanceId: 'default' }); + if (shared) { + shared.instanceId = 'default-quarantined'; + await shared.save(); + console.log('[guide-convention] shared envelope quarantined'); + } + } + + install.instanceId = newInstanceId; + await install.save(); + } + + await mongoose.disconnect(); + console.log('[guide-convention] done'); +} + +main().catch((err) => { + console.error('[guide-convention] failed:', err); + process.exit(1); +}); diff --git a/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx b/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx index 36aa7adb..fa74cb42 100644 --- a/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx +++ b/frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx @@ -58,7 +58,11 @@ const makeMessage = (content, agentDelivery) => ({ ...(agentDelivery ? { agentDelivery } : {}), }); -const DeliveryHarness = ({ response, sendSpy }) => { +const DEFAULT_AGENTS = [{ + agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', status: 'active', +}]; + +const DeliveryHarness = ({ response, sendSpy, agents = DEFAULT_AGENTS }) => { const [messages, setMessages] = useState([]); const sendMessage = async (...args) => { sendSpy(...args); @@ -69,9 +73,7 @@ const DeliveryHarness = ({ response, sendSpy }) => { pod: { _id: 'pod-1', name: 'Launch Room', type: 'chat' }, members: [{ _id: 'u1', username: 'alice', isBot: false }], messages, - agents: [{ - agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria', status: 'active', - }], + agents, sendMessage, loading: false, error: null, @@ -106,10 +108,40 @@ describe('V2PodChat agent delivery hint', () => { const hint = await screen.findByRole('status'); expect(hint).toHaveTextContent('No agent was notified'); + // A human-chosen instanceId ("aria") IS the identity — it stays the + // handle; agentName ("openclaw") is the runtime label we never surface. expect(hint).toHaveTextContent('@aria'); expect(sessionStorage.getItem('v2.agentDeliveryHint.pod-1')).toBe('1'); }); + test('an opaque per-user instance token is never the suggested handle', async () => { + // The u+sha10 convention (and its legacy long form) is a machine key. + // The backend resolves the bare agentName for single-install agents, so + // "@guide" both reads right and lands — "@u3f9c2a1b7d" does neither. + const response = makeMessage('hello guide', { + enqueued: 0, implicit: [], agentsInPod: 1, + }); + render( + + + + + , + ); + + sendDraft('hello guide'); + + const hint = await screen.findByRole('status'); + expect(hint).toHaveTextContent('@guide'); + expect(hint).not.toHaveTextContent('u3f9c2a1b7d'); + }); + test('shows at most once per pod per browser session', async () => { const first = renderHarness(makeMessage('first send', { enqueued: 0, implicit: [], agentsInPod: 1, diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index a1a791eb..83758b8d 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -38,6 +38,14 @@ const podMarkFor = (name: string, type: string | undefined, dmLabel: string): st const normalizeAgentSegment = (value: string | undefined): string => (value || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); +// Per-user agents carry OPAQUE instance tokens (the u+sha10 convention, plus +// the legacy long form) — machine keys, never identities. A moltbot's +// instanceId ("aria") is the opposite: the human-chosen name, with agentName +// as the runtime label we must never surface. So handle preference is: +// human-meaningful instanceId wins; opaque token falls back to agentName. +const isOpaqueInstanceToken = (value: string | undefined): boolean => + /^u[a-f0-9]{10}([a-f0-9]{14})?$/.test((value || '').toLowerCase()); + // Mirrors backend AgentIdentityService.buildAgentUsername — instance suffix // elides when default/empty/equal to base name. Used to wire a mention back // to the agent's User row username. @@ -474,7 +482,14 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, const username = buildAgentUsername(rawName, a.instanceId); const display = a.displayName || a.profile?.displayName || rawName; const instance = (a.instanceId || 'default').toLowerCase(); + // Handle preference: a human-chosen instanceId ("aria") IS the + // identity and stays the handle — agentName there is the runtime label + // we never surface. An OPAQUE per-user token (guide/u3f9c2a1b7d) is a + // machine key and must never be the handle; the backend's mention map + // resolves the bare agentName for it (single-install rule), so + // "@guide" both reads right and lands (Sam, 2026-08-13). const mentionValue = instance && instance !== 'default' && instance !== rawName.toLowerCase() + && !isOpaqueInstanceToken(instance) ? instance : rawName.toLowerCase(); const avatar = a.profile?.avatarUrl || a.profile?.iconUrl || a.iconUrl || fallbackAvatar; @@ -778,8 +793,12 @@ const V2PodChat: React.FC = ({ detail, firstRunVisible = false, if (created) { const delivery = created.agentDelivery; const exampleAgent = agents.find((agent) => agent.status === 'active') || agents[0]; + // Same handle rule as the typeahead: human-chosen instanceId is the + // identity and stays; an opaque per-user token must never be the + // suggested handle ("Try @u3f9c2a1b7d"). const rawMentionHandle = exampleAgent?.instanceId && exampleAgent.instanceId.toLowerCase() !== 'default' + && !isOpaqueInstanceToken(exampleAgent.instanceId) ? exampleAgent.instanceId : exampleAgent?.agentName; const mentionHandle = normalizeAgentSegment(rawMentionHandle);