diff --git a/apps/gateway/src/agent/__tests__/session-ownership.test.ts b/apps/gateway/src/agent/__tests__/session-ownership.test.ts index b7892791..ce2e6ddb 100644 --- a/apps/gateway/src/agent/__tests__/session-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/session-ownership.test.ts @@ -863,4 +863,96 @@ describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the process.off('unhandledRejection', onUnhandled); expect(unhandled).toHaveLength(0); }); + + it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + // The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential + // `await prompt()` the timer could never even be observed, so the turn hung forever. + prompt: vi.fn(() => new Promise(() => undefined)), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'a prompt that never returns', + }); + // No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all + // installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; + + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + // The single idempotent dispose ran on the timeout path: listener detached exactly once. + expect(detach).toHaveBeenCalledTimes(1); + // Advancing far past the deadline fires nothing more: dispose cleared the timer. + vi.advanceTimersByTime(600_000); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); + }); + + it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => { + const session = makeAgentSession(USER_A); + const detach = vi.fn(); + let rejectPrompt: (reason: unknown) => void = () => undefined; + const prompting = new Promise((_resolve, reject) => { + rejectPrompt = reject; + }); + const svc = { + getSession: vi.fn(() => session), + createSession: vi.fn(), + onEvent: vi.fn(() => detach), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn(() => prompting), + recordTokenUsage: vi.fn(), + }; + const runtime = new EmbeddedChatRuntime(svc as never); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + vi.useFakeTimers(); + try { + const resultPromise = runtime.completeLegacyRestTurn(ctx, { + content: 'prompt settles after the deadline', + }); + // The timeout wins the race while prompt is still pending. + await vi.advanceTimersByTimeAsync(200_000); + const result = await resultPromise; + + expect(result).toEqual({ ok: false, code: 'timeout', retryable: true }); + expect(detach).toHaveBeenCalledTimes(1); + + // The prompt now rejects LATE — after the turn already returned its timeout result. Because + // Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late + // rejection is already observed and must not escape as an unhandledRejection. + rejectPrompt(new Error('late backend failure')); + } finally { + vi.useRealTimers(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + process.off('unhandledRejection', onUnhandled); + expect(unhandled).toHaveLength(0); + }); }); diff --git a/apps/gateway/src/chat/__tests__/chat-security.test.ts b/apps/gateway/src/chat/__tests__/chat-security.test.ts index eba0f671..61af835d 100644 --- a/apps/gateway/src/chat/__tests__/chat-security.test.ts +++ b/apps/gateway/src/chat/__tests__/chat-security.test.ts @@ -967,7 +967,11 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv prompt: vi.fn().mockResolvedValue(undefined), }; const harnessConversations = { append: vi.fn() }; - const create = vi.fn().mockResolvedValue(undefined); + // The durable store honours the mint: it returns the exact record asked for (same id, same + // scoped owner). Admission requires that non-null, correctly-identified record before any effect. + const create = vi.fn((data: { id: string; userId: string }) => + Promise.resolve({ id: data.id, userId: data.userId }), + ); const brain = { conversations: { findById: vi.fn().mockResolvedValue(undefined), @@ -1009,6 +1013,159 @@ describe('Non-Discord ("Telegram-equivalent") socket ingress rejection (Task Fiv expect(agentService.prompt).toHaveBeenCalledTimes(1); expect(harnessConversations.append).not.toHaveBeenCalled(); }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint resolves nullish, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // Minting is not fire-and-forget: if create resolves nullish (the durable write silently + // produced no record), admission must fail closed BEFORE any runtime effect rather than dispatch + // against a conversation that was never persisted. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-nullish-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint resolves nullish', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed: no persist, no session + // mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('fails a legacy browser send with NO conversationId closed when the durable mint throws, with zero dispatch/persist/prompt/ack (Task 5 finding 1)', async () => { + // A create that rejects (durable store error) is a persistence failure, not a reason to proceed: + // the exception is caught at the admission seam and collapses to the same fail-closed refusal. + process.env['CHAT_HARNESS_RUNTIME'] = 'legacy'; + const order: string[] = []; + const counters = { dispatch: 0, dispose: 0 }; + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession: vi.fn().mockResolvedValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const harnessConversations = { append: vi.fn() }; + const create = vi.fn().mockRejectedValue(new Error('durable store unavailable')); + const addMessage = vi.fn().mockResolvedValue({ id: 'persisted-new' }); + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue(undefined), + findMessages: vi.fn().mockResolvedValue([]), + create, + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const router = readyRouter('legacy', agentService, harnessConversations); + instrumentLease(router, order, counters); + const gateway = new ChatGateway( + router as never, + { api: { getSession: vi.fn() } } as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'browser-new-throw-1', + data: { user: { id: 'user-a' } }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + { + content: 'first message, mint throws', + } as never, + ); + + // The mint was attempted for the sender, then admission failed closed on the thrown error: no + // persist, no session mint, no lease dispatch/dispose, no prompt, no ack — only the typed refusal. + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.any(String), userId: 'user-a' }), + ); + expect(addMessage).not.toHaveBeenCalled(); + expect(agentService.createSession).not.toHaveBeenCalled(); + expect(agentService.prompt).not.toHaveBeenCalled(); + expect(counters.dispatch).toBe(0); + expect(counters.dispose).toBe(0); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ code: 'conversation_unavailable' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); }); describe('Chat DTO validation', () => { diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index c9fc1378..b086d135 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -492,16 +492,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); // Reconcile the verified binding against the durable agent record BEFORE claiming the message - // id (security fix, finding 3). The configured agent must exist and its record name must match - // the binding's instance id exactly; a missing record, a name mismatch, or a lookup error - // rejects the turn WITHOUT consuming the replay claim, so a corrected retry is still admitted. - // The branded identity is derived from the record (id + name), never from the raw binding - // strings — an unreconciled binding must not execute a default or different embedded agent - // under a verified label. + // id (security fix, finding 3). The configured agent must exist and its record must match the + // binding EXACTLY on BOTH id and name — a record whose id differs from the binding's + // agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A + // missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT + // consuming the replay claim, so a corrected retry is still admitted. The branded identity is + // derived from the record (id + name), never from the raw binding strings — an unreconciled + // binding must not execute a default or different embedded agent under a verified label. let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string }; try { const record = await this.brain.agents.findById(binding.agentConfigId); - if (!record || record.name !== binding.instanceId) { + if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) { this.logger.warn( `Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`, ); @@ -1164,8 +1165,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const owned = await this.brain.conversations.findById(suppliedConversationId, userId); return owned !== undefined; } - await this.brain.conversations.create({ id: conversationId, userId }); - return true; + // Minting a new conversation must actually yield the durable record we asked for before any + // runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that + // is not this exact id owned by this user, is a persistence failure — fail closed so the caller + // never dispatches/persists against an unpersisted or mis-scoped conversation. + const created = await this.brain.conversations.create({ id: conversationId, userId }); + return created != null && created.id === conversationId && created.userId === userId; } catch (err) { this.logger.error( `Conversation admission failed for conversation=${conversationId}`, diff --git a/apps/gateway/src/chat/embedded-chat.runtime.ts b/apps/gateway/src/chat/embedded-chat.runtime.ts index 39018458..fa6260a2 100644 --- a/apps/gateway/src/chat/embedded-chat.runtime.ts +++ b/apps/gateway/src/chat/embedded-chat.runtime.ts @@ -94,9 +94,16 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort ); }); + // Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the + // timeout unobservable until prompt settled (a hung prompt could never time out) and, worse, + // let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection + // window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole + // turn even while prompt is pending, and neither promise can reject unobserved. Success still + // requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential + // await). The idempotent dispose() clears the timer + detaches on whichever settles first. + const prompting = this.agentService.prompt(conversationId, input.content, scope); try { - await this.agentService.prompt(conversationId, input.content, scope); - await done; + await Promise.all([prompting, done]); } catch (err) { dispose(); const message = err instanceof Error ? err.message : String(err); @@ -300,18 +307,22 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort | { readonly ok: true; readonly presentation: LegacySessionPresentation } | Exclude, { ok: true }> > { + // A verified-Discord turn may only run under a session whose configured identity matches the + // reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused + // pre-existing session AND a freshly created one: a session carrying a different configured + // agent — however it arose — is rejected rather than executed under the verified label, so we + // never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes + // no expectedAgent and skips the check. + const identityMatches = (candidate: AgentSession): boolean => + expectedAgent === undefined || + (candidate.agentConfigId === expectedAgent.agentConfigId && + candidate.agentName === expectedAgent.instanceId); + let session = this.agentService.getSession(conversationId, scope); - if (session && expectedAgent) { - // A verified-Discord turn reuses an existing in-process session only when its configured - // identity still matches the reconciled agent record. A pre-existing same-scope session - // carrying a different configured agent (config id or resolved name) is rejected rather than - // executed under the verified label — never silently run a different prompt/model/tool policy. - if ( - session.agentConfigId !== expectedAgent.agentConfigId || - session.agentName !== expectedAgent.instanceId - ) { - return CONVERSATION_UNAVAILABLE; - } + if (session && !identityMatches(session)) { + // Reused same-scope session minted under a different configured identity — reject with zero + // effects rather than dispatch a verified turn onto a foreign agent's session. + return CONVERSATION_UNAVAILABLE; } if (!session) { try { @@ -330,6 +341,12 @@ export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort ); return { ok: false, code: 'runtime_unavailable', retryable: true }; } + // The just-created session must ALSO carry the reconciled identity before any effect. A + // createSession that returns a session under a different configured agent (misconfiguration + // or a substituted factory) is rejected here, before subscribe/persist/ack/prompt. + if (!identityMatches(session)) { + return CONVERSATION_UNAVAILABLE; + } } return { ok: true, presentation: this.presentationForSession(session) }; } diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 9d6628ce..eef7ceec 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -612,6 +612,8 @@ describe('Discord ingress security', () => { const session = { provider: 'configured-provider', modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], @@ -697,6 +699,8 @@ describe('Discord ingress security', () => { const session = { provider: 'configured-provider', modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], @@ -796,6 +800,8 @@ describe('Discord ingress security', () => { const session = { provider: 'configured-provider', modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', piSession: { thinkingLevel: 'medium', getAvailableThinkingLevels: (): string[] => ['medium'], @@ -963,6 +969,197 @@ describe('Discord ingress security', () => { expect(harnessConversations.append).not.toHaveBeenCalled(); }); + it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-nova', + agentName: 'Nova', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const prompt = vi.fn().mockResolvedValue(undefined); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + // The name matches the verified binding, but the record's own id is a DIFFERENT agent config — + // an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery, + // then admit the corrected record whose id matches the binding. + const findAgent = vi + .fn() + .mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' }) + .mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' }); + const brain = { + agents: { findById: findAgent }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-reconcile-id', + data: { discordService: true }, + emit: vi.fn(), + }; + const ackCount = (): number => + client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length; + + const envelope = ingressEnvelope( + 'verified once with aliased agent id', + 'discord-reconcile-id-001', + { + conversationId: 'Nova:discord:channel-001', + }, + ); + + // (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the + // turn BEFORE the replay claim, so nothing dispatches and the claim stays available. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(0); + expect(prompt).toHaveBeenCalledTimes(0); + expect(addMessage).toHaveBeenCalledTimes(0); + expect(ackCount()).toBe(0); + + // (2) The record now reconciles on both id and name; because step (1) took no claim, this + // byte-identical retry claims once and runs the full embedded dispatch exactly once. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + + // (3) A genuine duplicate after the committed turn stays fail-closed. + await gateway.handleMessage(client as never, envelope); + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).toHaveBeenCalledTimes(1); + expect(addMessage).toHaveBeenCalledTimes(1); + expect(ackCount()).toBe(1); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + + it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => { + configureDiscordEnv(); + process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc'; + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + // No live session exists for this scope, so the runtime MINTS one — but createSession returns a + // session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova). + // The post-create identity recheck must refuse it rather than dispatch one agent's turn under + // another agent's verified label. (The existing reuse test covers the getSession path; this + // covers the createSession path scrappy flagged as unvalidated.) + const mintedForeignSession = { + provider: 'configured-provider', + modelId: 'configured-model', + agentConfigId: 'agent-config-orion', + agentName: 'Orion', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const prompt = vi.fn().mockResolvedValue(undefined); + const createSession = vi.fn().mockResolvedValue(mintedForeignSession); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + removeChannel: vi.fn(), + prompt, + }; + const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' }); + const brain = { + agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const harnessConversations = { append: vi.fn() }; + const gateway = new ChatGateway( + piRpcRouterFronting(agentService, harnessConversations) as never, + {} as never, + brain as never, + {} as never, + {} as never, + { resolve: vi.fn() } as never, + ); + const client = { + id: 'discord-client-postcreate-mismatch', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('mint under a different identity', 'discord-postcreate-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + // The freshly minted session failed the post-create identity recheck: refused with a typed + // error, no prompt, no persist, no ack. + expect(createSession).toHaveBeenCalledTimes(1); + expect(prompt).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + expect(client.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }), + ); + expect(harnessConversations.append).not.toHaveBeenCalled(); + }); + it('retains validated persisted attachments in resumed conversation history', async () => { const attachment = { id: 'attachment-history',