Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
});

Expand All @@ -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);
});
});
20 changes: 15 additions & 5 deletions backend/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<userId> 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 },
Expand Down
128 changes: 128 additions & 0 deletions backend/scripts/migrate-guide-identity-convention.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
40 changes: 36 additions & 4 deletions frontend/src/v2/__tests__/V2PodChatDeliveryHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<DeliveryHarness
response={response}
sendSpy={jest.fn()}
agents={[{
agentName: 'guide', instanceId: 'u3f9c2a1b7d', displayName: 'Guide', status: 'active',
}]}
/>
</MemoryRouter>
</AuthContext.Provider>,
);

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,
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/v2/components/V2PodChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -474,7 +482,14 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ 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;
Expand Down Expand Up @@ -778,8 +793,12 @@ const V2PodChat: React.FC<V2PodChatProps> = ({ 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);
Expand Down
Loading