From c4b7f0917189191e0e85f541984c98d0ffaca794 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 00:41:30 +0200 Subject: [PATCH 001/107] fix(stateless): contain watch loss and preserve desktop chats --- desktop-app/src/__tests__/chatStore.test.ts | 47 +++ desktop-app/src/appService.ts | 29 +- desktop-app/src/chatStore.ts | 21 +- desktop-app/src/rpcProxyClient.ts | 30 +- desktop-app/src/types.ts | 2 + desktop-app/test/rpcProxyClient.test.ts | 30 ++ .../__fixtures__/controllerHarness.tsx | 5 + .../__tests__/chatListTitleMerge.test.tsx | 190 ++++++++++- .../domain/__tests__/reconcileChat.test.ts | 82 ++++- .../__tests__/renameSessionQueue.test.tsx | 53 +++ .../__tests__/switchToChat.unified.test.tsx | 260 ++++++++++++++- .../useAgentChatController.crossChat.test.tsx | 252 +++++++++++++- ...eAgentChatController.pendingModel.test.tsx | 4 + .../__tests__/useAgentChatController.test.tsx | 44 +++ ...PrewarmController.chatIntegration.test.tsx | 4 + .../ui/src/hooks/domain/reconcileChat.ts | 46 ++- .../hooks/domain/useAgentChatController.ts | 310 ++++++++++++++---- .../src/hooks/domain/useChatListController.ts | 292 ++++++++++++++--- desktop-app/ui/src/hooks/useAppController.ts | 24 +- desktop-app/ui/src/lib/format.ts | 50 ++- host-context-controller/package-lock.json | 4 +- host-context-controller/package.json | 2 +- .../src/statelessLifecycle.types.ts | 7 +- .../src/statelessLifecycleExecutor.ts | 113 +++---- .../src/statelessLifecycleTracker.ts | 8 +- host-context-controller/src/types.ts | 2 +- .../test/hostReconciler.lifecycle.test.ts | 81 ++++- .../test/statelessLifecycleTracker.test.ts | 16 + scripts/e2e/_lib/hcc-watch-api-proxy.mjs | 52 ++- scripts/e2e/_lib/hcc-watch-pr-a.sh | 16 +- .../e2e-hcc-stateless-cache-containment.sh | 228 +++++++++++++ scripts/tests/hcc-watch-api-proxy.test.mjs | 76 +++++ .../test-hcc-stateless-cache-containment.sh | 74 +++++ 33 files changed, 2166 insertions(+), 288 deletions(-) create mode 100755 scripts/e2e/e2e-hcc-stateless-cache-containment.sh create mode 100755 scripts/tests/test-hcc-stateless-cache-containment.sh diff --git a/desktop-app/src/__tests__/chatStore.test.ts b/desktop-app/src/__tests__/chatStore.test.ts index 7c78ed7a0c..5d6875a2bc 100644 --- a/desktop-app/src/__tests__/chatStore.test.ts +++ b/desktop-app/src/__tests__/chatStore.test.ts @@ -178,6 +178,51 @@ describe('renameChat', () => { // ── deleteChat ─────────────────────────────────────────────────────────────── describe('deleteChat', () => { + it('persists a confirmed deletion across a new store instance', async () => { + await store.createChat('agent-1', 'deleted-session') + await store.createChat('agent-1', 'kept-session') + await store.deleteChat('agent-1', 'deleted-session') + + const restartedStore = new ChatStore(tempDir) + expect((await restartedStore.listChats('agent-1')).map(chat => chat.id)).toEqual([ + 'kept-session', + ]) + expect((await restartedStore.getIndex('agent-1')).deletedChatIds).toContain('deleted-session') + }) + + it('does not recreate a deleted transcript from queued or restarted writes', async () => { + const agentRef = 'agent-1' + const chatId = 'deleted-while-sending' + const message: ChatMessage = { + id: 'late-message', + role: 'user', + content: 'arrived after delete', + timestamp: 1, + } + await store.createChat(agentRef, chatId) + await store.saveMessages(agentRef, chatId, [ + { id: 'original', role: 'user', content: 'original', timestamp: 0 }, + ]) + + // Invocation order is the per-chat serialization order: both writes must + // observe the tombstone published by the preceding confirmed delete. + const deletion = store.deleteChat(agentRef, chatId) + const queuedAppend = store.appendMessages(agentRef, chatId, [message]) + const queuedReplace = store.replaceMessages(agentRef, chatId, [message]) + await Promise.all([deletion, queuedAppend, queuedReplace]) + + const restartedStore = new ChatStore(tempDir) + await restartedStore.appendMessages(agentRef, chatId, [message]) + await restartedStore.replaceMessages(agentRef, chatId, [message]) + await expect(restartedStore.createChat(agentRef, chatId)).rejects.toThrow( + 'Chat was deleted locally' + ) + expect(await restartedStore.loadMessages(agentRef, chatId)).toEqual([]) + expect(await restartedStore.listChats(agentRef)).toEqual([]) + expect((await restartedStore.getIndex(agentRef)).deletedChatIds).toContain(chatId) + await expect(fs.access(chatCacheDir(chatId))).rejects.toThrow() + }) + it('removes from index and deletes message cache', async () => { await store.createChat('agent-1', 'del-1') await store.saveMessages('agent-1', 'del-1', [ @@ -1665,12 +1710,14 @@ describe('corrupt/missing files', () => { lastActiveChatId: null, onboardingDismissed: false, chats: [], + deletedChatIds: [], }) await expect(store.getIndex('agent-1')).resolves.toEqual({ version: 2, lastActiveChatId: null, onboardingDismissed: false, chats: [], + deletedChatIds: [], }) const quarantinedIndexes = (await fs.readdir(agentPath('.corrupt'))).filter(name => name.startsWith('index-') diff --git a/desktop-app/src/appService.ts b/desktop-app/src/appService.ts index 83f1860fbd..49980d5eab 100644 --- a/desktop-app/src/appService.ts +++ b/desktop-app/src/appService.ts @@ -4277,12 +4277,10 @@ export class AppService { try { return await this.rpcClient.listSessions(rpc.token, targetHostRef, query) } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('401') && message.toLowerCase().includes('missing token')) { - console.warn( - '[AppService] Session catalog unavailable because the runtime rejected the session token.' - ) - return { items: [] } + if (AppService.shouldRefreshRpcToken(error)) { + this.rpcTokenManager.clear() + const retried = await this.issueRpcTokenForHostRefs(HOST_SESSION_SCOPES, effectiveHostRefs) + return this.rpcClient.listSessions(retried.token, targetHostRef, query) } throw error } @@ -4310,19 +4308,16 @@ export class AppService { query ) } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('401') && message.toLowerCase().includes('missing token')) { - console.warn( - '[AppService] Session messages unavailable because the runtime rejected the session token.' - ) - return { + if (AppService.shouldRefreshRpcToken(error)) { + this.rpcTokenManager.clear() + const retried = await this.issueRpcTokenForHostRefs(HOST_SESSION_SCOPES, effectiveHostRefs) + return this.rpcClient.loadSessionMessages( + retried.token, + targetHostRef, agent, chatId, - turns: [], - totalTurns: 0, - hasMoreBefore: false, - hasMoreAfter: false, - } + query + ) } throw error } diff --git a/desktop-app/src/chatStore.ts b/desktop-app/src/chatStore.ts index 563a7842fe..58d560118c 100644 --- a/desktop-app/src/chatStore.ts +++ b/desktop-app/src/chatStore.ts @@ -166,7 +166,10 @@ function parseChatIndex(raw: string): ChatIndex { (candidate.version !== 2 && candidate.version !== 3) || (candidate.lastActiveChatId !== null && typeof candidate.lastActiveChatId !== 'string') || typeof candidate.onboardingDismissed !== 'boolean' || - !Array.isArray(candidate.chats) + !Array.isArray(candidate.chats) || + (candidate.deletedChatIds !== undefined && + (!Array.isArray(candidate.deletedChatIds) || + !candidate.deletedChatIds.every((id: unknown) => typeof id === 'string'))) ) { throw new Error('Invalid chat index') } @@ -283,6 +286,7 @@ function emptyIndex(): ChatIndex { lastActiveChatId: null, onboardingDismissed: false, chats: [], + deletedChatIds: [], } } @@ -1520,6 +1524,9 @@ export class ChatStore { const index = await this.getIndex(agentRef) const existing = index.chats.find(c => c.id === chatId) if (existing) return existing + if (index.deletedChatIds?.includes(chatId)) { + throw new Error('Chat was deleted locally') + } const now = new Date().toISOString() const meta: ChatMetadata = { @@ -1551,6 +1558,9 @@ export class ChatStore { await this.serializeIndex(agentRef, async () => { const index = await this.getIndex(agentRef) index.chats = index.chats.filter(c => c.id !== chatId) + if (!index.deletedChatIds?.includes(chatId)) { + index.deletedChatIds = [...(index.deletedChatIds ?? []), chatId] + } if (index.lastActiveChatId === chatId) { index.lastActiveChatId = null } @@ -1639,7 +1649,8 @@ export class ChatStore { options: { preserveExistingTotals?: boolean } = {} ): Promise { return this.serializeChat(agentRef, chatId, async () => { - await this.getIndex(agentRef) + const index = await this.getIndex(agentRef) + if (index.deletedChatIds?.includes(chatId)) return const indexedCount = options.preserveExistingTotals ? await this.indexedMessageCount(agentRef, chatId) : undefined @@ -1672,7 +1683,8 @@ export class ChatStore { options: ReplaceChatMessagesOptions = {} ): Promise { await this.serializeChat(agentRef, chatId, async () => { - await this.getIndex(agentRef) + const index = await this.getIndex(agentRef) + if (index.deletedChatIds?.includes(chatId)) return const existingMeta = await this.readOrMigratePagedChatUnlocked(agentRef, chatId) const existingMessages = existingMeta ? await this.readMessagesFromPagedMeta(agentRef, chatId, existingMeta) @@ -1713,7 +1725,8 @@ export class ChatStore { newMessages: ChatMessage[] ): Promise { return this.serializeChat(agentRef, chatId, async () => { - await this.getIndex(agentRef) + const indexBeforeAppend = await this.getIndex(agentRef) + if (indexBeforeAppend.deletedChatIds?.includes(chatId)) return const existing = (await this.readOrMigratePagedChatUnlocked(agentRef, chatId)) ?? (await this.writePagedChatUnlocked(agentRef, chatId, [])) diff --git a/desktop-app/src/rpcProxyClient.ts b/desktop-app/src/rpcProxyClient.ts index 3f7a45e948..5fdcaabe23 100644 --- a/desktop-app/src/rpcProxyClient.ts +++ b/desktop-app/src/rpcProxyClient.ts @@ -520,7 +520,16 @@ export class RpcProxyClient { token: rpcAccessToken, body: payload, } - ) + ).catch(error => { + if ( + error instanceof ApiError && + error.status === 403 && + errorCodeFromBody(error.bodyText) === 'Forbidden: user cannot access this host' + ) { + throw new ApiError('403 Forbidden: host_access_revoked', 403, error.bodyText) + } + throw error + }) } async getTaskResult( @@ -1032,8 +1041,8 @@ export class RpcProxyClient { signal: withTimeout(), }) if (response.status === 404) { - // Keep the '404' token in the message: the renderer's `isHttp404` matches on - // it to evict a stale local chat. + // Preserve the status for the renderer. A transcript 404 is ambiguous + // during Host wake and never confirms that local data may be deleted. throw new ApiError(`Session not found (404)`, 404, '') } if (!response.ok) { @@ -1229,7 +1238,20 @@ export class RpcProxyClient { // Body may echo the invalid title; read it for the ApiError payload but // keep the raw title out of the human-facing message. const body = await readErrorBody(response) - throw new ApiError(`Rename session failed (${response.status})`, response.status, body) + let hostAccessRevoked = false + if (response.status === 403) { + try { + const parsed = JSON.parse(body) as { error?: unknown } + hostAccessRevoked = parsed?.error === 'Forbidden: user cannot access this host' + } catch { + // A non-JSON or interposed 403 is not an authoritative Host denial. + } + } + throw new ApiError( + `Rename session failed (${response.status})${hostAccessRevoked ? ': host_access_revoked' : ''}`, + response.status, + body + ) } const parsed = (await response.json().catch(() => ({}))) as { title?: unknown } // Re-sanitize the server's echoed title on read (defense in depth, A14 rule). diff --git a/desktop-app/src/types.ts b/desktop-app/src/types.ts index 42d99f7cf1..21a233b14b 100644 --- a/desktop-app/src/types.ts +++ b/desktop-app/src/types.ts @@ -914,6 +914,8 @@ export interface ChatIndex { lastActiveChatId: string | null onboardingDismissed: boolean chats: ChatMetadata[] + /** Optional for older indexes. User-confirmed local deletions stay hidden from remote lists. */ + deletedChatIds?: string[] } /** Server-reported session lifecycle (D.1). `idle` once no task is in flight. */ diff --git a/desktop-app/test/rpcProxyClient.test.ts b/desktop-app/test/rpcProxyClient.test.ts index 5990c4e957..913b9de14c 100644 --- a/desktop-app/test/rpcProxyClient.test.ts +++ b/desktop-app/test/rpcProxyClient.test.ts @@ -207,6 +207,36 @@ describe('RpcProxyClient — openTaskProgressStream()', () => { }) describe('RpcProxyClient — renameSession() (spec 15 Fase B)', () => { + it.each([ + { + status: 403, + body: JSON.stringify({ error: 'Forbidden: user cannot access this host' }), + marked: true, + }, + { + status: 403, + body: JSON.stringify({ error: 'Forbidden: missing rename scope' }), + marked: false, + }, + { status: 403, body: 'Forbidden: user cannot access this host', marked: false }, + { + status: 503, + body: JSON.stringify({ error: 'Forbidden: user cannot access this host' }), + marked: false, + }, + ])( + 'marks exact Host-wide denial only for authoritative 403 body ($status, $marked)', + async ({ status, body, marked }) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body, { status }))) + const client = new RpcProxyClient() + const error = await client + .renameSession('rpc-token', 'chatllm', 'chatllm', 'chat-1', 'name') + .catch((caught: Error) => caught) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message.includes('host_access_revoked')).toBe(marked) + } + ) + it('PATCHes the name route with the title and returns the (re-sanitized) server title', async () => { const fetchMock = vi .fn() diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx index 56ec8e5655..b6af0ba5ac 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx @@ -4,6 +4,9 @@ import { AgentTaskTrackerProvider } from '@contexts/AgentTaskTrackerContext' import { renderHook } from '@testing-library/react' import { useAgentChatController } from '../../useAgentChatController' +const onHostAccessRevoked = () => {} +const isHostAccessRevoked = () => false + type ControllerParams = Parameters[0] export interface RenderControllerResult { @@ -51,6 +54,8 @@ export function renderController( isAuthenticated: true, loadMenuData: true, navItem: 'agents', + onHostAccessRevoked, + isHostAccessRevoked, pushToast: spies.pushToast, pushNotification: spies.pushNotification, // Identity resolver by default (no catalog display layer in the harness). diff --git a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx index 5e824d3de7..18e0b3ae0c 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx @@ -10,7 +10,8 @@ * fixtures are derived from the producer instead of hand-built parsed shapes. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { waitFor } from '@testing-library/react' +import { makeTaskKey } from '@contexts/AgentTaskTrackerContext' +import { act, waitFor } from '@testing-library/react' import { parseSessionsListResult } from '../../../../../src/rpcProxyClient' import type { ChatIndex, SessionsListResult } from '../../../../../src/types' import { renderController } from './__fixtures__/controllerHarness' @@ -46,7 +47,10 @@ function serverSessions( }) } -function localIndex(chats: Array<{ id: string; title: string }>): ChatIndex { +function localIndex( + chats: Array<{ id: string; title: string }>, + deletedChatIds: string[] = [] +): ChatIndex { return { version: 1, lastActiveChatId: null, @@ -58,9 +62,18 @@ function localIndex(chats: Array<{ id: string; title: string }>): ChatIndex { updatedAt: NOW, messageCount: 0, })), + deletedChatIds, } } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(finish => { + resolve = finish + }) + return { promise, resolve } +} + describe('chat list title merge (spec 15 §2.2, cases A–D)', () => { it('case A: server-only session with a title shows the server title (no placeholder)', async () => { clerum.chat.getIndex.mockResolvedValue(localIndex([])) @@ -166,3 +179,176 @@ describe('cross-agent "Latest sessions" title merge (spec 15 §2.2)', () => { ) }) }) + +describe('confirmed deletion catalog protection', () => { + it('keeps a deleted session out of selected-agent and cross-agent catalogs after reload', async () => { + clerum.chat.getIndex.mockImplementation(async (agentRef: string) => + agentRef === 'agent-y' + ? localIndex([], ['deleted-y']) + : localIndex([{ id: 'kept-x', title: 'Kept chat' }], ['deleted-x']) + ) + clerum.rpc.listSessions.mockImplementation(async (agentRef: string) => + agentRef === 'agent-y' + ? serverSessions([{ agent: 'agent-y', chatId: 'deleted-y' }]) + : serverSessions([ + { agent: 'agent-x', chatId: 'deleted-x' }, + { agent: 'agent-x', chatId: 'kept-x' }, + ]) + ) + + const { result } = renderController({ + selectedAgent: 'agent-x', + agentNames: ['agent-x', 'agent-y'], + }) + await waitFor(() => expect(result.current.chatList.map(chat => chat.id)).toEqual(['kept-x'])) + await waitFor(() => expect(result.current.latestChatSessionsLoading).toBe(false)) + expect(result.current.latestChatSessions.map(session => session.id)).not.toContain('deleted-x') + expect(result.current.latestChatSessions.map(session => session.id)).not.toContain('deleted-y') + }) +}) + +describe('revoked host catalog protection', () => { + it('hides a nonselected host after 403 and blocks direct reselection', async () => { + const revoked = new Set() + clerum.chat.getIndex.mockResolvedValue(localIndex([])) + clerum.rpc.listSessions.mockImplementation(async (agentRef: string) => + agentRef === 'agent-y' + ? serverSessions([{ agent: 'agent-y', chatId: 'protected-y' }]) + : serverSessions([]) + ) + const controller = renderController({ + selectedAgent: 'agent-x', + agentNames: ['agent-x', 'agent-y'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await waitFor(() => + expect(controller.result.current.latestChatSessions.some(s => s.id === 'protected-y')).toBe( + true + ) + ) + + clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + await expect( + controller.result.current.reconcileChat(makeTaskKey('agent-y', 'protected-y'), { + reason: 'background_refresh', + }) + ).resolves.toBe('revoked') + expect(revoked.has('agent-y')).toBe(true) + await waitFor(() => + expect(controller.result.current.latestChatSessions.some(s => s.id === 'protected-y')).toBe( + false + ) + ) + + await controller.result.current.switchToChat('agent-y', 'protected-y') + expect(controller.result.current.activeChatId).toBeNull() + expect(clerum.chat.loadMessages).not.toHaveBeenCalledWith( + 'agent-y', + 'protected-y', + expect.anything() + ) + }) + + it.each([401, 403])( + 'hides cached sessions when the catalog explicitly returns %i', + async status => { + const revoked = new Set() + clerum.chat.getIndex.mockResolvedValue( + localIndex([{ id: 'protected-a', title: 'Cached protected chat' }]) + ) + clerum.rpc.listSessions.mockRejectedValue(new Error(`${status} forbidden`)) + const { result } = renderController({ + selectedAgent: null, + agentNames: ['agent-a'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + + await waitFor(() => expect(revoked.has('agent-a')).toBe(true)) + expect(result.current.latestChatSessions.some(chat => chat.agentRef === 'agent-a')).toBe( + false + ) + } + ) + + it('retains cached sessions when a 503 body merely mentions 403', async () => { + const revoked = new Set() + clerum.chat.getIndex.mockResolvedValue(localIndex([{ id: 'cached-a', title: 'Cached chat' }])) + clerum.rpc.listSessions.mockRejectedValue( + new Error('503 Service Unavailable: upstream body mentioned 403') + ) + const { result } = renderController({ + selectedAgent: null, + agentNames: ['agent-a'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await waitFor(() => + expect(result.current.latestChatSessions.some(chat => chat.id === 'cached-a')).toBe(true) + ) + await waitFor(() => expect(clerum.rpc.listSessions).toHaveBeenCalled()) + expect(revoked.has('agent-a')).toBe(false) + expect(result.current.latestChatSessions.some(chat => chat.id === 'cached-a')).toBe(true) + }) +}) + +describe('late global catalog response', () => { + it.each(['revocation', 'confirmed deletion'])( + 'does not republish Host A after %s while Host B is pending', + async action => { + const revoked = new Set() + const deliveredA = deferred() + const pendingB = deferred() + clerum.chat.getIndex.mockResolvedValue(localIndex([])) + clerum.rpc.listSessions.mockImplementation(async (agentRef: string) => { + if (agentRef === 'agent-a') { + deliveredA.resolve() + return serverSessions([{ agent: 'agent-a', chatId: 'session-a' }]) + } + return pendingB.promise + }) + const controller = renderController({ + selectedAgent: null, + agentNames: ['agent-a', 'agent-b'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await deliveredA.promise + await waitFor(() => + expect(clerum.rpc.listSessions).toHaveBeenCalledWith('agent-b', undefined, { + agent: 'agent-b', + limit: 50, + }) + ) + + if (action === 'revocation') { + clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + await act(async () => { + await controller.result.current.reconcileChat(makeTaskKey('agent-a', 'session-a'), { + reason: 'background_refresh', + }) + }) + expect(revoked.has('agent-a')).toBe(true) + } else { + await act(async () => { + await controller.result.current.handleDeleteChatForAgent('agent-a', 'session-a') + }) + expect(clerum.chat.delete).toHaveBeenCalledWith('agent-a', 'session-a') + } + + await act(async () => { + pendingB.resolve(serverSessions([{ agent: 'agent-b', chatId: 'session-b' }])) + await pendingB.promise + }) + await waitFor(() => + expect( + controller.result.current.latestChatSessions.some(chat => chat.id === 'session-b') + ).toBe(true) + ) + expect( + controller.result.current.latestChatSessions.some(chat => chat.id === 'session-a') + ).toBe(false) + } + ) +}) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts b/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts index 29191d7bf9..6d01c34a38 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts +++ b/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts @@ -25,6 +25,8 @@ function liveResp(overrides: Partial = {}): SessionMessag class NetErr extends Error {} class NotFound extends Error {} +class Unavailable extends Error {} +class Unauthorized extends Error {} function buildDeps(overrides: Partial = {}): ReconcileChatDeps { const fsm = createSessionFsmStore() @@ -35,9 +37,10 @@ function buildDeps(overrides: Partial = {}): ReconcileChatDep // the view (so a rejoin can anchor to the rendered bubble, P1-A) before attach. attachLiveTask: vi.fn(async () => 'reconcile_rejoined' as const), settleIdle: vi.fn(async () => 'fell_through_to_resend' as const), - evictChat: vi.fn(async () => {}), + revokeAccess: vi.fn(), isNetworkError: (e): e is NetErr => e instanceof NetErr, isHttp404: (e): e is NotFound => e instanceof NotFound, + isAuthorizationError: (e): e is Unauthorized => e instanceof Unauthorized, telemetry: vi.fn(), networkRetryBackoffMs: [0, 0, 0], ...overrides, @@ -78,22 +81,36 @@ describe('reconcileChat — precedence branches', () => { }) }) - it('404 evicts the chat, dispatches RESET, and does NOT re-finish', async () => { + it.each([ + ['transient 404', new NotFound('not found during wake')], + ['503', new Unavailable('service unavailable')], + ['timeout', new NetErr('timeout')], + ])('%s retains the selected conversation and draft state', async (_name, error) => { const deps = buildDeps({ loadSessionMessages: vi.fn(async () => { - throw new NotFound('gone') + throw error }), }) - // Seed an entry so we can observe RESET removing it. + // The FSM entry represents the selected conversation. A destructive reset + // would also cause the controller to clear its conversation and composer. deps.fsm.dispatch(chatKey, { type: 'SEND_STARTED', taskId: 't1' }) const reconcile = createReconcileChat(deps) await reconcile(chatKey, { reason: 'user_refresh' }) - expect(deps.evictChat).toHaveBeenCalledWith(chatKey) - expect(deps.fsm.getState(chatKey)).toBeUndefined() // RESET removed it, FINISHED skipped - expect(deps.telemetry).toHaveBeenCalledWith('stream_recovery', { - reason: 'user_refresh', - outcome: '404', + expect(deps.revokeAccess).not.toHaveBeenCalled() + expect(deps.fsm.getState(chatKey)).toBeDefined() + }) + + it.each([401, 403])('%i revokes access and resets protected conversation state', async status => { + const deps = buildDeps({ + loadSessionMessages: vi.fn(async () => { + throw new Unauthorized(String(status)) + }), }) + deps.fsm.dispatch(chatKey, { type: 'SEND_STARTED', taskId: 't1' }) + const reconcile = createReconcileChat(deps) + await reconcile(chatKey, { reason: 'user_refresh' }) + expect(deps.revokeAccess).toHaveBeenCalledWith(chatKey) + expect(deps.fsm.getState(chatKey)).toBeUndefined() }) it('exhausted network retries → WENT_OFFLINE + offline outcome', async () => { @@ -234,7 +251,7 @@ describe('reconcileChat — single-flight', () => { ) }) - it('drops a late 404 after reset instead of evicting the next session cache', async () => { + it('drops a late 404 after reset without changing the next session cache', async () => { let rejectLoad: (reason: unknown) => void = () => {} const load = vi.fn( (_agentRef: string, _chatId: string, _query: unknown, stillRelevant?: () => boolean) => { @@ -255,10 +272,53 @@ describe('reconcileChat — single-flight', () => { rejectLoad(new NotFound('late response')) await expect(pending).resolves.toBe('noop') - expect(deps.evictChat).not.toHaveBeenCalled() + expect(deps.revokeAccess).not.toHaveBeenCalled() expect(deps.fsm.getState(chatKey)).toBeUndefined() }) + it('revokes a Host when an old chat read returns 403 after selection moves to another chat', async () => { + let rejectLoad: (reason: unknown) => void = () => {} + let firstChatSelected = true + const deps = buildDeps({ + loadSessionMessages: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectLoad = reject + }) + ), + }) + const reconcile = createReconcileChat(deps) + const pending = reconcile(chatKey, { + reason: 'switch_to_chat', + isRelevant: () => firstChatSelected, + }) + firstChatSelected = false + rejectLoad(new Unauthorized('403 forbidden')) + + await expect(pending).resolves.toBe('revoked') + expect(deps.revokeAccess).toHaveBeenCalledWith(chatKey) + }) + + it('ignores a late 403 after reset tears down the principal scope', async () => { + let rejectLoad: (reason: unknown) => void = () => {} + const deps = buildDeps({ + loadSessionMessages: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectLoad = reject + }) + ), + }) + const reconcile = createReconcileChat(deps) + const pending = reconcile(chatKey, { reason: 'old-principal' }) + reconcile.reset() + deps.fsm.reset() + rejectLoad(new Unauthorized('403 forbidden')) + + await expect(pending).resolves.toBe('stale_drop') + expect(deps.revokeAccess).not.toHaveBeenCalled() + }) + it('a coalesced caller donates its taskIdHint to a hint-less in-flight run (M6)', async () => { let resolveFirst: (v: SessionMessagesResult) => void = () => {} const load = vi diff --git a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx index 075d63c2d0..34f2af9bc7 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx @@ -118,6 +118,59 @@ function reportedSessions(chats: Array<{ chatId: string; title?: string }>) { } describe('rename pending queue (spec 15 §2.5)', () => { + it.each([ + { + label: 'exact Host denial', + renameError: 'Rename session failed (403): host_access_revoked', + readStatus: null, + shouldRevoke: true, + }, + { + label: 'operation-scope denial', + renameError: 'Rename session failed (403)', + readStatus: null, + shouldRevoke: false, + }, + { + label: 'read also denied', + renameError: 'Rename session failed (403)', + readStatus: 403, + shouldRevoke: true, + }, + { + label: 'read unavailable', + renameError: 'Rename session failed (403)', + readStatus: 503, + shouldRevoke: false, + }, + ])( + '$label applies the transcript authority decision', + async ({ renameError, readStatus, shouldRevoke }) => { + const revoked = new Set() + clerum.chat.getIndex.mockResolvedValue(localIndex([{ id: 'c1', title: 'old' }])) + clerum.rpc.renameSession.mockRejectedValue(new Error(renameError)) + clerum.rpc.listSessions.mockImplementation( + async (_hostRef: string, _teamId: string | undefined, query?: { limit?: number }) => { + if (query?.limit === 1 && readStatus !== null) throw new Error(`${readStatus} response`) + return reportedSessions([{ chatId: 'c1', title: 'old' }]) + } + ) + const { result } = renderController({ + selectedAgent: 'agent-x', + agentNames: ['agent-x'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await waitFor(() => expect(titleInList(result.current, 'c1')).toBe('old')) + await act(async () => { + await result.current.handleRenameChatForAgent('agent-x', 'c1', 'new') + }) + expect(revoked.has('agent-x')).toBe(shouldRevoke) + if (shouldRevoke) expect(result.current.chatList).toEqual([]) + else expect(titleInList(result.current, 'c1')).toBe('old') + } + ) + it('200: optimistic local title + RPC sync (hostRef===agent===agentRef), no toast', async () => { clerum.chat.getIndex.mockResolvedValue(localIndex([{ id: 'c1', title: 'old' }])) clerum.rpc.renameSession.mockResolvedValue({ title: 'renamed' }) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx index b1359eb4e6..26513af6cb 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx @@ -10,6 +10,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeTaskKey } from '@contexts/AgentTaskTrackerContext/types' import { act, waitFor } from '@testing-library/react' +import { + getComposerDraft, + resetComposerDraftStore, + setComposerDraft, +} from '@lib/composerDraftStore' import { renderController } from './__fixtures__/controllerHarness' import { type MockClerum, installMockClerum, uninstallMockClerum } from './__fixtures__/mockClerum' @@ -29,6 +34,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() + resetComposerDraftStore() uninstallMockClerum() }) @@ -989,6 +995,238 @@ describe('switchToChat (unified, D.4)', () => { expect(result.current.olderMessagesLoading).toBe(false) }) + it.each([401, 403, 404])( + 'older-message HTTP %i applies the correct Host authority decision', + async status => { + const revoked = new Set() + clerum.chat.loadMessages.mockImplementation(async (_agentRef, _chatId, _limit, offset) => + offset === undefined + ? [ + { + id: 'turn-10-user', + role: 'user', + content: 'cached', + timestamp: 10, + serverTurnNumber: 10, + }, + ] + : [] + ) + clerum.rpc.loadSessionMessages.mockImplementation( + async (_hostRef, _agent, chatId, _teamId, query?: { beforeTurn?: number }) => { + if (query?.beforeTurn !== undefined) throw new Error(`${status} response`) + return { + agent: 'agent-x', + chatId, + state: 'idle', + turns: [], + hasMoreBefore: true, + hasMoreAfter: false, + } + } + ) + const { result } = renderController({ + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await settleMount() + await act(async () => { + await result.current.switchToChat('agent-x', 'older-authority') + }) + expect(result.current.hasOlderMessages).toBe(true) + + await act(async () => { + await result.current.handleLoadOlderMessages() + }) + if (status === 404) { + expect(revoked.has('agent-x')).toBe(false) + expect(result.current.activeChatId).toBe('older-authority') + expect(result.current.chatMessages.map(message => message.content)).toEqual(['cached']) + } else { + expect(revoked.has('agent-x')).toBe(true) + expect(result.current.activeChatId).toBeNull() + expect(result.current.chatMessages).toEqual([]) + } + } + ) + + it('ignores a send 403 from the prior team scope', async () => { + const revoked = new Set() + let rejectSend!: (reason: unknown) => void + clerum.rpc.invokeHostMessage.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSend = reject + }) + ) + const controller = renderController({ + currentTeamId: 'team-before', + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await settleMount() + await act(async () => { + await controller.result.current.switchToChat('agent-x', 'old-team-chat') + }) + + const pendingSend = controller.result.current.handleSendAgentMessage('hello') + await waitFor(() => expect(clerum.rpc.invokeHostMessage).toHaveBeenCalledTimes(1)) + controller.rerender({ currentTeamId: 'team-after' }) + await act(async () => { + rejectSend(new Error('403 forbidden')) + await pendingSend + }) + expect(revoked.has('agent-x')).toBe(false) + }) + + it('ignores an older-message 403 from the prior team scope', async () => { + const revoked = new Set() + let rejectOlder!: (reason: unknown) => void + clerum.chat.loadMessages.mockImplementation(async (_agentRef, _chatId, _limit, offset) => + offset === undefined + ? [ + { + id: 'turn-10-user', + role: 'user', + content: 'cached', + timestamp: 10, + serverTurnNumber: 10, + }, + ] + : [] + ) + clerum.rpc.loadSessionMessages.mockImplementation( + async (_hostRef, _agent, chatId, _teamId, query?: { beforeTurn?: number }) => { + if (query?.beforeTurn !== undefined) { + return new Promise((_resolve, reject) => { + rejectOlder = reject + }) + } + return { agent: 'agent-x', chatId, state: 'idle', turns: [], hasMoreBefore: true } + } + ) + const controller = renderController({ + currentTeamId: 'team-before', + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await settleMount() + await act(async () => { + await controller.result.current.switchToChat('agent-x', 'old-team-chat') + }) + const pendingOlder = controller.result.current.handleLoadOlderMessages() + await waitFor(() => expect(rejectOlder).toEqual(expect.any(Function))) + controller.rerender({ currentTeamId: 'team-after' }) + await act(async () => { + rejectOlder(new Error('403 forbidden')) + await pendingOlder + }) + expect(revoked.has('agent-x')).toBe(false) + }) + + it.each([401, 403, 404])( + 'durable task-result HTTP %i applies the correct Host authority decision', + async status => { + const revoked = new Set() + clerum.chat.loadMessages.mockResolvedValue([]) + clerum.rpc.loadSessionMessages.mockResolvedValue({ + agent: 'agent-x', + chatId: 'result-authority', + state: 'idle', + turns: [], + }) + clerum.rpc.getTaskResult.mockRejectedValue(new Error(`${status} response`)) + const { result } = renderController({ + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await settleMount() + await act(async () => { + await result.current.switchToChat('agent-x', 'result-authority') + }) + + await act(async () => { + await result.current.reconcileChat(makeTaskKey('agent-x', 'result-authority'), { + reason: 'task_result_recovery', + taskIdHint: 'task-missing-turn', + }) + }) + expect(clerum.rpc.getTaskResult).toHaveBeenCalled() + if (status === 404) { + expect(revoked.has('agent-x')).toBe(false) + expect(result.current.activeChatId).toBe('result-authority') + } else { + expect(revoked.has('agent-x')).toBe(true) + expect(result.current.activeChatId).toBeNull() + } + } + ) + + it.each(['revocation', 'confirmed deletion', 'team switch'])( + 'does not publish a late durable task reply after %s', + async action => { + const revoked = new Set() + const lateResult = deferred<{ response: string }>() + clerum.chat.loadMessages.mockResolvedValue([]) + clerum.rpc.loadSessionMessages.mockResolvedValueOnce({ + agent: 'agent-x', + chatId: 'late-result', + state: 'processing', + activeTaskId: 'task-late-result', + turns: [turn(1, 'question')], + }) + const controller = renderController({ + currentTeamId: 'team-before', + onHostAccessRevoked: agentRef => revoked.add(agentRef), + isHostAccessRevoked: agentRef => revoked.has(agentRef), + }) + await settleMount() + await act(async () => { + await controller.result.current.switchToChat('agent-x', 'late-result') + }) + await waitFor(() => expect(clerum.hasProgressHandler('task-late-result')).toBe(true)) + + // The initial processing snapshot may fetch attachments. Hold only the + // terminal fallback read whose late completion is under test. + clerum.rpc.getTaskResult.mockClear() + clerum.rpc.getTaskResult.mockImplementation(() => lateResult.promise) + clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('unexpected transcript failure')) + clerum.emitTaskProgress('task-late-result', { type: 'error', message: 'stream dropped' }) + await waitFor(() => expect(clerum.rpc.getTaskResult).toHaveBeenCalledTimes(1)) + + if (action === 'revocation') { + clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + await act(async () => { + await controller.result.current.reconcileChat(makeTaskKey('agent-x', 'late-result'), { + reason: 'authority_refresh', + }) + }) + expect(revoked.has('agent-x')).toBe(true) + } else if (action === 'confirmed deletion') { + await act(async () => { + await controller.result.current.handleDeleteChatForAgent('agent-x', 'late-result') + }) + expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', 'late-result') + } else { + controller.rerender({ currentTeamId: 'team-after' }) + } + + const writesBefore = clerum.chat.appendMessages.mock.calls.length + const notificationsBefore = controller.spies.pushNotification.mock.calls.length + const toastsBefore = controller.spies.pushToast.mock.calls.length + await act(async () => { + lateResult.resolve({ response: 'late protected reply' }) + await lateResult.promise + }) + expect(clerum.chat.appendMessages.mock.calls.length).toBe(writesBefore) + expect(controller.spies.pushNotification.mock.calls.length).toBe(notificationsBefore) + expect(controller.spies.pushToast.mock.calls.length).toBe(toastsBefore) + expect(controller.result.current.chatMessages.map(message => message.content)).not.toContain( + 'late protected reply' + ) + } + ) + it('ignores an older-page response when switching away and back before it resolves', async () => { let resolveOlder!: (messages: unknown[]) => void const olderPage = new Promise(resolve => { @@ -1665,10 +1903,12 @@ describe('switchToChat (unified, D.4)', () => { expect(clerum.hasProgressHandler('task-NEW')).toBe(true) }) - it('404 evicts the stale local chat and resets the active selection', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - clerum.chat.loadMessages.mockResolvedValue([]) + it('ambiguous 404 preserves the selected chat, cached messages and unsent draft', async () => { + clerum.chat.loadMessages.mockResolvedValue([ + { id: 'cached-turn', role: 'user' as const, content: 'known conversation', timestamp: 1 }, + ]) clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('404 Not Found')) + setComposerDraft('gone', 'unsent message') const { result } = renderController() await settleMount() @@ -1676,10 +1916,16 @@ describe('switchToChat (unified, D.4)', () => { await result.current.switchToChat('agent-x', 'gone') }) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', 'gone') - expect(result.current.activeChatId).toBeNull() - expect(result.current.sessionStateByChatKey['agent-x::gone']).toBeUndefined() - expect(warn).toHaveBeenCalled() + expect(clerum.chat.delete).not.toHaveBeenCalled() + expect(result.current.activeChatId).toBe('gone') + expect(result.current.chatMessages.map(message => message.content)).toEqual([ + 'known conversation', + ]) + expect(getComposerDraft('gone')).toBe('unsent message') + expect(result.current.sessionStateByChatKey['agent-x::gone']).toMatchObject({ + syncing: false, + offlineMode: true, + }) }) it('network error keeps the Phase-1 cache and flags offline mode', async () => { diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx index 563745c9a1..eda3cc67be 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx @@ -12,10 +12,15 @@ import { AgentTaskTrackerProvider } from '@contexts/AgentTaskTrackerContext' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useAgentChatController } from '@hooks/domain/useAgentChatController' import { useComposerDraft } from '@hooks/useComposerDraft' -import { resetComposerDraftStore } from '@lib/composerDraftStore' +import { getComposerDraft, resetComposerDraftStore } from '@lib/composerDraftStore' import type { TaskProgressStreamEvent } from '../../../../../src/types' type ProgressHandler = (event: TaskProgressStreamEvent) => void | Promise +const revokedAgents = new Set() +const onHostAccessRevoked = (agentRef: string) => { + revokedAgents.add(agentRef) +} +const isHostAccessRevoked = (agentRef: string) => revokedAgents.has(agentRef) function getDraftInputValue(): string { return (screen.getByTestId('draft-input') as HTMLInputElement).value @@ -35,8 +40,14 @@ function createChatMeta(chatId: string) { function installClerumHarness() { let taskIndex = 0 const chats: Array> = [] + const deletedChatIds = new Set() const messagesByChat = new Map() const progressHandlers = new Map() + const loadSessionMessages = vi.fn(async () => ({ agent: 'trader', chatId: '', turns: [] })) + const listSessions = vi.fn(async () => ({ items: [] })) + const appendMessages = vi.fn(async (_agentRef: string, chatId: string, messages: unknown[]) => { + messagesByChat.set(chatId, [...(messagesByChat.get(chatId) || []), ...messages]) + }) const invokeHostMessage = vi.fn(async (_agentRef: string, _payload: { threadId?: string }) => { taskIndex += 1 @@ -51,18 +62,22 @@ function installClerumHarness() { list: vi.fn(async () => chats), create: vi.fn(async (_agentRef: string, chatId: string) => { const meta = createChatMeta(chatId) + deletedChatIds.delete(chatId) chats.push(meta) messagesByChat.set(chatId, []) return meta }), rename: vi.fn(async () => undefined), - delete: vi.fn(async () => undefined), + delete: vi.fn(async (_agentRef: string, chatId: string) => { + deletedChatIds.add(chatId) + const index = chats.findIndex(chat => chat.id === chatId) + if (index >= 0) chats.splice(index, 1) + messagesByChat.delete(chatId) + }), loadMessages: vi.fn( async (_agentRef: string, chatId: string) => messagesByChat.get(chatId) || [] ), - appendMessages: vi.fn(async (_agentRef: string, chatId: string, messages: unknown[]) => { - messagesByChat.set(chatId, [...(messagesByChat.get(chatId) || []), ...messages]) - }), + appendMessages, replaceMessages: vi.fn(async (_agentRef: string, chatId: string, messages: unknown[]) => { messagesByChat.set(chatId, [...messages]) }), @@ -70,12 +85,12 @@ function installClerumHarness() { clearUnreadTerminal: vi.fn(async () => undefined), getLastActive: vi.fn(async () => null), setLastActive: vi.fn(async () => undefined), - getIndex: vi.fn(async () => ({ chats })), + getIndex: vi.fn(async () => ({ chats, deletedChatIds: [...deletedChatIds] })), dismissOnboarding: vi.fn(async () => undefined), }, rpc: { - listSessions: vi.fn(async () => ({ items: [] })), - loadSessionMessages: vi.fn(async () => ({ agent: 'trader', chatId: '', turns: [] })), + listSessions, + loadSessionMessages, subscribeHostActivity: vi.fn(async () => async () => undefined), invokeHostMessage, subscribeTaskProgress: vi.fn( @@ -94,10 +109,22 @@ function installClerumHarness() { }, }) - return { invokeHostMessage, progressHandlers } + return { + invokeHostMessage, + appendMessages, + progressHandlers, + loadSessionMessages, + listSessions, + seedMessage: (chatId: string, content: string) => + messagesByChat.set(chatId, [ + { id: `message-${chatId}`, role: 'user', content, createdAt: new Date().toISOString() }, + ]), + } } function AgentChatHarness() { + const [selectionState, setSelectionState] = React.useState('idle') + const [sendState, setSendState] = React.useState('idle') const vm = useAgentChatController({ selectedAgent: 'trader', agentNames: ['trader'], @@ -106,6 +133,8 @@ function AgentChatHarness() { isAuthenticated: true, loadMenuData: true, navItem: 'chat', + onHostAccessRevoked, + isHostAccessRevoked, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, @@ -123,6 +152,13 @@ function AgentChatHarness() {
{vm.activeChatId || ''}
{String(vm.agentSending)}
+
{vm.chatMessages.length}
+
{vm.chatList.map(chat => chat.id).join(',')}
+
+ {vm.latestChatSessions.map(chat => chat.id).join(',')} +
+
{selectionState}
+
{sendState}
void vm.handleCreateChat()}> Create chat - {vm.chatList.map(chat => ( - + + + + ))}
) @@ -148,6 +201,7 @@ describe('useAgentChatController (cross-chat, migrated)', () => { cleanup() vi.restoreAllMocks() resetComposerDraftStore() + revokedAgents.clear() delete (window as { clerum?: unknown }).clerum }) @@ -243,4 +297,176 @@ describe('useAgentChatController (cross-chat, migrated)', () => { await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).toBe(secondChatId)) expect(getDraftInputValue()).toBe('draft for session B') }) + + it.each(['404', '503', 'timeout'])( + 'keeps chat, selection and draft after a transient transcript %s', + async failure => { + const { loadSessionMessages, seedMessage } = installClerumHarness() + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Create chat' })) + await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).not.toBe('')) + const firstChatId = screen.getByTestId('active-chat-id').textContent || '' + seedMessage(firstChatId, 'known conversation turn') + fireEvent.click(screen.getByRole('button', { name: 'Create chat' })) + await waitFor(() => + expect(screen.getByTestId('active-chat-id').textContent).not.toBe(firstChatId) + ) + fireEvent.click(screen.getByRole('button', { name: `Select chat ${firstChatId}` })) + await waitFor(() => expect(screen.getByTestId('chat-message-count').textContent).toBe('1')) + fireEvent.change(screen.getByTestId('draft-input'), { + target: { value: 'unsent draft' }, + }) + + const readsBeforeFault = loadSessionMessages.mock.calls.length + loadSessionMessages.mockRejectedValue(new Error(failure)) + fireEvent.click(screen.getByRole('button', { name: `Select chat ${firstChatId}` })) + await waitFor(() => + expect(loadSessionMessages.mock.calls.length).toBeGreaterThan(readsBeforeFault) + ) + await waitFor(() => expect(screen.getByTestId('selection-state').textContent).toBe('settled')) + await waitFor(() => expect(screen.getByTestId('chat-message-count').textContent).toBe('1')) + expect(screen.getByTestId('active-chat-id').textContent).toBe(firstChatId) + expect(screen.getByTestId('chat-message-count').textContent).toBe('1') + expect(getDraftInputValue()).toBe('unsent draft') + expect(screen.getByRole('button', { name: `Select chat ${firstChatId}` })).toBeTruthy() + } + ) + + it('does not restore protected chat or messages when a pending send resolves after revocation', async () => { + const { invokeHostMessage, loadSessionMessages, progressHandlers } = installClerumHarness() + let finishInvoke!: (result: { taskId: string; status: string }) => void + invokeHostMessage.mockImplementationOnce( + () => + new Promise(resolve => { + finishInvoke = resolve + }) + ) + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Create chat' })) + await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).not.toBe('')) + const chatId = screen.getByTestId('active-chat-id').textContent || '' + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + await waitFor(() => expect(invokeHostMessage).toHaveBeenCalledTimes(1)) + + loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + fireEvent.click(screen.getByRole('button', { name: `Select chat ${chatId}` })) + await waitFor(() => expect(revokedAgents.has('trader')).toBe(true)) + await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).toBe('')) + expect(screen.getByTestId('chat-message-count').textContent).toBe('0') + + finishInvoke({ taskId: 'late-task', status: 'pending' }) + await waitFor(() => expect(screen.getByTestId('send-state').textContent).toBe('settled')) + await waitFor(() => expect(screen.getByTestId('agent-sending').textContent).toBe('false')) + expect(screen.getByTestId('active-chat-id').textContent).toBe('') + expect(screen.getByTestId('chat-message-count').textContent).toBe('0') + expect(screen.queryByRole('button', { name: `Select chat ${chatId}` })).toBeNull() + expect(progressHandlers.has('late-task')).toBe(false) + }) + + it.each([ + { label: '401', sendError: '401 unauthorized', readError: null, revoked: true }, + { + label: 'exact Host-wide 403', + sendError: '403 Forbidden: host_access_revoked', + readError: '503 unavailable', + revoked: true, + }, + { + label: 'generic 403 with readable catalog', + sendError: '403 missing send scope', + readError: null, + revoked: false, + }, + { + label: 'generic 403 with denied catalog', + sendError: '403 missing send scope', + readError: '403 forbidden', + revoked: true, + }, + { label: '404', sendError: '404 transient', readError: null, revoked: false }, + ])( + 'send $label applies the transcript authority decision', + async ({ sendError, readError, revoked }) => { + const { invokeHostMessage, listSessions } = installClerumHarness() + invokeHostMessage.mockRejectedValueOnce(new Error(sendError)) + render( + + + + ) + fireEvent.click(screen.getByRole('button', { name: 'Create chat' })) + await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).not.toBe('')) + const chatId = screen.getByTestId('active-chat-id').textContent || '' + if (readError) listSessions.mockRejectedValue(new Error(readError)) + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + await waitFor(() => expect(screen.getByTestId('send-state').textContent).toBe('settled')) + expect(revokedAgents.has('trader')).toBe(revoked) + if (revoked) { + expect(screen.getByTestId('active-chat-id').textContent).toBe('') + expect(screen.getByTestId('chat-message-count').textContent).toBe('0') + } else { + expect(screen.getByTestId('active-chat-id').textContent).toBe(chatId) + } + } + ) + + it('does not republish a confirmed deleted chat after its pending send resolves', async () => { + const { invokeHostMessage, appendMessages } = installClerumHarness() + let finishInvoke!: (result: { taskId: string; status: string }) => void + invokeHostMessage.mockImplementationOnce( + () => + new Promise(resolve => { + finishInvoke = resolve + }) + ) + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Create chat' })) + await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).not.toBe('')) + const deletedChatId = screen.getByTestId('active-chat-id').textContent || '' + await waitFor(() => + expect(screen.getByTestId('latest-list-ids').textContent).toContain(deletedChatId) + ) + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + await waitFor(() => expect(invokeHostMessage).toHaveBeenCalledTimes(1)) + fireEvent.change(screen.getByTestId('draft-input'), { + target: { value: 'draft to clear on delete' }, + }) + expect(getComposerDraft(deletedChatId)).toBe('draft to clear on delete') + + fireEvent.click(screen.getByRole('button', { name: `Delete chat ${deletedChatId}` })) + await waitFor(() => + expect(screen.queryByRole('button', { name: `Select chat ${deletedChatId}` })).toBeNull() + ) + expect(getComposerDraft(deletedChatId)).toBe('') + const writesBeforeLateAck = appendMessages.mock.calls.length + + finishInvoke({ taskId: 'late-deleted-task', status: 'pending' }) + await waitFor(() => expect(screen.getByTestId('send-state').textContent).toBe('settled')) + expect(screen.getByTestId('chat-list-ids').textContent).not.toContain(deletedChatId) + expect(screen.getByTestId('latest-list-ids').textContent).not.toContain(deletedChatId) + expect(getComposerDraft(deletedChatId)).toBe('') + expect(appendMessages.mock.calls.length).toBe(writesBeforeLateAck) + + const survivingChatId = screen.getByTestId('active-chat-id').textContent || '' + expect(survivingChatId).not.toBe('') + expect(survivingChatId).not.toBe(deletedChatId) + fireEvent.click(screen.getByRole('button', { name: 'Send message' })) + await waitFor(() => expect(invokeHostMessage).toHaveBeenCalledTimes(2)) + expect(invokeHostMessage.mock.calls[1]?.[1]).toMatchObject({ threadId: survivingChatId }) + }) }) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx index 041c5b4fb9..d1d996dcf6 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx @@ -19,6 +19,8 @@ import { resetHostModelSelectionStore } from '@lib/hostModelSelectionStore' import type { TaskProgressStreamEvent } from '../../../../../src/types' type ProgressHandler = (event: TaskProgressStreamEvent) => void | Promise +const onHostAccessRevoked = () => {} +const isHostAccessRevoked = () => false function createChatMeta(chatId: string) { const now = new Date().toISOString() @@ -121,6 +123,8 @@ function AgentChatHarness() { isAuthenticated: true, loadMenuData: true, navItem: 'chat', + onHostAccessRevoked, + isHostAccessRevoked, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.test.tsx index 9e82e9687c..ad9c72f68b 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.test.tsx @@ -431,6 +431,50 @@ describe('useAgentChatController — characterization (D.0)', () => { expect(appended?.[0]?.errorCode).toBe('provider_error') }) + it('does not toast or repaint a terminal error after its append crosses a team switch', async () => { + const pendingAppend = deferred() + clerum.rpc.invokeHostMessage.mockResolvedValue({ taskId: 'task-old-team' }) + clerum.chat.appendMessages.mockImplementation( + async (_agentRef: string, _chatId: string, messages: Array<{ role?: string }>) => { + if (messages[0]?.role === 'assistant') await pendingAppend.promise + } + ) + const controller = renderController({ currentTeamId: 'team-before' }) + await settleMount() + await act(async () => { + await controller.result.current.handleSendAgentMessage('question') + }) + await waitFor(() => expect(clerum.hasProgressHandler('task-old-team')).toBe(true)) + + clerum.emitTaskProgress('task-old-team', { + type: 'terminal', + data: { + taskId: 'task-old-team', + status: 'failed', + error: { message: 'protected old-team failure', code: 'provider_error' }, + }, + }) + await waitFor(() => + expect( + clerum.chat.appendMessages.mock.calls.some( + call => (call[2] as Array<{ role?: string }>)[0]?.role === 'assistant' + ) + ).toBe(true) + ) + controller.rerender({ currentTeamId: 'team-after' }) + const toastCount = controller.spies.pushToast.mock.calls.length + const notificationCount = controller.spies.pushNotification.mock.calls.length + const progressAfterSwitch = controller.result.current.progressByMessageId + + await act(async () => { + pendingAppend.resolve() + await pendingAppend.promise + }) + expect(controller.spies.pushToast.mock.calls.length).toBe(toastCount) + expect(controller.spies.pushNotification.mock.calls.length).toBe(notificationCount) + expect(controller.result.current.progressByMessageId).toEqual(progressAfterSwitch) + }) + it('4.6 marks the task cancelled without fetching a result', async () => { clerum.rpc.invokeHostMessage.mockResolvedValue({ taskId: 'task-abc' }) const { result } = renderController() diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx index 9c323fbd27..8f000b5c6d 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx @@ -81,6 +81,8 @@ interface HarnessProps { isAuthenticated: boolean navItem: NavItem } +const onHostAccessRevoked = () => {} +const isHostAccessRevoked = () => false /** Both real hooks, mounted together under the task-tracker the chat controller needs. */ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navItem }: HarnessProps) { @@ -93,6 +95,8 @@ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navIte isAuthenticated, loadMenuData: true, navItem, + onHostAccessRevoked, + isHostAccessRevoked, ...stableCallbacks, }) } diff --git a/desktop-app/ui/src/hooks/domain/reconcileChat.ts b/desktop-app/ui/src/hooks/domain/reconcileChat.ts index a186fe027a..9302de791d 100644 --- a/desktop-app/ui/src/hooks/domain/reconcileChat.ts +++ b/desktop-app/ui/src/hooks/domain/reconcileChat.ts @@ -8,14 +8,14 @@ import type { SessionFsmStore } from './sessionFsm' * second invocation while one runs coalesces onto the same promise) and routes * the server truth through the four precedence branches, dispatching * `SERVER_SNAPSHOT`/`RECONCILE_*` to the FSM. The side-effectful branch bodies - * (attach a live stream, materialize/persist idle turns + durable result, evict a - * 404 chat) are injected so the module stays pure and exhaustively unit-testable. + * (attach a live stream, materialize/persist idle turns + durable result, hide + * protected data after an authorization rejection) are injected. * * Telemetry (§4.8): one `stream_recovery` emission per `RECONCILE_FINISHED`, * carrying `{reason, outcome}` and preserving the existing outcome names * (`reconcile_rejoined`, `rejoin_capped_offline`, `reconcile_replaced`, - * `recovered_from_task_result`, `fell_through_to_resend`, `404`) plus new ones - * (`stale_drop`, `offline`, `error`). + * `recovered_from_task_result`, `fell_through_to_resend`) plus `revoked`, + * `stale_drop`, `offline`, and `error`. */ /** Precedence-branch outcomes (stable telemetry names + Fase-3 additions). */ @@ -30,7 +30,7 @@ export type ReconcileOutcome = | 'recovered_error' | 'fell_through_to_resend' | 'stale_drop' - | '404' + | 'revoked' | 'offline' | 'error' | 'noop' @@ -77,10 +77,11 @@ export interface ReconcileChatDeps { /** Same post-await teardown guard as `attachLiveTask` (see there). */ stillRelevant: () => boolean ) => Promise - /** Local eviction for a chat the server 404s (cache + sidebar + deselect). */ - evictChat: (chatKey: string) => Promise + /** Hide protected data after an explicit authorization rejection. */ + revokeAccess: (chatKey: string) => void isNetworkError: (err: unknown) => boolean isHttp404: (err: unknown) => boolean + isAuthorizationError: (err: unknown) => boolean telemetry: (event: string, data: Record) => void /** Guard: `false` aborts the reconcile mid-flight (chat switched away). */ isStillRelevant?: (chatKey: string) => boolean @@ -165,9 +166,13 @@ export function createReconcileChat(deps: ReconcileChatDeps): ReconcileChat { try { return await deps.loadSessionMessages(agentRef, chatId, query, stillRelevant) } catch (err) { + // Chat selection may change while this request is pending, but an + // explicit Host authorization denial still applies to the active scope. + // The outer catch checks the principal/team generation before revoking. + if (deps.isAuthorizationError(err)) throw err if (!stillRelevant()) return undefined - // Non-network (404, etc.) rethrows to the branch handler; a network blip - // is retried with backoff up to the cap (mirrors switchToChat P2-A). + // Transport failures retry with backoff. An ambiguous 404 and an + // authorization rejection reach the classification branch immediately. if (!deps.isNetworkError(err) || attempt === attempts - 1) throw err } if (!stillRelevant()) return undefined @@ -216,15 +221,20 @@ export function createReconcileChat(deps: ReconcileChatDeps): ReconcileChat { ) return outcome } catch (err) { - if (!stillRelevant()) { + // An authority rejection applies to the Host, even if the user switched + // from chat A to B while A's request was pending. The reset generation is + // the principal/team boundary; an old scope cannot revoke a new one. + if (deps.isAuthorizationError(err) && generation === startGeneration) { + deps.revokeAccess(chatKey) + deps.fsm.dispatch(chatKey, { type: 'RESET' }) + outcome = 'revoked' + } else if (!stillRelevant()) { outcome = 'stale_drop' return outcome - } - if (deps.isHttp404(err)) { - await deps.evictChat(chatKey) - deps.fsm.dispatch(chatKey, { type: 'RESET' }) - outcome = '404' - } else if (deps.isNetworkError(err)) { + } else if (deps.isHttp404(err) || deps.isNetworkError(err)) { + // A transcript 404 is anti-enumeration and can also occur while a Host + // wakes. It does not prove the session was deleted. Keep the local chat, + // selection and draft for a later successful reconcile. deps.fsm.dispatch(chatKey, { type: 'WENT_OFFLINE' }) outcome = 'offline' } else { @@ -239,10 +249,10 @@ export function createReconcileChat(deps: ReconcileChatDeps): ReconcileChat { } return outcome } finally { - // RESET already tore the entry down on 404; re-dispatching FINISHED would + // RESET already tore the entry down on revocation; re-dispatching FINISHED would // resurrect an empty entry, so skip the finalizer in that case. if ( - outcome !== '404' && + outcome !== 'revoked' && generation === startGeneration && (chatGenerations.get(chatKey) ?? 0) === startChatGeneration ) { diff --git a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts index d37893f25d..8b2590a991 100644 --- a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts +++ b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts @@ -43,6 +43,9 @@ import { classifyErrorKind, errorRecoveryHint, extractAssistantReply, + isAuthorizationError, + isConfirmedHostAccessRevoked, + isHttp403, isHttp404, isNetworkError, } from '../../lib/format' @@ -314,7 +317,8 @@ async function loadTaskResultResponseFileAttachments( try { const result = await window.clerum.rpc.getTaskResult(agentRef, taskId, [agentRef]) return buildResponseFileAttachments(result) - } catch { + } catch (error) { + if (isAuthorizationError(error)) throw error return [] } } @@ -354,6 +358,8 @@ interface UseAgentChatControllerParams { target: AgentConversationNotificationTarget ) => Promise decideApprovalFromNotification: (target: AgentApprovalNotificationTarget) => Promise + onHostAccessRevoked: (agentRef: string) => void + isHostAccessRevoked: (agentRef: string) => boolean } export function useAgentChatController({ @@ -372,6 +378,8 @@ export function useAgentChatController({ showDesktopNotification, openAgentConversationFromNotification, decideApprovalFromNotification, + onHostAccessRevoked, + isHostAccessRevoked, }: UseAgentChatControllerParams) { const chatStore = useChatStore() const authenticatedScope = `${currentUserId ?? 'unknown-user'}:${currentTeamId}` @@ -401,6 +409,11 @@ export function useAgentChatController({ // activeChatId) are injected through `chatListHostRef`, filled each render once // those parent callbacks are defined (created here so the hook can receive it). const chatListHostRef = useRef(null) + const revokeHostAccessRef = useRef<((agentRef: string) => void) | null>(null) + const onCatalogHostAccessRevoked = useCallback( + (agentRef: string) => revokeHostAccessRef.current!(agentRef), + [] + ) const autoSelectedChatIdRef = useRef(null) const chatListCtl = useChatListController({ selectedAgent, @@ -414,6 +427,8 @@ export function useAgentChatController({ chatStore, fsm, host: chatListHostRef, + isHostAccessRevoked, + onHostAccessRevoked: onCatalogHostAccessRevoked, }) const { chatList, @@ -422,6 +437,7 @@ export function useAgentChatController({ chatListHasMoreRemoteSessions, latestChatSessions, latestChatSessionsLoading, + hideAgent, loadChatList, loadMoreChatSessions, setChatListLoading, @@ -438,6 +454,7 @@ export function useAgentChatController({ readPendingSelection, writePendingSelection, clearPendingSelection, + isChatDeleted, } = chatListCtl const [chatMessages, setChatMessages] = useState([]) const [chatMessagesLoading, setChatMessagesLoading] = useState(false) @@ -497,6 +514,8 @@ export function useAgentChatController({ failRetainedSend, } = retainedSends const sendScopeGeneration = useRef(0) + const currentAuthScopeRef = useRef(`${isAuthenticated}:${authenticatedScope}`) + currentAuthScopeRef.current = `${isAuthenticated}:${authenticatedScope}` // #654 M5 — the userMessageId of the most recent send that retained its // payload. A retry compares it before and after to learn whether it retained // a snapshot of its own; `sendAgentMessage` has several early returns (image @@ -508,6 +527,7 @@ export function useAgentChatController({ if (lastRetentionScope.current === scope) return lastRetentionScope.current = scope sendScopeGeneration.current += 1 + agentSendSetupOwnerRef.current = null agentSendInFlightRef.current = false setAgentSending(false) retainedSends.resetRetainedSendStore() @@ -519,6 +539,7 @@ export function useAgentChatController({ const activityInFlightByAgentRef = useRef>({}) const activityTaskToMessageByAgentRef = useRef>>({}) const agentSendInFlightRef = useRef(false) + const agentSendSetupOwnerRef = useRef<{ id: symbol; agentRef: string } | null>(null) // (Phase-2 AbortController + `recoveringTaskIdByKeyRef` removed in Fase 5b: the // reconcile gate's per-call `isRelevant` covers the A→B→A abort, and the // epoch-anchored zombie-ack in `settleIdle` (R2) covers the residual-recreated- @@ -553,7 +574,7 @@ export function useAgentChatController({ loadSessionMessages: ReconcileChatDeps['loadSessionMessages'] attachLiveTask: ReconcileChatDeps['attachLiveTask'] settleIdle: ReconcileChatDeps['settleIdle'] - evictChat: ReconcileChatDeps['evictChat'] + revokeAccess: ReconcileChatDeps['revokeAccess'] } | null>(null) const reconcileChatRef = useRef(null) if (!reconcileChatRef.current) { @@ -565,9 +586,10 @@ export function useAgentChatController({ reconcileBranchesRef.current!.attachLiveTask(chatKey, resp, epoch, stillRelevant), settleIdle: (chatKey, resp, epoch, hint, stillRelevant) => reconcileBranchesRef.current!.settleIdle(chatKey, resp, epoch, hint, stillRelevant), - evictChat: chatKey => reconcileBranchesRef.current!.evictChat(chatKey), + revokeAccess: chatKey => reconcileBranchesRef.current!.revokeAccess(chatKey), isNetworkError, isHttp404, + isAuthorizationError, telemetry: (event, data) => console.log(`[telemetry] ${event}`, data), }) } @@ -825,6 +847,7 @@ export function useAgentChatController({ // main-process half (`stopAllStreams` on logout) is Fase 4. tracker.releaseAll() sendScopeGeneration.current += 1 + agentSendSetupOwnerRef.current = null agentSendInFlightRef.current = false retainedSends.resetRetainedSendStore() // Abort any in-flight reconcile so a run mid-backoff can't resurrect a @@ -861,6 +884,7 @@ export function useAgentChatController({ // chat, and a task in flight is rejoined via the tracker D.3 already mounts. const switchToChat = useCallback( async (agentRef: string, chatId: string) => { + if (isHostAccessRevoked(agentRef) || isChatDeleted(agentRef, chatId)) return const switchRequest = Symbol(`${agentRef}:${chatId}`) activeChatSwitchRequestRef.current = switchRequest const key = makeTaskKey(agentRef, chatId) @@ -891,6 +915,7 @@ export function useAgentChatController({ setAgentError(null) setFailedAgentSend(null) await chatStore.setLastActive(agentRef, chatId) + if (isHostAccessRevoked(agentRef) || isChatDeleted(agentRef, chatId)) return unfillableServerGapUpperBoundsRef.current.delete(key) @@ -948,7 +973,7 @@ export function useAgentChatController({ // clean. tracker.resetRejoinAttempts(key) - // PHASE 2/3 — the single reconcile gate (§4.3) owns fetch+retry, 404 evict, + // PHASE 2/3 — the single reconcile gate (§4.3) owns fetch+retry, ambiguous 404, // offline, the SERVER_SNAPSHOT, the idle replace (auto-title / S4 upsert / // epoch-anchored zombie-ack) and the live rejoin (approval re-seed). // `taskIdHint` = the pre-await zombie's task, which drives the durable @@ -977,6 +1002,8 @@ export function useAgentChatController({ }, [ currentTeamId, + isHostAccessRevoked, + isChatDeleted, dispatchSession, tracker, reconcileChat, @@ -1020,17 +1047,24 @@ export function useAgentChatController({ const handleSelectChat = useCallback( async (chatId: string) => { - if (!selectedAgent) return + if ( + !selectedAgent || + isHostAccessRevoked(selectedAgent) || + isChatDeleted(selectedAgent, chatId) + ) + return autoSelectedChatIdRef.current = null await switchToChat(selectedAgent, chatId) }, - [selectedAgent, switchToChat] + [selectedAgent, isHostAccessRevoked, isChatDeleted, switchToChat] ) const handleLoadOlderMessages = useCallback(async () => { if (!selectedAgent || !activeChatId || olderMessagesRequestRef.current) return const requestAgent = selectedAgent const requestChatId = activeChatId + const requestScope = sendScopeGeneration.current + const requestScopeIdentity = currentAuthScopeRef.current const requestToken = Symbol(`${requestAgent}:${requestChatId}`) olderMessagesRequestRef.current = requestToken const isStillRelevant = () => { @@ -1129,6 +1163,15 @@ export function useAgentChatController({ oldestMergedTurn > 1) ) } catch (error) { + if ( + isAuthorizationError(error) && + requestScope === sendScopeGeneration.current && + requestScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(requestAgent) + ) { + revokeHostAccessRef.current!(requestAgent) + return + } console.warn('[chat-history] failed to load older messages', { agentRef: requestAgent, chatId: requestChatId, @@ -1149,12 +1192,13 @@ export function useAgentChatController({ ignoredServerGapUpperBounds, markServerGapUnfillable, selectedAgent, + isHostAccessRevoked, tracker, ]) // Agent selection → load chats useEffect(() => { - if (!selectedAgent) { + if (!selectedAgent || isHostAccessRevoked(selectedAgent)) { autoSelectedChatIdRef.current = null activeChatVisibilityRef.current = { ...activeChatVisibilityRef.current, @@ -1212,7 +1256,7 @@ export function useAgentChatController({ setChatListLoading(true) ;(async () => { const result = await loadChatList(selectedAgent) - if (cancelled) return + if (cancelled || isHostAccessRevoked(selectedAgent)) return setChatListLoading(false) if (!result) { setChatMessagesLoading(false) @@ -1276,7 +1320,7 @@ export function useAgentChatController({ return () => { cancelled = true } - }, [cancelOlderMessagesLoad, currentTeamId, navItem, selectedAgent]) + }, [cancelOlderMessagesLoad, currentTeamId, navItem, selectedAgent, isHostAccessRevoked]) const mapComposerAttachmentsToHostRequest = ( attachments: ComposerImageAttachment[] @@ -1446,6 +1490,14 @@ export function useAgentChatController({ const appendAssistantMessage = useCallback( async (agentName: string, chatId: string | null, message: AgentChatMessage) => { + const messageScope = sendScopeGeneration.current + const messageScopeIdentity = currentAuthScopeRef.current + const mayAppend = () => + messageScope === sendScopeGeneration.current && + messageScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(agentName) && + (!chatId || !isChatDeleted(agentName, chatId)) + if (!mayAppend()) return // Update the in-memory view FIRST (synchronously), then persist. For a // rejoined task the in-flight placeholder hides the moment the tracker // emits 'completed'; landing the reply before the await closes the visual @@ -1457,6 +1509,7 @@ export function useAgentChatController({ if (chatId) { try { await chatStore.appendMessages(agentName, chatId, [message]) + if (!mayAppend()) return bumpActivity(agentName, chatId, new Date(message.timestamp).toISOString()) } catch { // persistence is best-effort; the in-memory view already updated @@ -1466,12 +1519,13 @@ export function useAgentChatController({ // onTrackerTerminal can't escape before its final tracker.ack(key) — a // throw here would otherwise leak the tracked (agent, chat) entry. try { + if (!mayAppend()) return pushAssistantReplyNotification(agentName, message, chatId) } catch { // notification delivery is best-effort } }, - [bumpActivity, chatStore, pushAssistantReplyNotification] + [bumpActivity, chatStore, pushAssistantReplyNotification, isHostAccessRevoked, isChatDeleted] ) // ─── reconcileChat branch callbacks (§4.3) ─── @@ -1508,6 +1562,8 @@ export function useAgentChatController({ const v = activeChatVisibilityRef.current return v.selectedAgent === agentRef && v.activeChatId === chatId } + const mayHydrate = () => + stillRelevant() && !isHostAccessRevoked(agentRef) && !isChatDeleted(agentRef, chatId) const cached = (await chatStore .loadMessages(agentRef, chatId, LOCAL_MESSAGE_PAGE_SIZE) .catch(error => { @@ -1531,7 +1587,7 @@ export function useAgentChatController({ cached: localMessages, stale: true, }) - if (!stillRelevant()) return staleResult() + if (!mayHydrate()) return staleResult() if (!isActive() || tracker.get(chatKey)) { return { rendered: localMessages, replaced: false, cached: localMessages, stale: false } } @@ -1553,7 +1609,7 @@ export function useAgentChatController({ hydrated, taskIdHint ? await loadTaskResultResponseFileAttachments(agentRef, taskIdHint) : [] ) - if (!stillRelevant()) return staleResult() + if (!mayHydrate()) return staleResult() if (!isActive() || tracker.get(chatKey)) { return { rendered: localMessages, replaced: false, cached: localMessages, stale: false } } @@ -1575,7 +1631,7 @@ export function useAgentChatController({ : hydratedWithAttachments const hasOlderAfterMerge = hasOlderFromServer || nextServerBackfillBeforeTurn(rendered) !== undefined - if (!stillRelevant()) return staleResult(rendered) + if (!mayHydrate()) return staleResult(rendered) if (latestPageFallback) { setHasOlderMessages(hasOlderAfterMerge) } else { @@ -1584,11 +1640,11 @@ export function useAgentChatController({ if (sameMessageSequence(rendered, localMessages)) { return { rendered: localMessages, replaced: false, cached: localMessages, stale: false } } - if (!stillRelevant()) return staleResult(rendered) + if (!mayHydrate()) return staleResult(rendered) chatMessagesRef.current = rendered setChatMessages(rendered) const meta = await chatStore.createChat(agentRef, chatId) - if (!stillRelevant()) return staleResult(rendered) + if (!mayHydrate()) return staleResult(rendered) if (resp.activeTaskId) { await chatStore.replaceMessages(agentRef, chatId, rendered, { activeTaskIds: [resp.activeTaskId], @@ -1596,7 +1652,7 @@ export function useAgentChatController({ } else { await chatStore.replaceMessages(agentRef, chatId, rendered) } - if (!stillRelevant()) return staleResult(rendered) + if (!mayHydrate()) return staleResult(rendered) if (!isActive()) { return { rendered, replaced: true, cached: localMessages, stale: false } } @@ -1624,6 +1680,8 @@ export function useAgentChatController({ }, [ tracker, + isHostAccessRevoked, + isChatDeleted, chatStore.loadMessages, chatStore.createChat, chatStore.replaceMessages, @@ -1735,7 +1793,10 @@ export function useAgentChatController({ if (cached.some(m => m.role === 'assistant' && m.task_id === taskIdHint)) return 'noop' const taskResult = await window.clerum.rpc .getTaskResult(agentRef, taskIdHint, [agentRef]) - .catch(() => null) + .catch(error => { + if (isAuthorizationError(error)) throw error + return null + }) if (!taskResult) return 'fell_through_to_resend' // Post-await teardown guard (parity with attachLiveTask): a `reset()` during // the getTaskResult await must not let the durable materialization persist a @@ -1786,29 +1847,70 @@ export function useAgentChatController({ [fsm, tracker, hydrateActiveChatFromServer, appendAssistantMessage] ) - const reconcileEvictChat = useCallback( - async chatKey => { - const { agentRef, chatId } = parseTaskKey(chatKey) - // The local cache referenced a chat the server 404s. Post-spec this - // shouldn't happen (every chat originates from a POST the server records) → - // treat as a stale-cache bug: log + evict locally (parity with the old - // switchToChat 404 branch, so its characterization pin keeps warning). - console.warn('[reconcileChat] chat unknown to server, evicting local cache', { - agentRef, - chatId, + const revokeHostAccess = useCallback( + (agentRef: string) => { + for (const chatKey of Object.keys(fsm.getSnapshot())) { + if (parseTaskKey(chatKey).agentRef !== agentRef) continue + tracker.release(chatKey as TaskKey) + reconcileChatRef.current?.supersede(chatKey) + fsm.dispatch(chatKey, { type: 'RESET' }) + } + const stopActivity = activityUnsubByAgentRef.current[agentRef] + if (stopActivity) { + delete activityUnsubByAgentRef.current[agentRef] + void stopActivity().catch(() => undefined) + } + delete activityInFlightByAgentRef.current[agentRef] + delete activityTaskToMessageByAgentRef.current[agentRef] + setActivityByAgentMessage(previous => { + const { [agentRef]: _removed, ...remaining } = previous + return remaining }) - tracker.release(chatKey as TaskKey) - await chatStore.deleteChat(agentRef, chatId).catch(() => undefined) - chatStore.clearCachedRemoteData() - removeFromList(chatId) + setProgressByAgentMessage(previous => { + const { [agentRef]: _removed, ...remaining } = previous + return remaining + }) + hideAgent(agentRef) const view = activeChatVisibilityRef.current - if (view.selectedAgent === agentRef && view.activeChatId === chatId) { - activeChatVisibilityRef.current = { ...view, activeChatId: null } + if (agentSendSetupOwnerRef.current?.agentRef === agentRef) { + agentSendSetupOwnerRef.current = null + agentSendInFlightRef.current = false + setAgentSending(false) + } + if (view.selectedAgent === agentRef) { + chatStore.clearCachedRemoteData() + clearList() + resetComposerAttachments() + activeChatVisibilityRef.current = { ...view, selectedAgent: null, activeChatId: null } setActiveChatId(null) setChatMessages([]) + setChatMessagesLoading(false) + setHasOlderMessages(false) + cancelOlderMessagesLoad() } + onHostAccessRevoked(agentRef) }, - [tracker, chatStore.deleteChat, chatStore.clearCachedRemoteData, removeFromList] + [ + tracker, + fsm, + hideAgent, + chatStore.clearCachedRemoteData, + clearList, + resetComposerAttachments, + cancelOlderMessagesLoad, + onHostAccessRevoked, + ] + ) + useEffect(() => { + revokeHostAccessRef.current = revokeHostAccess + }, [revokeHostAccess]) + + const reconcileRevokeAccess = useCallback( + chatKey => { + const { agentRef } = parseTaskKey(chatKey) + revokeHostAccess(agentRef) + }, + [revokeHostAccess] ) useEffect(() => { @@ -1918,14 +2020,14 @@ export function useAgentChatController({ }, attachLiveTask: reconcileAttachLiveTask, settleIdle: reconcileSettleIdle, - evictChat: reconcileEvictChat, + revokeAccess: reconcileRevokeAccess, } }, [ chatStore.loadSessionMessages, chatStore.loadMessages, reconcileAttachLiveTask, reconcileSettleIdle, - reconcileEvictChat, + reconcileRevokeAccess, ]) // ─── Tracker callbacks + subscription (the post-D.3 fire & forget glue) ─── @@ -1933,6 +2035,13 @@ export function useAgentChatController({ const onTrackerTerminal = useCallback( async (key: TaskKey, state: TaskState) => { const { agentRef, chatId } = parseTaskKey(key) + const terminalScope = sendScopeGeneration.current + const terminalScopeIdentity = currentAuthScopeRef.current + const terminalStillAuthorized = () => + terminalScope === sendScopeGeneration.current && + terminalScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(agentRef) && + !isChatDeleted(agentRef, chatId) // `task_duration_seconds` telemetry is now emitted by the coordinator // (`fireTerminal`, §4.8) — the lifecycle owner — so it is not duplicated here. const result = state.terminalResult @@ -1977,6 +2086,7 @@ export function useAgentChatController({ // Telemetry for this recovery is emitted once by the reconcile gate (§4.8, // `deps.telemetry`) with the real outcome, so no manual log here. const applyFallback = () => { + if (!terminalStillAuthorized()) return dropActivity() liveDepsRef.current.pushToast(`Message to ${agentRef} failed.`, 'error') updateMessageProgress(agentRef, state.userMessageId, () => ({ @@ -2020,9 +2130,28 @@ export function useAgentChatController({ // Mirrors taskTracker's interpretation of `getTaskResult` (source:'failed' // vs a durable reply) so the rendered bubble matches the direct path. const recoverFromDurableTaskResult = async (): Promise => { - const taskResult = await window.clerum.rpc - .getTaskResult(agentRef, state.taskId, [agentRef]) - .catch(() => null) + const mayRecover = () => + terminalScope === sendScopeGeneration.current && + terminalScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(agentRef) && + !isChatDeleted(agentRef, chatId) + let taskResult: Awaited> + try { + taskResult = await window.clerum.rpc.getTaskResult(agentRef, state.taskId, [agentRef]) + } catch (error) { + if (isAuthorizationError(error)) { + if ( + terminalScope === sendScopeGeneration.current && + terminalScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(agentRef) + ) { + revokeHostAccessRef.current!(agentRef) + } + return true + } + return false + } + if (!mayRecover()) return true if (!taskResult) return false // The awaits in this branch (incl. `getTaskResult` above) open a window @@ -2058,6 +2187,7 @@ export function useAgentChatController({ ...(errorCode ? { errorCode } : {}), ...(errorProvider ? { errorProvider } : {}), }) + if (!mayRecover()) return true updateMessageProgress(agentRef, state.userMessageId, () => ({ taskId: state.taskId, status: 'error', @@ -2096,6 +2226,7 @@ export function useAgentChatController({ task_id: state.taskId, ...(attachments.length ? { attachments } : {}), }) + if (!mayRecover()) return true updateMessageProgress(agentRef, state.userMessageId, () => ({ taskId: state.taskId, status: 'completed', @@ -2154,6 +2285,7 @@ export function useAgentChatController({ taskIdHint: state.taskId, isRelevant: isActive, }) + if (!terminalStillAuthorized()) return switch (outcome) { case 'reconcile_rejoined': case 'rejoin_capped_offline': @@ -2207,8 +2339,8 @@ export function useAgentChatController({ dropActivity() setIdle() break - case '404': - // The reconcile evicted the chat + reset the FSM already. + case 'revoked': + // The authorization rejection hid the protected chat + reset the FSM. dropActivity() break case 'offline': @@ -2254,6 +2386,7 @@ export function useAgentChatController({ ...(toolSteps ? { toolSteps } : {}), }) } + if (!terminalStillAuthorized()) return liveDepsRef.current.pushToast(`Message sent to ${agentRef}.`, 'success') } else if (result?.kind === 'error') { // Terminal failure: the payload stays retained for explicit recovery. @@ -2275,6 +2408,7 @@ export function useAgentChatController({ errorCode: result.code, errorProvider: result.provider, }) + if (!terminalStillAuthorized()) return liveDepsRef.current.pushToast(`Message to ${agentRef} failed: ${result.message}`, 'error') } else if (result.source === 'result_fetch') { await appendAssistantMessage(agentRef, chatId, { @@ -2285,10 +2419,12 @@ export function useAgentChatController({ task_id: state.taskId, isError: true, }) + if (!terminalStillAuthorized()) return liveDepsRef.current.pushToast(`Failed to retrieve result for ${agentRef}.`, 'error') } // `source: 'stream'` is intercepted + reconciled at the top of this handler. } + if (!terminalStillAuthorized()) return // The Resend payload is dropped by the coordinator on `release` below (B15). // Reflect the terminal state into progress/activity directly. The live // subscription also does this, but a task can finish before its chat's @@ -2354,6 +2490,8 @@ export function useAgentChatController({ updateMessageProgress, updateMessageActivity, reconcileChat, + isHostAccessRevoked, + isChatDeleted, ] ) @@ -2495,6 +2633,7 @@ export function useAgentChatController({ preserveComposer = false ) => { const sendScope = sendScopeGeneration.current + const sendScopeIdentity = currentAuthScopeRef.current const originalDraftChat = activeChatVisibilityRef.current.activeChatId const originalDraftRevision = getComposerDraftRevision(originalDraftChat) const originalAttachmentRevision = composerAttachmentRevisionRef.current @@ -2503,6 +2642,7 @@ export function useAgentChatController({ const effectiveReferences = [...references] if ( !selectedAgent || + isHostAccessRevoked(selectedAgent) || (!trimmedContent && !effectiveAttachments.length && !effectiveReferences.length) ) return @@ -2511,10 +2651,19 @@ export function useAgentChatController({ // Read the chat id from the ref (updated synchronously by auto-create / // switchToChat) rather than state, which may not have committed yet. const currentChatId = activeChatVisibilityRef.current.activeChatId + if (currentChatId && isChatDeleted(selectedAgent, currentChatId)) return if (currentChatId && tracker.get(makeTaskKey(selectedAgent, currentChatId))) return if (agentSendInFlightRef.current) return - agentSendInFlightRef.current = true const sendAgent = selectedAgent + const sendSetupOwner = { id: Symbol('agent-send-setup'), agentRef: sendAgent } + agentSendSetupOwnerRef.current = sendSetupOwner + agentSendInFlightRef.current = true + const releaseSendSetup = () => { + if (agentSendSetupOwnerRef.current !== sendSetupOwner) return + agentSendSetupOwnerRef.current = null + agentSendInFlightRef.current = false + setAgentSending(false) + } // Issue #654 — send-time image guard. Runs BEFORE the chat is created (a // blocked visual send must not leave a stray empty chat) and long before // the optimistic message/attachment clear. `readHostModelSelection` is the @@ -2534,7 +2683,7 @@ export function useAgentChatController({ 'Image attachments are not available for the selected model yet.' setAgentError(blocker) pushToast(blocker, 'error') - agentSendInFlightRef.current = false + releaseSendSetup() return } // Capture the model the guard just validated; the request below must @@ -2542,7 +2691,12 @@ export function useAgentChatController({ visualModelForSend = selection.intentModel ?? selection.effectiveModel ?? undefined visualModelRevisionForSend = selection.confirmedRevision ?? undefined } - let sendChatId = activeChatId + let sendChatId = currentChatId ?? activeChatId + const sendStillAuthorized = () => + sendScope === sendScopeGeneration.current && + sendScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(sendAgent) && + (!sendChatId || !isChatDeleted(sendAgent, sendChatId)) // Captured for the retention snapshot: the model this attempt actually // asked for (issue #654), readable from the catch below. let requestModelForRetention: string | undefined @@ -2555,11 +2709,14 @@ export function useAgentChatController({ try { const chatId = crypto.randomUUID() const meta = await chatStore.createChat(sendAgent, chatId) - if (sendScope !== sendScopeGeneration.current) return + sendChatId = chatId + if (!sendStillAuthorized()) { + releaseSendSetup() + return + } chatStore.clearCachedRemoteData() autoCreatedMeta = meta appendNewEntry(sendAgent, meta) - sendChatId = chatId // R2 new-chat composer: a per-session model picked BEFORE this chat // existed was held in the agent-keyed pre-chat slot (no chatId to key a // pending entry, no session to POST to). Now that the chatId exists, @@ -2579,13 +2736,18 @@ export function useAgentChatController({ setActiveChatId(chatId) setChatMessages([]) await chatStore.setLastActive(sendAgent, chatId) - if (sendScope !== sendScopeGeneration.current) return + if (!sendStillAuthorized()) { + releaseSendSetup() + return + } } catch (error) { - if (sendScope !== sendScopeGeneration.current) return + if (!sendStillAuthorized()) { + releaseSendSetup() + return + } const message = error instanceof Error ? error.message : String(error) pushToast(`Could not create a chat session: ${message}`, 'error') - agentSendInFlightRef.current = false - setAgentSending(false) + releaseSendSetup() return } } @@ -2700,7 +2862,7 @@ export function useAgentChatController({ // R2 "Option A": a per-session model chosen while the host was suspended // couldn't be persisted server-side, so it was held as pending. Piggyback // it here — this send wakes the host and applies the model to this task. - if (sendScope !== sendScopeGeneration.current) return + if (!sendStillAuthorized()) return const pendingModel = pendingModelForSend const requestModel = requestModelForRetention const request = { @@ -2729,7 +2891,7 @@ export function useAgentChatController({ // A successful send creates or updates the durable server session. Do not // let the short-lived sidebar cache hide that new catalog state. chatStore.clearCachedRemoteData() - if (sendScope !== sendScopeGeneration.current) return + if (!sendStillAuthorized()) return const responseRecord = response as Record const ackOk = !responseRecord.error && responseRecord.success !== false // #654 H1 — the send IS the write. Its ack carries the revision that @@ -2898,7 +3060,31 @@ export function useAgentChatController({ fsm.dispatch(taskKey, { type: 'TASK_CREATED', taskId }) } } catch (error) { - if (sendScope !== sendScopeGeneration.current) return + if ( + isAuthorizationError(error) && + sendScope === sendScopeGeneration.current && + sendScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(sendAgent) + ) { + if (isConfirmedHostAccessRevoked(error) || !isHttp403(error)) { + revokeHostAccess(sendAgent) + return + } + try { + await chatStore.listSessions(sendAgent, { agent: sendAgent, limit: 1 }, { force: true }) + } catch (readError) { + if ( + isAuthorizationError(readError) && + sendScope === sendScopeGeneration.current && + sendScopeIdentity === currentAuthScopeRef.current && + !isHostAccessRevoked(sendAgent) + ) { + revokeHostAccess(sendAgent) + return + } + } + } + if (!sendStillAuthorized()) return const message = error instanceof Error ? error.message : String(error) const normalized = message.toLowerCase() const isRequestEntityTooLarge = @@ -2969,14 +3155,14 @@ export function useAgentChatController({ } finally { // Fire & forget: release the synchronous setup guard immediately. The // task (if any) keeps running in the tracker. - if (sendScope === sendScopeGeneration.current) { - agentSendInFlightRef.current = false - setAgentSending(false) - } + releaseSendSetup() } }, [ selectedAgent, + isHostAccessRevoked, + isChatDeleted, + revokeHostAccess, activeChatId, composerImageAttachments, composerReferenceAttachments, @@ -3178,6 +3364,7 @@ export function useAgentChatController({ isRemote?: boolean } = {} ) => { + if (isHostAccessRevoked(agentName) || (chatId && isChatDeleted(agentName, chatId))) return if (options.selectLatest) { writePendingSelection(agentName, { mode: 'latest', chatId: null }) return @@ -3209,7 +3396,14 @@ export function useAgentChatController({ upsertProvisionalEntry(chatId, options.title, options.isRemote === true) } }, - [cancelOlderMessagesLoad, currentTeamId, writePendingSelection, upsertProvisionalEntry] + [ + cancelOlderMessagesLoad, + currentTeamId, + writePendingSelection, + upsertProvisionalEntry, + isHostAccessRevoked, + isChatDeleted, + ] ) const clearActiveChat = useCallback(() => { diff --git a/desktop-app/ui/src/hooks/domain/useChatListController.ts b/desktop-app/ui/src/hooks/domain/useChatListController.ts index 2620728fec..41f0bd2d51 100644 --- a/desktop-app/ui/src/hooks/domain/useChatListController.ts +++ b/desktop-app/ui/src/hooks/domain/useChatListController.ts @@ -3,6 +3,7 @@ import { makeTaskKey } from '@contexts/AgentTaskTrackerContext' import { agentChatPlaceholder, remotePlaceholder } from '@lib/chatTitle' import { type PendingRename, resolveSessionTitle } from '@lib/resolveSessionTitle' import type { ChatIndex, ChatMetadata, SessionsListResult } from '../../../../src/types' +import { isAuthorizationError, isConfirmedHostAccessRevoked } from '../../lib/format' import { scheduleAfterFirstPaint } from '../scheduleAfterFirstPaint' import type { useChatStore } from '../useChatStore' import { type SessionFsmEvent, type SessionFsmStore, seedSessionSnapshots } from './sessionFsm' @@ -100,6 +101,8 @@ interface UseChatListControllerParams { chatStore: ReturnType fsm: SessionFsmStore host: MutableRefObject + isHostAccessRevoked: (agentRef: string) => boolean + onHostAccessRevoked: (agentRef: string) => void } function sortableTimestamp(value: string): number { @@ -162,6 +165,8 @@ export function useChatListController({ chatStore, fsm, host, + isHostAccessRevoked, + onHostAccessRevoked, }: UseChatListControllerParams) { // Per-agent list for the SELECTED agent (the sidebar's chat list). const [chatList, setChatList] = useState([]) @@ -178,7 +183,9 @@ export function useChatListController({ const suppressAutoSelectionSequenceRef = useRef(0) const chatListNextCursorByAgentRef = useRef>({}) const chatListLoadingMoreByAgentRef = useRef>(new Set()) + const deletedChatIdsByAgentRef = useRef(new Map>()) const requestGenerationRef = useRef(0) + const authorityScopeGenerationRef = useRef(0) // Spec 15 §2.5 (B21) — pending-rename queue. A user rename is optimistic-local // first, then synced by RPC; while unconfirmed the local title wins over the @@ -229,6 +236,8 @@ export function useChatListController({ useEffect(() => { requestGenerationRef.current += 1 + authorityScopeGenerationRef.current += 1 + deletedChatIdsByAgentRef.current.clear() }, [isAuthenticated, scopeKey]) // R1-H1 — the pending-rename queue is per-USER identity. mcp-host keys a chat @@ -269,14 +278,27 @@ export function useChatListController({ // ─── Cross-agent latest-sessions mutators ─── - const upsertLatestChatSession = useCallback((agentRef: string, chat: SidebarChatEntry) => { - setLatestChatSessions(previous => { - const next = [ - { ...chat, agentRef }, - ...previous.filter(item => item.agentRef !== agentRef || item.id !== chat.id), - ] - return next.sort(byUpdatedDesc) - }) + const upsertLatestChatSession = useCallback( + (agentRef: string, chat: SidebarChatEntry) => { + if ( + isHostAccessRevoked(agentRef) || + deletedChatIdsByAgentRef.current.get(agentRef)?.has(chat.id) + ) + return + setLatestChatSessions(previous => { + if (deletedChatIdsByAgentRef.current.get(agentRef)?.has(chat.id)) return previous + const next = [ + { ...chat, agentRef }, + ...previous.filter(item => item.agentRef !== agentRef || item.id !== chat.id), + ] + return next.sort(byUpdatedDesc) + }) + }, + [isHostAccessRevoked] + ) + + const hideAgent = useCallback((agentRef: string) => { + setLatestChatSessions(previous => previous.filter(chat => chat.agentRef !== agentRef)) }, []) const removeLatestChatSession = useCallback((agentRef: string, chatId: string) => { @@ -292,29 +314,48 @@ export function useChatListController({ agentRef: string, requestGeneration: number ): Promise<{ index: ChatIndex; merged: SidebarChatEntry[] }> => { + const authorityScopeGeneration = authorityScopeGenerationRef.current chatListNextCursorByAgentRef.current[agentRef] = null if (selectedAgentRef.current === agentRef) { setChatListHasMoreRemoteSessions(false) setChatListMoreLoading(chatListLoadingMoreByAgentRef.current.has(agentRef)) } const index = await chatStore.getIndex(agentRef) - const merged = [...index.chats].sort(byUpdatedDesc) if ( selectedAgentRef.current !== agentRef || - requestGenerationRef.current !== requestGeneration + requestGenerationRef.current !== requestGeneration || + isHostAccessRevoked(agentRef) ) { - return { index, merged } + return { index, merged: [...index.chats].sort(byUpdatedDesc) } } + const deleted = deletedChatIdsByAgentRef.current.get(agentRef) ?? new Set() + for (const chatId of index.deletedChatIds ?? []) deleted.add(chatId) + deletedChatIdsByAgentRef.current.set(agentRef, deleted) + const merged = index.chats.filter(chat => !deleted.has(chat.id)).sort(byUpdatedDesc) setChatList(merged) const suppressionMarkerAtRequest = suppressAutoSelectionByAgentRef.current.get(agentRef) scheduleAfterFirstPaint(async () => { - const serverResult = await chatStore - .listSessions(agentRef, { agent: agentRef, limit: SESSION_CATALOG_PAGE_LIMIT }) - .catch((): SessionsListResult => ({ items: [] })) + let serverResult: SessionsListResult + try { + serverResult = await chatStore.listSessions(agentRef, { + agent: agentRef, + limit: SESSION_CATALOG_PAGE_LIMIT, + }) + } catch (error) { + if ( + isAuthorizationError(error) && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(agentRef) + ) { + onHostAccessRevoked(agentRef) + } + return + } if ( selectedAgentRef.current !== agentRef || - requestGenerationRef.current !== requestGeneration + requestGenerationRef.current !== requestGeneration || + isHostAccessRevoked(agentRef) ) { if ( suppressionMarkerAtRequest !== undefined && @@ -329,7 +370,10 @@ export function useChatListController({ // older hosts and malformed proxy responses so another agent's catalog // entry can never leak into the selected agent's sidebar. const serverSessions = serverResult.items - .filter(s => s.agent === agentRef) + .filter( + s => + s.agent === agentRef && !deletedChatIdsByAgentRef.current.get(agentRef)?.has(s.chatId) + ) .sort(byLastActivityDesc) chatListNextCursorByAgentRef.current[agentRef] = serverResult.nextCursor ?? null setChatListHasMoreRemoteSessions(Boolean(serverResult.nextCursor)) @@ -344,8 +388,14 @@ export function useChatListController({ // "Remote ·" label, no isRemote branch; switchToChat's unified path // hydrates them. setChatList(previous => { - const dedupedPrevious = dedupeSidebarChats(previous) - const serverById = new Map(serverSessions.map(s => [s.chatId, s])) + if (isHostAccessRevoked(agentRef)) return [] + const visibleSessions = serverSessions.filter( + session => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) + ) + const dedupedPrevious = dedupeSidebarChats(previous).filter( + chat => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(chat.id) + ) + const serverById = new Map(visibleSessions.map(s => [s.chatId, s])) const knownIds = new Set(dedupedPrevious.map(c => c.id)) // Cases C/D/E/F (§2.2): the server is authoritative for a cached chat's // title when it reports one AND no local rename is pending; a pending @@ -364,7 +414,7 @@ export function useChatListController({ }) // Cases A/B (§2.2): a server-only chat shows the server title when the // host reports one, else the "Chat " placeholder. - const fromServerOnly: SidebarChatEntry[] = serverSessions + const fromServerOnly: SidebarChatEntry[] = visibleSessions .filter(s => !knownIds.has(s.chatId)) .map(s => ({ id: s.chatId, @@ -434,7 +484,14 @@ export function useChatListController({ return { index, merged } }, - [chatStore.getIndex, chatStore.listSessions, chatStore.reconcileServerSessions, fsm] + [ + chatStore.getIndex, + chatStore.listSessions, + chatStore.reconcileServerSessions, + fsm, + isHostAccessRevoked, + onHostAccessRevoked, + ] ) const loadMoreChatSessions = useCallback(async () => { @@ -450,6 +507,7 @@ export function useChatListController({ chatListLoadingMoreByAgentRef.current.add(agentRef) const requestGeneration = requestGenerationRef.current + const authorityScopeGeneration = authorityScopeGenerationRef.current setChatListMoreLoading(true) try { let serverResult: SessionsListResult @@ -460,6 +518,14 @@ export function useChatListController({ { force: true } ) } catch (error) { + if ( + isAuthorizationError(error) && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(agentRef) + ) { + onHostAccessRevoked(agentRef) + return + } if ( requestGenerationRef.current === requestGeneration && selectedAgentRef.current === agentRef @@ -475,15 +541,23 @@ export function useChatListController({ } if (requestGenerationRef.current !== requestGeneration) return - const serverSessions = serverResult.items.filter(s => s.agent === agentRef) - if (selectedAgentRef.current !== agentRef) return + const serverSessions = serverResult.items.filter( + s => s.agent === agentRef && !deletedChatIdsByAgentRef.current.get(agentRef)?.has(s.chatId) + ) + if (selectedAgentRef.current !== agentRef || isHostAccessRevoked(agentRef)) return chatListNextCursorByAgentRef.current[agentRef] = serverResult.nextCursor ?? null setChatListHasMoreRemoteSessions(Boolean(serverResult.nextCursor)) seedSessionSnapshots(fsm, agentRef, serverSessions) setChatList(previous => { - const dedupedPrevious = dedupeSidebarChats(previous) - const serverById = new Map(serverSessions.map(s => [s.chatId, s])) + if (isHostAccessRevoked(agentRef)) return [] + const visibleSessions = serverSessions.filter( + session => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) + ) + const dedupedPrevious = dedupeSidebarChats(previous).filter( + chat => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(chat.id) + ) + const serverById = new Map(visibleSessions.map(s => [s.chatId, s])) const knownIds = new Set(dedupedPrevious.map(c => c.id)) // Cases C/D/E/F (§2.2): reconcile the title of any cached chat this page // also reports; server wins when it has a title and no rename is pending. @@ -500,7 +574,7 @@ export function useChatListController({ return title === chat.title ? chat : { ...chat, title } }) // Cases A/B (§2.2): server-only page entries. - const fromServerOnly: SidebarChatEntry[] = serverSessions + const fromServerOnly: SidebarChatEntry[] = visibleSessions .filter(s => !knownIds.has(s.chatId)) .map(s => ({ id: s.chatId, @@ -534,7 +608,13 @@ export function useChatListController({ setChatListMoreLoading(false) } } - }, [chatStore.listSessions, chatStore.reconcileServerSessions, fsm]) + }, [ + chatStore.listSessions, + chatStore.reconcileServerSessions, + fsm, + isHostAccessRevoked, + onHostAccessRevoked, + ]) const loadChatList = useCallback( async (agentRef: string): Promise<{ index: ChatIndex; merged: SidebarChatEntry[] } | null> => { @@ -642,10 +722,14 @@ export function useChatListController({ * just refresh the title on the existing entry. */ const upsertHydratedEntry = useCallback((meta: ChatMetadata, title: string) => { + const agentRef = selectedAgentRef.current + if (agentRef && deletedChatIdsByAgentRef.current.get(agentRef)?.has(meta.id)) return setChatList(prev => - prev.some(c => c.id === meta.id) - ? prev.map(c => (c.id === meta.id ? { ...c, ...meta, title, remote: false } : c)) - : [...prev, { ...meta, title }] + agentRef && deletedChatIdsByAgentRef.current.get(agentRef)?.has(meta.id) + ? prev + : prev.some(c => c.id === meta.id) + ? prev.map(c => (c.id === meta.id ? { ...c, ...meta, title, remote: false } : c)) + : [...prev, { ...meta, title }] ) }, []) @@ -679,7 +763,9 @@ export function useChatListController({ /** Append a freshly-created chat to both lists (create / send auto-create). */ const appendNewEntry = useCallback( (agentRef: string, meta: ChatMetadata) => { + if (deletedChatIdsByAgentRef.current.get(agentRef)?.has(meta.id)) return setChatList(prev => { + if (deletedChatIdsByAgentRef.current.get(agentRef)?.has(meta.id)) return prev const next = prev.some(chat => chat.id === meta.id) ? prev : [...prev, meta] return dedupeSidebarChats(next) }) @@ -713,6 +799,11 @@ export function useChatListController({ const clearPendingSelection = useCallback((agentName: string) => { delete pendingChatSelectionByAgentRef.current[agentName] }, []) + const isChatDeleted = useCallback( + (agentRef: string, chatId: string) => + deletedChatIdsByAgentRef.current.get(agentRef)?.has(chatId) ?? false, + [] + ) // ─── Chat CRUD ─── @@ -795,6 +886,7 @@ export function useChatListController({ if (entry.sending) return if (pendingRenamesRef.current.get(key) !== entry) return entry.sending = true + const authorityScopeGeneration = authorityScopeGenerationRef.current try { await chatStore.renameSession(entry.agentRef, entry.chatId, entry.title) // Only clear if this attempt still owns the key (a stale 200 must never @@ -803,6 +895,42 @@ export function useChatListController({ pendingRenamesRef.current.delete(key) } } catch (error) { + if ( + isConfirmedHostAccessRevoked(error) && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(entry.agentRef) + ) { + onHostAccessRevoked(entry.agentRef) + if (pendingRenamesRef.current.get(key) === entry) { + pendingRenamesRef.current.delete(key) + } + return + } + if ( + isAuthorizationError(error) && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(entry.agentRef) + ) { + try { + await chatStore.listSessions( + entry.agentRef, + { agent: entry.agentRef, limit: 1 }, + { force: true } + ) + } catch (readError) { + if ( + isAuthorizationError(readError) && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(entry.agentRef) + ) { + onHostAccessRevoked(entry.agentRef) + if (pendingRenamesRef.current.get(key) === entry) { + pendingRenamesRef.current.delete(key) + } + return + } + } + } // A newer rename superseded this one: leave the key entirely to it — no // rollback, no re-mark (FIX 2). if (pendingRenamesRef.current.get(key) !== entry) return @@ -844,7 +972,7 @@ export function useChatListController({ entry.sending = false } }, - [chatStore, applyLocalTitleOnly, host] + [chatStore, applyLocalTitleOnly, host, isHostAccessRevoked, onHostAccessRevoked] ) /** @@ -961,10 +1089,13 @@ export function useChatListController({ // FIRST (before the delete), preserving the ack-before-delete ordering. host.current?.dispatchSession(deletedKey, { type: 'CHAT_DELETED' }) await chatStore.deleteChat(agentRef, chatId) + const deleted = deletedChatIdsByAgentRef.current.get(agentRef) ?? new Set() + deleted.add(chatId) + deletedChatIdsByAgentRef.current.set(agentRef, deleted) chatStore.clearCachedRemoteData() - if (requestGenerationRef.current !== requestGeneration) return host.current?.clearComposerDraft(chatId) removeLatestChatSession(agentRef, chatId) + if (requestGenerationRef.current !== requestGeneration) return // Post-await guards read the LIVE committed values (selectedAgentRef / // getActiveChatId), intentionally — if the user switched agent during the // delete IPC we must not yank a reselection into the agent they just left. @@ -1008,6 +1139,7 @@ export function useChatListController({ } let cancelled = false + const authorityScopeGeneration = authorityScopeGenerationRef.current setLatestChatSessionsLoading(true) ;(async () => { try { @@ -1021,17 +1153,33 @@ export function useChatListController({ ...chat, agentRef, })), + deletedChatIds: index.deletedChatIds ?? [], } } catch { return { agentRef, entries: [] as LatestSidebarChatEntry[], + deletedChatIds: [] as string[], } } }) ) if (cancelled) return - setLatestChatSessions(localGroups.flatMap(group => group.entries).sort(byUpdatedDesc)) + for (const group of localGroups) { + const deleted = deletedChatIdsByAgentRef.current.get(group.agentRef) ?? new Set() + for (const chatId of group.deletedChatIds) deleted.add(chatId) + deletedChatIdsByAgentRef.current.set(group.agentRef, deleted) + } + setLatestChatSessions( + localGroups + .flatMap(group => group.entries) + .filter( + chat => + !isHostAccessRevoked(chat.agentRef) && + !deletedChatIdsByAgentRef.current.get(chat.agentRef)?.has(chat.id) + ) + .sort(byUpdatedDesc) + ) setLatestChatSessionsLoading(false) const sessionGroups = await Promise.all( @@ -1039,47 +1187,82 @@ export function useChatListController({ // This feeds only the cross-agent sidebar preview. Do not follow // cursors here; the selected-agent session list exposes explicit // on-demand pagination through `loadMoreChatSessions`. - const serverResult: SessionsListResult = await chatStore - .listSessions(agentRef, { + let serverResult: SessionsListResult + try { + serverResult = await chatStore.listSessions(agentRef, { agent: agentRef, limit: SESSION_CATALOG_PAGE_LIMIT, }) - .catch((): SessionsListResult => ({ items: [] })) + } catch (error) { + if ( + isAuthorizationError(error) && + !cancelled && + authorityScopeGenerationRef.current === authorityScopeGeneration && + !isHostAccessRevoked(agentRef) + ) { + onHostAccessRevoked(agentRef) + } + return { agentRef, sessions: [] as SessionsListResult['items'] } + } return { agentRef, - sessions: serverResult.items.filter(session => session.agent === agentRef), + sessions: serverResult.items.filter( + session => + session.agent === agentRef && + !isHostAccessRevoked(agentRef) && + !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) + ), } }) ) if (cancelled) return for (const group of sessionGroups) { - seedSessionSnapshots(fsm, group.agentRef, group.sessions) + if (isHostAccessRevoked(group.agentRef)) continue + seedSessionSnapshots( + fsm, + group.agentRef, + group.sessions.filter( + session => !deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId) + ) + ) } setLatestChatSessions(previous => { const serverByKey = new Map() for (const group of sessionGroups) { + if (isHostAccessRevoked(group.agentRef)) continue for (const session of group.sessions) { + if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) + continue serverByKey.set(`${group.agentRef}:${session.chatId}`, session) } } // Cases C/D/E/F (§2.2): server-authoritative title for cached entries, // unless a local rename is pending for that (agentRef, chatId). - const reconciled = previous.map(item => { - const server = serverByKey.get(`${item.agentRef}:${item.id}`) - if (!server) return item - const { title } = resolveSessionTitle({ - inCache: true, - localTitle: item.title, - serverTitle: server.title, - pendingRename: pendingRenameStateFor(item.agentRef, item.id), - placeholder: remotePlaceholder(item.id), + const reconciled = previous + .filter( + item => + !isHostAccessRevoked(item.agentRef) && + !deletedChatIdsByAgentRef.current.get(item.agentRef)?.has(item.id) + ) + .map(item => { + const server = serverByKey.get(`${item.agentRef}:${item.id}`) + if (!server) return item + const { title } = resolveSessionTitle({ + inCache: true, + localTitle: item.title, + serverTitle: server.title, + pendingRename: pendingRenameStateFor(item.agentRef, item.id), + placeholder: remotePlaceholder(item.id), + }) + return title === item.title ? item : { ...item, title } }) - return title === item.title ? item : { ...item, title } - }) const knownKeys = new Set(previous.map(item => `${item.agentRef}:${item.id}`)) const remoteOnly: LatestSidebarChatEntry[] = [] for (const group of sessionGroups) { + if (isHostAccessRevoked(group.agentRef)) continue for (const session of group.sessions) { + if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) + continue const key = `${group.agentRef}:${session.chatId}` if (knownKeys.has(key)) continue knownKeys.add(key) @@ -1108,7 +1291,14 @@ export function useChatListController({ flushPendingRenamesRef.current( new Set( sessionGroups.flatMap(group => - group.sessions.map(session => `${group.agentRef}:${session.chatId}`) + isHostAccessRevoked(group.agentRef) + ? [] + : group.sessions + .filter( + session => + !deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId) + ) + .map(session => `${group.agentRef}:${session.chatId}`) ) ) ) @@ -1130,6 +1320,8 @@ export function useChatListController({ loadMenuData, scopeKey, fsm, + isHostAccessRevoked, + onHostAccessRevoked, ]) return { @@ -1140,6 +1332,7 @@ export function useChatListController({ chatListHasMoreRemoteSessions, latestChatSessions, latestChatSessionsLoading, + hideAgent, // CRUD (public contract). handleCreateChat, handleRenameChat, @@ -1166,5 +1359,6 @@ export function useChatListController({ readPendingSelection, writePendingSelection, clearPendingSelection, + isChatDeleted, } } diff --git a/desktop-app/ui/src/hooks/useAppController.ts b/desktop-app/ui/src/hooks/useAppController.ts index 99965d61fc..14a0e3bc5d 100644 --- a/desktop-app/ui/src/hooks/useAppController.ts +++ b/desktop-app/ui/src/hooks/useAppController.ts @@ -306,6 +306,22 @@ export function useAppController() { (agentName: string) => agentsData.agentDisplayByName[agentName] ?? agentName, [agentsData.agentDisplayByName] ) + const revokedAgentRefsRef = useRef(new Set()) + const isHostAccessRevoked = useCallback( + (agentRef: string) => revokedAgentRefsRef.current.has(agentRef), + [] + ) + useEffect(() => { + revokedAgentRefsRef.current.clear() + }, [authenticatedPrincipalIdentity, currentTeamId]) + const onHostAccessRevoked = useCallback( + (agentRef: string) => { + revokedAgentRefsRef.current.add(agentRef) + if (nav.selectedAgent === agentRef) nav.setSelectedAgent(null) + void agentsData.refresh() + }, + [agentsData.refresh, nav.selectedAgent, nav.setSelectedAgent] + ) const chat = useAgentChatController({ selectedAgent: nav.selectedAgent, agentNames: agentsData.agentNames, @@ -322,6 +338,8 @@ export function useAppController() { showDesktopNotification: notificationSettings.showDesktopNotification, openAgentConversationFromNotification, decideApprovalFromNotification, + onHostAccessRevoked, + isHostAccessRevoked, }) // §4.7.4: the ONE central approval-decision function, bound to the chat @@ -597,7 +615,10 @@ export function useAppController() { useEffect(() => { if (!auth.isAuthenticated) return if (agentsData.accessCatalog && nav.selectedAgent) { - if (!agentsData.agentNames.includes(nav.selectedAgent)) { + if ( + !agentsData.agentNames.includes(nav.selectedAgent) || + isHostAccessRevoked(nav.selectedAgent) + ) { nav.setSelectedAgent(null) } } @@ -607,6 +628,7 @@ export function useAppController() { auth.isAuthenticated, nav.selectedAgent, nav.setSelectedAgent, + isHostAccessRevoked, ]) const switchTeamForWorkspace = useCallback( diff --git a/desktop-app/ui/src/lib/format.ts b/desktop-app/ui/src/lib/format.ts index 37942b9ae0..693352c5ac 100644 --- a/desktop-app/ui/src/lib/format.ts +++ b/desktop-app/ui/src/lib/format.ts @@ -158,23 +158,48 @@ function errorText(err: unknown): string { return (err instanceof Error ? err.message : String(err)).toLowerCase() } -/** - * True when an error reflects an HTTP 404 (the server doesn't know a chat the - * local cache references). Used by the D.4 reconcile to evict a stale local - * chat — which is destructive, so we require the 404 status token specifically - * rather than a generic "not found" substring (a "host/agent not found" - * transport error must NOT evict the user's cache). - */ +/** IPC preserves error messages but not always custom status fields. */ +function httpErrorStatus(err: unknown): number | undefined { + if (err && typeof err === 'object') { + const status = (err as { status?: unknown; cause?: { status?: unknown } }).status + if (typeof status === 'number') return status + const causeStatus = (err as { cause?: { status?: unknown } }).cause?.status + if (typeof causeStatus === 'number') return causeStatus + } + // ApiError puts the response status before the body. Using the first token + // keeps a 503 body that mentions 403 from becoming a revocation signal. + const match = /\b([1-5]\d{2})\b/.exec(errorText(err)) + return match ? Number(match[1]) : undefined +} + +/** A transcript 404 is ambiguous: absence, authority and wake failure share it. */ export function isHttp404(err: unknown): boolean { - const value = errorText(err) + return httpErrorStatus(err) === 404 +} + +/** Only an explicit authorization status can revoke the visible chat scope. */ +export function isAuthorizationError(err: unknown): boolean { + const status = httpErrorStatus(err) + return status === 401 || status === 403 +} + +export function isHttp403(err: unknown): boolean { + return httpErrorStatus(err) === 403 +} + +/** Exact Host-wide denial projected by the trusted Desktop RPC client. */ +export function isConfirmedHostAccessRevoked(err: unknown): boolean { return ( - /\b404\b/.test(value) || value.includes('chat not found') || value.includes('session not found') + httpErrorStatus(err) === 403 && + /(?:^|error: )(?:403 forbidden: host_access_revoked|rename session failed \(403\): host_access_revoked)$/.test( + errorText(err) + ) ) } /** - * True when an error reflects a transport/connectivity failure rather than a - * server-side rejection. The D.4 reconcile stays in offline mode on these. + * True when a transcript read failed without an authority decision. The + * reconcile keeps the cached conversation while these errors recover. */ export function isNetworkError(err: unknown): boolean { const value = errorText(err) @@ -184,7 +209,8 @@ export function isNetworkError(err: unknown): boolean { value.includes('network') || value.includes('fetch failed') || value.includes('econnrefused') || - value.includes('failed to fetch') + value.includes('failed to fetch') || + [502, 503, 504].includes(httpErrorStatus(err) ?? 0) ) } diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index cf00311fa5..961897acbc 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.351", + "version": "0.1.352", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.351", + "version": "0.1.352", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index 73798aa7e2..e0a845ff60 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.351", + "version": "0.1.352", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/src/statelessLifecycle.types.ts b/host-context-controller/src/statelessLifecycle.types.ts index 70a3d8cff3..ffeeb016ac 100644 --- a/host-context-controller/src/statelessLifecycle.types.ts +++ b/host-context-controller/src/statelessLifecycle.types.ts @@ -8,13 +8,14 @@ import { HostCondition, HostLifecycleState, HostLifecycleStatus } from './types' /** * Effective lifecycle of a Host after the reconcile-time rejection checks. - * `stateless` is false when spec.lifecycle.stateless is off OR a rejection - * applies (legacy force-always-on CommunicationChannel policy, spec.desktop - * present, unsatisfiable SharedFileSystem co-location). + * `stateless` is false when spec.lifecycle.stateless is off OR a confirmed + * incompatibility applies. Cache uncertainty keeps the stateless runtime + * identity but blocks suspension until channel authority returns. */ export interface EffectiveHostLifecycle { stateless: boolean state: HostLifecycleState + suspensionBlocked?: boolean } /** Reconcile-time lifecycle assessment: effective mode + durable status + condition. */ diff --git a/host-context-controller/src/statelessLifecycleExecutor.ts b/host-context-controller/src/statelessLifecycleExecutor.ts index f731391ca1..1551fbc107 100644 --- a/host-context-controller/src/statelessLifecycleExecutor.ts +++ b/host-context-controller/src/statelessLifecycleExecutor.ts @@ -218,13 +218,12 @@ export class StatelessLifecycleExecutor { return { stateless: false, state: 'active' } } const hasCommunicationChannels = this.countCommunicationChannels(host.name) > 0 - if ( - !this.isCommunicationChannelCacheSynced() || - host.spec.desktop !== undefined || - hasCommunicationChannels - ) { + if (host.spec.desktop !== undefined || hasCommunicationChannels) { return { stateless: false, state: 'active' } } + if (!this.isCommunicationChannelCacheSynced()) { + return { stateless: true, state: 'active', suspensionBlocked: true } + } return { stateless: true, state: host.status?.lifecycle?.state ?? 'active' } } @@ -234,10 +233,6 @@ export class StatelessLifecycleExecutor { } { const reasons: string[] = [] const messages: string[] = [] - if (!this.isCommunicationChannelCacheSynced()) { - reasons.push(COMMUNICATION_CHANNEL_CACHE_UNSYNCED_REASON) - messages.push(COMMUNICATION_CHANNEL_CACHE_UNSYNCED_MESSAGE) - } const ccCount = this.countCommunicationChannels(hostName) if (ccCount > 0) { // Addendum 6 (operator visibility): the rejection message names both the @@ -321,6 +316,10 @@ export class StatelessLifecycleExecutor { } } + if (!this.isCommunicationChannelCacheSynced()) { + return this.holdActiveDuringChannelCacheRecovery(wakeHandledGeneration) + } + // Accepted: preserve the durable state from the CRD status — a suspended // Host must survive HCC restarts and periodic resyncs. const state = host.status?.lifecycle?.state ?? 'active' @@ -351,13 +350,33 @@ export class StatelessLifecycleExecutor { } } + /** Unknown channel inventory blocks suspension without changing the runtime template. */ + private holdActiveDuringChannelCacheRecovery( + wakeHandledGeneration: number + ): HostLifecycleAssessment { + return { + effective: { stateless: true, state: 'active', suspensionBlocked: true }, + lifecycle: { + state: 'active', + wakeHandledGeneration, + reason: COMMUNICATION_CHANNEL_CACHE_UNSYNCED_MESSAGE, + }, + condition: { + type: STATELESS_REJECTED_CONDITION_TYPE, + status: 'False', + reason: COMMUNICATION_CHANNEL_CACHE_UNSYNCED_REASON, + message: COMMUNICATION_CHANNEL_CACHE_UNSYNCED_MESSAGE, + }, + pullPolicyCondition: statelessPullPolicyCondition(config.hostImage), + } + } + /** * Close the interval between the initial lifecycle assessment and the * Deployment write. Reconciliation performs several Kubernetes operations - * in between; if the CommunicationChannel watch ends or a channel starts - * referencing this Host during that interval, an assessment that was safe - * when computed must not still scale the Host to zero or leave it on the - * stateless runtime template. + * in between; if the CommunicationChannel watch ends, retain the stateless + * template but hold the Host active. A confirmed channel still applies the + * established incompatible-mode policy. * * This synchronous cache check adds no Kubernetes API calls to steady state. */ @@ -371,6 +390,9 @@ export class StatelessLifecycleExecutor { const { reasons, messages } = this.communicationChannelPolicyRejection(hostName) if (reasons.length === 0) { + if (!this.isCommunicationChannelCacheSynced() && !assessment.effective.suspensionBlocked) { + return this.holdActiveDuringChannelCacheRecovery(assessment.lifecycle.wakeHandledGeneration) + } return assessment } const message = messages.join('; ') @@ -575,44 +597,19 @@ export class StatelessLifecycleExecutor { freshLifecycle?.wakeHandledGeneration ?? 0 ) const cachedState = host.status?.lifecycle?.state ?? 'active' - // A rejection is an INTENDED reason override even when it does not - // change state. The reject/kill-switch path sets - // assessment.lifecycle = { state:'active', reason: } - // with condition.status='True' (StatelessEnableRejected). When the - // Host is ALREADY active a first-ever/steady rejection has - // assessment.state ('active') === cachedState ('active'), so the plain - // state-diff discriminator would route it to the ECHO branch, which - // re-sources reason via isHeartbeatManagedLifecycleReason and DROPS the - // rejection message (not heartbeat-managed) — silently losing the - // rejection explanation the CRD documents. Detect it via the condition - // transitioning to Rejected:True so the rejection reason is stamped on - // the fresh state instead of folded into the heartbeat-managed-only - // echo. (StatelessDisabled kill-switch carries status:'False' and no - // lifecycle reason, so it never trips this branch.) - const assessmentIsRejection = assessment.condition.status === 'True' + // Confirmed rejection and channel-cache uncertainty both require an + // active state and an explanatory reason, even when the cached state + // was already active. A state-only discriminator would treat that + // reason as an echo and drop it; it could also preserve a fresh drain + // decision made just before the cache lost authority. + const assessmentForcesActive = + assessment.condition.status === 'True' || assessment.effective.suspensionBlocked === true const assessmentIntendsStateOverride = assessment.lifecycle.state !== cachedState let lifecycle: HostLifecycleStatus - if (assessmentIntendsStateOverride) { - // The assessment DIFFERS from the cached snapshot's state: it - // INTENDED a transition (kill-switch/rejection forcing active over a - // suspended snapshot, or a resolveWakeBeforeScaleDown wake - // transition). That override wins; only wakeHandledGeneration stays - // monotonic against fresh. + if (assessmentIntendsStateOverride || assessmentForcesActive) { + // The assessment requires a transition or forces active by policy. + // Only wakeHandledGeneration is sourced monotonically from fresh. lifecycle = { ...assessment.lifecycle, wakeHandledGeneration } - } else if (assessmentIsRejection) { - // Reject-while-active: state does not change, but the assessment's - // rejection reason is an INTENDED override. Keep state from FRESH - // (never reintroduce the 8th costume — a heartbeat suspend/wake that - // landed since the snapshot must still be preserved) and stamp the - // rejection reason onto it instead of dropping it into the - // heartbeat-managed-only echo. - lifecycle = { - state: freshLifecycle?.state ?? 'active', - wakeHandledGeneration, - ...(assessment.lifecycle.reason !== undefined - ? { reason: assessment.lifecycle.reason } - : {}), - } } else { // The assessment's state was a pass-through ECHO of the cached // snapshot — prefer the FRESH durable state and the heartbeat-managed @@ -672,6 +669,11 @@ export class StatelessLifecycleExecutor { return this.effectiveLifecycleFromCache(host) } + private canSuspendFromCache(host: HostCRD): boolean { + const lifecycle = this.effectiveLifecycleFromCache(host) + return lifecycle.stateless && !lifecycle.suspensionBlocked + } + /** Public view of the clerum.io/wake-requested generation parser. */ getWakeRequestedGeneration(host: HostCRD): number { return this.wakeRequestedGeneration(host) @@ -788,10 +790,7 @@ export class StatelessLifecycleExecutor { // cache trails the API. Re-evaluate the complete effective stateless // policy from every fresh read/retry before honoring old heartbeat // evidence. - if ( - !this.sameHostSpecRevision(host, fresh) || - !this.effectiveLifecycleFromCache(fresh).stateless - ) { + if (!this.sameHostSpecRevision(host, fresh) || !this.canSuspendFromCache(fresh)) { return { skip: true } } const freshLifecycle = fresh.status?.lifecycle @@ -1057,10 +1056,7 @@ export class StatelessLifecycleExecutor { const result = await this.patchStatusWithPrecondition( host, fresh => { - if ( - !this.sameHostSpecRevision(host, fresh) || - !this.effectiveLifecycleFromCache(fresh).stateless - ) { + if (!this.sameHostSpecRevision(host, fresh) || !this.canSuspendFromCache(fresh)) { return { skip: true } } const freshLifecycle = fresh.status?.lifecycle @@ -1763,6 +1759,11 @@ export class StatelessLifecycleExecutor { host: HostCRD, assessment: HostLifecycleAssessment ): Promise { + if (assessment.effective.suspensionBlocked) { + // Cache authority loss intentionally forces active even when the fresh + // durable state is suspended; there is no scale-down to validate. + return assessment + } if (!assessment.effective.stateless) { // Kill-switch/rejection assessments INTEND active+replicas:1 regardless // of the durable state — never second-guess them from fresh. diff --git a/host-context-controller/src/statelessLifecycleTracker.ts b/host-context-controller/src/statelessLifecycleTracker.ts index d3c74c3450..ae7d93a997 100644 --- a/host-context-controller/src/statelessLifecycleTracker.ts +++ b/host-context-controller/src/statelessLifecycleTracker.ts @@ -438,6 +438,11 @@ export class StatelessLifecycleTracker implements HeartbeatLifecycleTracker { this.clearDrainGrace(hostRef) return { drain: false } } + if (effective.suspensionBlocked) { + this.clearDrainGrace(hostRef) + await this.cancelDrainOnEvidence(hostRef, host) + return { drain: false } + } this.noteWakeHandledGeneration(hostRef, host) let wakePending = @@ -793,7 +798,8 @@ export class StatelessLifecycleTracker implements HeartbeatLifecycleTracker { ) return } - if (!this.reconciler.getEffectiveLifecycle(host).stateless) { + const effective = this.reconciler.getEffectiveLifecycle(host) + if (!effective.stateless || effective.suspensionBlocked) { return } // KZ-R1: the informer-cached Host can be STALE here exactly as it is on the diff --git a/host-context-controller/src/types.ts b/host-context-controller/src/types.ts index 3a177dce13..404ac46281 100644 --- a/host-context-controller/src/types.ts +++ b/host-context-controller/src/types.ts @@ -544,7 +544,7 @@ export interface HostLifecycleStatus { * Written by the mcp-host runtime; HCC only preserves it across writes. */ wakeHandledGeneration: number - /** Human-readable explanation when the stateless request was rejected. */ + /** Human-readable rejection or temporary suspension-block reason. */ reason?: string } diff --git a/host-context-controller/test/hostReconciler.lifecycle.test.ts b/host-context-controller/test/hostReconciler.lifecycle.test.ts index d947e96012..a92d82a457 100644 --- a/host-context-controller/test/hostReconciler.lifecycle.test.ts +++ b/host-context-controller/test/hostReconciler.lifecycle.test.ts @@ -297,7 +297,7 @@ describe('HostReconciler stateless lifecycle — buildDeployment replicas', () = }) const dep = reconciler.buildDeployment(makeStatelessHost({ status: suspendedStatus() })) expect(dep.spec?.replicas).toBe(1) - expect(dep.spec?.template.spec?.priorityClassName).toBeUndefined() + expect(dep.spec?.template.spec?.priorityClassName).toBe('clerum-interactive-host') }) it('derives replicas=1 for stateless+active and stateless+draining', () => { @@ -737,22 +737,69 @@ describe('HostReconciler stateless lifecycle — env injection', () => { }) describe('HostReconciler stateless lifecycle — rejection matrix', () => { - it('fails closed while the CommunicationChannel cache is unsynced', async () => { + it('reconciles an active Host twice through cache loss without changing its session path or template', async () => { + let cacheSynced = true + const host = makeStatelessHost({ + status: { lifecycle: { state: 'active', wakeHandledGeneration: 2 } }, + }) + let serverHost = host + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => cacheSynced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(serverHost)) + + await reconciler.reconcile(host) + const baseline = structuredClone(hostDeploymentBody(appsApi, host.name).spec?.template) + expect(envValue(hostDeploymentBody(appsApi, host.name), 'CLERUM_SESSION_DB_DIR')).toBe( + '/var/lib/clerum/state' + ) + + for (let cycle = 1; cycle <= 2; cycle++) { + cacheSynced = false + // Exercise the fresh-read race as well as the cached active state: a + // suspend may have landed after the initial Host watch event. + serverHost = makeStatelessHost({ status: suspendedStatus(2 + cycle) }) + appsApi.createNamespacedDeployment.mockClear() + appsApi.replaceNamespacedDeployment.mockClear() + await reconciler.reconcile(host) + + const deployment = hostDeploymentBody(appsApi, host.name) + expect(deployment.spec?.replicas).toBe(1) + expect(deployment.spec?.template).toEqual(baseline) + expect(envValue(deployment, 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') + expect(reconciler.getEffectiveLifecycle(host)).toMatchObject({ + stateless: true, + state: 'active', + suspensionBlocked: true, + }) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'CommunicationChannelCacheUnsynced' + ) + expect(lifecycleStatusWrites(customApi).at(-1)?.lifecycle?.state).toBe('active') + cacheSynced = true + } + }) + + it('keeps the stateless template active while the CommunicationChannel cache is unsynced', async () => { const { reconciler, appsApi, customApi } = createReconciler({ isCommunicationChannelCacheSynced: () => false, }) await reconciler.reconcile(makeStatelessHost({ status: suspendedStatus() })) - expect(hostDeploymentBody(appsApi, 'stateless-host').spec?.replicas).toBe(1) + const deployment = hostDeploymentBody(appsApi, 'stateless-host') + expect(deployment.spec?.replicas).toBe(1) + expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') + expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') + expect(envValue(deployment, 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') const writes = lifecycleStatusWrites(customApi) expect(writes).toHaveLength(1) expect(writes[0].lifecycle?.state).toBe('active') const condition = rejectedCondition(writes[0]) - expect(condition.status).toBe('True') + expect(condition.status).toBe('False') expect(condition.reason).toContain('CommunicationChannelCacheUnsynced') }) - it('fails closed when the channel cache becomes unsynced during reconciliation', async () => { + it('preserves the stateless template when the channel cache becomes unsynced during reconciliation', async () => { let cacheSynced = true const { reconciler, appsApi, customApi, networkingApi } = createReconciler({ isCommunicationChannelCacheSynced: () => cacheSynced, @@ -778,9 +825,9 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { ) const deployment = hostDeploymentBody(appsApi, host.name) expect(deployment.spec?.replicas).toBe(1) - expect(containerEnv(deployment).map(entry => entry.name)).not.toContain( - 'CLERUM_STATELESS_LIFECYCLE' - ) + expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') + expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') + expect(envValue(deployment, 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') const writes = lifecycleStatusWrites(customApi) expect(writes).toHaveLength(2) expect(writes[0].lifecycle?.state).toBe('suspended') @@ -790,6 +837,7 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { reason: 'CommunicationChannel cache is not synchronized; stateless lifecycle is held active', }) expect(rejectedCondition(writes[1]).reason).toBe('CommunicationChannelCacheUnsynced') + expect(rejectedCondition(writes[1]).status).toBe('False') }) it('fails closed when a channel starts referencing the Host during reconciliation', async () => { @@ -1076,9 +1124,9 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { expect(provision).toHaveBeenCalledOnce() const deployment = hostDeploymentBody(appsApi, host.name) expect(deployment.spec?.replicas).toBe(1) - expect(containerEnv(deployment).map(entry => entry.name)).not.toContain( - 'CLERUM_STATELESS_LIFECYCLE' - ) + expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') + expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') + expect(envValue(deployment, 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') }) it('rejects stateless by default when CommunicationChannels reference the host', async () => { @@ -1742,15 +1790,12 @@ describe('HostReconciler stateless lifecycle — AP-1 fresh-read status writer', }) }) - it('reject-while-active keeps state from FRESH (8th costume preserved): a suspend that landed is not resurrected', async () => { + it('confirmed incompatibility restores active state after a concurrent suspend', async () => { const { reconciler, customApi } = createReconciler({ countCommunicationChannels: () => 1, }) - // Cached active, but a heartbeat suspend landed between the snapshot and - // the writer's fresh read (fresh = suspended, gen 5). The rejection reason - // is an intended override, but STATE must still come from fresh — the - // reject branch must NOT reintroduce the 8th costume by echoing the cached - // active state over a just-suspended Host. + // A confirmed channel conflict has the existing always-on policy. If a + // heartbeat suspended the Host during the fresh read, reconcile wakes it. const host = makeStatelessHost({ status: { lifecycle: { state: 'active', wakeHandledGeneration: 3 } }, }) @@ -1762,7 +1807,7 @@ describe('HostReconciler stateless lifecycle — AP-1 fresh-read status writer', const writes = lifecycleStatusWrites(customApi) expect(writes.at(-1)?.lifecycle).toEqual({ - state: 'suspended', + state: 'active', wakeHandledGeneration: 5, reason: '1 CommunicationChannel(s) reference this Host; disassociate them to enable the requested stateless lifecycle', diff --git a/host-context-controller/test/statelessLifecycleTracker.test.ts b/host-context-controller/test/statelessLifecycleTracker.test.ts index c6836057a6..3d057643bd 100644 --- a/host-context-controller/test/statelessLifecycleTracker.test.ts +++ b/host-context-controller/test/statelessLifecycleTracker.test.ts @@ -174,6 +174,22 @@ describe('StatelessLifecycleTracker — D8 idle rule', () => { }) }) + it('does not drain or suspend a stateless Host while channel authority is unavailable', async () => { + const port = makePort() + port.getEffectiveLifecycle.mockReturnValue({ + stateless: true, + state: 'active', + suspensionBlocked: true, + }) + const tracker = makeTracker({ port }) + for (let cycle = 0; cycle < 2; cycle++) { + expect(await tracker.handleHeartbeat(payload({ lastActivityTs: NOW - 10 * HOUR }))).toEqual({ + drain: false, + }) + } + expect(port.suspendHostFromHeartbeat).not.toHaveBeenCalled() + }) + it('answers drain:false for an unknown host', async () => { const port = makePort() const tracker = new StatelessLifecycleTracker({ diff --git a/scripts/e2e/_lib/hcc-watch-api-proxy.mjs b/scripts/e2e/_lib/hcc-watch-api-proxy.mjs index 31eca8cfb3..c53ef402e8 100644 --- a/scripts/e2e/_lib/hcc-watch-api-proxy.mjs +++ b/scripts/e2e/_lib/hcc-watch-api-proxy.mjs @@ -46,7 +46,11 @@ export function validateCommand(command, allowedPaths) { } else if (command.action === 'release') { if (typeof command.pauseId !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(command.pauseId)) throw new Error('invalid_pause_id') - } else if (command.action !== 'observe-bookmarks') throw new Error('invalid_control_action') + } else if (command.action === 'hold-channel') { + if (!Number.isInteger(command.durationMs) || command.durationMs < 1000 || command.durationMs > 60000) + throw new Error('invalid_channel_hold_duration') + } else if (command.action !== 'release-channel' && command.action !== 'observe-bookmarks') + throw new Error('invalid_control_action') return command } @@ -60,12 +64,15 @@ export function createProxy({ controlDir, periodMs, minAgeMs, + channelPath, + churnEnabled = true, }) { // CA bytes configure TLS trust only; they never enter the request or destination. const upstreamSecureContext = createSecureContext({ ca: upstreamCa }) const streams = new Set() const bookmarks = createBookmarkObservation() let pause = null + let channelHold = null let commandId = null let commandContent = null const writeRecord = (name, fields) => { @@ -83,6 +90,13 @@ export function createProxy({ old.resume?.() acknowledge({ id: old.id, state: reason }) } + const finishChannelHold = reason => { + if (!channelHold) return + const held = channelHold + channelHold = null + clearTimeout(held.timer) + writeRecord('channel-hold', { id: held.id, state: reason, rejected: held.rejected }) + } const server = https.createServer({ key, cert }, (request, response) => { const url = new URL(request.url, 'https://fixture.invalid') if (!request.url.startsWith('/') || request.url.startsWith('//')) { @@ -90,6 +104,12 @@ export function createProxy({ return } const watch = url.searchParams.get('watch') === 'true' + if (channelHold && request.method === 'GET' && url.pathname === channelPath) { + channelHold.rejected++ + response.writeHead(503, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ kind: 'Status', apiVersion: 'v1', status: 'Failure', reason: 'ServiceUnavailable', code: 503 })) + return + } const kind = url.pathname.endsWith('/mcpservers') ? 'McpServer' : url.pathname.endsWith('/contexts') @@ -188,6 +208,27 @@ export function createProxy({ throw new Error('pause_not_held') finishPause('released') acknowledge({ id: command.id, state: 'released' }) + } else if (command.action === 'hold-channel') { + if (!channelPath || channelHold) throw new Error('channel_hold_unavailable') + let count = 0 + channelHold = { + id: command.id, + rejected: 0, + timer: setTimeout(() => finishChannelHold('expired'), command.durationMs), + } + for (const stream of streams) { + if (stream.watch && stream.request.method === 'GET' && + new URL(stream.request.url, 'https://fixture.invalid').pathname === channelPath) { + stream.close() + count++ + } + } + writeRecord('channel-hold', { id: command.id, state: 'held', cut: count, rejected: 0 }) + acknowledge({ id: command.id, state: 'held', count }) + } else if (command.action === 'release-channel') { + if (!channelHold) throw new Error('channel_hold_not_active') + finishChannelHold('released') + acknowledge({ id: command.id, state: 'released' }) } else if (command.action === 'observe-bookmarks') { writeRecord('bookmarks', bookmarks.finish()) acknowledge({ id: command.id, state: 'observed' }) @@ -210,15 +251,16 @@ export function createProxy({ acknowledge({ id: observedId, state: 'rejected' }) } }, 100) - const churn = setInterval(() => { + const churn = churnEnabled ? setInterval(() => { if (fs.existsSync(`${controlDir}/paused`)) return for (const stream of streams) if (stream.watch && Date.now() - stream.born >= minAgeMs) stream.close() - }, periodMs) + }, periodMs) : null const close = () => { clearInterval(poll) - clearInterval(churn) + if (churn) clearInterval(churn) finishPause('shutdown') + finishChannelHold('shutdown') for (const stream of streams) stream.close() server.close() } @@ -235,6 +277,8 @@ if (import.meta.main) { controlDir: '/churn-ctl', periodMs: Number(process.env.CHURN_PERIOD_MS), minAgeMs: Number(process.env.CHURN_MIN_AGE_MS), + channelPath: process.env.CHANNEL_PATH, + churnEnabled: process.env.CHURN_DISABLED !== '1', }) proxy.server.listen(8443, '0.0.0.0') process.on('SIGTERM', proxy.close) diff --git a/scripts/e2e/_lib/hcc-watch-pr-a.sh b/scripts/e2e/_lib/hcc-watch-pr-a.sh index 0b24d121bf..8cb517078a 100755 --- a/scripts/e2e/_lib/hcc-watch-pr-a.sh +++ b/scripts/e2e/_lib/hcc-watch-pr-a.sh @@ -15,7 +15,7 @@ hcc_pr_a_command() { pod="$(hcc_pr_a_proxy_pod)" || return 1 HCC_PR_A_COMMAND_ID="${RUN_ID}-$(date +%s)-${RANDOM}" command="$(jq -cn --arg id "$HCC_PR_A_COMMAND_ID" --arg action "$action" --arg value "$value" \ - '{id:$id,action:$action} + (if $action=="arm" then {path:$value,method:"GET",durationMs:25000} elif $action=="cut" then {kind:$value} elif $action=="release" then {pauseId:$value} else {} end)')" + '{id:$id,action:$action} + (if $action=="arm" then {path:$value,method:"GET",durationMs:25000} elif $action=="cut" then {kind:$value} elif $action=="release" then {pauseId:$value} elif $action=="hold-channel" then {durationMs:($value|tonumber)} else {} end)')" printf '%s' "$command" | kctl exec -i "pod/$pod" -n "$HCC_NS" -c proxy -- node -e \ 'const fs=require("fs");const value=fs.readFileSync(0);fs.writeFileSync("/churn-ctl/next.json",value,{mode:384});fs.renameSync("/churn-ctl/next.json","/churn-ctl/command.json")' } @@ -24,7 +24,7 @@ hcc_pr_a_ack() { local state=$1 pod pod="$(hcc_pr_a_proxy_pod)" || return 1 kctl exec "pod/$pod" -n "$HCC_NS" -c proxy -- node -e \ - 'const fs=require("fs");try{const a=JSON.parse(fs.readFileSync("/churn-ctl/ack.json"));if(a.id!==process.argv[1]||a.state!==process.argv[2]||(a.state==="cut"&&a.count<1))process.exit(1)}catch{process.exit(1)}' \ + 'const fs=require("fs");try{const a=JSON.parse(fs.readFileSync("/churn-ctl/ack.json"));if(a.id!==process.argv[1]||a.state!==process.argv[2]||((a.state==="cut"||a.state==="held")&&a.count<1))process.exit(1)}catch{process.exit(1)}' \ "$HCC_PR_A_COMMAND_ID" "$state" >/dev/null 2>&1 } @@ -386,15 +386,19 @@ hcc_pr_a_enable_proxy() { # Generated private material stays in this anonymous pipe, never an artifact. node "$HCC_PR_A_LIB/hcc-watch-tls-manifest.mjs" "$PROXY_NAME" "$HCC_NS" "$RUN_ID" \ "$HCC_PR_A_LIB/hcc-watch-api-proxy.mjs" | kctl create -f - >/dev/null 2>&1 || die 'PR A TLS fixture creation failed' - patch="$(jq -cn --arg name "$PROXY_NAME" --arg paths "$paths" '{spec:{template:{spec:{ + patch="$(jq -cn --arg name "$PROXY_NAME" --arg paths "$paths" --arg channel_ns "${CHANNEL_NS:-channels}" \ + --argjson selective "${HCC_PR_A_SELECTIVE_CHANNEL:-0}" '{spec:{template:{spec:{ securityContext:{fsGroup:1000}, containers:[{name:"proxy",command:["node","/fixture/proxy.mjs"],args:[], - env:[{name:"PAUSE_PATHS",value:$paths}],volumeMounts:[ + env:([{name:"PAUSE_PATHS",value:$paths},{name:"CHANNEL_PATH",value:("/apis/clerum.io/v1alpha1/namespaces/"+$channel_ns+"/communicationchannels")}] + + (if $selective==1 then [{name:"CHURN_DISABLED",value:"1"}] else [] end)),volumeMounts:[ {name:"fixture-code",mountPath:"/fixture",readOnly:true}, {name:"fixture-tls",mountPath:"/fixture-tls",readOnly:true}, - {name:"upstream-ca",mountPath:"/upstream-ca",readOnly:true}]}], + {name:"upstream-ca",mountPath:"/upstream-ca",readOnly:true}, + {name:"churn-control",mountPath:"/churn-ctl"}]}], volumes:[{name:"fixture-code",configMap:{name:$name}}, {name:"fixture-tls","secret":{secretName:$name,defaultMode:288}}, - {name:"upstream-ca",configMap:{name:"kube-root-ca.crt"}}]}}}}')" + {name:"upstream-ca",configMap:{name:"kube-root-ca.crt"}}, + {name:"churn-control",emptyDir:{}}]}}}}')" kctl patch deployment "$PROXY_NAME" -n "$HCC_NS" --type=strategic -p "$patch" >/dev/null kctl rollout status deployment "$PROXY_NAME" -n "$HCC_NS" --timeout=60s >/dev/null || die 'PR A TLS proxy not Ready' } diff --git a/scripts/e2e/e2e-hcc-stateless-cache-containment.sh b/scripts/e2e/e2e-hcc-stateless-cache-containment.sh new file mode 100755 index 0000000000..ae46f512c3 --- /dev/null +++ b/scripts/e2e/e2e-hcc-stateless-cache-containment.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Branch-owned live gate for #396. The TLS fixture cuts only the +# CommunicationChannel watch/list while forwarding all other Kubernetes API +# requests. A known conversation is created through rpc-proxy before the cut. +set -euo pipefail +umask 077 +# shellcheck disable=SC2034 # Shared fixture helpers consume these globals. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/e2e/e2e-lib.sh +source "${SCRIPT_DIR}/e2e-lib.sh" +# shellcheck source=scripts/e2e/_lib/hcc-watch-recovery-fixture.sh +source "${SCRIPT_DIR}/_lib/hcc-watch-recovery-fixture.sh" +# shellcheck source=scripts/e2e/_lib/hcc-watch-recovery-assertions.sh +source "${SCRIPT_DIR}/_lib/hcc-watch-recovery-assertions.sh" +# shellcheck source=scripts/e2e/_lib/hcc-watch-recovery-logs.sh +source "${SCRIPT_DIR}/_lib/hcc-watch-recovery-logs.sh" +# shellcheck source=scripts/e2e/_lib/hcc-watch-recovery-lock.sh +source "${SCRIPT_DIR}/_lib/hcc-watch-recovery-lock.sh" +# shellcheck source=scripts/e2e/_lib/hcc-watch-pr-a.sh +source "${SCRIPT_DIR}/_lib/hcc-watch-pr-a.sh" + +die() { fail "$*"; exit 1; } +wait_until() { + local timeout=$1 description=$2 elapsed=0 + shift 2 + while [ "$elapsed" -lt "$timeout" ]; do + "$@" && return 0 + sleep 1 + elapsed=$((elapsed + 1)) + done + echo "Timed out waiting for ${description}" >&2 + return 1 +} + +if [ -z "$E2E_KUBECONTEXT" ] || ! is_branch_scoped_e2e_context "$E2E_KUBECONTEXT"; then + die 'select an explicit branch-scoped Minikube context' +fi +require_safe_kube_context +[ "${E2E_HCC_WATCH_FAULT_INJECTION:-0}" = 1 ] || die 'set E2E_HCC_WATCH_FAULT_INJECTION=1' +[ -n "${E2E_BRANCH_PROFILE_ENV:-}" ] && [ -r "$E2E_BRANCH_PROFILE_ENV" ] || + die 'provide E2E_BRANCH_PROFILE_ENV from the owned branch profile' +[ -n "${E2E_PROFILE_PORTS_ENV:-}" ] && [ -r "$E2E_PROFILE_PORTS_ENV" ] || + die 'provide E2E_PROFILE_PORTS_ENV from the owned branch profile' +require_branch_profile_urls "$E2E_KUBECONTEXT" "$E2E_PROFILE_PORTS_ENV" || + die 'profile URLs do not match the owned ports' +kctl get nodes -o json | jq -e --arg c "$E2E_KUBECONTEXT" \ + 'any(.items[]; .metadata.labels["minikube.k8s.io/name"]==$c)' >/dev/null || + die 'target is not the selected Minikube profile' + +HCC_NS=control-plane HCC_DEPLOY=host-context-controller +HOST_NS=mcp-host MCP_NS=mcp-server CHANNEL_NS=channels +HOST_REF="${E2E_STATELESS_HOST_REF:-chatllm-stateless}" +RUN_ID="$(date +%s)-$$" +PROXY_NAME="$(truncate_rfc1123 "e2e-hcc-channel-${RUN_ID}")" +PROBE_NAME="$(truncate_rfc1123 "${PROXY_NAME}-negative-probe")" +PROXY_EGRESS_NP="$(truncate_rfc1123 "${PROXY_NAME}-api")" +HCC_PROXY_NP="$(truncate_rfc1123 "${PROXY_NAME}-from-hcc")" +PROBE_EGRESS_NP="$(truncate_rfc1123 "${PROXY_NAME}-from-probe")" +THREAD_ID="e2e-hcc-cache-${RUN_ID}" +MARKER="hcc-cache-${RUN_ID}" +START_TIME="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +HCC_LOG_BUFFER="$(mktemp "${TMPDIR:-/tmp}/hcc-channel-log.XXXXXX")" +HCC_LOG_STREAM_PID='' +HCC_GATE_LOCK_ACQUIRED=0 HCC_GATE_LOCK_NAME='' HCC_GATE_LOCK_UID='' +HCC_GATE_FINALIZATION_FAILURE='' +HCC_PATCHED=0 PROXY_CREATED=0 PROBE_CREATED=0 HCC_PR_A_TLS_CREATED=0 +HCC_UID='' HCC_RESTARTS='' +ORIGINAL_REPLICAS='' + +cleanup() { + local status=$? cleanup_failed=0 restore_ok=1 + set +e + stop_hcc_recovery_log_stream + if [ "$HCC_PATCHED" = 1 ]; then + if hcc_pr_a_restore && kctl rollout status deployment "$HCC_DEPLOY" -n "$HCC_NS" --timeout=180s >/dev/null; then + HCC_PATCHED=0 + else + restore_ok=0 + fi + fi + if [ "$restore_ok" = 1 ]; then + [ "$PROXY_CREATED" = 0 ] || delete_hcc_proxy_fixture || cleanup_failed=1 + if [ "$HCC_PR_A_TLS_CREATED" = 1 ]; then + kctl delete configmap "$PROXY_NAME" -n "$HCC_NS" --ignore-not-found --wait=true --timeout=60s >/dev/null 2>&1 || cleanup_failed=1 + kctl delete secret "$PROXY_NAME" -n "$HCC_NS" --ignore-not-found --wait=true --timeout=60s >/dev/null 2>&1 || cleanup_failed=1 + fi + else + cleanup_failed=1 + echo "HCC restoration failed; retained proxy ${HCC_NS}/${PROXY_NAME} on ${E2E_KUBECONTEXT}" >&2 + fi + finalize_hcc_watch_gate_lock "$cleanup_failed" "$restore_ok" || cleanup_failed=1 + rm -f "$HCC_LOG_BUFFER" "${HCC_PR_A_CONFIG_SNAPSHOT:-}" + [ "$cleanup_failed" = 0 ] || status=1 + print_results + exit "$status" +} +trap cleanup EXIT + +header 'Stateless Host containment during CommunicationChannel cache uncertainty' +require_branch_owned_hcc_gate +acquire_hcc_watch_gate_lock || die 'another HCC watch gate owns this profile' +ORIGINAL_REPLICAS="$(kctl get deployment "$HCC_DEPLOY" -n "$HCC_NS" -o jsonpath='{.spec.replicas}')" +[ "$ORIGINAL_REPLICAS" = 1 ] || die 'HCC must have exactly one replica' +[ "$(kctl get host "$HOST_REF" -n "$HOST_NS" -o jsonpath='{.spec.lifecycle.stateless}')" = true ] || + die 'selected Host must request stateless lifecycle' + +HCC_IMAGE="$(kctl get deployment "$HCC_DEPLOY" -n "$HCC_NS" -o jsonpath='{.spec.template.spec.containers[?(@.name=="host-context-controller")].image}')" +[ -n "$HCC_IMAGE" ] || die 'could not resolve the running HCC image' +K8S_API_SERVICE_HOST="$(kctl exec deployment/"$HCC_DEPLOY" -n "$HCC_NS" -c host-context-controller -- printenv KUBERNETES_SERVICE_HOST)" +node -e 'process.exit(require("node:net").isIP(process.argv[1])===4?0:1)' "$K8S_API_SERVICE_HOST" || + die 'Kubernetes API service address is not IPv4' +K8S_API_CIDR="${K8S_API_SERVICE_HOST}/32" +hcc_pr_a_preflight +create_hcc_api_proxy +HCC_PR_A_SELECTIVE_CHANNEL=1 hcc_pr_a_enable_proxy +E2E_HCC_PR_A=1 verify_hcc_proxy_network_policy +HCC_PATCHED=1 +kctl patch deployment "$HCC_DEPLOY" -n "$HCC_NS" --type=strategic -p "$(hcc_pr_a_redirect_patch)" >/dev/null +kctl rollout status deployment "$HCC_DEPLOY" -n "$HCC_NS" --timeout=180s >/dev/null || + die 'HCC did not become Ready through selective TLS proxy' +read -r HCC_UID HCC_RESTARTS <<<"$(wait_for_hcc_identity 30)" +start_hcc_recovery_log_stream + +EXT_BASE="${EXTERNAL_REST_API_BASE_URL%/}" +RPC_BASE="${RPC_PROXY_BASE_URL%/}" +DEV_EMAIL="${E2E_DEV_LOGIN_EMAIL:-test@clerum.io}" +DEV_PASSWORD="${E2E_USER_PASSWORD:-${ADMIN_PASSWORD:-}}" +[ -n "$DEV_PASSWORD" ] || die 'E2E_USER_PASSWORD is required for the known-session proof' +curl -fsS -m 10 "${EXT_BASE}/health" >/dev/null || die 'external-rest-api unavailable' +curl -fsS -m 10 "${RPC_BASE}/health" >/dev/null || die 'rpc-proxy unavailable' +login="$(curl -fsS -m 20 -X POST "${EXT_BASE}/api/v1/auth/password-login" \ + -H 'Content-Type: application/json' -d "$(jq -cn --arg e "$DEV_EMAIL" --arg p "$DEV_PASSWORD" '{email:$e,password:$p}')")" || + die 'E2E user login failed' +SESSION_TOKEN="$(jq -er '.token' <<<"$login")" || die 'login returned no token' +mint_rpc_token() { + local body + body="$(curl -fsS -m 20 -X POST "${EXT_BASE}/api/v1/rpc/token" \ + -H "Authorization: Bearer ${SESSION_TOKEN}" -H 'Content-Type: application/json' \ + -d "$(jq -cn --arg host "$HOST_REF" '{hostRefs:[$host],scopes:["host:message:invoke","host:session:read","host:status:read","host:health:read"]}')")" || return 1 + RPC_TOKEN="$(jq -er '.token' <<<"$body")" +} +mint_rpc_token || die 'could not mint authorized RPC token' +message="$(jq -cn --arg content "Remember ${MARKER}." --arg thread "$THREAD_ID" '{content:$content,threadId:$thread}')" +baseline_accepted=0 +for attempt in 1 2 3; do + mint_rpc_token || die 'could not refresh authorized RPC token' + response="$(curl -sS -m 120 -w '\n%{http_code}' -X POST "${RPC_BASE}/api/v1/rpc/hosts/${HOST_REF}/messages" \ + -H "Authorization: Bearer ${RPC_TOKEN}" -H 'Content-Type: application/json' -d "$message")" || + die 'baseline message through rpc-proxy failed' + if [ "${response##*$'\n'}" = 200 ]; then + baseline_accepted=1 + break + fi + if [ "$attempt" = 3 ] || [ "${response##*$'\n'}" != 503 ] || + ! jq -e '.code=="host_waking"' <<<"${response%$'\n'*}" >/dev/null; then + die 'baseline message was not accepted' + fi + wait_until 90 'Host wake after retryable response' host_runtime_is_always_on "$HOST_REF" || + die 'Host did not wake after retryable response' +done +[ "$baseline_accepted" = 1 ] || die 'baseline message was not accepted' + +session_visible() { + local response status body + mint_rpc_token || return 1 + response="$(curl -sS -m 30 -w '\n%{http_code}' \ + -H "Authorization: Bearer ${RPC_TOKEN}" \ + "${RPC_BASE}/api/v1/rpc/hosts/${HOST_REF}/sessions/${HOST_REF}/${THREAD_ID}/messages")" || return 1 + status="${response##*$'\n'}" body="${response%$'\n'*}" + [ "$status" = 200 ] && jq -e --arg marker "$MARKER" \ + 'any(.turns[]?; .user_input|contains($marker))' <<<"$body" >/dev/null +} +session_listed() { + local response status body + mint_rpc_token || return 1 + response="$(curl -sS -m 30 -w '\n%{http_code}' \ + -H "Authorization: Bearer ${RPC_TOKEN}" \ + "${RPC_BASE}/api/v1/rpc/hosts/${HOST_REF}/sessions?agent=${HOST_REF}&limit=100")" || return 1 + status="${response##*$'\n'}" body="${response%$'\n'*}" + [ "$status" = 200 ] && jq -e --arg thread "$THREAD_ID" --arg agent "$HOST_REF" \ + 'any(.items[]?; .chatId==$thread and .agent==$agent)' <<<"$body" >/dev/null +} +wait_until 30 'baseline session transcript' session_visible || die 'known session was not readable' +wait_until 30 'baseline session list' session_listed || die 'known session was not listed' +wait_until 90 'baseline active Host' host_runtime_is_always_on "$HOST_REF" || + die 'Host was not active before fault injection' +baseline="$(kctl get deployment "$HOST_REF" -n "$HOST_NS" -o json)" +baseline_template="$(jq -Sc '.spec.template' <<<"$baseline")" +baseline_pod="$(kctl get pods -n "$HOST_NS" -l "app=${HOST_REF}" -o json | + jq -er '[.items[]|select(.metadata.deletionTimestamp==null)|select(any(.status.conditions[]?;.type=="Ready" and .status=="True"))]|if length==1 then .[0].metadata.uid else error("pod identity ambiguous") end')" +jq -e '[.spec.template.spec.containers[].env[]? | select(.name=="CLERUM_SESSION_DB_DIR" and .value=="/var/lib/clerum/state")]|length==1' <<<"$baseline" >/dev/null || + die 'baseline SQLite directory is not the stateless path' + +cache_held_active() { + local status deployment pod + status="$(kctl get host "$HOST_REF" -n "$HOST_NS" -o json)" || return 1 + jq -e '[.status.conditions[]?|select(.type=="StatelessEnableRejected")][0] | + .status=="False" and .reason=="CommunicationChannelCacheUnsynced"' <<<"$status" >/dev/null || return 1 + host_runtime_is_always_on "$HOST_REF" || return 1 + deployment="$(kctl get deployment "$HOST_REF" -n "$HOST_NS" -o json)" || return 1 + [ "$(jq -Sc '.spec.template' <<<"$deployment")" = "$baseline_template" ] || return 1 + pod="$(kctl get pods -n "$HOST_NS" -l "app=${HOST_REF}" -o json | + jq -er '[.items[]|select(.metadata.deletionTimestamp==null)|select(any(.status.conditions[]?;.type=="Ready" and .status=="True"))]|if length==1 then .[0].metadata.uid else error("pod identity ambiguous") end')" || return 1 + [ "$pod" = "$baseline_pod" ] +} + +for cycle in 1 2; do + before="$(log_count 'CommunicationChannel watch ended;')" + hcc_pr_a_command hold-channel 60000 || die "cycle ${cycle}: fault command failed" + wait_until 5 'selective hold acknowledgement' hcc_pr_a_ack held || + die "cycle ${cycle}: no live channel watch was cut" + wait_log_count 'CommunicationChannel watch ended;' "$((before + 1))" 20 || + die "cycle ${cycle}: HCC did not lose channel authority" + wait_until 30 'reconciliation while channel cache is unsynced' cache_held_active || + die "cycle ${cycle}: active stateless Host did not converge during watch loss" + session_visible || die "cycle ${cycle}: known session unavailable through rpc-proxy" + session_listed || die "cycle ${cycle}: known session absent from authorized list" + cache_held_active || die "cycle ${cycle}: template, SQLite path or pod identity changed" + hcc_pr_a_command release-channel || die "cycle ${cycle}: release command failed" + wait_until 5 'selective release acknowledgement' hcc_pr_a_ack released || + die "cycle ${cycle}: release was not acknowledged" + wait_until 30 'CommunicationChannel watch recovery' wait_host_mode "$HOST_REF" accepted 1 || + die "cycle ${cycle}: channel authority did not recover" + ok "cycle ${cycle}: reconciled active stateless Host, stable template/pod and readable session" +done +assert_hcc_identity +ok 'two selective watch-loss cycles completed without HCC restart' diff --git a/scripts/tests/hcc-watch-api-proxy.test.mjs b/scripts/tests/hcc-watch-api-proxy.test.mjs index 3edfde08dc..898b6eab49 100644 --- a/scripts/tests/hcc-watch-api-proxy.test.mjs +++ b/scripts/tests/hcc-watch-api-proxy.test.mjs @@ -30,10 +30,86 @@ const allowedPath = '/api/v1/namespaces/mcp-server/services/runtime-witness' const mcpPath = '/apis/clerum.io/v1alpha1/namespaces/mcp-server/mcpservers?watch=true' const contextPath = '/apis/clerum.io/v1alpha1/namespaces/mcp-server/contexts?watch=true' const otherPath = '/api/v1/namespaces/mcp-server/pods?watch=true' +const channelPath = '/apis/clerum.io/v1alpha1/namespaces/channels/communicationchannels' let front let upstreamTls let publicConfig +test('channel hold accepts only a bounded duration', () => { + assert.throws(() => validateCommand({ id: 'hold', action: 'hold-channel', durationMs: 0 }, [])) + assert.throws(() => validateCommand({ id: 'hold', action: 'hold-channel', durationMs: 60001 }, [])) + assert.deepEqual(validateCommand({ id: 'hold', action: 'hold-channel', durationMs: 60000 }, []), + { id: 'hold', action: 'hold-channel', durationMs: 60000 }) +}) + +test('channel hold cuts only its watch and keeps Host API requests available', { timeout: 10000 }, async () => { + const controlDir = mkdtempSync(join(tmpdir(), 'hcc-channel-hold-')) + const seen = [] + const upstream = https.createServer(upstreamTls, (request, response) => { + seen.push(request.url) + if (request.url.includes('watch=true')) { + response.writeHead(200) + response.flushHeaders() + } else { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end('{}') + } + }) + let proxy + let watchRequest + const get = path => new Promise((resolveResponse, rejectResponse) => { + https.get({ hostname: '127.0.0.1', port: proxy.server.address().port, path, + servername: 'proxy-fixture.test-fixture.svc', ca: front.cert }, response => { + response.resume() + response.once('end', () => resolveResponse(response.statusCode)) + }).once('error', rejectResponse) + }) + const ack = async (id, state) => { + for (let attempt = 0; attempt < 50; attempt++) { + if (existsSync(join(controlDir, 'ack.json'))) { + const value = JSON.parse(readFileSync(join(controlDir, 'ack.json'), 'utf8')) + if (value.id === id && value.state === state) return value + } + await new Promise(resolveWait => setTimeout(resolveWait, 20)) + } + throw new Error(`missing ${state} acknowledgement`) + } + try { + await new Promise(resolveListen => upstream.listen(0, '127.0.0.1', resolveListen)) + proxy = createProxy({ ...front, upstreamCa: upstreamTls.cert, + upstreamHost: '127.0.0.1', upstreamPort: upstream.address().port, + allowedPaths: [], controlDir, periodMs: 60000, minAgeMs: 60000, channelPath }) + await new Promise(resolveListen => proxy.server.listen(0, '127.0.0.1', resolveListen)) + await new Promise((resolveHeaders, rejectHeaders) => { + watchRequest = https.get({ hostname: '127.0.0.1', port: proxy.server.address().port, + path: `${channelPath}?watch=true`, servername: 'proxy-fixture.test-fixture.svc', + ca: front.cert }, resolveHeaders) + watchRequest.once('error', rejectHeaders) + }) + writeFileSync(join(controlDir, 'command.json'), JSON.stringify({ + id: 'hold-1', action: 'hold-channel', durationMs: 5000, + })) + const held = await ack('hold-1', 'held') + assert.equal(held.count, 1, 'live CommunicationChannel watch was cut') + assert.equal(await get(channelPath), 503, 'channel LIST remains unavailable') + assert.equal(await get('/apis/clerum.io/v1alpha1/namespaces/mcp-host/hosts/test'), 200) + assert.equal(await get('/api/v1/namespaces/mcp-host/pods'), 200) + assert.equal(seen.filter(path => path.startsWith(channelPath)).length, 1, + 'blocked channel retries never reached upstream') + writeFileSync(join(controlDir, 'command.json'), JSON.stringify({ + id: 'release-1', action: 'release-channel', + })) + await ack('release-1', 'released') + assert.equal(await get(channelPath), 200, 'authoritative LIST works after release') + } finally { + watchRequest?.destroy() + proxy?.close() + upstream.closeAllConnections() + await new Promise(resolveClose => upstream.close(resolveClose)) + rmSync(controlDir, { recursive: true, force: true }) + } +}) + test( 'quiet upstream WATCH headers reach the client before its first body event', { timeout: 5000 }, diff --git a/scripts/tests/test-hcc-stateless-cache-containment.sh b/scripts/tests/test-hcc-stateless-cache-containment.sh new file mode 100755 index 0000000000..48dd5a9949 --- /dev/null +++ b/scripts/tests/test-hcc-stateless-cache-containment.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Static contract for the branch-owned selective watch gate. Runtime behavior +# of the TLS fault is exercised by hcc-watch-api-proxy.test.mjs. +set -euo pipefail +# shellcheck disable=SC2034,SC2329 # Fixture globals/functions are consumed by the sourced helper. +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GATE="${ROOT}/scripts/e2e/e2e-hcc-stateless-cache-containment.sh" +PROXY="${ROOT}/scripts/e2e/_lib/hcc-watch-api-proxy.mjs" +HELPER="${ROOT}/scripts/e2e/_lib/hcc-watch-pr-a.sh" + +bash -n "$GATE" "$HELPER" +node --check "$PROXY" +grep -Fq 'require_branch_owned_hcc_gate' "$GATE" +grep -Fq 'E2E_BRANCH_PROFILE_ENV' "$GATE" +grep -Fq 'E2E_PROFILE_PORTS_ENV' "$GATE" +grep -Fq 'acquire_hcc_watch_gate_lock' "$GATE" +# shellcheck disable=SC2016 # Match literal shell source. +grep -Fq 'finalize_hcc_watch_gate_lock "$cleanup_failed" "$restore_ok"' "$GATE" +grep -Fq 'hcc_pr_a_command hold-channel 60000' "$GATE" +grep -Fq 'hcc_pr_a_command release-channel' "$GATE" +grep -Fq 'wait_log_count '\''CommunicationChannel watch ended;' "$GATE" +grep -Fq 'CommunicationChannelCacheUnsynced' "$GATE" +# shellcheck disable=SC2016 # Match literal shell source. +grep -Fq 'host_runtime_is_always_on "$HOST_REF"' "$GATE" +grep -Fq 'jq -Sc '\''.spec.template' "$GATE" +grep -Fq 'baseline_pod' "$GATE" +grep -Fq 'CLERUM_SESSION_DB_DIR' "$GATE" +# shellcheck disable=SC2016 # Match literal shell source. +grep -Fq '/sessions/${HOST_REF}/${THREAD_ID}/messages' "$GATE" +grep -Fq '/sessions?agent=${HOST_REF}&limit=100' "$GATE" +grep -Fq 'session_visible || die "cycle' "$GATE" +grep -Fq 'session_listed || die "cycle' "$GATE" +grep -Fq 'HCC_PR_A_SELECTIVE_CHANNEL=1 hcc_pr_a_enable_proxy' "$GATE" +grep -Fq 'response.writeHead(503' "$PROXY" +grep -Fq 'url.pathname === channelPath' "$PROXY" +if grep -Eq 'scale_proxy|kill .*HCC|kubectl .*delete pod' "$GATE"; then + echo 'Selective gate contains a broad API or pod fault' >&2 + exit 1 +fi + +# The proxy image has a read-only root filesystem. Exercise the real fixture +# builder and inspect the Deployment patch, so command/ack files have an owned +# writable mount in the effective pod configuration. +capture="$(mktemp "${TMPDIR:-/tmp}/hcc-channel-patch.XXXXXX")" +trap 'rm -f "$capture"' EXIT +( + # shellcheck source=scripts/e2e/_lib/hcc-watch-pr-a.sh + source "$HELPER" + PROXY_NAME=fixture-proxy HCC_NS=control-plane MCP_NS=mcp-server CHANNEL_NS=channels RUN_ID=fixture-run + HCC_PR_A_SELECTIVE_CHANNEL=1 + CAPTURE="$capture" + truncate_rfc1123() { printf '%.63s' "$1"; } + die() { echo "$*" >&2; exit 1; } + node() { printf '{}'; } + kctl() { + if [ "$1" = patch ]; then + while [ "$#" -gt 0 ]; do + if [ "$1" = -p ]; then printf '%s' "$2" > "$CAPTURE"; break; fi + shift + done + fi + } + hcc_pr_a_enable_proxy +) +jq -e ' + .spec.template.spec as $pod | + $pod.securityContext.fsGroup==1000 and + (any($pod.volumes[]; .name=="churn-control" and .emptyDir=={})) and + (any($pod.containers[]; .name=="proxy" and + any(.volumeMounts[]; .name=="churn-control" and .mountPath=="/churn-ctl" and (.readOnly!=true)) and + any(.env[]; .name=="CHURN_DISABLED" and .value=="1"))) +' "$capture" >/dev/null +grep -Fq 'readOnlyRootFilesystem: true' "${ROOT}/scripts/e2e/_lib/hcc-watch-recovery-fixture.sh" +echo 'Selective stateless-cache containment shell contract: PASS' From b3aae3becdec0c51b880a477c86f60fe529f02b7 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 01:38:38 +0200 Subject: [PATCH 002/107] test(control-api): pin refresh rate-limit window --- control-api/package-lock.json | 4 ++-- control-api/package.json | 2 +- .../routes.mcp-host.renewal.refresh.rate-limit.test.ts | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/control-api/package-lock.json b/control-api/package-lock.json index c0d3f00d5f..efe6829486 100644 --- a/control-api/package-lock.json +++ b/control-api/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-control-api", - "version": "0.1.636", + "version": "0.1.637", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-control-api", - "version": "0.1.636", + "version": "0.1.637", "dependencies": { "@clerum/display-field": "file:../packages/display-field", "@clerum/grok-provider-attempt-contract": "file:../packages/grok-provider-attempt-contract", diff --git a/control-api/package.json b/control-api/package.json index 7bab7053e7..52a7ddeb8b 100644 --- a/control-api/package.json +++ b/control-api/package.json @@ -1,6 +1,6 @@ { "name": "clerum-control-api", - "version": "0.1.636", + "version": "0.1.637", "description": "Internal Clerum control plane REST API + UI for managing CRDs and secrets", "main": "dist/main.js", "engines": { diff --git a/control-api/test/routes.mcp-host.renewal.refresh.rate-limit.test.ts b/control-api/test/routes.mcp-host.renewal.refresh.rate-limit.test.ts index 5fdfcaa0d6..086f972f32 100644 --- a/control-api/test/routes.mcp-host.renewal.refresh.rate-limit.test.ts +++ b/control-api/test/routes.mcp-host.renewal.refresh.rate-limit.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import jwt from 'jsonwebtoken' import request from 'supertest' import { createApp } from '../src/app.js' @@ -63,6 +63,8 @@ vi.mock('../src/db.js', () => ({ describe('Workflow approval refresh rate limit', () => { let app: ReturnType + afterEach(() => vi.restoreAllMocks()) + beforeEach(() => { vi.clearAllMocks() rateLimitCounts.clear() @@ -72,6 +74,9 @@ describe('Workflow approval refresh rate limit', () => { it('does not consume a refresh token when the request is rate limited', async () => { const limit = config.approvalRlRefreshPerMin + // Pin the fixed-window clock before warm-up, including near a minute boundary. + const baseNow = Math.floor(Date.now() / 60_000) * 60_000 + 10_000 + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(baseNow) // Warm up exactly `limit` calls with unique refresh tokens — all should 200. for (let i = 0; i < limit; i++) { const { token } = issueMcpHostRefreshJwt('ns', 'recipe') @@ -94,8 +99,6 @@ describe('Workflow approval refresh rate limit', () => { // Rollover the 60s window so the limiter grants a fresh slot. The // rateLimiterService uses `Date.now()` internally — spy on it so we don't // actually wait a minute in the test. - const baseNow = Date.now() - const nowSpy = vi.spyOn(Date, 'now') nowSpy.mockReturnValue(baseNow + 60_001) try { From f7349f468f298a8d1e1cfc1421d1175ba8601eed Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 13:45:21 +0200 Subject: [PATCH 003/107] fix(desktop,hcc): fence destructive scope, distinguish host authority, and preserve template - HCC: keep applied template during unknown channel inventory, block suspension without changing deployment, and add wake capability to the containment gate - Desktop: fence chat deletion to the issuing binding generation and authority scope; distinguish confirmed host revocation from uncertain 401/403; clear drafts and tabs on scope changes; reverify access on fresh authorization - IPC: add scoped chat:delete contract with binding-generation fence - Gate: extend selective hold budget and wire wake capability - Tests: cover cold-start template preservation, cross-principal delete, and host authority classification --- .../src/__tests__/chatStoreBinding.test.ts | 23 ++ desktop-app/src/chatStoreBinding.ts | 13 + desktop-app/src/ipc.ts | 34 +- desktop-app/src/preload.ts | 5 +- desktop-app/src/renderer.d.ts | 3 +- desktop-app/src/types.ts | 6 + desktop-app/test/ipc.test.ts | 9 + desktop-app/ui/src/App.tsx | 15 +- .../AgentTaskTrackerContext/taskTracker.ts | 37 ++- .../contexts/AgentTaskTrackerContext/types.ts | 4 +- .../__fixtures__/controllerHarness.tsx | 6 +- .../__tests__/__fixtures__/mockClerum.ts | 2 + .../chatActions.characterization.test.tsx | 2 +- .../__tests__/chatListTitleMerge.test.tsx | 42 +-- .../domain/__tests__/reconcileChat.test.ts | 30 +- .../__tests__/renameSessionQueue.test.tsx | 2 +- .../__tests__/switchToChat.unified.test.tsx | 17 +- .../useAgentChatController.crossChat.test.tsx | 9 +- ...eAgentChatController.pendingModel.test.tsx | 7 +- ...PrewarmController.chatIntegration.test.tsx | 6 +- .../ui/src/hooks/domain/reconcileChat.ts | 10 +- .../hooks/domain/useAgentChatController.ts | 138 +++++--- .../src/hooks/domain/useChatListController.ts | 86 +++-- desktop-app/ui/src/hooks/useAppController.ts | 112 ++++++- desktop-app/ui/src/hooks/useChatStore.ts | 5 +- desktop-app/ui/src/lib/composerDraftStore.ts | 9 + host-context-controller/package-lock.json | 4 +- host-context-controller/package.json | 2 +- .../src/hostReconciler.test.ts | 4 +- host-context-controller/src/hostReconciler.ts | 111 ++++++- .../src/statelessLifecycle.types.ts | 5 +- .../src/statelessLifecycleExecutor.ts | 110 ++++--- .../test/hostReconciler.heartbeat.test.ts | 10 +- .../test/hostReconciler.lifecycle.test.ts | 303 +++++++++++++++++- .../test/hostReconciler.networkpolicy.test.ts | 4 +- .../test/hostReconciler.wake.test.ts | 20 +- 36 files changed, 958 insertions(+), 247 deletions(-) diff --git a/desktop-app/src/__tests__/chatStoreBinding.test.ts b/desktop-app/src/__tests__/chatStoreBinding.test.ts index 8a9b9f715e..d4bb93200c 100644 --- a/desktop-app/src/__tests__/chatStoreBinding.test.ts +++ b/desktop-app/src/__tests__/chatStoreBinding.test.ts @@ -5,7 +5,9 @@ import path from 'node:path' import { __setChatStoreBaseDirForTests, bindChatStoreForUser, + getChatStoreBindingGeneration, requireChatStore, + requireChatStoreForBindingGeneration, unbindChatStore, } from '../chatStoreBinding' @@ -39,6 +41,27 @@ describe('chatStoreBinding', () => { expect(() => requireChatStore()).toThrow(/Not authenticated/) }) + it('fences destructive work to the binding that issued it', async () => { + await bindChatStoreForUser('user-a', ENV_A) + const firstBinding = getChatStoreBindingGeneration() + expect(requireChatStoreForBindingGeneration(firstBinding)).toBe(requireChatStore()) + + // Refreshing the same user's binding does not invalidate their work. + await bindChatStoreForUser('user-a', ENV_A) + expect(getChatStoreBindingGeneration()).toBe(firstBinding) + + await bindChatStoreForUser('user-b', ENV_A) + const nextBinding = getChatStoreBindingGeneration() + expect(nextBinding).not.toBe(firstBinding) + expect(() => requireChatStoreForBindingGeneration(firstBinding)).toThrow( + /Chat store binding changed/ + ) + expect(requireChatStoreForBindingGeneration(nextBinding)).toBe(requireChatStore()) + + unbindChatStore() + expect(() => requireChatStoreForBindingGeneration(nextBinding)).toThrow() + }) + it('does not follow symlinked quarantine roots during retention cleanup', async () => { const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clerum-external-quarantine-')) const sentinel = path.join(externalDir, 'corrupt-old') diff --git a/desktop-app/src/chatStoreBinding.ts b/desktop-app/src/chatStoreBinding.ts index 0a7b61d26b..bea6616899 100644 --- a/desktop-app/src/chatStoreBinding.ts +++ b/desktop-app/src/chatStoreBinding.ts @@ -343,6 +343,19 @@ export function requireChatStore(): ChatStore { return activeChatStore } +/** Fences destructive IPC work to the store binding that issued the request. */ +export function getChatStoreBindingGeneration(): number { + requireChatStore() + return bindingGeneration +} + +export function requireChatStoreForBindingGeneration(expectedGeneration: number): ChatStore { + if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration !== bindingGeneration) { + throw new Error('Chat store binding changed') + } + return requireChatStore() +} + /** Test-only helper. Do not call from production code. */ export function __setChatStoreBaseDirForTests(base: string | null): void { baseDirOverride = base diff --git a/desktop-app/src/ipc.ts b/desktop-app/src/ipc.ts index 88b88ba2ea..1c458d8966 100644 --- a/desktop-app/src/ipc.ts +++ b/desktop-app/src/ipc.ts @@ -11,7 +11,11 @@ import { randomUUID } from 'node:crypto' import { promises as fs } from 'node:fs' import path from 'node:path' import { AppService } from './appService.js' -import { requireChatStore } from './chatStoreBinding.js' +import { + getChatStoreBindingGeneration, + requireChatStore, + requireChatStoreForBindingGeneration, +} from './chatStoreBinding.js' import { GFS_PREVIEW_MAX_BYTES } from './gfs/previewLimits.js' import { assertSafeRouteSegment } from './pathSafety.js' import { @@ -1420,14 +1424,32 @@ export function registerIpcHandlers(service: AppService): void { } ) - ipcMain.handle('chat:delete', async (event, payload: { agentRef: string; chatId: string }) => { + ipcMain.handle('chat:bindingGeneration', event => { assertTrustedSender(event) - const agentRef = sanitizeString(payload?.agentRef) - const chatId = sanitizeString(payload?.chatId) - if (!agentRef || !chatId) throw new Error('agentRef and chatId are required') - await requireChatStore().deleteChat(agentRef, chatId) + return getChatStoreBindingGeneration() }) + ipcMain.handle( + 'chat:delete', + async ( + event, + payload: { + version?: number + bindingGeneration?: number + agentRef: string + chatId: string + } + ) => { + assertTrustedSender(event) + const agentRef = sanitizeString(payload?.agentRef) + const chatId = sanitizeString(payload?.chatId) + if (!agentRef || !chatId) throw new Error('agentRef and chatId are required') + if (payload?.version !== 2) throw new Error('Scoped chat deletion is required') + const store = requireChatStoreForBindingGeneration(payload.bindingGeneration as number) + await store.deleteChat(agentRef, chatId) + } + ) + ipcMain.handle( 'chat:loadMessages', async ( diff --git a/desktop-app/src/preload.ts b/desktop-app/src/preload.ts index 79a047e4fe..595a671967 100644 --- a/desktop-app/src/preload.ts +++ b/desktop-app/src/preload.ts @@ -580,8 +580,9 @@ const clerum = Object.freeze({ ipcRenderer.invoke('chat:create', { agentRef, chatId }), rename: (agentRef: string, chatId: string, title: string) => ipcRenderer.invoke('chat:rename', { agentRef, chatId, title }), - delete: (agentRef: string, chatId: string) => - ipcRenderer.invoke('chat:delete', { agentRef, chatId }), + getBindingGeneration: () => ipcRenderer.invoke('chat:bindingGeneration'), + delete: (agentRef: string, chatId: string, bindingGeneration: number) => + ipcRenderer.invoke('chat:delete', { version: 2, agentRef, chatId, bindingGeneration }), loadMessages: (agentRef: string, chatId: string, limit?: number, offset?: number) => ipcRenderer.invoke('chat:loadMessages', { agentRef, chatId, limit, offset }), appendMessages: (agentRef: string, chatId: string, messages: unknown[]) => diff --git a/desktop-app/src/renderer.d.ts b/desktop-app/src/renderer.d.ts index 1b9ba4585f..f7b3c2486d 100644 --- a/desktop-app/src/renderer.d.ts +++ b/desktop-app/src/renderer.d.ts @@ -696,7 +696,8 @@ declare global { list: (agentRef: string) => Promise create: (agentRef: string, chatId: string) => Promise rename: (agentRef: string, chatId: string, title: string) => Promise - delete: (agentRef: string, chatId: string) => Promise + getBindingGeneration: () => Promise + delete: (agentRef: string, chatId: string, bindingGeneration: number) => Promise loadMessages: ( agentRef: string, chatId: string, diff --git a/desktop-app/src/types.ts b/desktop-app/src/types.ts index 21a233b14b..6c4120219d 100644 --- a/desktop-app/src/types.ts +++ b/desktop-app/src/types.ts @@ -916,6 +916,12 @@ export interface ChatIndex { chats: ChatMetadata[] /** Optional for older indexes. User-confirmed local deletions stay hidden from remote lists. */ deletedChatIds?: string[] + /** Logical deletions whose on-disk cache cleanup must be retried. */ + pendingCleanupChatIds?: string[] +} + +export interface ChatDeleteResult { + cleanupPending: boolean } /** Server-reported session lifecycle (D.1). `idle` once no task is in flight. */ diff --git a/desktop-app/test/ipc.test.ts b/desktop-app/test/ipc.test.ts index 7802f9f10a..439240e232 100644 --- a/desktop-app/test/ipc.test.ts +++ b/desktop-app/test/ipc.test.ts @@ -136,6 +136,15 @@ describe('ipc host status stream handlers', () => { registerIpcHandlers(service as never) }) + it('rejects legacy chat deletion without a binding fence', async () => { + const handler = testState.handlers.get('chat:delete') + expect(handler).toBeDefined() + const { event } = makeTrustedEvent() + await expect( + Promise.resolve(handler?.(event, { agentRef: 'agent-x', chatId: 'chat-1' })) + ).rejects.toThrow('Scoped chat deletion is required') + }) + it('rejects untrusted sender for stream start', async () => { const handler = testState.handlers.get('rpc:hostStatusStreamStart') expect(handler).toBeDefined() diff --git a/desktop-app/ui/src/App.tsx b/desktop-app/ui/src/App.tsx index 14be2aca84..51cac40513 100644 --- a/desktop-app/ui/src/App.tsx +++ b/desktop-app/ui/src/App.tsx @@ -318,6 +318,15 @@ export function App() { // The universal tab store lives in the controller (single writer; `vm.navItem` // is its projection). App composes the strip/reveal/reconcile over it. const workspaceTabs = vm.workspaceTabs + const visibleWorkspaceTabs = React.useMemo( + () => + workspaceTabs.tabs.map(tab => + tab.kind === 'chat' && tab.chat?.agentRef && vm.isHostAccessBlocked(tab.chat.agentRef) + ? { ...tab, title: 'Conversation unavailable' } + : tab + ), + [workspaceTabs.tabs, vm.hostAuthorityRevision, vm.isHostAccessBlocked] + ) const setWorkspaceTabs = vm.setWorkspaceTabs const nextChatTabId = vm.nextWorkspaceTabId const workspaceTabsRef = React.useRef(workspaceTabs) @@ -2437,8 +2446,8 @@ export function App() { // Its "current" chat is the controller's (selectedAgent, activeChatId) — NOT // the store's active tab, which is the app tab while the drawer is open. const chatWorkspaceTabs = React.useMemo( - () => workspaceTabs.tabs.filter((tab): tab is WorkspaceTab => tab.kind === 'chat'), - [workspaceTabs.tabs] + () => visibleWorkspaceTabs.filter((tab): tab is WorkspaceTab => tab.kind === 'chat'), + [visibleWorkspaceTabs] ) const drawerActiveChatTabId = chatWorkspaceTabs.find( @@ -2540,7 +2549,7 @@ export function App() { tab. Hidden only when the workspace is empty. */} {workspaceTabs.tabs.length > 0 && ( { + s.status = 'failed' + s.terminalResult = { + kind: 'error', + source: 'authority', + message: 'Host authorization rejected', + authority: isConfirmedHostAccessRevoked(error) ? 'revoked' : 'uncertain', + } + }) + await this.fireTerminal(key) + return + } // Preserve the authoritative terminal error if artifact retrieval fails. } if (this.states.get(key)?.taskId !== failedTaskId) return @@ -694,7 +712,7 @@ export class TaskTracker implements AgentTaskTracker { ...(attachments.length ? { attachments } : {}), } }) - } catch { + } catch (error) { // Same post-await identity guard on the failure branch — a replacement // during the await must not have its state overwritten with this error. if (this.states.get(key)?.taskId !== completedTaskId) return @@ -702,8 +720,17 @@ export class TaskTracker implements AgentTaskTracker { s.status = 'failed' s.terminalResult = { kind: 'error', - source: 'result_fetch', - message: 'Failed to retrieve task result after completion', + source: isAuthorizationError(error) ? 'authority' : 'result_fetch', + message: isAuthorizationError(error) + ? 'Host authorization rejected' + : 'Failed to retrieve task result after completion', + ...(isAuthorizationError(error) + ? { + authority: isConfirmedHostAccessRevoked(error) + ? ('revoked' as const) + : ('uncertain' as const), + } + : {}), } }) } diff --git a/desktop-app/ui/src/contexts/AgentTaskTrackerContext/types.ts b/desktop-app/ui/src/contexts/AgentTaskTrackerContext/types.ts index 9bc88667f8..5232a41aca 100644 --- a/desktop-app/ui/src/contexts/AgentTaskTrackerContext/types.ts +++ b/desktop-app/ui/src/contexts/AgentTaskTrackerContext/types.ts @@ -57,7 +57,9 @@ export type TaskTerminalResult = | { kind: 'reply'; content: string; attachments?: ChatMessageAttachment[] } | { kind: 'error' - source: 'failed' | 'result_fetch' | 'stream' + source: 'failed' | 'result_fetch' | 'stream' | 'authority' + /** A final task-result read denial; no raw response body crosses this boundary. */ + authority?: 'revoked' | 'uncertain' attachments?: ChatMessageAttachment[] message: string code?: string diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx index b6af0ba5ac..61005ad173 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx @@ -5,7 +5,8 @@ import { renderHook } from '@testing-library/react' import { useAgentChatController } from '../../useAgentChatController' const onHostAccessRevoked = () => {} -const isHostAccessRevoked = () => false +const onHostAuthorityUncertain = () => {} +const isHostAccessBlocked = () => false type ControllerParams = Parameters[0] @@ -55,7 +56,8 @@ export function renderController( loadMenuData: true, navItem: 'agents', onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, pushToast: spies.pushToast, pushNotification: spies.pushNotification, // Identity resolver by default (no catalog display layer in the harness). diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts index 125bb93925..44fd8435a9 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts @@ -21,6 +21,7 @@ interface ChatMock { list: Fn create: Fn rename: Fn + getBindingGeneration: Fn delete: Fn loadMessages: Fn appendMessages: Fn @@ -76,6 +77,7 @@ export function installMockClerum(): MockClerum { messageCount: 0, })), rename: vi.fn(async () => undefined), + getBindingGeneration: vi.fn(async () => 1), delete: vi.fn(async () => undefined), loadMessages: vi.fn(async () => []), appendMessages: vi.fn(async () => undefined), diff --git a/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx index 5624ea5c46..e6751a145e 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx @@ -65,7 +65,7 @@ describe('handleDeleteChat', () => { // ack tore down the SSE subscription (no handler left) so a late terminal // can't resurrect the deleted chat. expect(clerum.hasProgressHandler('task-del')).toBe(false) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', chatId) + expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', chatId, 1) // D.5 review #5: the deleted chat's session-state entry is dropped. expect(result.current.sessionStateByChatKey[key]).toBeUndefined() // It was the only/active chat → a fresh chat is created and selected. diff --git a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx index 18e0b3ae0c..165d810e0b 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx @@ -209,7 +209,7 @@ describe('confirmed deletion catalog protection', () => { describe('revoked host catalog protection', () => { it('hides a nonselected host after 403 and blocks direct reselection', async () => { - const revoked = new Set() + const blocked = new Set() clerum.chat.getIndex.mockResolvedValue(localIndex([])) clerum.rpc.listSessions.mockImplementation(async (agentRef: string) => agentRef === 'agent-y' @@ -219,8 +219,9 @@ describe('revoked host catalog protection', () => { const controller = renderController({ selectedAgent: 'agent-x', agentNames: ['agent-x', 'agent-y'], - onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAccessRevoked: agentRef => blocked.add(agentRef), + onHostAuthorityUncertain: agentRef => blocked.add(agentRef), + isHostAccessBlocked: agentRef => blocked.has(agentRef), }) await waitFor(() => expect(controller.result.current.latestChatSessions.some(s => s.id === 'protected-y')).toBe( @@ -233,8 +234,8 @@ describe('revoked host catalog protection', () => { controller.result.current.reconcileChat(makeTaskKey('agent-y', 'protected-y'), { reason: 'background_refresh', }) - ).resolves.toBe('revoked') - expect(revoked.has('agent-y')).toBe(true) + ).resolves.toBe('authority_unverified') + expect(blocked.has('agent-y')).toBe(true) await waitFor(() => expect(controller.result.current.latestChatSessions.some(s => s.id === 'protected-y')).toBe( false @@ -251,9 +252,9 @@ describe('revoked host catalog protection', () => { }) it.each([401, 403])( - 'hides cached sessions when the catalog explicitly returns %i', + 'hides cached sessions when the catalog returns an uncertain %i', async status => { - const revoked = new Set() + const blocked = new Set() clerum.chat.getIndex.mockResolvedValue( localIndex([{ id: 'protected-a', title: 'Cached protected chat' }]) ) @@ -261,11 +262,12 @@ describe('revoked host catalog protection', () => { const { result } = renderController({ selectedAgent: null, agentNames: ['agent-a'], - onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAccessRevoked: agentRef => blocked.add(agentRef), + onHostAuthorityUncertain: agentRef => blocked.add(agentRef), + isHostAccessBlocked: agentRef => blocked.has(agentRef), }) - await waitFor(() => expect(revoked.has('agent-a')).toBe(true)) + await waitFor(() => expect(blocked.has('agent-a')).toBe(true)) expect(result.current.latestChatSessions.some(chat => chat.agentRef === 'agent-a')).toBe( false ) @@ -273,7 +275,7 @@ describe('revoked host catalog protection', () => { ) it('retains cached sessions when a 503 body merely mentions 403', async () => { - const revoked = new Set() + const blocked = new Set() clerum.chat.getIndex.mockResolvedValue(localIndex([{ id: 'cached-a', title: 'Cached chat' }])) clerum.rpc.listSessions.mockRejectedValue( new Error('503 Service Unavailable: upstream body mentioned 403') @@ -281,14 +283,15 @@ describe('revoked host catalog protection', () => { const { result } = renderController({ selectedAgent: null, agentNames: ['agent-a'], - onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAccessRevoked: agentRef => blocked.add(agentRef), + onHostAuthorityUncertain: agentRef => blocked.add(agentRef), + isHostAccessBlocked: agentRef => blocked.has(agentRef), }) await waitFor(() => expect(result.current.latestChatSessions.some(chat => chat.id === 'cached-a')).toBe(true) ) await waitFor(() => expect(clerum.rpc.listSessions).toHaveBeenCalled()) - expect(revoked.has('agent-a')).toBe(false) + expect(blocked.has('agent-a')).toBe(false) expect(result.current.latestChatSessions.some(chat => chat.id === 'cached-a')).toBe(true) }) }) @@ -297,7 +300,7 @@ describe('late global catalog response', () => { it.each(['revocation', 'confirmed deletion'])( 'does not republish Host A after %s while Host B is pending', async action => { - const revoked = new Set() + const blocked = new Set() const deliveredA = deferred() const pendingB = deferred() clerum.chat.getIndex.mockResolvedValue(localIndex([])) @@ -311,8 +314,9 @@ describe('late global catalog response', () => { const controller = renderController({ selectedAgent: null, agentNames: ['agent-a', 'agent-b'], - onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAccessRevoked: agentRef => blocked.add(agentRef), + onHostAuthorityUncertain: agentRef => blocked.add(agentRef), + isHostAccessBlocked: agentRef => blocked.has(agentRef), }) await deliveredA.promise await waitFor(() => @@ -329,12 +333,12 @@ describe('late global catalog response', () => { reason: 'background_refresh', }) }) - expect(revoked.has('agent-a')).toBe(true) + expect(blocked.has('agent-a')).toBe(true) } else { await act(async () => { await controller.result.current.handleDeleteChatForAgent('agent-a', 'session-a') }) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-a', 'session-a') + expect(clerum.chat.delete).toHaveBeenCalledWith('agent-a', 'session-a', 1) } await act(async () => { diff --git a/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts b/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts index 6d01c34a38..f8d3da40cc 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts +++ b/desktop-app/ui/src/hooks/domain/__tests__/reconcileChat.test.ts @@ -38,9 +38,12 @@ function buildDeps(overrides: Partial = {}): ReconcileChatDep attachLiveTask: vi.fn(async () => 'reconcile_rejoined' as const), settleIdle: vi.fn(async () => 'fell_through_to_resend' as const), revokeAccess: vi.fn(), + holdAccess: vi.fn(), isNetworkError: (e): e is NetErr => e instanceof NetErr, isHttp404: (e): e is NotFound => e instanceof NotFound, isAuthorizationError: (e): e is Unauthorized => e instanceof Unauthorized, + isConfirmedHostAccessRevoked: e => + e instanceof Unauthorized && e.message === 'verified-host-403', telemetry: vi.fn(), networkRetryBackoffMs: [0, 0, 0], ...overrides, @@ -100,7 +103,7 @@ describe('reconcileChat — precedence branches', () => { expect(deps.fsm.getState(chatKey)).toBeDefined() }) - it.each([401, 403])('%i revokes access and resets protected conversation state', async status => { + it.each([401, 403])('%i holds protected data pending renewed authority', async status => { const deps = buildDeps({ loadSessionMessages: vi.fn(async () => { throw new Unauthorized(String(status)) @@ -108,11 +111,26 @@ describe('reconcileChat — precedence branches', () => { }) deps.fsm.dispatch(chatKey, { type: 'SEND_STARTED', taskId: 't1' }) const reconcile = createReconcileChat(deps) - await reconcile(chatKey, { reason: 'user_refresh' }) - expect(deps.revokeAccess).toHaveBeenCalledWith(chatKey) + await expect(reconcile(chatKey, { reason: 'user_refresh' })).resolves.toBe( + 'authority_unverified' + ) + expect(deps.holdAccess).toHaveBeenCalledWith(chatKey) + expect(deps.revokeAccess).not.toHaveBeenCalled() expect(deps.fsm.getState(chatKey)).toBeUndefined() }) + it('revokes immediately for a verified Host denial', async () => { + const deps = buildDeps({ + loadSessionMessages: vi.fn(async () => { + throw new Unauthorized('verified-host-403') + }), + }) + const reconcile = createReconcileChat(deps) + await expect(reconcile(chatKey, { reason: 'user_refresh' })).resolves.toBe('revoked') + expect(deps.revokeAccess).toHaveBeenCalledWith(chatKey) + expect(deps.holdAccess).not.toHaveBeenCalled() + }) + it('exhausted network retries → WENT_OFFLINE + offline outcome', async () => { const load = vi.fn(async () => { throw new NetErr('down') @@ -276,7 +294,7 @@ describe('reconcileChat — single-flight', () => { expect(deps.fsm.getState(chatKey)).toBeUndefined() }) - it('revokes a Host when an old chat read returns 403 after selection moves to another chat', async () => { + it('holds a Host when an old chat read returns generic 403 after selection moves', async () => { let rejectLoad: (reason: unknown) => void = () => {} let firstChatSelected = true const deps = buildDeps({ @@ -295,8 +313,8 @@ describe('reconcileChat — single-flight', () => { firstChatSelected = false rejectLoad(new Unauthorized('403 forbidden')) - await expect(pending).resolves.toBe('revoked') - expect(deps.revokeAccess).toHaveBeenCalledWith(chatKey) + await expect(pending).resolves.toBe('authority_unverified') + expect(deps.holdAccess).toHaveBeenCalledWith(chatKey) }) it('ignores a late 403 after reset tears down the principal scope', async () => { diff --git a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx index 34f2af9bc7..036ed97cd0 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx @@ -159,7 +159,7 @@ describe('rename pending queue (spec 15 §2.5)', () => { selectedAgent: 'agent-x', agentNames: ['agent-x'], onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await waitFor(() => expect(titleInList(result.current, 'c1')).toBe('old')) await act(async () => { diff --git a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx index 26513af6cb..447885633d 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx @@ -1027,7 +1027,8 @@ describe('switchToChat (unified, D.4)', () => { ) const { result } = renderController({ onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => revoked.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await settleMount() await act(async () => { @@ -1062,7 +1063,8 @@ describe('switchToChat (unified, D.4)', () => { const controller = renderController({ currentTeamId: 'team-before', onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => revoked.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await settleMount() await act(async () => { @@ -1108,7 +1110,8 @@ describe('switchToChat (unified, D.4)', () => { const controller = renderController({ currentTeamId: 'team-before', onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => revoked.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await settleMount() await act(async () => { @@ -1138,7 +1141,8 @@ describe('switchToChat (unified, D.4)', () => { clerum.rpc.getTaskResult.mockRejectedValue(new Error(`${status} response`)) const { result } = renderController({ onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => revoked.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await settleMount() await act(async () => { @@ -1178,7 +1182,8 @@ describe('switchToChat (unified, D.4)', () => { const controller = renderController({ currentTeamId: 'team-before', onHostAccessRevoked: agentRef => revoked.add(agentRef), - isHostAccessRevoked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => revoked.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await settleMount() await act(async () => { @@ -1206,7 +1211,7 @@ describe('switchToChat (unified, D.4)', () => { await act(async () => { await controller.result.current.handleDeleteChatForAgent('agent-x', 'late-result') }) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', 'late-result') + expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', 'late-result', 1) } else { controller.rerender({ currentTeamId: 'team-after' }) } diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx index eda3cc67be..456dcb4715 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx @@ -20,7 +20,10 @@ const revokedAgents = new Set() const onHostAccessRevoked = (agentRef: string) => { revokedAgents.add(agentRef) } -const isHostAccessRevoked = (agentRef: string) => revokedAgents.has(agentRef) +const onHostAuthorityUncertain = (agentRef: string) => { + revokedAgents.add(agentRef) +} +const isHostAccessBlocked = (agentRef: string) => revokedAgents.has(agentRef) function getDraftInputValue(): string { return (screen.getByTestId('draft-input') as HTMLInputElement).value @@ -68,6 +71,7 @@ function installClerumHarness() { return meta }), rename: vi.fn(async () => undefined), + getBindingGeneration: vi.fn(async () => 1), delete: vi.fn(async (_agentRef: string, chatId: string) => { deletedChatIds.add(chatId) const index = chats.findIndex(chat => chat.id === chatId) @@ -134,7 +138,8 @@ function AgentChatHarness() { loadMenuData: true, navItem: 'chat', onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx index d1d996dcf6..a0aca4cf9a 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx @@ -20,7 +20,8 @@ import type { TaskProgressStreamEvent } from '../../../../../src/types' type ProgressHandler = (event: TaskProgressStreamEvent) => void | Promise const onHostAccessRevoked = () => {} -const isHostAccessRevoked = () => false +const onHostAuthorityUncertain = () => {} +const isHostAccessBlocked = () => false function createChatMeta(chatId: string) { const now = new Date().toISOString() @@ -65,6 +66,7 @@ function installClerumHarness() { return meta }), rename: vi.fn(async () => undefined), + getBindingGeneration: vi.fn(async () => 1), delete: vi.fn(async () => undefined), loadMessages: vi.fn( async (_agentRef: string, chatId: string) => messagesByChat.get(chatId) || [] @@ -124,7 +126,8 @@ function AgentChatHarness() { loadMenuData: true, navItem: 'chat', onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx index 8f000b5c6d..d98ebe7135 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx @@ -82,7 +82,8 @@ interface HarnessProps { navItem: NavItem } const onHostAccessRevoked = () => {} -const isHostAccessRevoked = () => false +const onHostAuthorityUncertain = () => {} +const isHostAccessBlocked = () => false /** Both real hooks, mounted together under the task-tracker the chat controller needs. */ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navItem }: HarnessProps) { @@ -96,7 +97,8 @@ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navIte loadMenuData: true, navItem, onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, ...stableCallbacks, }) } diff --git a/desktop-app/ui/src/hooks/domain/reconcileChat.ts b/desktop-app/ui/src/hooks/domain/reconcileChat.ts index 9302de791d..cbd395fe2d 100644 --- a/desktop-app/ui/src/hooks/domain/reconcileChat.ts +++ b/desktop-app/ui/src/hooks/domain/reconcileChat.ts @@ -31,6 +31,7 @@ export type ReconcileOutcome = | 'fell_through_to_resend' | 'stale_drop' | 'revoked' + | 'authority_unverified' | 'offline' | 'error' | 'noop' @@ -79,9 +80,11 @@ export interface ReconcileChatDeps { ) => Promise /** Hide protected data after an explicit authorization rejection. */ revokeAccess: (chatKey: string) => void + holdAccess: (chatKey: string) => void isNetworkError: (err: unknown) => boolean isHttp404: (err: unknown) => boolean isAuthorizationError: (err: unknown) => boolean + isConfirmedHostAccessRevoked: (err: unknown) => boolean telemetry: (event: string, data: Record) => void /** Guard: `false` aborts the reconcile mid-flight (chat switched away). */ isStillRelevant?: (chatKey: string) => boolean @@ -225,9 +228,11 @@ export function createReconcileChat(deps: ReconcileChatDeps): ReconcileChat { // from chat A to B while A's request was pending. The reset generation is // the principal/team boundary; an old scope cannot revoke a new one. if (deps.isAuthorizationError(err) && generation === startGeneration) { - deps.revokeAccess(chatKey) + const confirmed = deps.isConfirmedHostAccessRevoked(err) + if (confirmed) deps.revokeAccess(chatKey) + else deps.holdAccess(chatKey) deps.fsm.dispatch(chatKey, { type: 'RESET' }) - outcome = 'revoked' + outcome = confirmed ? 'revoked' : 'authority_unverified' } else if (!stillRelevant()) { outcome = 'stale_drop' return outcome @@ -253,6 +258,7 @@ export function createReconcileChat(deps: ReconcileChatDeps): ReconcileChat { // resurrect an empty entry, so skip the finalizer in that case. if ( outcome !== 'revoked' && + outcome !== 'authority_unverified' && generation === startGeneration && (chatGenerations.get(chatKey) ?? 0) === startChatGeneration ) { diff --git a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts index 8b2590a991..119780a415 100644 --- a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts +++ b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts @@ -12,6 +12,7 @@ import { } from '@lib/chatMessageAttachments' import { truncateTitle } from '@lib/chatTitle' import { + clearAllComposerDrafts, getComposerDraft, getComposerDraftRevision, setComposerDraft, @@ -359,7 +360,8 @@ interface UseAgentChatControllerParams { ) => Promise decideApprovalFromNotification: (target: AgentApprovalNotificationTarget) => Promise onHostAccessRevoked: (agentRef: string) => void - isHostAccessRevoked: (agentRef: string) => boolean + onHostAuthorityUncertain: (agentRef: string) => void + isHostAccessBlocked: (agentRef: string) => boolean } export function useAgentChatController({ @@ -379,7 +381,8 @@ export function useAgentChatController({ openAgentConversationFromNotification, decideApprovalFromNotification, onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, }: UseAgentChatControllerParams) { const chatStore = useChatStore() const authenticatedScope = `${currentUserId ?? 'unknown-user'}:${currentTeamId}` @@ -409,9 +412,15 @@ export function useAgentChatController({ // activeChatId) are injected through `chatListHostRef`, filled each render once // those parent callbacks are defined (created here so the hook can receive it). const chatListHostRef = useRef(null) - const revokeHostAccessRef = useRef<((agentRef: string) => void) | null>(null) + const hideHostAccessRef = useRef< + ((agentRef: string, kind: 'revoked' | 'uncertain') => void) | null + >(null) const onCatalogHostAccessRevoked = useCallback( - (agentRef: string) => revokeHostAccessRef.current!(agentRef), + (agentRef: string) => hideHostAccessRef.current!(agentRef, 'revoked'), + [] + ) + const onCatalogHostAuthorityUncertain = useCallback( + (agentRef: string) => hideHostAccessRef.current!(agentRef, 'uncertain'), [] ) const autoSelectedChatIdRef = useRef(null) @@ -427,8 +436,9 @@ export function useAgentChatController({ chatStore, fsm, host: chatListHostRef, - isHostAccessRevoked, + isHostAccessBlocked, onHostAccessRevoked: onCatalogHostAccessRevoked, + onHostAuthorityUncertain: onCatalogHostAuthorityUncertain, }) const { chatList, @@ -526,6 +536,7 @@ export function useAgentChatController({ const scope = `${isAuthenticated}:${authenticatedScope}` if (lastRetentionScope.current === scope) return lastRetentionScope.current = scope + clearAllComposerDrafts() sendScopeGeneration.current += 1 agentSendSetupOwnerRef.current = null agentSendInFlightRef.current = false @@ -575,6 +586,7 @@ export function useAgentChatController({ attachLiveTask: ReconcileChatDeps['attachLiveTask'] settleIdle: ReconcileChatDeps['settleIdle'] revokeAccess: ReconcileChatDeps['revokeAccess'] + holdAccess: ReconcileChatDeps['holdAccess'] } | null>(null) const reconcileChatRef = useRef(null) if (!reconcileChatRef.current) { @@ -587,9 +599,11 @@ export function useAgentChatController({ settleIdle: (chatKey, resp, epoch, hint, stillRelevant) => reconcileBranchesRef.current!.settleIdle(chatKey, resp, epoch, hint, stillRelevant), revokeAccess: chatKey => reconcileBranchesRef.current!.revokeAccess(chatKey), + holdAccess: chatKey => reconcileBranchesRef.current!.holdAccess(chatKey), isNetworkError, isHttp404, isAuthorizationError, + isConfirmedHostAccessRevoked, telemetry: (event, data) => console.log(`[telemetry] ${event}`, data), }) } @@ -830,6 +844,7 @@ export function useAgentChatController({ }, []) const resetChat = useCallback(() => { + clearAllComposerDrafts() setActivityByAgentMessage({}) setProgressByAgentMessage({}) resetComposerAttachments() @@ -884,7 +899,7 @@ export function useAgentChatController({ // chat, and a task in flight is rejoined via the tracker D.3 already mounts. const switchToChat = useCallback( async (agentRef: string, chatId: string) => { - if (isHostAccessRevoked(agentRef) || isChatDeleted(agentRef, chatId)) return + if (isHostAccessBlocked(agentRef) || isChatDeleted(agentRef, chatId)) return const switchRequest = Symbol(`${agentRef}:${chatId}`) activeChatSwitchRequestRef.current = switchRequest const key = makeTaskKey(agentRef, chatId) @@ -915,7 +930,7 @@ export function useAgentChatController({ setAgentError(null) setFailedAgentSend(null) await chatStore.setLastActive(agentRef, chatId) - if (isHostAccessRevoked(agentRef) || isChatDeleted(agentRef, chatId)) return + if (isHostAccessBlocked(agentRef) || isChatDeleted(agentRef, chatId)) return unfillableServerGapUpperBoundsRef.current.delete(key) @@ -1002,7 +1017,7 @@ export function useAgentChatController({ }, [ currentTeamId, - isHostAccessRevoked, + isHostAccessBlocked, isChatDeleted, dispatchSession, tracker, @@ -1049,14 +1064,14 @@ export function useAgentChatController({ async (chatId: string) => { if ( !selectedAgent || - isHostAccessRevoked(selectedAgent) || + isHostAccessBlocked(selectedAgent) || isChatDeleted(selectedAgent, chatId) ) return autoSelectedChatIdRef.current = null await switchToChat(selectedAgent, chatId) }, - [selectedAgent, isHostAccessRevoked, isChatDeleted, switchToChat] + [selectedAgent, isHostAccessBlocked, isChatDeleted, switchToChat] ) const handleLoadOlderMessages = useCallback(async () => { @@ -1167,9 +1182,12 @@ export function useAgentChatController({ isAuthorizationError(error) && requestScope === sendScopeGeneration.current && requestScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(requestAgent) + !isHostAccessBlocked(requestAgent) ) { - revokeHostAccessRef.current!(requestAgent) + hideHostAccessRef.current!( + requestAgent, + isConfirmedHostAccessRevoked(error) ? 'revoked' : 'uncertain' + ) return } console.warn('[chat-history] failed to load older messages', { @@ -1192,13 +1210,13 @@ export function useAgentChatController({ ignoredServerGapUpperBounds, markServerGapUnfillable, selectedAgent, - isHostAccessRevoked, + isHostAccessBlocked, tracker, ]) // Agent selection → load chats useEffect(() => { - if (!selectedAgent || isHostAccessRevoked(selectedAgent)) { + if (!selectedAgent || isHostAccessBlocked(selectedAgent)) { autoSelectedChatIdRef.current = null activeChatVisibilityRef.current = { ...activeChatVisibilityRef.current, @@ -1256,7 +1274,7 @@ export function useAgentChatController({ setChatListLoading(true) ;(async () => { const result = await loadChatList(selectedAgent) - if (cancelled || isHostAccessRevoked(selectedAgent)) return + if (cancelled || isHostAccessBlocked(selectedAgent)) return setChatListLoading(false) if (!result) { setChatMessagesLoading(false) @@ -1320,7 +1338,7 @@ export function useAgentChatController({ return () => { cancelled = true } - }, [cancelOlderMessagesLoad, currentTeamId, navItem, selectedAgent, isHostAccessRevoked]) + }, [cancelOlderMessagesLoad, currentTeamId, navItem, selectedAgent, isHostAccessBlocked]) const mapComposerAttachmentsToHostRequest = ( attachments: ComposerImageAttachment[] @@ -1495,7 +1513,7 @@ export function useAgentChatController({ const mayAppend = () => messageScope === sendScopeGeneration.current && messageScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(agentName) && + !isHostAccessBlocked(agentName) && (!chatId || !isChatDeleted(agentName, chatId)) if (!mayAppend()) return // Update the in-memory view FIRST (synchronously), then persist. For a @@ -1525,7 +1543,7 @@ export function useAgentChatController({ // notification delivery is best-effort } }, - [bumpActivity, chatStore, pushAssistantReplyNotification, isHostAccessRevoked, isChatDeleted] + [bumpActivity, chatStore, pushAssistantReplyNotification, isHostAccessBlocked, isChatDeleted] ) // ─── reconcileChat branch callbacks (§4.3) ─── @@ -1563,7 +1581,7 @@ export function useAgentChatController({ return v.selectedAgent === agentRef && v.activeChatId === chatId } const mayHydrate = () => - stillRelevant() && !isHostAccessRevoked(agentRef) && !isChatDeleted(agentRef, chatId) + stillRelevant() && !isHostAccessBlocked(agentRef) && !isChatDeleted(agentRef, chatId) const cached = (await chatStore .loadMessages(agentRef, chatId, LOCAL_MESSAGE_PAGE_SIZE) .catch(error => { @@ -1680,7 +1698,7 @@ export function useAgentChatController({ }, [ tracker, - isHostAccessRevoked, + isHostAccessBlocked, isChatDeleted, chatStore.loadMessages, chatStore.createChat, @@ -1847,8 +1865,8 @@ export function useAgentChatController({ [fsm, tracker, hydrateActiveChatFromServer, appendAssistantMessage] ) - const revokeHostAccess = useCallback( - (agentRef: string) => { + const hideHostAccess = useCallback( + (agentRef: string, kind: 'revoked' | 'uncertain') => { for (const chatKey of Object.keys(fsm.getSnapshot())) { if (parseTaskKey(chatKey).agentRef !== agentRef) continue tracker.release(chatKey as TaskKey) @@ -1888,7 +1906,8 @@ export function useAgentChatController({ setHasOlderMessages(false) cancelOlderMessagesLoad() } - onHostAccessRevoked(agentRef) + if (kind === 'revoked') onHostAccessRevoked(agentRef) + else onHostAuthorityUncertain(agentRef) }, [ tracker, @@ -1899,11 +1918,20 @@ export function useAgentChatController({ resetComposerAttachments, cancelOlderMessagesLoad, onHostAccessRevoked, + onHostAuthorityUncertain, ] ) + const revokeHostAccess = useCallback( + (agentRef: string) => hideHostAccess(agentRef, 'revoked'), + [hideHostAccess] + ) + const holdHostAccess = useCallback( + (agentRef: string) => hideHostAccess(agentRef, 'uncertain'), + [hideHostAccess] + ) useEffect(() => { - revokeHostAccessRef.current = revokeHostAccess - }, [revokeHostAccess]) + hideHostAccessRef.current = hideHostAccess + }, [hideHostAccess]) const reconcileRevokeAccess = useCallback( chatKey => { @@ -1912,6 +1940,13 @@ export function useAgentChatController({ }, [revokeHostAccess] ) + const reconcileHoldAccess = useCallback( + chatKey => { + const { agentRef } = parseTaskKey(chatKey) + holdHostAccess(agentRef) + }, + [holdHostAccess] + ) useEffect(() => { reconcileBranchesRef.current = { @@ -2021,6 +2056,7 @@ export function useAgentChatController({ attachLiveTask: reconcileAttachLiveTask, settleIdle: reconcileSettleIdle, revokeAccess: reconcileRevokeAccess, + holdAccess: reconcileHoldAccess, } }, [ chatStore.loadSessionMessages, @@ -2028,6 +2064,7 @@ export function useAgentChatController({ reconcileAttachLiveTask, reconcileSettleIdle, reconcileRevokeAccess, + reconcileHoldAccess, ]) // ─── Tracker callbacks + subscription (the post-D.3 fire & forget glue) ─── @@ -2040,11 +2077,22 @@ export function useAgentChatController({ const terminalStillAuthorized = () => terminalScope === sendScopeGeneration.current && terminalScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(agentRef) && + !isHostAccessBlocked(agentRef) && !isChatDeleted(agentRef, chatId) // `task_duration_seconds` telemetry is now emitted by the coordinator // (`fireTerminal`, §4.8) — the lifecycle owner — so it is not duplicated here. const result = state.terminalResult + if (result?.kind === 'error' && result.source === 'authority') { + if ( + terminalScope === sendScopeGeneration.current && + terminalScopeIdentity === currentAuthScopeRef.current && + !isHostAccessBlocked(agentRef) + ) { + if (result.authority === 'revoked') revokeHostAccess(agentRef) + else holdHostAccess(agentRef) + } + return + } // ── stream-recovery: a lost progress stream is NOT a task failure ── // The task is durable server-side (D.1/T2.1), so before surfacing the scary @@ -2133,7 +2181,7 @@ export function useAgentChatController({ const mayRecover = () => terminalScope === sendScopeGeneration.current && terminalScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(agentRef) && + !isHostAccessBlocked(agentRef) && !isChatDeleted(agentRef, chatId) let taskResult: Awaited> try { @@ -2143,9 +2191,12 @@ export function useAgentChatController({ if ( terminalScope === sendScopeGeneration.current && terminalScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(agentRef) + !isHostAccessBlocked(agentRef) ) { - revokeHostAccessRef.current!(agentRef) + hideHostAccessRef.current!( + agentRef, + isConfirmedHostAccessRevoked(error) ? 'revoked' : 'uncertain' + ) } return true } @@ -2340,6 +2391,7 @@ export function useAgentChatController({ setIdle() break case 'revoked': + case 'authority_unverified': // The authorization rejection hid the protected chat + reset the FSM. dropActivity() break @@ -2490,8 +2542,10 @@ export function useAgentChatController({ updateMessageProgress, updateMessageActivity, reconcileChat, - isHostAccessRevoked, + isHostAccessBlocked, isChatDeleted, + revokeHostAccess, + holdHostAccess, ] ) @@ -2642,7 +2696,7 @@ export function useAgentChatController({ const effectiveReferences = [...references] if ( !selectedAgent || - isHostAccessRevoked(selectedAgent) || + isHostAccessBlocked(selectedAgent) || (!trimmedContent && !effectiveAttachments.length && !effectiveReferences.length) ) return @@ -2695,7 +2749,7 @@ export function useAgentChatController({ const sendStillAuthorized = () => sendScope === sendScopeGeneration.current && sendScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(sendAgent) && + !isHostAccessBlocked(sendAgent) && (!sendChatId || !isChatDeleted(sendAgent, sendChatId)) // Captured for the retention snapshot: the model this attempt actually // asked for (issue #654), readable from the catch below. @@ -3064,12 +3118,16 @@ export function useAgentChatController({ isAuthorizationError(error) && sendScope === sendScopeGeneration.current && sendScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(sendAgent) + !isHostAccessBlocked(sendAgent) ) { - if (isConfirmedHostAccessRevoked(error) || !isHttp403(error)) { + if (isConfirmedHostAccessRevoked(error)) { revokeHostAccess(sendAgent) return } + if (!isHttp403(error)) { + holdHostAccess(sendAgent) + return + } try { await chatStore.listSessions(sendAgent, { agent: sendAgent, limit: 1 }, { force: true }) } catch (readError) { @@ -3077,9 +3135,10 @@ export function useAgentChatController({ isAuthorizationError(readError) && sendScope === sendScopeGeneration.current && sendScopeIdentity === currentAuthScopeRef.current && - !isHostAccessRevoked(sendAgent) + !isHostAccessBlocked(sendAgent) ) { - revokeHostAccess(sendAgent) + if (isConfirmedHostAccessRevoked(readError)) revokeHostAccess(sendAgent) + else holdHostAccess(sendAgent) return } } @@ -3160,9 +3219,10 @@ export function useAgentChatController({ }, [ selectedAgent, - isHostAccessRevoked, + isHostAccessBlocked, isChatDeleted, revokeHostAccess, + holdHostAccess, activeChatId, composerImageAttachments, composerReferenceAttachments, @@ -3364,7 +3424,7 @@ export function useAgentChatController({ isRemote?: boolean } = {} ) => { - if (isHostAccessRevoked(agentName) || (chatId && isChatDeleted(agentName, chatId))) return + if (isHostAccessBlocked(agentName) || (chatId && isChatDeleted(agentName, chatId))) return if (options.selectLatest) { writePendingSelection(agentName, { mode: 'latest', chatId: null }) return @@ -3401,7 +3461,7 @@ export function useAgentChatController({ currentTeamId, writePendingSelection, upsertProvisionalEntry, - isHostAccessRevoked, + isHostAccessBlocked, isChatDeleted, ] ) diff --git a/desktop-app/ui/src/hooks/domain/useChatListController.ts b/desktop-app/ui/src/hooks/domain/useChatListController.ts index 41f0bd2d51..24436ad280 100644 --- a/desktop-app/ui/src/hooks/domain/useChatListController.ts +++ b/desktop-app/ui/src/hooks/domain/useChatListController.ts @@ -101,8 +101,9 @@ interface UseChatListControllerParams { chatStore: ReturnType fsm: SessionFsmStore host: MutableRefObject - isHostAccessRevoked: (agentRef: string) => boolean + isHostAccessBlocked: (agentRef: string) => boolean onHostAccessRevoked: (agentRef: string) => void + onHostAuthorityUncertain: (agentRef: string) => void } function sortableTimestamp(value: string): number { @@ -165,8 +166,9 @@ export function useChatListController({ chatStore, fsm, host, - isHostAccessRevoked, + isHostAccessBlocked, onHostAccessRevoked, + onHostAuthorityUncertain, }: UseChatListControllerParams) { // Per-agent list for the SELECTED agent (the sidebar's chat list). const [chatList, setChatList] = useState([]) @@ -186,6 +188,8 @@ export function useChatListController({ const deletedChatIdsByAgentRef = useRef(new Map>()) const requestGenerationRef = useRef(0) const authorityScopeGenerationRef = useRef(0) + const currentAuthorityScopeRef = useRef(`${isAuthenticated}:${scopeKey}`) + currentAuthorityScopeRef.current = `${isAuthenticated}:${scopeKey}` // Spec 15 §2.5 (B21) — pending-rename queue. A user rename is optimistic-local // first, then synced by RPC; while unconfirmed the local title wins over the @@ -281,7 +285,7 @@ export function useChatListController({ const upsertLatestChatSession = useCallback( (agentRef: string, chat: SidebarChatEntry) => { if ( - isHostAccessRevoked(agentRef) || + isHostAccessBlocked(agentRef) || deletedChatIdsByAgentRef.current.get(agentRef)?.has(chat.id) ) return @@ -294,7 +298,7 @@ export function useChatListController({ return next.sort(byUpdatedDesc) }) }, - [isHostAccessRevoked] + [isHostAccessBlocked] ) const hideAgent = useCallback((agentRef: string) => { @@ -324,7 +328,7 @@ export function useChatListController({ if ( selectedAgentRef.current !== agentRef || requestGenerationRef.current !== requestGeneration || - isHostAccessRevoked(agentRef) + isHostAccessBlocked(agentRef) ) { return { index, merged: [...index.chats].sort(byUpdatedDesc) } } @@ -346,16 +350,17 @@ export function useChatListController({ if ( isAuthorizationError(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(agentRef) + !isHostAccessBlocked(agentRef) ) { - onHostAccessRevoked(agentRef) + if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) + else onHostAuthorityUncertain(agentRef) } return } if ( selectedAgentRef.current !== agentRef || requestGenerationRef.current !== requestGeneration || - isHostAccessRevoked(agentRef) + isHostAccessBlocked(agentRef) ) { if ( suppressionMarkerAtRequest !== undefined && @@ -388,7 +393,7 @@ export function useChatListController({ // "Remote ·" label, no isRemote branch; switchToChat's unified path // hydrates them. setChatList(previous => { - if (isHostAccessRevoked(agentRef)) return [] + if (isHostAccessBlocked(agentRef)) return [] const visibleSessions = serverSessions.filter( session => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) ) @@ -489,7 +494,7 @@ export function useChatListController({ chatStore.listSessions, chatStore.reconcileServerSessions, fsm, - isHostAccessRevoked, + isHostAccessBlocked, onHostAccessRevoked, ] ) @@ -521,9 +526,10 @@ export function useChatListController({ if ( isAuthorizationError(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(agentRef) + !isHostAccessBlocked(agentRef) ) { - onHostAccessRevoked(agentRef) + if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) + else onHostAuthorityUncertain(agentRef) return } if ( @@ -544,13 +550,13 @@ export function useChatListController({ const serverSessions = serverResult.items.filter( s => s.agent === agentRef && !deletedChatIdsByAgentRef.current.get(agentRef)?.has(s.chatId) ) - if (selectedAgentRef.current !== agentRef || isHostAccessRevoked(agentRef)) return + if (selectedAgentRef.current !== agentRef || isHostAccessBlocked(agentRef)) return chatListNextCursorByAgentRef.current[agentRef] = serverResult.nextCursor ?? null setChatListHasMoreRemoteSessions(Boolean(serverResult.nextCursor)) seedSessionSnapshots(fsm, agentRef, serverSessions) setChatList(previous => { - if (isHostAccessRevoked(agentRef)) return [] + if (isHostAccessBlocked(agentRef)) return [] const visibleSessions = serverSessions.filter( session => !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) ) @@ -612,7 +618,7 @@ export function useChatListController({ chatStore.listSessions, chatStore.reconcileServerSessions, fsm, - isHostAccessRevoked, + isHostAccessBlocked, onHostAccessRevoked, ]) @@ -835,8 +841,15 @@ export function useChatListController({ */ const applyLocalTitleOnly = useCallback( async (agentRef: string, chatId: string, newTitle: string) => { - if (!agentRef) return + if (!agentRef || !isAuthenticated) return const requestGeneration = requestGenerationRef.current + const authorityScopeGeneration = authorityScopeGenerationRef.current + const authorityScope = currentAuthorityScopeRef.current + const stillOwned = () => + authorityScopeGenerationRef.current === authorityScopeGeneration && + currentAuthorityScopeRef.current === authorityScope + const bindingGeneration = await chatStore.getBindingGeneration() + if (!stillOwned()) return const updatedAt = new Date().toISOString() await chatStore.renameChat(agentRef, chatId, newTitle) if (requestGenerationRef.current !== requestGeneration) return @@ -898,7 +911,7 @@ export function useChatListController({ if ( isConfirmedHostAccessRevoked(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(entry.agentRef) + !isHostAccessBlocked(entry.agentRef) ) { onHostAccessRevoked(entry.agentRef) if (pendingRenamesRef.current.get(key) === entry) { @@ -909,7 +922,7 @@ export function useChatListController({ if ( isAuthorizationError(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(entry.agentRef) + !isHostAccessBlocked(entry.agentRef) ) { try { await chatStore.listSessions( @@ -921,7 +934,7 @@ export function useChatListController({ if ( isAuthorizationError(readError) && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(entry.agentRef) + !isHostAccessBlocked(entry.agentRef) ) { onHostAccessRevoked(entry.agentRef) if (pendingRenamesRef.current.get(key) === entry) { @@ -972,7 +985,7 @@ export function useChatListController({ entry.sending = false } }, - [chatStore, applyLocalTitleOnly, host, isHostAccessRevoked, onHostAccessRevoked] + [chatStore, applyLocalTitleOnly, host, isHostAccessBlocked, onHostAccessRevoked] ) /** @@ -1079,6 +1092,11 @@ export function useChatListController({ async (agentRef: string, chatId: string) => { if (!agentRef) return const requestGeneration = requestGenerationRef.current + const authorityScopeGeneration = authorityScopeGenerationRef.current + const authorityScope = currentAuthorityScopeRef.current + const stillOwned = () => + authorityScopeGenerationRef.current === authorityScopeGeneration && + currentAuthorityScopeRef.current === authorityScope // Stop following any in-flight task for this chat first: ack tears down the // SSE + connect/watchdog timers, so a later terminal can't fire onTerminal // and resurrect the just-deleted chat file via appendAssistantMessage. @@ -1088,7 +1106,10 @@ export function useChatListController({ // fire onTerminal and resurrect the just-deleted chat file. Dispatched // FIRST (before the delete), preserving the ack-before-delete ordering. host.current?.dispatchSession(deletedKey, { type: 'CHAT_DELETED' }) - await chatStore.deleteChat(agentRef, chatId) + const bindingGeneration = await chatStore.getBindingGeneration() + if (!stillOwned()) return + await chatStore.deleteChat(agentRef, chatId, bindingGeneration) + if (!stillOwned()) return const deleted = deletedChatIdsByAgentRef.current.get(agentRef) ?? new Set() deleted.add(chatId) deletedChatIdsByAgentRef.current.set(agentRef, deleted) @@ -1117,7 +1138,7 @@ export function useChatListController({ } } }, - [chatList, chatStore, removeLatestChatSession, handleCreateChat, host] + [chatList, chatStore, removeLatestChatSession, handleCreateChat, host, isAuthenticated] ) const handleDeleteChat = useCallback( @@ -1175,7 +1196,7 @@ export function useChatListController({ .flatMap(group => group.entries) .filter( chat => - !isHostAccessRevoked(chat.agentRef) && + !isHostAccessBlocked(chat.agentRef) && !deletedChatIdsByAgentRef.current.get(chat.agentRef)?.has(chat.id) ) .sort(byUpdatedDesc) @@ -1198,9 +1219,10 @@ export function useChatListController({ isAuthorizationError(error) && !cancelled && authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessRevoked(agentRef) + !isHostAccessBlocked(agentRef) ) { - onHostAccessRevoked(agentRef) + if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) + else onHostAuthorityUncertain(agentRef) } return { agentRef, sessions: [] as SessionsListResult['items'] } } @@ -1209,7 +1231,7 @@ export function useChatListController({ sessions: serverResult.items.filter( session => session.agent === agentRef && - !isHostAccessRevoked(agentRef) && + !isHostAccessBlocked(agentRef) && !deletedChatIdsByAgentRef.current.get(agentRef)?.has(session.chatId) ), } @@ -1217,7 +1239,7 @@ export function useChatListController({ ) if (cancelled) return for (const group of sessionGroups) { - if (isHostAccessRevoked(group.agentRef)) continue + if (isHostAccessBlocked(group.agentRef)) continue seedSessionSnapshots( fsm, group.agentRef, @@ -1229,7 +1251,7 @@ export function useChatListController({ setLatestChatSessions(previous => { const serverByKey = new Map() for (const group of sessionGroups) { - if (isHostAccessRevoked(group.agentRef)) continue + if (isHostAccessBlocked(group.agentRef)) continue for (const session of group.sessions) { if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) continue @@ -1241,7 +1263,7 @@ export function useChatListController({ const reconciled = previous .filter( item => - !isHostAccessRevoked(item.agentRef) && + !isHostAccessBlocked(item.agentRef) && !deletedChatIdsByAgentRef.current.get(item.agentRef)?.has(item.id) ) .map(item => { @@ -1259,7 +1281,7 @@ export function useChatListController({ const knownKeys = new Set(previous.map(item => `${item.agentRef}:${item.id}`)) const remoteOnly: LatestSidebarChatEntry[] = [] for (const group of sessionGroups) { - if (isHostAccessRevoked(group.agentRef)) continue + if (isHostAccessBlocked(group.agentRef)) continue for (const session of group.sessions) { if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) continue @@ -1291,7 +1313,7 @@ export function useChatListController({ flushPendingRenamesRef.current( new Set( sessionGroups.flatMap(group => - isHostAccessRevoked(group.agentRef) + isHostAccessBlocked(group.agentRef) ? [] : group.sessions .filter( @@ -1320,7 +1342,7 @@ export function useChatListController({ loadMenuData, scopeKey, fsm, - isHostAccessRevoked, + isHostAccessBlocked, onHostAccessRevoked, ]) diff --git a/desktop-app/ui/src/hooks/useAppController.ts b/desktop-app/ui/src/hooks/useAppController.ts index 14a0e3bc5d..8fb63c1a5d 100644 --- a/desktop-app/ui/src/hooks/useAppController.ts +++ b/desktop-app/ui/src/hooks/useAppController.ts @@ -306,22 +306,66 @@ export function useAppController() { (agentName: string) => agentsData.agentDisplayByName[agentName] ?? agentName, [agentsData.agentDisplayByName] ) - const revokedAgentRefsRef = useRef(new Set()) - const isHostAccessRevoked = useCallback( - (agentRef: string) => revokedAgentRefsRef.current.has(agentRef), + const authorityScope = `${auth.isAuthenticated}:${authenticatedPrincipalIdentity ?? ''}:${currentTeamId}` + const authorityScopeRef = useRef(authorityScope) + authorityScopeRef.current = authorityScope + const hostAuthorityEpochRef = useRef(0) + const blockedHostsRef = useRef( + new Map() + ) + const [hostAuthorityRevision, setHostAuthorityRevision] = useState(0) + const selectedAgentRef = useRef(nav.selectedAgent) + selectedAgentRef.current = nav.selectedAgent + const isHostAccessBlocked = useCallback( + (agentRef: string) => blockedHostsRef.current.has(agentRef), [] ) useEffect(() => { - revokedAgentRefsRef.current.clear() - }, [authenticatedPrincipalIdentity, currentTeamId]) - const onHostAccessRevoked = useCallback( - (agentRef: string) => { - revokedAgentRefsRef.current.add(agentRef) - if (nav.selectedAgent === agentRef) nav.setSelectedAgent(null) + blockedHostsRef.current.clear() + hostAuthorityEpochRef.current += 1 + setHostAuthorityRevision(revision => revision + 1) + }, [authorityScope]) + const blockHostAccess = useCallback( + (agentRef: string, kind: 'revoked' | 'uncertain') => { + const existing = blockedHostsRef.current.get(agentRef) + if (existing?.kind === 'revoked' && kind === 'uncertain') return + blockedHostsRef.current.set(agentRef, { + kind, + epoch: ++hostAuthorityEpochRef.current, + }) + setHostAuthorityRevision(revision => revision + 1) + if (selectedAgentRef.current === agentRef) nav.setSelectedAgent(null) void agentsData.refresh() }, - [agentsData.refresh, nav.selectedAgent, nav.setSelectedAgent] + [agentsData.refresh, nav.setSelectedAgent] + ) + const onHostAccessRevoked = useCallback( + (agentRef: string) => blockHostAccess(agentRef, 'revoked'), + [blockHostAccess] + ) + const onHostAuthorityUncertain = useCallback( + (agentRef: string) => blockHostAccess(agentRef, 'uncertain'), + [blockHostAccess] ) + const verifyHostAccess = useCallback(async (agentRef: string): Promise => { + const blocked = blockedHostsRef.current.get(agentRef) + if (!blocked) return true + const scope = authorityScopeRef.current + const epoch = blocked.epoch + try { + await window.clerum.rpc.listSessions(agentRef, undefined, { agent: agentRef, limit: 1 }) + } catch { + return false + } + if ( + authorityScopeRef.current !== scope || + blockedHostsRef.current.get(agentRef)?.epoch !== epoch + ) + return false + blockedHostsRef.current.delete(agentRef) + setHostAuthorityRevision(revision => revision + 1) + return true + }, []) const chat = useAgentChatController({ selectedAgent: nav.selectedAgent, agentNames: agentsData.agentNames, @@ -339,7 +383,8 @@ export function useAppController() { openAgentConversationFromNotification, decideApprovalFromNotification, onHostAccessRevoked, - isHostAccessRevoked, + onHostAuthorityUncertain, + isHostAccessBlocked, }) // §4.7.4: the ONE central approval-decision function, bound to the chat @@ -617,7 +662,7 @@ export function useAppController() { if (agentsData.accessCatalog && nav.selectedAgent) { if ( !agentsData.agentNames.includes(nav.selectedAgent) || - isHostAccessRevoked(nav.selectedAgent) + isHostAccessBlocked(nav.selectedAgent) ) { nav.setSelectedAgent(null) } @@ -628,7 +673,7 @@ export function useAppController() { auth.isAuthenticated, nav.selectedAgent, nav.setSelectedAgent, - isHostAccessRevoked, + isHostAccessBlocked, ]) const switchTeamForWorkspace = useCallback( @@ -881,7 +926,7 @@ export function useAppController() { ]) // ─── Cross-domain: handleOpenAgentWorkspace ─── - const handleOpenAgentWorkspace = useCallback( + const openAgentWorkspace = useCallback( (agentName: string, route: AgentWorkspaceRoute = AGENT_WORKSPACE_ROUTES.connectors) => { if (!agentName) return chat.setPendingChatSelection(agentName, null) @@ -900,9 +945,25 @@ export function useAppController() { nav.setSelectedAgentRoute, ] ) + const handleOpenAgentWorkspace = useCallback( + (agentName: string, route: AgentWorkspaceRoute = AGENT_WORKSPACE_ROUTES.connectors) => { + if (!agentName) return + if (!isHostAccessBlocked(agentName)) { + openAgentWorkspace(agentName, route) + return + } + const scope = authorityScopeRef.current + void verifyHostAccess(agentName).then(verified => { + if (verified && authorityScopeRef.current === scope) { + openAgentWorkspace(agentName, route) + } + }) + }, + [isHostAccessBlocked, openAgentWorkspace, verifyHostAccess] + ) // ─── Cross-domain: handleSelectChatAgent (Chat page agent picker) ─── - const handleSelectChatAgent = useCallback( + const selectChatAgent = useCallback( ( agentName: string, options: { @@ -1003,6 +1064,22 @@ export function useAppController() { nav.setSelectedAgentRoute, ] ) + const handleSelectChatAgent = useCallback( + (agentName: string, options: Parameters[1] = {}) => { + if (!agentName) return + if (!isHostAccessBlocked(agentName)) { + selectChatAgent(agentName, options) + return + } + const scope = authorityScopeRef.current + void verifyHostAccess(agentName).then(verified => { + if (verified && authorityScopeRef.current === scope) { + selectChatAgent(agentName, options) + } + }) + }, + [isHostAccessBlocked, selectChatAgent, verifyHostAccess] + ) // ─── Cross-domain: handleNavSelect (extended) ─── const handleNavSelect = useCallback( @@ -1059,6 +1136,7 @@ export function useAppController() { if (requiresTeamSwitch) { await ensureTeamContext({ teamId: targetTeamId }) } + if (isHostAccessBlocked(targetAgent) && !(await verifyHostAccess(targetAgent))) return if (stayInDrawer) { // handleSelectChatAgent(keepNavItem) sets the active chat without @@ -1112,6 +1190,8 @@ export function useAppController() { ensureTeamContext, fullSetStatus, handleSelectChatAgent, + isHostAccessBlocked, + verifyHostAccess, nav.activateChatTab, nav.navItem, nav.selectedAgent, @@ -1358,6 +1438,8 @@ export function useAppController() { // Navigation navItem: nav.navItem, selectedAgent: nav.selectedAgent, + isHostAccessBlocked, + hostAuthorityRevision, selectedAgentRoute: nav.selectedAgentRoute, setSelectedAgent: nav.setSelectedAgent, handleNavSelect, diff --git a/desktop-app/ui/src/hooks/useChatStore.ts b/desktop-app/ui/src/hooks/useChatStore.ts index 4569da3cf3..6c9373b91d 100644 --- a/desktop-app/ui/src/hooks/useChatStore.ts +++ b/desktop-app/ui/src/hooks/useChatStore.ts @@ -68,8 +68,10 @@ export function useChatStore() { window.clerum.chat.rename(agentRef, chatId, title), [] ) + const getBindingGeneration = useCallback(() => window.clerum.chat.getBindingGeneration(), []) const deleteChat = useCallback( - (agentRef: string, chatId: string) => window.clerum.chat.delete(agentRef, chatId), + (agentRef: string, chatId: string, bindingGeneration: number) => + window.clerum.chat.delete(agentRef, chatId, bindingGeneration), [] ) const loadMessages = useCallback( @@ -244,6 +246,7 @@ export function useChatStore() { listChats, createChat, renameChat, + getBindingGeneration, deleteChat, loadMessages, appendMessages, diff --git a/desktop-app/ui/src/lib/composerDraftStore.ts b/desktop-app/ui/src/lib/composerDraftStore.ts index 8fce8e0310..e0bf96f40f 100644 --- a/desktop-app/ui/src/lib/composerDraftStore.ts +++ b/desktop-app/ui/src/lib/composerDraftStore.ts @@ -59,6 +59,15 @@ export function clearComposerDraftAfterSend(chatId: string | null): void { if (chatId) clearComposerDraft(chatId) } +/** Drop drafts on logout or principal/team change and notify mounted composers. */ +export function clearAllComposerDrafts(): void { + for (const key of [...drafts.keys()]) { + drafts.delete(key) + revisions.set(key, ++revision) + emit(key) + } +} + export function subscribeComposerDraft(chatId: string | null, listener: Listener): () => void { const key = keyFor(chatId) let listeners = listenersByKey.get(key) diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index 961897acbc..6c82a65481 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.352", + "version": "0.1.353", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.352", + "version": "0.1.353", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index e0a845ff60..d69981a819 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.352", + "version": "0.1.353", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/src/hostReconciler.test.ts b/host-context-controller/src/hostReconciler.test.ts index b185932a94..1e7ca0f551 100644 --- a/host-context-controller/src/hostReconciler.test.ts +++ b/host-context-controller/src/hostReconciler.test.ts @@ -1420,9 +1420,7 @@ describe('HostReconciler Host inventory mutation authority', () => { await releaseTokenProvision.promise return { revision: 'stale-revision', scopeHash: 'stale-scope' } }) - const ensureDeployment = vi - .spyOn(reconciler as any, 'ensureDeployment') - .mockResolvedValue(undefined) + const ensureDeployment = vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(true) const pending = reconciler.reconcile(host) await tokenProvisionStarted.promise diff --git a/host-context-controller/src/hostReconciler.ts b/host-context-controller/src/hostReconciler.ts index b84ae5337a..9ab121fe14 100644 --- a/host-context-controller/src/hostReconciler.ts +++ b/host-context-controller/src/hostReconciler.ts @@ -3605,18 +3605,40 @@ export class HostReconciler { private async ensureDeployment( host: HostCRD, mounts: ResolvedSfsMount[], - runtimeTokenRevision: string, + runtimeTokenRevision: string | undefined, lifecycle?: EffectiveHostLifecycle, resolveStateBeforeMutation?: () => Promise, revalidate?: () => void - ): Promise { - const buildDesiredDeployment = async (): Promise => { + ): Promise { + let observedDeployment: k8s.V1Deployment | undefined + let holdingTemplate = false + let applied = true + const buildDesiredDeployment = async (): Promise => { const state = resolveStateBeforeMutation ? await resolveStateBeforeMutation() : null + const effective = state?.lifecycle ?? lifecycle + holdingTemplate = effective?.suspensionBlocked === true + if (holdingTemplate) { + if (!observedDeployment) return null + const existing = observedDeployment + if ( + !this.isHccOwnedHostResource(existing, host.name) || + existing.metadata?.name !== host.name || + existing.metadata?.namespace !== host.namespace || + !existing.metadata?.uid || + !existing.metadata?.resourceVersion || + !existing.spec?.template?.spec + ) { + throw new Error(`Cannot preserve an unverified Deployment for Host "${host.name}"`) + } + // Preserve UID/resourceVersion and every applied field. Conflicts + // re-read this object before retrying; only replicas may change. + return { ...existing, spec: { ...existing.spec, replicas: 1 } } + } return this.buildDeployment( host, mounts, state?.runtimeTokenRevision ?? runtimeTokenRevision, - state?.lifecycle ?? lifecycle, + effective, state?.grokExecutionEnabled ?? this.hostDerivesGrokExecution(host) ) } @@ -3626,14 +3648,20 @@ export class HostReconciler { } await ensureResource({ mutationAllowed, - read: () => - observeExistenceRead('Deployment', () => + read: async () => { + observedDeployment = await observeExistenceRead('Deployment', () => this.appsApi.readNamespacedDeployment({ namespace: host.namespace, name: host.name }) - ), + ) + return observedDeployment + }, create: async () => { // The initial GET may outlive the lifecycle or token-scope observation. const deployment = await buildDesiredDeployment() revalidate?.() + if (!deployment) { + applied = false + return + } return observeCreate('Deployment', () => this.appsApi.createNamespacedDeployment({ namespace: host.namespace, body: deployment }) ) @@ -3643,8 +3671,13 @@ export class HostReconciler { replaceWithConflictRetry({ description: `Deployment "${host.name}"`, logPrefix: '[HostReconciler]', - resolveBody: buildDesiredDeployment, - mergeExisting: preserveHostDeploymentAnnotations, + resolveBody: async () => { + const desired = await buildDesiredDeployment() + if (!desired) throw new Error('Deployment disappeared during preservation') + return desired + }, + mergeExisting: (desired, existing) => + holdingTemplate ? desired : preserveHostDeploymentAnnotations(desired, existing), isUpToDate: deploymentMatchesDesired, mutationAllowed, read, @@ -3656,6 +3689,7 @@ export class HostReconciler { }), }), }) + return applied } private async deleteRuntimeResources(name: string, namespace: string): Promise { @@ -4686,6 +4720,32 @@ export class HostReconciler { }) } + const holdAppliedRuntime = async (): Promise => { + const applied = await this.ensureDeployment( + host, + mounts, + undefined, + lifecycle.effective, + undefined, + revalidateHostMutationBoundary + ) + revalidateHostMutationBoundary() + this.lifecycle.markHostNotSuspended(host.name) + const ready = applied && (await this.checkDeploymentReady(host.name, host.namespace)) + revalidateHostMutationBoundary() + this.setStatus(host.name, { + deployed: applied, + ready, + message: applied + ? 'CommunicationChannel inventory unavailable; preserving applied runtime with one replica' + : 'Waiting for CommunicationChannel inventory before creating runtime', + }) + } + if (lifecycle.effective.suspensionBlocked) { + await holdAppliedRuntime() + return + } + revalidateHostMutationBoundary() await this.ensurePvc(host, revalidateHostMutationBoundary) revalidateHostMutationBoundary() @@ -4755,6 +4815,11 @@ export class HostReconciler { } revalidateHostMutationBoundary() + if (lifecycle.effective.suspensionBlocked) { + await holdAppliedRuntime() + return + } + // Bootstrap captures the scope contract used for issuance. The Deployment // guard below compares that contract with the live channel cache after this // potentially slow I/O and before every create/replace attempt. @@ -4813,10 +4878,25 @@ export class HostReconciler { const resolveDeploymentState = async (): Promise => { for (let attempt = 1; attempt <= 3; attempt++) { revalidateHostMutationBoundary() - await resolveDeploymentLifecycle() + const beforeScope = await resolveDeploymentLifecycle() + if (beforeScope.suspensionBlocked) { + return { + lifecycle: beforeScope, + runtimeTokenRevision: runtimeTokenProvision.revision, + grokExecutionEnabled: this.hostDerivesGrokExecution(host), + } + } await ensureCurrentRuntimeTokenScope() const effective = await resolveDeploymentLifecycle() const currentScopeHash = this.runtimeScopeHashFor(host, await this.frontsOAuthServer(host)) + // The OAuth lookup can await I/O after the last lifecycle observation. + // Retry assessment rather than committing a now-obsolete mode. + if ( + this.lifecycle.enforceCommunicationChannelPolicyBeforeDeployment(host.name, lifecycle) !== + lifecycle + ) { + continue + } if (runtimeTokenProvision.scopeHash === currentScopeHash) { revalidateHostMutationBoundary() return { @@ -4837,7 +4917,7 @@ export class HostReconciler { } revalidateHostMutationBoundary() - await this.ensureDeployment( + const deploymentApplied = await this.ensureDeployment( host, mounts, runtimeTokenProvision.revision, @@ -4847,6 +4927,15 @@ export class HostReconciler { ) revalidateHostMutationBoundary() + if (!deploymentApplied) { + this.setStatus(host.name, { + deployed: false, + ready: false, + message: 'Waiting for CommunicationChannel inventory before creating runtime', + }) + return + } + const suspended = lifecycle.effective.stateless && lifecycle.effective.state === 'suspended' if (suspended) { this.lifecycle.recordSuspendedApplied(host.name) diff --git a/host-context-controller/src/statelessLifecycle.types.ts b/host-context-controller/src/statelessLifecycle.types.ts index ffeeb016ac..f63a6a5ed4 100644 --- a/host-context-controller/src/statelessLifecycle.types.ts +++ b/host-context-controller/src/statelessLifecycle.types.ts @@ -9,12 +9,13 @@ import { HostCondition, HostLifecycleState, HostLifecycleStatus } from './types' /** * Effective lifecycle of a Host after the reconcile-time rejection checks. * `stateless` is false when spec.lifecycle.stateless is off OR a confirmed - * incompatibility applies. Cache uncertainty keeps the stateless runtime - * identity but blocks suspension until channel authority returns. + * incompatibility applies. Cache uncertainty blocks suspension and template + * selection; `stateless` then describes the request, not the applied mode. */ export interface EffectiveHostLifecycle { stateless: boolean state: HostLifecycleState + /** Unknown channel authority: preserve the entire applied Deployment template. */ suspensionBlocked?: boolean } diff --git a/host-context-controller/src/statelessLifecycleExecutor.ts b/host-context-controller/src/statelessLifecycleExecutor.ts index 1551fbc107..a6e8292b42 100644 --- a/host-context-controller/src/statelessLifecycleExecutor.ts +++ b/host-context-controller/src/statelessLifecycleExecutor.ts @@ -11,6 +11,7 @@ import { config } from './config' import { isHeartbeatManagedLifecycleReason } from './constants' import type { ResolvedSfsMount } from './hostReconciler' import { SFS_LABEL, SFS_NAMESPACE_LABEL, WFC_APP_LABEL } from './k8s/sharedFileSystemFactory' +import { hccLogger } from './logger' import { STATELESS_PULL_POLICY_REJECTED_CONDITION_TYPE, pullPolicyNotApplicableCondition, @@ -25,6 +26,7 @@ import { HostCRD, HostCondition, HostCrdStatus, HostLifecycleStatus } from './ty import { getErrorCode } from './utils' // ─── Stateless lifecycle (Stage 2) ───────────────────────────────────────── +const log = hccLogger.child({ module: 'stateless-lifecycle' }) const GROUP = 'clerum.io' const VERSION = 'v1alpha1' const PLURAL_HOSTS = 'hosts' @@ -185,7 +187,7 @@ export class StatelessLifecycleExecutor { return } StatelessLifecycleExecutor.singleReplicaInvariantWarned = true - console.warn( + log.warn( '[HostReconciler] Stateless lifecycle status writes assume HCC runs replicas:1 WITHOUT leader election. serializeByHost orders writers within this process; the resourceVersion precondition + 409-retry closes the cross-writer window — but neither elects a leader. Running >1 replica risks two controllers racing (safe against corruption, but can livelock).' ) } @@ -217,7 +219,8 @@ export class StatelessLifecycleExecutor { if (host.spec.lifecycle?.stateless !== true) { return { stateless: false, state: 'active' } } - const hasCommunicationChannels = this.countCommunicationChannels(host.name) > 0 + const hasCommunicationChannels = + this.isCommunicationChannelCacheSynced() && this.countCommunicationChannels(host.name) > 0 if (host.spec.desktop !== undefined || hasCommunicationChannels) { return { stateless: false, state: 'active' } } @@ -233,7 +236,10 @@ export class StatelessLifecycleExecutor { } { const reasons: string[] = [] const messages: string[] = [] - const ccCount = this.countCommunicationChannels(hostName) + // A retained positive entry is no more authoritative than an empty cache. + const ccCount = this.isCommunicationChannelCacheSynced() + ? this.countCommunicationChannels(hostName) + : 0 if (ccCount > 0) { // Addendum 6 (operator visibility): the rejection message names both the // associated channel count AND the recovery action, because control-ui @@ -350,7 +356,7 @@ export class StatelessLifecycleExecutor { } } - /** Unknown channel inventory blocks suspension without changing the runtime template. */ + /** Unknown channel inventory requires preserving the applied Deployment. */ private holdActiveDuringChannelCacheRecovery( wakeHandledGeneration: number ): HostLifecycleAssessment { @@ -374,8 +380,8 @@ export class StatelessLifecycleExecutor { /** * Close the interval between the initial lifecycle assessment and the * Deployment write. Reconciliation performs several Kubernetes operations - * in between; if the CommunicationChannel watch ends, retain the stateless - * template but hold the Host active. A confirmed channel still applies the + * in between; if the CommunicationChannel watch ends, retain the applied + * template, whose mode may be stateful. A confirmed channel still applies the * established incompatible-mode policy. * * This synchronous cache check adds no Kubernetes API calls to steady state. @@ -385,6 +391,14 @@ export class StatelessLifecycleExecutor { assessment: HostLifecycleAssessment ): HostLifecycleAssessment { if (!assessment.effective.stateless) { + // Independent desktop/SFS incompatibilities retain their policy. A + // channel-only rejection cannot choose a runtime after authority is lost. + if ( + !this.isCommunicationChannelCacheSynced() && + assessment.condition.reason === ACTIVE_COMMUNICATION_CHANNELS_REASON + ) { + return this.holdActiveDuringChannelCacheRecovery(assessment.lifecycle.wakeHandledGeneration) + } return assessment } @@ -441,9 +455,9 @@ export class StatelessLifecycleExecutor { } } } catch (err) { - console.warn( + log.warn( `[HostReconciler] Could not resolve wfc node placement for SFS "${m.namespace}/${m.name}" (host "${host.name}"); skipping the co-location rejection check:`, - err + { err } ) return null } @@ -650,9 +664,9 @@ export class StatelessLifecycleExecutor { } catch (err) { // Best-effort: a status-write failure (including 409-retry exhaustion) // is logged, not thrown — status writes must not block reconciliation. - console.error( + log.error( `[HostReconciler] Failed to write lifecycle status for "${host.name}" (${getErrorCode(err) ?? 'no code'}):`, - err + { err } ) return false } @@ -803,7 +817,7 @@ export class StatelessLifecycleExecutor { // newer evidence. A fresh `suspended` returns as a SILENT idempotent // no-op: the drained-report retry path relies on exactly that. if (freshLifecycle?.state !== 'suspended') { - console.log( + log.info( `[HostReconciler] Suspend of stateless Host "${host.name}" skipped — fresh state "${freshLifecycle?.state ?? 'active'}" is not draining (drain decision overturned)` ) } @@ -827,7 +841,7 @@ export class StatelessLifecycleExecutor { // with no reviver. const freshHandled = freshLifecycle.wakeHandledGeneration ?? 0 if (freshHandled > entryWakeHandledGeneration) { - console.log( + log.info( `[StatelessSuspend] host=${host.name} phase=drained_report_stale reason=wake_handled_since entryGeneration=${entryWakeHandledGeneration} freshGeneration=${freshHandled} ts=${this.now().getTime()}` ) return { skip: true } @@ -839,7 +853,7 @@ export class StatelessLifecycleExecutor { // suspended state that must immediately be overturned. const freshRequested = this.wakeRequestedGeneration(fresh) if (freshRequested > freshHandled) { - console.log( + log.info( `[StatelessSuspend] host=${host.name} phase=drained_report_stale reason=wake_pending requestedGeneration=${freshRequested} handledGeneration=${freshHandled} ts=${this.now().getTime()}` ) return { skip: true } @@ -886,7 +900,7 @@ export class StatelessLifecycleExecutor { } this.lastWrittenLifecycleStatus.delete(host.name) this.logSuspendedApplied(host.name) - console.log( + log.info( `[HostReconciler] Suspending stateless Host "${host.name}" (reason: ${reason}) — reconciling to replicas=0` ) // L2: call reconcileCore (not the serialized reconcile) — this method @@ -1073,7 +1087,7 @@ export class StatelessLifecycleExecutor { // evidence. const freshHandled = freshLifecycle?.wakeHandledGeneration ?? 0 if (freshHandled > entryWakeHandledGeneration) { - console.log( + log.info( `[StatelessSuspend] host=${host.name} phase=draining_write_stale reason=wake_handled_since entryGeneration=${entryWakeHandledGeneration} freshGeneration=${freshHandled} ts=${this.now().getTime()}` ) return { skip: true } @@ -1082,7 +1096,7 @@ export class StatelessLifecycleExecutor { // by definition ("pending wake wins") — never fence over it. const freshRequested = this.wakeRequestedGeneration(fresh) if (freshRequested > freshHandled) { - console.log( + log.info( `[StatelessSuspend] host=${host.name} phase=draining_write_stale reason=wake_pending requestedGeneration=${freshRequested} handledGeneration=${freshHandled} ts=${this.now().getTime()}` ) return { skip: true } @@ -1104,7 +1118,7 @@ export class StatelessLifecycleExecutor { target.status = writtenStatus }) this.lastWrittenLifecycleStatus.delete(host.name) - console.log( + log.info( `[HostReconciler] Marked stateless Host "${host.name}" draining — control-api now answers {drain:true} from Host.status` ) } @@ -1169,7 +1183,7 @@ export class StatelessLifecycleExecutor { revalidate ) if ('skipped' in result || writtenStatus === undefined) { - console.log( + log.info( `[StatelessLifecycle] host=${host.name} cancel-drain skipped — fresh state "${skippedNotDraining ?? 'active'}" is not draining` ) return @@ -1178,7 +1192,7 @@ export class StatelessLifecycleExecutor { target.status = writtenStatus }) this.lastWrittenLifecycleStatus.delete(host.name) - console.log( + log.info( `[StatelessLifecycle] host=${host.name} cancel-drain: activity evidence while draining — reverting status to active` ) } @@ -1219,7 +1233,7 @@ export class StatelessLifecycleExecutor { if (Number.isNaN(parsed)) { if (this.malformedWakeAnnotationLogged.get(host.name) !== raw) { this.malformedWakeAnnotationLogged.set(host.name, raw) - console.error( + log.error( `[HostReconciler] Malformed ${WAKE_REQUESTED_ANNOTATION} annotation on Host "${host.name}": ${JSON.stringify( raw )} is not an integer; treating it as no wake intent` @@ -1277,9 +1291,9 @@ export class StatelessLifecycleExecutor { try { initialFresh = await this.readFreshHost(host) } catch (err) { - console.error( + log.error( `[HostReconciler] Wake fast-path fresh Host read failed for "${host.name}" (${getErrorCode(err) ?? 'no code'}):`, - err + { err } ) return false } @@ -1300,12 +1314,12 @@ export class StatelessLifecycleExecutor { } const currentState = initialFresh.status?.lifecycle?.state ?? 'active' - console.log( + log.info( `[HostReconciler] Wake fast-path for "${host.name}": generation ${freshWakeRequested} > handled ${freshWakeHandled} while ${currentState}` ) // Stage 6 (W2): machine-parseable wake-phase timestamps for the // wake-budget script. One line per phase, correlated by generation. - console.log( + log.info( `[StatelessWake] host=${host.name} generation=${freshWakeRequested} phase=wake_observed ts=${this.now().getTime()}` ) @@ -1366,9 +1380,9 @@ export class StatelessLifecycleExecutor { // lost — the annotation is durable and the drained-pre-scale guard in // reconcile() re-checks it before any replicas:0 apply, so a pending // wake can never be raced away by the suspension. - console.error( + log.error( `[HostReconciler] Wake fast-path status write failed for "${host.name}" (${getErrorCode(err) ?? 'no code'}):`, - err + { err } ) return false } @@ -1381,7 +1395,7 @@ export class StatelessLifecycleExecutor { target.annotations = latestFresh.annotations target.status = reflectedStatus }) - console.log( + log.info( `[StatelessWake] host=${host.name} generation=${writtenLifecycle.wakeHandledGeneration} phase=status_flipped ts=${this.now().getTime()}` ) @@ -1391,6 +1405,10 @@ export class StatelessLifecycleExecutor { return false } + // Cache recovery uses the full reconcile's owned, resource-version-fenced + // replica-only update. Do not send the unfenced wake scale patch here. + if (!this.isCommunicationChannelCacheSynced()) return false + // Minimal scale patch — NOT the full buildDeployment replace. It also // starts the pod even when the heavy body aborts early (e.g. on a // transient Secret read failure). @@ -1403,8 +1421,8 @@ export class StatelessLifecycleExecutor { ], } ) - console.log(`[HostReconciler] Wake fast-path scaled Deployment "${host.name}" to replicas=1`) - console.log( + log.info(`[HostReconciler] Wake fast-path scaled Deployment "${host.name}" to replicas=1`) + log.info( `[StatelessWake] host=${host.name} generation=${writtenLifecycle.wakeHandledGeneration} phase=replicas_patched ts=${this.now().getTime()}` ) this.markHostNotSuspended(host.name) @@ -1414,7 +1432,7 @@ export class StatelessLifecycleExecutor { if (getErrorCode(err) === 404) { // Deployment missing (e.g. deleted while suspended): fall through — // the full reconcile below builds it with replicas=1. - console.log( + log.info( `[HostReconciler] Wake fast-path: Deployment "${host.name}" not found; the full reconcile will create it` ) return true @@ -1437,29 +1455,27 @@ export class StatelessLifecycleExecutor { namespace: host.namespace, body: deployment, }) - console.warn( + log.warn( `[HostReconciler] Wake fast-path scale patch forbidden for "${host.name}"; used deployment update fallback` ) - console.log( - `[HostReconciler] Wake fast-path scaled Deployment "${host.name}" to replicas=1` - ) - console.log( + log.info(`[HostReconciler] Wake fast-path scaled Deployment "${host.name}" to replicas=1`) + log.info( `[StatelessWake] host=${host.name} generation=${writtenLifecycle.wakeHandledGeneration} phase=replicas_patched ts=${this.now().getTime()}` ) this.markHostNotSuspended(host.name) this.recordScaleTransition(host.name, 'up') return true } catch (fallbackErr) { - console.error( + log.error( `[HostReconciler] Wake fast-path deployment update fallback failed for "${host.name}":`, - fallbackErr + { err: fallbackErr } ) return true } } // Surfaced loudly; ensureDeployment in this same reconcile pass // replaces the Deployment with replicas=1, so nothing is masked. - console.error(`[HostReconciler] Wake fast-path scale patch failed for "${host.name}":`, err) + log.error(`[HostReconciler] Wake fast-path scale patch failed for "${host.name}":`, { err }) return true } } @@ -1473,7 +1489,7 @@ export class StatelessLifecycleExecutor { private recordScaleTransition(hostName: string, direction: 'up' | 'down'): void { const total = (this.scaleTransitionsByHost.get(hostName) ?? 0) + 1 this.scaleTransitionsByHost.set(hostName, total) - console.log( + log.info( `[StatelessMetric] scale_transition host=${hostName} direction=${direction} total=${total}` ) } @@ -1492,7 +1508,7 @@ export class StatelessLifecycleExecutor { return } this.suspendedAppliedLoggedByHost.add(hostName) - console.log( + log.info( `[StatelessSuspend] host=${hostName} phase=suspended_applied ts=${this.now().getTime()}` ) } @@ -1591,7 +1607,7 @@ export class StatelessLifecycleExecutor { // The CR changed under us (a concurrent lifecycle writer or an // unrelated metadata write). Re-read fresh and re-evaluate the // guard so a decision the racing writer just made is respected. - console.warn( + log.warn( `[HostReconciler] Status write for "${host.name}" hit 409 (stale resourceVersion); re-reading fresh and retrying (attempt ${attempt}/${maxAttempts})` ) fresh = undefined @@ -1778,16 +1794,16 @@ export class StatelessLifecycleExecutor { // Fail loud and skip the scale-down this pass: a suspend derived from // a possibly-stale cache must NOT strand a racing wake at replicas:0. // The periodic resync re-runs this reconcile with a fresh cache. - console.error( + log.error( `[HostReconciler] Drained-pre-scale guard for "${host.name}": fresh Host read failed; skipping replicas=0 this pass (resync will retry):`, - err + { err } ) } else { // Same posture in the mirror direction: never commit replicas from a // possibly-stale cached state when the fresh read is unavailable. - console.error( + log.error( `[HostReconciler] Stateless replicas guard for "${host.name}": fresh Host read failed; skipping the Deployment scale this pass (resync will retry):`, - err + { err } ) } return null @@ -1797,7 +1813,7 @@ export class StatelessLifecycleExecutor { const freshState = fresh.status?.lifecycle?.state ?? 'active' if (freshWakeRequested > freshWakeHandled) { if (cachedState === 'suspended') { - console.log( + log.info( `[HostReconciler] Drained-pre-scale guard for "${host.name}": aborting replicas=0 — fresh wake generation ${freshWakeRequested} > handled ${freshWakeHandled}` ) // A wake is pending in the fresh read — run the wake transition @@ -1826,7 +1842,7 @@ export class StatelessLifecycleExecutor { if ((cachedState === 'suspended') === freshSuspended) { return assessment } - console.log( + log.info( `[HostReconciler] Stateless replicas guard for "${host.name}": cached lifecycle state "${cachedState}" disagrees with fresh "${freshState}" — deriving replicas from FRESH state` ) // Reflect fresh onto the in-memory host (mirroring the wake fast-path's diff --git a/host-context-controller/test/hostReconciler.heartbeat.test.ts b/host-context-controller/test/hostReconciler.heartbeat.test.ts index 98e47c6607..a1d9c78536 100644 --- a/host-context-controller/test/hostReconciler.heartbeat.test.ts +++ b/host-context-controller/test/hostReconciler.heartbeat.test.ts @@ -1076,7 +1076,7 @@ describe('HostReconciler.suspendHostFromHeartbeat', () => { expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() expect(host.status?.lifecycle?.state).toBe('draining') const staleLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=drained_report_stale')) expect(staleLines).toEqual([ expect.stringMatching( @@ -1117,7 +1117,7 @@ describe('HostReconciler.suspendHostFromHeartbeat', () => { expect(appsApi.createNamespacedDeployment).not.toHaveBeenCalled() expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() const staleLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=drained_report_stale')) expect(staleLines).toEqual([ expect.stringMatching( @@ -1876,7 +1876,7 @@ describe('HostReconciler.markHostDrainingFromHeartbeat — AP-1 entry-epoch guar expect(customApi.patchNamespacedCustomObjectStatus).not.toHaveBeenCalled() expect(host.status?.lifecycle?.state).toBe('active') const staleLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=draining_write_stale')) expect(staleLines).toEqual([ expect.stringMatching( @@ -1914,7 +1914,7 @@ describe('HostReconciler.markHostDrainingFromHeartbeat — AP-1 entry-epoch guar expect(customApi.patchNamespacedCustomObjectStatus).not.toHaveBeenCalled() const staleLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=draining_write_stale')) expect(staleLines).toEqual([ expect.stringMatching( @@ -2018,7 +2018,7 @@ describe('HostReconciler reconcile — stateless replicas derive from FRESH stat // pod is never scaled to 0. expect(hostDeploymentBody(appsApi, 'stateless-host').spec?.replicas).toBe(1) const guardLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('disagrees with fresh')) expect(guardLines).toEqual([ '[HostReconciler] Stateless replicas guard for "stateless-host": cached lifecycle state "suspended" disagrees with fresh "active" — deriving replicas from FRESH state', diff --git a/host-context-controller/test/hostReconciler.lifecycle.test.ts b/host-context-controller/test/hostReconciler.lifecycle.test.ts index a92d82a457..6c894cebd0 100644 --- a/host-context-controller/test/hostReconciler.lifecycle.test.ts +++ b/host-context-controller/test/hostReconciler.lifecycle.test.ts @@ -195,6 +195,28 @@ function runtimeTokenProvision(host: HostCRD, hasChannelIngress = false) { } } +/** Model persisted API state so a loss-of-authority test cannot invent a template. */ +function persistHostDeployment(appsApi: MockAppsApi, host: HostCRD, initial: k8s.V1Deployment) { + let live = structuredClone(initial) + live.metadata = { + ...live.metadata, + uid: live.metadata?.uid ?? 'deployment-uid', + resourceVersion: live.metadata?.resourceVersion ?? '73', + } + const read = appsApi.readNamespacedDeployment.getMockImplementation()! + appsApi.readNamespacedDeployment.mockImplementation(request => + request.name === host.name ? Promise.resolve(structuredClone(live)) : read(request) + ) + const replace = appsApi.replaceNamespacedDeployment.getMockImplementation()! + appsApi.replaceNamespacedDeployment.mockImplementation(async request => { + if (request.name !== host.name) return replace(request) + expect(request.body.metadata.resourceVersion).toBe(live.metadata!.resourceVersion) + live = structuredClone(request.body) + return live + }) + return () => live +} + /** The mcp-host Deployment body sent to the K8s API (excludes channel-reader). */ function hostDeploymentBody(appsApi: MockAppsApi, name: string): k8s.V1Deployment { const calls = [ @@ -737,6 +759,246 @@ describe('HostReconciler stateless lifecycle — env injection', () => { }) describe('HostReconciler stateless lifecycle — rejection matrix', () => { + it('preserves a rejected stateful runtime across cold channel LIST failure and recovery', async () => { + const host = makeStatelessHost() + const previous = createReconciler({ countCommunicationChannels: () => 1 }) + previous.customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + vi.spyOn(previous.reconciler as any, 'provisionRuntimeTokenRevision').mockResolvedValue( + runtimeTokenProvision(host, true) + ) + await previous.reconciler.reconcile(host) + host.status = lifecycleStatusWrites(previous.customApi).at(-1) + expect(rejectedCondition(host.status!).reason).toBe('ActiveCommunicationChannels') + const applied = hostDeploymentBody(previous.appsApi, host.name) + applied.metadata = { ...applied.metadata, uid: 'deployment-uid', resourceVersion: '73' } + const baseline = JSON.stringify(applied.spec!.template) + expect(containerEnv(applied).some(entry => entry.name === 'CLERUM_STATELESS_LIFECYCLE')).toBe( + false + ) + let synced = false + let channels = 0 + const { reconciler, appsApi, customApi } = createReconciler({ + countCommunicationChannels: () => channels, + isCommunicationChannelCacheSynced: () => synced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const live = persistHostDeployment(appsApi, host, applied) + vi.spyOn(reconciler as any, 'provisionRuntimeTokenRevision').mockResolvedValue( + runtimeTokenProvision(host, true) + ) + await reconciler.reconcile(host) + expect(JSON.stringify(live().spec!.template)).toBe(baseline) + expect(live().spec!.replicas).toBe(1) + expect( + appsApi.replaceNamespacedDeployment.mock.calls.filter(([r]) => r.name === host.name) + ).toHaveLength(0) + host.status = lifecycleStatusWrites(customApi).at(-1) + synced = true + channels = 1 + await reconciler.reconcile(host) + expect(JSON.stringify(live().spec!.template)).toBe(baseline) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'ActiveCommunicationChannels' + ) + }) + + it('does not use a stale positive channel cache to change an applied stateless template', async () => { + const host = makeStatelessHost() + const { reconciler, appsApi, customApi } = createReconciler({ + countCommunicationChannels: () => 1, + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + applied.metadata = { ...applied.metadata, uid: 'deployment-uid', resourceVersion: '74' } + const live = persistHostDeployment(appsApi, host, applied) + await reconciler.reconcile(host) + expect(live().spec!.template).toEqual(applied.spec!.template) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'CommunicationChannelCacheUnsynced' + ) + }) + + it('defers creating a missing Deployment until the channel inventory is authoritative', async () => { + let synced = false + const host = makeStatelessHost() + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => synced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const read = appsApi.readNamespacedDeployment.getMockImplementation()! + appsApi.readNamespacedDeployment.mockImplementation(request => + request.name === host.name ? Promise.reject({ code: 404 }) : read(request) + ) + await reconciler.reconcile(host) + expect( + appsApi.createNamespacedDeployment.mock.calls.filter( + ([r]) => r.body.metadata.name === host.name + ) + ).toHaveLength(0) + expect(reconciler.getStatus(host.name)).toMatchObject({ deployed: false, ready: false }) + synced = true + await reconciler.reconcile(host) + expect( + appsApi.createNamespacedDeployment.mock.calls.filter( + ([r]) => r.body.metadata.name === host.name + ) + ).toHaveLength(1) + }) + + it('re-reads the applied template after a scale conflict and preserves the fresh UID and revision', async () => { + const host = makeStatelessHost({ status: suspendedStatus() }) + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + let current = structuredClone(applied) + current.metadata = { ...current.metadata, uid: 'deployment-uid', resourceVersion: '41' } + const baseline = JSON.stringify(current.spec!.template) + appsApi.readNamespacedDeployment.mockImplementation(async () => structuredClone(current)) + appsApi.replaceNamespacedDeployment.mockImplementation(async request => { + expect(request.body.metadata.uid).toBe('deployment-uid') + expect(request.body.metadata.resourceVersion).toBe(current.metadata!.resourceVersion) + expect(request.body.spec.template).toEqual(current.spec!.template) + if (current.metadata!.resourceVersion === '41') { + current.metadata!.resourceVersion = '42' + current.spec!.template.metadata!.annotations = { 'example.org/rollout': 'fresh' } + throw { code: 409 } + } + current = structuredClone(request.body) + return current + }) + await reconciler.reconcile(host) + expect(appsApi.replaceNamespacedDeployment).toHaveBeenCalledTimes(2) + expect(current.spec!.replicas).toBe(1) + expect(JSON.stringify(applied.spec!.template)).toBe(baseline) + expect(current.spec!.template.metadata!.annotations).toEqual({ 'example.org/rollout': 'fresh' }) + }) + + it.each(['foreign owner', 'missing UID', 'missing resourceVersion'])( + 'does not scale an unverified Deployment during cache loss: %s', + async invalid => { + const host = makeStatelessHost({ status: suspendedStatus() }) + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + applied.metadata = { ...applied.metadata, uid: 'deployment-uid', resourceVersion: '73' } + if (invalid === 'foreign owner') applied.metadata.labels!['clerum.io/host'] = 'another-host' + if (invalid === 'missing UID') delete applied.metadata.uid + if (invalid === 'missing resourceVersion') delete applied.metadata.resourceVersion + appsApi.readNamespacedDeployment.mockResolvedValue(applied) + await expect(reconciler.reconcile(host)).rejects.toThrow('unverified Deployment') + expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() + expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() + } + ) + + it('holds the existing template when channel authority is lost during runtime provisioning', async () => { + let synced = true + const host = makeStatelessHost({ status: suspendedStatus() }) + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => synced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = reconciler.buildDeployment(host) + applied.spec!.template.metadata!.annotations = { 'example.org/applied': 'keep-exactly' } + const live = persistHostDeployment(appsApi, host, applied) + vi.spyOn(reconciler as any, 'provisionRuntimeTokenRevision').mockImplementation(async () => { + synced = false + return runtimeTokenProvision(host) + }) + await reconciler.reconcile(host) + expect(live().spec!.replicas).toBe(1) + expect(live().spec!.template).toEqual(applied.spec!.template) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'CommunicationChannelCacheUnsynced' + ) + }) + + it('uses the fenced replica-only update for a pending wake during cache loss', async () => { + const host = makeStatelessHost({ status: suspendedStatus() }) + host.annotations = { 'clerum.io/wake-requested': '1' } + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + const live = persistHostDeployment(appsApi, host, applied) + await reconciler.reconcile(host) + expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() + expect(live().spec!.replicas).toBe(1) + expect(live().spec!.template).toEqual(applied.spec!.template) + }) + + it('does not apply a channel rejection after that inventory loses authority mid-reconcile', async () => { + let synced = true + const host = makeStatelessHost() + const { reconciler, appsApi, customApi, networkingApi } = createReconciler({ + countCommunicationChannels: () => 1, + isCommunicationChannelCacheSynced: () => synced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + const live = persistHostDeployment(appsApi, host, applied) + const readPolicy = networkingApi.readNamespacedNetworkPolicy.getMockImplementation()! + networkingApi.readNamespacedNetworkPolicy.mockImplementation(async request => { + synced = false + return readPolicy(request) + }) + await reconciler.reconcile(host) + expect(live().spec!.template).toEqual(applied.spec!.template) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'CommunicationChannelCacheUnsynced' + ) + }) + + it('preserves the template when authority is lost during the final asynchronous scope lookup', async () => { + let synced = true + let lookup = 0 + const host = makeStatelessHost() + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => synced, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = reconciler.buildDeployment(host) + applied.spec!.template.metadata!.annotations = { 'example.org/applied': 'keep-exactly' } + const live = persistHostDeployment(appsApi, host, applied) + vi.spyOn(reconciler as any, 'provisionRuntimeTokenRevision').mockResolvedValue( + runtimeTokenProvision(host) + ) + vi.spyOn(reconciler as any, 'frontsOAuthServer').mockImplementation(async () => { + if (++lookup === 2) synced = false + return false + }) + await reconciler.reconcile(host) + expect(lookup).toBe(2) + expect(live().spec!.template).toEqual(applied.spec!.template) + expect(rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!).reason).toBe( + 'CommunicationChannelCacheUnsynced' + ) + }) + + it('preserves an independently confirmed desktop rejection while the channel cache is unknown', async () => { + const host = makeStatelessHost({ spec: { desktop: { browser: true } } }) + const { reconciler, appsApi, customApi } = createReconciler({ + countCommunicationChannels: () => 1, + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + await reconciler.reconcile(host) + const condition = rejectedCondition(lifecycleStatusWrites(customApi).at(-1)!) + expect(condition.reason).toBe('DesktopEnabled') + expect(condition.status).toBe('True') + expect( + containerEnv(hostDeploymentBody(appsApi, host.name)).some( + entry => entry.name === 'CLERUM_STATELESS_LIFECYCLE' + ) + ).toBe(false) + }) + it('reconciles an active Host twice through cache loss without changing its session path or template', async () => { let cacheSynced = true const host = makeStatelessHost({ @@ -749,10 +1011,9 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(serverHost)) await reconciler.reconcile(host) - const baseline = structuredClone(hostDeploymentBody(appsApi, host.name).spec?.template) - expect(envValue(hostDeploymentBody(appsApi, host.name), 'CLERUM_SESSION_DB_DIR')).toBe( - '/var/lib/clerum/state' - ) + const live = persistHostDeployment(appsApi, host, hostDeploymentBody(appsApi, host.name)) + const baseline = structuredClone(live().spec?.template) + expect(envValue(live(), 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') for (let cycle = 1; cycle <= 2; cycle++) { cacheSynced = false @@ -763,7 +1024,7 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { appsApi.replaceNamespacedDeployment.mockClear() await reconciler.reconcile(host) - const deployment = hostDeploymentBody(appsApi, host.name) + const deployment = live() expect(deployment.spec?.replicas).toBe(1) expect(deployment.spec?.template).toEqual(baseline) expect(envValue(deployment, 'CLERUM_SESSION_DB_DIR')).toBe('/var/lib/clerum/state') @@ -776,7 +1037,13 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { 'CommunicationChannelCacheUnsynced' ) expect(lifecycleStatusWrites(customApi).at(-1)?.lifecycle?.state).toBe('active') + expect( + appsApi.replaceNamespacedDeployment.mock.calls.filter(([r]) => r.name === host.name) + ).toHaveLength(0) cacheSynced = true + serverHost = host + await reconciler.reconcile(host) + expect(live().spec?.template).toEqual(baseline) } }) @@ -784,9 +1051,15 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { const { reconciler, appsApi, customApi } = createReconciler({ isCommunicationChannelCacheSynced: () => false, }) - await reconciler.reconcile(makeStatelessHost({ status: suspendedStatus() })) + const host = makeStatelessHost({ status: suspendedStatus() }) + const live = persistHostDeployment( + appsApi, + host, + createReconciler().reconciler.buildDeployment(host) + ) + await reconciler.reconcile(host) - const deployment = hostDeploymentBody(appsApi, 'stateless-host') + const deployment = live() expect(deployment.spec?.replicas).toBe(1) expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') @@ -805,6 +1078,7 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { isCommunicationChannelCacheSynced: () => cacheSynced, }) const host = makeStatelessHost({ status: suspendedStatus(4) }) + const live = persistHostDeployment(appsApi, host, reconciler.buildDeployment(host)) customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) const readPolicy = networkingApi.readNamespacedNetworkPolicy.getMockImplementation()! networkingApi.readNamespacedNetworkPolicy.mockImplementation(async request => { @@ -818,12 +1092,8 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { await reconciler.reconcile(host) expect(networkingApi.readNamespacedNetworkPolicy).toHaveBeenCalled() - expect(provision).toHaveBeenCalledOnce() - expect(provision).toHaveBeenCalledWith( - host, - expect.objectContaining({ targetSuspended: false }) - ) - const deployment = hostDeploymentBody(appsApi, host.name) + expect(provision).not.toHaveBeenCalled() + const deployment = live() expect(deployment.spec?.replicas).toBe(1) expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') @@ -1100,7 +1370,7 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { expect(provision).toHaveBeenCalledOnce() }) - it('does not repeat bootstrap provisioning for active cache loss without channels', async () => { + it('does not bootstrap new runtime credentials for active cache loss without channels', async () => { let cacheSynced = true const { reconciler, appsApi, customApi, networkingApi } = createReconciler({ isCommunicationChannelCacheSynced: () => cacheSynced, @@ -1108,6 +1378,7 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { const host = makeStatelessHost({ status: { lifecycle: { state: 'active', wakeHandledGeneration: 1 } }, }) + const live = persistHostDeployment(appsApi, host, reconciler.buildDeployment(host)) customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) const readPolicy = networkingApi.readNamespacedNetworkPolicy.getMockImplementation()! networkingApi.readNamespacedNetworkPolicy.mockImplementation(async request => { @@ -1121,8 +1392,8 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { await reconciler.reconcile(host) expect(networkingApi.readNamespacedNetworkPolicy).toHaveBeenCalled() - expect(provision).toHaveBeenCalledOnce() - const deployment = hostDeploymentBody(appsApi, host.name) + expect(provision).not.toHaveBeenCalled() + const deployment = live() expect(deployment.spec?.replicas).toBe(1) expect(envValue(deployment, 'CLERUM_STATELESS_LIFECYCLE')).toBe('true') expect(envValue(deployment, 'CLERUM_SESSION_STORE')).toBe('sqlite') diff --git a/host-context-controller/test/hostReconciler.networkpolicy.test.ts b/host-context-controller/test/hostReconciler.networkpolicy.test.ts index 3f7964cf19..1754a9d172 100644 --- a/host-context-controller/test/hostReconciler.networkpolicy.test.ts +++ b/host-context-controller/test/hostReconciler.networkpolicy.test.ts @@ -217,7 +217,7 @@ describe('HostReconciler.reconcile — NP wiring', () => { }) vi.spyOn(reconciler as any, 'ensurePvc').mockResolvedValue(undefined) vi.spyOn(reconciler as any, 'ensureService').mockResolvedValue(undefined) - vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(undefined) + vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(true) vi.spyOn(reconciler as any, 'ensureDesktopNetworkPolicy').mockResolvedValue(undefined) vi.spyOn(reconciler as any, 'reconcileChannelReaderDeployment').mockResolvedValue(undefined) vi.spyOn(reconciler as any, 'checkDeploymentReady').mockResolvedValue(true) @@ -230,7 +230,7 @@ describe('HostReconciler.reconcile — NP wiring', () => { const ingressSpy = vi .spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy') .mockResolvedValue(undefined) - const deploySpy = vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(undefined) + const deploySpy = vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(true) const readySpy = vi.spyOn(reconciler as any, 'checkDeploymentReady').mockResolvedValue(true) await reconciler.reconcile(HOST) diff --git a/host-context-controller/test/hostReconciler.wake.test.ts b/host-context-controller/test/hostReconciler.wake.test.ts index 4c8fcb062a..93b8175b83 100644 --- a/host-context-controller/test/hostReconciler.wake.test.ts +++ b/host-context-controller/test/hostReconciler.wake.test.ts @@ -358,7 +358,7 @@ describe('HostReconciler wake fast-path — wake × state transition matrix', () ) expect( logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .some(line => line.includes('phase=replicas_patched')) ).toBe(true) } finally { @@ -581,7 +581,7 @@ describe('HostReconciler wake fast-path — durability and restart behavior', () String(args[0]).includes('Malformed clerum.io/wake-requested') ) expect(malformedLogs).toHaveLength(1) - expect(String(malformedLogs[0][0])).toContain('"not-a-number"') + expect(JSON.parse(String(malformedLogs[0][0])).msg).toContain('"not-a-number"') } finally { errorSpy.mockRestore() } @@ -599,7 +599,7 @@ describe('HostReconciler wake fast-path — structured wake timestamps (Stage 6, await reconciler.reconcile(makeWakeHost({ wakeRequested: '2', state: 'suspended' })) const wakeLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.startsWith('[StatelessWake]')) expect(wakeLines).toEqual([ expect.stringMatching( @@ -628,7 +628,7 @@ describe('HostReconciler wake fast-path — structured wake timestamps (Stage 6, makeWakeHost({ wakeRequested: '4', state: 'draining', wakeHandledGeneration: 1 }) ) - const lines = logSpy.mock.calls.map(args => String(args[0])) + const lines = logSpy.mock.calls.map(args => JSON.parse(String(args[0])).msg as string) const phases = lines .filter(line => line.startsWith('[StatelessWake]')) .map(line => /phase=(\w+)/.exec(line)?.[1]) @@ -683,7 +683,7 @@ describe('HostReconciler — scale-transition counter (Stage 6 metric)', () => { await reconciler.suspendHostFromHeartbeat(host, 'idle', 2) const metricLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.startsWith('[StatelessMetric]')) expect(metricLines).toEqual([ '[StatelessMetric] scale_transition host=stateless-host direction=up total=1', @@ -691,7 +691,7 @@ describe('HostReconciler — scale-transition counter (Stage 6 metric)', () => { ]) const suspendLines = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.startsWith('[StatelessSuspend]')) expect(suspendLines).toEqual([ expect.stringMatching( @@ -720,7 +720,7 @@ describe('HostReconciler wake fast-path — AP-1 fresh re-decide at the commit p expect(customApi.patchNamespacedCustomObjectStatus).not.toHaveBeenCalled() expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() - const lines = logSpy.mock.calls.map(args => String(args[0])) + const lines = logSpy.mock.calls.map(args => JSON.parse(String(args[0])).msg as string) expect(lines.filter(line => line.startsWith('[StatelessWake]'))).toEqual([]) expect(lines.filter(line => line.includes('Wake fast-path for'))).toEqual([]) } finally { @@ -786,7 +786,7 @@ describe('HostReconciler wake fast-path — AP-1 fresh re-decide at the commit p expect(writes[0].lifecycle).toEqual({ state: 'active', wakeHandledGeneration: 5 }) expect(appsApi.patchNamespacedDeployment).toHaveBeenCalledTimes(1) const phases = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.startsWith('[StatelessWake]')) .map(line => /phase=(\w+)/.exec(line)?.[1]) expect(phases).toEqual(['wake_observed', 'status_flipped', 'replicas_patched']) @@ -814,7 +814,7 @@ describe('HostReconciler wake fast-path — AP-1 fresh re-decide at the commit p expect(writes.at(-1)?.lifecycle).toEqual({ state: 'active', wakeHandledGeneration: 2 }) expect(appsApi.patchNamespacedDeployment).toHaveBeenCalledTimes(1) const flipped = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=status_flipped')) expect(flipped).toHaveLength(1) } finally { @@ -839,7 +839,7 @@ describe('HostReconciler wake fast-path — AP-1 fresh re-decide at the commit p expect(customApi.patchNamespacedCustomObjectStatus).toHaveBeenCalledTimes(1) expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() const flipped = logSpy.mock.calls - .map(args => String(args[0])) + .map(args => JSON.parse(String(args[0])).msg as string) .filter(line => line.includes('phase=status_flipped')) expect(flipped).toEqual([]) } finally { From 7b7718290b661f4b4438afd508bd3b965f535845 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 15:15:45 +0200 Subject: [PATCH 004/107] fix(hcc,desktop): contain scoped deletions and truthful runtime holds HCC now keeps the applied Deployment bound to the current Host UID, reports missing applies truthfully, and derives held readiness from the full PVC, Service, and NetworkPolicy boundary. Desktop captures an environment/user/team deletion fence, persists scoped tombstones with retryable cleanup, separates unconfirmed authorization failures from confirmed revocation, fences authority/navigation epochs, and isolates no-chat drafts by agent. The stateless cache-containment gate gains wake capability, renewable two-cycle holds, CI contract coverage, and an explicit owned-profile T2 entry point. --- .github/workflows/ci-public.yml | 3 + Makefile | 13 + desktop-app/src/__tests__/chatStore.test.ts | 101 +++++- desktop-app/src/appService.ts | 23 +- desktop-app/src/chatStore.ts | 261 ++++++++++++++-- desktop-app/src/chatStoreBinding.ts | 22 +- desktop-app/src/ipc.ts | 89 +++++- desktop-app/src/preload.ts | 6 +- desktop-app/src/renderer.d.ts | 10 +- desktop-app/src/types.ts | 29 +- desktop-app/test/ipc.test.ts | 62 ++++ .../ui/src/components/SidebarNav/index.tsx | 40 ++- .../components/__tests__/SidebarNav.test.tsx | 3 + .../ui/src/components/agents/ChatThread.tsx | 33 +- .../src/components/agents/ComposerPanel.tsx | 2 +- .../ChatThread.renderSemantics.test.tsx | 3 + .../chatThread.connectFailure.test.tsx | 3 + .../__tests__/chatThread.toolSteps.test.tsx | 3 + .../contexts/AgentChatActionsContext/index.ts | 6 +- .../contexts/AgentChatActionsContext/types.ts | 16 +- .../__fixtures__/controllerHarness.tsx | 3 + .../__tests__/__fixtures__/mockClerum.ts | 9 +- .../chatActions.characterization.test.tsx | 9 +- .../__tests__/chatListTitleMerge.test.tsx | 51 +++- .../__tests__/renameSessionQueue.test.tsx | 64 +++- .../__tests__/switchToChat.unified.test.tsx | 37 ++- .../useAgentChatController.crossChat.test.tsx | 52 +++- ...eAgentChatController.pendingModel.test.tsx | 11 +- ...PrewarmController.chatIntegration.test.tsx | 3 + .../hooks/domain/useAgentChatController.ts | 67 ++-- .../src/hooks/domain/useChatListController.ts | 289 +++++++++++++++--- .../ui/src/hooks/useAgentChatActionsValue.ts | 3 + desktop-app/ui/src/hooks/useAppController.ts | 29 +- desktop-app/ui/src/hooks/useChatStore.ts | 12 +- desktop-app/ui/src/hooks/useComposerDraft.ts | 14 +- .../lib/__tests__/composerDraftStore.test.ts | 15 + desktop-app/ui/src/lib/composerDraftStore.ts | 42 +-- host-context-controller/package-lock.json | 4 +- host-context-controller/package.json | 2 +- .../hostReconciler.rbacPvcReadFirst.test.ts | 30 +- .../src/hostReconciler.readFirst.test.ts | 8 +- .../src/hostReconciler.test.ts | 6 +- host-context-controller/src/hostReconciler.ts | 279 +++++++++++------ .../test/hostReconciler.lifecycle.test.ts | 130 ++++++-- .../test/hostReconciler.networkpolicy.test.ts | 38 ++- scripts/e2e/_lib/hcc-watch-api-proxy.mjs | 93 +++++- .../e2e-hcc-stateless-cache-containment.sh | 47 ++- scripts/tests/hcc-watch-api-proxy.test.mjs | 22 ++ .../test-hcc-stateless-cache-containment.sh | 24 +- 49 files changed, 1765 insertions(+), 356 deletions(-) diff --git a/.github/workflows/ci-public.yml b/.github/workflows/ci-public.yml index 3846e51657..9a240542e0 100644 --- a/.github/workflows/ci-public.yml +++ b/.github/workflows/ci-public.yml @@ -528,6 +528,9 @@ jobs: - name: Test HCC watch-churn readiness gate contract run: bash scripts/tests/test-hcc-watch-churn-readiness-gate.sh + - name: Test HCC stateless cache-containment gate contract + run: bash scripts/tests/test-hcc-stateless-cache-containment.sh + - name: HCC policy lifecycle and restoration contracts run: | bash scripts/tests/test-hcc-watch-churn-empty-fleet.sh diff --git a/Makefile b/Makefile index 820c05904d..21f5e46f5b 100644 --- a/Makefile +++ b/Makefile @@ -783,6 +783,19 @@ minikube-t2-hcc-watch-recovery: ## Certify PR A recovery omission, runtime repai @bash scripts/tests/test-hcc-watch-pr-a.sh @E2E_HCC_PR_A=1 $(MAKE) minikube-t2-hcc-networkpolicy-lifecycle +.PHONY: minikube-t2-hcc-stateless-cache-containment +minikube-t2-hcc-stateless-cache-containment: ## Opt-in branch-owned T2 cache-containment journey; requires matching profile/context and refreshed user-facing forwards + @bash scripts/tests/test-hcc-stateless-cache-containment.sh + @context='$(CONTROL_API_REAL_PG_CONTEXT)'; \ + [ -n "$$context" ] || { echo 'Set CONTROL_API_REAL_PG_CONTEXT to the verified branch-owned context' >&2; exit 1; }; \ + [ "$(MINIKUBE_PROFILE)" = "$$context" ] || { echo 'MINIKUBE_PROFILE and CONTROL_API_REAL_PG_CONTEXT must select the same branch-owned profile/context' >&2; exit 1; }; \ + printf '%s\n' "$$context" | grep -Eq '^clerum-[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$$' || { echo 'The selected context must be generated and branch-scoped' >&2; exit 1; } + @E2E_HCC_WATCH_FAULT_INJECTION=1 \ + T2_GATE_ID=minikube-t2 \ + T2_HEALTHCHECK_COMMAND='KUBECONTEXT="$$T2_CONTEXT" MINIKUBE_PROFILE="$$T2_PROFILE" E2E_BRANCH_PROFILE_ENV="$$T2_PROFILE_ENV" E2E_PROFILE_PORTS_ENV="$$T2_PORTS_ENV" E2E_EXPECTED_PRE_GATE_GATE=minikube-t2 bash scripts/e2e/e2e-hcc-stateless-cache-containment.sh' \ + T2_HEALTHCHECK_TIMEOUT_SECONDS=900 T2_HEALTHCHECK_KILL_GRACE_SECONDS=300 \ + $(MAKE) minikube-t2 + .PHONY: minikube-t2-runtime minikube-t2-runtime: ## Exact-head T2 after T0 and T1 already passed on this HEAD and profile @T2_RUN_T0=false T2_RUN_T1=false \ diff --git a/desktop-app/src/__tests__/chatStore.test.ts b/desktop-app/src/__tests__/chatStore.test.ts index 5d6875a2bc..d19c2e9e84 100644 --- a/desktop-app/src/__tests__/chatStore.test.ts +++ b/desktop-app/src/__tests__/chatStore.test.ts @@ -8,10 +8,17 @@ import type { ChatMessage } from '../types.js' let tempDir: string let store: ChatStore +const TEAM_A_SCOPE = { + environmentKey: 'env-a', + userId: 'user-a', + teamId: 'team-a', +} as const +const TEAM_B_SCOPE = { ...TEAM_A_SCOPE, teamId: 'team-b' } as const beforeEach(async () => { tempDir = await fs.mkdtemp(join(tmpdir(), 'chatstore-test-')) store = new ChatStore(tempDir) + store.setAuthorityScope(TEAM_A_SCOPE) }) afterEach(async () => { @@ -181,13 +188,16 @@ describe('deleteChat', () => { it('persists a confirmed deletion across a new store instance', async () => { await store.createChat('agent-1', 'deleted-session') await store.createChat('agent-1', 'kept-session') - await store.deleteChat('agent-1', 'deleted-session') + await store.deleteChat('agent-1', 'deleted-session', TEAM_A_SCOPE) const restartedStore = new ChatStore(tempDir) expect((await restartedStore.listChats('agent-1')).map(chat => chat.id)).toEqual([ 'kept-session', ]) - expect((await restartedStore.getIndex('agent-1')).deletedChatIds).toContain('deleted-session') + expect((await restartedStore.getIndex('agent-1')).deletedChatTombstones).toContainEqual({ + chatId: 'deleted-session', + authorityScope: TEAM_A_SCOPE, + }) }) it('does not recreate a deleted transcript from queued or restarted writes', async () => { @@ -206,7 +216,7 @@ describe('deleteChat', () => { // Invocation order is the per-chat serialization order: both writes must // observe the tombstone published by the preceding confirmed delete. - const deletion = store.deleteChat(agentRef, chatId) + const deletion = store.deleteChat(agentRef, chatId, TEAM_A_SCOPE) const queuedAppend = store.appendMessages(agentRef, chatId, [message]) const queuedReplace = store.replaceMessages(agentRef, chatId, [message]) await Promise.all([deletion, queuedAppend, queuedReplace]) @@ -219,7 +229,10 @@ describe('deleteChat', () => { ) expect(await restartedStore.loadMessages(agentRef, chatId)).toEqual([]) expect(await restartedStore.listChats(agentRef)).toEqual([]) - expect((await restartedStore.getIndex(agentRef)).deletedChatIds).toContain(chatId) + expect((await restartedStore.getIndex(agentRef)).deletedChatTombstones).toContainEqual({ + chatId, + authorityScope: TEAM_A_SCOPE, + }) await expect(fs.access(chatCacheDir(chatId))).rejects.toThrow() }) @@ -228,7 +241,7 @@ describe('deleteChat', () => { await store.saveMessages('agent-1', 'del-1', [ { id: 'm1', role: 'user', content: 'hello', timestamp: Date.now() }, ]) - await store.deleteChat('agent-1', 'del-1') + await store.deleteChat('agent-1', 'del-1', TEAM_A_SCOPE) const chats = await store.listChats('agent-1') expect(chats).toHaveLength(0) @@ -239,10 +252,79 @@ describe('deleteChat', () => { it('clears lastActiveChatId when deleting active chat', async () => { await store.createChat('agent-1', 'active-1') await store.setLastActiveChatId('agent-1', 'active-1') - await store.deleteChat('agent-1', 'active-1') + await store.deleteChat('agent-1', 'active-1', TEAM_A_SCOPE) const lastActive = await store.getLastActiveChatId('agent-1') expect(lastActive).toBeNull() }) + + it('keeps new tombstones scoped to the full authority identity', async () => { + await store.createChat('agent-1', 'team-owned-chat') + await store.deleteChat('agent-1', 'team-owned-chat', TEAM_A_SCOPE) + + const teamBStore = new ChatStore(tempDir) + teamBStore.setAuthorityScope(TEAM_B_SCOPE) + await expect(teamBStore.createChat('agent-1', 'team-owned-chat')).resolves.toMatchObject({ + id: 'team-owned-chat', + }) + await expect(store.createChat('agent-1', 'team-owned-chat')).rejects.toThrow( + 'Chat was deleted locally' + ) + }) + + it('retains legacy tombstone reads without carrying them into a new team', async () => { + await fs.mkdir(agentPath(), { recursive: true }) + await fs.writeFile( + agentPath('index.json'), + JSON.stringify({ + version: 2, + lastActiveChatId: null, + onboardingDismissed: false, + chats: [], + deletedChatIds: ['legacy-chat', 'legacy-unscoped-only'], + }) + ) + const teamBStore = new ChatStore(tempDir) + teamBStore.setAuthorityScope(TEAM_B_SCOPE) + expect((await teamBStore.getIndex('agent-1')).deletedChatIds).toContain('legacy-chat') + await expect(teamBStore.createChat('agent-1', 'legacy-chat')).resolves.toMatchObject({ + id: 'legacy-chat', + }) + + const unscopedStore = new ChatStore(tempDir) + unscopedStore.setAuthorityScope({ ...TEAM_A_SCOPE, teamId: null }) + await expect(unscopedStore.createChat('agent-1', 'legacy-unscoped-only')).rejects.toThrow( + 'Chat was deleted locally' + ) + }) + + it('keeps cleanup queued after a failure and retries it only in the owning scope', async () => { + await store.createChat('agent-1', 'retry-cleanup') + await store.saveMessages('agent-1', 'retry-cleanup', [ + { id: 'message', role: 'user', content: 'private', timestamp: 1 }, + ]) + const cleanupReadError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) + vi.spyOn(fs, 'readdir').mockRejectedValueOnce(cleanupReadError) + + await expect(store.deleteChat('agent-1', 'retry-cleanup', TEAM_A_SCOPE)).resolves.toEqual({ + cleanupPending: true, + }) + expect((await store.getIndex('agent-1')).pendingChatCleanup).toContainEqual({ + chatId: 'retry-cleanup', + authorityScope: TEAM_A_SCOPE, + }) + await expect(fs.access(chatCacheDir('retry-cleanup'))).resolves.toBeUndefined() + + store.setAuthorityScope(TEAM_B_SCOPE) + await expect(store.retryPendingDeleteCleanups(TEAM_A_SCOPE)).rejects.toThrow( + 'Chat deletion authority scope changed' + ) + await expect(fs.access(chatCacheDir('retry-cleanup'))).resolves.toBeUndefined() + + store.setAuthorityScope(TEAM_A_SCOPE) + await store.retryPendingDeleteCleanups(TEAM_A_SCOPE) + expect((await store.getIndex('agent-1')).pendingChatCleanup).toEqual([]) + await expect(fs.access(chatCacheDir('retry-cleanup'))).rejects.toThrow() + }) }) // ── messages ───────────────────────────────────────────────────────────────── @@ -288,6 +370,7 @@ describe('messages', () => { pageSize: 3, maxLocalSyncedMessages: Number.POSITIVE_INFINITY, }) + pagedStore.setAuthorityScope(TEAM_A_SCOPE) await pagedStore.createChat('agent-1', 'paged-1') const msgs = Array.from({ length: 10 }, (_, i) => ({ id: `m${i}`, @@ -318,6 +401,7 @@ describe('messages', () => { pageSize: 2, maxLocalSyncedMessages: Number.POSITIVE_INFINITY, }) + pagedStore.setAuthorityScope(TEAM_A_SCOPE) await pagedStore.createChat('agent-1', 'append-pages') await pagedStore.saveMessages('agent-1', 'append-pages', [ { id: 'm0', role: 'user', content: 'msg-0', timestamp: 0 }, @@ -708,6 +792,7 @@ describe('messages', () => { pageSize: 2, maxLocalSyncedMessages: Number.POSITIVE_INFINITY, }) + pagedStore.setAuthorityScope(TEAM_A_SCOPE) await pagedStore.createChat('agent-1', 'delete-siblings') await pagedStore.saveMessages('agent-1', 'delete-siblings', [ { id: 'm0', role: 'user', content: 'msg-0', timestamp: 0 }, @@ -718,7 +803,7 @@ describe('messages', () => { [{ id: 'backup-0', role: 'user', content: 'backup-0', timestamp: 1 }] ) - await pagedStore.deleteChat('agent-1', 'delete-siblings') + await pagedStore.deleteChat('agent-1', 'delete-siblings', TEAM_A_SCOPE) expect(await pagedStore.loadMessages('agent-1', 'delete-siblings')).toEqual([]) await expect( @@ -1894,7 +1979,7 @@ describe('corrupt/missing files', () => { await fs.writeFile(join(corruptDir, `${encodedChatId}-${legacyUuid}.json`), '{}') await fs.writeFile(join(corruptDir, `${otherEncodedChatId}-${legacyUuid}.json`), '{}') - await store.deleteChat('agent-1', chatId) + await store.deleteChat('agent-1', chatId, TEAM_A_SCOPE) await expect(fs.access(join(corruptDir, encodedChatId))).rejects.toMatchObject({ code: 'ENOENT', diff --git a/desktop-app/src/appService.ts b/desktop-app/src/appService.ts index 49980d5eab..f4a64765ed 100644 --- a/desktop-app/src/appService.ts +++ b/desktop-app/src/appService.ts @@ -42,6 +42,7 @@ import { AccessCatalog, AgentWithMcpServers, ApprovalDecisionResult, + ChatAuthorityScope, ContextBreakdownResult, DependencyHealth, DesktopAppInfo, @@ -1008,9 +1009,26 @@ export class AppService { private async bindCurrentChatStore(userId: string): Promise { await bindChatStoreForUser(userId, getActiveEnvKey(), { legacyEnvKeys: getActiveLegacyEnvKeys(), + teamId: this.me?.id === userId ? this.me.teamId : null, }) } + getChatDeletionFenceAuthority(): { + authorityScope: ChatAuthorityScope + sessionGeneration: number + } { + this.requireSessionToken() + if (!this.me) throw new Error('Not authenticated') + return { + authorityScope: { + environmentKey: getActiveEnvKey(), + userId: this.me.id, + teamId: String(this.me.teamId || '').trim() || null, + }, + sessionGeneration: this.sessionGeneration, + } + } + private async commitSessionToken( token: string, options: { refreshMe?: boolean } = {} @@ -1393,7 +1411,10 @@ export class AppService { if (this.sessionGeneration !== restoreGeneration || this.sessionToken !== token) { return { authenticated: Boolean(this.sessionToken && this.me), me: this.me } } - await bindChatStoreForUser(restoredMe.id, envKey, { legacyEnvKeys }) + await bindChatStoreForUser(restoredMe.id, envKey, { + legacyEnvKeys, + teamId: restoredMe.teamId, + }) if (this.sessionGeneration !== restoreGeneration || this.sessionToken !== token) { if (this.me) { await this.bindCurrentChatStore(this.me.id) diff --git a/desktop-app/src/chatStore.ts b/desktop-app/src/chatStore.ts index 58d560118c..cf12f65353 100644 --- a/desktop-app/src/chatStore.ts +++ b/desktop-app/src/chatStore.ts @@ -4,6 +4,9 @@ import { dirname, join } from 'node:path' import { mergeAuthoritativeServerMessages, messageServerTurnNumber } from './chatMessageMerge.js' import { assertSafeFilesystemSegment } from './pathSafety.js' import type { + ChatAuthorityScope, + ChatDeleteResult, + ChatDeleteTombstone, ChatFile, ChatIndex, ChatMessage, @@ -69,6 +72,32 @@ function isPositiveSafeInteger(value: unknown): value is number { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 } +function isChatAuthorityScope(value: unknown): value is ChatAuthorityScope { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const scope = value as Record + return ( + typeof scope.environmentKey === 'string' && + scope.environmentKey.length > 0 && + typeof scope.userId === 'string' && + scope.userId.length > 0 && + (scope.teamId === null || typeof scope.teamId === 'string') + ) +} + +function isChatDeleteTombstone(value: unknown): value is ChatDeleteTombstone { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const tombstone = value as Record + return typeof tombstone.chatId === 'string' && isChatAuthorityScope(tombstone.authorityScope) +} + +function sameChatAuthorityScope(left: ChatAuthorityScope, right: ChatAuthorityScope): boolean { + return ( + left.environmentKey === right.environmentKey && + left.userId === right.userId && + left.teamId === right.teamId + ) +} + function normalizePositiveInteger(value: number | undefined, fallback: number): number { if (value === undefined) return fallback if (!Number.isFinite(value)) return fallback @@ -169,7 +198,16 @@ function parseChatIndex(raw: string): ChatIndex { !Array.isArray(candidate.chats) || (candidate.deletedChatIds !== undefined && (!Array.isArray(candidate.deletedChatIds) || - !candidate.deletedChatIds.every((id: unknown) => typeof id === 'string'))) + !candidate.deletedChatIds.every((id: unknown) => typeof id === 'string'))) || + (candidate.deletedChatTombstones !== undefined && + (!Array.isArray(candidate.deletedChatTombstones) || + !candidate.deletedChatTombstones.every(isChatDeleteTombstone))) || + (candidate.pendingChatCleanup !== undefined && + (!Array.isArray(candidate.pendingChatCleanup) || + !candidate.pendingChatCleanup.every(isChatDeleteTombstone))) || + (candidate.pendingCleanupChatIds !== undefined && + (!Array.isArray(candidate.pendingCleanupChatIds) || + !candidate.pendingCleanupChatIds.every((id: unknown) => typeof id === 'string'))) ) { throw new Error('Invalid chat index') } @@ -293,6 +331,7 @@ function emptyIndex(): ChatIndex { export class ChatStore { private readonly pageSize: number private readonly maxLocalSyncedMessages: number + private authorityScope: ChatAuthorityScope | null = null constructor( private readonly baseDir: string, @@ -302,6 +341,38 @@ export class ChatStore { this.maxLocalSyncedMessages = normalizeMaxLocalSyncedMessages(options.maxLocalSyncedMessages) } + setAuthorityScope(scope: ChatAuthorityScope): void { + this.authorityScope = Object.freeze({ ...scope }) + } + + getAuthorityScope(): ChatAuthorityScope | null { + return this.authorityScope ? { ...this.authorityScope } : null + } + + private requireAuthorityScope(expected?: ChatAuthorityScope): ChatAuthorityScope { + const current = this.authorityScope + if (!current || (expected && !sameChatAuthorityScope(current, expected))) { + throw new Error('Chat deletion authority scope changed') + } + return current + } + + private isDeletedInScope(index: ChatIndex, chatId: string, scope = this.authorityScope): boolean { + if ( + index.deletedChatTombstones?.some( + tombstone => + tombstone.chatId === chatId && + (!scope || sameChatAuthorityScope(tombstone.authorityScope, scope)) + ) + ) { + return true + } + // Legacy entries were global inside an env/user store. Retain that behavior + // for the unscoped legacy account and for stores used without authority + // context, but never carry an unknowable old-team deletion into a new team. + return Boolean(index.deletedChatIds?.includes(chatId) && (!scope || scope.teamId === null)) + } + /** * Index read-modify-write sequences are serialized per agent because every * chat shares one `index.json`. Paged transcript operations use a separate @@ -1522,11 +1593,11 @@ export class ChatStore { async createChat(agentRef: string, chatId: string): Promise { return this.serializeIndex(agentRef, async () => { const index = await this.getIndex(agentRef) - const existing = index.chats.find(c => c.id === chatId) - if (existing) return existing - if (index.deletedChatIds?.includes(chatId)) { + if (this.isDeletedInScope(index, chatId)) { throw new Error('Chat was deleted locally') } + const existing = index.chats.find(c => c.id === chatId) + if (existing) return existing const now = new Date().toISOString() const meta: ChatMetadata = { @@ -1553,38 +1624,174 @@ export class ChatStore { }) } - async deleteChat(agentRef: string, chatId: string): Promise { - return this.serializeChat(agentRef, chatId, async () => { + async deleteChat( + agentRef: string, + chatId: string, + authorityScope: ChatAuthorityScope + ): Promise { + const scope = this.requireAuthorityScope(authorityScope) + const tombstone: ChatDeleteTombstone = { chatId, authorityScope: { ...scope } } + await this.serializeChat(agentRef, chatId, async () => { + this.requireAuthorityScope(scope) await this.serializeIndex(agentRef, async () => { + this.requireAuthorityScope(scope) const index = await this.getIndex(agentRef) index.chats = index.chats.filter(c => c.id !== chatId) - if (!index.deletedChatIds?.includes(chatId)) { - index.deletedChatIds = [...(index.deletedChatIds ?? []), chatId] + index.deletedChatTombstones = index.deletedChatTombstones ?? [] + if ( + !index.deletedChatTombstones.some( + existing => + existing.chatId === chatId && sameChatAuthorityScope(existing.authorityScope, scope) + ) + ) { + index.deletedChatTombstones.push(tombstone) } - if (index.lastActiveChatId === chatId) { - index.lastActiveChatId = null + index.pendingChatCleanup = index.pendingChatCleanup ?? [] + if ( + !index.pendingChatCleanup.some( + existing => + existing.chatId === chatId && sameChatAuthorityScope(existing.authorityScope, scope) + ) + ) { + index.pendingChatCleanup.push(tombstone) } + if (index.lastActiveChatId === chatId) index.lastActiveChatId = null await this.saveIndex(agentRef, index) }) - await fs.rm(this.chatFilePath(agentRef, chatId), { force: true }) - const corruptLegacyDir = join(this.agentDir(agentRef), '.corrupt') - await fs.rm(join(corruptLegacyDir, encodedChatId(chatId)), { recursive: true, force: true }) - const corruptLegacyEntries = await fs.readdir(corruptLegacyDir).catch(() => []) - await Promise.all( - corruptLegacyEntries - .filter(name => legacyCorruptFileBelongsToChat(name, chatId)) - .map(name => fs.rm(join(corruptLegacyDir, name), { force: true })) - ) - await fs.rm(`${this.chatFilePath(agentRef, chatId)}.tmp`, { force: true }) - await fs.rm(this.chatCompatibilitySignaturePath(agentRef, chatId), { force: true }) - await fs.rm(`${this.chatCompatibilitySignaturePath(agentRef, chatId)}.tmp`, { force: true }) - await fs.rm(this.chatDirPath(agentRef, chatId), { recursive: true, force: true }) - await this.removePagedChatSnapshotSiblings(agentRef, chatId) const cacheKey = this.chatSnapshotCleanupKey(agentRef, chatId) this.cleanedSnapshotSiblingKeys.delete(cacheKey) this.compatibilityCheckedKeys.delete(cacheKey) this.dropCompatibilityWindow(cacheKey) }) + + const cleaned = await this.finishPendingDeleteCleanup(agentRef, tombstone) + return { cleanupPending: !cleaned } + } + + private async removeChatArtifacts( + agentRef: string, + chatId: string, + authorityScope: ChatAuthorityScope + ): Promise { + const corruptLegacyDir = join(this.agentDir(agentRef), '.corrupt') + let corruptLegacyEntries: string[] + try { + corruptLegacyEntries = await fs.readdir(corruptLegacyDir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') corruptLegacyEntries = [] + else return false + } + if (!this.authorityScope || !sameChatAuthorityScope(this.authorityScope, authorityScope)) { + return false + } + const paths = [ + { path: this.chatFilePath(agentRef, chatId), recursive: false }, + { path: join(corruptLegacyDir, encodedChatId(chatId)), recursive: true }, + ...corruptLegacyEntries + .filter(name => legacyCorruptFileBelongsToChat(name, chatId)) + .map(name => ({ path: join(corruptLegacyDir, name), recursive: false })), + { path: `${this.chatFilePath(agentRef, chatId)}.tmp`, recursive: false }, + { path: this.chatCompatibilitySignaturePath(agentRef, chatId), recursive: false }, + { path: `${this.chatCompatibilitySignaturePath(agentRef, chatId)}.tmp`, recursive: false }, + { path: this.chatDirPath(agentRef, chatId), recursive: true }, + ] + let allRemoved = true + for (const target of paths) { + if (!this.authorityScope || !sameChatAuthorityScope(this.authorityScope, authorityScope)) { + return false + } + try { + await fs.rm(target.path, { recursive: target.recursive, force: true }) + } catch { + allRemoved = false + } + } + if (!this.authorityScope || !sameChatAuthorityScope(this.authorityScope, authorityScope)) { + return false + } + try { + await this.removePagedChatSnapshotSiblings(agentRef, chatId) + } catch { + allRemoved = false + } + return ( + allRemoved && + Boolean(this.authorityScope && sameChatAuthorityScope(this.authorityScope, authorityScope)) + ) + } + + private async finishPendingDeleteCleanup( + agentRef: string, + tombstone: ChatDeleteTombstone + ): Promise { + if ( + !this.authorityScope || + !sameChatAuthorityScope(this.authorityScope, tombstone.authorityScope) + ) { + return false + } + let artifactsRemoved = false + try { + artifactsRemoved = await this.removeChatArtifacts( + agentRef, + tombstone.chatId, + tombstone.authorityScope + ) + } catch { + return false + } + if ( + !artifactsRemoved || + !this.authorityScope || + !sameChatAuthorityScope(this.authorityScope, tombstone.authorityScope) + ) { + return false + } + try { + await this.serializeIndex(agentRef, async () => { + this.requireAuthorityScope(tombstone.authorityScope) + const index = await this.getIndex(agentRef) + index.pendingChatCleanup = (index.pendingChatCleanup ?? []).filter( + existing => + existing.chatId !== tombstone.chatId || + !sameChatAuthorityScope(existing.authorityScope, tombstone.authorityScope) + ) + await this.saveIndex(agentRef, index) + }) + return true + } catch { + return false + } + } + + async retryPendingDeleteCleanups(authorityScope: ChatAuthorityScope): Promise { + const scope = this.requireAuthorityScope(authorityScope) + let entries + try { + entries = await fs.readdir(this.baseDir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + let agentRef = entry.name + try { + const index = await this.getIndex(agentRef) + const pending = (index.pendingChatCleanup ?? []).filter(item => + sameChatAuthorityScope(item.authorityScope, scope) + ) + for (const tombstone of pending) { + if (!this.authorityScope || !sameChatAuthorityScope(this.authorityScope, scope)) return + await this.serializeChat(agentRef, tombstone.chatId, () => + this.finishPendingDeleteCleanup(agentRef, tombstone) + ) + } + } catch { + // An unrelated or unreadable agent index must not prevent other durable + // cleanup entries from being retried on the next authorized read. + } + } } async loadMessages( @@ -1650,7 +1857,7 @@ export class ChatStore { ): Promise { return this.serializeChat(agentRef, chatId, async () => { const index = await this.getIndex(agentRef) - if (index.deletedChatIds?.includes(chatId)) return + if (this.isDeletedInScope(index, chatId)) return const indexedCount = options.preserveExistingTotals ? await this.indexedMessageCount(agentRef, chatId) : undefined @@ -1684,7 +1891,7 @@ export class ChatStore { ): Promise { await this.serializeChat(agentRef, chatId, async () => { const index = await this.getIndex(agentRef) - if (index.deletedChatIds?.includes(chatId)) return + if (this.isDeletedInScope(index, chatId)) return const existingMeta = await this.readOrMigratePagedChatUnlocked(agentRef, chatId) const existingMessages = existingMeta ? await this.readMessagesFromPagedMeta(agentRef, chatId, existingMeta) @@ -1726,7 +1933,7 @@ export class ChatStore { ): Promise { return this.serializeChat(agentRef, chatId, async () => { const indexBeforeAppend = await this.getIndex(agentRef) - if (indexBeforeAppend.deletedChatIds?.includes(chatId)) return + if (this.isDeletedInScope(indexBeforeAppend, chatId)) return const existing = (await this.readOrMigratePagedChatUnlocked(agentRef, chatId)) ?? (await this.writePagedChatUnlocked(agentRef, chatId, [])) diff --git a/desktop-app/src/chatStoreBinding.ts b/desktop-app/src/chatStoreBinding.ts index bea6616899..67c52a4916 100644 --- a/desktop-app/src/chatStoreBinding.ts +++ b/desktop-app/src/chatStoreBinding.ts @@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs' import { join } from 'node:path' import { ChatStore } from './chatStore.js' import { assertSafeFilesystemSegment } from './pathSafety.js' +import type { ChatAuthorityScope } from './types.js' /** * Keep the shared catalog at v2 so pre-paging desktop builds can read the @@ -49,7 +50,7 @@ const PRE_ENV_MIGRATION_MARKER = '.env-scoped' export async function bindChatStoreForUser( userId: string, envKey: string, - options: { legacyEnvKeys?: readonly string[] } = {} + options: { legacyEnvKeys?: readonly string[]; teamId?: string | null } = {} ): Promise { assertSafeFilesystemSegment('userId', userId) assertSafeFilesystemSegment('envKey', envKey) @@ -60,9 +61,23 @@ export async function bindChatStoreForUser( // refreshes re-call this with an unchanged `me.id`; tearing the store down // just to rebuild it opens a window where every concurrent chat IPC fails // with "Not authenticated" (seen as an empty "Latest sessions" at boot). - if (activeChatStore && activeUserId === userId && activeEnvKey === envKey) return + const authorityScope: ChatAuthorityScope = { + environmentKey: envKey, + userId, + teamId: String(options.teamId || '').trim() || null, + } + if (activeChatStore && activeUserId === userId && activeEnvKey === envKey) { + activeChatStore.setAuthorityScope(authorityScope) + return + } const bindKey = `${envKey}::${userId}` - if (bindInFlight?.key === bindKey) return bindInFlight.promise + if (bindInFlight?.key === bindKey) { + await bindInFlight.promise + if (activeChatStore && activeUserId === userId && activeEnvKey === envKey) { + activeChatStore.setAuthorityScope(authorityScope) + } + return + } const generation = ++bindingGeneration const promise = (async () => { @@ -84,6 +99,7 @@ export async function bindChatStoreForUser( await sweepExpiredCorruptQuarantines(userDir) if (generation !== bindingGeneration) return activeChatStore = new ChatStore(userDir) + activeChatStore.setAuthorityScope(authorityScope) activeUserId = userId activeEnvKey = envKey })() diff --git a/desktop-app/src/ipc.ts b/desktop-app/src/ipc.ts index 1c458d8966..38d15fd589 100644 --- a/desktop-app/src/ipc.ts +++ b/desktop-app/src/ipc.ts @@ -26,6 +26,8 @@ import { PLUGIN_SDK_REQUEST_CHANNEL, } from './pluginSdkProtocol.js' import { + type ChatAuthorityScope, + type ChatDeleteFence, DesktopRuntimeConfig, HostActivityStreamEvent, HostMessageRequest, @@ -38,6 +40,54 @@ function sanitizeString(input: unknown): string { return String(input || '').trim() } +function sanitizeChatAuthorityScope(input: unknown): ChatAuthorityScope { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Invalid chat deletion authority scope') + } + const value = input as Record + const environmentKey = sanitizeString(value.environmentKey) + const userId = sanitizeString(value.userId) + const teamId = value.teamId === null ? null : sanitizeString(value.teamId) + if (!environmentKey || !userId || (value.teamId !== null && !teamId)) { + throw new Error('Invalid chat deletion authority scope') + } + return { environmentKey, userId, teamId } +} + +function sameChatAuthorityScope(left: ChatAuthorityScope, right: ChatAuthorityScope): boolean { + return ( + left.environmentKey === right.environmentKey && + left.userId === right.userId && + left.teamId === right.teamId + ) +} + +function sanitizeChatDeleteFence(input: unknown): ChatDeleteFence { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Scoped chat deletion is required') + } + const value = input as Record + const bindingGeneration = value.bindingGeneration + const sessionGeneration = value.sessionGeneration + if ( + value.version !== 1 || + typeof bindingGeneration !== 'number' || + !Number.isSafeInteger(bindingGeneration) || + bindingGeneration < 1 || + typeof sessionGeneration !== 'number' || + !Number.isSafeInteger(sessionGeneration) || + sessionGeneration < 0 + ) { + throw new Error('Invalid chat deletion fence') + } + return { + version: 1, + authorityScope: sanitizeChatAuthorityScope(value.authorityScope), + bindingGeneration, + sessionGeneration, + } +} + /** * Sanitize a bulk subject-key array (grant/share to many subjects in one call). * Fails loud on a non-array or empty payload rather than silently no-op'ing — an @@ -1429,13 +1479,31 @@ export function registerIpcHandlers(service: AppService): void { return getChatStoreBindingGeneration() }) + ipcMain.handle( + 'chat:captureDeleteFence', + (event, payload: { expectedAuthorityScope?: unknown }) => { + assertTrustedSender(event) + const expectedAuthorityScope = sanitizeChatAuthorityScope(payload?.expectedAuthorityScope) + const authority = service.getChatDeletionFenceAuthority() + if (!sameChatAuthorityScope(expectedAuthorityScope, authority.authorityScope)) { + throw new Error('Chat deletion authority scope changed before confirmation') + } + return { + version: 1, + authorityScope: authority.authorityScope, + bindingGeneration: getChatStoreBindingGeneration(), + sessionGeneration: authority.sessionGeneration, + } satisfies ChatDeleteFence + } + ) + ipcMain.handle( 'chat:delete', async ( event, payload: { version?: number - bindingGeneration?: number + fence?: unknown agentRef: string chatId: string } @@ -1444,9 +1512,17 @@ export function registerIpcHandlers(service: AppService): void { const agentRef = sanitizeString(payload?.agentRef) const chatId = sanitizeString(payload?.chatId) if (!agentRef || !chatId) throw new Error('agentRef and chatId are required') - if (payload?.version !== 2) throw new Error('Scoped chat deletion is required') - const store = requireChatStoreForBindingGeneration(payload.bindingGeneration as number) - await store.deleteChat(agentRef, chatId) + if (payload?.version !== 3) throw new Error('Scoped chat deletion is required') + const fence = sanitizeChatDeleteFence(payload.fence) + const authority = service.getChatDeletionFenceAuthority() + if ( + !sameChatAuthorityScope(fence.authorityScope, authority.authorityScope) || + fence.sessionGeneration !== authority.sessionGeneration + ) { + throw new Error('Chat deletion authority changed before confirmation') + } + const store = requireChatStoreForBindingGeneration(fence.bindingGeneration) + await store.deleteChat(agentRef, chatId, fence.authorityScope) } ) @@ -1598,7 +1674,10 @@ export function registerIpcHandlers(service: AppService): void { assertTrustedSender(event) const agentRef = sanitizeString(payload?.agentRef) if (!agentRef) throw new Error('agentRef is required') - return requireChatStore().getIndex(agentRef) + const store = requireChatStore() + const authority = service.getChatDeletionFenceAuthority() + await store.retryPendingDeleteCleanups(authority.authorityScope) + return store.getIndex(agentRef) }) ipcMain.handle('chat:dismissOnboarding', async (event, payload: { agentRef: string }) => { diff --git a/desktop-app/src/preload.ts b/desktop-app/src/preload.ts index 595a671967..ced4b55f97 100644 --- a/desktop-app/src/preload.ts +++ b/desktop-app/src/preload.ts @@ -581,8 +581,10 @@ const clerum = Object.freeze({ rename: (agentRef: string, chatId: string, title: string) => ipcRenderer.invoke('chat:rename', { agentRef, chatId, title }), getBindingGeneration: () => ipcRenderer.invoke('chat:bindingGeneration'), - delete: (agentRef: string, chatId: string, bindingGeneration: number) => - ipcRenderer.invoke('chat:delete', { version: 2, agentRef, chatId, bindingGeneration }), + captureDeleteFence: (expectedAuthorityScope: import('./types.js').ChatAuthorityScope) => + ipcRenderer.invoke('chat:captureDeleteFence', { expectedAuthorityScope }), + delete: (agentRef: string, chatId: string, fence: import('./types.js').ChatDeleteFence) => + ipcRenderer.invoke('chat:delete', { version: 3, agentRef, chatId, fence }), loadMessages: (agentRef: string, chatId: string, limit?: number, offset?: number) => ipcRenderer.invoke('chat:loadMessages', { agentRef, chatId, limit, offset }), appendMessages: (agentRef: string, chatId: string, messages: unknown[]) => diff --git a/desktop-app/src/renderer.d.ts b/desktop-app/src/renderer.d.ts index f7b3c2486d..5dbd6480ad 100644 --- a/desktop-app/src/renderer.d.ts +++ b/desktop-app/src/renderer.d.ts @@ -8,6 +8,9 @@ import { AccessCatalog, AgentWithMcpServers, ApprovalDecisionResult, + ChatAuthorityScope, + ChatDeleteFence, + ChatDeleteResult, ChatIndex, ChatMessage, ChatMetadata, @@ -697,7 +700,12 @@ declare global { create: (agentRef: string, chatId: string) => Promise rename: (agentRef: string, chatId: string, title: string) => Promise getBindingGeneration: () => Promise - delete: (agentRef: string, chatId: string, bindingGeneration: number) => Promise + captureDeleteFence: (expectedAuthorityScope: ChatAuthorityScope) => Promise + delete: ( + agentRef: string, + chatId: string, + fence: ChatDeleteFence + ) => Promise loadMessages: ( agentRef: string, chatId: string, diff --git a/desktop-app/src/types.ts b/desktop-app/src/types.ts index 6c4120219d..263e0d3fc2 100644 --- a/desktop-app/src/types.ts +++ b/desktop-app/src/types.ts @@ -908,15 +908,40 @@ export interface ChatMetadata { lastTerminalAt?: string } +/** The authority identity that owns a local chat deletion. */ +export interface ChatAuthorityScope { + environmentKey: string + userId: string + teamId: string | null +} + +/** Main-issued fence captured when a delete is queued and rechecked on confirm. */ +export interface ChatDeleteFence { + version: 1 + authorityScope: ChatAuthorityScope + bindingGeneration: number + sessionGeneration: number +} + +/** Durable tombstone and cleanup identity for one scoped local deletion. */ +export interface ChatDeleteTombstone { + chatId: string + authorityScope: ChatAuthorityScope +} + export interface ChatIndex { /** v2 remains readable by pre-paging builds; v3 is accepted and normalized for compatibility. */ version: 1 | 2 | 3 lastActiveChatId: string | null onboardingDismissed: boolean chats: ChatMetadata[] - /** Optional for older indexes. User-confirmed local deletions stay hidden from remote lists. */ + /** Legacy tombstones from pre-scoped versions; retained for read compatibility. */ deletedChatIds?: string[] - /** Logical deletions whose on-disk cache cleanup must be retried. */ + /** New deletions are isolated to their complete authority identity. */ + deletedChatTombstones?: ChatDeleteTombstone[] + /** Durable artifact-cleanup work, retried only in the owning authority scope. */ + pendingChatCleanup?: ChatDeleteTombstone[] + /** Legacy pending cleanup field, read for compatibility with intermediate builds. */ pendingCleanupChatIds?: string[] } diff --git a/desktop-app/test/ipc.test.ts b/desktop-app/test/ipc.test.ts index 439240e232..8072118e34 100644 --- a/desktop-app/test/ipc.test.ts +++ b/desktop-app/test/ipc.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { promises as fs } from 'node:fs' import path from 'node:path' +import { + __setChatStoreBaseDirForTests, + bindChatStoreForUser, + getChatStoreBindingGeneration, + requireChatStore, + unbindChatStore, +} from '../src/chatStoreBinding.js' import { registerIpcHandlers, sanitizeChatLoadWindow } from '../src/ipc.js' import type { HostStatusStreamEvent } from '../src/types.js' @@ -104,6 +111,7 @@ describe('ipc host status stream handlers', () => { listArtifacts: vi.fn(), downloadArtifact: vi.fn(), listSessions: vi.fn(), + getChatDeletionFenceAuthority: vi.fn(), loadSessionMessages: vi.fn(), renameSession: vi.fn(), listWorkflowRuns: vi.fn(), @@ -132,6 +140,12 @@ describe('ipc host status stream handlers', () => { vi.clearAllMocks() await fs.rm(testState.downloadsDir, { recursive: true, force: true }) await fs.mkdir(testState.downloadsDir, { recursive: true }) + unbindChatStore() + __setChatStoreBaseDirForTests(path.join(testState.downloadsDir, 'chat-cache')) + service.getChatDeletionFenceAuthority.mockReturnValue({ + authorityScope: { environmentKey: 'env-1', userId: 'user-1', teamId: 'team-a' }, + sessionGeneration: 7, + }) service.stopHostStatusStream.mockReturnValue(true) registerIpcHandlers(service as never) }) @@ -145,6 +159,54 @@ describe('ipc host status stream handlers', () => { ).rejects.toThrow('Scoped chat deletion is required') }) + it('captures and rechecks the main-owned authority and binding fence', async () => { + await bindChatStoreForUser('user-1', 'env-1', { teamId: 'team-a' }) + const { event } = makeTrustedEvent() + const capture = testState.handlers.get('chat:captureDeleteFence') + const remove = testState.handlers.get('chat:delete') + const expectedAuthorityScope = { + environmentKey: 'env-1', + userId: 'user-1', + teamId: 'team-a', + } + const fence = await Promise.resolve(capture?.(event, { expectedAuthorityScope })) + expect(fence).toMatchObject({ + version: 1, + authorityScope: expectedAuthorityScope, + bindingGeneration: getChatStoreBindingGeneration(), + sessionGeneration: 7, + }) + + // Returning to the same team does not revive the old confirmation: the + // auth/session epoch changed during the intervening team switch. + service.getChatDeletionFenceAuthority.mockReturnValue({ + authorityScope: expectedAuthorityScope, + sessionGeneration: 9, + }) + await expect( + Promise.resolve(remove?.(event, { version: 3, agentRef: 'agent-x', chatId: 'c1', fence })) + ).rejects.toThrow('Chat deletion authority changed before confirmation') + }) + + it('persists a scoped tombstone before returning from the fenced delete IPC', async () => { + await bindChatStoreForUser('user-1', 'env-1', { teamId: 'team-a' }) + const { event } = makeTrustedEvent() + const capture = testState.handlers.get('chat:captureDeleteFence') + const remove = testState.handlers.get('chat:delete') + const authorityScope = { + environmentKey: 'env-1', + userId: 'user-1', + teamId: 'team-a', + } + const fence = await Promise.resolve( + capture?.(event, { expectedAuthorityScope: authorityScope }) + ) + await Promise.resolve(remove?.(event, { version: 3, agentRef: 'agent-x', chatId: 'c1', fence })) + const index = await requireChatStore().getIndex('agent-x') + expect(index.deletedChatTombstones).toContainEqual({ chatId: 'c1', authorityScope }) + expect(index.pendingChatCleanup).toEqual([]) + }) + it('rejects untrusted sender for stream start', async () => { const handler = testState.handlers.get('rpc:hostStatusStreamStart') expect(handler).toBeDefined() diff --git a/desktop-app/ui/src/components/SidebarNav/index.tsx b/desktop-app/ui/src/components/SidebarNav/index.tsx index 3df859e8ac..3770436977 100644 --- a/desktop-app/ui/src/components/SidebarNav/index.tsx +++ b/desktop-app/ui/src/components/SidebarNav/index.tsx @@ -1,5 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { useAgentChatActionsContext } from '@contexts/AgentChatActionsContext' +import { + type QueuedChatDelete, + useAgentChatActionsContext, +} from '@contexts/AgentChatActionsContext' import { makeTaskKey } from '@contexts/AgentTaskTrackerContext/types' import { useAuthContext } from '@contexts/AuthContext' import { useChatListContext } from '@contexts/ChatListContext' @@ -53,6 +56,7 @@ export function SidebarNav({ const { activeChatId, latestChatSessions, latestChatSessionsLoading, sessionStateByChatKey } = useChatListContext() const { + captureChatDeleteFence, handleRenameChatForAgent: onRenameChatForAgent, handleDeleteChatForAgent: onDeleteChatForAgent, } = useAgentChatActionsContext() @@ -71,6 +75,7 @@ export function SidebarNav({ agentRef: string chatId: string title: string + deletion: QueuedChatDelete } | null>(null) const lastToggleRequestIdRef = useRef(0) const desktopAppInfo = useDesktopAppInfo() @@ -88,6 +93,24 @@ export function SidebarNav({ [onSettingsMenuOpenChange] ) + const requestDeleteSession = React.useCallback( + async (session: { agentRef: string; id: string; title: string }) => { + setSessionMenuId(null) + try { + const deletion = await captureChatDeleteFence(session.agentRef) + setPendingDeleteSession({ + agentRef: session.agentRef, + chatId: session.id, + title: session.title, + deletion, + }) + } catch { + // captureChatDeleteFence reports the failure through the shared chat toast. + } + }, + [captureChatDeleteFence, pushToast] + ) + useClickOutside(settingsMenuRef, settingsMenuOpen, () => { updateSettingsMenuOpen(false) }) @@ -503,14 +526,7 @@ export function SidebarNav({ role="menuitem" color="danger" className="danger" - onClick={() => { - setSessionMenuId(null) - setPendingDeleteSession({ - agentRef: session.agentRef, - chatId: session.id, - title: session.title, - }) - }} + onClick={() => void requestDeleteSession(session)} > Delete @@ -749,7 +765,11 @@ export function SidebarNav({ tone="danger" onCancel={() => setPendingDeleteSession(null)} onConfirm={() => { - void onDeleteChatForAgent(pendingDeleteSession.agentRef, pendingDeleteSession.chatId) + void onDeleteChatForAgent( + pendingDeleteSession.agentRef, + pendingDeleteSession.chatId, + pendingDeleteSession.deletion + ) setPendingDeleteSession(null) }} /> diff --git a/desktop-app/ui/src/components/__tests__/SidebarNav.test.tsx b/desktop-app/ui/src/components/__tests__/SidebarNav.test.tsx index 38ac267270..eda9d18393 100644 --- a/desktop-app/ui/src/components/__tests__/SidebarNav.test.tsx +++ b/desktop-app/ui/src/components/__tests__/SidebarNav.test.tsx @@ -24,6 +24,9 @@ vi.mock('@contexts/ChatListContext', () => ({ })) vi.mock('@contexts/AgentChatActionsContext', () => ({ useAgentChatActionsContext: () => ({ + captureChatDeleteFence: vi.fn(async () => { + throw new Error('no delete target') + }), handleRenameChatForAgent: vi.fn(), handleDeleteChatForAgent: vi.fn(), }), diff --git a/desktop-app/ui/src/components/agents/ChatThread.tsx b/desktop-app/ui/src/components/agents/ChatThread.tsx index c272bc721f..67a5b75f4e 100644 --- a/desktop-app/ui/src/components/agents/ChatThread.tsx +++ b/desktop-app/ui/src/components/agents/ChatThread.tsx @@ -7,7 +7,10 @@ import { useRef, useState, } from 'react' -import { useAgentChatActionsContext } from '@contexts/AgentChatActionsContext' +import { + type QueuedChatDelete, + useAgentChatActionsContext, +} from '@contexts/AgentChatActionsContext' import { useChatListContext } from '@contexts/ChatListContext' import { useChatThreadStateContext } from '@contexts/ChatThreadStateContext' import { useMcpRuntimeContext } from '@contexts/McpRuntimeContext' @@ -239,6 +242,7 @@ export function ChatThread({ showAgentLabel = false, onScrollPositionChange }: C chatEndRef, handleSelectChat: onSelectChat, handleRenameChat: onRenameChat, + captureChatDeleteFence, handleDeleteChat: onDeleteChat, } = useAgentChatActionsContext() const { cancelTask: onCancelTask } = useMcpRuntimeContext() @@ -249,8 +253,10 @@ export function ChatThread({ showAgentLabel = false, onScrollPositionChange }: C const [renamingSessionId, setRenamingSessionId] = useState(null) const [sessionRenameValue, setSessionRenameValue] = useState('') const [pendingDeleteSession, setPendingDeleteSession] = useState<{ + agentRef: string id: string title: string + deletion: QueuedChatDelete } | null>(null) const dedicatedSessionsRef = useRef(null) const sessionRenameInputRef = useRef(null) @@ -406,16 +412,29 @@ export function ChatThread({ showAgentLabel = false, onScrollPositionChange }: C cancelSessionRename() }, [cancelSessionRename, onRenameChat, renamingSessionId, sessionRenameValue]) - const deleteSession = useCallback((chatId: string, chatTitle: string) => { - setSessionMenuChatId(null) - setPendingDeleteSession({ id: chatId, title: chatTitle }) - }, []) + const deleteSession = useCallback( + async (chatId: string, chatTitle: string) => { + setSessionMenuChatId(null) + if (!selectedAgent) return + try { + const deletion = await captureChatDeleteFence(selectedAgent) + setPendingDeleteSession({ agentRef: selectedAgent, id: chatId, title: chatTitle, deletion }) + } catch { + // Capture reports the scope/authority failure through the normal chat toast path. + } + }, + [captureChatDeleteFence, selectedAgent] + ) const confirmDeleteSession = useCallback(() => { if (!pendingDeleteSession) return - void onDeleteChat(pendingDeleteSession.id) + if (selectedAgent !== pendingDeleteSession.agentRef) { + setPendingDeleteSession(null) + return + } + void onDeleteChat(pendingDeleteSession.id, pendingDeleteSession.deletion) setPendingDeleteSession(null) - }, [onDeleteChat, pendingDeleteSession]) + }, [onDeleteChat, pendingDeleteSession, selectedAgent]) const handleCopyMessage = useCallback(async (messageKey: string, content: string) => { try { diff --git a/desktop-app/ui/src/components/agents/ComposerPanel.tsx b/desktop-app/ui/src/components/agents/ComposerPanel.tsx index dd17760d79..191bd2fbeb 100644 --- a/desktop-app/ui/src/components/agents/ComposerPanel.tsx +++ b/desktop-app/ui/src/components/agents/ComposerPanel.tsx @@ -162,7 +162,7 @@ export function ComposerPanel({ inline = false, agentSelector }: ComposerPanelPr // Per-chat draft from the module-scoped store. Typing only re-renders this // component (it's the sole subscriber); the value is restored automatically when // activeChatId changes and survives the inline→docked composer swap. - const [draft, setDraft] = useComposerDraft(activeChatId) + const [draft, setDraft] = useComposerDraft(activeChatId, selectedAgent ?? undefined) const composerInputRef = useRef(null) const composerInputWidthRef = useRef(null) const composerFileInputRef = useRef(null) diff --git a/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx b/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx index 838dd7971d..7305b9b1bd 100644 --- a/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/ChatThread.renderSemantics.test.tsx @@ -16,6 +16,9 @@ const actionsValue = { chatEndRef: { current: null }, handleSelectChat: vi.fn(), handleRenameChat: vi.fn(), + captureChatDeleteFence: vi.fn(async () => { + throw new Error('no delete target') + }), handleDeleteChat: vi.fn(), } const runtimeValue = { cancelTask: vi.fn() } diff --git a/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx b/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx index c863860722..0573203575 100644 --- a/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/chatThread.connectFailure.test.tsx @@ -19,6 +19,9 @@ const actionsValue = { chatEndRef: { current: null }, handleSelectChat: vi.fn(), handleRenameChat: vi.fn(), + captureChatDeleteFence: vi.fn(async () => { + throw new Error('no delete target') + }), handleDeleteChat: vi.fn(), } const runtimeValue = { cancelTask: vi.fn() } diff --git a/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx b/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx index 569adc9b35..0013f2e1c0 100644 --- a/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx +++ b/desktop-app/ui/src/components/agents/__tests__/chatThread.toolSteps.test.tsx @@ -26,6 +26,9 @@ const actionsValue = { chatEndRef: { current: null }, handleSelectChat: vi.fn(), handleRenameChat: vi.fn(), + captureChatDeleteFence: vi.fn(async () => { + throw new Error('no delete target') + }), handleDeleteChat: vi.fn(), } const mcpRuntimeValue = { cancelTask: vi.fn() } diff --git a/desktop-app/ui/src/contexts/AgentChatActionsContext/index.ts b/desktop-app/ui/src/contexts/AgentChatActionsContext/index.ts index 39d0cfe9d1..50ee10b34b 100644 --- a/desktop-app/ui/src/contexts/AgentChatActionsContext/index.ts +++ b/desktop-app/ui/src/contexts/AgentChatActionsContext/index.ts @@ -1,3 +1,7 @@ export { AgentChatActionsContext, AgentChatActionsProvider } from './context' export { useAgentChatActionsContext } from './useAgentChatActionsContext' -export type { AgentChatActionsContextValue, AgentChatActionsProviderProps } from './types' +export type { + AgentChatActionsContextValue, + AgentChatActionsProviderProps, + QueuedChatDelete, +} from './types' diff --git a/desktop-app/ui/src/contexts/AgentChatActionsContext/types.ts b/desktop-app/ui/src/contexts/AgentChatActionsContext/types.ts index 81beab82ab..be847729ff 100644 --- a/desktop-app/ui/src/contexts/AgentChatActionsContext/types.ts +++ b/desktop-app/ui/src/contexts/AgentChatActionsContext/types.ts @@ -1,6 +1,13 @@ import type { ReactNode, RefObject } from 'react' +import type { ChatDeleteFence } from '../../../../src/types' import type { ComposerImageAttachment, ComposerReferenceAttachment } from '../../uiTypes' +export interface QueuedChatDelete { + agentRef: string + fence: ChatDeleteFence + hostAuthorityEpoch: number +} + /** * Stable, cross-cutting chat actions. These are `useCallback` handlers plus the * scroll ref — split out from chat state so action-only consumers (e.g. FleetBoard) @@ -12,8 +19,13 @@ export interface AgentChatActionsContextValue { handleCreateChat: () => Promise handleRenameChat: (chatId: string, newTitle: string) => Promise handleRenameChatForAgent: (agentRef: string, chatId: string, newTitle: string) => Promise - handleDeleteChat: (chatId: string) => Promise - handleDeleteChatForAgent: (agentRef: string, chatId: string) => Promise + captureChatDeleteFence: (agentRef: string) => Promise + handleDeleteChat: (chatId: string, deletion: QueuedChatDelete) => Promise + handleDeleteChatForAgent: ( + agentRef: string, + chatId: string, + deletion: QueuedChatDelete + ) => Promise handleSelectChat: (chatId: string) => Promise clearComposerSendError: () => void handleAddComposerImageAttachments: (attachments: ComposerImageAttachment[]) => void diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx index 61005ad173..ca91cb722b 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/controllerHarness.tsx @@ -7,6 +7,7 @@ import { useAgentChatController } from '../../useAgentChatController' const onHostAccessRevoked = () => {} const onHostAuthorityUncertain = () => {} const isHostAccessBlocked = () => false +const getHostAuthorityEpoch = () => 0 type ControllerParams = Parameters[0] @@ -51,6 +52,7 @@ export function renderController( selectedAgent: 'agent-x', agentNames: ['agent-x'], currentTeamId: 'team-1', + currentEnvironmentKey: 'env-test', currentTeamName: 'Team 1', isAuthenticated: true, loadMenuData: true, @@ -58,6 +60,7 @@ export function renderController( onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, pushToast: spies.pushToast, pushNotification: spies.pushNotification, // Identity resolver by default (no catalog display layer in the harness). diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts index 44fd8435a9..5859831581 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts @@ -22,6 +22,7 @@ interface ChatMock { create: Fn rename: Fn getBindingGeneration: Fn + captureDeleteFence: Fn delete: Fn loadMessages: Fn appendMessages: Fn @@ -78,7 +79,13 @@ export function installMockClerum(): MockClerum { })), rename: vi.fn(async () => undefined), getBindingGeneration: vi.fn(async () => 1), - delete: vi.fn(async () => undefined), + captureDeleteFence: vi.fn(async (authorityScope: unknown) => ({ + version: 1, + authorityScope, + bindingGeneration: 1, + sessionGeneration: 1, + })), + delete: vi.fn(async () => ({ cleanupPending: false })), loadMessages: vi.fn(async () => []), appendMessages: vi.fn(async () => undefined), replaceMessages: vi.fn(async () => undefined), diff --git a/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx index e6751a145e..20ef621e9f 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/chatActions.characterization.test.tsx @@ -59,13 +59,18 @@ describe('handleDeleteChat', () => { expect(result.current.sessionStateByChatKey[key]?.state).toBe('processing') await act(async () => { - await result.current.handleDeleteChat(chatId) + const deletion = await result.current.captureChatDeleteFence('agent-x') + await result.current.handleDeleteChat(chatId, deletion) }) // ack tore down the SSE subscription (no handler left) so a late terminal // can't resurrect the deleted chat. expect(clerum.hasProgressHandler('task-del')).toBe(false) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', chatId, 1) + expect(clerum.chat.delete).toHaveBeenCalledWith( + 'agent-x', + chatId, + expect.objectContaining({ version: 1, bindingGeneration: 1 }) + ) // D.5 review #5: the deleted chat's session-state entry is dropped. expect(result.current.sessionStateByChatKey[key]).toBeUndefined() // It was the only/active chat → a fresh chat is created and selected. diff --git a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx index 165d810e0b..0c41cbccef 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx @@ -51,6 +51,7 @@ function localIndex( chats: Array<{ id: string; title: string }>, deletedChatIds: string[] = [] ): ChatIndex { + const authorityScope = { environmentKey: 'env-test', userId: 'unknown-user', teamId: 'team-1' } return { version: 1, lastActiveChatId: null, @@ -63,6 +64,7 @@ function localIndex( messageCount: 0, })), deletedChatIds, + deletedChatTombstones: deletedChatIds.map(chatId => ({ chatId, authorityScope })), } } @@ -229,12 +231,14 @@ describe('revoked host catalog protection', () => { ) ) - clerum.rpc.loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + clerum.rpc.loadSessionMessages.mockRejectedValue( + new Error('403 forbidden: host_access_revoked') + ) await expect( controller.result.current.reconcileChat(makeTaskKey('agent-y', 'protected-y'), { reason: 'background_refresh', }) - ).resolves.toBe('authority_unverified') + ).resolves.toBe('revoked') expect(blocked.has('agent-y')).toBe(true) await waitFor(() => expect(controller.result.current.latestChatSessions.some(s => s.id === 'protected-y')).toBe( @@ -296,6 +300,40 @@ describe('revoked host catalog protection', () => { }) }) +describe('host authority verification', () => { + it('discards a pre-verification authorization denial after the host authority epoch advances', async () => { + let hostAuthorityEpoch = 0 + const revoked = new Set() + const uncertain = new Set() + clerum.chat.getIndex.mockResolvedValue(localIndex([])) + let rejectFirst!: (reason: unknown) => void + let firstCatalogRead = true + clerum.rpc.listSessions.mockImplementation(() => { + if (!firstCatalogRead) return Promise.resolve(serverSessions([])) + firstCatalogRead = false + return new Promise((_resolve, reject) => { + rejectFirst = reject + }) + }) + + const controller = renderController({ + selectedAgent: 'agent-x', + agentNames: ['agent-x'], + onHostAccessRevoked: agentRef => revoked.add(agentRef), + onHostAuthorityUncertain: agentRef => uncertain.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef) || uncertain.has(agentRef), + getHostAuthorityEpoch: () => hostAuthorityEpoch, + }) + + await waitFor(() => expect(clerum.rpc.listSessions).toHaveBeenCalled()) + hostAuthorityEpoch += 1 + rejectFirst(new Error('403 stale')) + await waitFor(() => expect(controller.result.current.latestChatSessionsLoading).toBe(false)) + expect(revoked.has('agent-x')).toBe(false) + expect(uncertain.has('agent-x')).toBe(false) + }) +}) + describe('late global catalog response', () => { it.each(['revocation', 'confirmed deletion'])( 'does not republish Host A after %s while Host B is pending', @@ -336,9 +374,14 @@ describe('late global catalog response', () => { expect(blocked.has('agent-a')).toBe(true) } else { await act(async () => { - await controller.result.current.handleDeleteChatForAgent('agent-a', 'session-a') + const deletion = await controller.result.current.captureChatDeleteFence('agent-a') + await controller.result.current.handleDeleteChatForAgent('agent-a', 'session-a', deletion) }) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-a', 'session-a', 1) + expect(clerum.chat.delete).toHaveBeenCalledWith( + 'agent-a', + 'session-a', + expect.objectContaining({ version: 1, bindingGeneration: 1 }) + ) } await act(async () => { diff --git a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx index 036ed97cd0..183a1ffe33 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/renameSessionQueue.test.tsx @@ -14,7 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, waitFor } from '@testing-library/react' import { parseSessionsListResult } from '../../../../../src/rpcProxyClient' import type { ChatIndex } from '../../../../../src/types' -import { MAX_RENAME_SYNC_ATTEMPTS } from '../useChatListController' +import { MAX_RENAME_SYNC_ATTEMPTS, deletedChatIdsForScope } from '../useChatListController' import { renderController } from './__fixtures__/controllerHarness' import { type MockClerum, installMockClerum, uninstallMockClerum } from './__fixtures__/mockClerum' @@ -53,6 +53,47 @@ function localIndex(chats: Array<{ id: string; title: string }>): ChatIndex { } } +describe('scoped deletion visibility', () => { + it('filters tombstones by environment, user, and team while keeping legacy reads conservative', () => { + const index: ChatIndex = { + ...localIndex([]), + deletedChatIds: ['legacy-delete'], + deletedChatTombstones: [ + { + chatId: 'team-a-delete', + authorityScope: { environmentKey: 'env-1', userId: 'user-1', teamId: 'team-a' }, + }, + { + chatId: 'other-environment-delete', + authorityScope: { environmentKey: 'env-2', userId: 'user-1', teamId: 'team-a' }, + }, + ], + } + + expect( + deletedChatIdsForScope(index, { + environmentKey: 'env-1', + userId: 'user-1', + teamId: 'team-a', + }) + ).toEqual(['team-a-delete']) + expect( + deletedChatIdsForScope(index, { + environmentKey: 'env-1', + userId: 'user-1', + teamId: 'team-b', + }) + ).toEqual([]) + expect( + deletedChatIdsForScope(index, { + environmentKey: 'env-1', + userId: 'user-1', + teamId: null, + }) + ).toEqual(['legacy-delete']) + }) +}) + function titleInList(current: { chatList: Array<{ id: string; title: string }> }, id: string) { return current.chatList.find(c => c.id === id)?.title } @@ -124,29 +165,34 @@ describe('rename pending queue (spec 15 §2.5)', () => { renameError: 'Rename session failed (403): host_access_revoked', readStatus: null, shouldRevoke: true, + shouldBeUncertain: false, }, { label: 'operation-scope denial', renameError: 'Rename session failed (403)', readStatus: null, shouldRevoke: false, + shouldBeUncertain: true, }, { label: 'read also denied', renameError: 'Rename session failed (403)', readStatus: 403, - shouldRevoke: true, + shouldRevoke: false, + shouldBeUncertain: true, }, { label: 'read unavailable', renameError: 'Rename session failed (403)', readStatus: 503, shouldRevoke: false, + shouldBeUncertain: true, }, ])( '$label applies the transcript authority decision', - async ({ renameError, readStatus, shouldRevoke }) => { + async ({ renameError, readStatus, shouldRevoke, shouldBeUncertain }) => { const revoked = new Set() + const uncertain = new Set() clerum.chat.getIndex.mockResolvedValue(localIndex([{ id: 'c1', title: 'old' }])) clerum.rpc.renameSession.mockRejectedValue(new Error(renameError)) clerum.rpc.listSessions.mockImplementation( @@ -159,6 +205,7 @@ describe('rename pending queue (spec 15 §2.5)', () => { selectedAgent: 'agent-x', agentNames: ['agent-x'], onHostAccessRevoked: agentRef => revoked.add(agentRef), + onHostAuthorityUncertain: agentRef => uncertain.add(agentRef), isHostAccessBlocked: agentRef => revoked.has(agentRef), }) await waitFor(() => expect(titleInList(result.current, 'c1')).toBe('old')) @@ -166,8 +213,9 @@ describe('rename pending queue (spec 15 §2.5)', () => { await result.current.handleRenameChatForAgent('agent-x', 'c1', 'new') }) expect(revoked.has('agent-x')).toBe(shouldRevoke) + expect(uncertain.has('agent-x')).toBe(shouldBeUncertain) if (shouldRevoke) expect(result.current.chatList).toEqual([]) - else expect(titleInList(result.current, 'c1')).toBe('old') + else expect(titleInList(result.current, 'c1')).toBe('new') } ) @@ -275,7 +323,7 @@ describe('rename pending queue (spec 15 §2.5)', () => { // Next poll reports c1 → the queue retries the 404'd rename. await act(async () => { - rerender({ agentNames: ['agent-x'] }) + rerender({ agentNames: ['agent-x', 'agent-y'] }) }) await waitFor(() => expect(clerum.rpc.renameSession).toHaveBeenCalledTimes(2)) // Still shows the rename (never clobbered by the server title). @@ -343,11 +391,11 @@ describe('rename pending queue — concurrency (spec 15 §2.5)', () => { // trick drives a retry in the 404 case — see the T5 test — so it does invoke // flushPendingRenames; here the in-flight guard must make it a no-op.) await act(async () => { - rerender({ agentNames: ['agent-x'] }) + rerender({ agentNames: ['agent-x', 'agent-y'] }) await flushMicrotasks() }) await act(async () => { - rerender({ agentNames: ['agent-x'] }) + rerender({ agentNames: ['agent-x', 'agent-y'] }) await flushMicrotasks() }) @@ -704,7 +752,7 @@ describe('rename pending queue — bounded retry (spec 15 §2.5 / R1-M1)', () => reportedSessions([{ chatId: 'c1', title: 'server' }]) ) await act(async () => { - rerender({ agentNames: ['agent-x'] }) + rerender({ agentNames: ['agent-x', 'agent-y'] }) await flushMicrotasks() }) await waitFor(() => expect(clerum.rpc.renameSession).toHaveBeenCalledTimes(2)) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx index 447885633d..142ef9c824 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx @@ -999,6 +999,7 @@ describe('switchToChat (unified, D.4)', () => { 'older-message HTTP %i applies the correct Host authority decision', async status => { const revoked = new Set() + const uncertain = new Set() clerum.chat.loadMessages.mockImplementation(async (_agentRef, _chatId, _limit, offset) => offset === undefined ? [ @@ -1027,8 +1028,8 @@ describe('switchToChat (unified, D.4)', () => { ) const { result } = renderController({ onHostAccessRevoked: agentRef => revoked.add(agentRef), - onHostAuthorityUncertain: agentRef => revoked.add(agentRef), - isHostAccessBlocked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => uncertain.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef) || uncertain.has(agentRef), }) await settleMount() await act(async () => { @@ -1041,12 +1042,14 @@ describe('switchToChat (unified, D.4)', () => { }) if (status === 404) { expect(revoked.has('agent-x')).toBe(false) + expect(uncertain.has('agent-x')).toBe(false) expect(result.current.activeChatId).toBe('older-authority') expect(result.current.chatMessages.map(message => message.content)).toEqual(['cached']) } else { - expect(revoked.has('agent-x')).toBe(true) - expect(result.current.activeChatId).toBeNull() - expect(result.current.chatMessages).toEqual([]) + expect(revoked.has('agent-x')).toBe(false) + expect(uncertain.has('agent-x')).toBe(true) + expect(result.current.activeChatId).toBe('older-authority') + expect(result.current.chatMessages.map(message => message.content)).toEqual(['cached']) } } ) @@ -1131,6 +1134,7 @@ describe('switchToChat (unified, D.4)', () => { 'durable task-result HTTP %i applies the correct Host authority decision', async status => { const revoked = new Set() + const uncertain = new Set() clerum.chat.loadMessages.mockResolvedValue([]) clerum.rpc.loadSessionMessages.mockResolvedValue({ agent: 'agent-x', @@ -1141,8 +1145,8 @@ describe('switchToChat (unified, D.4)', () => { clerum.rpc.getTaskResult.mockRejectedValue(new Error(`${status} response`)) const { result } = renderController({ onHostAccessRevoked: agentRef => revoked.add(agentRef), - onHostAuthorityUncertain: agentRef => revoked.add(agentRef), - isHostAccessBlocked: agentRef => revoked.has(agentRef), + onHostAuthorityUncertain: agentRef => uncertain.add(agentRef), + isHostAccessBlocked: agentRef => revoked.has(agentRef) || uncertain.has(agentRef), }) await settleMount() await act(async () => { @@ -1158,10 +1162,12 @@ describe('switchToChat (unified, D.4)', () => { expect(clerum.rpc.getTaskResult).toHaveBeenCalled() if (status === 404) { expect(revoked.has('agent-x')).toBe(false) + expect(uncertain.has('agent-x')).toBe(false) expect(result.current.activeChatId).toBe('result-authority') } else { - expect(revoked.has('agent-x')).toBe(true) - expect(result.current.activeChatId).toBeNull() + expect(revoked.has('agent-x')).toBe(false) + expect(uncertain.has('agent-x')).toBe(true) + expect(result.current.activeChatId).toBe('result-authority') } } ) @@ -1209,9 +1215,18 @@ describe('switchToChat (unified, D.4)', () => { expect(revoked.has('agent-x')).toBe(true) } else if (action === 'confirmed deletion') { await act(async () => { - await controller.result.current.handleDeleteChatForAgent('agent-x', 'late-result') + const deletion = await controller.result.current.captureChatDeleteFence('agent-x') + await controller.result.current.handleDeleteChatForAgent( + 'agent-x', + 'late-result', + deletion + ) }) - expect(clerum.chat.delete).toHaveBeenCalledWith('agent-x', 'late-result', 1) + expect(clerum.chat.delete).toHaveBeenCalledWith( + 'agent-x', + 'late-result', + expect.objectContaining({ version: 1, bindingGeneration: 1 }) + ) } else { controller.rerender({ currentTeamId: 'team-after' }) } diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx index 456dcb4715..78a6b3628b 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx @@ -17,13 +17,16 @@ import type { TaskProgressStreamEvent } from '../../../../../src/types' type ProgressHandler = (event: TaskProgressStreamEvent) => void | Promise const revokedAgents = new Set() +const uncertainAgents = new Set() const onHostAccessRevoked = (agentRef: string) => { revokedAgents.add(agentRef) } const onHostAuthorityUncertain = (agentRef: string) => { - revokedAgents.add(agentRef) + uncertainAgents.add(agentRef) } -const isHostAccessBlocked = (agentRef: string) => revokedAgents.has(agentRef) +const isHostAccessBlocked = (agentRef: string) => + revokedAgents.has(agentRef) || uncertainAgents.has(agentRef) +const getHostAuthorityEpoch = () => 0 function getDraftInputValue(): string { return (screen.getByTestId('draft-input') as HTMLInputElement).value @@ -72,11 +75,18 @@ function installClerumHarness() { }), rename: vi.fn(async () => undefined), getBindingGeneration: vi.fn(async () => 1), + captureDeleteFence: vi.fn(async (authorityScope: unknown) => ({ + version: 1, + authorityScope, + bindingGeneration: 1, + sessionGeneration: 1, + })), delete: vi.fn(async (_agentRef: string, chatId: string) => { deletedChatIds.add(chatId) const index = chats.findIndex(chat => chat.id === chatId) if (index >= 0) chats.splice(index, 1) messagesByChat.delete(chatId) + return { cleanupPending: false } }), loadMessages: vi.fn( async (_agentRef: string, chatId: string) => messagesByChat.get(chatId) || [] @@ -133,6 +143,7 @@ function AgentChatHarness() { selectedAgent: 'trader', agentNames: ['trader'], currentTeamId: 'team-1', + currentEnvironmentKey: 'env-test', currentTeamName: 'Team One', isAuthenticated: true, loadMenuData: true, @@ -140,6 +151,7 @@ function AgentChatHarness() { onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, @@ -192,7 +204,14 @@ function AgentChatHarness() { > Select chat {chat.id} - @@ -205,6 +224,7 @@ describe('useAgentChatController (cross-chat, migrated)', () => { afterEach(() => { cleanup() vi.restoreAllMocks() + uncertainAgents.clear() resetComposerDraftStore() revokedAgents.clear() delete (window as { clerum?: unknown }).clerum @@ -363,7 +383,7 @@ describe('useAgentChatController (cross-chat, migrated)', () => { fireEvent.click(screen.getByRole('button', { name: 'Send message' })) await waitFor(() => expect(invokeHostMessage).toHaveBeenCalledTimes(1)) - loadSessionMessages.mockRejectedValue(new Error('403 forbidden')) + loadSessionMessages.mockRejectedValue(new Error('403 forbidden: host_access_revoked')) fireEvent.click(screen.getByRole('button', { name: `Select chat ${chatId}` })) await waitFor(() => expect(revokedAgents.has('trader')).toBe(true)) await waitFor(() => expect(screen.getByTestId('active-chat-id').textContent).toBe('')) @@ -379,29 +399,44 @@ describe('useAgentChatController (cross-chat, migrated)', () => { }) it.each([ - { label: '401', sendError: '401 unauthorized', readError: null, revoked: true }, + { + label: '401', + sendError: '401 unauthorized', + readError: null, + revoked: false, + uncertain: true, + }, { label: 'exact Host-wide 403', sendError: '403 Forbidden: host_access_revoked', readError: '503 unavailable', revoked: true, + uncertain: false, }, { label: 'generic 403 with readable catalog', sendError: '403 missing send scope', readError: null, revoked: false, + uncertain: false, }, { label: 'generic 403 with denied catalog', sendError: '403 missing send scope', readError: '403 forbidden', - revoked: true, + revoked: false, + uncertain: true, + }, + { + label: '404', + sendError: '404 transient', + readError: null, + revoked: false, + uncertain: false, }, - { label: '404', sendError: '404 transient', readError: null, revoked: false }, ])( 'send $label applies the transcript authority decision', - async ({ sendError, readError, revoked }) => { + async ({ sendError, readError, revoked, uncertain }) => { const { invokeHostMessage, listSessions } = installClerumHarness() invokeHostMessage.mockRejectedValueOnce(new Error(sendError)) render( @@ -416,6 +451,7 @@ describe('useAgentChatController (cross-chat, migrated)', () => { fireEvent.click(screen.getByRole('button', { name: 'Send message' })) await waitFor(() => expect(screen.getByTestId('send-state').textContent).toBe('settled')) expect(revokedAgents.has('trader')).toBe(revoked) + expect(uncertainAgents.has('trader')).toBe(uncertain) if (revoked) { expect(screen.getByTestId('active-chat-id').textContent).toBe('') expect(screen.getByTestId('chat-message-count').textContent).toBe('0') diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx index a0aca4cf9a..8f5638af55 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.pendingModel.test.tsx @@ -22,6 +22,7 @@ type ProgressHandler = (event: TaskProgressStreamEvent) => void | Promise const onHostAccessRevoked = () => {} const onHostAuthorityUncertain = () => {} const isHostAccessBlocked = () => false +const getHostAuthorityEpoch = () => 0 function createChatMeta(chatId: string) { const now = new Date().toISOString() @@ -67,7 +68,13 @@ function installClerumHarness() { }), rename: vi.fn(async () => undefined), getBindingGeneration: vi.fn(async () => 1), - delete: vi.fn(async () => undefined), + captureDeleteFence: vi.fn(async (authorityScope: unknown) => ({ + version: 1, + authorityScope, + bindingGeneration: 1, + sessionGeneration: 1, + })), + delete: vi.fn(async () => ({ cleanupPending: false })), loadMessages: vi.fn( async (_agentRef: string, chatId: string) => messagesByChat.get(chatId) || [] ), @@ -121,6 +128,7 @@ function AgentChatHarness() { selectedAgent: 'trader', agentNames: ['trader'], currentTeamId: 'team-1', + currentEnvironmentKey: 'env-test', currentTeamName: 'Team One', isAuthenticated: true, loadMenuData: true, @@ -128,6 +136,7 @@ function AgentChatHarness() { onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, pushToast: vi.fn(), pushNotification: vi.fn(), agentDisplayName: (agentName: string) => agentName, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx index d98ebe7135..0e5e38bd6f 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useHostPrewarmController.chatIntegration.test.tsx @@ -84,6 +84,7 @@ interface HarnessProps { const onHostAccessRevoked = () => {} const onHostAuthorityUncertain = () => {} const isHostAccessBlocked = () => false +const getHostAuthorityEpoch = () => 0 /** Both real hooks, mounted together under the task-tracker the chat controller needs. */ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navItem }: HarnessProps) { @@ -92,6 +93,7 @@ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navIte selectedAgent, agentNames, currentTeamId: 'team-1', + currentEnvironmentKey: 'env-test', currentTeamName: 'Team 1', isAuthenticated, loadMenuData: true, @@ -99,6 +101,7 @@ function useBothControllers({ selectedAgent, agentNames, isAuthenticated, navIte onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, ...stableCallbacks, }) } diff --git a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts index 119780a415..a0d9dbc1a9 100644 --- a/desktop-app/ui/src/hooks/domain/useAgentChatController.ts +++ b/desktop-app/ui/src/hooks/domain/useAgentChatController.ts @@ -13,6 +13,7 @@ import { import { truncateTitle } from '@lib/chatTitle' import { clearAllComposerDrafts, + clearComposerDraft, getComposerDraft, getComposerDraftRevision, setComposerDraft, @@ -335,6 +336,7 @@ interface UseAgentChatControllerParams { agentNames: string[] currentUserId?: string currentTeamId: string + currentEnvironmentKey: string currentTeamName: string isAuthenticated: boolean loadMenuData: boolean @@ -362,6 +364,7 @@ interface UseAgentChatControllerParams { onHostAccessRevoked: (agentRef: string) => void onHostAuthorityUncertain: (agentRef: string) => void isHostAccessBlocked: (agentRef: string) => boolean + getHostAuthorityEpoch: (agentRef: string) => number } export function useAgentChatController({ @@ -369,6 +372,7 @@ export function useAgentChatController({ agentNames, currentUserId, currentTeamId, + currentEnvironmentKey, currentTeamName, isAuthenticated, loadMenuData, @@ -383,9 +387,20 @@ export function useAgentChatController({ onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, }: UseAgentChatControllerParams) { const chatStore = useChatStore() - const authenticatedScope = `${currentUserId ?? 'unknown-user'}:${currentTeamId}` + const userScopeKey = currentUserId ?? 'unknown-user' + const normalizedTeamId = String(currentTeamId || '').trim() || null + const authorityScope = useMemo( + () => ({ + environmentKey: currentEnvironmentKey, + userId: userScopeKey, + teamId: normalizedTeamId, + }), + [currentEnvironmentKey, normalizedTeamId, userScopeKey] + ) + const authenticatedScope = `${currentEnvironmentKey}:${userScopeKey}:${normalizedTeamId ?? ''}` useEffect(() => { chatStore.setRemoteCacheScope( @@ -432,11 +447,13 @@ export function useAgentChatController({ // User portion of the scope only (NOT the combined user:team scopeKey): the // pending-rename queue is per-user and must survive a team-switch (R1-H1). authUserKey: currentUserId ?? 'unknown-user', + authorityScope, loadMenuData, chatStore, fsm, host: chatListHostRef, isHostAccessBlocked, + getHostAuthorityEpoch, onHostAccessRevoked: onCatalogHostAccessRevoked, onHostAuthorityUncertain: onCatalogHostAuthorityUncertain, }) @@ -1041,6 +1058,7 @@ export function useAgentChatController({ handleRenameChat, handleRenameChatForAgent, applyLocalTitleOnly, + captureChatDeleteFence, handleDeleteChat, handleDeleteChatForAgent, } = chatListCtl @@ -1888,23 +1906,25 @@ export function useAgentChatController({ const { [agentRef]: _removed, ...remaining } = previous return remaining }) - hideAgent(agentRef) - const view = activeChatVisibilityRef.current - if (agentSendSetupOwnerRef.current?.agentRef === agentRef) { - agentSendSetupOwnerRef.current = null - agentSendInFlightRef.current = false - setAgentSending(false) - } - if (view.selectedAgent === agentRef) { - chatStore.clearCachedRemoteData() - clearList() - resetComposerAttachments() - activeChatVisibilityRef.current = { ...view, selectedAgent: null, activeChatId: null } - setActiveChatId(null) - setChatMessages([]) - setChatMessagesLoading(false) - setHasOlderMessages(false) - cancelOlderMessagesLoad() + if (kind === 'revoked') { + hideAgent(agentRef) + const view = activeChatVisibilityRef.current + if (agentSendSetupOwnerRef.current?.agentRef === agentRef) { + agentSendSetupOwnerRef.current = null + agentSendInFlightRef.current = false + setAgentSending(false) + } + if (view.selectedAgent === agentRef) { + chatStore.clearCachedRemoteData() + clearList() + resetComposerAttachments() + activeChatVisibilityRef.current = { ...view, selectedAgent: null, activeChatId: null } + setActiveChatId(null) + setChatMessages([]) + setChatMessagesLoading(false) + setHasOlderMessages(false) + cancelOlderMessagesLoad() + } } if (kind === 'revoked') onHostAccessRevoked(agentRef) else onHostAuthorityUncertain(agentRef) @@ -2689,7 +2709,8 @@ export function useAgentChatController({ const sendScope = sendScopeGeneration.current const sendScopeIdentity = currentAuthScopeRef.current const originalDraftChat = activeChatVisibilityRef.current.activeChatId - const originalDraftRevision = getComposerDraftRevision(originalDraftChat) + const originalDraftAgent = selectedAgent + const originalDraftRevision = getComposerDraftRevision(originalDraftChat, originalDraftAgent) const originalAttachmentRevision = composerAttachmentRevisionRef.current const trimmedContent = content.trim() const effectiveAttachments = [...attachments] @@ -2903,9 +2924,10 @@ export function useAgentChatController({ activeChatVisibilityRef.current.selectedAgent === sendAgent && activeChatVisibilityRef.current.activeChatId === sendChatId && composerAttachmentRevisionRef.current === originalAttachmentRevision && - getComposerDraftRevision(originalDraftChat) === originalDraftRevision + getComposerDraftRevision(originalDraftChat, originalDraftAgent) === originalDraftRevision ) { clearComposerAfterSend(sendChatId) + clearComposerDraft(null, sendAgent) } setAgentError(null) setFailedAgentSend(null) @@ -3319,14 +3341,14 @@ export function useAgentChatController({ ) return if ( - getComposerDraft(activeChatId) || + getComposerDraft(activeChatId, selectedAgent ?? undefined) || composerImageAttachments.length || composerReferenceAttachments.length ) { pushToast('Keep or clear the current draft before recovering the earlier input.', 'error') return } - setComposerDraft(activeChatId, visibleFailure.content) + setComposerDraft(activeChatId, visibleFailure.content, selectedAgent ?? undefined) handleAddComposerImageAttachments( visibleFailure.attachments.map(attachment => ({ ...attachment, @@ -3548,6 +3570,7 @@ export function useAgentChatController({ handleRenameChat, handleRenameChatForAgent, applyLocalTitleOnly, + captureChatDeleteFence, handleDeleteChat, handleDeleteChatForAgent, handleSelectChat, diff --git a/desktop-app/ui/src/hooks/domain/useChatListController.ts b/desktop-app/ui/src/hooks/domain/useChatListController.ts index 24436ad280..dcbea1bb80 100644 --- a/desktop-app/ui/src/hooks/domain/useChatListController.ts +++ b/desktop-app/ui/src/hooks/domain/useChatListController.ts @@ -2,7 +2,13 @@ import { type MutableRefObject, useCallback, useEffect, useRef, useState } from import { makeTaskKey } from '@contexts/AgentTaskTrackerContext' import { agentChatPlaceholder, remotePlaceholder } from '@lib/chatTitle' import { type PendingRename, resolveSessionTitle } from '@lib/resolveSessionTitle' -import type { ChatIndex, ChatMetadata, SessionsListResult } from '../../../../src/types' +import type { + ChatAuthorityScope, + ChatDeleteFence, + ChatIndex, + ChatMetadata, + SessionsListResult, +} from '../../../../src/types' import { isAuthorizationError, isConfirmedHostAccessRevoked } from '../../lib/format' import { scheduleAfterFirstPaint } from '../scheduleAfterFirstPaint' import type { useChatStore } from '../useChatStore' @@ -97,11 +103,13 @@ interface UseChatListControllerParams { * team-switch of the same user — see the teardown effect below. */ authUserKey: string + authorityScope: ChatAuthorityScope loadMenuData: boolean chatStore: ReturnType fsm: SessionFsmStore host: MutableRefObject isHostAccessBlocked: (agentRef: string) => boolean + getHostAuthorityEpoch: (agentRef: string) => number onHostAccessRevoked: (agentRef: string) => void onHostAuthorityUncertain: (agentRef: string) => void } @@ -129,8 +137,9 @@ function isRecoverableCatalogCursorError(error: unknown): boolean { * error) is treated as a network failure. * - 'not-found' (404): session not materialized server-side yet → keep pending, * retry once it appears in listSessions. NOT an error; no rollback. - * - 'client-error' (other 4xx: 400 invalid title, 401/403 access): a genuine - * rejection → roll the optimistic title back and toast. + * - 'client-error' (other 4xx, such as 400 invalid title): a genuine rejection + * → roll the optimistic title back and toast. Authorization failures are + * handled separately because a generic 401/403 does not prove revocation. * - 'network' (5xx / no status): transient → queue offline, retry on reconnect. */ function classifyRenameError(error: unknown): 'not-found' | 'client-error' | 'network' { @@ -138,6 +147,7 @@ function classifyRenameError(error: unknown): 'not-found' | 'client-error' | 'ne const match = message.match(/\((\d{3})\)/) const status = match ? Number(match[1]) : null if (status === 404) return 'not-found' + if (status === 401 || status === 403) return 'network' if (status !== null && status >= 400 && status < 500) return 'client-error' return 'network' } @@ -156,17 +166,37 @@ function knownServerMessageCount(session: SessionsListResult['items'][number]): return typeof count === 'number' && Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 } +function sameAuthorityScope(left: ChatAuthorityScope, right: ChatAuthorityScope): boolean { + return ( + left.environmentKey === right.environmentKey && + left.userId === right.userId && + left.teamId === right.teamId + ) +} + +export function deletedChatIdsForScope(index: ChatIndex, scope: ChatAuthorityScope): string[] { + const scoped = (index.deletedChatTombstones ?? []) + .filter(tombstone => sameAuthorityScope(tombstone.authorityScope, scope)) + .map(tombstone => tombstone.chatId) + // Legacy IDs have no known team owner. Keep their historic behavior for the + // unscoped account, but don't carry them into a different new team. + const legacy = scope.teamId === null ? (index.deletedChatIds ?? []) : [] + return [...new Set([...legacy, ...scoped])] +} + export function useChatListController({ selectedAgent, agentNames, isAuthenticated, scopeKey, authUserKey, + authorityScope, loadMenuData, chatStore, fsm, host, isHostAccessBlocked, + getHostAuthorityEpoch, onHostAccessRevoked, onHostAuthorityUncertain, }: UseChatListControllerParams) { @@ -188,6 +218,9 @@ export function useChatListController({ const deletedChatIdsByAgentRef = useRef(new Map>()) const requestGenerationRef = useRef(0) const authorityScopeGenerationRef = useRef(0) + const agentNamesRef = useRef(agentNames) + agentNamesRef.current = agentNames + const agentNamesKey = agentNames.join('\n') const currentAuthorityScopeRef = useRef(`${isAuthenticated}:${scopeKey}`) currentAuthorityScopeRef.current = `${isAuthenticated}:${scopeKey}` @@ -319,6 +352,8 @@ export function useChatListController({ requestGeneration: number ): Promise<{ index: ChatIndex; merged: SidebarChatEntry[] }> => { const authorityScopeGeneration = authorityScopeGenerationRef.current + const authorityScopeAtRequest = currentAuthorityScopeRef.current + const hostAuthorityEpoch = getHostAuthorityEpoch(agentRef) chatListNextCursorByAgentRef.current[agentRef] = null if (selectedAgentRef.current === agentRef) { setChatListHasMoreRemoteSessions(false) @@ -326,14 +361,20 @@ export function useChatListController({ } const index = await chatStore.getIndex(agentRef) if ( - selectedAgentRef.current !== agentRef || - requestGenerationRef.current !== requestGeneration || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpoch || + currentAuthorityScopeRef.current !== authorityScopeAtRequest || isHostAccessBlocked(agentRef) + ) { + return { index, merged: [] } + } + if ( + selectedAgentRef.current !== agentRef || + requestGenerationRef.current !== requestGeneration ) { return { index, merged: [...index.chats].sort(byUpdatedDesc) } } const deleted = deletedChatIdsByAgentRef.current.get(agentRef) ?? new Set() - for (const chatId of index.deletedChatIds ?? []) deleted.add(chatId) + for (const chatId of deletedChatIdsForScope(index, authorityScope)) deleted.add(chatId) deletedChatIdsByAgentRef.current.set(agentRef, deleted) const merged = index.chats.filter(chat => !deleted.has(chat.id)).sort(byUpdatedDesc) setChatList(merged) @@ -350,6 +391,8 @@ export function useChatListController({ if ( isAuthorizationError(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && + getHostAuthorityEpoch(agentRef) === hostAuthorityEpoch && + currentAuthorityScopeRef.current === authorityScopeAtRequest && !isHostAccessBlocked(agentRef) ) { if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) @@ -360,6 +403,8 @@ export function useChatListController({ if ( selectedAgentRef.current !== agentRef || requestGenerationRef.current !== requestGeneration || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpoch || + currentAuthorityScopeRef.current !== authorityScopeAtRequest || isHostAccessBlocked(agentRef) ) { if ( @@ -490,10 +535,12 @@ export function useChatListController({ return { index, merged } }, [ + authorityScope, chatStore.getIndex, chatStore.listSessions, chatStore.reconcileServerSessions, fsm, + getHostAuthorityEpoch, isHostAccessBlocked, onHostAccessRevoked, ] @@ -513,6 +560,8 @@ export function useChatListController({ chatListLoadingMoreByAgentRef.current.add(agentRef) const requestGeneration = requestGenerationRef.current const authorityScopeGeneration = authorityScopeGenerationRef.current + const authorityScopeAtRequest = currentAuthorityScopeRef.current + const hostAuthorityEpoch = getHostAuthorityEpoch(agentRef) setChatListMoreLoading(true) try { let serverResult: SessionsListResult @@ -526,6 +575,8 @@ export function useChatListController({ if ( isAuthorizationError(error) && authorityScopeGenerationRef.current === authorityScopeGeneration && + getHostAuthorityEpoch(agentRef) === hostAuthorityEpoch && + currentAuthorityScopeRef.current === authorityScopeAtRequest && !isHostAccessBlocked(agentRef) ) { if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) @@ -534,6 +585,8 @@ export function useChatListController({ } if ( requestGenerationRef.current === requestGeneration && + getHostAuthorityEpoch(agentRef) === hostAuthorityEpoch && + currentAuthorityScopeRef.current === authorityScopeAtRequest && selectedAgentRef.current === agentRef ) { if (isRecoverableCatalogCursorError(error)) { @@ -545,12 +598,25 @@ export function useChatListController({ } return } - if (requestGenerationRef.current !== requestGeneration) return + if ( + requestGenerationRef.current !== requestGeneration || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpoch || + currentAuthorityScopeRef.current !== authorityScopeAtRequest + ) { + return + } const serverSessions = serverResult.items.filter( s => s.agent === agentRef && !deletedChatIdsByAgentRef.current.get(agentRef)?.has(s.chatId) ) - if (selectedAgentRef.current !== agentRef || isHostAccessBlocked(agentRef)) return + if ( + selectedAgentRef.current !== agentRef || + isHostAccessBlocked(agentRef) || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpoch || + currentAuthorityScopeRef.current !== authorityScopeAtRequest + ) { + return + } chatListNextCursorByAgentRef.current[agentRef] = serverResult.nextCursor ?? null setChatListHasMoreRemoteSessions(Boolean(serverResult.nextCursor)) @@ -618,6 +684,7 @@ export function useChatListController({ chatStore.listSessions, chatStore.reconcileServerSessions, fsm, + getHostAuthorityEpoch, isHostAccessBlocked, onHostAccessRevoked, ]) @@ -844,10 +911,10 @@ export function useChatListController({ if (!agentRef || !isAuthenticated) return const requestGeneration = requestGenerationRef.current const authorityScopeGeneration = authorityScopeGenerationRef.current - const authorityScope = currentAuthorityScopeRef.current + const authorityScopeAtDelete = currentAuthorityScopeRef.current const stillOwned = () => authorityScopeGenerationRef.current === authorityScopeGeneration && - currentAuthorityScopeRef.current === authorityScope + currentAuthorityScopeRef.current === authorityScopeAtDelete const bindingGeneration = await chatStore.getBindingGeneration() if (!stillOwned()) return const updatedAt = new Date().toISOString() @@ -900,6 +967,10 @@ export function useChatListController({ if (pendingRenamesRef.current.get(key) !== entry) return entry.sending = true const authorityScopeGeneration = authorityScopeGenerationRef.current + const hostAuthorityEpoch = getHostAuthorityEpoch(entry.agentRef) + const requestStillCurrent = () => + authorityScopeGenerationRef.current === authorityScopeGeneration && + getHostAuthorityEpoch(entry.agentRef) === hostAuthorityEpoch try { await chatStore.renameSession(entry.agentRef, entry.chatId, entry.title) // Only clear if this attempt still owns the key (a stale 200 must never @@ -910,7 +981,7 @@ export function useChatListController({ } catch (error) { if ( isConfirmedHostAccessRevoked(error) && - authorityScopeGenerationRef.current === authorityScopeGeneration && + requestStillCurrent() && !isHostAccessBlocked(entry.agentRef) ) { onHostAccessRevoked(entry.agentRef) @@ -919,11 +990,8 @@ export function useChatListController({ } return } - if ( - isAuthorizationError(error) && - authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessBlocked(entry.agentRef) - ) { + if (isAuthorizationError(error)) { + if (!requestStillCurrent() || isHostAccessBlocked(entry.agentRef)) return try { await chatStore.listSessions( entry.agentRef, @@ -931,11 +999,8 @@ export function useChatListController({ { force: true } ) } catch (readError) { - if ( - isAuthorizationError(readError) && - authorityScopeGenerationRef.current === authorityScopeGeneration && - !isHostAccessBlocked(entry.agentRef) - ) { + if (!requestStillCurrent() || isHostAccessBlocked(entry.agentRef)) return + if (isConfirmedHostAccessRevoked(readError)) { onHostAccessRevoked(entry.agentRef) if (pendingRenamesRef.current.get(key) === entry) { pendingRenamesRef.current.delete(key) @@ -943,6 +1008,19 @@ export function useChatListController({ return } } + if (!requestStillCurrent() || isHostAccessBlocked(entry.agentRef)) return + // Only the structured host denial proves revocation. A generic 403 + // may be a stale/missing write capability, so block the host as + // uncertain and retain the optimistic rename for a later retry. + onHostAuthorityUncertain(entry.agentRef) + if (pendingRenamesRef.current.get(key) === entry) { + entry.state = 'offline' + entry.failureCount += 1 + if (entry.failureCount >= MAX_RENAME_SYNC_ATTEMPTS) { + entry.retriesExhausted = true + } + } + return } // A newer rename superseded this one: leave the key entirely to it — no // rollback, no re-mark (FIX 2). @@ -985,7 +1063,15 @@ export function useChatListController({ entry.sending = false } }, - [chatStore, applyLocalTitleOnly, host, isHostAccessBlocked, onHostAccessRevoked] + [ + chatStore, + applyLocalTitleOnly, + getHostAuthorityEpoch, + host, + isHostAccessBlocked, + onHostAccessRevoked, + onHostAuthorityUncertain, + ] ) /** @@ -1002,6 +1088,7 @@ export function useChatListController({ // FIX 1: skip a key with a PATCH already in flight (attemptRenameRpc // guards this too, but skipping avoids spawning a no-op). if (entry.sending) continue + if (isHostAccessBlocked(entry.agentRef)) continue // R1-M1: skip an entry whose retry budget is spent — its optimistic title // stays, but it no longer PATCHes on polls/online. Only 'offline' entries // can ever reach this state (in-flight never counts toward the budget). @@ -1020,7 +1107,7 @@ export function useChatListController({ await attemptRenameRpc(entry) } }, - [attemptRenameRpc] + [attemptRenameRpc, isHostAccessBlocked] ) /** @@ -1088,15 +1175,57 @@ export function useChatListController({ } }, [flushPendingRenames]) + const captureChatDeleteFence = useCallback( + async ( + agentRef: string + ): Promise<{ agentRef: string; fence: ChatDeleteFence; hostAuthorityEpoch: number }> => { + if (!agentRef || isHostAccessBlocked(agentRef)) { + throw new Error('Chat deletion is unavailable while host access is blocked') + } + const hostAuthorityEpoch = getHostAuthorityEpoch(agentRef) + try { + const fence = await chatStore.captureDeleteFence(authorityScope) + if ( + !sameAuthorityScope(fence.authorityScope, authorityScope) || + hostAuthorityEpoch !== getHostAuthorityEpoch(agentRef) || + isHostAccessBlocked(agentRef) + ) { + throw new Error('Chat deletion authority changed before confirmation') + } + return { agentRef, fence, hostAuthorityEpoch } + } catch (error) { + host.current?.pushToast('Could not confirm chat access. Please try again.', 'error') + throw error + } + }, + [authorityScope, chatStore.captureDeleteFence, getHostAuthorityEpoch, host, isHostAccessBlocked] + ) + const handleDeleteChatForAgent = useCallback( - async (agentRef: string, chatId: string) => { + async ( + agentRef: string, + chatId: string, + deletion: { agentRef: string; fence: ChatDeleteFence; hostAuthorityEpoch: number } + ) => { if (!agentRef) return + if ( + deletion.agentRef !== agentRef || + !sameAuthorityScope(deletion.fence.authorityScope, authorityScope) || + deletion.hostAuthorityEpoch !== getHostAuthorityEpoch(agentRef) || + isHostAccessBlocked(agentRef) + ) { + host.current?.pushToast( + 'Delete cancelled because chat access changed. Please try again.', + 'error' + ) + return + } const requestGeneration = requestGenerationRef.current const authorityScopeGeneration = authorityScopeGenerationRef.current - const authorityScope = currentAuthorityScopeRef.current + const authorityScopeAtDelete = currentAuthorityScopeRef.current const stillOwned = () => authorityScopeGenerationRef.current === authorityScopeGeneration && - currentAuthorityScopeRef.current === authorityScope + currentAuthorityScopeRef.current === authorityScopeAtDelete // Stop following any in-flight task for this chat first: ack tears down the // SSE + connect/watchdog timers, so a later terminal can't fire onTerminal // and resurrect the just-deleted chat file via appendAssistantMessage. @@ -1106,9 +1235,13 @@ export function useChatListController({ // fire onTerminal and resurrect the just-deleted chat file. Dispatched // FIRST (before the delete), preserving the ack-before-delete ordering. host.current?.dispatchSession(deletedKey, { type: 'CHAT_DELETED' }) - const bindingGeneration = await chatStore.getBindingGeneration() - if (!stillOwned()) return - await chatStore.deleteChat(agentRef, chatId, bindingGeneration) + let result: Awaited> + try { + result = await chatStore.deleteChat(agentRef, chatId, deletion.fence) + } catch { + host.current?.pushToast('Could not delete the chat. Please try again.', 'error') + return + } if (!stillOwned()) return const deleted = deletedChatIdsByAgentRef.current.get(agentRef) ?? new Set() deleted.add(chatId) @@ -1116,6 +1249,12 @@ export function useChatListController({ chatStore.clearCachedRemoteData() host.current?.clearComposerDraft(chatId) removeLatestChatSession(agentRef, chatId) + if (result.cleanupPending) { + host.current?.pushToast( + 'Chat removed. Local transcript cleanup will retry when this team is active.', + 'info' + ) + } if (requestGenerationRef.current !== requestGeneration) return // Post-await guards read the LIVE committed values (selectedAgentRef / // getActiveChatId), intentionally — if the user switched agent during the @@ -1138,14 +1277,25 @@ export function useChatListController({ } } }, - [chatList, chatStore, removeLatestChatSession, handleCreateChat, host, isAuthenticated] + [ + authorityScope, + chatList, + chatStore, + getHostAuthorityEpoch, + isHostAccessBlocked, + removeLatestChatSession, + handleCreateChat, + host, + isAuthenticated, + ] ) const handleDeleteChat = useCallback( - async (chatId: string) => { - const agentRef = selectedAgentRef.current - if (!agentRef) return - await handleDeleteChatForAgent(agentRef, chatId) + async ( + chatId: string, + deletion: { agentRef: string; fence: ChatDeleteFence; hostAuthorityEpoch: number } + ) => { + await handleDeleteChatForAgent(deletion.agentRef, chatId, deletion) }, [handleDeleteChatForAgent] ) @@ -1153,7 +1303,8 @@ export function useChatListController({ // ─── Cross-agent latest-sessions loader (seeds badges via SERVER_SNAPSHOT) ─── useEffect(() => { - if (!isAuthenticated || !loadMenuData || !agentNames.length) { + const requestedAgentNames = agentNamesRef.current + if (!isAuthenticated || !loadMenuData || !requestedAgentNames.length) { setLatestChatSessions([]) setLatestChatSessionsLoading(false) return @@ -1161,20 +1312,35 @@ export function useChatListController({ let cancelled = false const authorityScopeGeneration = authorityScopeGenerationRef.current + const authorityScopeAtRequest = currentAuthorityScopeRef.current + const hostAuthorityEpochByAgent = new Map( + requestedAgentNames.map(agentRef => [agentRef, getHostAuthorityEpoch(agentRef)]) + ) setLatestChatSessionsLoading(true) ;(async () => { try { const localGroups = await Promise.all( - agentNames.map(async agentRef => { + requestedAgentNames.map(async agentRef => { try { const index = await chatStore.getIndex(agentRef) + if ( + currentAuthorityScopeRef.current !== authorityScopeAtRequest || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpochByAgent.get(agentRef) || + isHostAccessBlocked(agentRef) + ) { + return { + agentRef, + entries: [] as LatestSidebarChatEntry[], + deletedChatIds: [] as string[], + } + } return { agentRef, entries: index.chats.map(chat => ({ ...chat, agentRef, })), - deletedChatIds: index.deletedChatIds ?? [], + deletedChatIds: deletedChatIdsForScope(index, authorityScope), } } catch { return { @@ -1185,8 +1351,15 @@ export function useChatListController({ } }) ) - if (cancelled) return + if (cancelled || currentAuthorityScopeRef.current !== authorityScopeAtRequest) return for (const group of localGroups) { + if ( + getHostAuthorityEpoch(group.agentRef) !== + hostAuthorityEpochByAgent.get(group.agentRef) || + isHostAccessBlocked(group.agentRef) + ) { + continue + } const deleted = deletedChatIdsByAgentRef.current.get(group.agentRef) ?? new Set() for (const chatId of group.deletedChatIds) deleted.add(chatId) deletedChatIdsByAgentRef.current.set(group.agentRef, deleted) @@ -1204,7 +1377,7 @@ export function useChatListController({ setLatestChatSessionsLoading(false) const sessionGroups = await Promise.all( - agentNames.map(async agentRef => { + requestedAgentNames.map(async agentRef => { // This feeds only the cross-agent sidebar preview. Do not follow // cursors here; the selected-agent session list exposes explicit // on-demand pagination through `loadMoreChatSessions`. @@ -1219,6 +1392,8 @@ export function useChatListController({ isAuthorizationError(error) && !cancelled && authorityScopeGenerationRef.current === authorityScopeGeneration && + currentAuthorityScopeRef.current === authorityScopeAtRequest && + getHostAuthorityEpoch(agentRef) === hostAuthorityEpochByAgent.get(agentRef) && !isHostAccessBlocked(agentRef) ) { if (isConfirmedHostAccessRevoked(error)) onHostAccessRevoked(agentRef) @@ -1226,6 +1401,13 @@ export function useChatListController({ } return { agentRef, sessions: [] as SessionsListResult['items'] } } + if ( + currentAuthorityScopeRef.current !== authorityScopeAtRequest || + getHostAuthorityEpoch(agentRef) !== hostAuthorityEpochByAgent.get(agentRef) || + isHostAccessBlocked(agentRef) + ) { + return { agentRef, sessions: [] as SessionsListResult['items'] } + } return { agentRef, sessions: serverResult.items.filter( @@ -1237,9 +1419,14 @@ export function useChatListController({ } }) ) - if (cancelled) return + if (cancelled || currentAuthorityScopeRef.current !== authorityScopeAtRequest) return for (const group of sessionGroups) { - if (isHostAccessBlocked(group.agentRef)) continue + if ( + getHostAuthorityEpoch(group.agentRef) !== + hostAuthorityEpochByAgent.get(group.agentRef) || + isHostAccessBlocked(group.agentRef) + ) + continue seedSessionSnapshots( fsm, group.agentRef, @@ -1251,7 +1438,12 @@ export function useChatListController({ setLatestChatSessions(previous => { const serverByKey = new Map() for (const group of sessionGroups) { - if (isHostAccessBlocked(group.agentRef)) continue + if ( + getHostAuthorityEpoch(group.agentRef) !== + hostAuthorityEpochByAgent.get(group.agentRef) || + isHostAccessBlocked(group.agentRef) + ) + continue for (const session of group.sessions) { if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) continue @@ -1263,6 +1455,8 @@ export function useChatListController({ const reconciled = previous .filter( item => + getHostAuthorityEpoch(item.agentRef) === + hostAuthorityEpochByAgent.get(item.agentRef) && !isHostAccessBlocked(item.agentRef) && !deletedChatIdsByAgentRef.current.get(item.agentRef)?.has(item.id) ) @@ -1281,7 +1475,12 @@ export function useChatListController({ const knownKeys = new Set(previous.map(item => `${item.agentRef}:${item.id}`)) const remoteOnly: LatestSidebarChatEntry[] = [] for (const group of sessionGroups) { - if (isHostAccessBlocked(group.agentRef)) continue + if ( + getHostAuthorityEpoch(group.agentRef) !== + hostAuthorityEpochByAgent.get(group.agentRef) || + isHostAccessBlocked(group.agentRef) + ) + continue for (const session of group.sessions) { if (deletedChatIdsByAgentRef.current.get(group.agentRef)?.has(session.chatId)) continue @@ -1335,13 +1534,14 @@ export function useChatListController({ cancelled = true } }, [ - agentNames, + agentNamesKey, chatStore.getIndex, chatStore.listSessions, isAuthenticated, loadMenuData, scopeKey, fsm, + getHostAuthorityEpoch, isHostAccessBlocked, onHostAccessRevoked, ]) @@ -1360,6 +1560,7 @@ export function useChatListController({ handleRenameChat, handleRenameChatForAgent, applyLocalTitleOnly, + captureChatDeleteFence, handleDeleteChat, handleDeleteChatForAgent, // Loader + list-loading control (parent agent-selection effect). diff --git a/desktop-app/ui/src/hooks/useAgentChatActionsValue.ts b/desktop-app/ui/src/hooks/useAgentChatActionsValue.ts index 739d9c2dcb..49a6826ea8 100644 --- a/desktop-app/ui/src/hooks/useAgentChatActionsValue.ts +++ b/desktop-app/ui/src/hooks/useAgentChatActionsValue.ts @@ -18,6 +18,7 @@ export function useAgentChatActionsValue(vm: AppVm): AgentChatActionsContextValu const handleCreateChat = useStableCallback(vm.handleCreateChat) const handleRenameChat = useStableCallback(vm.handleRenameChat) const handleRenameChatForAgent = useStableCallback(vm.handleRenameChatForAgent) + const captureChatDeleteFence = useStableCallback(vm.captureChatDeleteFence) const handleDeleteChat = useStableCallback(vm.handleDeleteChat) const handleDeleteChatForAgent = useStableCallback(vm.handleDeleteChatForAgent) const handleSelectChat = useStableCallback(vm.handleSelectChat) @@ -47,6 +48,7 @@ export function useAgentChatActionsValue(vm: AppVm): AgentChatActionsContextValu handleCreateChat, handleRenameChat, handleRenameChatForAgent, + captureChatDeleteFence, handleDeleteChat, handleDeleteChatForAgent, handleSelectChat, @@ -67,6 +69,7 @@ export function useAgentChatActionsValue(vm: AppVm): AgentChatActionsContextValu handleCreateChat, handleRenameChat, handleRenameChatForAgent, + captureChatDeleteFence, handleDeleteChat, handleDeleteChatForAgent, handleSelectChat, diff --git a/desktop-app/ui/src/hooks/useAppController.ts b/desktop-app/ui/src/hooks/useAppController.ts index 8fb63c1a5d..d0da62f43c 100644 --- a/desktop-app/ui/src/hooks/useAppController.ts +++ b/desktop-app/ui/src/hooks/useAppController.ts @@ -306,10 +306,12 @@ export function useAppController() { (agentName: string) => agentsData.agentDisplayByName[agentName] ?? agentName, [agentsData.agentDisplayByName] ) - const authorityScope = `${auth.isAuthenticated}:${authenticatedPrincipalIdentity ?? ''}:${currentTeamId}` + const authorityScope = `${auth.runtimeConfigState?.envKey ?? ''}:${auth.isAuthenticated}:${authenticatedPrincipalIdentity ?? ''}:${currentTeamId}` const authorityScopeRef = useRef(authorityScope) authorityScopeRef.current = authorityScope const hostAuthorityEpochRef = useRef(0) + const hostAuthorityEpochByAgentRef = useRef(new Map()) + const navigationIntentEpochRef = useRef(0) const blockedHostsRef = useRef( new Map() ) @@ -320,9 +322,15 @@ export function useAppController() { (agentRef: string) => blockedHostsRef.current.has(agentRef), [] ) + const getHostAuthorityEpoch = useCallback( + (agentRef: string) => + hostAuthorityEpochByAgentRef.current.get(agentRef) ?? hostAuthorityEpochRef.current, + [] + ) useEffect(() => { blockedHostsRef.current.clear() hostAuthorityEpochRef.current += 1 + hostAuthorityEpochByAgentRef.current.clear() setHostAuthorityRevision(revision => revision + 1) }, [authorityScope]) const blockHostAccess = useCallback( @@ -333,6 +341,7 @@ export function useAppController() { kind, epoch: ++hostAuthorityEpochRef.current, }) + hostAuthorityEpochByAgentRef.current.set(agentRef, hostAuthorityEpochRef.current) setHostAuthorityRevision(revision => revision + 1) if (selectedAgentRef.current === agentRef) nav.setSelectedAgent(null) void agentsData.refresh() @@ -363,6 +372,7 @@ export function useAppController() { ) return false blockedHostsRef.current.delete(agentRef) + hostAuthorityEpochByAgentRef.current.set(agentRef, ++hostAuthorityEpochRef.current) setHostAuthorityRevision(revision => revision + 1) return true }, []) @@ -371,6 +381,7 @@ export function useAppController() { agentNames: agentsData.agentNames, currentUserId: auth.me?.id, currentTeamId, + currentEnvironmentKey: auth.runtimeConfigState?.envKey ?? '', currentTeamName, isAuthenticated: auth.isAuthenticated, loadMenuData: postPaintDataReady, @@ -385,6 +396,7 @@ export function useAppController() { onHostAccessRevoked, onHostAuthorityUncertain, isHostAccessBlocked, + getHostAuthorityEpoch, }) // §4.7.4: the ONE central approval-decision function, bound to the chat @@ -948,13 +960,18 @@ export function useAppController() { const handleOpenAgentWorkspace = useCallback( (agentName: string, route: AgentWorkspaceRoute = AGENT_WORKSPACE_ROUTES.connectors) => { if (!agentName) return + const navigationIntentEpoch = ++navigationIntentEpochRef.current if (!isHostAccessBlocked(agentName)) { openAgentWorkspace(agentName, route) return } const scope = authorityScopeRef.current void verifyHostAccess(agentName).then(verified => { - if (verified && authorityScopeRef.current === scope) { + if ( + verified && + authorityScopeRef.current === scope && + navigationIntentEpochRef.current === navigationIntentEpoch + ) { openAgentWorkspace(agentName, route) } }) @@ -1067,13 +1084,18 @@ export function useAppController() { const handleSelectChatAgent = useCallback( (agentName: string, options: Parameters[1] = {}) => { if (!agentName) return + const navigationIntentEpoch = ++navigationIntentEpochRef.current if (!isHostAccessBlocked(agentName)) { selectChatAgent(agentName, options) return } const scope = authorityScopeRef.current void verifyHostAccess(agentName).then(verified => { - if (verified && authorityScopeRef.current === scope) { + if ( + verified && + authorityScopeRef.current === scope && + navigationIntentEpochRef.current === navigationIntentEpoch + ) { selectChatAgent(agentName, options) } }) @@ -1523,6 +1545,7 @@ export function useAppController() { handleCreateChat: chat.handleCreateChat, handleRenameChat: chat.handleRenameChat, handleRenameChatForAgent: chat.handleRenameChatForAgent, + captureChatDeleteFence: chat.captureChatDeleteFence, handleDeleteChat: chat.handleDeleteChat, handleDeleteChatForAgent: chat.handleDeleteChatForAgent, handleSelectChat: chat.handleSelectChat, diff --git a/desktop-app/ui/src/hooks/useChatStore.ts b/desktop-app/ui/src/hooks/useChatStore.ts index 6c9373b91d..765f70de29 100644 --- a/desktop-app/ui/src/hooks/useChatStore.ts +++ b/desktop-app/ui/src/hooks/useChatStore.ts @@ -1,5 +1,7 @@ import { useCallback } from 'react' import type { + ChatAuthorityScope, + ChatDeleteFence, ChatMessage, ReplaceChatMessagesOptions, SessionMessagesQuery, @@ -69,9 +71,14 @@ export function useChatStore() { [] ) const getBindingGeneration = useCallback(() => window.clerum.chat.getBindingGeneration(), []) + const captureDeleteFence = useCallback( + (expectedAuthorityScope: ChatAuthorityScope) => + window.clerum.chat.captureDeleteFence(expectedAuthorityScope), + [] + ) const deleteChat = useCallback( - (agentRef: string, chatId: string, bindingGeneration: number) => - window.clerum.chat.delete(agentRef, chatId, bindingGeneration), + (agentRef: string, chatId: string, fence: ChatDeleteFence) => + window.clerum.chat.delete(agentRef, chatId, fence), [] ) const loadMessages = useCallback( @@ -247,6 +254,7 @@ export function useChatStore() { createChat, renameChat, getBindingGeneration, + captureDeleteFence, deleteChat, loadMessages, appendMessages, diff --git a/desktop-app/ui/src/hooks/useComposerDraft.ts b/desktop-app/ui/src/hooks/useComposerDraft.ts index dffeeaea53..f5973edb7c 100644 --- a/desktop-app/ui/src/hooks/useComposerDraft.ts +++ b/desktop-app/ui/src/hooks/useComposerDraft.ts @@ -11,13 +11,17 @@ import { * not. Returns a `[draft, setDraft]` pair scoped to `chatId`. */ export function useComposerDraft( - chatId: string | null + chatId: string | null, + agentRef?: string ): readonly [string, (value: string) => void] { const subscribe = useCallback( - (listener: () => void) => subscribeComposerDraft(chatId, listener), - [chatId] + (listener: () => void) => subscribeComposerDraft(chatId, listener, agentRef), + [agentRef, chatId] + ) + const draft = useSyncExternalStore(subscribe, () => getComposerDraft(chatId, agentRef)) + const setDraft = useCallback( + (value: string) => setComposerDraft(chatId, value, agentRef), + [agentRef, chatId] ) - const draft = useSyncExternalStore(subscribe, () => getComposerDraft(chatId)) - const setDraft = useCallback((value: string) => setComposerDraft(chatId, value), [chatId]) return [draft, setDraft] as const } diff --git a/desktop-app/ui/src/lib/__tests__/composerDraftStore.test.ts b/desktop-app/ui/src/lib/__tests__/composerDraftStore.test.ts index b2ac9e1757..5e9b0dcdda 100644 --- a/desktop-app/ui/src/lib/__tests__/composerDraftStore.test.ts +++ b/desktop-app/ui/src/lib/__tests__/composerDraftStore.test.ts @@ -95,6 +95,21 @@ describe('composerDraftStore', () => { expect(getComposerDraft('chat-1')).toBe('') }) + it('isolates no-chat drafts by agent while preserving the legacy unscoped bucket', () => { + setComposerDraft(null, 'agent-a draft', 'agent-a') + setComposerDraft(null, 'agent-b draft', 'agent-b') + setComposerDraft(null, 'legacy draft') + + expect(getComposerDraft(null, 'agent-a')).toBe('agent-a draft') + expect(getComposerDraft(null, 'agent-b')).toBe('agent-b draft') + expect(getComposerDraft(null)).toBe('legacy draft') + + clearComposerDraftAfterSend(null, 'agent-a') + expect(getComposerDraft(null, 'agent-a')).toBe('') + expect(getComposerDraft(null, 'agent-b')).toBe('agent-b draft') + expect(getComposerDraft(null)).toBe('legacy draft') + }) + it('resetComposerDraftStore wipes all drafts and listeners', () => { const listener = vi.fn() subscribeComposerDraft('chat-1', listener) diff --git a/desktop-app/ui/src/lib/composerDraftStore.ts b/desktop-app/ui/src/lib/composerDraftStore.ts index e0bf96f40f..a75b38c07a 100644 --- a/desktop-app/ui/src/lib/composerDraftStore.ts +++ b/desktop-app/ui/src/lib/composerDraftStore.ts @@ -8,13 +8,7 @@ type Listener = () => void -/** - * Draft key used while no chat is active yet (before the first send creates one). - * This bucket is shared, not per-agent: it assumes at most one "no active chat" - * composer is mounted at a time (true today — the inline composer renders only when - * `!activeChatId`). A future split-view that showed two no-chat composers at once - * would make them share this draft. - */ +/** Legacy no-chat key retained for callers that do not yet provide an agent. */ const NO_CHAT_DRAFT_KEY = '__no_chat__' const drafts = new Map() @@ -22,8 +16,10 @@ let revision = 0 const revisions = new Map() const listenersByKey = new Map>() -function keyFor(chatId: string | null): string { - return chatId ?? NO_CHAT_DRAFT_KEY +function keyFor(chatId: string | null, agentRef?: string): string { + if (chatId) return chatId + const agent = agentRef?.trim() + return agent ? `${NO_CHAT_DRAFT_KEY}:${agent}` : NO_CHAT_DRAFT_KEY } function emit(key: string): void { @@ -32,16 +28,16 @@ function emit(key: string): void { for (const listener of listeners) listener() } -export function getComposerDraftRevision(chatId: string | null): number { - return revisions.get(keyFor(chatId)) ?? 0 +export function getComposerDraftRevision(chatId: string | null, agentRef?: string): number { + return revisions.get(keyFor(chatId, agentRef)) ?? 0 } -export function getComposerDraft(chatId: string | null): string { - return drafts.get(keyFor(chatId)) ?? '' +export function getComposerDraft(chatId: string | null, agentRef?: string): string { + return drafts.get(keyFor(chatId, agentRef)) ?? '' } -export function setComposerDraft(chatId: string | null, value: string): void { - const key = keyFor(chatId) +export function setComposerDraft(chatId: string | null, value: string, agentRef?: string): void { + const key = keyFor(chatId, agentRef) if ((drafts.get(key) ?? '') === value) return revisions.set(key, ++revision) if (value) drafts.set(key, value) @@ -49,13 +45,13 @@ export function setComposerDraft(chatId: string | null, value: string): void { emit(key) } -export function clearComposerDraft(chatId: string | null): void { - setComposerDraft(chatId, '') +export function clearComposerDraft(chatId: string | null, agentRef?: string): void { + setComposerDraft(chatId, '', agentRef) } /** Clears the draft for a chat and the "no chat yet" bucket (used on send). */ -export function clearComposerDraftAfterSend(chatId: string | null): void { - clearComposerDraft(null) +export function clearComposerDraftAfterSend(chatId: string | null, agentRef?: string): void { + clearComposerDraft(null, agentRef) if (chatId) clearComposerDraft(chatId) } @@ -68,8 +64,12 @@ export function clearAllComposerDrafts(): void { } } -export function subscribeComposerDraft(chatId: string | null, listener: Listener): () => void { - const key = keyFor(chatId) +export function subscribeComposerDraft( + chatId: string | null, + listener: Listener, + agentRef?: string +): () => void { + const key = keyFor(chatId, agentRef) let listeners = listenersByKey.get(key) if (!listeners) { listeners = new Set() diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index 6c82a65481..f0b4aa14b9 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.353", + "version": "0.1.354", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.353", + "version": "0.1.354", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index d69981a819..6b87da0928 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.353", + "version": "0.1.354", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/src/hostReconciler.rbacPvcReadFirst.test.ts b/host-context-controller/src/hostReconciler.rbacPvcReadFirst.test.ts index 75a2c3d9a5..379060db4e 100644 --- a/host-context-controller/src/hostReconciler.rbacPvcReadFirst.test.ts +++ b/host-context-controller/src/hostReconciler.rbacPvcReadFirst.test.ts @@ -308,7 +308,7 @@ describe('Host read-first RBAC and PVC contracts', () => { }, spec: { volumeName: 'bound-volume' }, }) - await (f.reconciler as any).ensurePvc(host) + await expect((f.reconciler as any).ensurePvc(host)).resolves.toBe(true) expect(f.core.readNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.createNamespacedPersistentVolumeClaim).not.toHaveBeenCalled() expect(f.core.replaceNamespacedPersistentVolumeClaim).not.toHaveBeenCalled() @@ -317,7 +317,7 @@ describe('Host read-first RBAC and PVC contracts', () => { it('updates an unbound Host PVC once with its observed resourceVersion', async () => { const f = fixture() - await (f.reconciler as any).ensurePvc(host) + await expect((f.reconciler as any).ensurePvc(host)).resolves.toBe(true) expect(f.core.readNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.createNamespacedPersistentVolumeClaim).not.toHaveBeenCalled() expect(f.core.replaceNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() @@ -327,6 +327,28 @@ describe('Host read-first RBAC and PVC contracts', () => { expect(await count('PersistentVolumeClaim', 'skipped')).toBe(1) }) + it('counts a Host PVC create race as successful after fresh read convergence', async () => { + const f = fixture() + f.core.readNamespacedPersistentVolumeClaim + .mockRejectedValueOnce({ code: 404 }) + .mockResolvedValueOnce({ + metadata: { + name: 'rbac-host-workspace', + namespace: host.namespace, + uid: 'pvc-uid', + resourceVersion: '17', + }, + }) + f.core.createNamespacedPersistentVolumeClaim.mockRejectedValueOnce({ code: 409 }) + + await expect((f.reconciler as any).ensurePvc(host)).resolves.toBe(true) + + expect(f.core.readNamespacedPersistentVolumeClaim).toHaveBeenCalledTimes(2) + expect(f.core.createNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() + expect(f.core.replaceNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() + expect(await count('PersistentVolumeClaim', 'conflict')).toBe(1) + }) + it('propagates a Host PVC initial GET failure outside retained write catches', async () => { const f = fixture() const failure = { code: 403 } @@ -342,7 +364,7 @@ describe('Host read-first RBAC and PVC contracts', () => { const f = fixture() f.core.readNamespacedPersistentVolumeClaim.mockRejectedValueOnce({ code: 404 }) f.core.createNamespacedPersistentVolumeClaim.mockRejectedValueOnce({ code: 503 }) - await expect((f.reconciler as any).ensurePvc(host)).resolves.toBeUndefined() + await expect((f.reconciler as any).ensurePvc(host)).resolves.toBe(false) expect(f.core.readNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.createNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.replaceNamespacedPersistentVolumeClaim).not.toHaveBeenCalled() @@ -353,7 +375,7 @@ describe('Host read-first RBAC and PVC contracts', () => { it.each([409, 503])('retains Host PVC PUT%d without retrying or recording a skip', async code => { const f = fixture() f.core.replaceNamespacedPersistentVolumeClaim.mockRejectedValueOnce({ code }) - await expect((f.reconciler as any).ensurePvc(host)).resolves.toBeUndefined() + await expect((f.reconciler as any).ensurePvc(host)).resolves.toBe(false) expect(f.core.readNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.replaceNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() expect(f.core.createNamespacedPersistentVolumeClaim).not.toHaveBeenCalled() diff --git a/host-context-controller/src/hostReconciler.readFirst.test.ts b/host-context-controller/src/hostReconciler.readFirst.test.ts index 01095d6654..0601f9730e 100644 --- a/host-context-controller/src/hostReconciler.readFirst.test.ts +++ b/host-context-controller/src/hostReconciler.readFirst.test.ts @@ -214,7 +214,7 @@ describe('Host read-first Service and Deployment contracts', () => { const { core, reconciler } = fixture() core.readNamespacedService.mockRejectedValueOnce({ code: 404 }) core.createNamespacedService.mockRejectedValueOnce({ code: 503 }) - await expect((reconciler as any).ensureService(host)).resolves.toBeUndefined() + await expect((reconciler as any).ensureService(host)).resolves.toBe(false) expect(core.readNamespacedService).toHaveBeenCalledOnce() expect(core.createNamespacedService).toHaveBeenCalledOnce() expect(core.replaceNamespacedService).not.toHaveBeenCalled() @@ -225,7 +225,7 @@ describe('Host read-first Service and Deployment contracts', () => { it('retains a Host Service convergence failure without recording a successful skip', async () => { const { core, reconciler } = fixture() core.replaceNamespacedService.mockRejectedValueOnce({ code: 503 }) - await expect((reconciler as any).ensureService(host)).resolves.toBeUndefined() + await expect((reconciler as any).ensureService(host)).resolves.toBe(false) expect(core.readNamespacedService).toHaveBeenCalledOnce() expect(core.createNamespacedService).not.toHaveBeenCalled() expect(core.replaceNamespacedService).toHaveBeenCalledOnce() @@ -238,7 +238,7 @@ describe('Host read-first Service and Deployment contracts', () => { const desired = (reconciler as any).buildService(host) as k8s.V1Service core.readNamespacedService.mockResolvedValue(asApiserverService(desired)) core.createNamespacedService.mockRejectedValue({ code: 409 }) - await (reconciler as any).ensureService(host) + await expect((reconciler as any).ensureService(host)).resolves.toBe(true) expect(core.readNamespacedService).toHaveBeenCalledOnce() expect(core.createNamespacedService).not.toHaveBeenCalled() expect(core.replaceNamespacedService).not.toHaveBeenCalled() @@ -325,7 +325,7 @@ describe('Host read-first Service and Deployment contracts', () => { const { core, reconciler } = fixture() core.readNamespacedService.mockRejectedValueOnce({ code: 404 }) core.createNamespacedService.mockRejectedValueOnce({ code: 409 }) - await (reconciler as any).ensureService(host) + await expect((reconciler as any).ensureService(host)).resolves.toBe(true) expect(core.readNamespacedService).toHaveBeenCalledTimes(2) expect(core.createNamespacedService).toHaveBeenCalledOnce() expect(core.replaceNamespacedService).toHaveBeenCalledOnce() diff --git a/host-context-controller/src/hostReconciler.test.ts b/host-context-controller/src/hostReconciler.test.ts index 1e7ca0f551..f7a2e31c91 100644 --- a/host-context-controller/src/hostReconciler.test.ts +++ b/host-context-controller/src/hostReconciler.test.ts @@ -1056,12 +1056,12 @@ describe('HostReconciler Host inventory mutation authority', () => { function stubPreDeploymentReconcileEffects(reconciler: HostReconciler): void { vi.spyOn(reconciler as any, 'validateHostSecret').mockResolvedValue({ ok: true }) + vi.spyOn(reconciler as any, 'ensurePvc').mockResolvedValue(true) + vi.spyOn(reconciler as any, 'ensureService').mockResolvedValue(true) for (const method of [ 'ensureHostServiceAccount', 'ensureHostRole', 'ensureHostRoleBinding', - 'ensurePvc', - 'ensureService', 'ensureMcpHostIngressNetworkPolicy', 'ensureMcpHostGfsEgressNetworkPolicy', 'ensureWorkflowApprovalReaderMcpHostIngressNetworkPolicy', @@ -1071,7 +1071,7 @@ describe('HostReconciler Host inventory mutation authority', () => { 'ensureChannelReaderEgressNetworkPolicy', 'ensureWorkflowApprovalReaderHostEgressNetworkPolicy', ]) { - vi.spyOn(reconciler as any, method).mockResolvedValue(undefined) + vi.spyOn(reconciler as any, method).mockResolvedValue('up_to_date') } } diff --git a/host-context-controller/src/hostReconciler.ts b/host-context-controller/src/hostReconciler.ts index 9ab121fe14..7a28f33504 100644 --- a/host-context-controller/src/hostReconciler.ts +++ b/host-context-controller/src/hostReconciler.ts @@ -78,6 +78,7 @@ import { HostWorkflowControlSpec, } from './types' import { + type ResourceApplyResult, applyNetworkPolicy, canonicalStringify, canonicalizeValue, @@ -382,6 +383,7 @@ const GFS_TOKEN_REFRESH_BEFORE_ANNOTATION = 'clerum.io/gfs-token-refresh-before' const GFS_TOKEN_EXPECTED_SUBJECT_ANNOTATION = 'clerum.io/gfs-token-expected-subject' const GFS_TOKEN_CAPABILITY_SET_HASH_ANNOTATION = 'clerum.io/gfs-token-capability-set-hash' const GFS_TOKEN_HOST_UID_ANNOTATION = 'clerum.io/gfs-token-host-uid' +const HOST_UID_ANNOTATION = 'clerum.io/host-uid' const GFS_TOKEN_HOST_GENERATION_ANNOTATION = 'clerum.io/gfs-token-host-generation' const RUNTIME_TOKEN_HOST_BINDING_HASH_ANNOTATION = 'clerum.io/runtime-token-host-binding-hash' const RUNTIME_TOKEN_SCOPE_HASH_ANNOTATION = 'clerum.io/runtime-token-scope-hash' @@ -3182,6 +3184,7 @@ export class HostReconciler { name: host.name, namespace: host.namespace, labels, + ...(host.uid ? { annotations: { [HOST_UID_ANNOTATION]: host.uid } } : {}), }, spec: { // A suspended stateless Host scales to 0 on EVERY reconcile path @@ -3502,16 +3505,17 @@ export class HostReconciler { this.readinessTimers.set(name, timer) } - private async ensurePvc(host: HostCRD, revalidate?: () => void): Promise { + private async ensurePvc(host: HostCRD, revalidate?: () => void): Promise { const pvc = this.buildPvc(host) const name = this.pvcName(host) + let createFailed = false const mutationAllowed = () => { revalidate?.() return true } // Initial GET errors must propagate; retain only the established // non-throwing POST and convergence failures of this PVC writer. - await ensureResource({ + const result = await ensureResource({ mutationAllowed, read: () => observeExistenceRead('PersistentVolumeClaim', () => @@ -3527,6 +3531,7 @@ export class HostReconciler { ) } catch (error) { if (error != null && getErrorCode(error) === 409) throw error + createFailed = true log.error('Failed to create Host PVC', { host: host.name, err: error }) } }, @@ -3549,17 +3554,19 @@ export class HostReconciler { }, onSkipped: () => createsTotal.inc({ kind: 'PersistentVolumeClaim', outcome: 'skipped' }), }) + return !createFailed && this.resourceApplySucceeded(result) } - private async ensureService(host: HostCRD, revalidate?: () => void): Promise { + private async ensureService(host: HostCRD, revalidate?: () => void): Promise { const service = this.buildService(host) + let createFailed = false const mutationAllowed = () => { revalidate?.() return true } // Initial read errors propagate. Only the existing POST/convergence paths // retain their historical non-throwing failures. - await ensureResource({ + const result = await ensureResource({ mutationAllowed, read: () => observeExistenceRead('Service', () => @@ -3572,6 +3579,7 @@ export class HostReconciler { ) } catch (error) { if (error != null && getErrorCode(error) === 409) throw error + createFailed = true log.error('Failed to create Host Service', { host: host.name, err: error }) } }, @@ -3600,6 +3608,11 @@ export class HostReconciler { } }, }) + return !createFailed && this.resourceApplySucceeded(result) + } + + private resourceApplySucceeded(result: ResourceApplyResult): boolean { + return result === 'created' || result === 'replaced' || result === 'up_to_date' } private async ensureDeployment( @@ -3626,13 +3639,23 @@ export class HostReconciler { existing.metadata?.namespace !== host.namespace || !existing.metadata?.uid || !existing.metadata?.resourceVersion || + existing.metadata.deletionTimestamp || !existing.spec?.template?.spec ) { throw new Error(`Cannot preserve an unverified Deployment for Host "${host.name}"`) } + const annotatedHostUid = existing.metadata.annotations?.[HOST_UID_ANNOTATION] + const hostUidVerified = !!host.uid && annotatedHostUid === host.uid + if (!hostUidVerified) { + throw new Error(`Cannot preserve an unverified Deployment for Host "${host.name}"`) + } // Preserve UID/resourceVersion and every applied field. Conflicts - // re-read this object before retrying; only replicas may change. - return { ...existing, spec: { ...existing.spec, replicas: 1 } } + // re-read this object before retrying; replicas is the only field that + // may change. An unverified legacy object must never be scaled. + return { + ...existing, + spec: { ...existing.spec, replicas: 1 }, + } } return this.buildDeployment( host, @@ -3646,7 +3669,7 @@ export class HostReconciler { revalidate?.() return true } - await ensureResource({ + const result = await ensureResource({ mutationAllowed, read: async () => { observedDeployment = await observeExistenceRead('Deployment', () => @@ -3689,7 +3712,7 @@ export class HostReconciler { }), }), }) - return applied + return applied && this.resourceApplySucceeded(result) } private async deleteRuntimeResources(name: string, namespace: string): Promise { @@ -3856,9 +3879,11 @@ export class HostReconciler { } } - private async ensureDesktopNetworkPolicy(host: HostCRD): Promise { + private async ensureDesktopNetworkPolicy( + host: HostCRD + ): Promise<'not-required' | ResourceApplyResult> { const isDesktop = !!(host.spec.desktop?.browser || host.spec.desktop?.x11) - if (!isDesktop) return + if (!isDesktop) return 'not-required' const policyName = `allow-rpc-proxy-desktop-${host.name}` const policy: k8s.V1NetworkPolicy = { @@ -3892,17 +3917,13 @@ export class HostReconciler { }, } - try { - await applyNetworkPolicy( - this.networkingApi, - policyName, - host.namespace, - policy, - '[HostReconciler]' - ) - } catch (error) { - log.error('Failed to ensure desktop NetworkPolicy', { policy: policyName, err: error }) - } + return applyNetworkPolicy( + this.networkingApi, + policyName, + host.namespace, + policy, + '[HostReconciler]' + ) } /** @@ -3911,7 +3932,7 @@ export class HostReconciler { * egress. Desktop hosts also expose the desktop service port through the * same host-scoped egress boundary. */ - private async ensureRpcProxyHostEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureRpcProxyHostEgressNetworkPolicy(host: HostCRD): Promise { const isDesktop = !!(host.spec.desktop?.browser || host.spec.desktop?.x11) const policyName = `rpc-proxy-${host.name}-egress-mcp-host` const ports: k8s.V1NetworkPolicyPort[] = [{ port: config.hostPort, protocol: 'TCP' }] @@ -3954,7 +3975,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, config.rpcProxyNamespace, @@ -3968,7 +3989,9 @@ export class HostReconciler { * its bound mcp-host on :8080. Provider approvals and verification continue * through mcp-host; channel-reader must not reach control-api gateways. */ - private async ensureChannelReaderEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureChannelReaderEgressNetworkPolicy( + host: HostCRD + ): Promise { const policyName = `channel-reader-${host.name}-egress` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4013,7 +4036,7 @@ export class HostReconciler { // channel-reader security boundary. Route-level JWT auth is the other // half, so an NP apply error still MUST surface in HostRuntimeStatus // rather than be silently swallowed. - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, config.channelsNamespace, @@ -4027,7 +4050,9 @@ export class HostReconciler { * are verified by control-api against a CommunicationChannel, then forwarded * to that channel's bound Host for the authoritative provider-decision path. */ - private async ensureWorkflowApprovalReaderHostEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureWorkflowApprovalReaderHostEgressNetworkPolicy( + host: HostCRD + ): Promise { const policyName = `workflow-approval-reader-${host.name}-egress-mcp-host` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4067,7 +4092,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, config.channelsNamespace, @@ -4081,7 +4106,7 @@ export class HostReconciler { * pod ingress on :8080 to ONLY accept connections from channel-reader- * pods (label match). */ - private async ensureMcpHostIngressNetworkPolicy(host: HostCRD): Promise { + private async ensureMcpHostIngressNetworkPolicy(host: HostCRD): Promise { const policyName = `mcp-host-${host.name}-ingress-channel-reader` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4129,7 +4154,7 @@ export class HostReconciler { // channel-reader↔mcp-host security boundary. Route-level JWT auth is // the other half, so an apply error MUST surface in HostRuntimeStatus // rather than be silently swallowed. - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4145,7 +4170,7 @@ export class HostReconciler { */ private async ensureWorkflowApprovalReaderMcpHostIngressNetworkPolicy( host: HostCRD - ): Promise { + ): Promise { const policyName = `mcp-host-${host.name}-ingress-workflow-approval-reader` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4187,7 +4212,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4201,7 +4226,9 @@ export class HostReconciler { * namespace-wide mcp-host ingress rule for rpc-proxy. Desktop hosts include * the desktop port, but only for the selected Host pod. */ - private async ensureRpcProxyMcpHostIngressNetworkPolicy(host: HostCRD): Promise { + private async ensureRpcProxyMcpHostIngressNetworkPolicy( + host: HostCRD + ): Promise { const isDesktop = !!(host.spec.desktop?.browser || host.spec.desktop?.x11) const policyName = `mcp-host-${host.name}-ingress-rpc-proxy` const ports: k8s.V1NetworkPolicyPort[] = [{ port: config.hostPort, protocol: 'TCP' }] @@ -4244,7 +4271,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4258,7 +4285,7 @@ export class HostReconciler { * NetworkPolicy only opens the required transport lane from the selected Host * pod to the governed GFS controller service. */ - private async ensureMcpHostGfsEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureMcpHostGfsEgressNetworkPolicy(host: HostCRD): Promise { const target = { namespace: config.gfsNamespace, port: config.gfscPort } const policyName = `mcp-host-${host.name}-egress-gfs` const policy: k8s.V1NetworkPolicy = { @@ -4299,7 +4326,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4312,7 +4339,9 @@ export class HostReconciler { * Per-Host mcp-host egress to the static Codex LLM proxy runtime listener. * Derived from the same Codex projection that mints `llm:codex:execute`. */ - private async ensureMcpHostCodexProxyEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureMcpHostCodexProxyEgressNetworkPolicy( + host: HostCRD + ): Promise { const policyName = `mcp-host-${host.name}-egress-codex-proxy` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4352,7 +4381,7 @@ export class HostReconciler { }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4381,17 +4410,21 @@ export class HostReconciler { ) } - private async reconcileMcpHostCodexProxyEgressNetworkPolicy(host: HostCRD): Promise { + private async reconcileMcpHostCodexProxyEgressNetworkPolicy( + host: HostCRD + ): Promise<'not-required' | ResourceApplyResult> { const projection = this.projectCodexForHost(host) - if (projection.eligibility === 'uncertain') return + if (projection.eligibility === 'uncertain') return 'not-required' if (projection.requiresCodexProxyEgress) { - await this.ensureMcpHostCodexProxyEgressNetworkPolicy(host) - return + return this.ensureMcpHostCodexProxyEgressNetworkPolicy(host) } await this.deleteMcpHostCodexProxyEgressNetworkPolicy(host) + return 'not-required' } - private async ensureMcpHostGrokProxyEgressNetworkPolicy(host: HostCRD): Promise { + private async ensureMcpHostGrokProxyEgressNetworkPolicy( + host: HostCRD + ): Promise { const policyName = `mcp-host-${host.name}-egress-grok-proxy` const policy: k8s.V1NetworkPolicy = { apiVersion: 'networking.k8s.io/v1', @@ -4430,7 +4463,7 @@ export class HostReconciler { ], }, } - await applyNetworkPolicy( + return applyNetworkPolicy( this.networkingApi, policyName, host.namespace, @@ -4459,14 +4492,16 @@ export class HostReconciler { ) } - private async reconcileMcpHostGrokProxyEgressNetworkPolicy(host: HostCRD): Promise { + private async reconcileMcpHostGrokProxyEgressNetworkPolicy( + host: HostCRD + ): Promise<'not-required' | ResourceApplyResult> { const projection = this.projectGrokForHost(host) - if (projection.eligibility === 'uncertain') return + if (projection.eligibility === 'uncertain') return 'not-required' if (projection.requiresGrokProxyEgress) { - await this.ensureMcpHostGrokProxyEgressNetworkPolicy(host) - return + return this.ensureMcpHostGrokProxyEgressNetworkPolicy(host) } await this.deleteMcpHostGrokProxyEgressNetworkPolicy(host) + return 'not-required' } /** @@ -4720,6 +4755,30 @@ export class HostReconciler { }) } + let pvcApplied = true + let serviceApplied = true + const resourceErrors: unknown[] = [] + const resourceFailureMessages = new Map<'Host PVC' | 'Host Service', string>() + const npFailures: string[] = [] + const ensureIndependentResource = async ( + kind: 'Host PVC' | 'Host Service', + reconcile: () => Promise + ): Promise => { + try { + return await reconcile() + } catch (error) { + if (isBenignSupersessionError(error)) throw error + resourceErrors.push(error) + const message = error instanceof Error ? error.message : String(error) + resourceFailureMessages.set(kind, `${kind}: ${message}`) + log.error('Failed to reconcile Host resource', { + host: host.name, + resource: kind, + err: error, + }) + return false + } + } const holdAppliedRuntime = async (): Promise => { const applied = await this.ensureDeployment( host, @@ -4731,25 +4790,40 @@ export class HostReconciler { ) revalidateHostMutationBoundary() this.lifecycle.markHostNotSuspended(host.name) - const ready = applied && (await this.checkDeploymentReady(host.name, host.namespace)) + const deploymentReady = + applied && (await this.checkDeploymentReady(host.name, host.namespace)) revalidateHostMutationBoundary() + const ready = deploymentReady && pvcApplied && serviceApplied && npFailures.length === 0 + const failures = [ + ...(!pvcApplied + ? [resourceFailureMessages.get('Host PVC') ?? 'Host PVC did not converge'] + : []), + ...(!serviceApplied + ? [resourceFailureMessages.get('Host Service') ?? 'Host Service did not converge'] + : []), + ...npFailures, + ] this.setStatus(host.name, { deployed: applied, ready, - message: applied - ? 'CommunicationChannel inventory unavailable; preserving applied runtime with one replica' - : 'Waiting for CommunicationChannel inventory before creating runtime', + message: !applied + ? 'Waiting for CommunicationChannel inventory before creating runtime' + : failures.length > 0 + ? `degraded — Host runtime boundary incomplete while preserving applied runtime (${failures.join('; ')})` + : deploymentReady + ? 'CommunicationChannel inventory unavailable; preserving applied runtime with one replica' + : 'CommunicationChannel inventory unavailable; preserved applied runtime is not Ready', }) } - if (lifecycle.effective.suspensionBlocked) { - await holdAppliedRuntime() - return - } revalidateHostMutationBoundary() - await this.ensurePvc(host, revalidateHostMutationBoundary) + pvcApplied = await ensureIndependentResource('Host PVC', () => + this.ensurePvc(host, revalidateHostMutationBoundary) + ) revalidateHostMutationBoundary() - await this.ensureService(host, revalidateHostMutationBoundary) + serviceApplied = await ensureIndependentResource('Host Service', () => + this.ensureService(host, revalidateHostMutationBoundary) + ) // NetworkPolicies before Deployments. Calico/Cilium evaluate egress and // ingress against the policies that exist when the connection is opened, @@ -4762,45 +4836,54 @@ export class HostReconciler { // Route-level edge context and ownership checks remain the application // boundary. An NP apply failure means the network boundary is missing, so // surface it via HostRuntimeStatus instead of swallowing it. - const npFailures: string[] = [] - try { - revalidateHostMutationBoundary() - await this.ensureMcpHostIngressNetworkPolicy(host) - revalidateHostMutationBoundary() - await this.ensureMcpHostGfsEgressNetworkPolicy(host) - revalidateHostMutationBoundary() - await this.reconcileMcpHostCodexProxyEgressNetworkPolicy(host) - revalidateHostMutationBoundary() - await this.reconcileMcpHostGrokProxyEgressNetworkPolicy(host) - // The mcp-host→llm-hooks egress policy is now owned by LlmHookReconciler - // (per-host, scoped to referenced hook pods — N1/N7); host-delete cleanup - // of `mcp-host--egress-llm-hooks` stays in deleteHostNetworkPolicies. - revalidateHostMutationBoundary() - await this.ensureWorkflowApprovalReaderMcpHostIngressNetworkPolicy(host) - } catch (err) { - log.error('Failed to ensure mcp-host NP', { host: host.name, err }) - npFailures.push(`mcp-host NP: ${(err as Error).message}`) - } - try { - revalidateHostMutationBoundary() - await this.ensureRpcProxyMcpHostIngressNetworkPolicy(host) - revalidateHostMutationBoundary() - await this.ensureRpcProxyHostEgressNetworkPolicy(host) - } catch (err) { - log.error('Failed to ensure rpc-proxy host NP', { host: host.name, err }) - npFailures.push(`rpc-proxy NP: ${(err as Error).message}`) + const ensureNetworkPolicy = async ( + name: string, + reconcile: () => Promise<'not-required' | ResourceApplyResult> + ): Promise => { + try { + revalidateHostMutationBoundary() + const result = await reconcile() + revalidateHostMutationBoundary() + if (result !== 'not-required' && !this.resourceApplySucceeded(result)) { + throw new Error(`apply did not converge (${result})`) + } + } catch (err) { + log.error('Failed to ensure Host NetworkPolicy', { host: host.name, policy: name, err }) + npFailures.push(`${name}: ${err instanceof Error ? err.message : String(err)}`) + } } + await ensureNetworkPolicy('mcp-host ingress', () => + this.ensureMcpHostIngressNetworkPolicy(host) + ) + await ensureNetworkPolicy('mcp-host GFS egress', () => + this.ensureMcpHostGfsEgressNetworkPolicy(host) + ) + await ensureNetworkPolicy('mcp-host Codex proxy egress', () => + this.reconcileMcpHostCodexProxyEgressNetworkPolicy(host) + ) + await ensureNetworkPolicy('mcp-host Grok proxy egress', () => + this.reconcileMcpHostGrokProxyEgressNetworkPolicy(host) + ) + // The mcp-host→llm-hooks egress policy is now owned by LlmHookReconciler + // (per-host, scoped to referenced hook pods — N1/N7); host-delete cleanup + // of `mcp-host--egress-llm-hooks` stays in deleteHostNetworkPolicies. + await ensureNetworkPolicy('workflow approval reader ingress', () => + this.ensureWorkflowApprovalReaderMcpHostIngressNetworkPolicy(host) + ) + await ensureNetworkPolicy('rpc-proxy mcp-host ingress', () => + this.ensureRpcProxyMcpHostIngressNetworkPolicy(host) + ) + await ensureNetworkPolicy('rpc-proxy Host egress', () => + this.ensureRpcProxyHostEgressNetworkPolicy(host) + ) + await ensureNetworkPolicy('desktop ingress', () => this.ensureDesktopNetworkPolicy(host)) + await ensureNetworkPolicy('channel-reader egress', () => + this.ensureChannelReaderEgressNetworkPolicy(host) + ) + await ensureNetworkPolicy('workflow approval reader egress', () => + this.ensureWorkflowApprovalReaderHostEgressNetworkPolicy(host) + ) revalidateHostMutationBoundary() - await this.ensureDesktopNetworkPolicy(host) - try { - revalidateHostMutationBoundary() - await this.ensureChannelReaderEgressNetworkPolicy(host) - revalidateHostMutationBoundary() - await this.ensureWorkflowApprovalReaderHostEgressNetworkPolicy(host) - } catch (err) { - log.error('Failed to ensure channels egress NP', { host: host.name, err }) - npFailures.push(`egress NP: ${(err as Error).message}`) - } // Resolve policy once before bootstrap so token scopes and wake/suspend // decisions use the latest channel state already observed. @@ -4820,6 +4903,14 @@ export class HostReconciler { return } + if (resourceErrors.length === 1) throw resourceErrors[0] + if (resourceErrors.length > 1) { + throw new AggregateError( + resourceErrors, + `Failed to reconcile Host resources for "${host.name}"` + ) + } + // Bootstrap captures the scope contract used for issuance. The Deployment // guard below compares that contract with the live channel cache after this // potentially slow I/O and before every create/replace attempt. diff --git a/host-context-controller/test/hostReconciler.lifecycle.test.ts b/host-context-controller/test/hostReconciler.lifecycle.test.ts index 6c894cebd0..062806fa9a 100644 --- a/host-context-controller/test/hostReconciler.lifecycle.test.ts +++ b/host-context-controller/test/hostReconciler.lifecycle.test.ts @@ -380,7 +380,10 @@ describe('HostReconciler ensureDeployment — idempotent replacement', () => { uid: 'deployment-uid', generation: 7, creationTimestamp: new Date('2026-07-10T00:00:00Z'), - annotations: { 'deployment.kubernetes.io/revision': '7' }, + annotations: { + ...deployment.metadata?.annotations, + 'deployment.kubernetes.io/revision': '7', + }, } deployment.status = { readyReplicas: 1, availableReplicas: 1 } deployment.spec = { @@ -876,25 +879,114 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { expect(current.spec!.template.metadata!.annotations).toEqual({ 'example.org/rollout': 'fresh' }) }) - it.each(['foreign owner', 'missing UID', 'missing resourceVersion'])( - 'does not scale an unverified Deployment during cache loss: %s', - async invalid => { - const host = makeStatelessHost({ status: suspendedStatus() }) - const { reconciler, appsApi, customApi } = createReconciler({ - isCommunicationChannelCacheSynced: () => false, - }) - customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) - const applied = createReconciler().reconciler.buildDeployment(host) - applied.metadata = { ...applied.metadata, uid: 'deployment-uid', resourceVersion: '73' } - if (invalid === 'foreign owner') applied.metadata.labels!['clerum.io/host'] = 'another-host' - if (invalid === 'missing UID') delete applied.metadata.uid - if (invalid === 'missing resourceVersion') delete applied.metadata.resourceVersion - appsApi.readNamespacedDeployment.mockResolvedValue(applied) - await expect(reconciler.reconcile(host)).rejects.toThrow('unverified Deployment') - expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() - expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() + it.each([ + 'foreign owner', + 'missing UID', + 'missing resourceVersion', + 'prior Host UID', + 'unannotated legacy', + ])('does not scale an unverified Deployment during cache loss: %s', async invalid => { + const host = makeStatelessHost({ status: suspendedStatus() }) + const { reconciler, appsApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = createReconciler().reconciler.buildDeployment(host) + applied.metadata = { ...applied.metadata, uid: 'deployment-uid', resourceVersion: '73' } + if (invalid === 'foreign owner') applied.metadata.labels!['clerum.io/host'] = 'another-host' + if (invalid === 'missing UID') delete applied.metadata.uid + if (invalid === 'missing resourceVersion') delete applied.metadata.resourceVersion + if (invalid === 'prior Host UID') { + applied.metadata.annotations = { + ...applied.metadata.annotations, + 'clerum.io/host-uid': 'prior-host-uid', + } } - ) + if (invalid === 'unannotated legacy') + delete applied.metadata.annotations?.['clerum.io/host-uid'] + appsApi.readNamespacedDeployment.mockResolvedValue(applied) + await expect(reconciler.reconcile(host)).rejects.toThrow('unverified Deployment') + expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() + expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() + }) + + it('never scales an unannotated legacy Deployment, even when created after the current Host', async () => { + const host = makeStatelessHost({ status: suspendedStatus() }) + host.metadata = { + ...host.metadata, + creationTimestamp: new Date('2026-09-20T10:00:00Z'), + } + const { reconciler, appsApi } = createReconciler() + const legacy = reconciler.buildDeployment(host) + delete legacy.metadata?.annotations?.['clerum.io/host-uid'] + legacy.metadata = { + ...legacy.metadata, + uid: 'deployment-uid', + resourceVersion: '73', + creationTimestamp: new Date('2026-09-20T10:01:00Z'), + } + appsApi.readNamespacedDeployment.mockResolvedValue(legacy) + + await expect( + (reconciler as any).ensureDeployment(host, [], undefined, { + stateless: true, + state: 'active', + suspensionBlocked: true, + }) + ).rejects.toThrow('unverified Deployment') + expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() + expect(appsApi.createNamespacedDeployment).not.toHaveBeenCalled() + }) + + it('converges the held runtime boundary and reports failures from PVC and policy reconciliation', async () => { + const host = makeStatelessHost({ status: suspendedStatus() }) + const { reconciler, appsApi, coreApi, customApi } = createReconciler({ + isCommunicationChannelCacheSynced: () => false, + }) + customApi.getNamespacedCustomObject.mockImplementation(async () => hostApiObject(host)) + const applied = reconciler.buildDeployment(host) + applied.status = { readyReplicas: 1 } + persistHostDeployment(appsApi, host, applied) + + coreApi.readNamespacedPersistentVolumeClaim.mockRejectedValueOnce(new Error('PVC read failed')) + coreApi.readNamespacedService.mockRejectedValueOnce(new Error('Service read failed')) + const ingress = vi + .spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy') + .mockRejectedValueOnce(new Error('ingress policy failed')) + const gfsEgress = vi + .spyOn(reconciler as any, 'ensureMcpHostGfsEgressNetworkPolicy') + .mockResolvedValue(undefined) + const codexEgress = vi + .spyOn(reconciler as any, 'reconcileMcpHostCodexProxyEgressNetworkPolicy') + .mockResolvedValue(undefined) + + await reconciler.reconcile(host) + + expect(coreApi.readNamespacedPersistentVolumeClaim).toHaveBeenCalledOnce() + expect(coreApi.readNamespacedService).toHaveBeenCalledOnce() + expect(ingress).toHaveBeenCalledOnce() + expect(gfsEgress).toHaveBeenCalledOnce() + expect(codexEgress).toHaveBeenCalledOnce() + expect(reconciler.getStatus(host.name)).toMatchObject({ + deployed: true, + ready: false, + message: expect.stringContaining('Host runtime boundary incomplete'), + }) + }) + + it('does not report a Deployment applied when create conflict is followed by a missing read', async () => { + const host = makeStatelessHost() + const { reconciler, appsApi } = createReconciler() + appsApi.readNamespacedDeployment + .mockRejectedValueOnce({ code: 404 }) + .mockRejectedValueOnce({ code: 404 }) + appsApi.createNamespacedDeployment.mockRejectedValueOnce({ code: 409 }) + + await expect((reconciler as any).ensureDeployment(host, [], 'runtime-revision')).resolves.toBe( + false + ) + expect(appsApi.replaceNamespacedDeployment).not.toHaveBeenCalled() + }) it('holds the existing template when channel authority is lost during runtime provisioning', async () => { let synced = true diff --git a/host-context-controller/test/hostReconciler.networkpolicy.test.ts b/host-context-controller/test/hostReconciler.networkpolicy.test.ts index 1754a9d172..87f342f287 100644 --- a/host-context-controller/test/hostReconciler.networkpolicy.test.ts +++ b/host-context-controller/test/hostReconciler.networkpolicy.test.ts @@ -215,10 +215,10 @@ describe('HostReconciler.reconcile — NP wiring', () => { revision: 'runtime-revision', scopeHash: (HostReconciler as any).runtimeTokenScopeHash(HOST, false), }) - vi.spyOn(reconciler as any, 'ensurePvc').mockResolvedValue(undefined) - vi.spyOn(reconciler as any, 'ensureService').mockResolvedValue(undefined) + vi.spyOn(reconciler as any, 'ensurePvc').mockResolvedValue(true) + vi.spyOn(reconciler as any, 'ensureService').mockResolvedValue(true) vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(true) - vi.spyOn(reconciler as any, 'ensureDesktopNetworkPolicy').mockResolvedValue(undefined) + vi.spyOn(reconciler as any, 'ensureDesktopNetworkPolicy').mockResolvedValue('up_to_date') vi.spyOn(reconciler as any, 'reconcileChannelReaderDeployment').mockResolvedValue(undefined) vi.spyOn(reconciler as any, 'checkDeploymentReady').mockResolvedValue(true) }) @@ -229,7 +229,7 @@ describe('HostReconciler.reconcile — NP wiring', () => { // than racing the deny-all default. const ingressSpy = vi .spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy') - .mockResolvedValue(undefined) + .mockResolvedValue('up_to_date') const deploySpy = vi.spyOn(reconciler as any, 'ensureDeployment').mockResolvedValue(true) const readySpy = vi.spyOn(reconciler as any, 'checkDeploymentReady').mockResolvedValue(true) @@ -246,7 +246,7 @@ describe('HostReconciler.reconcile — NP wiring', () => { it('calls ensureChannelReaderEgressNetworkPolicy before reconcileChannelReaderDeployment', async () => { const egressSpy = vi .spyOn(reconciler as any, 'ensureChannelReaderEgressNetworkPolicy') - .mockResolvedValue(undefined) + .mockResolvedValue('up_to_date') const cReaderSpy = vi .spyOn(reconciler as any, 'reconcileChannelReaderDeployment') .mockResolvedValue(undefined) @@ -266,7 +266,7 @@ describe('HostReconciler.reconcile — NP wiring', () => { new Error('rbac: networkpolicies.networking.k8s.io is forbidden') ) vi.spyOn(reconciler as any, 'ensureChannelReaderEgressNetworkPolicy').mockResolvedValue( - undefined + 'up_to_date' ) await reconciler.reconcile(HOST) @@ -278,11 +278,13 @@ describe('HostReconciler.reconcile — NP wiring', () => { expect(status!.deployed).toBe(true) expect(status!.ready).toBe(false) expect(status!.message).toMatch(/degraded/i) - expect(status!.message).toMatch(/mcp-host NP/) + expect(status!.message).toMatch( + /mcp-host ingress: rbac: networkpolicies\.networking\.k8s\.io is forbidden/ + ) }) it('surfaces egress NP apply failure in Host status as degraded', async () => { - vi.spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy').mockResolvedValue(undefined) + vi.spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy').mockResolvedValue('up_to_date') vi.spyOn(reconciler as any, 'ensureChannelReaderEgressNetworkPolicy').mockRejectedValue( new Error('cluster connectivity failure') ) @@ -296,7 +298,25 @@ describe('HostReconciler.reconcile — NP wiring', () => { expect(status!.deployed).toBe(true) expect(status!.ready).toBe(false) expect(status!.message).toMatch(/degraded/i) - expect(status!.message).toMatch(/egress NP/) + expect(status!.message).toMatch(/channel-reader egress: cluster connectivity failure/) + }) + + it('surfaces a NetworkPolicy apply result of missing as degraded', async () => { + vi.spyOn(reconciler as any, 'ensureMcpHostIngressNetworkPolicy').mockResolvedValue('missing') + vi.spyOn(reconciler as any, 'ensureChannelReaderEgressNetworkPolicy').mockResolvedValue( + 'up_to_date' + ) + + await reconciler.reconcile(HOST) + + const status = (reconciler as any).statusMap.get('chatllm') as + | { deployed?: boolean; ready?: boolean; message?: string } + | undefined + expect(status).toMatchObject({ + deployed: true, + ready: false, + message: expect.stringContaining('mcp-host ingress: apply did not converge (missing)'), + }) }) }) diff --git a/scripts/e2e/_lib/hcc-watch-api-proxy.mjs b/scripts/e2e/_lib/hcc-watch-api-proxy.mjs index c53ef402e8..b69444f2da 100644 --- a/scripts/e2e/_lib/hcc-watch-api-proxy.mjs +++ b/scripts/e2e/_lib/hcc-watch-api-proxy.mjs @@ -4,6 +4,11 @@ import https from 'node:https' import { createSecureContext } from 'node:tls' import { createBookmarkObservation } from './hcc-watch-bookmarks.mjs' +// Individual fixture commands remain one-minute bounded; a single continuous +// cache-loss observation has a two-minute ceiling even when renewed. +const MAX_CHANNEL_HOLD_WINDOW_MS = 60_000 +const MAX_CHANNEL_HOLD_BUDGET_MS = 120_000 + export function proxyUpstreamErrorRecord(error) { const codes = [ 'ECONNREFUSED', @@ -47,7 +52,11 @@ export function validateCommand(command, allowedPaths) { if (typeof command.pauseId !== 'string' || !/^[a-zA-Z0-9-]{1,80}$/.test(command.pauseId)) throw new Error('invalid_pause_id') } else if (command.action === 'hold-channel') { - if (!Number.isInteger(command.durationMs) || command.durationMs < 1000 || command.durationMs > 60000) + if ( + !Number.isInteger(command.durationMs) || + command.durationMs < 1000 || + command.durationMs > MAX_CHANNEL_HOLD_WINDOW_MS + ) throw new Error('invalid_channel_hold_duration') } else if (command.action !== 'release-channel' && command.action !== 'observe-bookmarks') throw new Error('invalid_control_action') @@ -95,7 +104,14 @@ export function createProxy({ const held = channelHold channelHold = null clearTimeout(held.timer) - writeRecord('channel-hold', { id: held.id, state: reason, rejected: held.rejected }) + writeRecord('channel-hold', { + id: held.id, + state: reason, + cut: held.cut, + rejected: held.rejected, + renewals: held.renewals, + deadlineAtMs: held.deadlineAtMs, + }) } const server = https.createServer({ key, cert }, (request, response) => { const url = new URL(request.url, 'https://fixture.invalid') @@ -209,22 +225,67 @@ export function createProxy({ finishPause('released') acknowledge({ id: command.id, state: 'released' }) } else if (command.action === 'hold-channel') { - if (!channelPath || channelHold) throw new Error('channel_hold_unavailable') - let count = 0 - channelHold = { - id: command.id, - rejected: 0, - timer: setTimeout(() => finishChannelHold('expired'), command.durationMs), - } - for (const stream of streams) { - if (stream.watch && stream.request.method === 'GET' && - new URL(stream.request.url, 'https://fixture.invalid').pathname === channelPath) { - stream.close() - count++ + if (!channelPath) throw new Error('channel_hold_unavailable') + if (channelHold) { + const now = Date.now() + const budgetDeadlineMs = channelHold.startedAtMs + MAX_CHANNEL_HOLD_BUDGET_MS + const deadlineAtMs = Math.min(now + command.durationMs, budgetDeadlineMs) + if (deadlineAtMs <= now) throw new Error('channel_hold_budget_exhausted') + clearTimeout(channelHold.timer) + channelHold.id = command.id + channelHold.deadlineAtMs = deadlineAtMs + channelHold.renewals++ + channelHold.timer = setTimeout( + () => finishChannelHold('expired'), + deadlineAtMs - now, + ) + writeRecord('channel-hold', { + id: channelHold.id, + state: 'held', + cut: channelHold.cut, + rejected: channelHold.rejected, + renewals: channelHold.renewals, + deadlineAtMs, + }) + acknowledge({ + id: command.id, + state: 'held', + count: channelHold.cut, + renewed: true, + renewals: channelHold.renewals, + deadlineAtMs, + }) + } else { + let count = 0 + const now = Date.now() + const deadlineAtMs = now + command.durationMs + channelHold = { + id: command.id, + startedAtMs: now, + deadlineAtMs, + cut: 0, + rejected: 0, + renewals: 0, + timer: setTimeout(() => finishChannelHold('expired'), command.durationMs), + } + for (const stream of streams) { + if (stream.watch && stream.request.method === 'GET' && + new URL(stream.request.url, 'https://fixture.invalid').pathname === channelPath) { + stream.close() + count++ + } } + channelHold.cut = count + writeRecord('channel-hold', { + id: command.id, + state: 'held', + cut: count, + rejected: 0, + renewals: 0, + deadlineAtMs, + }) + acknowledge({ id: command.id, state: 'held', count, renewals: 0, deadlineAtMs }) } - writeRecord('channel-hold', { id: command.id, state: 'held', cut: count, rejected: 0 }) - acknowledge({ id: command.id, state: 'held', count }) } else if (command.action === 'release-channel') { if (!channelHold) throw new Error('channel_hold_not_active') finishChannelHold('released') diff --git a/scripts/e2e/e2e-hcc-stateless-cache-containment.sh b/scripts/e2e/e2e-hcc-stateless-cache-containment.sh index ae46f512c3..94837bad91 100755 --- a/scripts/e2e/e2e-hcc-stateless-cache-containment.sh +++ b/scripts/e2e/e2e-hcc-stateless-cache-containment.sh @@ -42,6 +42,14 @@ require_safe_kube_context die 'provide E2E_BRANCH_PROFILE_ENV from the owned branch profile' [ -n "${E2E_PROFILE_PORTS_ENV:-}" ] && [ -r "$E2E_PROFILE_PORTS_ENV" ] || die 'provide E2E_PROFILE_PORTS_ENV from the owned branch profile' +profile_port_value() { + awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' "$E2E_PROFILE_PORTS_ENV" +} +CONTROL_API_BASE_URL="$(profile_port_value CONTROL_API_URL)" +CONTROL_UI_BASE_URL="$(profile_port_value CONTROL_UI_URL)" +EXTERNAL_REST_API_BASE_URL="$(profile_port_value EXTERNAL_REST_API_URL)" +RPC_PROXY_BASE_URL="$(profile_port_value RPC_PROXY_URL)" +export CONTROL_API_BASE_URL CONTROL_UI_BASE_URL EXTERNAL_REST_API_BASE_URL RPC_PROXY_BASE_URL require_branch_profile_urls "$E2E_KUBECONTEXT" "$E2E_PROFILE_PORTS_ENV" || die 'profile URLs do not match the owned ports' kctl get nodes -o json | jq -e --arg c "$E2E_KUBECONTEXT" \ @@ -67,6 +75,7 @@ HCC_GATE_FINALIZATION_FAILURE='' HCC_PATCHED=0 PROXY_CREATED=0 PROBE_CREATED=0 HCC_PR_A_TLS_CREATED=0 HCC_UID='' HCC_RESTARTS='' ORIGINAL_REPLICAS='' +HCC_PR_A_CHANNEL_HOLD_ID='' cleanup() { local status=$? cleanup_failed=0 restore_ok=1 @@ -135,9 +144,12 @@ login="$(curl -fsS -m 20 -X POST "${EXT_BASE}/api/v1/auth/password-login" \ SESSION_TOKEN="$(jq -er '.token' <<<"$login")" || die 'login returned no token' mint_rpc_token() { local body + # The existing messages route uses wakeAndHold when the stateless Host is + # suspended. Keep wake capability on this user token so the baseline message + # can take that supported Desktop-to-rpc-proxy path when it is needed. body="$(curl -fsS -m 20 -X POST "${EXT_BASE}/api/v1/rpc/token" \ -H "Authorization: Bearer ${SESSION_TOKEN}" -H 'Content-Type: application/json' \ - -d "$(jq -cn --arg host "$HOST_REF" '{hostRefs:[$host],scopes:["host:message:invoke","host:session:read","host:status:read","host:health:read"]}')")" || return 1 + -d "$(jq -cn --arg host "$HOST_REF" '{hostRefs:[$host],scopes:["host:message:invoke","host:session:read","host:status:read","host:health:read","host:wake:write"]}')")" || return 1 RPC_TOKEN="$(jq -er '.token' <<<"$body")" } mint_rpc_token || die 'could not mint authorized RPC token' @@ -205,17 +217,50 @@ cache_held_active() { [ "$pod" = "$baseline_pod" ] } +channel_hold_is_active() { + local expected_id=$1 pod + pod="$(hcc_pr_a_proxy_pod)" || return 1 + kctl exec "pod/$pod" -n "$HCC_NS" -c proxy -- node -e \ + 'const fs=require("fs");try{const hold=JSON.parse(fs.readFileSync("/churn-ctl/channel-hold.json"));if(hold.id!==process.argv[1]||hold.state!=="held"||!Number.isFinite(hold.deadlineAtMs)||hold.deadlineAtMs<=Date.now())process.exit(1)}catch{process.exit(1)}' \ + "$expected_id" >/dev/null 2>&1 +} + +renew_channel_hold() { + local cycle=$1 + hcc_pr_a_command hold-channel 60000 || die "cycle ${cycle}: channel-hold renewal command failed" + HCC_PR_A_CHANNEL_HOLD_ID="$HCC_PR_A_COMMAND_ID" + wait_until 5 'selective hold renewal acknowledgement' hcc_pr_a_ack held || + die "cycle ${cycle}: channel-hold renewal was not acknowledged" + wait_until 5 'live selective hold after renewal' channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: renewed channel hold is not active" +} + for cycle in 1 2; do before="$(log_count 'CommunicationChannel watch ended;')" hcc_pr_a_command hold-channel 60000 || die "cycle ${cycle}: fault command failed" + HCC_PR_A_CHANNEL_HOLD_ID="$HCC_PR_A_COMMAND_ID" wait_until 5 'selective hold acknowledgement' hcc_pr_a_ack held || die "cycle ${cycle}: no live channel watch was cut" + wait_until 5 'live selective hold' channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold is not active" wait_log_count 'CommunicationChannel watch ended;' "$((before + 1))" 20 || die "cycle ${cycle}: HCC did not lose channel authority" wait_until 30 'reconciliation while channel cache is unsynced' cache_held_active || die "cycle ${cycle}: active stateless Host did not converge during watch loss" + channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold expired during cache reconciliation" + renew_channel_hold "$cycle" + channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold expired before session transcript read" session_visible || die "cycle ${cycle}: known session unavailable through rpc-proxy" + channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold expired during session transcript read" + renew_channel_hold "$cycle" + channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold expired before session list read" session_listed || die "cycle ${cycle}: known session absent from authorized list" + channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" || + die "cycle ${cycle}: channel hold expired during session list read" cache_held_active || die "cycle ${cycle}: template, SQLite path or pod identity changed" hcc_pr_a_command release-channel || die "cycle ${cycle}: release command failed" wait_until 5 'selective release acknowledgement' hcc_pr_a_ack released || diff --git a/scripts/tests/hcc-watch-api-proxy.test.mjs b/scripts/tests/hcc-watch-api-proxy.test.mjs index 898b6eab49..a4c705541a 100644 --- a/scripts/tests/hcc-watch-api-proxy.test.mjs +++ b/scripts/tests/hcc-watch-api-proxy.test.mjs @@ -91,11 +91,33 @@ test('channel hold cuts only its watch and keeps Host API requests available', { })) const held = await ack('hold-1', 'held') assert.equal(held.count, 1, 'live CommunicationChannel watch was cut') + const initialHold = JSON.parse(readFileSync(join(controlDir, 'channel-hold.json'), 'utf8')) assert.equal(await get(channelPath), 503, 'channel LIST remains unavailable') assert.equal(await get('/apis/clerum.io/v1alpha1/namespaces/mcp-host/hosts/test'), 200) assert.equal(await get('/api/v1/namespaces/mcp-host/pods'), 200) assert.equal(seen.filter(path => path.startsWith(channelPath)).length, 1, 'blocked channel retries never reached upstream') + writeFileSync(join(controlDir, 'command.json'), JSON.stringify({ + id: 'hold-2', action: 'hold-channel', durationMs: 5000, + })) + const renewed = await ack('hold-2', 'held') + assert.equal(renewed.count, 1, 'renewal retains the original watch-cut witness') + assert.equal(renewed.renewed, true) + assert.equal(renewed.renewals, 1) + assert.ok(renewed.deadlineAtMs > initialHold.deadlineAtMs, + 'renewal extends the live hold deadline without a timing sleep') + const renewedHold = JSON.parse(readFileSync(join(controlDir, 'channel-hold.json'), 'utf8')) + assert.deepEqual(renewedHold, { + id: 'hold-2', + state: 'held', + cut: 1, + rejected: 1, + renewals: 1, + deadlineAtMs: renewed.deadlineAtMs, + }) + assert.equal(await get(channelPath), 503, 'channel LIST remains unavailable after renewal') + assert.equal(seen.filter(path => path.startsWith(channelPath)).length, 1, + 'renewed channel hold still blocks upstream channel requests') writeFileSync(join(controlDir, 'command.json'), JSON.stringify({ id: 'release-1', action: 'release-channel', })) diff --git a/scripts/tests/test-hcc-stateless-cache-containment.sh b/scripts/tests/test-hcc-stateless-cache-containment.sh index 48dd5a9949..e9ed9919c3 100755 --- a/scripts/tests/test-hcc-stateless-cache-containment.sh +++ b/scripts/tests/test-hcc-stateless-cache-containment.sh @@ -6,13 +6,17 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" GATE="${ROOT}/scripts/e2e/e2e-hcc-stateless-cache-containment.sh" PROXY="${ROOT}/scripts/e2e/_lib/hcc-watch-api-proxy.mjs" +PROXY_TEST="${ROOT}/scripts/tests/hcc-watch-api-proxy.test.mjs" HELPER="${ROOT}/scripts/e2e/_lib/hcc-watch-pr-a.sh" +MAKEFILE="${ROOT}/Makefile" +WORKFLOW="${ROOT}/.github/workflows/ci-public.yml" -bash -n "$GATE" "$HELPER" +bash -n "$GATE" "$HELPER" "$0" node --check "$PROXY" grep -Fq 'require_branch_owned_hcc_gate' "$GATE" grep -Fq 'E2E_BRANCH_PROFILE_ENV' "$GATE" grep -Fq 'E2E_PROFILE_PORTS_ENV' "$GATE" +grep -Fq 'profile_port_value CONTROL_API_URL' "$GATE" grep -Fq 'acquire_hcc_watch_gate_lock' "$GATE" # shellcheck disable=SC2016 # Match literal shell source. grep -Fq 'finalize_hcc_watch_gate_lock "$cleanup_failed" "$restore_ok"' "$GATE" @@ -30,9 +34,27 @@ grep -Fq '/sessions/${HOST_REF}/${THREAD_ID}/messages' "$GATE" grep -Fq '/sessions?agent=${HOST_REF}&limit=100' "$GATE" grep -Fq 'session_visible || die "cycle' "$GATE" grep -Fq 'session_listed || die "cycle' "$GATE" +grep -Fq '"host:wake:write"' "$GATE" +grep -Fq '/api/v1/rpc/hosts/${HOST_REF}/messages' "$GATE" +grep -Fq 'channel_hold_is_active' "$GATE" +[ "$(grep -Fc 'channel_hold_is_active "$HCC_PR_A_CHANNEL_HOLD_ID" ||' "$GATE")" -ge 6 ] +[ "$(grep -Fc 'hcc_pr_a_command hold-channel 60000' "$GATE")" -ge 2 ] +grep -Fq 'MAX_CHANNEL_HOLD_BUDGET_MS = 120_000' "$PROXY" +grep -Fq 'channelHold.renewals++' "$PROXY" +grep -Fq 'deadlineAtMs' "$PROXY" +grep -Fq "renewal extends the live hold deadline without a timing sleep" "$PROXY_TEST" +grep -Fq "renewed channel hold still blocks upstream channel requests" "$PROXY_TEST" grep -Fq 'HCC_PR_A_SELECTIVE_CHANNEL=1 hcc_pr_a_enable_proxy' "$GATE" grep -Fq 'response.writeHead(503' "$PROXY" grep -Fq 'url.pathname === channelPath' "$PROXY" +grep -Fq 'CONTROL_UI_BASE_URL="$(profile_port_value CONTROL_UI_URL)"' "$GATE" +grep -Fq 'minikube-t2-hcc-stateless-cache-containment' "$MAKEFILE" +grep -Fq 'T2_HEALTHCHECK_COMMAND=' "$MAKEFILE" +grep -Fq 'MINIKUBE_PROFILE="$$T2_PROFILE"' "$MAKEFILE" +grep -Fq 'T2_GATE_ID=minikube-t2' "$MAKEFILE" +grep -Fq 'MINIKUBE_PROFILE)" = "$$context"' "$MAKEFILE" +grep -Fq 'E2E_EXPECTED_PRE_GATE_GATE=minikube-t2' "$MAKEFILE" +grep -Fq 'bash scripts/tests/test-hcc-stateless-cache-containment.sh' "$WORKFLOW" if grep -Eq 'scale_proxy|kill .*HCC|kubectl .*delete pod' "$GATE"; then echo 'Selective gate contains a broad API or pod fault' >&2 exit 1 From d69d212ed14c007f9d345a49fd9f7fb7e8078c35 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 15:37:58 +0200 Subject: [PATCH 005/107] test(hcc): type the legacy deployment fence fixture --- host-context-controller/package-lock.json | 4 ++-- host-context-controller/package.json | 2 +- .../test/hostReconciler.lifecycle.test.ts | 7 +------ 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index f0b4aa14b9..ab259ecf72 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.354", + "version": "0.1.355", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.354", + "version": "0.1.355", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index 6b87da0928..3ecf6f0af2 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.354", + "version": "0.1.355", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/test/hostReconciler.lifecycle.test.ts b/host-context-controller/test/hostReconciler.lifecycle.test.ts index 062806fa9a..5e226115fd 100644 --- a/host-context-controller/test/hostReconciler.lifecycle.test.ts +++ b/host-context-controller/test/hostReconciler.lifecycle.test.ts @@ -910,12 +910,8 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { expect(appsApi.patchNamespacedDeployment).not.toHaveBeenCalled() }) - it('never scales an unannotated legacy Deployment, even when created after the current Host', async () => { + it('never scales an unannotated legacy Deployment with an existing UID and resourceVersion', async () => { const host = makeStatelessHost({ status: suspendedStatus() }) - host.metadata = { - ...host.metadata, - creationTimestamp: new Date('2026-09-20T10:00:00Z'), - } const { reconciler, appsApi } = createReconciler() const legacy = reconciler.buildDeployment(host) delete legacy.metadata?.annotations?.['clerum.io/host-uid'] @@ -923,7 +919,6 @@ describe('HostReconciler stateless lifecycle — rejection matrix', () => { ...legacy.metadata, uid: 'deployment-uid', resourceVersion: '73', - creationTimestamp: new Date('2026-09-20T10:01:00Z'), } appsApi.readNamespacedDeployment.mockResolvedValue(legacy) From d99a0b3dfe5dbd5e877da5465979e8d559ce65fb Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 15:49:24 +0200 Subject: [PATCH 006/107] test(desktop): update host authority fixtures --- desktop-app/src/__tests__/appService.bootstrap.test.ts | 2 +- desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx | 1 + desktop-app/ui/src/__tests__/App.deepLinks.test.tsx | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop-app/src/__tests__/appService.bootstrap.test.ts b/desktop-app/src/__tests__/appService.bootstrap.test.ts index ba2eb86c83..e4a5f21d5e 100644 --- a/desktop-app/src/__tests__/appService.bootstrap.test.ts +++ b/desktop-app/src/__tests__/appService.bootstrap.test.ts @@ -132,7 +132,7 @@ describe('AppService invitation configuration lookup', () => { expect(bindChatStoreForUser).toHaveBeenCalledWith( 'user-1', resolveEnvKey('https://api.example.com', 'https://rpc.example.com'), - { legacyEnvKeys: [getActiveLegacyRestOnlyEnvKey()] } + { legacyEnvKeys: [getActiveLegacyRestOnlyEnvKey()], teamId: 'team-1' } ) expect(config.externalRestApiBaseUrl).toBe('https://api.example.com') expect(config.rpcProxyBaseUrl).toBe('https://rpc.example.com') diff --git a/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx b/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx index 23dc0468a0..0d57e86afc 100644 --- a/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx +++ b/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx @@ -341,6 +341,7 @@ function makeController(overrides: Partial = {}): AppController { authTransitioning: false, handleEnsureTeamContext: vi.fn(async () => false), getCurrentTeamId: vi.fn(() => 'team-a'), + isHostAccessBlocked: vi.fn(() => false), handleSelectChatAgent, handleOpenNotification, handleNavSelect, diff --git a/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx b/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx index a521a80088..64dda1896e 100644 --- a/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx +++ b/desktop-app/ui/src/__tests__/App.deepLinks.test.tsx @@ -320,6 +320,7 @@ function makeController(overrides: Partial = {}): AppController { authTransitioning: false, handleEnsureTeamContext: ensureTeamContext, getCurrentTeamId: vi.fn(() => liveTeamId), + isHostAccessBlocked: vi.fn(() => false), handleSelectChatAgent, handleNavSelect, handleLogout: vi.fn(), From b70a66e5e096483aaf18566379d444b03f54a937 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 16:45:32 +0200 Subject: [PATCH 007/107] fix(desktop,hcc): harden revoked reads and draining holds --- .../src/__tests__/rpcProxyClient.test.ts | 31 +++++++++++++ desktop-app/src/ipc.ts | 22 +++++++-- desktop-app/src/preload.ts | 4 +- desktop-app/src/renderer.d.ts | 7 ++- desktop-app/src/rpcProxyClient.ts | 46 ++++++++++++++----- desktop-app/test/ipc.test.ts | 5 +- desktop-app/ui/src/App.tsx | 10 ++-- .../ui/src/__tests__/App.chatDrawer.test.tsx | 29 +++++++++++- ...sendAgentMessage.characterization.test.tsx | 3 +- .../src/hooks/domain/useChatListController.ts | 12 ++++- desktop-app/ui/src/hooks/useChatStore.ts | 4 +- host-context-controller/package-lock.json | 4 +- host-context-controller/package.json | 2 +- .../src/statelessLifecycleTracker.ts | 15 ++++-- .../test/statelessLifecycleTracker.test.ts | 20 ++++++++ 15 files changed, 180 insertions(+), 34 deletions(-) diff --git a/desktop-app/src/__tests__/rpcProxyClient.test.ts b/desktop-app/src/__tests__/rpcProxyClient.test.ts index c7f5de222b..88ffea8486 100644 --- a/desktop-app/src/__tests__/rpcProxyClient.test.ts +++ b/desktop-app/src/__tests__/rpcProxyClient.test.ts @@ -44,6 +44,37 @@ describe('RpcProxyClient.listSessions', () => { } ) + it('projects the exact Host access denial from catalog and transcript reads', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => JSON.stringify({ error: 'Forbidden: user cannot access this host' }), + }) + ) + + await expect(client.listSessions('token', 'host')).rejects.toThrow( + '403 Forbidden: host_access_revoked' + ) + await expect(client.loadSessionMessages('token', 'host', 'agent-a', 'chat-a')).rejects.toThrow( + '403 Forbidden: host_access_revoked' + ) + }) + + it('keeps a generic catalog 403 uncertain', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => JSON.stringify({ error: 'missing scope' }), + }) + ) + + await expect(client.listSessions('token', 'host')).rejects.toThrow('List sessions failed (403)') + }) + it('accepts legacy session catalog responses that omit optional metadata', async () => { vi.stubGlobal( 'fetch', diff --git a/desktop-app/src/ipc.ts b/desktop-app/src/ipc.ts index 38d15fd589..b142d00b11 100644 --- a/desktop-app/src/ipc.ts +++ b/desktop-app/src/ipc.ts @@ -1463,14 +1463,30 @@ export function registerIpcHandlers(service: AppService): void { ipcMain.handle( 'chat:rename', - async (event, payload: { agentRef: string; chatId: string; title: string }) => { + async ( + event, + payload: { + agentRef: string + chatId: string + title: string + bindingGeneration: number + } + ) => { assertTrustedSender(event) const agentRef = sanitizeString(payload?.agentRef) const chatId = sanitizeString(payload?.chatId) const title = sanitizeString(payload?.title) if (!agentRef || !chatId || !title) throw new Error('agentRef, chatId, and title are required') - await requireChatStore().renameChat(agentRef, chatId, title) + const bindingGeneration = payload?.bindingGeneration + if (typeof bindingGeneration !== 'number' || !Number.isSafeInteger(bindingGeneration)) { + throw new Error('A valid chat store binding generation is required') + } + await requireChatStoreForBindingGeneration(bindingGeneration).renameChat( + agentRef, + chatId, + title + ) } ) @@ -1522,7 +1538,7 @@ export function registerIpcHandlers(service: AppService): void { throw new Error('Chat deletion authority changed before confirmation') } const store = requireChatStoreForBindingGeneration(fence.bindingGeneration) - await store.deleteChat(agentRef, chatId, fence.authorityScope) + return await store.deleteChat(agentRef, chatId, fence.authorityScope) } ) diff --git a/desktop-app/src/preload.ts b/desktop-app/src/preload.ts index ced4b55f97..a87185b128 100644 --- a/desktop-app/src/preload.ts +++ b/desktop-app/src/preload.ts @@ -578,8 +578,8 @@ const clerum = Object.freeze({ list: (agentRef: string) => ipcRenderer.invoke('chat:list', { agentRef }), create: (agentRef: string, chatId: string) => ipcRenderer.invoke('chat:create', { agentRef, chatId }), - rename: (agentRef: string, chatId: string, title: string) => - ipcRenderer.invoke('chat:rename', { agentRef, chatId, title }), + rename: (agentRef: string, chatId: string, title: string, bindingGeneration: number) => + ipcRenderer.invoke('chat:rename', { agentRef, chatId, title, bindingGeneration }), getBindingGeneration: () => ipcRenderer.invoke('chat:bindingGeneration'), captureDeleteFence: (expectedAuthorityScope: import('./types.js').ChatAuthorityScope) => ipcRenderer.invoke('chat:captureDeleteFence', { expectedAuthorityScope }), diff --git a/desktop-app/src/renderer.d.ts b/desktop-app/src/renderer.d.ts index 5dbd6480ad..a179777199 100644 --- a/desktop-app/src/renderer.d.ts +++ b/desktop-app/src/renderer.d.ts @@ -698,7 +698,12 @@ declare global { chat: { list: (agentRef: string) => Promise create: (agentRef: string, chatId: string) => Promise - rename: (agentRef: string, chatId: string, title: string) => Promise + rename: ( + agentRef: string, + chatId: string, + title: string, + bindingGeneration: number + ) => Promise getBindingGeneration: () => Promise captureDeleteFence: (expectedAuthorityScope: ChatAuthorityScope) => Promise delete: ( diff --git a/desktop-app/src/rpcProxyClient.ts b/desktop-app/src/rpcProxyClient.ts index 5fdcaabe23..de5292bfe7 100644 --- a/desktop-app/src/rpcProxyClient.ts +++ b/desktop-app/src/rpcProxyClient.ts @@ -456,6 +456,33 @@ function errorCodeFromBody(body: string): string | null { } } +function exactHostAccessRevokedError(status: number, body: string): ApiError | null { + const denied = + status === 403 && errorCodeFromBody(body) === 'Forbidden: user cannot access this host' + return denied ? new ApiError('403 Forbidden: host_access_revoked', 403, body) : null +} + +function projectHostAccessRevoked(error: unknown): unknown { + if (!(error instanceof ApiError)) return error + return exactHostAccessRevokedError(error.status, error.bodyText) ?? error +} + +async function projectHostAccessRevokedInPromise(promise: Promise): Promise { + try { + return await promise + } catch (error) { + throw projectHostAccessRevoked(error) + } +} + +function requestJsonProjected( + method: Parameters[0], + url: Parameters[1], + options: Parameters[2] +): Promise { + return projectHostAccessRevokedInPromise(requestJson(method, url, options)) +} + export class RpcProxyClient { async health(): Promise<{ status: string }> { return requestJson<{ status: string }>('GET', url('/health')) @@ -513,23 +540,14 @@ export class RpcProxyClient { options?: { async?: boolean } ): Promise { const query = options?.async ? '?async=true' : '' - return requestJson( + return requestJsonProjected( 'POST', url(`/api/v1/rpc/hosts/${encodeURIComponent(hostRef)}/messages${query}`), { token: rpcAccessToken, body: payload, } - ).catch(error => { - if ( - error instanceof ApiError && - error.status === 403 && - errorCodeFromBody(error.bodyText) === 'Forbidden: user cannot access this host' - ) { - throw new ApiError('403 Forbidden: host_access_revoked', 403, error.bodyText) - } - throw error - }) + ) } async getTaskResult( @@ -537,7 +555,7 @@ export class RpcProxyClient { hostRef: string, taskId: string ): Promise { - return requestJson( + return requestJsonProjected( 'GET', url( `/api/v1/rpc/hosts/${encodeURIComponent(hostRef)}/tasks/${encodeURIComponent(taskId)}/result` @@ -1005,6 +1023,8 @@ export class RpcProxyClient { }) if (!response.ok) { const body = await response.text() + const hostAccessRevoked = exactHostAccessRevokedError(response.status, body) + if (hostAccessRevoked) throw hostAccessRevoked throw new ApiError( `List sessions failed (${response.status}): ${body}`, response.status, @@ -1047,6 +1067,8 @@ export class RpcProxyClient { } if (!response.ok) { const body = await response.text() + const hostAccessRevoked = exactHostAccessRevokedError(response.status, body) + if (hostAccessRevoked) throw hostAccessRevoked throw new ApiError( `Load session messages failed (${response.status}): ${body}`, response.status, diff --git a/desktop-app/test/ipc.test.ts b/desktop-app/test/ipc.test.ts index 8072118e34..98940595bf 100644 --- a/desktop-app/test/ipc.test.ts +++ b/desktop-app/test/ipc.test.ts @@ -201,7 +201,10 @@ describe('ipc host status stream handlers', () => { const fence = await Promise.resolve( capture?.(event, { expectedAuthorityScope: authorityScope }) ) - await Promise.resolve(remove?.(event, { version: 3, agentRef: 'agent-x', chatId: 'c1', fence })) + const result = await Promise.resolve( + remove?.(event, { version: 3, agentRef: 'agent-x', chatId: 'c1', fence }) + ) + expect(result).toEqual({ cleanupPending: false }) const index = await requireChatStore().getIndex('agent-x') expect(index.deletedChatTombstones).toContainEqual({ chatId: 'c1', authorityScope }) expect(index.pendingChatCleanup).toEqual([]) diff --git a/desktop-app/ui/src/App.tsx b/desktop-app/ui/src/App.tsx index 51cac40513..e5e09702f7 100644 --- a/desktop-app/ui/src/App.tsx +++ b/desktop-app/ui/src/App.tsx @@ -2213,8 +2213,12 @@ export function App() { const notifValue = React.useMemo( () => ({ - notifications: vm.notifications, - unreadNotificationCount: vm.unreadNotificationCount, + notifications: vm.notifications.filter( + notification => !vm.isHostAccessBlocked(notification.agentName) + ), + unreadNotificationCount: vm.notifications.filter( + notification => !vm.isHostAccessBlocked(notification.agentName) && !notification.read + ).length, notificationActionById: vm.notificationActionById, pendingApprovals: vm.pendingApprovals, pendingApprovalsLoading: vm.pendingApprovalsLoading, @@ -2242,13 +2246,13 @@ export function App() { vm.markNotificationsRead, vm.notificationActionById, vm.notifications, + vm.isHostAccessBlocked, vm.pendingApprovalActionId, vm.pendingApprovals, vm.pendingApprovalsLoading, vm.removeNotification, vm.resolveApprovalNotification, vm.toasts, - vm.unreadNotificationCount, ] ) diff --git a/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx b/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx index 0d57e86afc..618910fe3b 100644 --- a/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx +++ b/desktop-app/ui/src/__tests__/App.chatDrawer.test.tsx @@ -93,6 +93,8 @@ const appHeaderHarness = vi.hoisted(() => ({ // Captured from context so tests can drive the "open conversation" gesture the // notification tray fires. openNotification: null as null | ((notification: AppNotification) => Promise), + notifications: [] as AppNotification[], + unreadNotificationCount: 0, })) vi.mock('@hooks/useAppController', () => ({ useAppController: vi.fn() })) @@ -100,7 +102,10 @@ vi.mock('@hooks/useAgentChatActionsValue', () => ({ useAgentChatActionsValue: () vi.mock('@components/AppHeader', () => ({ AppHeader: (props: NonNullable) => { appHeaderHarness.props = props - appHeaderHarness.openNotification = useNotificationsContext().handleOpenNotification + const notificationsContext = useNotificationsContext() + appHeaderHarness.openNotification = notificationsContext.handleOpenNotification + appHeaderHarness.notifications = notificationsContext.notifications + appHeaderHarness.unreadNotificationCount = notificationsContext.unreadNotificationCount return null }, })) @@ -406,6 +411,8 @@ describe('App chat drawer — reopen preserves the last-viewed chat', () => { sandboxUiPageHarness.props = null appHeaderHarness.props = null appHeaderHarness.openNotification = null + appHeaderHarness.notifications = [] + appHeaderHarness.unreadNotificationCount = 0 currentController = makeController() vi.mocked(useAppController).mockImplementation(() => useReactiveController(currentController)) Object.defineProperty(window, 'clerum', { @@ -478,6 +485,26 @@ describe('App chat drawer — reopen preserves the last-viewed chat', () => { expect(screen.getByRole('button', { name: 'Open chats' }).textContent).toContain('Second chat') }) + it('hides already-delivered notifications for a revoked Host', () => { + const notification = { + id: 'approval-1', + kind: 'approval_required', + agentName: 'alpha', + text: 'Approval required', + timestamp: 1, + read: false, + } as AppNotification + currentController = makeController({ + notifications: [notification], + isHostAccessBlocked: vi.fn(() => true), + } as Partial) + + render() + + expect(appHeaderHarness.notifications).toEqual([]) + expect(appHeaderHarness.unreadNotificationCount).toBe(0) + }) + // The header's "Open chat in full screen" CTA must EJECT the drawer's ACTIVE // conversation — the one the user switched to inside the drawer, not the launch // origin — to the full-screen chat route. That is the non-drawer branch of diff --git a/desktop-app/ui/src/hooks/domain/__tests__/sendAgentMessage.characterization.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/sendAgentMessage.characterization.test.tsx index 3958fa963e..3eb511eb40 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/sendAgentMessage.characterization.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/sendAgentMessage.characterization.test.tsx @@ -218,7 +218,8 @@ describe('sendAgentMessage — auto-title on send (B10)', () => { expect(clerum.chat.rename).toHaveBeenCalledWith( 'agent-x', createdChatId, - 'summarize the quarterly report' + 'summarize the quarterly report', + 1 ) ) }) diff --git a/desktop-app/ui/src/hooks/domain/useChatListController.ts b/desktop-app/ui/src/hooks/domain/useChatListController.ts index dcbea1bb80..df239312d6 100644 --- a/desktop-app/ui/src/hooks/domain/useChatListController.ts +++ b/desktop-app/ui/src/hooks/domain/useChatListController.ts @@ -261,6 +261,11 @@ export function useChatListController({ >(new Map()) const pendingRenameKey = (agentRef: string, chatId: string): string => `${agentRef}:${chatId}` + const authUserKeyRef = useRef(authUserKey) + + useEffect(() => { + authUserKeyRef.current = authUserKey + }, [authUserKey]) const pendingRenameStateFor = useCallback((agentRef: string, chatId: string): PendingRename => { return pendingRenamesRef.current.get(pendingRenameKey(agentRef, chatId))?.state ?? 'none' @@ -918,7 +923,7 @@ export function useChatListController({ const bindingGeneration = await chatStore.getBindingGeneration() if (!stillOwned()) return const updatedAt = new Date().toISOString() - await chatStore.renameChat(agentRef, chatId, newTitle) + await chatStore.renameChat(agentRef, chatId, newTitle, bindingGeneration) if (requestGenerationRef.current !== requestGeneration) return setLatestChatSessions(prev => prev @@ -1118,6 +1123,7 @@ export function useChatListController({ const handleRenameChatForAgent = useCallback( async (agentRef: string, chatId: string, newTitle: string) => { if (!agentRef) return + const authUserKeyAtRequest = authUserKeyRef.current const key = pendingRenameKey(agentRef, chatId) // FIX 2: the rollback target is the title BEFORE the pending chain began. // If a rename is already pending for this key, preserve its `previousTitle` @@ -1129,9 +1135,11 @@ export function useChatListController({ previousTitle = existing.previousTitle } else { const index = await chatStore.getIndex(agentRef) + if (authUserKeyRef.current !== authUserKeyAtRequest) return previousTitle = index.chats.find(c => c.id === chatId)?.title ?? '' } await applyLocalTitleOnly(agentRef, chatId, newTitle) + if (authUserKeyRef.current !== authUserKeyAtRequest) return // Replace any prior entry: this is now the current rename for the key. A // still-in-flight older attempt will no-op on completion (identity guard). const entry = { @@ -1147,7 +1155,7 @@ export function useChatListController({ pendingRenamesRef.current.set(key, entry) await attemptRenameRpc(entry) }, - [chatStore, applyLocalTitleOnly, attemptRenameRpc] + [authUserKey, chatStore, applyLocalTitleOnly, attemptRenameRpc] ) const handleRenameChat = useCallback( diff --git a/desktop-app/ui/src/hooks/useChatStore.ts b/desktop-app/ui/src/hooks/useChatStore.ts index 765f70de29..bcfd120f28 100644 --- a/desktop-app/ui/src/hooks/useChatStore.ts +++ b/desktop-app/ui/src/hooks/useChatStore.ts @@ -66,8 +66,8 @@ export function useChatStore() { [] ) const renameChat = useCallback( - (agentRef: string, chatId: string, title: string) => - window.clerum.chat.rename(agentRef, chatId, title), + (agentRef: string, chatId: string, title: string, bindingGeneration: number) => + window.clerum.chat.rename(agentRef, chatId, title, bindingGeneration), [] ) const getBindingGeneration = useCallback(() => window.clerum.chat.getBindingGeneration(), []) diff --git a/host-context-controller/package-lock.json b/host-context-controller/package-lock.json index ab259ecf72..28f6525e27 100644 --- a/host-context-controller/package-lock.json +++ b/host-context-controller/package-lock.json @@ -1,12 +1,12 @@ { "name": "clerum-host-context-controller", - "version": "0.1.355", + "version": "0.1.356", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clerum-host-context-controller", - "version": "0.1.355", + "version": "0.1.356", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clerum/codex-catalog-projection": "file:../packages/codex-catalog-projection", diff --git a/host-context-controller/package.json b/host-context-controller/package.json index 3ecf6f0af2..0a57cd12ec 100644 --- a/host-context-controller/package.json +++ b/host-context-controller/package.json @@ -1,6 +1,6 @@ { "name": "clerum-host-context-controller", - "version": "0.1.355", + "version": "0.1.356", "description": "Host Context Controller - provides McpServer CRDs to mcp-host via REST API", "main": "dist/main.js", "engines": { diff --git a/host-context-controller/src/statelessLifecycleTracker.ts b/host-context-controller/src/statelessLifecycleTracker.ts index ae7d93a997..c25520012a 100644 --- a/host-context-controller/src/statelessLifecycleTracker.ts +++ b/host-context-controller/src/statelessLifecycleTracker.ts @@ -440,7 +440,7 @@ export class StatelessLifecycleTracker implements HeartbeatLifecycleTracker { } if (effective.suspensionBlocked) { this.clearDrainGrace(hostRef) - await this.cancelDrainOnEvidence(hostRef, host) + await this.cancelDrainOnEvidence(hostRef, host, true) return { drain: false } } this.noteWakeHandledGeneration(hostRef, host) @@ -615,9 +615,18 @@ export class StatelessLifecycleTracker implements HeartbeatLifecycleTracker { * a suspended Host is exclusively the wake fast-path's job). Loud, not * fatal on failure — the emitter keeps beating while fenced, so the * revert retries on the next polled heartbeat. + * + * When CommunicationChannel authority is unknown, the cached Host may still + * say `active` while the durable CR already says `draining`. The caller then + * bypasses the cache precheck so this fresh-read writer can safely revert the + * durable `draining` state (and no-op for active/suspended). */ - private async cancelDrainOnEvidence(hostRef: string, host: HostCRD): Promise { - if ((host.status?.lifecycle?.state ?? 'active') !== 'draining') { + private async cancelDrainOnEvidence( + hostRef: string, + host: HostCRD, + bypassCachedState = false + ): Promise { + if (!bypassCachedState && (host.status?.lifecycle?.state ?? 'active') !== 'draining') { return } try { diff --git a/host-context-controller/test/statelessLifecycleTracker.test.ts b/host-context-controller/test/statelessLifecycleTracker.test.ts index 3d057643bd..b540c2bc07 100644 --- a/host-context-controller/test/statelessLifecycleTracker.test.ts +++ b/host-context-controller/test/statelessLifecycleTracker.test.ts @@ -190,6 +190,26 @@ describe('StatelessLifecycleTracker — D8 idle rule', () => { expect(port.suspendHostFromHeartbeat).not.toHaveBeenCalled() }) + it('reverts a durable draining Host through a fresh read when a cached Host is stale', async () => { + const port = makePort() + const cachedHost = makeHost({ lifecycle: { state: 'active', wakeHandledGeneration: 3 } }) + port.getEffectiveLifecycle.mockReturnValue({ + stateless: true, + state: 'active', + suspensionBlocked: true, + }) + port.readFreshHost.mockResolvedValue( + makeHost({ lifecycle: { state: 'draining', wakeHandledGeneration: 3 } }) + ) + const tracker = makeTracker({ port, host: cachedHost }) + + await expect(tracker.handleHeartbeat(payload({ state: 'draining' }))).resolves.toEqual({ + drain: false, + }) + expect(port.markHostActiveFromHeartbeat).toHaveBeenCalledWith(cachedHost) + expect(port.suspendHostFromHeartbeat).not.toHaveBeenCalled() + }) + it('answers drain:false for an unknown host', async () => { const port = makePort() const tracker = new StatelessLifecycleTracker({ From 8d669ada99385753431178425c7bff1e533d8387 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 19:35:11 +0200 Subject: [PATCH 008/107] test(desktop): record two-host stateless continuity journey --- .../test/e2e-playwright/global-setup.ts | 6 +- .../human-stateless-continuity.spec.ts | 567 ++++++++++++++++++ 2 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts diff --git a/desktop-app/test/e2e-playwright/global-setup.ts b/desktop-app/test/e2e-playwright/global-setup.ts index 21cb983476..95e3d18934 100644 --- a/desktop-app/test/e2e-playwright/global-setup.ts +++ b/desktop-app/test/e2e-playwright/global-setup.ts @@ -142,8 +142,8 @@ export function validateBaseUrls({ } } -function kubectlCurrentContext(): string { - return execFileSync('kubectl', ['config', 'current-context'], { +function kubectlCurrentContext(expectedContext: string): string { + return execFileSync('kubectl', ['--context', expectedContext, 'config', 'current-context'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }).trim() @@ -185,7 +185,7 @@ async function globalSetup(): Promise { let current: string try { - current = kubectlCurrentContext() + current = kubectlCurrentContext(expected) } catch (err) { throw new Error(`[E2E-GUARD] Failed to read kubectl current-context: ${(err as Error).message}`) } diff --git a/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts b/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts new file mode 100644 index 0000000000..f7f5d5a3b6 --- /dev/null +++ b/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts @@ -0,0 +1,567 @@ +import { type ElectronApplication, type Locator, type Page, expect, test } from '@playwright/test' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + EXTERNAL_REST_API_BASE_URL, + RPC_PROXY_BASE_URL, + assertAllowedTarget, + desktopCredentials, + finalizeRecording, + launchDesktopApp, + login, + openAgentsPage, + requireRecorderConfirm, + screenshotAndLog, +} from './qa-recorder-helpers' + +const execFileAsync = promisify(execFile) + +const KUBE_CONTEXT = process.env.HUMAN_E2E_KUBE_CONTEXT?.trim() || '' +const STATEFUL_HOST = process.env.HUMAN_E2E_STATEFUL_HOST?.trim() || 'chatLLM' +const STATELESS_HOST = process.env.HUMAN_E2E_STATELESS_HOST?.trim() || 'chatllm-stateless' +const STATEFUL_HOST_RESOURCE = process.env.HUMAN_E2E_STATEFUL_HOST_RESOURCE?.trim() || 'chatllm' +const STATELESS_HOST_RESOURCE = + process.env.HUMAN_E2E_STATELESS_HOST_RESOURCE?.trim() || 'chatllm-stateless' +const HCC_DEPLOYMENT = 'host-context-controller' +const HCC_NAMESPACE = 'control-plane' +const STATELESS_NAMESPACE = 'mcp-host' +const STATELESS_DEPLOYMENT = 'chatllm-stateless' +const STATEFUL_DEPLOYMENT = 'chatllm' +const SUSPEND_TIMEOUT_MS = 240_000 + +// E2E_GUARDIAN_IPC_FLOW: Desktop sends RPC through Electron's main process. +// Renderer page.waitForResponse cannot observe those requests. The visible +// assistant response, known conversation marker, and Host CRD/pod state are +// the business signals for this recorded journey. + +type HostSnapshot = { + lifecycleState: string + lifecycleReason: string + replicas: number + readyReplicas: number + podNames: string[] + imageIds: string[] + pullPolicyRejection: string | null +} + +type JourneyMetrics = { + hcc_idle_minutes: number + hcc_idle_floor_minutes: number + hcc_drain_grace_ms: number + hcc_heartbeat_poll_ms: number + first_profile_path: string + second_profile_path: string + stateful_pod_before: string[] + stateless_pod_before: string[] + stateless_image_ids_before: string[] + stateless_image_ids_after_reopen: string[] + stateless_image_ids_after_cold_send: string[] + stateless_pull_policy_warning: string | null + first_close_ms: number | null + first_suspend_ms: number + second_launch_to_window_ms: number + second_launch_to_authenticated_ms: number + second_launch_to_stateless_ready_ms: number + draft_preserved_while_switching_hosts: boolean + draft_preserved_across_restart: boolean + second_suspend_ms: number + cold_send_to_waking_visible_ms: number | null + cold_send_to_response_ms: number + cold_send_to_composer_idle_ms: number + cold_send_retries: number + stateless_pod_after_cold_send: string[] + stateful_pod_after: string[] + markers: { + stateful_initial: string + stateless_initial: string + stateless_follow_up: string + stateful_follow_up: string + } +} + +async function kubectl(args: string[], timeoutMs = 20_000): Promise { + const { stdout } = await execFileAsync('kubectl', ['--context', KUBE_CONTEXT, ...args], { + timeout: timeoutMs, + maxBuffer: 8 * 1024 * 1024, + }) + return stdout.toString() +} + +async function jsonKubectl(resource: string, namespace: string): Promise { + return JSON.parse(await kubectl(['-n', namespace, 'get', resource, '-o', 'json'])) +} + +async function readHostSnapshot(deployment: string, hostName: string): Promise { + const [deploymentJson, hostJson, podsJson] = await Promise.all([ + jsonKubectl(`deployment.apps/${deployment}`, STATELESS_NAMESPACE), + jsonKubectl(`host/${hostName}`, STATELESS_NAMESPACE), + kubectl(['-n', STATELESS_NAMESPACE, 'get', 'pods', '-l', `app=${hostName}`, '-o', 'json']), + ]) + const pods = JSON.parse(podsJson).items ?? [] + const spec = deploymentJson.spec ?? {} + const status = deploymentJson.status ?? {} + const lifecycle = hostJson.status?.lifecycle ?? {} + const pullPolicyRejection = (hostJson.status?.conditions ?? []).find( + (condition: { type?: string; status?: string }) => + condition.type === 'StatelessPullPolicyRejected' && condition.status === 'True' + ) + return { + lifecycleState: String(lifecycle.state ?? ''), + lifecycleReason: String(lifecycle.reason ?? ''), + replicas: Number(spec.replicas ?? -1), + readyReplicas: Number(status.readyReplicas ?? 0), + podNames: pods + .map((pod: { metadata?: { name?: string } }) => pod.metadata?.name) + .filter(Boolean) + .sort(), + imageIds: [ + ...new Set( + pods.flatMap((pod: { status?: { containerStatuses?: Array<{ imageID?: string }> } }) => + (pod.status?.containerStatuses ?? []) + .map(container => container.imageID) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ) + ), + ].sort(), + pullPolicyRejection: pullPolicyRejection?.reason ?? null, + } +} + +async function waitForStatelessSuspended(timeoutMs: number): Promise<{ + snapshot: HostSnapshot + elapsedMs: number +}> { + const started = Date.now() + let last = await readHostSnapshot(STATELESS_DEPLOYMENT, STATELESS_HOST_RESOURCE) + while (Date.now() - started < timeoutMs) { + last = await readHostSnapshot(STATELESS_DEPLOYMENT, STATELESS_HOST_RESOURCE) + if ( + last.lifecycleState === 'suspended' && + last.replicas === 0 && + last.readyReplicas === 0 && + last.podNames.length === 0 + ) { + return { snapshot: last, elapsedMs: Date.now() - started } + } + await new Promise(resolve => setTimeout(resolve, 5_000)) + } + throw new Error( + `Stateless host did not reach verified replicas-0 suspension: ${JSON.stringify(last)}` + ) +} + +async function waitForStatelessReady(timeoutMs: number): Promise<{ + snapshot: HostSnapshot + elapsedMs: number +}> { + const started = Date.now() + let last = await readHostSnapshot(STATELESS_DEPLOYMENT, STATELESS_HOST_RESOURCE) + while (Date.now() - started < timeoutMs) { + last = await readHostSnapshot(STATELESS_DEPLOYMENT, STATELESS_HOST_RESOURCE) + if (last.replicas >= 1 && last.readyReplicas >= 1 && last.podNames.length > 0) { + return { snapshot: last, elapsedMs: Date.now() - started } + } + await new Promise(resolve => setTimeout(resolve, 2_000)) + } + throw new Error(`Stateless host did not become Ready: ${JSON.stringify(last)}`) +} + +async function readHccCadences(): Promise<{ + idleMinutes: number + idleFloorMinutes: number + drainGraceMs: number + heartbeatPollMs: number +}> { + const deployment = await jsonKubectl(`deployment.apps/${HCC_DEPLOYMENT}`, HCC_NAMESPACE) + const env = deployment.spec?.template?.spec?.containers?.[0]?.env ?? [] + const value = (key: string, fallback: number): number => { + const found = env.find((item: { name?: string }) => item.name === key) + return typeof found?.value === 'string' ? Number(found.value) : fallback + } + return { + idleMinutes: value('CONTEXT_MAPPER_STATELESS_IDLE_MINUTES', 30), + idleFloorMinutes: value('CONTEXT_MAPPER_STATELESS_IDLE_FLOOR_MINUTES', 15), + drainGraceMs: value('CONTEXT_MAPPER_STATELESS_DRAIN_GRACE_MS', 60_000), + heartbeatPollMs: value('CONTEXT_MAPPER_HEARTBEAT_POLL_MS', 10_000), + } +} + +async function desktopProfilePath(app: ElectronApplication): Promise { + return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')) +} + +async function newChatFromAgentsPage(page: Page, hostName: string): Promise { + await openAgentsPage(page) + await page.getByRole('button', { name: `More actions for ${hostName}`, exact: true }).click() + const menu = page.getByRole('menu') + await expect(menu).toBeVisible({ timeout: 15_000 }) + const newChat = menu + .getByRole('button', { name: /^New chat$/ }) + .or(menu.getByRole('menuitem', { name: /^New chat$/ })) + await expect(newChat).toBeVisible() + await newChat.click() + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + await expect(composer).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: /^Switch chat agent$/ })).toContainText(hostName, { + timeout: 30_000, + }) +} + +async function openExactSession(page: Page, title: string, hostName: string): Promise { + await page.getByRole('button', { name: `Open ${title}`, exact: true }).click() + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + await expect(composer).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: /^Switch chat agent$/ })).toContainText(hostName, { + timeout: 30_000, + }) +} + +async function renameSessionByMarker(page: Page, marker: string, title: string): Promise { + await expect(page.getByTestId('agent-response').filter({ hasText: marker })).toBeVisible() + const row = page.locator('.nav-latest-session.active') + await expect(row).toBeVisible({ timeout: 30_000 }) + const options = row.getByRole('button', { name: /^Session options for / }) + await options.click() + await page.getByRole('menuitem', { name: 'Rename', exact: true }).click() + const input = page.getByRole('textbox', { name: 'Rename session', exact: true }) + await expect(input).toBeVisible() + await input.fill(title) + await input.press('Enter') + await expect(page.getByRole('button', { name: `Open ${title}`, exact: true })).toBeVisible({ + timeout: 30_000, + }) +} + +async function humanType(locator: Locator, text: string): Promise { + await locator.fill('') + await locator.pressSequentially(text, { delay: 90 }) +} + +async function sendMarker(page: Page, marker: string, prompt: string): Promise { + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + const response = page.getByTestId('agent-response').filter({ hasText: marker }) + await humanType(composer, prompt) + await expect(page.getByTestId('send-button')).toBeEnabled() + await page.getByTestId('send-button').click() + await expect(page.getByTestId('message-list')).toContainText(marker, { timeout: 30_000 }) + await expect(response).toBeVisible({ timeout: 150_000 }) + await expect(response).toContainText(marker, { timeout: 150_000 }) + await expect(composer).toHaveValue('', { timeout: 150_000 }) + await expect(page.getByTestId('send-button')).toBeDisabled({ timeout: 150_000 }) +} + +async function authenticatedWithoutRelogin(page: Page): Promise { + await expect(page.locator('.boot-overlay')).toBeHidden({ timeout: 30_000 }) + const emailInput = page.locator('#email-input') + const authenticatedShell = page.getByTestId('nav-chat') + await expect(authenticatedShell).toBeVisible({ timeout: 30_000 }) + await expect(emailInput).toBeHidden({ timeout: 30_000 }) +} + +async function resolveColdSend( + page: Page, + responseMarker: string, + contextMarker: string +): Promise<{ + wakingVisibleMs: number | null + retries: number +}> { + const started = Date.now() + const response = page.getByTestId('agent-response').filter({ hasText: responseMarker }) + const waking = page.getByTestId('waking-state') + let firstOutcome: 'response' | 'waking' | null = null + await expect + .poll( + async () => { + if (await response.isVisible()) return (firstOutcome = 'response') + if (await waking.isVisible()) return (firstOutcome = 'waking') + return null + }, + { timeout: 270_000 } + ) + .not.toBeNull() + let wakingVisibleMs: number | null = null + let retries = 0 + if (firstOutcome === 'waking') { + wakingVisibleMs = Date.now() - started + const retry = waking.getByRole('button', { name: /retry last send/i }) + await expect(retry).toBeEnabled({ timeout: 150_000 }) + await retry.click() + retries += 1 + } + await expect(response).toBeVisible({ timeout: 270_000 }) + await expect(response).toContainText(contextMarker, { timeout: 150_000 }) + return { + wakingVisibleMs, + retries, + } +} + +async function closeElectron(app: ElectronApplication | undefined): Promise { + const child = app?.process() + const pid = child?.pid ?? null + await app?.close().catch(() => undefined) + if (!child) return null + const deadline = Date.now() + 30_000 + while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) { + await new Promise(resolve => setTimeout(resolve, 250)) + } + expect( + child.exitCode !== null || child.signalCode !== null, + `Electron process ${pid ?? 'unknown'} must exit; window close alone is not app exit on macOS` + ).toBe(true) + return pid +} + +test('human journey — two Hosts, restart, cache, and two verified stateless wake episodes', async ({}, testInfo) => { + test.setTimeout(1_800_000) + + if (!KUBE_CONTEXT) { + throw new Error('HUMAN_E2E_KUBE_CONTEXT is required for the recorded stateless journey.') + } + requireRecorderConfirm( + 'QA_RECORDER_CONFIRM_CHAT', + 'This journey sends four real model turns and may incur model cost.' + ) + + await assertAllowedTarget('EXTERNAL_REST_API_BASE_URL', EXTERNAL_REST_API_BASE_URL) + await assertAllowedTarget('RPC_PROXY_BASE_URL', RPC_PROXY_BASE_URL) + + const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}` + const statefulTitle = `HUMAN_STATEFUL_${runId}` + const statelessTitle = `HUMAN_STATELESS_${runId}` + const statefulMarker = `HUMAN_A_${runId}` + const statelessMarker = `HUMAN_B_${runId}` + const statelessFollowMarker = `HUMAN_B2_${runId}` + const statefulFollowMarker = `HUMAN_A2_${runId}` + + let app: ElectronApplication | undefined + let page: Page | undefined + + const metrics: JourneyMetrics = { + hcc_idle_minutes: 0, + hcc_idle_floor_minutes: 0, + hcc_drain_grace_ms: 0, + hcc_heartbeat_poll_ms: 0, + first_profile_path: '', + second_profile_path: '', + stateful_pod_before: [], + stateless_pod_before: [], + stateless_image_ids_before: [], + stateless_image_ids_after_reopen: [], + stateless_image_ids_after_cold_send: [], + stateless_pull_policy_warning: null, + first_close_ms: null, + first_suspend_ms: 0, + second_launch_to_window_ms: 0, + second_launch_to_authenticated_ms: 0, + second_launch_to_stateless_ready_ms: 0, + draft_preserved_while_switching_hosts: false, + draft_preserved_across_restart: false, + second_suspend_ms: 0, + cold_send_to_waking_visible_ms: null, + cold_send_to_response_ms: 0, + cold_send_to_composer_idle_ms: 0, + cold_send_retries: 0, + stateless_pod_after_cold_send: [], + stateful_pod_after: [], + markers: { + stateful_initial: statefulMarker, + stateless_initial: statelessMarker, + stateless_follow_up: statelessFollowMarker, + stateful_follow_up: statefulFollowMarker, + }, + } + + try { + await test.step('verify both Hosts and the branch-owned runtime baseline', async () => { + const [statefulBefore, statelessBefore, cadences] = await Promise.all([ + readHostSnapshot(STATEFUL_DEPLOYMENT, STATEFUL_HOST_RESOURCE), + readHostSnapshot(STATELESS_DEPLOYMENT, STATELESS_HOST_RESOURCE), + readHccCadences(), + ]) + expect( + statefulBefore.readyReplicas, + 'stateful control Host must start Ready' + ).toBeGreaterThan(0) + expect(statelessBefore.readyReplicas, 'stateless Host must start Ready').toBeGreaterThan(0) + expect( + statelessBefore.imageIds, + 'record the actual stateless container imageID for later wake comparison' + ).not.toHaveLength(0) + const estimatedSuspendMs = + Math.max(cadences.idleMinutes, cadences.idleFloorMinutes) * 60_000 + + cadences.drainGraceMs + + cadences.heartbeatPollMs * 2 + expect( + Object.values(cadences).every(value => Number.isFinite(value) && value > 0) && + estimatedSuspendMs < SUSPEND_TIMEOUT_MS, + 'HCC idle/floor/drain/poll cadences must permit a bounded natural suspension; configure the owned profile before recording' + ).toBe(true) + metrics.hcc_idle_minutes = cadences.idleMinutes + metrics.hcc_idle_floor_minutes = cadences.idleFloorMinutes + metrics.hcc_drain_grace_ms = cadences.drainGraceMs + metrics.hcc_heartbeat_poll_ms = cadences.heartbeatPollMs + metrics.stateful_pod_before = statefulBefore.podNames + metrics.stateless_pod_before = statelessBefore.podNames + metrics.stateless_image_ids_before = statelessBefore.imageIds + metrics.stateless_pull_policy_warning = statelessBefore.pullPolicyRejection + }) + + await test.step('first Desktop session: create, name, and exercise both Host conversations', async () => { + const launched = await launchDesktopApp(testInfo) + app = launched.app + page = launched.page + metrics.first_profile_path = await desktopProfilePath(app) + await login(page, desktopCredentials()) + await openAgentsPage(page) + await expect( + page.getByRole('button', { name: `More actions for ${STATEFUL_HOST}`, exact: true }) + ).toBeVisible({ timeout: 30_000 }) + await expect( + page.getByRole('button', { name: `More actions for ${STATELESS_HOST}`, exact: true }) + ).toBeVisible({ timeout: 30_000 }) + await screenshotAndLog(page, testInfo, '01-authenticated-fleet') + + await newChatFromAgentsPage(page, STATELESS_HOST) + await sendMarker( + page, + statelessMarker, + `Reply with exactly: ${statelessMarker}. Remember this token for a later question.` + ) + await renameSessionByMarker(page, statelessMarker, statelessTitle) + await screenshotAndLog(page, testInfo, '02-stateless-initial-turn') + + await newChatFromAgentsPage(page, STATEFUL_HOST) + await sendMarker( + page, + statefulMarker, + `Reply with exactly: ${statefulMarker}. Remember this token for a later question.` + ) + await renameSessionByMarker(page, statefulMarker, statefulTitle) + await screenshotAndLog(page, testInfo, '03-stateful-initial-turn') + + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + await openExactSession(page, statelessTitle, STATELESS_HOST) + await humanType(composer, `Unsent stateless draft ${runId}`) + await openExactSession(page, statefulTitle, STATEFUL_HOST) + await expect(composer).toHaveValue('') + await openExactSession(page, statelessTitle, STATELESS_HOST) + await expect(composer).toHaveValue(`Unsent stateless draft ${runId}`) + metrics.draft_preserved_while_switching_hosts = true + await screenshotAndLog(page, testInfo, '04-draft-preserved-across-host-switch') + }) + + await test.step('exit Electron and prove a real replicas-0 suspension', async () => { + const closeStarted = Date.now() + await finalizeRecording(app, page) + await closeElectron(app) + metrics.first_close_ms = Date.now() - closeStarted + app = undefined + page = undefined + + const suspended = await waitForStatelessSuspended(SUSPEND_TIMEOUT_MS) + metrics.first_suspend_ms = suspended.elapsedMs + expect(suspended.snapshot.lifecycleState).toBe('suspended') + expect(suspended.snapshot.replicas).toBe(0) + expect(suspended.snapshot.podNames).toHaveLength(0) + }) + + await test.step('reopen the same Desktop profile while the stateless Host is suspended', async () => { + const launchStarted = Date.now() + const launched = await launchDesktopApp(testInfo) + metrics.second_launch_to_window_ms = Date.now() - launchStarted + app = launched.app + page = launched.page + metrics.second_profile_path = await desktopProfilePath(app) + expect(metrics.second_profile_path).toBe(metrics.first_profile_path) + await authenticatedWithoutRelogin(page) + metrics.second_launch_to_authenticated_ms = Date.now() - launchStarted + + const ready = await waitForStatelessReady(270_000) + metrics.second_launch_to_stateless_ready_ms = Date.now() - launchStarted + metrics.stateless_image_ids_after_reopen = ready.snapshot.imageIds + expect(ready.snapshot.readyReplicas).toBeGreaterThan(0) + expect(ready.snapshot.podNames.join(',')).not.toBe(metrics.stateless_pod_before.join(',')) + expect(ready.snapshot.imageIds).toEqual(metrics.stateless_image_ids_before) + + await openExactSession(page, statelessTitle, STATELESS_HOST) + await expect(page.getByTestId('message-list')).toContainText(statelessMarker, { + timeout: 30_000, + }) + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + // Drafts are currently renderer-memory scoped. Record this observation; + // PR A requires draft continuity during transient errors in a live app. + metrics.draft_preserved_across_restart = + (await composer.inputValue()) === `Unsent stateless draft ${runId}` + await screenshotAndLog(page, testInfo, '05-reopened-stateless-transcript') + + await openExactSession(page, statefulTitle, STATEFUL_HOST) + await expect(page.getByTestId('message-list')).toContainText(statefulMarker, { + timeout: 30_000, + }) + await screenshotAndLog(page, testInfo, '06-reopened-stateful-transcript') + }) + + await test.step('prepare and send from a second verified cold stateless state', async () => { + await openExactSession(page, statelessTitle, STATELESS_HOST) + const composer = page.getByRole('textbox', { name: 'Agent message composer' }) + await composer.fill('') + const secondSuspended = await waitForStatelessSuspended(SUSPEND_TIMEOUT_MS) + metrics.second_suspend_ms = secondSuspended.elapsedMs + expect(secondSuspended.snapshot.lifecycleState).toBe('suspended') + expect(secondSuspended.snapshot.replicas).toBe(0) + expect(secondSuspended.snapshot.podNames).toHaveLength(0) + + const followPrompt = `Earlier I gave you a token. Reply with exactly that token followed by ${statelessFollowMarker}.` + await humanType(composer, followPrompt) + await expect(page.getByTestId('send-button')).toBeEnabled() + const sendStarted = Date.now() + await page.getByTestId('send-button').click() + const outcome = await resolveColdSend(page, statelessFollowMarker, statelessMarker) + metrics.cold_send_to_waking_visible_ms = outcome.wakingVisibleMs + metrics.cold_send_to_response_ms = Date.now() - sendStarted + metrics.cold_send_retries = outcome.retries + await expect(composer).toHaveValue('', { timeout: 150_000 }) + await expect(page.getByTestId('send-button')).toBeDisabled({ timeout: 150_000 }) + metrics.cold_send_to_composer_idle_ms = Date.now() - sendStarted + + const ready = await waitForStatelessReady(270_000) + metrics.stateless_pod_after_cold_send = ready.snapshot.podNames + metrics.stateless_image_ids_after_cold_send = ready.snapshot.imageIds + expect(ready.snapshot.readyReplicas).toBeGreaterThan(0) + expect(ready.snapshot.imageIds).toEqual(metrics.stateless_image_ids_before) + await expect( + page.getByTestId('agent-response').filter({ hasText: statelessFollowMarker }) + ).toContainText(statelessFollowMarker, { timeout: 30_000 }) + await screenshotAndLog(page, testInfo, '07-stateless-context-continuity-after-cold-send') + }) + + await test.step('stateful control remains continuous and responsive', async () => { + await openExactSession(page, statefulTitle, STATEFUL_HOST) + await expect(page.getByTestId('message-list')).toContainText(statefulMarker, { + timeout: 30_000, + }) + await sendMarker( + page, + statefulFollowMarker, + `What was the earlier token in this conversation? Reply with that token followed by ${statefulFollowMarker}.` + ) + await expect( + page.getByTestId('agent-response').filter({ hasText: statefulFollowMarker }) + ).toContainText(statefulMarker) + const statefulAfter = await readHostSnapshot(STATEFUL_DEPLOYMENT, STATEFUL_HOST_RESOURCE) + metrics.stateful_pod_after = statefulAfter.podNames + expect(statefulAfter.readyReplicas).toBeGreaterThan(0) + expect(statefulAfter.podNames.join(',')).toBe(metrics.stateful_pod_before.join(',')) + await screenshotAndLog(page, testInfo, '08-stateful-control-continuity') + }) + } finally { + await finalizeRecording(app, page).catch(() => undefined) + await testInfo.attach('human-stateless-continuity-metrics', { + body: JSON.stringify(metrics, null, 2), + contentType: 'application/json', + }) + console.log(`[HumanStatelessContinuity] ${JSON.stringify(metrics)}`) + } +}) From b59ff67849bae2bc209f88375f9101f9061315dc Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 20:13:03 +0200 Subject: [PATCH 009/107] test(stateless): align recorded journey with owned runtime --- .../human-stateless-continuity.spec.ts | 71 ++++++++++++++++--- .../e2e-hcc-stateless-cache-containment.sh | 16 ++++- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts b/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts index f7f5d5a3b6..b2bd8a8b72 100644 --- a/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts +++ b/desktop-app/test/e2e-playwright/human-stateless-continuity.spec.ts @@ -53,6 +53,8 @@ type JourneyMetrics = { second_profile_path: string stateful_pod_before: string[] stateless_pod_before: string[] + stateless_initial_lifecycle_state: string + stateless_initial_lifecycle_reason: string stateless_image_ids_before: string[] stateless_image_ids_after_reopen: string[] stateless_image_ids_after_cold_send: string[] @@ -69,6 +71,7 @@ type JourneyMetrics = { cold_send_to_response_ms: number cold_send_to_composer_idle_ms: number cold_send_retries: number + initial_stateless_send_retries: number stateless_pod_after_cold_send: string[] stateful_pod_after: string[] markers: { @@ -141,6 +144,9 @@ async function waitForStatelessSuspended(timeoutMs: number): Promise<{ last.readyReplicas === 0 && last.podNames.length === 0 ) { + expect(last.lifecycleReason, 'stateless suspension must be caused by idle timeout').toBe( + 'idle' + ) return { snapshot: last, elapsedMs: Date.now() - started } } await new Promise(resolve => setTimeout(resolve, 5_000)) @@ -247,6 +253,9 @@ async function sendMarker(page: Page, marker: string, prompt: string): Promise/dev/null || die 'external-rest-api unavailable' curl -fsS -m 10 "${RPC_BASE}/health" >/dev/null || die 'rpc-proxy unavailable' login="$(curl -fsS -m 20 -X POST "${EXT_BASE}/api/v1/auth/password-login" \ From a7d2e5da86d849fb3ceb37a991bc3aeb6e20d067 Mon Sep 17 00:00:00 2001 From: Alfredo Lopez Date: Fri, 25 Sep 2026 21:44:46 +0200 Subject: [PATCH 010/107] fix(desktop,hcc): preserve pending stateless conversations --- Makefile | 4 +- .../host-context-controller.yaml | 10 ++ deploy/overlays/minikube/kustomization.yaml | 1 + .../patches/hcc-stateless-lifecycle.yaml | 22 ++++ desktop-app/src/__tests__/chatStore.test.ts | 13 ++ desktop-app/src/chatStore.ts | 25 ++++ desktop-app/src/chatStoreBinding.ts | 5 +- desktop-app/src/ipc.ts | 16 +++ desktop-app/src/preload.ts | 2 + desktop-app/src/renderer.d.ts | 1 + .../__tests__/__fixtures__/mockClerum.ts | 11 ++ .../__tests__/imageInputJourney.test.tsx | 3 +- .../__tests__/switchToChat.unified.test.tsx | 117 ++++++++++++++++- .../useAgentChatController.crossChat.test.tsx | 124 +++++++++++++++--- ...eAgentChatController.pendingModel.test.tsx | 6 + .../hooks/domain/useAgentChatController.ts | 119 ++++++++++++----- .../src/hooks/domain/useChatListController.ts | 11 ++ desktop-app/ui/src/hooks/useChatStore.ts | 6 + scripts/e2e/e2e-stateless-cron-block.sh | 32 ++++- scripts/e2e/e2e-stateless-suspend-wake.sh | 33 ++++- scripts/e2e/e2e-stateless-wake-recovery.sh | 34 ++++- scripts/minikube/pre-gate-incremental.sh | 8 +- .../tests/test-minikube-pre-gate-shadow.sh | 10 +- 23 files changed, 531 insertions(+), 82 deletions(-) create mode 100644 deploy/overlays/minikube/patches/hcc-stateless-lifecycle.yaml diff --git a/Makefile b/Makefile index de28a4e653..1f036f4c1f 100644 --- a/Makefile +++ b/Makefile @@ -1386,12 +1386,12 @@ test-e2e-stateless-durability: ## Run stateless host durability E2E gate (Phase .PHONY: test-e2e-stateless-suspend-wake test-e2e-stateless-suspend-wake: ## Run stateless suspend/wake E2E gate (Phase 1 API + Phase 2 Desktop Playwright; needs port-forwards + seeded chatllm-stateless) @echo "Running stateless suspend/wake E2E gate..." - KUBECONTEXT=$(E2E_KUBECONTEXT) bash scripts/e2e/e2e-stateless-suspend-wake.sh + E2E_ALLOW_STATELESS_CADENCE_ACCELERATION=1 KUBECONTEXT=$(E2E_KUBECONTEXT) bash scripts/e2e/e2e-stateless-suspend-wake.sh .PHONY: test-e2e-stateless-wake-recovery test-e2e-stateless-wake-recovery: ## Run stateless wake-recovery latency gate (R1 warm draining / R2 cold suspended / R3 drained-window p95 budgets; needs port-forwards + seeded chatllm-stateless) @echo "Running stateless wake-recovery latency gate..." - KUBECONTEXT=$(E2E_KUBECONTEXT) bash scripts/e2e/e2e-stateless-wake-recovery.sh + E2E_ALLOW_STATELESS_CADENCE_ACCELERATION=1 KUBECONTEXT=$(E2E_KUBECONTEXT) bash scripts/e2e/e2e-stateless-wake-recovery.sh .PHONY: test-e2e-hcc-communicationchannel-watch-recovery test-e2e-hcc-communicationchannel-watch-recovery: ## Run isolated minikube HCC watch-recovery fault-injection gate diff --git a/deploy/base/control-plane/host-context-controller.yaml b/deploy/base/control-plane/host-context-controller.yaml index d6c0fa1e54..ec7a3d9f7a 100644 --- a/deploy/base/control-plane/host-context-controller.yaml +++ b/deploy/base/control-plane/host-context-controller.yaml @@ -76,6 +76,16 @@ spec: value: "8081" - name: CONTEXT_MAPPER_NAMESPACE value: mcp-server + # Product baseline for stateless Hosts. HCC uses the effective idle window + # max(idleMinutes, idleFloorMinutes), then drains active work before scale-to-zero. + - name: CONTEXT_MAPPER_STATELESS_IDLE_MINUTES + value: "30" + - name: CONTEXT_MAPPER_STATELESS_IDLE_FLOOR_MINUTES + value: "15" + - name: CONTEXT_MAPPER_STATELESS_DRAIN_GRACE_MS + value: "60000" + - name: CONTEXT_MAPPER_HEARTBEAT_POLL_MS + value: "10000" - name: CONTEXT_MAPPER_HOST_NAMESPACE value: mcp-host - name: CONTEXT_MAPPER_HOST_IMAGE diff --git a/deploy/overlays/minikube/kustomization.yaml b/deploy/overlays/minikube/kustomization.yaml index 3e20987799..912e2dee34 100644 --- a/deploy/overlays/minikube/kustomization.yaml +++ b/deploy/overlays/minikube/kustomization.yaml @@ -83,6 +83,7 @@ images: newTag: test patchesStrategicMerge: + - patches/hcc-stateless-lifecycle.yaml - patches/hcc-policy-resync.yaml - patches/resource-limits.yaml - patches/dynamic-images.yaml diff --git a/deploy/overlays/minikube/patches/hcc-stateless-lifecycle.yaml b/deploy/overlays/minikube/patches/hcc-stateless-lifecycle.yaml new file mode 100644 index 0000000000..79bba8029a --- /dev/null +++ b/deploy/overlays/minikube/patches/hcc-stateless-lifecycle.yaml @@ -0,0 +1,22 @@ +# Local Minikube mirrors the product stateless idle policy explicitly. This is +# also the baseline that pre-gate reconciliation restores after an accelerated +# lifecycle test mutates the live Deployment. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: host-context-controller + namespace: control-plane +spec: + template: + spec: + containers: + - name: host-context-controller + env: + - name: CONTEXT_MAPPER_STATELESS_IDLE_MINUTES + value: '30' + - name: CONTEXT_MAPPER_STATELESS_IDLE_FLOOR_MINUTES + value: '15' + - name: CONTEXT_MAPPER_STATELESS_DRAIN_GRACE_MS + value: '60000' + - name: CONTEXT_MAPPER_HEARTBEAT_POLL_MS + value: '10000' diff --git a/desktop-app/src/__tests__/chatStore.test.ts b/desktop-app/src/__tests__/chatStore.test.ts index d19c2e9e84..1674a5866a 100644 --- a/desktop-app/src/__tests__/chatStore.test.ts +++ b/desktop-app/src/__tests__/chatStore.test.ts @@ -342,6 +342,19 @@ describe('messages', () => { expect(messages[1]!.content).toBe('hello') }) + it('upserts an optimistic outgoing message and later updates it by the same id', async () => { + await store.createChat('agent-1', 'upsert-send') + const optimistic = { id: 'outgoing-1', role: 'user' as const, content: 'hello', timestamp: 1 } + await store.upsertMessages('agent-1', 'upsert-send', [optimistic]) + await store.upsertMessages('agent-1', 'upsert-send', [ + { ...optimistic, task_id: 'task-1', timestamp: 2 }, + ]) + + await expect(store.loadMessages('agent-1', 'upsert-send')).resolves.toEqual([ + expect.objectContaining({ id: 'outgoing-1', task_id: 'task-1' }), + ]) + }) + it('pagination with limit and offset', async () => { await store.createChat('agent-1', 'pag-1') const msgs = Array.from({ length: 10 }, (_, i) => ({ diff --git a/desktop-app/src/chatStore.ts b/desktop-app/src/chatStore.ts index cf12f65353..f12e9e1b6c 100644 --- a/desktop-app/src/chatStore.ts +++ b/desktop-app/src/chatStore.ts @@ -1954,6 +1954,31 @@ export class ChatStore { }) } + /** + * Insert or update messages by reconciled identity before a send's task id is + * known. A send is first persisted optimistically, then updated in place after + * its acknowledgement; append-only writes would duplicate the same user row. + */ + async upsertMessages(agentRef: string, chatId: string, messages: ChatMessage[]): Promise { + if (!messages.length) return + return this.serializeChat(agentRef, chatId, async () => { + const index = await this.getIndex(agentRef) + if (this.isDeletedInScope(index, chatId)) return + const existingMeta = + (await this.readOrMigratePagedChatUnlocked(agentRef, chatId)) ?? + (await this.writePagedChatUnlocked(agentRef, chatId, [])) + const existingMessages = await this.readMessagesFromPagedMeta(agentRef, chatId, existingMeta) + const merged = mergeReconciledMessages(existingMessages, messages) + const indexedCount = await this.indexedMessageCount(agentRef, chatId) + await this.writePagedChatUnlocked(agentRef, chatId, merged, { + messageCount: Math.max(merged.length, indexedCount ?? 0), + }) + await this.updateChatIndexFromMessages(agentRef, chatId, merged, { + preserveExistingTotals: true, + }) + }) + } + /** * Flag that a task terminated while the chat was not the active view (D.5). * Idempotent: a no-op when already flagged, so a noisy task can't spam FS writes. diff --git a/desktop-app/src/chatStoreBinding.ts b/desktop-app/src/chatStoreBinding.ts index 67c52a4916..5e9ca2764c 100644 --- a/desktop-app/src/chatStoreBinding.ts +++ b/desktop-app/src/chatStoreBinding.ts @@ -2,6 +2,7 @@ import { app } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import { ChatStore } from './chatStore.js' +import { resolveDevIsolation } from './devIsolation.js' import { assertSafeFilesystemSegment } from './pathSafety.js' import type { ChatAuthorityScope } from './types.js' @@ -14,12 +15,14 @@ const PREVIOUS_PAGED_INDEX_VERSION = 3 const LEGACY_INDEX_VERSIONS = new Set([1]) const CORRUPT_QUARANTINE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 -// Default base dir derived from Electron's userData path. Tests override via +// Default base dir derived from the user's home. Tests override via // __setChatStoreBaseDirForTests to point at a tmpdir, which means the // app.getPath call below is only reached in production. let baseDirOverride: string | null = null function currentBaseDir(): string { if (baseDirOverride !== null) return baseDirOverride + const isolation = resolveDevIsolation(process.env, process.argv, app.isPackaged) + if (isolation.mode === 'isolated') return join(isolation.plan.runDir, 'chats') return join(app.getPath('home'), '.clerum', 'chats') } diff --git a/desktop-app/src/ipc.ts b/desktop-app/src/ipc.ts index b142d00b11..87c9d5b5eb 100644 --- a/desktop-app/src/ipc.ts +++ b/desktop-app/src/ipc.ts @@ -1573,6 +1573,22 @@ export function registerIpcHandlers(service: AppService): void { } ) + ipcMain.handle( + 'chat:upsertMessages', + async (event, payload: { agentRef: string; chatId: string; messages: unknown[] }) => { + assertTrustedSender(event) + const agentRef = sanitizeString(payload?.agentRef) + const chatId = sanitizeString(payload?.chatId) + if (!agentRef || !chatId) throw new Error('agentRef and chatId are required') + const messages = Array.isArray(payload?.messages) ? payload.messages : [] + await requireChatStore().upsertMessages( + agentRef, + chatId, + messages as import('./types.js').ChatMessage[] + ) + } + ) + ipcMain.handle( 'chat:replaceMessages', async ( diff --git a/desktop-app/src/preload.ts b/desktop-app/src/preload.ts index a87185b128..d9ce05167d 100644 --- a/desktop-app/src/preload.ts +++ b/desktop-app/src/preload.ts @@ -589,6 +589,8 @@ const clerum = Object.freeze({ ipcRenderer.invoke('chat:loadMessages', { agentRef, chatId, limit, offset }), appendMessages: (agentRef: string, chatId: string, messages: unknown[]) => ipcRenderer.invoke('chat:appendMessages', { agentRef, chatId, messages }), + upsertMessages: (agentRef: string, chatId: string, messages: unknown[]) => + ipcRenderer.invoke('chat:upsertMessages', { agentRef, chatId, messages }), replaceMessages: ( agentRef: string, chatId: string, diff --git a/desktop-app/src/renderer.d.ts b/desktop-app/src/renderer.d.ts index a179777199..23e5ecaffb 100644 --- a/desktop-app/src/renderer.d.ts +++ b/desktop-app/src/renderer.d.ts @@ -718,6 +718,7 @@ declare global { offset?: number ) => Promise appendMessages: (agentRef: string, chatId: string, messages: ChatMessage[]) => Promise + upsertMessages: (agentRef: string, chatId: string, messages: ChatMessage[]) => Promise replaceMessages: ( agentRef: string, chatId: string, diff --git a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts index 5859831581..f9381498e5 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts +++ b/desktop-app/ui/src/hooks/domain/__tests__/__fixtures__/mockClerum.ts @@ -26,6 +26,7 @@ interface ChatMock { delete: Fn loadMessages: Fn appendMessages: Fn + upsertMessages: Fn replaceMessages: Fn markUnreadTerminal: Fn clearUnreadTerminal: Fn @@ -65,6 +66,7 @@ export interface MockClerum { export function installMockClerum(): MockClerum { const progressHandlers = new Map() const activityHandlers = new Map() + const upsertedMessageIds = new Set() const isoNow = () => new Date().toISOString() @@ -88,6 +90,15 @@ export function installMockClerum(): MockClerum { delete: vi.fn(async () => ({ cleanupPending: false })), loadMessages: vi.fn(async () => []), appendMessages: vi.fn(async () => undefined), + upsertMessages: vi.fn(async (agentRef: string, chatId: string, messages: unknown[]) => { + const unseen = messages.filter(message => { + const id = (message as { id?: unknown }).id + if (typeof id !== 'string' || upsertedMessageIds.has(id)) return false + upsertedMessageIds.add(id) + return true + }) + if (unseen.length) await chat.appendMessages(agentRef, chatId, unseen) + }), replaceMessages: vi.fn(async () => undefined), markUnreadTerminal: vi.fn(async () => undefined), clearUnreadTerminal: vi.fn(async () => undefined), diff --git a/desktop-app/ui/src/hooks/domain/__tests__/imageInputJourney.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/imageInputJourney.test.tsx index f3f1d90278..89abdfec58 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/imageInputJourney.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/imageInputJourney.test.tsx @@ -331,6 +331,7 @@ describe('#654 visual send and recovery', () => { }) expect(result.current.failedAgentSend).toBeNull() expect(result.current.agentError).toBeNull() - expect(bridge.chat.appendMessages).not.toHaveBeenCalled() + expect(bridge.chat.upsertMessages).toHaveBeenCalledTimes(1) + expect(bridge.chat.appendMessages).toHaveBeenCalledTimes(1) }) }) diff --git a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx index 142ef9c824..30d09eff6e 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx @@ -58,6 +58,8 @@ function deferred() { return { promise, resolve } } +type SessionResult = Awaited> + describe('switchToChat (unified, D.4)', () => { it('Phase 1 renders the cache, Phase 2 reconciles, and preserves the cache when there is no diff', async () => { const cached = [ @@ -84,7 +86,7 @@ describe('switchToChat (unified, D.4)', () => { 'agent-x', 'c1', undefined, - { limit: 40, afterTurn: 1 } + { limit: 40, afterTurn: 0 } ) expect(clerum.chat.replaceMessages).not.toHaveBeenCalled() expect(result.current.chatMessages).toHaveLength(2) @@ -380,7 +382,7 @@ describe('switchToChat (unified, D.4)', () => { 'agent-x', 'delta-pages', undefined, - { limit: 40, afterTurn: 1 } + { limit: 40, afterTurn: 0 } ) expect(clerum.rpc.loadSessionMessages).toHaveBeenNthCalledWith( 2, @@ -463,6 +465,46 @@ describe('switchToChat (unified, D.4)', () => { ]) }) + it('preserves turnless legacy history outside a partial authoritative window', async () => { + const legacyMessages = Array.from({ length: 100 }, (_, index) => ({ + id: `legacy-${index + 1}`, + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `legacy-${index + 1}`, + timestamp: index + 1, + })) + clerum.chat.loadMessages.mockResolvedValue(legacyMessages) + clerum.rpc.loadSessionMessages.mockResolvedValue({ + agent: 'agent-x', + chatId: 'legacy-partial-window', + state: 'idle', + totalTurns: 50, + oldestTurnNumber: 11, + latestTurnNumber: 50, + hasMoreBefore: true, + hasMoreAfter: false, + turns: Array.from({ length: 40 }, (_, index) => + turn(index + 11, `server-q${index + 11}`, `server-a${index + 11}`) + ), + }) + const { result } = renderController() + await settleMount() + + await act(async () => { + await result.current.switchToChat('agent-x', 'legacy-partial-window') + }) + + expect(result.current.chatMessages.map(message => message.id)).toContain('legacy-1') + expect(result.current.chatMessages.map(message => message.id)).toContain('turn-50-assistant') + expect(clerum.chat.replaceMessages).toHaveBeenCalledWith( + 'agent-x', + 'legacy-partial-window', + expect.arrayContaining([ + expect.objectContaining({ id: 'legacy-1' }), + expect.objectContaining({ id: 'turn-11-user' }), + ]) + ) + }) + it('caps server delta pagination so reconcile cannot walk an unbounded cursor chain', async () => { clerum.chat.loadMessages.mockResolvedValue([ { id: 'turn-1-user', role: 'user' as const, content: 'q1', timestamp: 1 }, @@ -518,8 +560,6 @@ describe('switchToChat (unified, D.4)', () => { 'turn-4-assistant', 'turn-5-user', 'turn-5-assistant', - 'turn-6-user', - 'turn-6-assistant', 'turn-999-user', 'turn-999-assistant', ]) @@ -889,7 +929,7 @@ describe('switchToChat (unified, D.4)', () => { 'agent-x', 'synced-chat', undefined, - { limit: 40, afterTurn: 1 } + { limit: 40, afterTurn: 0 } ) expect(result.current.chatMessages.map(message => message.id)).toEqual([ 'turn-1-user', @@ -1054,6 +1094,73 @@ describe('switchToChat (unified, D.4)', () => { } ) + it('re-fetches the newest cached turn so a response completed after caching is recovered', async () => { + clerum.chat.loadMessages.mockResolvedValue([ + { id: 'turn-1-user', role: 'user' as const, content: 'q', timestamp: 1, serverTurnNumber: 1 }, + ]) + clerum.rpc.loadSessionMessages.mockResolvedValue({ + agent: 'agent-x', + chatId: 'partial-turn', + state: 'idle', + totalTurns: 1, + turns: [turn(1, 'q', 'completed answer')], + }) + const { result } = renderController() + await settleMount() + + await act(async () => { + await result.current.switchToChat('agent-x', 'partial-turn') + }) + + expect(clerum.rpc.loadSessionMessages).toHaveBeenCalledWith( + 'agent-x', + 'agent-x', + 'partial-turn', + undefined, + { limit: 40, afterTurn: 0 } + ) + expect(result.current.chatMessages.map(message => message.content)).toEqual([ + 'q', + 'completed answer', + ]) + }) + + it('reuses the in-flight selection when the same chat is reopened', async () => { + const response = deferred() + clerum.chat.loadMessages.mockResolvedValue([]) + clerum.rpc.loadSessionMessages.mockImplementationOnce(() => response.promise) + const { result } = renderController() + await settleMount() + + let firstSwitch!: Promise + await act(async () => { + firstSwitch = result.current.switchToChat('agent-x', 'same-chat') + await waitFor(() => expect(clerum.rpc.loadSessionMessages).toHaveBeenCalledTimes(1)) + }) + let secondSwitch!: Promise + await act(async () => { + secondSwitch = result.current.switchToChat('agent-x', 'same-chat') + await Promise.resolve() + }) + + response.resolve({ + agent: 'agent-x', + chatId: 'same-chat', + state: 'idle', + totalTurns: 1, + turns: [turn(1, 'question', 'fresh answer')], + }) + await act(async () => { + await Promise.all([firstSwitch, secondSwitch]) + }) + + expect(clerum.rpc.loadSessionMessages).toHaveBeenCalledTimes(1) + expect(result.current.chatMessages.map(message => message.content)).toEqual([ + 'question', + 'fresh answer', + ]) + }) + it('ignores a send 403 from the prior team scope', async () => { const revoked = new Set() let rejectSend!: (reason: unknown) => void diff --git a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx index 78a6b3628b..80b572cb4b 100644 --- a/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx +++ b/desktop-app/ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx @@ -45,14 +45,26 @@ function createChatMeta(chatId: string) { function installClerumHarness() { let taskIndex = 0 - const chats: Array> = [] + const chatsByAgent = new Map>>() const deletedChatIds = new Set() - const messagesByChat = new Map() + const messagesByAgentChat = new Map() + const messageKey = (agentRef: string, chatId: string) => `${agentRef}\u0000${chatId}` const progressHandlers = new Map() const loadSessionMessages = vi.fn(async () => ({ agent: 'trader', chatId: '', turns: [] })) const listSessions = vi.fn(async () => ({ items: [] })) - const appendMessages = vi.fn(async (_agentRef: string, chatId: string, messages: unknown[]) => { - messagesByChat.set(chatId, [...(messagesByChat.get(chatId) || []), ...messages]) + const appendMessages = vi.fn(async (agentRef: string, chatId: string, messages: unknown[]) => { + const key = messageKey(agentRef, chatId) + messagesByAgentChat.set(key, [...(messagesByAgentChat.get(key) || []), ...messages]) + }) + const upsertMessagesIntoMap = (agentRef: string, chatId: string, messages: unknown[]) => { + const key = messageKey(agentRef, chatId) + const existing = messagesByAgentChat.get(key) || [] + const byId = new Map(existing.map(message => [(message as { id: string }).id, message])) + for (const message of messages) byId.set((message as { id: string }).id, message) + messagesByAgentChat.set(key, [...byId.values()]) + } + const upsertMessages = vi.fn(async (agentRef: string, chatId: string, messages: unknown[]) => { + upsertMessagesIntoMap(agentRef, chatId, messages) }) const invokeHostMessage = vi.fn(async (_agentRef: string, _payload: { threadId?: string }) => { @@ -65,12 +77,13 @@ function installClerumHarness() { writable: true, value: { chat: { - list: vi.fn(async () => chats), + list: vi.fn(async (agentRef: string) => chatsByAgent.get(agentRef) || []), create: vi.fn(async (_agentRef: string, chatId: string) => { const meta = createChatMeta(chatId) - deletedChatIds.delete(chatId) - chats.push(meta) - messagesByChat.set(chatId, []) + const agentChats = chatsByAgent.get(_agentRef) || [] + agentChats.push(meta) + chatsByAgent.set(_agentRef, agentChats) + messagesByAgentChat.set(messageKey(_agentRef, chatId), []) return meta }), rename: vi.fn(async () => undefined), @@ -83,23 +96,29 @@ function installClerumHarness() { })), delete: vi.fn(async (_agentRef: string, chatId: string) => { deletedChatIds.add(chatId) + const chats = chatsByAgent.get(_agentRef) || [] const index = chats.findIndex(chat => chat.id === chatId) if (index >= 0) chats.splice(index, 1) - messagesByChat.delete(chatId) + messagesByAgentChat.delete(messageKey(_agentRef, chatId)) return { cleanupPending: false } }), loadMessages: vi.fn( - async (_agentRef: string, chatId: string) => messagesByChat.get(chatId) || [] + async (agentRef: string, chatId: string) => + messagesByAgentChat.get(messageKey(agentRef, chatId)) || [] ), appendMessages, - replaceMessages: vi.fn(async (_agentRef: string, chatId: string, messages: unknown[]) => { - messagesByChat.set(chatId, [...messages]) + upsertMessages, + replaceMessages: vi.fn(async (agentRef: string, chatId: string, messages: unknown[]) => { + messagesByAgentChat.set(messageKey(agentRef, chatId), [...messages]) }), markUnreadTerminal: vi.fn(async () => undefined), clearUnreadTerminal: vi.fn(async () => undefined), getLastActive: vi.fn(async () => null), setLastActive: vi.fn(async () => undefined), - getIndex: vi.fn(async () => ({ chats, deletedChatIds: [...deletedChatIds] })), + getIndex: vi.fn(async (agentRef: string) => ({ + chats: chatsByAgent.get(agentRef) || [], + deletedChatIds: [...deletedChatIds], + })), dismissOnboarding: vi.fn(async () => undefined), }, rpc: { @@ -126,11 +145,12 @@ function installClerumHarness() { return { invokeHostMessage, appendMessages, + upsertMessages, progressHandlers, loadSessionMessages, listSessions, seedMessage: (chatId: string, content: string) => - messagesByChat.set(chatId, [ + messagesByAgentChat.set(messageKey('trader', chatId), [ { id: `message-${chatId}`, role: 'user', content, createdAt: new Date().toISOString() }, ]), } @@ -139,9 +159,10 @@ function installClerumHarness() { function AgentChatHarness() { const [selectionState, setSelectionState] = React.useState('idle') const [sendState, setSendState] = React.useState('idle') + const [selectedAgent, setSelectedAgent] = React.useState('trader') const vm = useAgentChatController({ - selectedAgent: 'trader', - agentNames: ['trader'], + selectedAgent, + agentNames: ['trader', 'chatllm-stateless'], currentTeamId: 'team-1', currentEnvironmentKey: 'env-test', currentTeamName: 'Team One', @@ -184,6 +205,17 @@ function AgentChatHarness() { + {(['trader', 'chatllm-stateless'] as const).map(agent => ( + + ))}