diff --git a/docs/chat-chain-changes/2026-07-17-group-chat-invite-admission.md b/docs/chat-chain-changes/2026-07-17-group-chat-invite-admission.md new file mode 100644 index 0000000000..0962e777c9 --- /dev/null +++ b/docs/chat-chain-changes/2026-07-17-group-chat-invite-admission.md @@ -0,0 +1,13 @@ +--- +date: 2026-07-17 +pr: 1 +feature: Group Chat v2 invite admission atomicity and non-enumerating room access +impact: Group chat invite joins now recheck room state inside one transaction, rotate invite generations with room authorization revisions, and hide private room existence from strangers on join/detail access paths. +--- + +- Invite admission now persists membership and actor state only after a transactional room reload confirms the current invite or an already-authorized subject. +- Invite rotation increments both `inviteGeneration` and `authorizationRevision` in the central `gc_rooms` schema path. +- Stranger room detail, Socket.IO join, and invalid invite lookups now reuse the same missing-room shape instead of exposing private room existence. +- Automatically generated invite codes now use a 32-character unambiguous alphabet with 16 symbols (80 bits) from OS-backed cryptographic randomness in both server and browser paths; explicit user-supplied codes remain exact-byte, case-sensitive secrets. +- REST and Socket.IO invite failures share one bounded per-subject limiter. Limited attempts keep the same missing-room response and limiter keys never contain the invite secret. +- Invite-code lookup compares every persisted candidate through the same domain-separated constant-time digest path instead of using a valid-vs-invalid SQL equality branch. diff --git a/docs/chat-chain-changes/2026-07-17-group-chat-v2-actor-access.md b/docs/chat-chain-changes/2026-07-17-group-chat-v2-actor-access.md new file mode 100644 index 0000000000..ab9f51ce11 --- /dev/null +++ b/docs/chat-chain-changes/2026-07-17-group-chat-v2-actor-access.md @@ -0,0 +1,37 @@ +--- +date: 2026-07-17 +pr: 1 +feature: Group Chat v2 actor identity, access policy, schema migration, and DB ownership hardening +impact: Group chat actor/capability persistence, access decisions, schema startup, and SQLite process ownership now fail closed behind a typed PR1-only contract. +--- + +# Group Chat v2 actor identity, access policy, schema migration, and DB ownership hardening + +- Group chat actor identity moved behind typed `identity/` modules for actor records, capability normalization, deterministic migration IDs, and typed revision fingerprints. +- Access decisions now operate on typed verified subjects instead of Koa/Socket shapes, ignore client-supplied authenticated identity fields, and use one finite supported capability registry for persistence, reporting, and enforcement. Active actors with no persisted grant rows receive no actor-type defaults at read time, and in-memory runtime membership never substitutes authority when canonical actor lookup is unavailable. Ordinary identity/metadata refresh and authenticated reconnect preserve existing grants, while creation or an explicit valid-invite/public local admission may persist new authority. A valid invite adds `room.read` to an existing authenticated actor without deleting unrelated grants. +- Successful join acknowledgements expose only room ids for which the current subject has `canDiscover`. Every room broadcast is delivered per socket after a fresh `canRead` decision; a subscriber whose read authority disappeared is removed from the Socket.IO room and runtime presence before confidential messages, tool/workspace output, or stream/reasoning deltas are emitted. +- Connected authenticated sockets retain only the stable numeric account id. Every admission, ingress action, and outbound recipient decision re-reads the current active user row, role, and profile assignments; disabled or deleted users fail closed. All concurrent sockets for one persisted subject retain independent runtime membership and ingress eligibility while presenting one de-duplicated member; only the final socket departure emits `member_left`. Account disable/delete also tombstones all Group Chat actors, removes memberships and ownership, advances room authorization revisions, and disconnects every socket for that account. Profile, owner, explicit member, and actor-grant authority remain separate sources rather than one source silently rewriting another. +- `agent.invoke` gates both human-origin and agent-to-agent mention routing. Approval requests and resolutions are delivered independently to every joined socket whose current persisted subject has `approval.respond`; management-only sockets receive neither. REST room DTOs expose `canApprove` separately from `canManage`, and the first-party UI uses the former for approval controls. Each accepted Bridge approval id is bounded in memory to its originating room, active agent actor, current opaque session fingerprint, normalized allowed-choice set, permanent-approval flag, and fresh `room.read` plus `room.write` grants before any response can reach Bridge; unbound, cross-room, duplicate, stale, revoked, or unoffered responses fail closed. `always` is removed whenever permanent approval is disabled, and the Python Bridge independently coerces any response outside the request-bound choice set to `deny`. If the same globally addressed Bridge approval id is ever observed from a different room, agent, or run session, that id becomes a bounded permanent conflict tombstone for the server lifetime and cannot be re-enabled by duplicate events, resolution events, or room-local cleanup. +- Agent runtime launch rechecks the current room/session/grant lease immediately after asynchronous model resolution and before reading members or constructing private room context. `ContextEngine` receives that lease as a required fail-closed guard and rechecks it after compression-lock waits, token-estimation waits, progress callbacks, and summarizer calls, before private context logging or snapshot persistence. Automatic and manual compression share one per-room FIFO lock, so concurrent callers cannot race snapshot writes. A typed authorization abort cannot degrade into an unguarded Bridge launch. Later pre-launch, post-launch, and stream checks stop or interrupt the run if that lease changes. +- Startup now runs a named `groupChatIdentityV1` migration through central Hermes schema initialization. The migration upgrades only fresh/latest-main group chat state, records a minimum reader epoch, rejects pending or foreign Group Chat schema-state rows before any schema write, rotates invalid legacy room seeds, backfills canonical actors/capabilities in one transaction, validates references and uniqueness before success, and rolls back fully on failure. +- Hermes Web UI SQLite startup now acquires process-lifetime file ownership through SQLite locking instead of relying on ad hoc multi-process behavior. A second process using the same DB path fails before schema/application use, while `closeDb()` and owner termination release the kernel lock for reacquire. +- Public Bridge session IDs are fixed-length opaque digests over room, actor incarnation, authorization/context revisions, and the cryptographic room seed; raw room, profile, actor, workspace, and capability values are not exposed in the key. Before the first external Bridge run call, the session mapping is registered transactionally only if the room seed/revision, persisted agent row, active actor incarnation/revisions, and persisted `room.read` plus `room.write` grants are still current. Context estimation, pre-launch, post-launch, and stream processing also fresh-check both run grants so grant loss fences and interrupts an in-flight run even if a malformed mutation failed to advance the actor revision. Cleanup-only interrupt registration remains available after grant revocation so an already-derived external session can be stopped without authorizing a new run. +- Context summarization uses a separate fixed-length opaque HMAC session with a cryptographic nonce. Its active-agent mapping and future-due crash-cleanup intent are written in the same immediate transaction before any summary Bridge request; normal completion or failure activates the durable cleanup outbox immediately. Manual compression must acquire the same registered lease before reading room history and is unavailable when no current room agent has persisted `room.read` plus `room.write`. +- Agent revocation now tombstones the immutable actor id, scrubs authoritative identity keys, enqueues recorded bridge sessions before mapping cleanup, and attempts runtime interrupt/destroy after authority is already revoked. Room creation, cloning, and agent removal call only the hardened atomic storage operations; no legacy multi-step compatibility path remains reachable. The restart worker replays every profile recorded in the durable outbox, retries transient failures indefinitely with bounded exponential backoff, and never depends only on the currently active profile. +- Full room deletion removes actor grants, actor rows, and session mappings in the room transaction while retaining durable pending-session-delete outbox entries for external cleanup. +- Every unauthenticated Group Chat REST request resolves the same signed server-issued local subject used by Socket.IO before discovery, detail/history, workspace reads, or management checks. Missing, forged, or valid-but-ungranted credentials receive zero authority; listing evaluates every room through persisted policy, unknown/unreadable rooms remain hidden, and manage/workspace/invite fields are redacted unless granted. Auth-disabled room creation and cloning atomically create the local actor with full creator grants in the room transaction without persisting the authority id as a routing/display member id. First-party JSON and binary workspace requests carry the credential uniformly and wait for the current socket connection to confirm identity readiness before initial REST access. The shared HTTP client normalizes header names case-insensitively before `fetch`, so wrapper-provided lower-case headers cannot combine with injected JSON, authorization, or profile headers into invalid comma-separated duplicates; a client integration regression exercises the room-creation request. Invite lookup still URL-encodes exact invite bytes and uses the same credential, so REST and Socket.IO share one subject-wide failure budget without collapsing independent local clients onto an IP-wide identity. Limiter capacity is bounded fail-closed: active window/lock penalties are never evicted to admit a new subject, and capacity becomes available only after entries naturally expire. +- Protected Group Chat REST work never treats request-start authentication as a durable lease. Every post-`await` authorization boundary reloads the authenticated account status, role, and profile assignments and reevaluates local actor grants from storage immediately before a protected response or mutation. Agent provisioning applies a separate fresh profile-entitlement lease: authenticated non-super-admins must still hold every requested profile before nested room creation or cloning, before each provisional runtime creation, before persistence, after asynchronous room join, and before the successful response; local/auth-disabled provisioning retains the signed-local-subject room-management policy. Create and clone hold all requested profiles as one request-level lease across serial agent provisioning; any room/profile lease loss rolls back the new target room and every provisioned target agent while preserving a clone source room. Provisional agent clients are disconnected before persistence on revocation, and an agent persisted before asynchronous room join is tombstoned and removed if room or profile authority changes before completion. Room deletion and context clearing recheck current management authority inside runtime cleanup immediately after agent interruption and before socket eviction, agent disconnection, context reset, or room removal; denial releases the provisional session fence before returning the same non-enumerating REST failure. Workspace replacement, workspace file operations, and manual compression likewise recheck current authority after their asynchronous boundaries; denied workspace replacement also releases its provisional session fence. Client connection setup, auxiliary socket-connect waiters, and local-identity readiness waiters share explicit generation-owned cancellation state, so `disconnect()` during identity lookup, socket establishment, or local credential readiness cannot publish/connect a stale socket, retain listeners or a 30-second timer, or interfere with a later reconnect. + +## Upgrade / restore + +- `groupChatIdentityV1` is a forward-only schema boundary. Downgrade to a build that does not understand this reader epoch is not supported. +- Take a full backup of `HERMES_WEB_UI_HOME` before first startup on this build if rollback to pre-PR1 data is required. The backup must keep the SQLite database (normally `hermes-web-ui.db`) and `.group-chat-local-identity-secret` together; deployments using `GROUP_CHAT_LOCAL_IDENTITY_SECRET` must preserve that same 64-hex value with the database instead. +- Restore requires stopping the app, replacing the upgraded home/database and local-identity secret with the matching backup copies, and restarting on the same or newer build that matches the restored files. +- Legacy unauthenticated rows are backfilled under deterministic server-issued subject ids rather than trusting their old client routing ids. Existing private-room local clients must re-admit with the room invite once to receive a signed durable credential; authenticated actors are unaffected. +- Export and destructive delete procedures remain separately reviewed operational paths; this PR only hardens actor/access/runtime behavior and does not change the review requirements for external export or history deletion. + +## Deferrals + +- PR2 channel and visibility grants are not included here. +- PR3 private-context plumbing is not included here. +- PR4 private-fact persistence or grants are not included here. diff --git a/docs/openapi.json b/docs/openapi.json index 43db22f66b..dc068bd606 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -4619,7 +4619,6 @@ } }, "required": [ - "inviteCode", "name" ] } @@ -5146,6 +5145,39 @@ } } }, + "/api/hermes/group-chat/rooms/{roomId}/members/me": { + "delete": { + "tags": [ + "Group Chat" + ], + "summary": "Delete me", + "description": "DELETE /api/hermes/group-chat/rooms/:roomId/members/me", + "operationId": "deleteMe", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + }, + "parameters": [ + { + "name": "roomId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/api/hermes/group-chat/rooms/{roomId}/workspace": { "put": { "tags": [ diff --git a/packages/client/src/api/client.ts b/packages/client/src/api/client.ts index e9ca407200..6b31497414 100644 --- a/packages/client/src/api/client.ts +++ b/packages/client/src/api/client.ts @@ -156,26 +156,47 @@ function responseErrorMessage(text: string, statusText: string): string { } } +function setHeaderCaseInsensitive(headers: Record, name: string, value: string): void { + const normalizedName = name.toLowerCase() + for (const existingName of Object.keys(headers)) { + if (existingName !== name && existingName.toLowerCase() === normalizedName) { + delete headers[existingName] + } + } + headers[name] = value +} + +function mergeRequestHeaders(headers: Record, source?: HeadersInit): void { + if (!source) return + if (source instanceof Headers) { + source.forEach((value, name) => setHeaderCaseInsensitive(headers, name, value)) + return + } + const entries = Array.isArray(source) ? source : Object.entries(source) + for (const [name, value] of entries) { + setHeaderCaseInsensitive(headers, name, value) + } +} + export async function request(path: string, options: RequestInit = {}): Promise { await ensureDesktopAuthReady() const base = getBaseUrl() const url = `${base}${path}` const isFormDataBody = typeof FormData !== 'undefined' && options.body instanceof FormData - const headers: Record = { - ...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }), - ...options.headers as Record, - } + const headers: Record = {} + if (!isFormDataBody) setHeaderCaseInsensitive(headers, 'Content-Type', 'application/json') + mergeRequestHeaders(headers, options.headers) const apiKey = getApiKey() if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}` + setHeaderCaseInsensitive(headers, 'Authorization', `Bearer ${apiKey}`) } // Inject active profile header for request-scoped endpoints. Explicit profile // selectors in the URL/body and profile-name routes are validated directly. const profileName = getActiveProfileName() if (profileName && shouldAttachProfileHeader(path, options)) { - headers['X-Hermes-Profile'] = profileName + setHeaderCaseInsensitive(headers, 'X-Hermes-Profile', profileName) } const res = await fetch(url, { ...options, headers }) diff --git a/packages/client/src/api/hermes/binary-content.ts b/packages/client/src/api/hermes/binary-content.ts index 8b6552da07..b24e03963a 100644 --- a/packages/client/src/api/hermes/binary-content.ts +++ b/packages/client/src/api/hermes/binary-content.ts @@ -8,10 +8,11 @@ function errorMessage(value: unknown, fallback: string): string { export async function fetchAuthenticatedBlob( path: string, - options: { signal?: AbortSignal; profile?: string | null } = {}, + options: { signal?: AbortSignal; profile?: string | null; headers?: HeadersInit } = {}, ): Promise { await ensureDesktopAuthReady() const headers: Record = {} + if (options.headers) new Headers(options.headers).forEach((value, name) => { headers[name] = value }) const apiKey = getApiKey() if (apiKey) headers.Authorization = `Bearer ${apiKey}` const profile = typeof options.profile === 'string' && options.profile.trim() diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index 23ec2ebb4c..b1cde12969 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -9,6 +9,8 @@ export interface RoomInfo { name: string inviteCode: string | null canManage?: boolean + canApprove?: boolean + canLeave?: boolean triggerTokens?: number maxHistoryTokens?: number tailMessageCount?: number @@ -118,6 +120,7 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de const token = getApiKey() const userId = opts?.userId || localStorage.getItem('gc_user_id') || generateUUID() + const localCredential = localStorage.getItem('gc_local_credential') || undefined if (!opts?.userId) localStorage.setItem('gc_user_id', userId) socket = io('/group-chat', { @@ -127,6 +130,7 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de name: opts?.userName || localStorage.getItem('gc_user_name') || undefined, description: opts?.description || localStorage.getItem('gc_user_description') || undefined, authUserId: opts?.authUserId, + localCredential, }, transports: ['websocket', 'polling'], reconnection: true, @@ -135,6 +139,7 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de reconnectionDelayMax: 30000, randomizationFactor: 0.5, timeout: 30000, + autoConnect: false, }) return socket @@ -176,6 +181,25 @@ export function disconnectGroupChat(): void { // ─── REST API ─────────────────────────────────────────────── +function localCredentialHeaders(headers?: HeadersInit): Record { + const result: Record = {} + if (headers) new Headers(headers).forEach((value, name) => { result[name] = value }) + const localCredential = localStorage.getItem('gc_local_credential') || '' + if (localCredential) result['X-Group-Chat-Local-Credential'] = localCredential + return result +} + +function groupChatRequest(path: string, options: RequestInit = {}): Promise { + return request(path, { + ...options, + headers: localCredentialHeaders(options.headers), + }) +} + +function groupChatBlobOptions(signal?: AbortSignal): { signal?: AbortSignal; headers: Record } { + return { signal, headers: localCredentialHeaders() } +} + export async function createRoom(data: { name: string inviteCode: string @@ -185,7 +209,7 @@ export async function createRoom(data: { compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number } workspace?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { - return request('/api/hermes/group-chat/rooms', { + return groupChatRequest('/api/hermes/group-chat/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -193,7 +217,7 @@ export async function createRoom(data: { } export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data || {}), @@ -201,7 +225,7 @@ export async function cloneRoom(roomId: string, data?: { name?: string; inviteCo } export async function listRooms(): Promise<{ rooms: RoomInfo[] }> { - return request('/api/hermes/group-chat/rooms') + return groupChatRequest('/api/hermes/group-chat/rooms') } export async function getRoomDetail( @@ -212,15 +236,15 @@ export async function getRoomDetail( if (options.offset != null) params.set('offset', String(options.offset)) if (options.limit != null) params.set('limit', String(options.limit)) const query = params.toString() - return request(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`) + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`) } export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> { - return request(`/api/hermes/group-chat/rooms/join/${code}`) + return groupChatRequest(`/api/hermes/group-chat/rooms/join/${encodeURIComponent(code)}`) } export async function updateInviteCode(roomId: string, inviteCode: string): Promise<{ success: boolean }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/invite-code`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/invite-code`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteCode }), @@ -233,7 +257,7 @@ export async function addAgent(roomId: string, data: { description?: string invited?: boolean }): Promise<{ agent: RoomAgent }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/agents`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -241,29 +265,35 @@ export async function addAgent(roomId: string, data: { } export async function listAgents(roomId: string): Promise<{ agents: RoomAgent[] }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/agents`) + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/agents`) } export async function removeAgent(roomId: string, agentId: string): Promise<{ success: boolean; agents: RoomAgent[]; members: MemberInfo[] }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, { method: 'DELETE', }) } export async function deleteRoom(roomId: string): Promise { - return request(`/api/hermes/group-chat/rooms/${roomId}`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}`, { + method: 'DELETE', + }) +} + +export async function leaveRoom(roomId: string): Promise<{ success: boolean; left?: boolean }> { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/members/me`, { method: 'DELETE', }) } export async function clearRoomContext(roomId: string): Promise<{ success: boolean; room: RoomInfo }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, { method: 'POST', }) } export async function updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): Promise<{ room: RoomInfo }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/config`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -271,7 +301,7 @@ export async function updateRoomConfig(roomId: string, config: { triggerTokens?: } export async function updateRoomWorkspace(roomId: string, workspace: string): Promise<{ room: RoomInfo }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/workspace`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/workspace`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workspace }), @@ -279,7 +309,7 @@ export async function updateRoomWorkspace(roomId: string, workspace: string): Pr } export async function forceCompress(roomId: string): Promise<{ success: boolean; summary: string }> { - return request(`/api/hermes/group-chat/rooms/${roomId}/compress`, { + return groupChatRequest(`/api/hermes/group-chat/rooms/${roomId}/compress`, { method: 'POST', }) } @@ -292,19 +322,19 @@ export async function listGroupWorkspaceFiles(roomId: string, path = ''): Promis const params = new URLSearchParams() if (path) params.set('path', path) const query = params.toString() - return request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-files/list${query ? `?${query}` : ''}`) + return groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-files/list${query ? `?${query}` : ''}`) } export async function readGroupWorkspaceFile(roomId: string, path: string): Promise<{ content: string; path: string; size: number }> { const params = new URLSearchParams({ path }) - return request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/read?${params}`) + return groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/read?${params}`) } export async function fetchGroupWorkspaceFileBlob(roomId: string, path: string, signal?: AbortSignal): Promise { const params = new URLSearchParams({ path }) return fetchAuthenticatedBlob( `/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/content?${params}`, - { signal }, + groupChatBlobOptions(signal), ) } @@ -312,7 +342,7 @@ export async function fetchGroupWorkspaceAttachmentBlob(roomId: string, path: st const params = new URLSearchParams({ path, download: '1' }) return fetchAuthenticatedBlob( `/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/content?${params}`, - { signal }, + groupChatBlobOptions(signal), ) } @@ -320,6 +350,7 @@ export async function fetchGroupWorkspaceFileText(roomId: string, path: string): const params = new URLSearchParams({ path, text: '1' }) const blob = await fetchAuthenticatedBlob( `/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/content?${params}`, + groupChatBlobOptions(), ) return { content: await blob.text(), size: blob.size } } @@ -328,36 +359,37 @@ export async function downloadGroupWorkspaceFile(roomId: string, path: string, f const params = new URLSearchParams({ path, download: '1' }) const blob = await fetchAuthenticatedBlob( `/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/content?${params}`, + groupChatBlobOptions(), ) saveBlob(blob, fileName) } export async function writeGroupWorkspaceFile(roomId: string, path: string, content: string): Promise { - await request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/write`, { + await groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/write`, { method: 'PUT', body: JSON.stringify({ path, content }), }) } export async function mkdirGroupWorkspaceFile(roomId: string, path: string): Promise { - await request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/mkdir`, { + await groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/mkdir`, { method: 'POST', body: JSON.stringify({ path }), }) } export async function deleteGroupWorkspaceFile(roomId: string, path: string, recursive = false): Promise { - await request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/delete`, { + await groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/delete`, { method: 'DELETE', body: JSON.stringify({ path, recursive }), }) } export async function renameGroupWorkspaceFile(roomId: string, oldPath: string, newPath: string): Promise { - await request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/rename`, { + await groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/rename`, { method: 'POST', body: JSON.stringify({ oldPath, newPath }), }) } export async function copyGroupWorkspaceFile(roomId: string, srcPath: string, destPath: string): Promise { - await request(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/copy`, { + await groupChatRequest(`/api/hermes/group-chat/rooms/${encodeURIComponent(roomId)}/workspace-file/copy`, { method: 'POST', body: JSON.stringify({ srcPath, destPath }), }) } diff --git a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue index e9cce0c67d..2b233c5b78 100644 --- a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue +++ b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui' import { getStoredUsername } from '@/api/client' import FolderPicker from '@/components/hermes/chat/FolderPicker.vue' +import { generateGroupChatInviteCode, groupChatInviteCodeForCreate } from '@/utils/group-chat-invite' type InputLikeInstance = { focus: () => void @@ -29,17 +30,12 @@ const compression = ref({ }) function generateCode(): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - let code = '' - for (let i = 0; i < 6; i++) { - code += chars[Math.floor(Math.random() * chars.length)] - } - return code + return generateGroupChatInviteCode() } function handleCreate() { const name = roomName.value.trim() - const code = inviteCode.value.trim() || generateCode() + const code = groupChatInviteCodeForCreate(inviteCode.value) const user = userName.value.trim() if (!name || !user) return emit('submit', name, code, user, description.value.trim(), { ...compression.value }, workspace.value || '') diff --git a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue index 26bd8f6dbb..5ae75a02d4 100644 --- a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue +++ b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue @@ -14,6 +14,7 @@ import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue' import PageSidebarNav from '@/components/layout/PageSidebarNav.vue' import SettingsCircuitBadge from '@/components/layout/SettingsCircuitBadge.vue' import { copyToClipboard } from '@/utils/clipboard' +import { generateGroupChatInviteCode, groupChatInviteCodeForClone } from '@/utils/group-chat-invite' import type { Attachment } from '@/stores/hermes/chat' import type { RoomAgent, RoomInfo } from '@/api/hermes/group-chat' import { useFilesStore } from '@/stores/hermes/files' @@ -49,6 +50,9 @@ const showUserProfileModal = ref(false) const userProfileName = ref('') const userProfileDescription = ref('') const isSavingUserProfile = ref(false) +const joinInviteCode = ref('') +const isJoiningByInviteCode = ref(false) +const leavingRoomIds = ref>(new Set()) const compressionConfig = ref({ triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10 }) const isCompressing = ref(false) const inviteCodeDraft = ref('') @@ -99,13 +103,21 @@ const contextRoom = computed(() => store.rooms.find(room => room.id === contextR function canManageRoom(room: Pick | null | undefined): boolean { return room?.canManage === true } +function canApproveRoom(room: Pick | null | undefined): boolean { + return room?.canApprove === true +} const currentRoomCanManage = computed(() => canManageRoom(currentRoom.value)) -const visibleApproval = computed(() => currentRoomCanManage.value ? store.activePendingApproval : null) +const currentRoomCanApprove = computed(() => canApproveRoom(currentRoom.value)) +const visibleApproval = computed(() => currentRoomCanApprove.value ? store.activePendingApproval : null) const currentWorkspaceLabel = computed(() => workspaceBasename(currentRoom.value?.workspace || '')) const canUpdateInviteCode = computed(() => { - const nextCode = inviteCodeDraft.value.trim() - return currentRoomCanManage.value && !isSavingInviteCode.value && !!nextCode && nextCode !== (currentRoom.value?.inviteCode || '') + const nextCode = inviteCodeDraft.value + return currentRoomCanManage.value && !isSavingInviteCode.value && !!nextCode.trim() && nextCode !== (currentRoom.value?.inviteCode || '') }) +const canJoinByInviteCode = computed(() => !!joinInviteCode.value.trim() && !isJoiningByInviteCode.value) +function isLeavingRoom(roomId: string): boolean { + return leavingRoomIds.value.has(roomId) +} const showWorkspaceModal = ref(false) const workspaceRoomId = ref(null) const workspaceValue = ref('') @@ -312,12 +324,7 @@ function handleWorkspaceFileAttach(file: File) { } function generateCode(): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - let code = '' - for (let i = 0; i < 6; i++) { - code += chars[Math.floor(Math.random() * chars.length)] - } - return code + return generateGroupChatInviteCode() } function formatAgentFailures(results?: Array<{ ok: boolean; profile: string; error?: string; reason?: string }>): string | null { @@ -363,6 +370,22 @@ async function handleCreateRoom(name: string, inviteCode: string, userName: stri } } +async function handleJoinByInviteCode() { + const code = joinInviteCode.value + if (!code.trim() || isJoiningByInviteCode.value) return + isJoiningByInviteCode.value = true + try { + const room = await store.joinByCode(code) + joinInviteCode.value = '' + message.success(t('groupChat.joined')) + await router.push({ name: 'hermes.groupChatRoom', params: { roomId: room.id } }) + } catch (err: any) { + message.error(err?.message || t('groupChat.joinFailed')) + } finally { + isJoiningByInviteCode.value = false + } +} + async function handleDeleteRoom(roomId: string) { const room = store.rooms.find(r => r.id === roomId) if (!canManageRoom(room)) return @@ -377,6 +400,25 @@ async function handleDeleteRoom(roomId: string) { } } +async function handleLeaveRoom(roomId: string) { + if (isLeavingRoom(roomId)) return + const wasCurrentRoom = store.currentRoomId === roomId + leavingRoomIds.value = new Set([...leavingRoomIds.value, roomId]) + try { + await store.leaveRoom(roomId) + if (wasCurrentRoom) { + await router.replace({ name: 'hermes.groupChat' }) + } + message.success(t('groupChat.roomLeft')) + } catch (err: any) { + message.error(err?.message || t('groupChat.leaveRoomFailed')) + } finally { + const next = new Set(leavingRoomIds.value) + next.delete(roomId) + leavingRoomIds.value = next + } +} + function buildRoomUrl(roomId: string) { const href = router.resolve({ name: 'hermes.groupChatRoom', params: { roomId } }).href return `${window.location.origin}${window.location.pathname}${href}` @@ -388,6 +430,14 @@ async function copyRoomLink(roomId: string) { else message.error(t('common.copied') + ' ✗') } +async function handleCopyInviteCode() { + const code = inviteCodeDraft.value + if (!code.trim()) return + const ok = await copyToClipboard(code) + if (ok) message.success(t('groupChat.inviteCodeCopied')) + else message.error(t('groupChat.inviteCodeCopyFailed')) +} + const roomContextMenuOptions = computed(() => { const options: DropdownOption[] = [{ label: t('groupChat.copyRoomLink'), key: 'copy-link' }] if (canManageRoom(contextRoom.value)) { @@ -438,7 +488,7 @@ async function confirmCloneRoom() { try { const res = await store.cloneRoom(cloneSourceRoomId.value, { name: cloneRoomName.value.trim(), - inviteCode: cloneInviteCode.value.trim() || undefined, + inviteCode: groupChatInviteCodeForClone(cloneInviteCode.value), }) showCloneModal.value = false cloneSourceRoomId.value = null @@ -629,7 +679,7 @@ function handleOpenRoomSettings() { async function handleSaveInviteCode() { if (!store.currentRoomId || !currentRoomCanManage.value || isSavingInviteCode.value || !canUpdateInviteCode.value) return - const nextCode = inviteCodeDraft.value.trim() + const nextCode = inviteCodeDraft.value isSavingInviteCode.value = true try { await store.setRoomInviteCode(store.currentRoomId, nextCode) @@ -694,7 +744,7 @@ async function handleInterruptAgent(agentName: string) { } async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') { - if (!currentRoomCanManage.value) return + if (!currentRoomCanApprove.value) return try { await store.respondApproval(choice) } catch (err: any) { @@ -717,6 +767,23 @@ async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') { @primary="showCreateModal = true" /> +
+ + + {{ t('groupChat.joinByCode') }} + +
{{ room.inviteCode }} {{ formatTokens(room.totalTokens || 0) }}
+ + + {{ t('groupChat.leaveRoomConfirm') }} + @@ -1200,6 +1286,12 @@ async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') { + + + + + + { const hasReachedMessageDisplayLimit = computed(() => hasMoreBefore.value && loadedMessageCount.value >= GROUP_CHAT_MAX_DISPLAY_MESSAGES, ) -const currentUserAvatar = ref('') + const currentUserAvatar = ref('') + let usesAuthenticatedInviteSubject = false + let localIdentityReady: Promise | null = null + let resolveLocalIdentityReady: (() => void) | null = null + let currentLocalCredential = '' + let connectionSetup: Promise | null = null + let connectionGeneration = 0 + const realtimeSocketWaiterCancels = new Set<() => void>() + + function resetLocalIdentityReadiness(authenticated: boolean): void { + usesAuthenticatedInviteSubject = authenticated + currentLocalCredential = '' + resolveLocalIdentityReady = null + localIdentityReady = authenticated + ? Promise.resolve() + : new Promise((resolve) => { + resolveLocalIdentityReady = resolve + }) + } + + function acceptServerUserId(value: unknown): void { + const serverUserId = typeof value === 'string' ? value.trim() : '' + if (!serverUserId) return + userId.value = serverUserId + localStorage.setItem('gc_user_id', serverUserId) + } + + function acceptLocalIdentity(data: { localCredential?: string; userId?: string }): void { + const localCredential = typeof data?.localCredential === 'string' ? data.localCredential : '' + if (!localCredential) return + currentLocalCredential = localCredential + localStorage.setItem('gc_local_credential', localCredential) + acceptServerUserId(data.userId) + resolveLocalIdentityReady?.() + resolveLocalIdentityReady = null + } + + async function waitForInviteAttemptSubject(socket: GroupChatSocket, generation: number): Promise { + if (generation !== connectionGeneration) { + throw new Error('Group chat connection setup was cancelled') + } + if (usesAuthenticatedInviteSubject) return + if (!localIdentityReady) throw new Error('Group chat local identity is not initialized') + if (currentLocalCredential) { + localStorage.setItem('gc_local_credential', currentLocalCredential) + return + } + + await new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | null = null + const cleanup = () => { + if (timeout) clearTimeout(timeout) + timeout = null + socket.off?.('local_identity', onLocalIdentity) + socket.off?.('disconnect', onDisconnect) + realtimeSocketWaiterCancels.delete(cancel) + } + const finish = (callback: () => void) => { + if (settled) return + settled = true + cleanup() + callback() + } + const cancel = () => finish(() => reject(new Error('Group chat connection setup was cancelled'))) + const onDisconnect = () => cancel() + const onLocalIdentity = (data: { localCredential?: string; userId?: string }) => { + if (generation !== connectionGeneration) { + cancel() + return + } + acceptLocalIdentity(data) + if (currentLocalCredential) finish(resolve) + } + + realtimeSocketWaiterCancels.add(cancel) + socket.on('local_identity', onLocalIdentity) + socket.on('disconnect', onDisconnect) + timeout = setTimeout( + () => finish(() => reject(new Error('Group chat local identity timed out'))), + 30000, + ) + if (generation !== connectionGeneration) cancel() + }) + + if (generation !== connectionGeneration) { + throw new Error('Group chat connection setup was cancelled') + } + if (!currentLocalCredential) { + throw new Error('Group chat local identity is unavailable') + } + localStorage.setItem('gc_local_credential', currentLocalCredential) + } function resetMessagePaging() { totalMessages.value = 0 @@ -248,6 +341,7 @@ const currentUserAvatar = ref('') } function applyRealtimeJoinState(res: any, options: { syncMessages?: boolean } = {}) { + acceptServerUserId(res.currentUserId) members.value = res.members || [] if (res.agents) agents.value = res.agents if (res.roomName) roomName.value = res.roomName @@ -290,15 +384,18 @@ const currentUserAvatar = ref('') } } - async function waitForRealtimeSocket(socket: GroupChatSocket): Promise { + async function waitForRealtimeSocket(socket: GroupChatSocket, generation: number): Promise { + if (generation !== connectionGeneration) throw new Error('Group chat socket connection cancelled') if (socket.connected) return await new Promise((resolve, reject) => { let settled = false let timeout: ReturnType | null = null const cleanup = () => { if (timeout) clearTimeout(timeout) + realtimeSocketWaiterCancels.delete(cancel) socket.off?.('connect', onConnect) socket.off?.('connect_error', onError) + socket.off?.('disconnect', onDisconnect) } const finish = (fn: () => void) => { if (settled) return @@ -306,26 +403,46 @@ const currentUserAvatar = ref('') cleanup() fn() } - const onConnect = () => finish(resolve) + const cancel = () => finish(() => reject(new Error('Group chat socket connection cancelled'))) + const onConnect = () => { + if (generation !== connectionGeneration) { + cancel() + return + } + finish(resolve) + } const onError = (err: Error) => finish(() => reject(err)) + const onDisconnect = () => cancel() timeout = setTimeout(() => finish(() => reject(new Error('Group chat socket connection timed out'))), 30000) - socket.once('connect', onConnect) - socket.once('connect_error', onError) + realtimeSocketWaiterCancels.add(cancel) + socket.on('connect', onConnect) + socket.on('connect_error', onError) + socket.on('disconnect', onDisconnect) + if (generation !== connectionGeneration) cancel() }) } async function ensureRealtimeSocket(): Promise { + const generation = connectionGeneration let socket = getSocket() - if (socket) return socket + if (socket && localIdentityReady) return socket await connect() + if (generation !== connectionGeneration) throw new Error('Group chat socket connection cancelled') socket = getSocket({ requireConnected: false }) if (!socket) throw new Error('Group chat socket not connected') - await waitForRealtimeSocket(socket) + await waitForRealtimeSocket(socket, generation) + if (generation !== connectionGeneration) throw new Error('Group chat socket connection cancelled') const connectedSocket = getSocket() if (!connectedSocket) throw new Error('Group chat socket not connected') return connectedSocket } + async function ensureRestSubject(): Promise { + const generation = connectionGeneration + const socket = await ensureRealtimeSocket() + await waitForInviteAttemptSubject(socket, generation) + } + async function joinRealtimeRoom(roomId: string, options: { syncMessages?: boolean; inviteCode?: string } = {}) { const socket = await ensureRealtimeSocket() // Browser storage is only a first-join default. Once the member row @@ -374,24 +491,51 @@ const currentUserAvatar = ref('') }) // ─── Connection ──────────────────────────────────────── - async function connect() { - let authUserId: number | undefined + async function setupConnection(generation: number) { + let authenticatedUser: Awaited> | null = null const connectionName = getStoredGroupUserName() try { - const user = await fetchCurrentUser() - authUserId = user.id - userId.value = authenticatedGroupUserId(user.id) - if (!connectionName) userName.value = user.username - currentUserAvatar.value = user.avatar || '' + authenticatedUser = await fetchCurrentUser() } catch { /* non-critical: avatar fallback handles missing id */ } + if (generation !== connectionGeneration) return + + const authUserId = authenticatedUser?.id + if (authenticatedUser) { + userId.value = authenticatedGroupUserId(authenticatedUser.id) + if (!connectionName) userName.value = authenticatedUser.username + currentUserAvatar.value = authenticatedUser.avatar || '' + } + const authenticatedInviteSubject = typeof authUserId === 'number' && authUserId > 0 const socket = connectGroupChat({ userId: userId.value, userName: connectionName || undefined, authUserId, }) + if (generation !== connectionGeneration) { + socket.disconnect() + disconnectGroupChat() + return + } + resetLocalIdentityReadiness(authenticatedInviteSubject) + if (!authenticatedInviteSubject && socket.connected) { + const currentAuth = typeof socket.auth === 'object' && socket.auth + ? socket.auth as Record + : {} + acceptLocalIdentity({ + localCredential: currentAuth.localIdentityVerified === true + && typeof currentAuth.localCredential === 'string' + ? currentAuth.localCredential + : undefined, + userId: currentAuth.localIdentityVerified === true + && typeof currentAuth.userId === 'string' + ? currentAuth.userId + : undefined, + }) + } console.log('[GroupChat] connecting...', { userId: userId.value, userName: userName.value }) socket.on('connect', () => { + if (!usesAuthenticatedInviteSubject) resetLocalIdentityReadiness(false) console.log('[GroupChat] connected, socket id:', socket.id) connected.value = true error.value = null @@ -414,6 +558,19 @@ const currentUserAvatar = ref('') connected.value = false }) + socket.on('local_identity', (data: { localCredential?: string; userId?: string }) => { + acceptLocalIdentity(data) + const localCredential = currentLocalCredential + if (!localCredential) return + const currentAuth = typeof socket.auth === 'object' && socket.auth ? socket.auth as Record : {} + socket.auth = { + ...currentAuth, + userId: userId.value, + localCredential, + localIdentityVerified: true, + } + }) + socket.on('message', (msg: ChatMessage) => { if (msg.roomId === currentRoomId.value) { const idx = messages.value.findIndex(m => m.id === msg.id) @@ -619,10 +776,36 @@ const currentUserAvatar = ref('') pendingApprovals.value.clear() } }) + + socket.connect() + } + + async function connect(): Promise { + if (connectionSetup) { + await connectionSetup + return + } + if (getSocket({ requireConnected: false }) && localIdentityReady) return + + const generation = connectionGeneration + const setup = setupConnection(generation) + connectionSetup = setup + try { + await setup + } finally { + if (connectionSetup === setup) connectionSetup = null + } } function disconnect() { + connectionGeneration += 1 + connectionSetup = null + for (const cancel of Array.from(realtimeSocketWaiterCancels)) cancel() disconnectGroupChat() + usesAuthenticatedInviteSubject = false + localIdentityReady = null + resolveLocalIdentityReady = null + currentLocalCredential = '' connected.value = false currentRoomId.value = null messages.value = [] @@ -673,6 +856,7 @@ const currentUserAvatar = ref('') error.value = null try { + await ensureRestSubject() const res = await getRoomDetail(roomId) upsertRoom(res.room) currentRoomId.value = res.room.id @@ -763,6 +947,7 @@ const currentUserAvatar = ref('') async function loadRooms() { try { + await ensureRestSubject() const res = await listRooms() rooms.value = res.rooms } catch (err: any) { @@ -779,6 +964,7 @@ const currentUserAvatar = ref('') memberProfile?: { name: string; description?: string }, ) { try { + await ensureRestSubject() const res = await createRoom({ name, inviteCode, @@ -798,9 +984,11 @@ const currentUserAvatar = ref('') async function joinByCode(code: string) { try { + const generation = connectionGeneration + const socket = await ensureRealtimeSocket() + await waitForInviteAttemptSubject(socket, generation) const res = await joinRoomByCode(code) upsertRoom(res.room) - await ensureRealtimeSocket() currentRoomId.value = res.room.id roomName.value = res.room.name await joinRealtimeRoom(res.room.id, { syncMessages: true, inviteCode: code }) @@ -814,6 +1002,7 @@ const currentUserAvatar = ref('') async function deleteRoom(roomId: string) { try { + await ensureRestSubject() await deleteRoomApi(roomId) rooms.value = rooms.value.filter(r => r.id !== roomId) clearMessageReference(roomId) @@ -831,8 +1020,33 @@ const currentUserAvatar = ref('') } } + async function leaveRoom(roomId: string) { + try { + await ensureRestSubject() + const res = await leaveRoomApi(roomId) + if (res.left === false) { + await loadRooms() + return + } + rooms.value = rooms.value.filter(r => r.id !== roomId) + clearMessageReference(roomId) + if (currentRoomId.value === roomId) { + currentRoomId.value = null + messages.value = [] + resetMessagePaging() + members.value = [] + agents.value = [] + roomName.value = '' + } + } catch (err: any) { + error.value = err.message + throw err + } + } + async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }) { try { + await ensureRestSubject() const res = await cloneRoomApi(roomId, data) upsertRoom(res.room) return res @@ -846,6 +1060,7 @@ const currentUserAvatar = ref('') if (!currentRoomId.value) return const roomId = currentRoomId.value try { + await ensureRestSubject() const res = await clearRoomContext(roomId) messages.value = [] clearMessageReference(roomId) @@ -863,6 +1078,7 @@ const currentUserAvatar = ref('') async function setRoomWorkspace(roomId: string, workspace: string) { try { + await ensureRestSubject() const res = await updateRoomWorkspaceApi(roomId, workspace) if (res.room) { upsertRoom(res.room) @@ -876,8 +1092,8 @@ const currentUserAvatar = ref('') } async function setRoomInviteCode(roomId: string, inviteCode: string) { - const nextCode = inviteCode.trim() - if (!nextCode) throw new Error('inviteCode is required') + const nextCode = inviteCode + if (!nextCode.trim()) throw new Error('inviteCode is required') try { await updateInviteCodeApi(roomId, nextCode) const room = rooms.value.find(r => r.id === roomId) @@ -895,6 +1111,7 @@ const currentUserAvatar = ref('') // ─── Agent Actions ───────────────────────────────────── async function loadAgents(roomId: string) { try { + await ensureRestSubject() const res = await listAgents(roomId) agents.value = res.agents } catch { /* ignore */ } @@ -902,6 +1119,7 @@ const currentUserAvatar = ref('') async function addAgentToRoom(roomId: string, data: { profile: string; name?: string; description?: string; invited?: boolean }) { try { + await ensureRestSubject() const res = await addAgent(roomId, data) agents.value.push(res.agent) return res.agent @@ -913,6 +1131,7 @@ const currentUserAvatar = ref('') async function removeAgentFromRoom(roomId: string, agentId: string) { try { + await ensureRestSubject() const res = await removeAgent(roomId, agentId) agents.value = res.agents ?? agents.value.filter(a => a.id !== agentId && a.agentId !== agentId) if (res.members) members.value = res.members @@ -1018,6 +1237,7 @@ const currentUserAvatar = ref('') createNewRoom, joinByCode, deleteRoom, + leaveRoom, cloneRoom, clearCurrentRoomContext, setRoomWorkspace, diff --git a/packages/client/src/utils/group-chat-invite.ts b/packages/client/src/utils/group-chat-invite.ts new file mode 100644 index 0000000000..c6f88bf959 --- /dev/null +++ b/packages/client/src/utils/group-chat-invite.ts @@ -0,0 +1,20 @@ +const INVITE_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +export const GROUP_CHAT_INVITE_CODE_LENGTH = 16 + +export function generateGroupChatInviteCode(): string { + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + throw new Error('Secure random number generation is unavailable') + } + const random = new Uint8Array(GROUP_CHAT_INVITE_CODE_LENGTH) + cryptoApi.getRandomValues(random) + return Array.from(random, (value) => INVITE_CODE_ALPHABET[value & 31]).join('') +} + +export function groupChatInviteCodeForCreate(explicitCode: string): string { + return explicitCode === '' ? generateGroupChatInviteCode() : explicitCode +} + +export function groupChatInviteCodeForClone(explicitCode: string): string | undefined { + return explicitCode === '' ? undefined : explicitCode +} diff --git a/packages/server/src/controllers/auth.ts b/packages/server/src/controllers/auth.ts index 56d8c4166c..582c473eb4 100644 --- a/packages/server/src/controllers/auth.ts +++ b/packages/server/src/controllers/auth.ts @@ -23,6 +23,7 @@ import { type UserStatus, } from '../db/hermes/users-store' import { issueUserJwt } from '../middleware/user-auth' +import { getGroupChatRuntimeServer } from '../services/hermes/group-chat/runtime' import { listProfileNamesFromDisk } from '../services/hermes/hermes-profile' import { startOutboundRelayClient, stopOutboundRelayClient } from '../services/global-agent/outbound-relay-client' import { getLanEndpointKind } from '../services/lan-discovery' @@ -611,7 +612,7 @@ export async function updateManagedUser(ctx: Context) { } } - updateUser({ + const updated = updateUser({ userId: user.id, username, password: password || undefined, @@ -620,6 +621,9 @@ export async function updateManagedUser(ctx: Context) { profiles: nextRole === 'super_admin' ? [] : profiles, defaultProfile: body.defaultProfile, }) + if (updated?.status === 'disabled') { + getGroupChatRuntimeServer()?.revokeAuthenticatedUser(user.id) + } ctx.body = { user: findUserById(user.id), users: listUsers() } } @@ -647,6 +651,7 @@ export async function deleteManagedUser(ctx: Context) { return } + getGroupChatRuntimeServer()?.revokeAuthenticatedUser(user.id) deleteUser(user.id) ctx.body = { success: true, users: listUsers() } } diff --git a/packages/server/src/controllers/hermes/group-chat-workspace.ts b/packages/server/src/controllers/hermes/group-chat-workspace.ts index 9fdd009dcb..1d4f4b6145 100644 --- a/packages/server/src/controllers/hermes/group-chat-workspace.ts +++ b/packages/server/src/controllers/hermes/group-chat-workspace.ts @@ -1,6 +1,10 @@ import { basename, resolve as pathResolve } from 'path' import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises' -import { canManageGroupChatRoom } from '../../services/hermes/group-chat/access' +import { + loadActiveAuthenticatedUser, + type AuthenticatedUser, +} from '../../middleware/user-auth' +import { canManageGroupChatRoom, canReadGroupChatRoom } from '../../services/hermes/group-chat/access' import { getGroupChatRuntimeServer } from '../../services/hermes/group-chat/runtime' import { groupWorkspaceRelativePath, @@ -10,15 +14,58 @@ import { isSensitivePath, MAX_DOWNLOAD_SIZE, MAX_EDIT_SIZE } from '../../service import { buildFileContentHeaders, getFilePreviewDescriptor } from '../../services/hermes/file-preview' import { defaultHermesWorkspace } from '../../services/hermes/run-chat/workspace' -function managedRoom(ctx: any): { room: any; storage: ReturnType>['getStorage']> } { +type GroupChatWorkspaceStorage = ReturnType>['getStorage']> + +type ManagedRoom = { + room: NonNullable> + storage: GroupChatWorkspaceStorage +} + +type ManagedRoomContext = { + params: { roomId: string } + state?: { + user?: AuthenticatedUser + groupChatLocalSubjectId?: string + } +} + +function authorizedWorkspaceRoots({ room, storage }: ManagedRoom): string[] { + return [ + String(room.workspace || '').trim(), + ...storage.getRoomAgents(room.id).map(agent => defaultHermesWorkspace(String(agent.profile || 'default'))), + ].filter((root, index, all) => root && all.indexOf(root) === index) +} + +function managedRoom(ctx: ManagedRoomContext, expectedWorkspaceRoot?: string): ManagedRoom { const server = getGroupChatRuntimeServer() if (!server) throw Object.assign(new Error('Group chat not initialized'), { status: 503, code: 'group_chat_unavailable' }) const storage = server.getStorage() const room = storage.getRoom(ctx.params.roomId) if (!room) throw Object.assign(new Error('Room not found'), { status: 404, code: 'not_found' }) - if (!canManageGroupChatRoom(storage, room.id, ctx.state?.user)) { + const state = ctx.state || {} + if (state.user) { + const userId = Number(state.user.id) + const currentUser = Number.isInteger(userId) && userId > 0 + ? loadActiveAuthenticatedUser(userId) + : null + if (currentUser) state.user = currentUser + else { + delete state.user + delete state.groupChatLocalSubjectId + } + } + const localSubjectId = typeof state.groupChatLocalSubjectId === 'string' + ? state.groupChatLocalSubjectId + : null + if (!canManageGroupChatRoom(storage, room.id, state.user, localSubjectId)) { + if (!canReadGroupChatRoom(storage, room.id, state.user, localSubjectId)) { + throw Object.assign(new Error('Room not found'), { status: 404, code: 'not_found' }) + } throw Object.assign(new Error('Access denied'), { status: 403, code: 'permission_denied' }) } + if (expectedWorkspaceRoot && !authorizedWorkspaceRoots({ room, storage }).includes(expectedWorkspaceRoot)) { + throw Object.assign(new Error('Workspace authorization changed'), { status: 403, code: 'permission_denied' }) + } return { room, storage } } @@ -43,11 +90,8 @@ async function resolveRoomPreviewPath(ctx: any, path: unknown) { const isAbsolute = rawPath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(rawPath) if (!isAbsolute) return resolveRoomPath(ctx, path) - const { room, storage } = managedRoom(ctx) - const roots = [ - String(room.workspace || '').trim(), - ...storage.getRoomAgents(room.id).map(agent => defaultHermesWorkspace(String(agent.profile || 'default'))), - ].filter((root, index, all) => root && all.indexOf(root) === index) + const managed = managedRoom(ctx) + const roots = authorizedWorkspaceRoots(managed) for (const root of roots) { try { return await resolveGroupWorkspacePath(root, rawPath, { allowAbsolute: true }) @@ -61,9 +105,12 @@ async function resolveRoomPreviewPath(ctx: any, path: unknown) { export async function listWorkspaceFiles(ctx: any): Promise { try { const { relativePath, fullPath, workspace } = await resolveRoomPath(ctx, ctx.query.path, { allowEmpty: true }) + managedRoom(ctx, workspace) const info = await stat(fullPath) + managedRoom(ctx, workspace) if (!info.isDirectory()) throw Object.assign(new Error('Not a directory'), { status: 400, code: 'not_a_directory' }) const dirEntries = await readdir(fullPath, { withFileTypes: true }) + managedRoom(ctx, workspace) const entries = await Promise.all(dirEntries.map(async entry => { const entryFullPath = pathResolve(fullPath, entry.name) const entryStat = await stat(entryFullPath) @@ -76,6 +123,7 @@ export async function listWorkspaceFiles(ctx: any): Promise { modTime: entryStat.mtime.toISOString(), } })) + managedRoom(ctx, workspace) entries.sort((a, b) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1) ctx.body = { entries, path: relativePath, absolutePath: fullPath } } catch (error) { @@ -85,11 +133,14 @@ export async function listWorkspaceFiles(ctx: any): Promise { export async function readWorkspaceFile(ctx: any): Promise { try { - const { relativePath, fullPath } = await resolveRoomPath(ctx, ctx.query.path) + const { relativePath, fullPath, workspace } = await resolveRoomPath(ctx, ctx.query.path) + managedRoom(ctx, workspace) const info = await stat(fullPath) + managedRoom(ctx, workspace) if (!info.isFile()) throw Object.assign(new Error('Not a file'), { status: 400, code: 'not_a_file' }) if (info.size > MAX_EDIT_SIZE) throw Object.assign(new Error('File too large to edit'), { status: 413, code: 'file_too_large' }) const data = await readFile(fullPath) + managedRoom(ctx, workspace) ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length } } catch (error) { handleWorkspaceError(ctx, error) @@ -98,8 +149,10 @@ export async function readWorkspaceFile(ctx: any): Promise { export async function readWorkspaceFileContent(ctx: any): Promise { try { - const { relativePath, fullPath } = await resolveRoomPreviewPath(ctx, ctx.query.path) + const { relativePath, fullPath, workspace } = await resolveRoomPreviewPath(ctx, ctx.query.path) + managedRoom(ctx, workspace) const info = await stat(fullPath) + managedRoom(ctx, workspace) if (!info.isFile()) throw Object.assign(new Error('Not a file'), { status: 400, code: 'not_a_file' }) const download = String(ctx.query?.download || '') === '1' @@ -113,6 +166,7 @@ export async function readWorkspaceFileContent(ctx: any): Promise { throw Object.assign(new Error(download ? 'File too large to download' : 'File too large to preview'), { status: 413, code: 'file_too_large' }) } const data = await readFile(fullPath) + managedRoom(ctx, workspace) if (data.length > maxBytes) { throw Object.assign(new Error(download ? 'File too large to download' : 'File too large to preview'), { status: 413, code: 'file_too_large' }) } @@ -132,11 +186,13 @@ export async function readWorkspaceFileContent(ctx: any): Promise { export async function writeWorkspaceFile(ctx: any): Promise { try { const body = ctx.request.body as { path?: unknown; content?: unknown } - const { relativePath, fullPath } = await resolveRoomPath(ctx, body?.path) + const { relativePath, fullPath, workspace } = await resolveRoomPath(ctx, body?.path) + managedRoom(ctx, workspace) if (isSensitivePath(relativePath)) throw Object.assign(new Error('Cannot modify sensitive file'), { status: 403, code: 'permission_denied' }) const data = Buffer.from(typeof body?.content === 'string' ? body.content : '', 'utf-8') if (data.length > MAX_EDIT_SIZE) throw Object.assign(new Error('Content too large'), { status: 413, code: 'file_too_large' }) await writeFile(fullPath, data) + managedRoom(ctx, workspace) ctx.body = { ok: true, path: relativePath } } catch (error) { handleWorkspaceError(ctx, error) @@ -145,8 +201,10 @@ export async function writeWorkspaceFile(ctx: any): Promise { export async function mkdirWorkspaceFile(ctx: any): Promise { try { - const { fullPath } = await resolveRoomPath(ctx, (ctx.request.body as { path?: unknown })?.path) + const { fullPath, workspace } = await resolveRoomPath(ctx, (ctx.request.body as { path?: unknown })?.path) + managedRoom(ctx, workspace) await mkdir(fullPath, { recursive: true }) + managedRoom(ctx, workspace) ctx.body = { ok: true } } catch (error) { handleWorkspaceError(ctx, error) @@ -156,10 +214,13 @@ export async function mkdirWorkspaceFile(ctx: any): Promise { export async function deleteWorkspaceFile(ctx: any): Promise { try { const body = ctx.request.body as { path?: unknown; recursive?: unknown } - const { relativePath, fullPath } = await resolveRoomPath(ctx, body?.path) + const { relativePath, fullPath, workspace } = await resolveRoomPath(ctx, body?.path) + managedRoom(ctx, workspace) if (isSensitivePath(relativePath)) throw Object.assign(new Error('Cannot delete sensitive file'), { status: 403, code: 'permission_denied' }) const info = await stat(fullPath) + managedRoom(ctx, workspace) await rm(fullPath, info.isDirectory() ? { recursive: Boolean(body?.recursive), force: false } : undefined) + managedRoom(ctx, workspace) ctx.body = { ok: true } } catch (error) { handleWorkspaceError(ctx, error) @@ -170,11 +231,17 @@ export async function renameWorkspaceFile(ctx: any): Promise { try { const body = ctx.request.body as { oldPath?: unknown; newPath?: unknown } const oldTarget = await resolveRoomPath(ctx, body?.oldPath) + managedRoom(ctx, oldTarget.workspace) const newTarget = await resolveRoomPath(ctx, body?.newPath) + managedRoom(ctx, newTarget.workspace) if (isSensitivePath(oldTarget.relativePath) || isSensitivePath(newTarget.relativePath)) { throw Object.assign(new Error('Cannot rename sensitive file'), { status: 403, code: 'permission_denied' }) } + managedRoom(ctx, oldTarget.workspace) + managedRoom(ctx, newTarget.workspace) await rename(oldTarget.fullPath, newTarget.fullPath) + managedRoom(ctx, oldTarget.workspace) + managedRoom(ctx, newTarget.workspace) ctx.body = { ok: true } } catch (error) { handleWorkspaceError(ctx, error) @@ -185,11 +252,17 @@ export async function copyWorkspaceFile(ctx: any): Promise { try { const body = ctx.request.body as { srcPath?: unknown; destPath?: unknown } const source = await resolveRoomPath(ctx, body?.srcPath) + managedRoom(ctx, source.workspace) const destination = await resolveRoomPath(ctx, body?.destPath) + managedRoom(ctx, destination.workspace) if (isSensitivePath(destination.relativePath)) throw Object.assign(new Error('Cannot overwrite sensitive file'), { status: 403, code: 'permission_denied' }) const info = await stat(source.fullPath) + managedRoom(ctx, source.workspace) + managedRoom(ctx, destination.workspace) if (!info.isFile()) throw Object.assign(new Error('Not a file'), { status: 400, code: 'not_a_file' }) await copyFile(source.fullPath, destination.fullPath) + managedRoom(ctx, source.workspace) + managedRoom(ctx, destination.workspace) ctx.body = { ok: true } } catch (error) { handleWorkspaceError(ctx, error) diff --git a/packages/server/src/db/hermes/group-chat-identity-migration.ts b/packages/server/src/db/hermes/group-chat-identity-migration.ts new file mode 100644 index 0000000000..9a87fb1694 --- /dev/null +++ b/packages/server/src/db/hermes/group-chat-identity-migration.ts @@ -0,0 +1,249 @@ +import { randomBytes } from 'node:crypto' +import type { DatabaseSync } from 'node:sqlite' + +import { + backfillGroupChatActors, + validateBackfilledGroupChatActors, +} from '../../services/hermes/group-chat/identity/actor-store' +import { GROUP_CHAT_IDENTITY_READER_EPOCH } from '../../services/hermes/group-chat/identity/types' +import { assertHermesDatabaseOwnership } from '../ownership' + +const SCHEMA_STATE_TABLE = 'gc_schema_state' +const SCHEMA_NAME = 'groupChatIdentityV1' +const SCHEMA_VERSION = 1 +const SCHEMA_STATUS_COMPLETE = 'complete' +const SCHEMA_STATUS_PENDING = 'pending' +const ACTOR_TABLE = 'gc_room_actors' +const CAPABILITY_TABLE = 'gc_room_actor_capabilities' +const ROOM_SESSION_SEED_RE = /^[0-9A-Fa-f]{32}$/ + +type TransactionalDatabase = DatabaseSync & { + readonly inTransaction?: boolean + readonly isTransaction?: boolean +} + +export type GroupChatIdentityMigrationFailpoint = 'after-backfill' | null + +let groupChatIdentityMigrationFailpoint: GroupChatIdentityMigrationFailpoint = null + +function tableExists(db: DatabaseSync, tableName: string): boolean { + return Boolean(db.prepare( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?` + ).get(tableName)) +} + +function rowCount(db: DatabaseSync, tableName: string): number { + return (db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get() as { count: number }).count +} + +function createSchemaStateTable(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS ${SCHEMA_STATE_TABLE} ( + schema_name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + status TEXT NOT NULL, + min_reader_epoch INTEGER NOT NULL, + applied_at INTEGER NOT NULL, + rollback_marker TEXT NOT NULL + ) + `) +} + +function createActorTables(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS ${ACTOR_TABLE} ( + id TEXT PRIMARY KEY, + roomId TEXT NOT NULL, + actorType TEXT NOT NULL, + authUserId INTEGER, + agentId TEXT, + localSubjectId TEXT, + systemKey TEXT, + name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + authorizationRevision INTEGER NOT NULL DEFAULT 0, + contextRevision INTEGER NOT NULL DEFAULT 0, + tombstonedAt INTEGER, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS ${CAPABILITY_TABLE} ( + id TEXT PRIMARY KEY, + roomId TEXT NOT NULL, + actorId TEXT NOT NULL, + capability TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + `) +} + +function createActorIndexes(db: DatabaseSync): void { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_gc_room_actors_room ON ${ACTOR_TABLE}(roomId); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_gc_room_actors_active_auth ON ${ACTOR_TABLE}(roomId, authUserId) WHERE active = 1 AND authUserId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uniq_gc_room_actors_active_agent ON ${ACTOR_TABLE}(roomId, agentId) WHERE active = 1 AND agentId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uniq_gc_room_actors_active_local ON ${ACTOR_TABLE}(roomId, localSubjectId) WHERE active = 1 AND localSubjectId IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uniq_gc_room_actors_active_system ON ${ACTOR_TABLE}(roomId, systemKey) WHERE active = 1 AND systemKey IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_gc_room_actor_capabilities_actor ON ${CAPABILITY_TABLE}(actorId); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_gc_room_actor_capabilities_active ON ${CAPABILITY_TABLE}(roomId, actorId, capability) WHERE active = 1; + `) +} + +function appliedState(db: DatabaseSync): { + schema_name: string + version: number + status: string + min_reader_epoch: number +} | null { + if (!tableExists(db, SCHEMA_STATE_TABLE)) { + return null + } + const states = db.prepare( + `SELECT schema_name, version, status, min_reader_epoch + FROM ${SCHEMA_STATE_TABLE} + ORDER BY schema_name ASC` + ).all() as Array<{ + schema_name: string + version: number + status: string + min_reader_epoch: number + }> + const foreign = states.find(state => state.schema_name !== SCHEMA_NAME) + if (foreign) { + throw new Error( + `${SCHEMA_NAME} found foreign schema state ${foreign.schema_name} ${foreign.status}@${foreign.version}`, + ) + } + return states.find(state => state.schema_name === SCHEMA_NAME) ?? null +} + +function assertSupportedReaderEpoch(state: { min_reader_epoch: number }): void { + if (state.min_reader_epoch > GROUP_CHAT_IDENTITY_READER_EPOCH) { + throw new Error( + `${SCHEMA_NAME} minimum reader epoch ${state.min_reader_epoch} exceeds supported epoch ${GROUP_CHAT_IDENTITY_READER_EPOCH}`, + ) + } +} + +function assertSupportedRecordedState(state: { version: number; status: string; min_reader_epoch: number }): void { + assertSupportedReaderEpoch(state) + if (state.version !== SCHEMA_VERSION || state.status !== SCHEMA_STATUS_COMPLETE) { + throw new Error(`${SCHEMA_NAME} found unsupported recorded state ${state.status}@${state.version}`) + } +} + +function withImmediateTransaction(db: TransactionalDatabase, fn: () => T): T { + if (db.inTransaction || db.isTransaction) { + return fn() + } + db.exec('BEGIN IMMEDIATE') + try { + const result = fn() + db.exec('COMMIT') + return result + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + // best effort rollback + } + throw error + } +} + +function assertLatestMainOnly(db: DatabaseSync): void { + if (!tableExists(db, ACTOR_TABLE) && !tableExists(db, CAPABILITY_TABLE)) { + return + } + const actorRows = tableExists(db, ACTOR_TABLE) ? rowCount(db, ACTOR_TABLE) : 0 + const capabilityRows = tableExists(db, CAPABILITY_TABLE) ? rowCount(db, CAPABILITY_TABLE) : 0 + if (actorRows > 0 || capabilityRows > 0) { + throw new Error(`${SCHEMA_NAME} refuses mixed-version group chat identity state without recorded schema success`) + } +} + +function maybeFail(stage: GroupChatIdentityMigrationFailpoint): void { + if (groupChatIdentityMigrationFailpoint === stage) { + throw new Error(`${SCHEMA_NAME} failpoint triggered at ${stage}`) + } +} + +function rotateLegacyRoomSessionSeeds(db: DatabaseSync): void { + const rows = db.prepare( + 'SELECT id, sessionSeed FROM gc_rooms' + ).all() as Array<{ id: string; sessionSeed: string | null }> + const update = db.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?') + for (const row of rows) { + const sessionSeed = typeof row.sessionSeed === 'string' ? row.sessionSeed : '' + if (sessionSeed !== '0' && sessionSeed !== '' && ROOM_SESSION_SEED_RE.test(sessionSeed)) { + continue + } + update.run(randomBytes(16).toString('hex'), row.id) + } +} + +export function setGroupChatIdentityMigrationFailpointForTesting( + failpoint: GroupChatIdentityMigrationFailpoint, +): void { + groupChatIdentityMigrationFailpoint = failpoint +} + +export function assertGroupChatIdentityReaderEpochPreflight(db: DatabaseSync): void { + const state = appliedState(db) + if (state) { + assertSupportedRecordedState(state) + } +} + +export function runGroupChatIdentityV1Migration(db: DatabaseSync): void { + assertHermesDatabaseOwnership(db) + assertGroupChatIdentityReaderEpochPreflight(db) + + withImmediateTransaction(db as TransactionalDatabase, () => { + const state = appliedState(db) + if (state) { + assertSupportedRecordedState(state) + rotateLegacyRoomSessionSeeds(db) + createActorIndexes(db) + return + } + + assertLatestMainOnly(db) + + createSchemaStateTable(db) + createActorTables(db) + db.prepare( + `INSERT INTO ${SCHEMA_STATE_TABLE} + (schema_name, version, status, min_reader_epoch, applied_at, rollback_marker) + VALUES (?, ?, ?, ?, ?, ?)` + ).run( + SCHEMA_NAME, + SCHEMA_VERSION, + SCHEMA_STATUS_PENDING, + GROUP_CHAT_IDENTITY_READER_EPOCH, + Date.now(), + `${SCHEMA_NAME}.rollback`, + ) + + const summary = backfillGroupChatActors(db) + rotateLegacyRoomSessionSeeds(db) + maybeFail('after-backfill') + validateBackfilledGroupChatActors(db, summary) + createActorIndexes(db) + + db.prepare( + `UPDATE ${SCHEMA_STATE_TABLE} + SET status = ?, applied_at = ?, rollback_marker = ? + WHERE schema_name = ?` + ).run( + SCHEMA_STATUS_COMPLETE, + Date.now(), + `${SCHEMA_NAME}.complete`, + SCHEMA_NAME, + ) + }) +} diff --git a/packages/server/src/db/hermes/schemas.ts b/packages/server/src/db/hermes/schemas.ts index dc7f64eec3..7a24ae7da0 100644 --- a/packages/server/src/db/hermes/schemas.ts +++ b/packages/server/src/db/hermes/schemas.ts @@ -544,13 +544,15 @@ export const GC_ROOMS_SCHEMA: Record = { id: 'TEXT PRIMARY KEY', name: 'TEXT NOT NULL', inviteCode: 'TEXT UNIQUE', + inviteGeneration: 'INTEGER NOT NULL DEFAULT 0', triggerTokens: 'INTEGER NOT NULL DEFAULT 100000', maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000', tailMessageCount: 'INTEGER NOT NULL DEFAULT 10', totalTokens: 'INTEGER NOT NULL DEFAULT 0', - sessionSeed: "TEXT NOT NULL DEFAULT '0'", + sessionSeed: 'TEXT NOT NULL', workspace: "TEXT NOT NULL DEFAULT ''", ownerAuthUserId: 'INTEGER', + authorizationRevision: 'INTEGER NOT NULL DEFAULT 0', } export const GC_MESSAGES_TABLE = 'gc_messages' @@ -582,6 +584,7 @@ export const GC_ROOM_AGENTS_SCHEMA: Record = { name: 'TEXT NOT NULL', description: "TEXT NOT NULL DEFAULT ''", invited: 'INTEGER NOT NULL DEFAULT 0', + createdAt: 'INTEGER NOT NULL DEFAULT 0', } export const GC_CONTEXT_SNAPSHOTS_TABLE = 'gc_context_snapshots' @@ -608,6 +611,39 @@ export const GC_ROOM_MEMBERS_SCHEMA: Record = { authUserId: 'INTEGER', } +export const GC_ROOM_ACTORS_TABLE = 'gc_room_actors' + +export const GC_ROOM_ACTORS_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + roomId: 'TEXT NOT NULL', + actorType: 'TEXT NOT NULL', + authUserId: 'INTEGER', + agentId: 'TEXT', + localSubjectId: 'TEXT', + systemKey: 'TEXT', + name: "TEXT NOT NULL DEFAULT ''", + description: "TEXT NOT NULL DEFAULT ''", + avatar: "TEXT NOT NULL DEFAULT ''", + active: 'INTEGER NOT NULL DEFAULT 1', + authorizationRevision: 'INTEGER NOT NULL DEFAULT 0', + contextRevision: 'INTEGER NOT NULL DEFAULT 0', + tombstonedAt: 'INTEGER', + createdAt: 'INTEGER NOT NULL', + updatedAt: 'INTEGER NOT NULL', +} + +export const GC_ROOM_ACTOR_CAPABILITIES_TABLE = 'gc_room_actor_capabilities' + +export const GC_ROOM_ACTOR_CAPABILITIES_SCHEMA: Record = { + id: 'TEXT PRIMARY KEY', + roomId: 'TEXT NOT NULL', + actorId: 'TEXT NOT NULL', + capability: 'TEXT NOT NULL', + active: 'INTEGER NOT NULL DEFAULT 1', + createdAt: 'INTEGER NOT NULL', + updatedAt: 'INTEGER NOT NULL', +} + export const GC_PENDING_SESSION_DELETES_TABLE = 'gc_pending_session_deletes' export const GC_PENDING_SESSION_DELETES_SCHEMA: Record = { @@ -636,6 +672,16 @@ export const GC_SESSION_PROFILES_SCHEMA: Record = { // ============================================================================ import { getDb, getStoragePath } from '../index' +import { assertHermesDatabaseOwnership } from '../ownership' +import { + assertGroupChatIdentityReaderEpochPreflight, + runGroupChatIdentityV1Migration, +} from './group-chat-identity-migration' + +type TransactionalDatabase = NonNullable> & { + readonly inTransaction?: boolean + readonly isTransaction?: boolean +} function quoteIdentifier(identifier: string): string { return `"${identifier.replace(/"/g, '""')}"` @@ -1036,6 +1082,9 @@ export function initAllHermesTables(): void { if (!db) return try { + assertHermesDatabaseOwnership(db) + assertGroupChatIdentityReaderEpochPreflight(db) + // Usage store syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' }) db.exec(USAGE_RUN_INDEX) @@ -1139,28 +1188,45 @@ export function initAllHermesTables(): void { copyLegacyProviderSettingsToDefaultProfile(db, TTS_PROVIDER_SETTINGS_TABLE, TTS_PROFILE_PROVIDER_SETTINGS_TABLE) copyLegacyActiveSettingsToDefaultProfile(db, TTS_USER_SETTINGS_TABLE, TTS_PROFILE_SETTINGS_TABLE) - // Group chat - basic tables - syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA) - syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA) - syncTable(GC_CONTEXT_SNAPSHOTS_TABLE, GC_CONTEXT_SNAPSHOTS_SCHEMA) - syncTable(GC_PENDING_SESSION_DELETES_TABLE, GC_PENDING_SESSION_DELETES_SCHEMA) - syncTable(GC_SESSION_PROFILES_TABLE, GC_SESSION_PROFILES_SCHEMA) - - // Group chat - single-column primary key tables (PRIMARY KEY in column definition) - syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, { - indexes: { - idx_gc_room_agents_profile: 'CREATE INDEX idx_gc_room_agents_profile ON gc_room_agents(profile)', - } - }) + const transactionalDb = db as TransactionalDatabase + const runGroupChatInitialization = () => { + syncTable(GC_ROOMS_TABLE, GC_ROOMS_SCHEMA) + syncTable(GC_MESSAGES_TABLE, GC_MESSAGES_SCHEMA) + syncTable(GC_CONTEXT_SNAPSHOTS_TABLE, GC_CONTEXT_SNAPSHOTS_SCHEMA) + syncTable(GC_PENDING_SESSION_DELETES_TABLE, GC_PENDING_SESSION_DELETES_SCHEMA) + syncTable(GC_SESSION_PROFILES_TABLE, GC_SESSION_PROFILES_SCHEMA) + syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, { + indexes: { + idx_gc_room_agents_profile: 'CREATE INDEX idx_gc_room_agents_profile ON gc_room_agents(profile)', + }, + }) + syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, { + indexes: { + idx_gc_room_members_user: 'CREATE INDEX idx_gc_room_members_user ON gc_room_members(userId)', + }, + }) + runGroupChatIdentityV1Migration(db) + } - syncTable(GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA, { - indexes: { - idx_gc_room_members_user: 'CREATE INDEX idx_gc_room_members_user ON gc_room_members(userId)', + if (transactionalDb.inTransaction || transactionalDb.isTransaction) { + runGroupChatInitialization() + } else { + db.exec('BEGIN IMMEDIATE') + try { + runGroupChatInitialization() + db.exec('COMMIT') + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + // best effort rollback + } + throw error } - }) + } } catch (e) { console.error('Error initializing Hermes SQLite tables:', e) - console.error(`[Schema] Database initialization failed. Existing database was left untouched: ${getStoragePath()}`) + console.error(`[Schema] Database initialization aborted for: ${getStoragePath()}`) throw e } } diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index adb9718cc8..8e27d8c0ae 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -2,6 +2,10 @@ import { DatabaseSync } from 'node:sqlite' import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs' import { resolve } from 'path' import { config } from '../config' +import { + acquireHermesDatabaseOwnership, + releaseHermesDatabaseOwnership, +} from './ownership' const isDev = process.env.NODE_ENV !== 'production' const isTest = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' @@ -12,7 +16,11 @@ const testDbDirOverride = process.env.HERMES_WEB_UI_TEST_DB_DIR?.trim() const DB_DIR = isTest ? testDbDirOverride ? resolve(testDbDirOverride) - : resolve(process.cwd(), 'packages/server/data/test-runtime') + : resolve( + process.cwd(), + 'packages/server/data/test-runtime', + `${process.pid}-${process.env.VITEST_POOL_ID || 'main'}`, + ) : isDev ? resolve(process.cwd(), 'packages/server/data') : config.appHome @@ -34,24 +42,50 @@ export function isSqliteAvailable(): boolean { let _db: DatabaseSync | null = null +function applyJournalModePragmas(db: DatabaseSync): void { + if (isTest) { + db.exec('PRAGMA journal_mode=WAL') + db.exec('PRAGMA synchronous=NORMAL') + return + } + if (isDev) { + db.exec('PRAGMA journal_mode=DELETE') + return + } + db.exec('PRAGMA journal_mode=WAL') + db.exec('PRAGMA synchronous=NORMAL') +} + +function applyPostOwnershipPragmas(db: DatabaseSync): void { + if (isTest) { + db.exec('PRAGMA busy_timeout=5000') + db.exec('PRAGMA foreign_keys=ON') + return + } + if (!isDev) { + db.exec('PRAGMA busy_timeout=5000') + db.exec('PRAGMA foreign_keys=ON') + } +} + export function getDb(): DatabaseSync | null { if (!SQLITE_AVAILABLE) return null if (!_db) { mkdirSync(DB_DIR, { recursive: true }) - _db = new DatabaseSync(DB_PATH) - // Use WAL mode for better concurrency and WSL compatibility - if (isTest) { - _db.exec('PRAGMA journal_mode=WAL') - _db.exec('PRAGMA synchronous=NORMAL') - _db.exec('PRAGMA busy_timeout=5000') - _db.exec('PRAGMA foreign_keys=ON') - } else if (isDev) { - _db.exec('PRAGMA journal_mode=DELETE') - } else { - _db.exec('PRAGMA journal_mode=WAL') - _db.exec('PRAGMA synchronous=NORMAL') - _db.exec('PRAGMA busy_timeout=5000') - _db.exec('PRAGMA foreign_keys=ON') + const candidate = new DatabaseSync(DB_PATH) + try { + acquireHermesDatabaseOwnership(candidate, DB_PATH) + applyJournalModePragmas(candidate) + applyPostOwnershipPragmas(candidate) + _db = candidate + } catch (error) { + try { + candidate.close() + } catch { + // best effort cleanup + } + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Hermes Web UI database ownership failed for ${DB_PATH}: ${detail}`) } } return _db @@ -129,9 +163,14 @@ export function getStoragePath(): string { */ export function closeDb(): void { if (_db) { - try { - _db.close() - } catch { /* best-effort */ } + const db = _db _db = null + try { + db.close() + } catch { + // best effort + } finally { + releaseHermesDatabaseOwnership(db) + } } } diff --git a/packages/server/src/db/ownership.ts b/packages/server/src/db/ownership.ts new file mode 100644 index 0000000000..5c90966f76 --- /dev/null +++ b/packages/server/src/db/ownership.ts @@ -0,0 +1,77 @@ +import type { DatabaseSync } from 'node:sqlite' +import { realpathSync } from 'node:fs' +import { resolve } from 'node:path' + +type HermesDatabaseOwnership = { + kind: 'process' | 'test' + storagePath: string + claimedAt: number +} + +const hermesOwnedDatabases = new WeakMap() +const isTestRuntime = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' + +function sqliteMainDatabasePath(db: DatabaseSync): string { + const rows = db.prepare('PRAGMA database_list').all() as Array<{ name: string; file: string }> + const main = rows.find((row) => row.name === 'main') + return String(main?.file || '') +} + +function isInMemorySqlitePath(path: string): boolean { + return path === '' + || path === ':memory:' + || path.startsWith('file::memory:') + || path.includes('mode=memory') +} + +function canonicalFilePath(path: string): string { + try { + return realpathSync(path) + } catch { + return resolve(path) + } +} + +export function acquireHermesDatabaseOwnership(db: DatabaseSync, storagePath: string): void { + const actualPath = sqliteMainDatabasePath(db) + if (isInMemorySqlitePath(actualPath) || isInMemorySqlitePath(storagePath)) { + throw new Error('In-memory Hermes databases must use the test-only ownership claim') + } + if (canonicalFilePath(actualPath) !== canonicalFilePath(storagePath)) { + throw new Error(`Hermes database ownership path mismatch: opened ${actualPath}, expected ${storagePath}`) + } + + db.exec('PRAGMA busy_timeout=1000') + db.prepare('PRAGMA locking_mode=EXCLUSIVE').get() + db.exec('BEGIN IMMEDIATE') + db.exec('COMMIT') + hermesOwnedDatabases.set(db, { + kind: 'process', + storagePath: actualPath, + claimedAt: Date.now(), + }) +} + +export function claimHermesDatabaseOwnershipForTesting(db: DatabaseSync): void { + if (!isTestRuntime) { + throw new Error('Hermes test database ownership may only be claimed during test runtime') + } + const storagePath = sqliteMainDatabasePath(db) + if (!isInMemorySqlitePath(storagePath)) { + throw new Error(`Hermes test database ownership only supports in-memory SQLite handles, received ${storagePath}`) + } + hermesOwnedDatabases.set(db, { + kind: 'test', + storagePath: storagePath || ':memory:', + claimedAt: Date.now(), + }) +} + +export function assertHermesDatabaseOwnership(db: DatabaseSync): void { + if (hermesOwnedDatabases.has(db)) return + throw new Error('Hermes database ownership is required before schema initialization or migration') +} + +export function releaseHermesDatabaseOwnership(db: DatabaseSync): void { + hermesOwnedDatabases.delete(db) +} diff --git a/packages/server/src/middleware/user-auth.ts b/packages/server/src/middleware/user-auth.ts index 6984cb9fd0..8d98564fb4 100644 --- a/packages/server/src/middleware/user-auth.ts +++ b/packages/server/src/middleware/user-auth.ts @@ -198,15 +198,18 @@ export function toAuthenticatedUser(user: Pick { const secret = await getJwtSecret() const payload = token ? verifyUserJwt(token, secret) : null if (!payload) return null - - const user = findUserById(payload.sub) - if (!user || user.status !== 'active') return null - return toAuthenticatedUser(user) + return loadActiveAuthenticatedUser(payload.sub) } export async function isAuthEnabled(): Promise { @@ -233,14 +236,14 @@ export async function requireUserJwt(ctx: Context, next: Next): Promise { return } - const user = findUserById(payload.sub) - if (!user || user.status !== 'active') { + const user = loadActiveAuthenticatedUser(payload.sub) + if (!user) { ctx.status = 403 ctx.body = { error: 'User is disabled or does not exist' } return } - ctx.state.user = toAuthenticatedUser(user) + ctx.state.user = user touchUserLogin(user.id) await next() } diff --git a/packages/server/src/routes/hermes/group-chat.ts b/packages/server/src/routes/hermes/group-chat.ts index 4251b551b3..321e5ac781 100644 --- a/packages/server/src/routes/hermes/group-chat.ts +++ b/packages/server/src/routes/hermes/group-chat.ts @@ -1,17 +1,35 @@ import Router from '@koa/router' +import { randomBytes, randomUUID } from 'node:crypto' +import type { Context } from 'koa' import type { GroupChatServer } from '../../services/hermes/group-chat' +import { + loadActiveAuthenticatedUser, + type AuthenticatedUser, +} from '../../middleware/user-auth' import { isReservedMentionName } from '../../services/hermes/group-chat/mention-routing' import { assertAllowedWorkspaceFolder } from '../../services/hermes/workspace-path' import { - canManageGroupChatRoom as canManageRoom, - canReadGroupChatRoom as canReadRoom, - groupChatUserProfiles as userProfiles, + evaluateGroupChatRequestAccess, } from '../../services/hermes/group-chat/access' import { setGroupChatRuntimeServer } from '../../services/hermes/group-chat/runtime' import * as ctrl from '../../controllers/hermes/group-chat-workspace' export const groupChatRoutes = new Router() +type GroupChatStorage = ReturnType +type GroupChatRoom = NonNullable> +type GroupChatRouteState = { + user?: AuthenticatedUser + groupChatLocalSubjectId?: string +} +type GroupChatRouteContext = Context & { state: GroupChatRouteState } +type RoomPermissions = { canManage: boolean; canApprove: boolean; canLeave: boolean } +type SerializedGroupChatRoom = Omit< + GroupChatRoom, + 'ownerAuthUserId' | 'inviteGeneration' | 'sessionSeed' +> & RoomPermissions +type RoomAccess = RoomPermissions & { room: GroupChatRoom; canDiscover: boolean; canRead: boolean } + let chatServer: GroupChatServer | null = null export function setGroupChatServer(server: GroupChatServer | null) { @@ -24,16 +42,16 @@ export function getGroupChatServer(): GroupChatServer | null { } function generateId(): string { - return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + return randomUUID() } function generateInviteCode(): string { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - let code = '' - for (let i = 0; i < 6; i++) { - code += chars[Math.floor(Math.random() * chars.length)] - } - return code + return Array.from(randomBytes(16), value => chars[value & 31]).join('') +} + +function isBlankInviteCode(value: string | undefined): boolean { + return value === undefined || !value.trim() } type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number } @@ -55,57 +73,242 @@ function agentConnectFailureBody(profile: string, err: any) { } } -function serializeRoom(room: any, includeManageFields: boolean) { +function serializeRoom( + room: GroupChatRoom | null | undefined, + permissions: RoomPermissions, +): SerializedGroupChatRoom | null | undefined { if (!room) return room - const { ownerAuthUserId: _ownerAuthUserId, ...rest } = room - const serialized = { ...rest, canManage: includeManageFields } + const { + ownerAuthUserId: _ownerAuthUserId, + inviteGeneration: _inviteGeneration, + sessionSeed: _sessionSeed, + ...rest + } = room + const serialized = { + ...rest, + canManage: permissions.canManage, + canApprove: permissions.canApprove, + canLeave: permissions.canLeave, + } if (Object.prototype.hasOwnProperty.call(room, 'inviteCode')) { - serialized.inviteCode = includeManageFields ? room.inviteCode ?? null : null + serialized.inviteCode = permissions.canManage ? room.inviteCode ?? null : null } if (Object.prototype.hasOwnProperty.call(room, 'workspace')) { - serialized.workspace = includeManageFields ? String(room.workspace || '') : '' + serialized.workspace = permissions.canManage ? String(room.workspace || '') : '' } return serialized } -function persistRoomCreator( - storage: ReturnType, +function respondRoomNotFound(ctx: GroupChatRouteContext): void { + ctx.status = 404 + ctx.body = { error: 'Room not found' } +} + +function refreshGroupChatRouteState(state: GroupChatRouteState): GroupChatRouteState { + if (!state.user) return state + const userId = Number(state.user.id) + const user = Number.isInteger(userId) && userId > 0 + ? loadActiveAuthenticatedUser(userId) + : null + if (!user) { + delete state.user + delete state.groupChatLocalSubjectId + return state + } + state.user = user + delete state.groupChatLocalSubjectId + return state +} + +function roomAccess( + storage: GroupChatStorage, + roomId: string, + state: GroupChatRouteState, + knownRoom?: GroupChatRoom, +): RoomAccess | null { + const room = knownRoom || storage.getRoom(roomId) + if (!room) return null + const currentState = refreshGroupChatRouteState(state) + const decision = evaluateGroupChatRequestAccess( + storage, + room.id, + currentState.user, + currentState.groupChatLocalSubjectId, + ) + const authUserId = Number(currentState.user?.id ?? 0) + const isAuthenticatedOwner = Number.isInteger(authUserId) + && authUserId > 0 + && Number(room.ownerAuthUserId ?? 0) === authUserId + return { + room, + canDiscover: Boolean(decision?.canDiscover), + canManage: Boolean(decision?.canManage), + canRead: Boolean(decision?.canRead), + canApprove: Boolean(decision?.canRespondApproval), + canLeave: decision?.actorType === 'authenticated_human' || isAuthenticatedOwner, + } +} + +function requireReadableRoom( + ctx: GroupChatRouteContext, + storage: GroupChatStorage, + roomId: string, +): RoomAccess | null { + const access = roomAccess(storage, roomId, ctx.state) + if (!access?.canRead) { + respondRoomNotFound(ctx) + return null + } + return access +} + +function requireManageRoom( + ctx: GroupChatRouteContext, + storage: GroupChatStorage, + roomId: string, +): RoomAccess | null { + const access = roomAccess(storage, roomId, ctx.state) + if (!access?.canRead) { + respondRoomNotFound(ctx) + return null + } + if (!access.canManage) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return null + } + return access +} + +const REQUEST_AUTHORIZATION_CHANGED = 'group_chat_request_authorization_changed' + +function assertCurrentRoomManager( + ctx: GroupChatRouteContext, + storage: GroupChatStorage, roomId: string, - user: any, - memberName?: string, - memberDescription?: string, +): RoomAccess { + const access = requireManageRoom(ctx, storage, roomId) + if (access) return access + throw Object.assign(new Error('Group chat request authorization changed'), { + code: REQUEST_AUTHORIZATION_CHANGED, + status: ctx.status, + }) +} + +function authenticatedRequesterId(state: GroupChatRouteState): number | null { + const userId = Number(state.user?.id) + return Number.isInteger(userId) && userId > 0 ? userId : null +} + +function requireCurrentAgentProfile( + ctx: GroupChatRouteContext, + authenticatedUserId: number | null, + profile: string, +): boolean { + if (authenticatedUserId === null) return true + const user = loadActiveAuthenticatedUser(authenticatedUserId) + if (!user) { + delete ctx.state.user + delete ctx.state.groupChatLocalSubjectId + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return false + } + ctx.state.user = user + delete ctx.state.groupChatLocalSubjectId + if (user.role === 'super_admin' || user.profiles?.includes(profile)) return true + ctx.status = 403 + ctx.body = { error: `Profile "${profile}" is not available for this user` } + return false +} + +function assertCurrentAgentProfile( + ctx: GroupChatRouteContext, + authenticatedUserId: number | null, + profile: string, ): void { - if (typeof user?.id !== 'number' || user.id <= 0) return - storage.setRoomOwnerAuthUserId?.(roomId, user.id) - const username = memberName?.trim() || String(user.username || `User-${user.id}`) - storage.addRoomMember(roomId, `auth:${user.id}`, username, memberDescription?.trim() || '', '', user.id) + if (requireCurrentAgentProfile(ctx, authenticatedUserId, profile)) return + throw Object.assign(new Error('Group chat request authorization changed'), { + code: REQUEST_AUTHORIZATION_CHANGED, + status: ctx.status, + }) +} + +function isRequestAuthorizationChanged(error: unknown): boolean { + return Boolean( + error + && typeof error === 'object' + && 'code' in error + && error.code === REQUEST_AUTHORIZATION_CHANGED, + ) } -function visibleRoomsForUser(storage: ReturnType, user: any) { - if (!user || user.role === 'super_admin') return storage.getAllRooms().map(room => serializeRoom(room, true)) - const byId = new Map() - const addRoom = (room: any, includeWorkspace: boolean) => { - if (!room) return - if (byId.has(room.id) && includeWorkspace) byId.set(room.id, serializeRoom(room, true)) - else if (!byId.has(room.id)) byId.set(room.id, serializeRoom(room, includeWorkspace)) - } - for (const room of storage.getRoomsForProfiles(userProfiles(user))) addRoom(room, true) - if (typeof user.id === 'number') { - if (typeof storage.getOwnedRoomsForAuthUser === 'function') { - for (const room of storage.getOwnedRoomsForAuthUser(user.id)) addRoom(room, true) +type GroupChatRoomOwner = ({ + kind: 'authenticated' + authUserId: number + username: string + description?: string + avatar?: string +} | { + kind: 'local' + localSubjectId: string + username: string + description?: string + avatar?: string +}) + +function roomOwnerFromState(state: GroupChatRouteState, memberName?: string, memberDescription?: string): GroupChatRoomOwner | null { + const currentState = refreshGroupChatRouteState(state) + const requestedName = memberName?.trim() + const requestedDescription = memberDescription?.trim() + const user = currentState.user + if (typeof user?.id === 'number' && user.id > 0) { + return { + kind: 'authenticated', + authUserId: user.id, + username: requestedName || String(user.username || `User-${user.id}`), + description: requestedDescription || undefined, } - if (typeof storage.getRoomsForAuthUser === 'function') { - for (const room of storage.getRoomsForAuthUser(user.id)) addRoom(room, canManageRoom(storage, room.id, user)) + } + if (typeof currentState.groupChatLocalSubjectId === 'string' && currentState.groupChatLocalSubjectId) { + return { + kind: 'local', + localSubjectId: currentState.groupChatLocalSubjectId, + username: requestedName || 'Local user', + description: requestedDescription || undefined, } } - return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) + return null } -async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) { +function visibleRoomsForRequest( + storage: GroupChatStorage, + state: GroupChatRouteState, +): SerializedGroupChatRoom[] { + const visible: SerializedGroupChatRoom[] = [] + for (const room of storage.getAllRooms()) { + const access = roomAccess(storage, room.id, state, room) + if (!access?.canDiscover) continue + const serializedRoom = serializeRoom(room, access) + if (serializedRoom) visible.push(serializedRoom) + } + return visible +} + +type PersistedRoomAgent = ReturnType + +async function connectAndPersistRoomAgent( + server: GroupChatServer, + roomId: string, + input: AgentInput, + assertAuthorized: () => void, + agentId = generateId(), +): Promise { const profile = input.profile const name = input.name || profile const description = input.description || '' const invited = input.invited ? 1 : 0 + assertAuthorized() const client = await server.agentClients.createAgent({ agentId, profile, @@ -116,19 +319,58 @@ async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: strin }) const storage = server.getStorage() - let persisted: any + let persisted: PersistedRoomAgent | null = null try { + assertAuthorized() persisted = storage.addRoomAgent(roomId, agentId, profile, name, description, invited) await server.agentClients.addAgentToRoom(roomId, client) + assertAuthorized() return persisted } catch (err) { - if (persisted) storage.removeRoomAgent(roomId, persisted.id || agentId) - else client.disconnect?.() + if (persisted) { + storage.removeAgentActorWithRetention(roomId, persisted.id || agentId) + } + client.disconnect?.() server.agentClients.removeAgentFromRoom(roomId, client.agentId) throw err } } +async function rollbackNewlyProvisionedRoom( + server: GroupChatServer, + roomId: string, +): Promise { + const storage = server.getStorage() + const removals: Array>> = [] + for (const agent of storage.getRoomAgents(roomId)) { + const removal = storage.removeAgentActorWithRetention(roomId, agent.id || agent.agentId) + if (removal) removals.push(removal) + } + server.agentClients.disconnectRoom(roomId) + storage.deleteRoom(roomId) + for (const removal of removals) { + try { + await server.cleanupRemovedAgentRuntime(removal) + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err || '') + console.error(`[GroupChat] Failed runtime rollback for agent ${removal.agent.agentId} in room ${roomId}: ${sanitizeAgentConnectReason(reason)}`) + } + } +} + +groupChatRoutes.use(async (ctx, next) => { + const state = ctx.state as GroupChatRouteState + delete state.groupChatLocalSubjectId + if (!state.user && chatServer) { + const localCredential = typeof ctx.get === 'function' + ? ctx.get('x-group-chat-local-credential') + : '' + const localSubjectId = await chatServer.resolveLocalCredentialSubject(localCredential) + if (localSubjectId) state.groupChatLocalSubjectId = localSubjectId + } + await next() +}) + // Create room groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { if (!chatServer) { @@ -137,6 +379,7 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { return } + const authenticatedUserId = authenticatedRequesterId(ctx.state) const { name, inviteCode, agents, compression, workspace, memberName, memberDescription } = ctx.request.body as { name?: string inviteCode?: string @@ -146,9 +389,9 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { memberName?: string memberDescription?: string } - if (!name || !inviteCode) { + if (typeof name !== 'string' || !name.trim()) { ctx.status = 400 - ctx.body = { error: 'name and inviteCode are required' } + ctx.body = { error: 'name is required' } return } if ( @@ -164,12 +407,63 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { ctx.body = { error: 'Member profile is too long' } return } - const reservedAgent = (agents || []).find(a => isReservedMentionName(a.name || a.profile)) + const owner = roomOwnerFromState(ctx.state, memberName, memberDescription) + if (!owner) { + ctx.status = 401 + ctx.body = { error: 'Group chat identity required' } + return + } + if (inviteCode !== undefined && typeof inviteCode !== 'string') { + ctx.status = 400 + ctx.body = { error: 'inviteCode must be a string' } + return + } + const resolvedInviteCode = isBlankInviteCode(inviteCode) + ? generateInviteCode() + : inviteCode + if (agents !== undefined && !Array.isArray(agents)) { + ctx.status = 400 + ctx.body = { error: 'agents must be an array' } + return + } + const normalizedAgents: AgentInput[] = [] + for (const agent of agents || []) { + if (!agent || typeof agent.profile !== 'string' || !agent.profile.trim()) { + ctx.status = 400 + ctx.body = { error: 'agent profile is required' } + return + } + if (agent.name !== undefined && typeof agent.name !== 'string') { + ctx.status = 400 + ctx.body = { error: 'agent name must be a string' } + return + } + if (agent.description !== undefined && typeof agent.description !== 'string') { + ctx.status = 400 + ctx.body = { error: 'agent description must be a string' } + return + } + if (agent.invited !== undefined && typeof agent.invited !== 'boolean') { + ctx.status = 400 + ctx.body = { error: 'agent invited must be a boolean' } + return + } + normalizedAgents.push({ + profile: agent.profile.trim(), + name: agent.name, + description: agent.description, + invited: agent.invited, + }) + } + const reservedAgent = normalizedAgents.find(agent => isReservedMentionName(agent.name || agent.profile)) if (reservedAgent) { ctx.status = 400 ctx.body = { error: '`all` is reserved for @all mentions' } return } + for (const agent of normalizedAgents) { + if (!requireCurrentAgentProfile(ctx, authenticatedUserId, agent.profile)) return + } const roomId = generateId() const storage = chatServer.getStorage() @@ -184,42 +478,107 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { if (rawWorkspace) { try { normalizedWorkspace = (await assertAllowedWorkspaceFolder(rawWorkspace)).fullPath - } catch (err: any) { - ctx.status = Number(err?.status || 403) - ctx.body = { error: err?.message || 'Workspace folder is not allowed' } + } catch (err: unknown) { + const status = typeof err === 'object' && err !== null && 'status' in err + ? Number(err.status) + : 403 + ctx.status = Number.isFinite(status) ? status : 403 + ctx.body = { error: err instanceof Error ? err.message : 'Workspace folder is not allowed' } return } } } + const refreshedOwner = roomOwnerFromState(ctx.state, memberName, memberDescription) + if (!refreshedOwner) { + ctx.status = 403 + ctx.body = { error: 'Access denied' } + return + } + for (const agent of normalizedAgents) { + if (!requireCurrentAgentProfile(ctx, authenticatedUserId, agent.profile)) return + } const compressionConfig = compression ? { triggerTokens: compression.triggerTokens, maxHistoryTokens: compression.maxHistoryTokens, tailMessageCount: compression.tailMessageCount, workspace: normalizedWorkspace, } : { workspace: normalizedWorkspace } - storage.saveRoom(roomId, name, inviteCode, compressionConfig) - persistRoomCreator(storage, roomId, ctx.state?.user, memberName, memberDescription) + if (typeof storage.createRoomWithOwner === 'function') { + storage.createRoomWithOwner({ + id: roomId, + name, + inviteCode: resolvedInviteCode, + config: compressionConfig, + owner: refreshedOwner, + }) + } else { + storage.saveRoom(roomId, name, resolvedInviteCode, compressionConfig) + if (refreshedOwner.kind === 'authenticated') { + storage.setRoomOwnerAuthUserId?.(roomId, refreshedOwner.authUserId) + storage.addRoomMember?.( + roomId, + `auth:${refreshedOwner.authUserId}`, + refreshedOwner.username, + refreshedOwner.description || '', + refreshedOwner.avatar || '', + refreshedOwner.authUserId, + ) + } else { + storage.addRoomMember?.( + roomId, + refreshedOwner.localSubjectId, + refreshedOwner.username, + refreshedOwner.description || '', + refreshedOwner.avatar || '', + ) + } + } const addedAgents = [] const agentResults = [] - for (const a of agents || []) { + for (const agentInput of normalizedAgents) { try { const agent = await connectAndPersistRoomAgent(chatServer, roomId, { - profile: a.profile, - name: a.name || a.profile, - description: a.description || '', - invited: a.invited, + profile: agentInput.profile, + name: agentInput.name || agentInput.profile, + description: agentInput.description || '', + invited: agentInput.invited, + }, () => { + assertCurrentRoomManager(ctx, storage, roomId) + for (const requestedAgent of normalizedAgents) { + assertCurrentAgentProfile(ctx, authenticatedUserId, requestedAgent.profile) + } }) addedAgents.push(agent) - agentResults.push({ profile: a.profile, ok: true, agent }) - } catch (err: any) { - console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) - agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) }) + agentResults.push({ profile: agentInput.profile, ok: true, agent }) + } catch (err: unknown) { + if (isRequestAuthorizationChanged(err)) { + await rollbackNewlyProvisionedRoom(chatServer, roomId) + return + } + const reason = err instanceof Error ? err.message : String(err || '') + console.error(`[GroupChat] Failed to connect agent ${agentInput.profile} to room ${roomId}: ${sanitizeAgentConnectReason(reason)}`) + agentResults.push({ ok: false, ...agentConnectFailureBody(agentInput.profile, err) }) } } + let finalAccess: RoomAccess + try { + finalAccess = assertCurrentRoomManager(ctx, storage, roomId) + for (const requestedAgent of normalizedAgents) { + assertCurrentAgentProfile(ctx, authenticatedUserId, requestedAgent.profile) + } + } catch (err: unknown) { + if (!isRequestAuthorizationChanged(err)) throw err + await rollbackNewlyProvisionedRoom(chatServer, roomId) + return + } const room = storage.getRoom(roomId) - ctx.body = { room: serializeRoom(room, true), agents: addedAgents, agentResults } + ctx.body = { + room: serializeRoom(room, finalAccess), + agents: addedAgents, + agentResults, + } }) // Clone room roles/config without copying the conversation context. @@ -230,50 +589,93 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) = return } + const authenticatedUserId = authenticatedRequesterId(ctx.state) const storage = chatServer.getStorage() - const sourceRoom = storage.getRoom(ctx.params.roomId) - if (!sourceRoom) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } + const access = requireManageRoom(ctx, storage, ctx.params.roomId) + if (!access) { return } - if (!canManageRoom(storage, sourceRoom.id, ctx.state?.user)) { + const sourceRoom = access.room + + const owner = roomOwnerFromState(ctx.state) + if (!owner) { ctx.status = 403 ctx.body = { error: 'Access denied' } return } - + const sourceAgents = storage.getRoomAgents(sourceRoom.id) + for (const sourceAgent of sourceAgents) { + if (!requireCurrentAgentProfile(ctx, authenticatedUserId, sourceAgent.profile)) return + } const { name, inviteCode } = ctx.request.body as { name?: string; inviteCode?: string } const roomId = generateId() - const code = inviteCode?.trim() || generateInviteCode() - storage.saveRoom(roomId, name?.trim() || `${sourceRoom.name} Copy`, code, { + if (inviteCode !== undefined && typeof inviteCode !== 'string') { + ctx.status = 400 + ctx.body = { error: 'inviteCode must be a string' } + return + } + const code = isBlankInviteCode(inviteCode) ? generateInviteCode() : inviteCode + const cloneConfig = { triggerTokens: sourceRoom.triggerTokens, maxHistoryTokens: sourceRoom.maxHistoryTokens, tailMessageCount: sourceRoom.tailMessageCount, workspace: sourceRoom.workspace || '', + } + storage.createRoomWithOwner({ + id: roomId, + name: name?.trim() || `${sourceRoom.name} Copy`, + inviteCode: code, + config: cloneConfig, + owner, }) - persistRoomCreator(storage, roomId, ctx.state?.user) const addedAgents = [] const agentResults = [] - for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) { + for (const sourceAgent of sourceAgents) { try { const agent = await connectAndPersistRoomAgent(chatServer, roomId, { profile: sourceAgent.profile, name: sourceAgent.name, description: sourceAgent.description, invited: sourceAgent.invited, + }, () => { + assertCurrentRoomManager(ctx, storage, sourceRoom.id) + assertCurrentRoomManager(ctx, storage, roomId) + for (const requestedAgent of sourceAgents) { + assertCurrentAgentProfile(ctx, authenticatedUserId, requestedAgent.profile) + } }) addedAgents.push(agent) agentResults.push({ profile: sourceAgent.profile, ok: true, agent }) - } catch (err: any) { - console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) + } catch (err: unknown) { + if (isRequestAuthorizationChanged(err)) { + await rollbackNewlyProvisionedRoom(chatServer, roomId) + return + } + const reason = err instanceof Error ? err.message : String(err || '') + console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(reason)}`) agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) }) } } + let finalTargetAccess: RoomAccess + try { + assertCurrentRoomManager(ctx, storage, sourceRoom.id) + finalTargetAccess = assertCurrentRoomManager(ctx, storage, roomId) + for (const requestedAgent of sourceAgents) { + assertCurrentAgentProfile(ctx, authenticatedUserId, requestedAgent.profile) + } + } catch (err: unknown) { + if (!isRequestAuthorizationChanged(err)) throw err + await rollbackNewlyProvisionedRoom(chatServer, roomId) + return + } const room = storage.getRoom(roomId) - ctx.body = { room: serializeRoom(room, true), agents: addedAgents, agentResults } + ctx.body = { + room: serializeRoom(room, finalTargetAccess), + agents: addedAgents, + agentResults, + } }) // Get room detail and messages @@ -285,18 +687,11 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { } const storage = chatServer.getStorage() - const room = storage.getRoom(ctx.params.roomId) - if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - const canManage = canManageRoom(storage, room.id, ctx.state?.user) - if (!canManage && !canReadRoom(storage, room.id, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + const access = requireReadableRoom(ctx, storage, ctx.params.roomId) + if (!access) { return } + const { room } = access const offset = ctx.query.offset ? Math.max(0, parseInt(ctx.query.offset as string, 10) || 0) : 0 const limit = ctx.query.limit ? Math.max(1, parseInt(ctx.query.limit as string, 10) || 150) : 150 @@ -304,7 +699,7 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { const total = storage.getMessageCount(ctx.params.roomId) const agents = storage.getRoomAgents(ctx.params.roomId) const members = storage.getRoomMembers(ctx.params.roomId) - ctx.body = { room: serializeRoom(room, canManage), messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total } + ctx.body = { room: serializeRoom(room, access), messages, agents, members, total, offset, limit, hasMore: offset + messages.length < total } }) groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/workspace-files/list', ctrl.listWorkspaceFiles) @@ -324,14 +719,13 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => { return } - const user = ctx.state?.user const storage = chatServer.getStorage() - const rooms = visibleRoomsForUser(storage, user) + const rooms = visibleRoomsForRequest(storage, ctx.state) ctx.body = { rooms } }) -function roomWithoutWorkspace(room: any) { - return serializeRoom(room, false) +function roomWithoutWorkspace(room: GroupChatRoom | null | undefined) { + return serializeRoom(room, { canManage: false, canApprove: false, canLeave: false }) } // Get room by invite code @@ -342,10 +736,24 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/join/:code', async (ctx) => { return } + const limiter = chatServer.getInviteAttemptLimiter() + const localCredential = typeof ctx.get === 'function' + ? ctx.get('x-group-chat-local-credential') + : '' + const attemptKey = await chatServer.resolveInviteAttemptSubjectKey( + ctx.state?.user?.id, + ctx.ip, + localCredential, + ) + if (!limiter.isAllowed(attemptKey)) { + respondRoomNotFound(ctx) + return + } + const room = chatServer.getStorage().getRoomByInviteCode(ctx.params.code) if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } + limiter.recordFailure(attemptKey) + respondRoomNotFound(ctx) return } @@ -361,20 +769,12 @@ groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/invite-code', async (c } const storage = chatServer.getStorage() - const room = storage.getRoom(ctx.params.roomId) - if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, ctx.params.roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + if (!requireManageRoom(ctx, storage, ctx.params.roomId)) { return } const { inviteCode } = ctx.request.body as { inviteCode?: string } - if (!inviteCode) { + if (typeof inviteCode !== 'string' || !inviteCode.trim()) { ctx.status = 400 ctx.body = { error: 'inviteCode is required' } return @@ -393,32 +793,43 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) } const { profile, name, description, invited } = ctx.request.body as { profile?: string; name?: string; description?: string; invited?: boolean } - if (!profile) { + if (typeof profile !== 'string' || !profile.trim()) { ctx.status = 400 ctx.body = { error: 'profile is required' } return } - if (isReservedMentionName(name || profile)) { + const normalizedProfile = profile.trim() + if (name !== undefined && typeof name !== 'string') { + ctx.status = 400 + ctx.body = { error: 'name must be a string' } + return + } + if (description !== undefined && typeof description !== 'string') { + ctx.status = 400 + ctx.body = { error: 'description must be a string' } + return + } + if (invited !== undefined && typeof invited !== 'boolean') { + ctx.status = 400 + ctx.body = { error: 'invited must be a boolean' } + return + } + if (isReservedMentionName(name || normalizedProfile)) { ctx.status = 400 ctx.body = { error: '`all` is reserved for @all mentions' } return } + const authenticatedUserId = authenticatedRequesterId(ctx.state) const storage = chatServer.getStorage() - if (typeof storage.getRoom === 'function' && !storage.getRoom(ctx.params.roomId)) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, ctx.params.roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + if (!requireManageRoom(ctx, storage, ctx.params.roomId)) { return } + if (!requireCurrentAgentProfile(ctx, authenticatedUserId, normalizedProfile)) return // Prevent duplicate agent in same room const existing = storage.getRoomAgents(ctx.params.roomId) - if (existing.find(a => a.profile === profile)) { + if (existing.find(agent => agent.profile === normalizedProfile)) { ctx.status = 409 ctx.body = { error: 'Agent already in room' } return @@ -426,16 +837,21 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) try { const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, { - profile, - name: name || profile, + profile: normalizedProfile, + name: name || normalizedProfile, description: description || '', invited, + }, () => { + assertCurrentRoomManager(ctx, storage, ctx.params.roomId) + assertCurrentAgentProfile(ctx, authenticatedUserId, normalizedProfile) }) ctx.body = { agent } - } catch (err: any) { - console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`) + } catch (err: unknown) { + if (isRequestAuthorizationChanged(err)) return + const reason = err instanceof Error ? err.message : String(err || '') + console.error(`[GroupChat] Failed to connect agent ${normalizedProfile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(reason)}`) ctx.status = 502 - ctx.body = agentConnectFailureBody(profile, err) + ctx.body = agentConnectFailureBody(normalizedProfile, err) } }) @@ -448,14 +864,7 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) = } const storage = chatServer.getStorage() - if (typeof storage.getRoom === 'function' && !storage.getRoom(ctx.params.roomId)) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canReadRoom(storage, ctx.params.roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + if (!requireReadableRoom(ctx, storage, ctx.params.roomId)) { return } @@ -474,21 +883,27 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', a const roomId = ctx.params.roomId const requestedAgentId = ctx.params.agentId const storage = chatServer.getStorage() - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + if (!requireManageRoom(ctx, storage, roomId)) { return } const agent = storage.getRoomAgent(roomId, requestedAgentId) if (!agent) { - ctx.status = 404 - ctx.body = { error: 'Agent not found' } + ctx.body = { + success: true, + agents: storage.getRoomAgents(roomId), + members: storage.getRoomMembers(roomId), + } return } - storage.removeRoomMembersForAgent(roomId, agent) - storage.removeRoomAgent(roomId, requestedAgentId) - chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId) + const removal = storage.removeAgentActorWithRetention(roomId, requestedAgentId) + try { + await chatServer.cleanupRemovedAgentRuntime(removal) + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err || '') + console.error(`[GroupChat] Failed runtime cleanup for agent ${agent.agentId} in room ${roomId}: ${sanitizeAgentConnectReason(reason)}`) + } + if (!requireManageRoom(ctx, storage, roomId)) return ctx.body = { success: true, agents: storage.getRoomAgents(roomId), @@ -496,8 +911,8 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', a } }) -// Delete room -groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { +// Leave current room membership without deleting the room. +groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/members/me', async (ctx) => { if (!chatServer) { ctx.status = 503 ctx.body = { error: 'Group chat not initialized' } @@ -506,24 +921,53 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { const roomId = ctx.params.roomId const storage = chatServer.getStorage() - if (!storage.getRoom(roomId)) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } + if (!requireReadableRoom(ctx, storage, roomId)) { return } - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + const authUserId = authenticatedRequesterId(ctx.state) + if (authUserId === null) { + ctx.status = 401 + ctx.body = { error: 'Authenticated user required' } + return + } + const existingMember = storage.getMemberByAuthUserId(roomId, authUserId) + const room = storage.getRoom(roomId) + const isOwner = Number(room?.ownerAuthUserId ?? 0) === authUserId + if (!existingMember && !isOwner) { + ctx.body = { success: true, left: false } + return + } + storage.deactivateAuthenticatedHumanActorWithRetention(roomId, authUserId) + storage.clearRoomOwnerAuthUserId(roomId, authUserId) + chatServer.leaveAuthenticatedUserRoom(roomId, authUserId) + ctx.body = { success: true, left: true } +}) + +// Delete room +groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId', async (ctx) => { + if (!chatServer) { + ctx.status = 503 + ctx.body = { error: 'Group chat not initialized' } + return + } + + const roomId = ctx.params.roomId + const storage = chatServer.getStorage() + if (!requireManageRoom(ctx, storage, roomId)) { return } // Interrupt active bridge runs, then evict sockets and disconnect agents before deleting persisted data. try { - await chatServer.deleteRoomRuntimeState(roomId) + await chatServer.deleteRoomRuntimeState(roomId, () => { + assertCurrentRoomManager(ctx, storage, roomId) + }) } catch (err: any) { + if (isRequestAuthorizationChanged(err)) return ctx.status = Number(err?.status || 409) ctx.body = { error: err?.message || 'Room interrupt did not complete' } return } + if (!requireManageRoom(ctx, storage, roomId)) return // Delete all data storage.deleteRoom(roomId) ctx.body = { success: true } @@ -539,26 +983,24 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clear-context', async const roomId = ctx.params.roomId const storage = chatServer.getStorage() - const room = storage.getRoom(roomId) - if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + const access = requireManageRoom(ctx, storage, roomId) + if (!access) { return } try { - await chatServer.clearRoomRuntimeState(roomId) + await chatServer.clearRoomRuntimeState(roomId, () => { + assertCurrentRoomManager(ctx, storage, roomId) + }) } catch (err: any) { + if (isRequestAuthorizationChanged(err)) return ctx.status = Number(err?.status || 409) ctx.body = { error: err?.message || 'Room interrupt did not complete' } return } + const finalAccess = requireManageRoom(ctx, storage, roomId) + if (!finalAccess) return storage.clearRoomContext(roomId) - ctx.body = { success: true, room: serializeRoom(storage.getRoom(roomId), true) } + ctx.body = { success: true, room: serializeRoom(storage.getRoom(roomId), finalAccess) } }) // Update room compression config @@ -577,19 +1019,12 @@ groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/config', async (ctx) = } const storage = chatServer.getStorage() - const room = storage.getRoom(roomId) - if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + const access = requireManageRoom(ctx, storage, roomId) + if (!access) { return } storage.updateRoomConfig(roomId, { triggerTokens, maxHistoryTokens, tailMessageCount }) - ctx.body = { room: serializeRoom(storage.getRoom(roomId), true) } + ctx.body = { room: serializeRoom(storage.getRoom(roomId), access) } }) // Update room workspace @@ -602,15 +1037,8 @@ groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/workspace', async (ctx const storage = chatServer.getStorage() const roomId = ctx.params.roomId - const room = storage.getRoom(roomId) - if (!room) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + const access = requireManageRoom(ctx, storage, roomId) + if (!access) { return } @@ -621,22 +1049,37 @@ groupChatRoutes.put('/api/hermes/group-chat/rooms/:roomId/workspace', async (ctx return } + let releaseSessionFence: (() => void) | null = null try { const rawWorkspace = workspace.trim() const normalized = rawWorkspace ? (await assertAllowedWorkspaceFolder(rawWorkspace)).fullPath : '' - if (normalized !== String(room.workspace || '')) { - const releaseSessionFence = chatServer.fenceCurrentRoomAgentSessions(roomId) + let currentAccess = requireManageRoom(ctx, storage, roomId) + if (!currentAccess) return + if (normalized !== String(currentAccess.room.workspace || '')) { + releaseSessionFence = chatServer.fenceCurrentRoomAgentSessions(roomId) try { await chatServer.agentClients.interruptRoom(roomId) } catch (err) { releaseSessionFence() + releaseSessionFence = null throw err } + currentAccess = requireManageRoom(ctx, storage, roomId) + if (!currentAccess) { + releaseSessionFence() + return + } } - ctx.body = { room: serializeRoom(storage.updateRoomWorkspace(roomId, normalized), true) } - } catch (err: any) { - ctx.status = Number(err?.status || 403) - ctx.body = { error: err?.message || 'Workspace folder is not allowed' } + const updatedRoom = storage.updateRoomWorkspace(roomId, normalized) + releaseSessionFence = null + ctx.body = { room: serializeRoom(updatedRoom, currentAccess) } + } catch (err: unknown) { + releaseSessionFence?.() + const status = typeof err === 'object' && err !== null && 'status' in err + ? Number(err.status) + : 403 + ctx.status = Number.isFinite(status) ? status : 403 + ctx.body = { error: err instanceof Error ? err.message : 'Workspace folder is not allowed' } } }) @@ -650,14 +1093,7 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx const roomId = ctx.params.roomId const storage = chatServer.getStorage() - if (!storage.getRoom(roomId)) { - ctx.status = 404 - ctx.body = { error: 'Room not found' } - return - } - if (!canManageRoom(storage, roomId, ctx.state?.user)) { - ctx.status = 403 - ctx.body = { error: 'Access denied' } + if (!requireManageRoom(ctx, storage, roomId)) { return } @@ -668,11 +1104,33 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/compress', async (ctx return } + const summarySessionContext = chatServer.agentClients.getSummarySessionContext(roomId) + if (!summarySessionContext) { + ctx.status = 409 + ctx.body = { error: 'No active room agent is authorized to compress context' } + return + } + + const requestAuthorizationGuard = () => Boolean(roomAccess(storage, roomId, ctx.state)?.canManage) + const sessionRegistrar = () => { + const session = summarySessionContext.sessionRegistrar() + return { + ...session, + authorizationGuard: () => requestAuthorizationGuard() && session.authorizationGuard(), + } + } + try { - const result = await engine.forceCompress(roomId) + const result = await engine.forceCompress( + roomId, + summarySessionContext.profile, + sessionRegistrar, + ) + if (!requireManageRoom(ctx, storage, roomId)) return ctx.body = { success: true, summary: result } - } catch (err: any) { + } catch (err: unknown) { + if (!requireManageRoom(ctx, storage, roomId)) return ctx.status = 500 - ctx.body = { error: err.message } + ctx.body = { error: err instanceof Error ? err.message : 'Compression failed' } } }) diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts index b437005f36..fb5113dc4a 100644 --- a/packages/server/src/services/auth.ts +++ b/packages/server/src/services/auth.ts @@ -6,11 +6,19 @@ import { config } from '../config' const APP_HOME = config.appHome const TOKEN_FILE = join(APP_HOME, '.token') +const GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE = join(APP_HOME, '.group-chat-local-identity-secret') function generateToken(): string { return randomBytes(32).toString('hex') } +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && error.code === code +} + /** * Get or create the auth token. */ @@ -26,7 +34,7 @@ export async function getToken(): Promise { const token = generateToken() await mkdir(APP_HOME, { recursive: true }) // Only set mode on Unix systems (Windows ignores this) - const options: any = {} + const options: { mode?: number } = {} if (process.platform !== 'win32') { options.mode = 0o600 } @@ -35,6 +43,48 @@ export async function getToken(): Promise { } } +let groupChatLocalIdentitySecretPromise: Promise | null = null + +async function readOrCreateGroupChatLocalIdentitySecret(): Promise { + if (process.env.GROUP_CHAT_LOCAL_IDENTITY_SECRET) { + const configured = process.env.GROUP_CHAT_LOCAL_IDENTITY_SECRET + if (!/^[0-9a-f]{64}$/i.test(configured)) { + throw new Error('GROUP_CHAT_LOCAL_IDENTITY_SECRET must be exactly 32 bytes encoded as 64 hex characters') + } + return configured + } + try { + const secret = (await readFile(GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE, 'utf-8')).trim() + if (!/^[0-9a-f]{64}$/i.test(secret)) { + throw new Error(`Invalid group chat local identity secret at ${GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE}`) + } + return secret + } catch (error: unknown) { + if (!hasErrorCode(error, 'ENOENT')) throw error + } + + const secret = generateToken() + await mkdir(APP_HOME, { recursive: true }) + const options: { flag: 'wx'; mode?: number } = { flag: 'wx' } + if (process.platform !== 'win32') options.mode = 0o600 + try { + await writeFile(GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE, secret + '\n', options) + return secret + } catch (error: unknown) { + if (!hasErrorCode(error, 'EEXIST')) throw error + const existing = (await readFile(GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE, 'utf-8')).trim() + if (!/^[0-9a-f]{64}$/i.test(existing)) { + throw new Error(`Invalid group chat local identity secret at ${GROUP_CHAT_LOCAL_IDENTITY_SECRET_FILE}`) + } + return existing + } +} + +export function getGroupChatLocalIdentitySecret(): Promise { + groupChatLocalIdentitySecretPromise ??= readOrCreateGroupChatLocalIdentitySecret() + return groupChatLocalIdentitySecretPromise +} + /** * Koa middleware: check Authorization header or query token. * No path whitelisting — applied globally after public routes. diff --git a/packages/server/src/services/hermes/agent-bridge/python/bridge_pool.py b/packages/server/src/services/hermes/agent-bridge/python/bridge_pool.py index 38b7229f7a..1c26c0b9b1 100644 --- a/packages/server/src/services/hermes/agent-bridge/python/bridge_pool.py +++ b/packages/server/src/services/hermes/agent-bridge/python/bridge_pool.py @@ -179,6 +179,7 @@ def __init__(self) -> None: self._lock = threading.RLock() self._db = SessionDbHolder() self._approval_requests: dict[str, queue.Queue[str]] = {} + self._approval_allowed_choices: dict[str, frozenset[str]] = {} self._gateway_approval_requests: dict[str, str] = {} self._gateway_approval_pattern_keys: dict[str, list[str]] = {} self._compression_requests: dict[str, queue.Queue[dict[str, Any]]] = {} @@ -1187,9 +1188,10 @@ def _approval_callback(self, session_id: str): def callback(command: str, description: str, *, allow_permanent: bool = True) -> str: approval_id = uuid.uuid4().hex response_queue: queue.Queue[str] = queue.Queue(maxsize=1) + choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] with self._lock: self._approval_requests[approval_id] = response_queue - choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"] + self._approval_allowed_choices[approval_id] = frozenset(choices) self._append_event(session_id, { "event": "approval.requested", "approval_id": approval_id, @@ -1206,6 +1208,7 @@ def callback(command: str, description: str, *, allow_permanent: bool = True) -> finally: with self._lock: self._approval_requests.pop(approval_id, None) + self._approval_allowed_choices.pop(approval_id, None) self._append_event(session_id, { "event": "approval.resolved", "approval_id": approval_id, @@ -1287,6 +1290,7 @@ def callback(approval_data: dict[str, Any]) -> None: with self._lock: self._gateway_approval_requests[approval_id] = session_id self._gateway_approval_pattern_keys[approval_id] = pattern_keys + self._approval_allowed_choices[approval_id] = frozenset(choices) self._append_event(session_id, { "event": "approval.requested", "approval_id": approval_id, @@ -1820,10 +1824,14 @@ def respond_approval(self, approval_id: str, choice: str) -> dict[str, Any]: cleaned = "deny" with self._lock: response_queue = self._approval_requests.get(approval_id) + allowed_choices = self._approval_allowed_choices.get(approval_id) + if allowed_choices is not None and cleaned not in allowed_choices: + cleaned = "deny" if response_queue is None: with self._lock: gateway_session_id = self._gateway_approval_requests.pop(approval_id, None) pattern_keys = self._gateway_approval_pattern_keys.pop(approval_id, []) + self._approval_allowed_choices.pop(approval_id, None) if gateway_session_id is None: return {"approval_id": approval_id, "resolved": False, "choice": cleaned} try: diff --git a/packages/server/src/services/hermes/context-engine/compressor.ts b/packages/server/src/services/hermes/context-engine/compressor.ts index 31c4bde3d6..27d2f283c6 100644 --- a/packages/server/src/services/hermes/context-engine/compressor.ts +++ b/packages/server/src/services/hermes/context-engine/compressor.ts @@ -5,6 +5,7 @@ import type { BuildContextInput, MessageFetcher, GatewayCaller, + GatewaySessionLease, SessionCleaner, } from './types' import { DEFAULT_COMPRESSION_CONFIG } from './types' @@ -14,6 +15,13 @@ import { logger } from '../../../services/logger' import { buildProjectedGroupChatHistory, projectGroupChatMessage } from '../group-chat/context-projection' import { sliceGroupMessagesForSnapshotTail } from '../group-chat/group-message-ordering' +export class ContextAuthorizationChangedError extends Error { + constructor() { + super('Group chat context authorization changed') + this.name = 'ContextAuthorizationChangedError' + } +} + export class ContextEngine { private config: CompressionConfig private messageFetcher: MessageFetcher @@ -37,6 +45,37 @@ export class ContextEngine { private sessionCleaner?: SessionCleaner + private assertGuard(guard: () => boolean): void { + try { + if (guard()) return + } catch { + // Storage/guard failures are authorization failures. + } + throw new ContextAuthorizationChangedError() + } + + private assertAuthorization(input: BuildContextInput): void { + this.assertGuard(input.authorizationGuard) + } + + private cleanupSummarySession(roomId: string, sessionId: string | null): void { + if (!sessionId) return + try { + this.sessionCleaner?.(sessionId) + } catch { + logger.warn({ roomId }, '[ContextEngine] failed to schedule summary session cleanup') + } + } + + private assertAuthorizationAfterExternal(input: BuildContextInput, sessionId: string | null): void { + try { + this.assertAuthorization(input) + } catch (error: unknown) { + this.cleanupSummarySession(input.roomId, sessionId) + throw error + } + } + setUpstream(upstream: string, apiKey: string | null): void { this._upstream = upstream this._apiKey = apiKey @@ -57,24 +96,28 @@ export class ContextEngine { * b. Under threshold → return all verbatim * c. Over threshold → full compress, save snapshot, return */ - async buildContext(input: BuildContextInput): Promise { - // Serialize compression per room to prevent concurrent snapshot overwrites - const existing = this._compressLocks.get(input.roomId) - if (existing) { - await existing - } - let resolveLock!: () => void - const lock = new Promise(r => { resolveLock = r }) - this._compressLocks.set(input.roomId, lock) + private async runWithCompressionLock(roomId: string, operationFactory: () => Promise): Promise { + // Publish an always-resolving tail before awaiting the operation so later callers + // chain behind this caller instead of waking together from the same predecessor. + const previous = this._compressLocks.get(roomId) || Promise.resolve() + const operation = previous.then(operationFactory) + const lock = operation.then(() => undefined, () => undefined) + this._compressLocks.set(roomId, lock) try { - return await this._buildContextImpl(input) + return await operation } finally { - resolveLock() - this._compressLocks.delete(input.roomId) + if (this._compressLocks.get(roomId) === lock) { + this._compressLocks.delete(roomId) + } } } + async buildContext(input: BuildContextInput): Promise { + return this.runWithCompressionLock(input.roomId, () => this._buildContextImpl(input)) + } + private async _buildContextImpl(input: BuildContextInput): Promise { + this.assertAuthorization(input) const config = { ...this.config, ...input.compression } const messages = this.messageFetcher.getMessagesForContext(input.roomId, { throughMessageId: input.currentMessage.id, @@ -122,12 +165,16 @@ export class ContextEngine { ): Promise => { try { const estimate = await input.contextTokenEstimator?.(history, instructions) + this.assertAuthorization(input) if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) { return Math.floor(estimate) } - } catch (err: any) { - logger.warn(`[ContextEngine] full context estimate failed room=${input.roomId}, agent=${input.agentName}: ${err.message}`) + } catch (err: unknown) { + this.assertAuthorization(input) + const message = err instanceof Error ? err.message : 'unknown error' + logger.warn(`[ContextEngine] full context estimate failed room=${input.roomId}, agent=${input.agentName}: ${message}`) } + this.assertAuthorization(input) return messageTokenEstimate } @@ -229,6 +276,7 @@ export class ContextEngine { decision: 'incremental_compress', }, '[ContextEngine] compression started') meta.compressed = true + this.assertAuthorization(input) input.onProgress?.({ status: 'compressing', path: 'snapshot', @@ -236,6 +284,7 @@ export class ContextEngine { tokenCount: totalTokens, }) + this.assertAuthorization(input) const t0 = Date.now() const result = await this.summarize( input.roomId, @@ -244,16 +293,20 @@ export class ContextEngine { input.apiKey, input.profile || 'default', snapshot.summary, + input.authorizationGuard, + input.summarySessionRegistrar, ) + this.assertAuthorizationAfterExternal(input, result.sessionId) + this.cleanupSummarySession(input.roomId, result.sessionId) const elapsed = Date.now() - t0 if (result.summary) { const lastMsg = newMessages[newMessages.length - 1] - this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastMsg.id, lastMsg.timestamp) - meta.summaryTokenEstimate = this.countTokens(result.summary) const history = this.buildHistory(result.summary, newMessages, input.agentId, input.agentSocketId, input.agentName) meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history)) + this.assertAuthorization(input) + this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastMsg.id, lastMsg.timestamp) logger.info({ roomId: input.roomId, agentName: input.agentName, @@ -266,7 +319,6 @@ export class ContextEngine { elapsedMs: elapsed, }, '[ContextEngine] compression completed') this.logHistory('Path A (after incremental compress)', history) - if (result.sessionId) this.sessionCleaner?.(result.sessionId) return { conversationHistory: history, instructions, meta } } @@ -343,6 +395,7 @@ export class ContextEngine { decision: 'full_compress', }, '[ContextEngine] compression started') meta.compressed = true + this.assertAuthorization(input) input.onProgress?.({ status: 'compressing', path: 'full', @@ -350,6 +403,7 @@ export class ContextEngine { tokenCount: totalTokens, }) + this.assertAuthorization(input) const t0 = Date.now() const result = await this.summarize( input.roomId, @@ -357,7 +411,12 @@ export class ContextEngine { input.upstream, input.apiKey, input.profile || 'default', + undefined, + input.authorizationGuard, + input.summarySessionRegistrar, ) + this.assertAuthorizationAfterExternal(input, result.sessionId) + this.cleanupSummarySession(input.roomId, result.sessionId) const elapsed = Date.now() - t0 if (result.summary) { @@ -367,11 +426,11 @@ export class ContextEngine { const tail = messages.length > tailMessageCount ? messages.slice(-tailMessageCount) : [] const lastCompressedMsg = toCompress[toCompress.length - 1] - this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) - meta.summaryTokenEstimate = this.countTokens(result.summary) const history = this.buildHistory(result.summary, tail, input.agentId, input.agentSocketId, input.agentName) meta.contextTokenEstimate = await estimateFullContextTokens(history, this.estimateTokens(history)) + this.assertAuthorization(input) + this.messageFetcher.saveContextSnapshot(input.roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) logger.info({ roomId: input.roomId, agentName: input.agentName, @@ -385,7 +444,6 @@ export class ContextEngine { elapsedMs: elapsed, }, '[ContextEngine] compression completed') this.logHistory('Path B (after full compress)', history) - if (result.sessionId) this.sessionCleaner?.(result.sessionId) return { conversationHistory: history, instructions, meta } } @@ -416,29 +474,72 @@ export class ContextEngine { * Force compress all messages in a room (full compression). * Used when user manually triggers compression. */ - async forceCompress(roomId: string, profile?: string): Promise { - const allMessages = this.messageFetcher.getMessagesForContext(roomId) - if (allMessages.length === 0) return '' + async forceCompress( + roomId: string, + profile: string, + summarySessionRegistrar: () => GatewaySessionLease, + ): Promise { + return this.runWithCompressionLock(roomId, () => this._forceCompressImpl( + roomId, + profile, + summarySessionRegistrar, + )) + } - const config = { ...this.config } - logger.debug(`[ContextEngine] forceCompress room=${roomId}, messages=${allMessages.length}`) + private async _forceCompressImpl( + roomId: string, + profile: string, + summarySessionRegistrar: () => GatewaySessionLease, + ): Promise { + const summarySession = summarySessionRegistrar() + let handedOff = false + try { + this.assertGuard(summarySession.authorizationGuard) + const allMessages = this.messageFetcher.getMessagesForContext(roomId) + if (allMessages.length === 0) return '' - const t0 = Date.now() - const result = await this.summarize(roomId, allMessages, this._upstream, this._apiKey, profile || 'default') - const elapsed = Date.now() - t0 + const config = { ...this.config } + logger.debug(`[ContextEngine] forceCompress room=${roomId}, messages=${allMessages.length}`) - if (result.summary) { - const { tailMessageCount } = config - const toCompress = allMessages.length > tailMessageCount ? allMessages.slice(0, -tailMessageCount) : allMessages - const lastCompressedMsg = toCompress[toCompress.length - 1] + const t0 = Date.now() + const result = await this.summarize( + roomId, + allMessages, + this._upstream, + this._apiKey, + profile, + undefined, + summarySession.authorizationGuard, + () => { + handedOff = true + return summarySession + }, + ) + this.assertGuard(summarySession.authorizationGuard) + const elapsed = Date.now() - t0 - this.messageFetcher.saveContextSnapshot(roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) - logger.debug(`[ContextEngine] forceCompress DONE in ${elapsed}ms`) - if (result.sessionId) this.sessionCleaner?.(result.sessionId) - return result.summary - } + if (result.summary) { + const { tailMessageCount } = config + const toCompress = allMessages.length > tailMessageCount ? allMessages.slice(0, -tailMessageCount) : allMessages + const lastCompressedMsg = toCompress[toCompress.length - 1] + + this.assertGuard(summarySession.authorizationGuard) + this.messageFetcher.saveContextSnapshot(roomId, result.summary, lastCompressedMsg.id, lastCompressedMsg.timestamp) + logger.debug(`[ContextEngine] forceCompress DONE in ${elapsed}ms`) + return result.summary + } - throw new Error('Compression failed') + throw new Error('Compression failed') + } finally { + if (!handedOff) { + try { + summarySession.release() + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'unknown error' + logger.warn(`[ContextEngine] failed to release unused forced-compression session: ${message}`) + } + } + } } // ─── Private ───────────────────────────────────────────── @@ -463,9 +564,11 @@ export class ContextEngine { roomId: string, messages: StoredMessage[], upstream: string, - apiKey: string | null, + apiKey: BuildContextInput['apiKey'], profile: string, - previousSummary?: string, + previousSummary: string | undefined, + authorizationGuard: (() => boolean) | null, + sessionRegistrar: () => GatewaySessionLease, ): Promise<{ summary: string | null; sessionId: string | null }> { if (messages.length === 0 && !previousSummary) return { summary: null, sessionId: null } @@ -478,13 +581,22 @@ export class ContextEngine { roomId, profile, previousSummary, + sessionRegistrar, ) return { summary: result.summary, sessionId: result.sessionId } - } catch (err: any) { - logger.warn(`[ContextEngine] Summarization failed for room ${roomId}: ${err.message}`) + } catch (err: unknown) { + if (authorizationGuard) { + let current = false + try { + current = authorizationGuard() + } catch { + current = false + } + if (!current) throw new ContextAuthorizationChangedError() + } + const message = err instanceof Error ? err.message : 'unknown error' + logger.warn(`[ContextEngine] Summarization failed for room ${roomId}: ${message}`) return { summary: null, sessionId: null } - } finally { - // Session cleanup handled here if sessionCleaner is provided } } diff --git a/packages/server/src/services/hermes/context-engine/gateway-client.ts b/packages/server/src/services/hermes/context-engine/gateway-client.ts index 9be28dd94d..b255542425 100644 --- a/packages/server/src/services/hermes/context-engine/gateway-client.ts +++ b/packages/server/src/services/hermes/context-engine/gateway-client.ts @@ -1,4 +1,4 @@ -import type { StoredMessage, GatewayCaller } from './types' +import type { StoredMessage, GatewayCaller, GatewaySessionLease } from './types' import { buildSummarizationSystemPrompt, buildFullSummaryPrompt, @@ -25,30 +25,35 @@ export class GatewaySummarizer implements GatewayCaller { messages: StoredMessage[], roomId: string, profile: string, - previousSummary?: string, + previousSummary: string | undefined, + sessionRegistrar: () => GatewaySessionLease, ): Promise<{ summary: string; sessionId: string }> { - const history: Array<{ role: string; content: string }> = messages.map(m => ({ - role: 'user', - content: summarizeMessageForPrompt(m), - })) + const registeredSession = sessionRegistrar() + try { + if (!/^gc_h_[0-9a-f]{32}$/.test(registeredSession.sessionId)) { + throw new Error('Registered group chat summary session ID is invalid') + } + assertGatewaySessionLeaseCurrent(registeredSession) - if (previousSummary) { - history.unshift( - { role: 'user', content: `[Previous summary]\n${previousSummary}` }, - { role: 'assistant', content: 'Understood, I will update the summary.' }, - ) - } + const history: Array<{ role: string; content: string }> = messages.map(m => ({ + role: 'user', + content: summarizeMessageForPrompt(m), + })) - const userPrompt = previousSummary - ? buildIncrementalUpdatePrompt() - : buildFullSummaryPrompt() + if (previousSummary) { + history.unshift( + { role: 'user', content: `[Previous summary]\n${previousSummary}` }, + { role: 'assistant', content: 'Understood, I will update the summary.' }, + ) + } - const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 }) - const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - .replace(/[^a-zA-Z0-9_-]/g, '_') - .slice(0, 160) + const userPrompt = previousSummary + ? buildIncrementalUpdatePrompt() + : buildFullSummaryPrompt() - try { + const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 }) + const sessionId = registeredSession.sessionId + assertGatewaySessionLeaseCurrent(registeredSession) const result = await bridge.request({ action: 'chat', session_id: sessionId, @@ -60,6 +65,7 @@ export class GatewaySummarizer implements GatewayCaller { wait: true, timeout: Math.ceil(this.timeoutMs / 1000), }, { timeoutMs: this.timeoutMs + 15_000 }) + assertGatewaySessionLeaseCurrent(registeredSession) if (result.status === 'error') { throw new Error(result.error || 'Summarization bridge run failed') @@ -72,11 +78,25 @@ export class GatewaySummarizer implements GatewayCaller { logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`) return { summary: output, sessionId } } finally { - await bridge.destroy(sessionId, profile).catch(() => undefined) + try { + registeredSession.release() + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'unknown error' + logger.warn(`[GatewaySummarizer] Failed to activate durable cleanup for registered summary session: ${message}`) + } } } } +function assertGatewaySessionLeaseCurrent(lease: GatewaySessionLease): void { + try { + if (lease.authorizationGuard()) return + } catch { + // Guard/storage failures are authorization failures. + } + throw new Error('Group chat summary session authorization changed') +} + function summarizeMessageForPrompt(message: StoredMessage): string { if (message.role === 'tool') { const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result' diff --git a/packages/server/src/services/hermes/context-engine/types.ts b/packages/server/src/services/hermes/context-engine/types.ts index 249bd6acf2..5ec25dc396 100644 --- a/packages/server/src/services/hermes/context-engine/types.ts +++ b/packages/server/src/services/hermes/context-engine/types.ts @@ -84,15 +84,22 @@ export interface MessageFetcher { deleteContextSnapshot(roomId: string): void } +export interface GatewaySessionLease { + sessionId: string + authorizationGuard: () => boolean + release: () => void +} + export interface GatewayCaller { summarize( upstream: string, - apiKey: string | null, + apiKey: BuildContextInput['apiKey'], systemPrompt: string, messages: StoredMessage[], roomId: string, profile: string, - previousSummary?: string, + previousSummary: string | undefined, + sessionRegistrar: () => GatewaySessionLease, ): Promise<{ summary: string; sessionId: string }> } @@ -125,6 +132,10 @@ export interface BuildContextInput { upstream: string apiKey: string | null currentMessage: StoredMessage + /** Must remain true before private context reads, logs, persistence, or external summarization. */ + authorizationGuard: () => boolean + /** Atomically registers an opaque, cleanup-leased Bridge session before summarization. */ + summarySessionRegistrar: () => GatewaySessionLease compression?: Partial profile?: string contextTokenEstimator?: ( diff --git a/packages/server/src/services/hermes/group-chat/access-policy.ts b/packages/server/src/services/hermes/group-chat/access-policy.ts new file mode 100644 index 0000000000..a7c564eb20 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/access-policy.ts @@ -0,0 +1,320 @@ +import { createHash, timingSafeEqual } from 'node:crypto' + +import { + groupChatCapabilityFlags, + normalizeGroupChatCapabilities, + SUPPORTED_GROUP_CHAT_CAPABILITIES, + type GroupChatCapability, + type GroupChatCapabilityDecision, +} from './identity/capability-policy' +import type { GroupActor, GroupActorType } from './identity/types' +import { revisionNumber } from './identity/types' + +export interface GroupChatAuthenticatedUserLike { + id?: number | null + role: string + profiles?: readonly string[] +} + +export interface GroupChatRoomRecord { + id: string + inviteCode?: string | null + ownerAuthUserId?: number | null + authorizationRevision?: number +} + +export interface GroupChatMemberRecord { + id: string + userId: string + name: string + description: string + joinedAt: number + avatar: string + authUserId?: number | null +} + +export interface GroupChatAccessStore { + getRoom?: (roomId: string) => GroupChatRoomRecord | null | undefined + getMemberByAuthUserId?: (roomId: string, authUserId: number) => GroupChatMemberRecord | null + getMemberByUserId?: (roomId: string, userId: string) => GroupChatMemberRecord | null + findActiveActorByAuthUserId?: (roomId: string, authUserId: number) => GroupActor | null + findActiveActorByAgentIdentity?: (roomId: string, agentId: string) => GroupActor | null + findActiveActorByLocalSubjectId?: (roomId: string, localSubjectId: string) => GroupActor | null + findActiveActorBySystemKey?: (roomId: string, systemKey: string) => GroupActor | null + getActorCapabilities?: (actorId: string) => readonly string[] +} + +export type GroupChatSubject = + | { + kind: 'authenticated_human' + authUserId: number | null + role: string + profiles: readonly string[] + } + | { + kind: 'agent' + agentId: string + } + | { + kind: 'local' + localSubjectId: string + } + | { + kind: 'system' + systemKey: string + } + +export interface GroupChatAccessDecision extends GroupChatCapabilityDecision { + roomId: string + roomExists: boolean + canDiscover: boolean + canJoin: boolean + canInvokeAgent: boolean + canRespondApproval: boolean + actorId: string | null + actorType: GroupActorType | null + actorAuthorizationRevision: number + actorContextRevision: number + roomAuthorizationRevision: number +} + +export interface GroupChatAccessPolicyInput { + roomId: string + room: GroupChatRoomRecord | null + subject: GroupChatSubject + actor: GroupActor | null + storedCapabilities: readonly string[] + hasMembership: boolean +} + +function inviteDigest(value: string): Buffer { + return createHash('sha256') + .update('group-chat-invite-v1\0', 'utf8') + .update(value, 'utf8') + .digest() +} + +export function groupChatInviteCodeMatches( + requestedInviteCode: string | null | undefined, + storedInviteCode: string | null | undefined, +): boolean { + if ( + typeof requestedInviteCode !== 'string' || + requestedInviteCode.length === 0 || + typeof storedInviteCode !== 'string' || + storedInviteCode.length === 0 + ) { + return false + } + return timingSafeEqual(inviteDigest(requestedInviteCode), inviteDigest(storedInviteCode)) +} + +function toPositiveInteger(value: number): number | null { + return Number.isInteger(value) && value > 0 ? value : null +} + +function groupChatDecision( + roomId: string, + roomExists: boolean, + roomAuthorizationRevision: number, + actor: GroupActor | null, + capabilities: readonly string[], +): GroupChatAccessDecision { + const flags = groupChatCapabilityFlags(capabilities) + return { + roomId, + roomExists, + canDiscover: flags.canRead, + canJoin: flags.canRead, + canInvokeAgent: flags.canInvokeAgents, + canRespondApproval: flags.canApprove, + actorId: actor?.id ?? null, + actorType: actor?.actorType ?? null, + actorAuthorizationRevision: revisionNumber(actor?.authorizationRevision), + actorContextRevision: revisionNumber(actor?.contextRevision), + roomAuthorizationRevision, + ...flags, + } +} + +function actorCapabilities(storage: GroupChatAccessStore, actor: GroupActor | null): GroupChatCapability[] { + if (!actor || typeof storage.getActorCapabilities !== 'function') { + return [] + } + return normalizeGroupChatCapabilities(storage.getActorCapabilities(actor.id)) +} + +function profileList(user: GroupChatAuthenticatedUserLike | null | undefined): string[] { + return Array.isArray(user?.profiles) ? user.profiles.map(String).filter(Boolean) : [] +} + +function isRoomOwner(room: GroupChatRoomRecord, authUserId: number): boolean { + const ownerAuthUserId = toPositiveInteger(Number(room.ownerAuthUserId ?? 0)) + return ownerAuthUserId !== null && ownerAuthUserId === authUserId +} + +function findActorForSubject( + storage: GroupChatAccessStore, + roomId: string, + subject: GroupChatSubject, +): GroupActor | null { + if (subject.kind === 'authenticated_human') { + return typeof storage.findActiveActorByAuthUserId === 'function' && subject.authUserId !== null + ? storage.findActiveActorByAuthUserId(roomId, subject.authUserId) + : null + } + if (subject.kind === 'agent') { + return typeof storage.findActiveActorByAgentIdentity === 'function' + ? storage.findActiveActorByAgentIdentity(roomId, subject.agentId) + : null + } + if (subject.kind === 'local') { + return typeof storage.findActiveActorByLocalSubjectId === 'function' + ? storage.findActiveActorByLocalSubjectId(roomId, subject.localSubjectId) + : null + } + return typeof storage.findActiveActorBySystemKey === 'function' + ? storage.findActiveActorBySystemKey(roomId, subject.systemKey) + : null +} + +export function createAuthenticatedGroupChatSubject( + user: GroupChatAuthenticatedUserLike | null | undefined, +): GroupChatSubject | null { + const authUserId = toPositiveInteger(Number(user?.id)) + const role = String(user?.role ?? '') + if (authUserId === null && role !== 'super_admin') { + return null + } + return { + kind: 'authenticated_human', + authUserId, + role, + profiles: profileList(user), + } +} + +export function createAgentGroupChatSubject(agentId: string): GroupChatSubject { + return { + kind: 'agent', + agentId: String(agentId || ''), + } +} + +export function createLocalGroupChatSubject(localSubjectId: string): GroupChatSubject { + return { + kind: 'local', + localSubjectId: String(localSubjectId || ''), + } +} + +export function createSystemGroupChatSubject(systemKey = 'room-system'): GroupChatSubject { + return { + kind: 'system', + systemKey: String(systemKey || 'room-system'), + } +} + +export function groupChatUserProfiles(user: GroupChatAuthenticatedUserLike | null | undefined): string[] { + return profileList(user) +} + +export function decideGroupChatAccessPolicy(input: GroupChatAccessPolicyInput): GroupChatAccessDecision { + const { roomId, room, subject, actor, storedCapabilities, hasMembership } = input + const roomAuthorizationRevision = revisionNumber(room?.authorizationRevision) + + if (!room) { + return groupChatDecision(roomId, false, roomAuthorizationRevision, actor, []) + } + + if (subject.kind === 'authenticated_human') { + if ( + subject.role === 'super_admin' || + (subject.authUserId !== null && isRoomOwner(room, subject.authUserId)) + ) { + return groupChatDecision( + roomId, + true, + roomAuthorizationRevision, + actor, + SUPPORTED_GROUP_CHAT_CAPABILITIES, + ) + } + const effectiveCapabilities = hasMembership + ? normalizeGroupChatCapabilities([...storedCapabilities, 'room.read']) + : storedCapabilities + return groupChatDecision(roomId, true, roomAuthorizationRevision, actor, effectiveCapabilities) + } + + return groupChatDecision( + roomId, + true, + roomAuthorizationRevision, + actor, + actor ? storedCapabilities : [], + ) +} + +export function evaluateGroupChatAccessPolicy( + storage: GroupChatAccessStore, + roomId: string, + subject: GroupChatSubject, +): GroupChatAccessDecision { + const room = typeof storage.getRoom === 'function' ? storage.getRoom(roomId) ?? null : null + const actor = findActorForSubject(storage, roomId, subject) + const storedCapabilities = actorCapabilities(storage, actor) + const hasMembership = subject.kind === 'authenticated_human' + && subject.authUserId !== null + && typeof storage.getMemberByAuthUserId === 'function' + ? Boolean(storage.getMemberByAuthUserId(roomId, subject.authUserId)) + : false + + return decideGroupChatAccessPolicy({ + roomId, + room, + subject, + actor, + storedCapabilities, + hasMembership, + }) +} + +export function canAuthenticatedUserAccessRoom( + storage: GroupChatAccessStore, + roomId: string, + user: GroupChatAuthenticatedUserLike, + inviteCode?: string, +): boolean { + const room = typeof storage.getRoom === 'function' ? storage.getRoom(roomId) ?? null : null + if (!room) { + return false + } + if (groupChatInviteCodeMatches(inviteCode, room.inviteCode)) { + return true + } + const subject = createAuthenticatedGroupChatSubject(user) + if (!subject) { + return false + } + return evaluateGroupChatAccessPolicy(storage, roomId, subject).canRead +} + +export function canLocalSubjectAccessRoom( + storage: GroupChatAccessStore, + roomId: string, + localSubjectId: string, + inviteCode?: string, +): boolean { + const room = typeof storage.getRoom === 'function' ? storage.getRoom(roomId) ?? null : null + if (!room) { + return false + } + if (!room.inviteCode || groupChatInviteCodeMatches(inviteCode, room.inviteCode)) { + return true + } + return evaluateGroupChatAccessPolicy( + storage, + roomId, + createLocalGroupChatSubject(localSubjectId), + ).canRead +} diff --git a/packages/server/src/services/hermes/group-chat/access.ts b/packages/server/src/services/hermes/group-chat/access.ts index faac510e0b..5dcabfd912 100644 --- a/packages/server/src/services/hermes/group-chat/access.ts +++ b/packages/server/src/services/hermes/group-chat/access.ts @@ -1,33 +1,52 @@ import type { GroupChatServer } from './index' +import { + createAuthenticatedGroupChatSubject, + createLocalGroupChatSubject, + evaluateGroupChatAccessPolicy, + groupChatUserProfiles, + type GroupChatAccessDecision, + type GroupChatAuthenticatedUserLike, + type GroupChatSubject, +} from './access-policy' type GroupChatStorage = ReturnType -function userProfiles(user: any): string[] { - return Array.isArray(user?.profiles) ? user.profiles.map(String).filter(Boolean) : [] +export function createGroupChatRequestSubject( + user: GroupChatAuthenticatedUserLike | null | undefined, + localSubjectId?: string | null, +): GroupChatSubject | null { + const authenticatedSubject = createAuthenticatedGroupChatSubject(user) + if (authenticatedSubject) return authenticatedSubject + if (typeof localSubjectId !== 'string' || !localSubjectId.trim()) return null + return createLocalGroupChatSubject(localSubjectId.trim()) } -function isRoomOwner(room: any, user: any): boolean { - return typeof user?.id === 'number' && Number(room?.ownerAuthUserId || 0) === user.id +export function evaluateGroupChatRequestAccess( + storage: GroupChatStorage, + roomId: string, + user: GroupChatAuthenticatedUserLike | null | undefined, + localSubjectId?: string | null, +): GroupChatAccessDecision | null { + const subject = createGroupChatRequestSubject(user, localSubjectId) + return subject ? evaluateGroupChatAccessPolicy(storage, roomId, subject) : null } -function hasProfileRoomAccess(storage: GroupChatStorage, roomId: string, user: any): boolean { - const profiles = userProfiles(user) - if (!profiles.length || typeof storage.getRoomsForProfiles !== 'function') return false - return storage.getRoomsForProfiles(profiles).some(room => room.id === roomId) +export function canManageGroupChatRoom( + storage: GroupChatStorage, + roomId: string, + user: GroupChatAuthenticatedUserLike | null | undefined, + localSubjectId?: string | null, +): boolean { + return Boolean(evaluateGroupChatRequestAccess(storage, roomId, user, localSubjectId)?.canManage) } -export function canManageGroupChatRoom(storage: GroupChatStorage, roomId: string, user: any): boolean { - if (!user || user.role === 'super_admin') return true - const room = typeof storage.getRoom === 'function' ? storage.getRoom(roomId) : null - if (room && isRoomOwner(room, user)) return true - return hasProfileRoomAccess(storage, roomId, user) +export function canReadGroupChatRoom( + storage: GroupChatStorage, + roomId: string, + user: GroupChatAuthenticatedUserLike | null | undefined, + localSubjectId?: string | null, +): boolean { + return Boolean(evaluateGroupChatRequestAccess(storage, roomId, user, localSubjectId)?.canRead) } -export function canReadGroupChatRoom(storage: GroupChatStorage, roomId: string, user: any): boolean { - if (canManageGroupChatRoom(storage, roomId, user)) return true - return typeof user?.id === 'number' && typeof storage.getMemberByAuthUserId === 'function' && !!storage.getMemberByAuthUserId(roomId, user.id) -} - -export function groupChatUserProfiles(user: any): string[] { - return userProfiles(user) -} +export { groupChatUserProfiles } diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index bad9f89408..8d0936d5a7 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -1,5 +1,5 @@ import { io, Socket } from 'socket.io-client' -import { createHash, randomBytes } from 'crypto' +import { createHmac, randomBytes } from 'crypto' import { getToken } from '../../../services/auth' import { logger } from '../../../services/logger' import { countTokens } from '../../../lib/context-compressor' @@ -12,9 +12,12 @@ import { startWorkspaceRunCheckpoint, } from '../run-chat/workspace-diff-tracker' import type { ContentBlock } from '../run-chat/types' -import type { StoredMessage } from '../context-engine/types' +import { ContextAuthorizationChangedError } from '../context-engine/compressor' +import type { GatewaySessionLease, StoredMessage } from '../context-engine/types' +import { SessionDeleter } from '../session-deleter' import { buildProjectedGroupChatHistory, isWorkspaceDiffToolMessage, projectGroupChatMessage } from './context-projection' import { sliceGroupMessagesForSnapshotTail } from './group-message-ordering' +import type { GroupActorRevisions } from './identity/types' import { isAllAgentsMentioned, resolveMentionTargets, @@ -68,6 +71,7 @@ export function mentionMessageToStoredContextMessage(roomId: string, msg: Mentio } type GroupEstimateMessage = { role: 'user' | 'assistant'; content: string } +const SUMMARY_SESSION_CRASH_CLEANUP_MS = 10 * 60 * 1000 export type GroupModelContext = { model: string; provider: string } type WorkspaceDiffTerminalStatus = 'completed' | 'failed' | 'aborted' type WorkspaceDiffBroadcaster = (roomId: string, message: MessageData & Record, totalTokens: number) => void @@ -99,6 +103,21 @@ interface BridgeContextCache { provider?: string } +type GroupBridgeSessionRevisions = Partial + +type GroupBridgeSessionIdentity = GroupBridgeSessionRevisions & { + sessionSeed: string +} + +function revisionNumber(value: unknown): number { + const numeric = Number(value) + return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0 +} + +function sessionActorIdentity(agentId: string, profile: string, name: string): string { + return agentId ? `agent:${agentId}` : `agent:${profile}:${name}` +} + export async function resolveGroupAgentModelContext(profile: string): Promise { return resolveBridgeRunModelConfig({ profile }) } @@ -177,7 +196,7 @@ class AgentClient { private workspaceDiffBroadcaster: WorkspaceDiffBroadcaster | null = null constructor(config: AgentConfig, handlers: AgentEventHandler = {}) { - this.agentId = config.agentId || Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + this.agentId = config.agentId || `gca_${randomBytes(16).toString('hex')}` this.profile = config.profile this.name = config.name this.description = config.description @@ -205,6 +224,107 @@ class AgentClient { this.workspaceDiffBroadcaster = broadcaster } + private currentRoomSessionIdentity(roomId: string, room = this.storage?.getRoom?.(roomId)): GroupBridgeSessionIdentity { + const sessionSeed = String(room?.sessionSeed || '') + if (!room || !/^[0-9a-f]{32}$/i.test(sessionSeed)) { + throw new Error(`Group chat room ${roomId} is missing a cryptographic session seed`) + } + const actor = typeof this.storage?.findActiveActorByAgentIdentity === 'function' + ? this.storage.findActiveActorByAgentIdentity(roomId, this.agentId) + : null + return { + sessionSeed, + actorId: actor?.id || null, + roomAuthorizationRevision: room.authorizationRevision, + actorAuthorizationRevision: actor?.authorizationRevision, + actorContextRevision: actor?.contextRevision, + } + } + + private registerCurrentRoomSession( + roomId: string, + sessionId: string, + identity: GroupBridgeSessionIdentity, + requireRunCapabilities: boolean, + cleanupAfterMs?: number, + ): void { + const register = this.storage?.registerSessionProfileForActiveAgent + if (typeof register !== 'function') { + throw new Error('Group chat storage cannot durably register Bridge sessions') + } + const registered = register.call(this.storage, { + sessionId, + roomId, + agentId: this.agentId, + profileName: this.profile, + agentName: this.name, + sessionSeed: identity.sessionSeed, + roomAuthorizationRevision: revisionNumber(identity.roomAuthorizationRevision), + actorId: identity.actorId || '', + actorAuthorizationRevision: revisionNumber(identity.actorAuthorizationRevision), + actorContextRevision: revisionNumber(identity.actorContextRevision), + requireRunCapabilities, + cleanupAfterMs, + }) + if (!registered) { + throw new Error(`Group chat room ${roomId} changed before Bridge session registration`) + } + } + + canCreateSummarySession(roomId: string): boolean { + try { + const identity = this.currentRoomSessionIdentity(roomId) + if (!identity.actorId || typeof this.storage?.getActorCapabilities !== 'function') return false + const capabilities = new Set(this.storage.getActorCapabilities(identity.actorId)) + return capabilities.has('room.read') && capabilities.has('room.write') + } catch { + return false + } + } + + createSummarySessionLease(roomId: string): GatewaySessionLease { + const enqueueCleanup = this.storage?.enqueuePendingSessionDelete + if (typeof enqueueCleanup !== 'function') { + throw new Error('Group chat storage cannot durably clean up Bridge sessions') + } + const identity = this.currentRoomSessionIdentity(roomId) + const authoritySessionId = groupBridgeSessionId( + roomId, + this.profile, + this.name, + identity.sessionSeed, + identity, + ) + const sessionId = groupBridgeSummarySessionId( + roomId, + this.profile, + this.name, + identity.sessionSeed, + identity, + ) + this.registerCurrentRoomSession( + roomId, + sessionId, + identity, + true, + SUMMARY_SESSION_CRASH_CLEANUP_MS, + ) + let released = false + return { + sessionId, + authorizationGuard: () => this.roomSessionIsCurrent(roomId, authoritySessionId), + release: () => { + if (released) return + released = true + enqueueCleanup.call(this.storage, sessionId, this.profile) + void SessionDeleter.getInstance().drain(this.profile).catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'unknown error' + logger.warn(`[AgentClients] failed to drain registered summary session cleanup: ${message}`) + }) + }, + } + } + async connect(port?: number): Promise { const actualPort = port ?? parseInt(process.env.PORT || '8648', 10) const token = await getToken() @@ -308,8 +428,9 @@ class AgentClient { } async interrupt(roomId: string): Promise { - const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') - const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) + const sessionIdentity = this.currentRoomSessionIdentity(roomId) + const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionIdentity.sessionSeed, sessionIdentity) + this.registerCurrentRoomSession(roomId, sessionId, sessionIdentity, false) let result: Awaited> | null = null try { result = await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile) @@ -422,6 +543,7 @@ class AgentClient { modelContext: GroupModelContext, phase: string, ): Promise { + if (!this.roomSessionIsCurrent(roomId, sessionId)) return undefined const cachedTokens = this.estimateWithCachedBridgeContext(sessionId, history, instructions, modelContext) if (cachedTokens != null) { logger.info({ @@ -511,8 +633,11 @@ class AgentClient { private roomSessionIsCurrent(roomId: string, sessionId: string): boolean { const room = this.storage?.getRoom?.(roomId) if (!room) return false - const seed = String(room.sessionSeed || '0') - return groupBridgeSessionId(roomId, this.profile, this.name, seed) === sessionId + const sessionIdentity = this.currentRoomSessionIdentity(roomId, room) + if (!sessionIdentity.actorId || typeof this.storage?.getActorCapabilities !== 'function') return false + const capabilities = new Set(this.storage.getActorCapabilities(sessionIdentity.actorId)) + if (!capabilities.has('room.read') || !capabilities.has('room.write')) return false + return groupBridgeSessionId(roomId, this.profile, this.name, sessionIdentity.sessionSeed, sessionIdentity) === sessionId } private markWorkspaceDiffAborted(roomId: string): WorkspaceDiffRunState[] { @@ -586,7 +711,6 @@ class AgentClient { msg: MentionMessage, onStatus?: (status: 'compressing' | 'replying' | 'ready', extra?: Record) => void, ): Promise { - logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`) const runMessageId = groupMessageId(roomId, this.profile, this.name) let partIndex = 0 let streamMessageId = groupMessagePartId(runMessageId, partIndex) @@ -601,15 +725,15 @@ class AgentClient { let staleStartedRunStopped = false let stopStaleStartedRun: ((reason?: string) => Promise) | null = null try { - // Notify room that agent is typing - this.startTyping(roomId) - // Build compressed context if context engine is available let conversationHistory: Array<{ role: string; content: string }> = [] let instructions: string | undefined const bridge = new AgentBridgeClient() - const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') - const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) + const sessionIdentity = this.currentRoomSessionIdentity(roomId) + const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionIdentity.sessionSeed, sessionIdentity) + this.registerCurrentRoomSession(roomId, sessionId, sessionIdentity, true) + logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`) + this.startTyping(roomId) const replyInterruptVersion = this.interruptVersion(sessionId) const reportStatus = (status: 'compressing' | 'replying' | 'ready', extra?: Record) => { onStatus?.(status, { ...extra, agentSessionId: sessionId }) @@ -653,6 +777,10 @@ class AgentClient { reportStatus('ready') } const modelContext = await resolveGroupAgentModelContext(this.profile) + if (!this.replySessionIsCurrent(roomId, sessionId, replyInterruptVersion)) { + await stopStaleStartedRun('Interrupted because group chat run authority changed') + return + } if (this.contextEngine && this.storage) { try { @@ -682,6 +810,8 @@ class AgentClient { upstream: '', apiKey: null, currentMessage: mentionMessageToStoredContextMessage(roomId, msg), + authorizationGuard: () => this.replySessionIsCurrent(roomId, sessionId, replyInterruptVersion), + summarySessionRegistrar: () => this.createSummarySessionLease(roomId), compression, profile: this.profile, onProgress: (event: { status: 'compressing'; messageCount: number; tokenCount: number }) => { @@ -714,8 +844,13 @@ class AgentClient { } logger.debug(`[AgentClients] ${this.name}: context built — historyLen=${conversationHistory.length}, meta=%j`, ctx.meta) reportStatus('replying') - } catch (err: any) { - logger.warn(`[AgentClients] ${this.name}: context engine failed: ${err.message}`) + } catch (err: unknown) { + if (err instanceof ContextAuthorizationChangedError) { + await stopStaleStartedRun('Interrupted because group chat context authority changed') + return + } + const message = err instanceof Error ? err.message : String(err) + logger.warn(`[AgentClients] ${this.name}: context engine failed: ${message}`) reportStatus('replying') // Degrade: continue without context } @@ -889,8 +1024,6 @@ class AgentClient { this.stopTyping(roomId) if (activeSessionId) { onStatus?.('ready', { agentSessionId: activeSessionId }) - } else { - onStatus?.('ready') } } } @@ -1135,16 +1268,62 @@ class AgentClient { } } -export function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string { - const rawKey = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}` - const safePrefix = rawKey.replace(/[^a-zA-Z0-9_-]/g, '_') - const keyHash = createHash('sha256').update(rawKey).digest('hex').slice(0, 16) - const suffix = `_h_${keyHash}` - return `${safePrefix.slice(0, Math.max(0, 120 - suffix.length))}${suffix}` +function groupBridgeHmacSessionId(sessionSeed: string, values: unknown[]): string { + if (!/^[0-9a-f]{32}$/i.test(sessionSeed)) { + throw new Error('Group chat Bridge session IDs require a cryptographic room seed') + } + const hmac = createHmac('sha256', Buffer.from(sessionSeed, 'hex')) + for (const value of values) { + const bytes = Buffer.from(String(value ?? ''), 'utf8') + const length = Buffer.allocUnsafe(4) + length.writeUInt32BE(bytes.length) + hmac.update(length) + hmac.update(bytes) + } + return `gc_h_${hmac.digest('hex').slice(0, 32)}` +} + +export function groupBridgeSessionId( + roomId: string, + profile: string, + name: string, + sessionSeed: string, + revisions: GroupBridgeSessionRevisions = {}, +): string { + return groupBridgeHmacSessionId(sessionSeed, [ + 'group-chat-bridge-session-v2', + roomId, + profile, + name, + revisions.actorId || '', + revisionNumber(revisions.roomAuthorizationRevision), + revisionNumber(revisions.actorAuthorizationRevision), + revisionNumber(revisions.actorContextRevision), + ]) +} + +export function groupBridgeSummarySessionId( + roomId: string, + profile: string, + name: string, + sessionSeed: string, + revisions: GroupBridgeSessionRevisions = {}, +): string { + return groupBridgeHmacSessionId(sessionSeed, [ + 'group-chat-bridge-summary-session-v1', + roomId, + profile, + name, + revisions.actorId || '', + revisionNumber(revisions.roomAuthorizationRevision), + revisionNumber(revisions.actorAuthorizationRevision), + revisionNumber(revisions.actorContextRevision), + randomBytes(16).toString('hex'), + ]) } function groupMessageId(roomId: string, profile: string, name: string): string { - const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${randomBytes(16).toString('hex')}` return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160) } @@ -1278,6 +1457,18 @@ export class AgentClients { return this.rooms.get(roomId)?.get(agentId) } + getSummarySessionContext(roomId: string): { + profile: string + sessionRegistrar: () => GatewaySessionLease + } | null { + const agent = this.getAgents(roomId).find(candidate => candidate.canCreateSummarySession(roomId)) + if (!agent) return null + return { + profile: agent.profile, + sessionRegistrar: () => agent.createSummarySessionLease(roomId), + } + } + /** * Get all room IDs that have agents. */ diff --git a/packages/server/src/services/hermes/group-chat/identity/actor-ids.ts b/packages/server/src/services/hermes/group-chat/identity/actor-ids.ts new file mode 100644 index 0000000000..061753b497 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/identity/actor-ids.ts @@ -0,0 +1,74 @@ +import { createHash, randomUUID } from 'node:crypto' + +import type { GroupActorType } from './types' + +type GroupActorIdentitySeed = { + roomId: string + actorType: GroupActorType + authUserId?: number | null + agentId?: string | null + localSubjectId?: string | null + systemKey?: string | null + stableSourceKey: string +} + +function sha256Hex(parts: ReadonlyArray): string { + const hash = createHash('sha256') + for (const part of parts) { + hash.update(String(part)) + hash.update('\0') + } + return hash.digest('hex') +} + +function authoritativeIdentity(seed: GroupActorIdentitySeed): string { + if (seed.actorType === 'authenticated_human') { + return `auth:${seed.authUserId ?? ''}` + } + if (seed.actorType === 'agent') { + return `agent:${seed.agentId ?? ''}` + } + if (seed.actorType === 'local') { + return `local:${seed.localSubjectId ?? ''}` + } + return `system:${seed.systemKey ?? ''}` +} + +export function createGroupActorId(): string { + return `gca_${randomUUID()}` +} + +export function createGroupActorCapabilityId(): string { + return `gcac_${randomUUID()}` +} + +export function createDeterministicGroupActorId(seed: GroupActorIdentitySeed): string { + return `gca_${sha256Hex([ + 'group-chat-identity-v1', + seed.roomId, + seed.actorType, + authoritativeIdentity(seed), + seed.stableSourceKey, + ]).slice(0, 32)}` +} + +export function createDeterministicGroupActorCapabilityId( + roomId: string, + actorId: string, + capability: string, +): string { + return `gcac_${sha256Hex([ + 'group-chat-capability-v1', + roomId, + actorId, + capability, + ]).slice(0, 32)}` +} + +export function createDeterministicLocalSubjectId(roomId: string, stableSourceKey: string): string { + return `local:${sha256Hex([ + 'group-chat-local-subject-v1', + roomId, + stableSourceKey, + ]).slice(0, 32)}` +} diff --git a/packages/server/src/services/hermes/group-chat/identity/actor-store.ts b/packages/server/src/services/hermes/group-chat/identity/actor-store.ts new file mode 100644 index 0000000000..f5db1baec2 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/identity/actor-store.ts @@ -0,0 +1,694 @@ +import type { DatabaseSync } from 'node:sqlite' + +import { + AGENT_GROUP_CHAT_CAPABILITIES, + isSupportedGroupChatCapability, + LOCAL_GROUP_CHAT_CAPABILITIES, + normalizeGroupChatCapabilities, + SYSTEM_GROUP_CHAT_CAPABILITIES, + type GroupChatCapability, +} from './capability-policy' +import { + createDeterministicGroupActorCapabilityId, + createDeterministicGroupActorId, + createDeterministicLocalSubjectId, + createGroupActorCapabilityId, + createGroupActorId, +} from './actor-ids' +import type { + EnsureAgentActorInput, + EnsureAuthenticatedHumanActorInput, + EnsureLocalActorInput, + EnsureSystemActorInput, + GroupActor, + GroupActorMetadata, + GroupActorType, +} from './types' +import { + verifyAgentActorInput, + verifyAuthenticatedHumanActorInput, + verifyLocalActorInput, + verifySystemActorInput, +} from './types' + +const ACTOR_TABLE = 'gc_room_actors' +const CAPABILITY_TABLE = 'gc_room_actor_capabilities' +const DELETED_AGENT_NAME = 'Deleted agent' + +type TransactionalDatabase = DatabaseSync & { + readonly inTransaction?: boolean + readonly isTransaction?: boolean +} + +type ActorInsertSeed = { + roomId: string + actorType: GroupActorType + authUserId: number | null + agentId: string | null + localSubjectId: string | null + systemKey: string | null + metadata: GroupActorMetadata + id?: string + createdAt?: number + updatedAt?: number +} + +type EnsureActorSeed = { + roomId: string + actorType: GroupActorType + metadata: GroupActorMetadata + capabilities: readonly string[] + replaceExistingCapabilities?: boolean + findExisting: () => GroupActor | null + insertSeed: Omit +} + +type LegacyActorSource = { + roomId: string + actorType: GroupActorType + authUserId: number | null + agentId: string | null + localSubjectId: string | null + systemKey: string | null + name: string + description: string + avatar: string + stableSourceKey: string + createdAt: number + capabilities: readonly GroupChatCapability[] +} + +export interface GroupChatActorBackfillSummary { + actorCount: number + capabilityCount: number +} + +const ACTOR_SELECT = ` + SELECT id, roomId, actorType, authUserId, agentId, localSubjectId, systemKey, + name, description, avatar, active, authorizationRevision, contextRevision, + tombstonedAt, createdAt, updatedAt + FROM ${ACTOR_TABLE} +` + +function isUniqueConstraintError(error: unknown): boolean { + return error instanceof Error && /unique constraint/i.test(error.message) +} + +function withImmediateTransaction(db: TransactionalDatabase, fn: () => T): T { + if (db.inTransaction || db.isTransaction) { + return fn() + } + db.exec('BEGIN IMMEDIATE') + try { + const result = fn() + db.exec('COMMIT') + return result + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + // best effort rollback + } + throw error + } +} + +function findActorById(db: DatabaseSync, actorId: string): GroupActor | null { + return (db.prepare(`${ACTOR_SELECT} WHERE id = ?`).get(actorId) as GroupActor | undefined) ?? null +} + +function updateActorMetadata(db: DatabaseSync, actorId: string, metadata: GroupActorMetadata, updatedAt: number): void { + db.prepare( + `UPDATE ${ACTOR_TABLE} + SET name = ?, description = ?, avatar = ?, updatedAt = ? + WHERE id = ?` + ).run(metadata.name, metadata.description, metadata.avatar, updatedAt, actorId) +} + +function replaceActorCapabilities( + db: DatabaseSync, + roomId: string, + actorId: string, + capabilities: readonly string[], +): { capabilities: GroupChatCapability[]; changed: boolean } { + const normalized = normalizeGroupChatCapabilities(capabilities) + const current = getActorCapabilities(db, actorId) + const changed = current.length !== normalized.length + || current.some((capability, index) => capability !== normalized[index]) + if (!changed) { + return { capabilities: normalized, changed: false } + } + db.prepare(`DELETE FROM ${CAPABILITY_TABLE} WHERE actorId = ?`).run(actorId) + if (!normalized.length) { + return { capabilities: normalized, changed: true } + } + const now = Date.now() + const insert = db.prepare( + `INSERT INTO ${CAPABILITY_TABLE} + (id, roomId, actorId, capability, active, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 1, ?, ?)` + ) + for (const capability of normalized) { + insert.run(createGroupActorCapabilityId(), roomId, actorId, capability, now, now) + } + return { capabilities: normalized, changed: true } +} + +function insertActor(db: DatabaseSync, seed: ActorInsertSeed): GroupActor { + const now = seed.updatedAt ?? seed.createdAt ?? Date.now() + const createdAt = seed.createdAt ?? now + const id = seed.id ?? createGroupActorId() + db.prepare( + `INSERT INTO ${ACTOR_TABLE} + (id, roomId, actorType, authUserId, agentId, localSubjectId, systemKey, name, description, avatar, active, authorizationRevision, contextRevision, tombstonedAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, NULL, ?, ?)` + ).run( + id, + seed.roomId, + seed.actorType, + seed.authUserId, + seed.agentId, + seed.localSubjectId, + seed.systemKey, + seed.metadata.name, + seed.metadata.description, + seed.metadata.avatar, + createdAt, + now, + ) + const actor = findActorById(db, id) + if (!actor) { + throw new Error(`failed to read back actor ${id}`) + } + return actor +} + +function ensureActor(db: DatabaseSync, seed: EnsureActorSeed): GroupActor { + return withImmediateTransaction(db as TransactionalDatabase, () => { + const updatedAt = Date.now() + let actor = seed.findExisting() + let createdActor = false + if (!actor) { + try { + actor = insertActor(db, { + ...seed.insertSeed, + roomId: seed.roomId, + actorType: seed.actorType, + metadata: seed.metadata, + createdAt: updatedAt, + updatedAt, + }) + createdActor = true + } catch (error) { + if (!isUniqueConstraintError(error)) { + throw error + } + actor = seed.findExisting() + if (!actor) { + throw error + } + } + } + const metadataChanged = actor.name !== seed.metadata.name + || actor.description !== seed.metadata.description + || actor.avatar !== seed.metadata.avatar + updateActorMetadata(db, actor.id, seed.metadata, updatedAt) + const capabilityReplacement = createdActor || seed.replaceExistingCapabilities + ? replaceActorCapabilities(db, seed.roomId, actor.id, seed.capabilities) + : { capabilities: getActorCapabilities(db, actor.id), changed: false } + if (!createdActor && (metadataChanged || capabilityReplacement.changed)) { + db.prepare( + `UPDATE ${ACTOR_TABLE} + SET authorizationRevision = authorizationRevision + ?, + contextRevision = contextRevision + ?, + updatedAt = ? + WHERE id = ?` + ).run(capabilityReplacement.changed ? 1 : 0, metadataChanged ? 1 : 0, updatedAt, actor.id) + } + const refreshed = findActorById(db, actor.id) + if (!refreshed) { + throw new Error(`failed to refresh actor ${actor.id}`) + } + return refreshed + }) +} + +function tableHasColumn(db: DatabaseSync, tableName: string, columnName: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }> + return columns.some((column) => column.name === columnName) +} + +function assertLegacyRoomReferences(db: DatabaseSync, tableName: 'gc_room_agents' | 'gc_room_members'): void { + const count = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${tableName} source + LEFT JOIN gc_rooms room ON room.id = source.roomId + WHERE room.id IS NULL` + ).get() as { count: number }).count + if (count !== 0) { + throw new Error(`groupChatIdentityV1 found ${count} ${tableName} rows referencing unknown rooms`) + } +} + +function canonicalLegacySources(db: DatabaseSync): LegacyActorSource[] { + assertLegacyRoomReferences(db, 'gc_room_agents') + assertLegacyRoomReferences(db, 'gc_room_members') + + const canonical = new Map() + const agentCreatedAtColumn = tableHasColumn(db, 'gc_room_agents', 'createdAt') ? 'createdAt' : '0' + const agentRows = db.prepare( + `SELECT id, roomId, agentId, profile, name, description, ${agentCreatedAtColumn} AS createdAt + FROM gc_room_agents + ORDER BY createdAt ASC, id ASC` + ).all() as Array<{ + id: string + roomId: string + agentId: string + profile: string + name: string + description: string + createdAt: number + }> + const canonicalAgentKeys = new Set() + + for (const row of agentRows) { + const canonicalCreatedAt = Number.isFinite(row.createdAt) && row.createdAt > 0 + ? Math.floor(row.createdAt) + : 0 + const agentId = String(row.agentId || '').trim() + if (!agentId) { + throw new Error(`groupChatIdentityV1 invalid agent identity shape for room ${row.roomId}`) + } + const key = `${row.roomId}::agent::${row.agentId}` + if (canonical.has(key)) { + continue + } + canonicalAgentKeys.add(`${row.roomId}::${row.agentId}`) + canonical.set(key, { + roomId: row.roomId, + actorType: 'agent', + authUserId: null, + agentId: row.agentId, + localSubjectId: null, + systemKey: null, + name: row.name, + description: row.description, + avatar: '', + stableSourceKey: `legacy-agent:${row.id}`, + createdAt: canonicalCreatedAt, + capabilities: AGENT_GROUP_CHAT_CAPABILITIES, + }) + } + + const memberRows = db.prepare( + `SELECT id, roomId, userId, userName, description, avatar, authUserId, joinedAt + FROM gc_room_members + ORDER BY joinedAt ASC, id ASC` + ).all() as Array<{ + id: string + roomId: string + userId: string + userName: string + description: string + avatar: string + authUserId: number | null + joinedAt: number + }> + + for (const row of memberRows) { + if (canonicalAgentKeys.has(`${row.roomId}::${row.userId}`)) { + continue + } + if (typeof row.authUserId === 'number' && row.authUserId > 0) { + const key = `${row.roomId}::auth::${row.authUserId}` + if (canonical.has(key)) { + continue + } + canonical.set(key, { + roomId: row.roomId, + actorType: 'authenticated_human', + authUserId: row.authUserId, + agentId: null, + localSubjectId: null, + systemKey: null, + name: row.userName, + description: row.description, + avatar: row.avatar, + stableSourceKey: `legacy-auth:${row.id}`, + createdAt: row.joinedAt > 0 ? row.joinedAt : 0, + capabilities: ['room.read'], + }) + continue + } + const legacyRoutingUserId = String(row.userId || '').trim() + if (!legacyRoutingUserId) { + throw new Error(`groupChatIdentityV1 invalid local member identity shape for room ${row.roomId}`) + } + const key = `${row.roomId}::local::${row.userId}` + if (canonical.has(key)) { + continue + } + const stableSourceKey = `legacy-local:${row.id}` + canonical.set(key, { + roomId: row.roomId, + actorType: 'local', + authUserId: null, + agentId: null, + localSubjectId: createDeterministicLocalSubjectId(row.roomId, stableSourceKey), + systemKey: null, + name: row.userName, + description: row.description, + avatar: row.avatar, + stableSourceKey, + createdAt: row.joinedAt > 0 ? row.joinedAt : 0, + capabilities: LOCAL_GROUP_CHAT_CAPABILITIES, + }) + } + + return [...canonical.values()] +} + +export function findActiveActorByAuthUserId(db: DatabaseSync, roomId: string, authUserId: number): GroupActor | null { + return (db.prepare( + `${ACTOR_SELECT} + WHERE roomId = ? AND authUserId = ? AND active = 1 + ORDER BY createdAt DESC + LIMIT 1` + ).get(roomId, authUserId) as GroupActor | undefined) ?? null +} + +export function findActiveActorByAgentIdentity(db: DatabaseSync, roomId: string, agentId: string): GroupActor | null { + return (db.prepare( + `${ACTOR_SELECT} + WHERE roomId = ? AND agentId = ? AND active = 1 + ORDER BY createdAt DESC + LIMIT 1` + ).get(roomId, agentId) as GroupActor | undefined) ?? null +} + +export function findActiveActorByLocalSubjectId(db: DatabaseSync, roomId: string, localSubjectId: string): GroupActor | null { + return (db.prepare( + `${ACTOR_SELECT} + WHERE roomId = ? AND localSubjectId = ? AND active = 1 + ORDER BY createdAt DESC + LIMIT 1` + ).get(roomId, localSubjectId) as GroupActor | undefined) ?? null +} + +export function findActiveActorBySystemKey(db: DatabaseSync, roomId: string, systemKey: string): GroupActor | null { + return (db.prepare( + `${ACTOR_SELECT} + WHERE roomId = ? AND systemKey = ? AND active = 1 + ORDER BY createdAt DESC + LIMIT 1` + ).get(roomId, systemKey) as GroupActor | undefined) ?? null +} + +export function getActorCapabilities(db: DatabaseSync, actorId: string): GroupChatCapability[] { + const rows = db.prepare( + `SELECT capability + FROM ${CAPABILITY_TABLE} + WHERE actorId = ? AND active = 1 + ORDER BY capability` + ).all(actorId) as Array<{ capability: string }> + return normalizeGroupChatCapabilities(rows.map((row) => row.capability)) +} + +export function ensureAuthenticatedHumanActor( + db: DatabaseSync, + input: EnsureAuthenticatedHumanActorInput, +): GroupActor { + const verified = verifyAuthenticatedHumanActorInput(input) + return ensureActor(db, { + roomId: verified.roomId, + actorType: 'authenticated_human', + metadata: { + name: verified.userName, + description: verified.description, + avatar: verified.avatar, + }, + capabilities: verified.capabilities ?? [], + replaceExistingCapabilities: input.capabilities !== undefined, + findExisting: () => findActiveActorByAuthUserId(db, verified.roomId, verified.authUserId), + insertSeed: { + roomId: verified.roomId, + actorType: 'authenticated_human', + authUserId: verified.authUserId, + agentId: null, + localSubjectId: null, + systemKey: null, + }, + }) +} + +export function ensureLocalActor(db: DatabaseSync, input: EnsureLocalActorInput): GroupActor { + const verified = verifyLocalActorInput(input) + return ensureActor(db, { + roomId: verified.roomId, + actorType: 'local', + metadata: { + name: verified.userName, + description: verified.description, + avatar: verified.avatar, + }, + capabilities: LOCAL_GROUP_CHAT_CAPABILITIES, + replaceExistingCapabilities: verified.grantDefaultCapabilities === true, + findExisting: () => findActiveActorByLocalSubjectId(db, verified.roomId, verified.localSubjectId), + insertSeed: { + roomId: verified.roomId, + actorType: 'local', + authUserId: null, + agentId: null, + localSubjectId: verified.localSubjectId, + systemKey: null, + }, + }) +} + +export function ensureAgentActor(db: DatabaseSync, input: EnsureAgentActorInput): GroupActor { + const verified = verifyAgentActorInput(input) + return ensureActor(db, { + roomId: verified.roomId, + actorType: 'agent', + metadata: { + name: verified.name, + description: verified.description, + avatar: '', + }, + capabilities: AGENT_GROUP_CHAT_CAPABILITIES, + findExisting: () => findActiveActorByAgentIdentity(db, verified.roomId, verified.agentId), + insertSeed: { + roomId: verified.roomId, + actorType: 'agent', + authUserId: null, + agentId: verified.agentId, + localSubjectId: null, + systemKey: null, + }, + }) +} + +export function ensureSystemActor(db: DatabaseSync, input: EnsureSystemActorInput): GroupActor { + const verified = verifySystemActorInput(input) + return ensureActor(db, { + roomId: verified.roomId, + actorType: 'system', + metadata: { + name: 'system', + description: '', + avatar: '', + }, + capabilities: SYSTEM_GROUP_CHAT_CAPABILITIES, + findExisting: () => findActiveActorBySystemKey(db, verified.roomId, verified.systemKey ?? 'room-system'), + insertSeed: { + roomId: verified.roomId, + actorType: 'system', + authUserId: null, + agentId: null, + localSubjectId: null, + systemKey: verified.systemKey ?? 'room-system', + }, + }) +} + +export function deactivateAgentActorWithRetention( + db: DatabaseSync, + roomId: string, + agentId: string, +): GroupActor | null { + return withImmediateTransaction(db as TransactionalDatabase, () => { + const actor = findActiveActorByAgentIdentity(db, roomId, agentId) + if (!actor) { + return null + } + const now = Date.now() + db.prepare(`DELETE FROM ${CAPABILITY_TABLE} WHERE actorId = ?`).run(actor.id) + db.prepare( + `UPDATE ${ACTOR_TABLE} + SET active = 0, + authUserId = NULL, + agentId = NULL, + localSubjectId = NULL, + systemKey = NULL, + name = ?, + description = '', + avatar = '', + authorizationRevision = authorizationRevision + 1, + contextRevision = contextRevision + 1, + tombstonedAt = ?, + updatedAt = ? + WHERE id = ? AND active = 1` + ).run(DELETED_AGENT_NAME, now, now, actor.id) + return findActorById(db, actor.id) + }) +} + +export function backfillGroupChatActors(db: DatabaseSync): GroupChatActorBackfillSummary { + const sources = canonicalLegacySources(db) + db.prepare(`DELETE FROM ${CAPABILITY_TABLE}`).run() + db.prepare(`DELETE FROM ${ACTOR_TABLE}`).run() + const insertCapability = db.prepare( + `INSERT INTO ${CAPABILITY_TABLE} + (id, roomId, actorId, capability, active, createdAt, updatedAt) + VALUES (?, ?, ?, ?, 1, ?, ?)` + ) + + let capabilityCount = 0 + for (const source of sources) { + const actorId = createDeterministicGroupActorId({ + roomId: source.roomId, + actorType: source.actorType, + authUserId: source.authUserId, + agentId: source.agentId, + localSubjectId: source.localSubjectId, + systemKey: source.systemKey, + stableSourceKey: source.stableSourceKey, + }) + insertActor(db, { + id: actorId, + roomId: source.roomId, + actorType: source.actorType, + authUserId: source.authUserId, + agentId: source.agentId, + localSubjectId: source.localSubjectId, + systemKey: source.systemKey, + metadata: { + name: source.name, + description: source.description, + avatar: source.avatar, + }, + createdAt: source.createdAt, + updatedAt: source.createdAt, + }) + for (const capability of source.capabilities) { + insertCapability.run( + createDeterministicGroupActorCapabilityId(source.roomId, actorId, capability), + source.roomId, + actorId, + capability, + source.createdAt, + source.createdAt, + ) + capabilityCount += 1 + } + } + + return { + actorCount: sources.length, + capabilityCount, + } +} + +export function validateBackfilledGroupChatActors( + db: DatabaseSync, + summary: GroupChatActorBackfillSummary, +): void { + const actorCount = (db.prepare(`SELECT COUNT(*) AS count FROM ${ACTOR_TABLE}`).get() as { count: number }).count + const capabilityCount = (db.prepare(`SELECT COUNT(*) AS count FROM ${CAPABILITY_TABLE}`).get() as { count: number }).count + if (actorCount !== summary.actorCount) { + throw new Error(`groupChatIdentityV1 actor count mismatch: expected ${summary.actorCount}, found ${actorCount}`) + } + if (capabilityCount !== summary.capabilityCount) { + throw new Error(`groupChatIdentityV1 capability count mismatch: expected ${summary.capabilityCount}, found ${capabilityCount}`) + } + const orphanedCapabilities = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${CAPABILITY_TABLE} capability + LEFT JOIN ${ACTOR_TABLE} actor ON actor.id = capability.actorId + WHERE actor.id IS NULL` + ).get() as { count: number }).count + if (orphanedCapabilities !== 0) { + throw new Error(`groupChatIdentityV1 found ${orphanedCapabilities} orphaned capability rows`) + } + const orphanedActorRooms = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${ACTOR_TABLE} actor + LEFT JOIN gc_rooms room ON room.id = actor.roomId + WHERE room.id IS NULL` + ).get() as { count: number }).count + if (orphanedActorRooms !== 0) { + throw new Error(`groupChatIdentityV1 found ${orphanedActorRooms} actor rows referencing unknown rooms`) + } + const orphanedCapabilityRooms = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${CAPABILITY_TABLE} capability + LEFT JOIN gc_rooms room ON room.id = capability.roomId + WHERE room.id IS NULL` + ).get() as { count: number }).count + if (orphanedCapabilityRooms !== 0) { + throw new Error(`groupChatIdentityV1 found ${orphanedCapabilityRooms} capability rows referencing unknown rooms`) + } + const capabilityRoomMismatches = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${CAPABILITY_TABLE} capability + INNER JOIN ${ACTOR_TABLE} actor ON actor.id = capability.actorId + WHERE capability.roomId <> actor.roomId` + ).get() as { count: number }).count + if (capabilityRoomMismatches !== 0) { + throw new Error(`groupChatIdentityV1 found ${capabilityRoomMismatches} capability rows with mismatched actor rooms`) + } + const invalidIdentityShapes = (db.prepare( + `SELECT COUNT(*) AS count + FROM ${ACTOR_TABLE} + WHERE active = 1 AND ( + (actorType = 'authenticated_human' AND ( + authUserId IS NULL OR agentId IS NOT NULL OR localSubjectId IS NOT NULL OR systemKey IS NOT NULL + )) + OR + (actorType = 'agent' AND ( + COALESCE(TRIM(agentId), '') = '' OR authUserId IS NOT NULL OR localSubjectId IS NOT NULL OR systemKey IS NOT NULL + )) + OR + (actorType = 'local' AND ( + COALESCE(TRIM(localSubjectId), '') = '' OR authUserId IS NOT NULL OR agentId IS NOT NULL OR systemKey IS NOT NULL + )) + OR + (actorType = 'system' AND ( + COALESCE(TRIM(systemKey), '') = '' OR authUserId IS NOT NULL OR agentId IS NOT NULL OR localSubjectId IS NOT NULL + )) + )` + ).get() as { count: number }).count + if (invalidIdentityShapes !== 0) { + throw new Error(`groupChatIdentityV1 found ${invalidIdentityShapes} invalid actor identity shapes`) + } + const unsupportedCapabilities = (db.prepare( + `SELECT capability + FROM ${CAPABILITY_TABLE} + WHERE active = 1` + ).all() as Array<{ capability: string }>).map((row) => row.capability) + if (unsupportedCapabilities.some((capability) => !isSupportedGroupChatCapability(capability))) { + throw new Error('groupChatIdentityV1 found unsupported capabilities') + } + const duplicateQueries = [ + `SELECT roomId, authUserId, COUNT(*) AS count FROM ${ACTOR_TABLE} WHERE active = 1 AND authUserId IS NOT NULL GROUP BY roomId, authUserId HAVING COUNT(*) > 1`, + `SELECT roomId, agentId, COUNT(*) AS count FROM ${ACTOR_TABLE} WHERE active = 1 AND agentId IS NOT NULL GROUP BY roomId, agentId HAVING COUNT(*) > 1`, + `SELECT roomId, localSubjectId, COUNT(*) AS count FROM ${ACTOR_TABLE} WHERE active = 1 AND localSubjectId IS NOT NULL GROUP BY roomId, localSubjectId HAVING COUNT(*) > 1`, + `SELECT roomId, systemKey, COUNT(*) AS count FROM ${ACTOR_TABLE} WHERE active = 1 AND systemKey IS NOT NULL GROUP BY roomId, systemKey HAVING COUNT(*) > 1`, + ] as const + for (const query of duplicateQueries) { + const duplicates = db.prepare(query).all() as Array<{ count: number }> + if (duplicates.length > 0) { + throw new Error('groupChatIdentityV1 detected duplicate active actors') + } + } +} diff --git a/packages/server/src/services/hermes/group-chat/identity/capability-policy.ts b/packages/server/src/services/hermes/group-chat/identity/capability-policy.ts new file mode 100644 index 0000000000..3266b87c57 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/identity/capability-policy.ts @@ -0,0 +1,81 @@ +export const SUPPORTED_GROUP_CHAT_CAPABILITIES = [ + 'room.read', + 'room.write', + 'room.type', + 'room.manage', + 'agent.invoke', + 'approval.respond', +] as const + +export type GroupChatCapability = (typeof SUPPORTED_GROUP_CHAT_CAPABILITIES)[number] + +export interface GroupChatCapabilityDecision { + capabilities: GroupChatCapability[] + canRead: boolean + canWrite: boolean + canType: boolean + canManage: boolean + canInvokeAgents: boolean + canApprove: boolean + isReadOnly: boolean +} + +const SUPPORTED_GROUP_CHAT_CAPABILITY_SET = new Set(SUPPORTED_GROUP_CHAT_CAPABILITIES) + +export const READ_ONLY_GROUP_CHAT_CAPABILITIES: readonly GroupChatCapability[] = [ + 'room.read', +] + +export const AGENT_GROUP_CHAT_CAPABILITIES: readonly GroupChatCapability[] = [ + 'room.read', + 'room.write', + 'room.type', + 'agent.invoke', +] + +export const LOCAL_GROUP_CHAT_CAPABILITIES: readonly GroupChatCapability[] = [ + 'room.read', + 'room.write', + 'room.type', + 'room.manage', + 'agent.invoke', + 'approval.respond', +] + +export const SYSTEM_GROUP_CHAT_CAPABILITIES: readonly GroupChatCapability[] = AGENT_GROUP_CHAT_CAPABILITIES + +export function isSupportedGroupChatCapability(value: unknown): value is GroupChatCapability { + return typeof value === 'string' && SUPPORTED_GROUP_CHAT_CAPABILITY_SET.has(value) +} + +export function normalizeGroupChatCapabilities(capabilities: Iterable): GroupChatCapability[] { + const selected = new Set() + for (const capability of capabilities) { + if (isSupportedGroupChatCapability(capability)) { + selected.add(capability) + } + } + return SUPPORTED_GROUP_CHAT_CAPABILITIES.filter((capability) => selected.has(capability)) +} + +export function groupChatCapabilityFlags(capabilities: Iterable): GroupChatCapabilityDecision { + const normalized = normalizeGroupChatCapabilities(capabilities) + const granted = new Set(normalized) + const canRead = granted.has('room.read') + const canWrite = granted.has('room.write') + const canType = granted.has('room.type') + const canManage = granted.has('room.manage') + const canInvokeAgents = granted.has('agent.invoke') + const canApprove = granted.has('approval.respond') + + return { + capabilities: normalized, + canRead, + canWrite, + canType, + canManage, + canInvokeAgents, + canApprove, + isReadOnly: canRead && !canWrite && !canType && !canManage && !canInvokeAgents && !canApprove, + } +} diff --git a/packages/server/src/services/hermes/group-chat/identity/types.ts b/packages/server/src/services/hermes/group-chat/identity/types.ts new file mode 100644 index 0000000000..1f7b496158 --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/identity/types.ts @@ -0,0 +1,136 @@ +export const GROUP_CHAT_IDENTITY_READER_EPOCH = 1 as const + +export type GroupActorType = 'authenticated_human' | 'agent' | 'local' | 'system' + +export interface GroupActor { + id: string + roomId: string + actorType: GroupActorType + authUserId: number | null + agentId: string | null + localSubjectId: string | null + systemKey: string | null + name: string + description: string + avatar: string + active: 0 | 1 + authorizationRevision: number + contextRevision: number + tombstonedAt: number | null + createdAt: number + updatedAt: number +} + +export interface GroupActorRevisions { + actorId: string | null + roomAuthorizationRevision: number + actorAuthorizationRevision: number + actorContextRevision: number +} + +export interface EnsureAuthenticatedHumanActorInput { + roomId: string + authUserId: number + userName: string + description: string + avatar: string + capabilities?: readonly string[] +} + +export interface EnsureLocalActorInput { + roomId: string + localSubjectId: string + userName: string + description: string + avatar: string + grantDefaultCapabilities?: boolean +} + +export interface EnsureAgentActorInput { + roomId: string + agentId: string + name: string + description: string +} + +export interface EnsureSystemActorInput { + roomId: string + systemKey?: string +} + +export interface GroupActorMetadata { + name: string + description: string + avatar: string +} + +function requireIdentifier(value: string, fieldName: string): string { + const normalized = value.trim() + if (!normalized) { + throw new Error(`${fieldName} is required`) + } + return normalized +} + +function requirePositiveInteger(value: number, fieldName: string): number { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${fieldName} must be a positive integer`) + } + return value +} + +function normalizeMetadata(name: string, description: string, avatar: string): GroupActorMetadata { + return { + name: name.trim(), + description: description.trim(), + avatar: avatar.trim(), + } +} + +export function revisionNumber(value: unknown): number { + const numeric = Number(value) + return Number.isFinite(numeric) && numeric >= 0 ? Math.floor(numeric) : 0 +} + +export function verifyAuthenticatedHumanActorInput( + input: EnsureAuthenticatedHumanActorInput, +): EnsureAuthenticatedHumanActorInput { + const metadata = normalizeMetadata(input.userName, input.description, input.avatar) + return { + roomId: requireIdentifier(input.roomId, 'roomId'), + authUserId: requirePositiveInteger(input.authUserId, 'authUserId'), + userName: metadata.name, + description: metadata.description, + avatar: metadata.avatar, + capabilities: input.capabilities ?? [], + } +} + +export function verifyLocalActorInput(input: EnsureLocalActorInput): EnsureLocalActorInput { + const metadata = normalizeMetadata(input.userName, input.description, input.avatar) + return { + roomId: requireIdentifier(input.roomId, 'roomId'), + localSubjectId: requireIdentifier(input.localSubjectId, 'localSubjectId'), + userName: metadata.name, + description: metadata.description, + avatar: metadata.avatar, + grantDefaultCapabilities: input.grantDefaultCapabilities === true, + } +} + +export function verifyAgentActorInput(input: EnsureAgentActorInput): EnsureAgentActorInput { + const metadata = normalizeMetadata(input.name, input.description, '') + return { + roomId: requireIdentifier(input.roomId, 'roomId'), + agentId: requireIdentifier(input.agentId, 'agentId'), + name: metadata.name, + description: metadata.description, + } +} + +export function verifySystemActorInput(input: EnsureSystemActorInput): EnsureSystemActorInput { + return { + roomId: requireIdentifier(input.roomId, 'roomId'), + systemKey: requireIdentifier(input.systemKey ?? 'room-system', 'systemKey'), + } +} diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index af656d8061..fbc16ee014 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -1,5 +1,7 @@ import { Server, Socket, Namespace } from 'socket.io' import type { Server as HttpServer } from 'http' +import type { DatabaseSync } from 'node:sqlite' +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { basename } from 'path' import { logger } from '../../../services/logger' import { getDb } from '../../../db' @@ -11,10 +13,35 @@ import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' import { AgentBridgeClient } from '../agent-bridge' import { insertWorkspaceRunChange, deleteWorkspaceRunChangesForRoom, type SaveWorkspaceRunChangeInput, type WorkspaceRunChangeSummary } from '../../../db/hermes/workspace-run-changes-store' import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' -import { findUserByUsername, getUserAvatar } from '../../../db/hermes/users-store' +import { findUserById, findUserByUsername, getUserAvatar, listUserProfiles } from '../../../db/hermes/users-store' import { config } from '../../../config' import { createSocketIoCorsOrigin, shouldRejectUpgradeOrigin } from '../../../security' +import { getGroupChatLocalIdentitySecret } from '../../auth' import { paginateRecentGroupMessagesCanonical, sliceGroupMessagesCanonical, sliceGroupMessagesForSnapshotTail, type GroupMessageCursorCutoff } from './group-message-ordering' +import { + createAgentGroupChatSubject, + createAuthenticatedGroupChatSubject, + createLocalGroupChatSubject, + evaluateGroupChatAccessPolicy, + groupChatInviteCodeMatches, +} from './access-policy' +import { + deactivateAgentActorWithRetention as deactivatePersistedAgentActorWithRetention, + ensureAgentActor as persistAgentActor, + ensureAuthenticatedHumanActor as persistAuthenticatedHumanActor, + ensureLocalActor as persistLocalActor, + ensureSystemActor as persistSystemActor, + findActiveActorByAgentIdentity as readActiveActorByAgentIdentity, + findActiveActorByAuthUserId as readActiveActorByAuthUserId, + findActiveActorByLocalSubjectId as readActiveActorByLocalSubjectId, + findActiveActorBySystemKey as readActiveActorBySystemKey, + getActorCapabilities as readPersistedActorCapabilities, +} from './identity/actor-store' +import type { GroupActor } from './identity/types' +import { + GroupChatInviteAttemptLimiter, + groupChatInviteAttemptSubjectKey, +} from './invite-attempt-limiter' // ─── Types ──────────────────────────────────────────────────── @@ -69,6 +96,23 @@ function contentToText(content: unknown): string { return content == null ? '' : String(content) } +const APPROVAL_CHOICES = ['once', 'session', 'always', 'deny'] as const +type ApprovalChoice = typeof APPROVAL_CHOICES[number] +const APPROVAL_CHOICE_SET = new Set(APPROVAL_CHOICES) + +interface PendingApprovalBinding { + roomId: string + agentId: string + agentSessionId: string + allowedChoices: ApprovalChoice[] + allowPermanent: boolean + responding: boolean + responded: boolean + conflicted: boolean +} + +const MAX_PENDING_APPROVAL_BINDINGS = 1_000 + interface RoomAgent { id: string roomId: string @@ -83,6 +127,7 @@ interface RoomInfo { id: string name: string inviteCode: string | null + inviteGeneration: number triggerTokens: number maxHistoryTokens: number tailMessageCount: number @@ -90,6 +135,7 @@ interface RoomInfo { sessionSeed: string workspace: string ownerAuthUserId: number | null + authorizationRevision: number } interface SaveWorkspaceDiffMessageArgs { @@ -104,6 +150,26 @@ interface SaveWorkspaceDiffMessageArgs { parentMessageId?: string | null } +interface CreateRoomWithOwnerArgs { + id: string + name: string + inviteCode?: string + config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number; workspace?: string; ownerAuthUserId?: number | null } + owner?: ({ + kind: 'authenticated' + authUserId: number + username: string + description?: string + avatar?: string + } | { + kind: 'local' + localSubjectId: string + username: string + description?: string + avatar?: string + }) | null +} + interface Member { id: string userId: string @@ -125,6 +191,83 @@ function authenticatedUserProfiles(user: AuthenticatedUser | undefined): string[ return Array.isArray(user?.profiles) ? user.profiles.map(String).filter(Boolean) : [] } +const GROUP_CHAT_LOCAL_CREDENTIAL_CONTEXT = 'group-chat-local-identity-v1\0' +const GROUP_CHAT_LOCAL_ROUTING_CONTEXT = 'group-chat-local-routing-v1\0' +const GROUP_CHAT_LOCAL_SUBJECT_ID_RE = /^local:[0-9a-f]{32}$/ + +function createLocalGroupChatSubjectId(): string { + return `local:${randomBytes(16).toString('hex')}` +} + +function localGroupUserId(secret: string, localSubjectId: string): string { + const digest = createHmac('sha256', secret) + .update(GROUP_CHAT_LOCAL_ROUTING_CONTEXT, 'utf8') + .update(localSubjectId, 'utf8') + .digest('hex') + return `local-user:${digest}` +} + +function localCredentialSignature(secret: string, payload: string): Buffer { + return createHmac('sha256', secret) + .update(GROUP_CHAT_LOCAL_CREDENTIAL_CONTEXT, 'utf8') + .update(payload, 'utf8') + .digest() +} + +function encodeLocalCredential(secret: string, localSubjectId: string): string { + const payload = Buffer.from(JSON.stringify({ sub: localSubjectId }), 'utf8').toString('base64url') + const signature = localCredentialSignature(secret, payload).toString('base64url') + return `${payload}.${signature}` +} + +function decodeLocalCredential(secret: string, credential: string): string | null { + const trimmed = credential.trim() + const separator = trimmed.indexOf('.') + if (separator <= 0 || separator === trimmed.length - 1) return null + const payload = trimmed.slice(0, separator) + const signature = trimmed.slice(separator + 1) + let localSubjectId = '' + try { + const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { sub?: unknown } + localSubjectId = typeof parsed?.sub === 'string' ? parsed.sub : '' + } catch { + return null + } + if (!GROUP_CHAT_LOCAL_SUBJECT_ID_RE.test(localSubjectId)) { + return null + } + const expected = localCredentialSignature(secret, payload) + const provided = Buffer.from(signature, 'base64url') + if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) { + return null + } + return localSubjectId +} + +function noAccessPolicyDecision(roomId: string, room: RoomInfo | null | undefined) { + return { + roomId, + roomExists: Boolean(room), + canDiscover: false, + canJoin: false, + canInvokeAgent: false, + canRespondApproval: false, + actorId: null, + actorType: null, + actorAuthorizationRevision: 0, + actorContextRevision: 0, + roomAuthorizationRevision: Number(room?.authorizationRevision || 0), + capabilities: [], + canRead: false, + canWrite: false, + canType: false, + canManage: false, + canInvokeAgents: false, + canApprove: false, + isReadOnly: false, + } +} + let _tablesEnsured = false interface PendingSessionDelete { @@ -146,6 +289,37 @@ interface GroupChatSessionProfile { created_at: number } +interface RemovedAgentRetention { + agent: RoomAgent + actorId: string | null + sessionProfiles: GroupChatSessionProfile[] +} + +interface HumanRoomAdmissionArgs { + roomId: string + userId: string + localSubjectId?: string | null + inviteCode?: string + requestedName: string + requestedDescription: string + avatar: string + authUser?: AuthenticatedUser +} + +interface AdmittedHumanRoomAdmission { + status: 'admitted' + room: RoomInfo + userName: string + description: string + avatar: string +} + +type HumanRoomAdmissionResult = + | AdmittedHumanRoomAdmission + | { + status: 'not_found' + } + export interface PendingSessionDeleteDrainResult { deleted: string[] failed: Array<{ sessionId: string; error: string }> @@ -173,6 +347,11 @@ function normalizeMentionDepth(depth: unknown): number { return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 } +function isUnknownBridgeSessionError(err: unknown): boolean { + const message = String((err as { message?: unknown } | null)?.message || err || '').toLowerCase() + return message.includes('unknown session') || message.includes('session not found') +} + function maxAgentMentionDepth(): number { const value = Number(process.env.HERMES_GROUP_CHAT_MAX_AGENT_MENTION_DEPTH) if (!Number.isFinite(value) || value <= 0) return 4 @@ -204,17 +383,117 @@ class ChatStorage { } saveSessionProfile(sessionId: string, roomId: string, agentId: string, profileName: string): void { - this.db()?.prepare( + const db = this.db() + if (!db) throw new Error('Group chat database is unavailable') + db.prepare( 'INSERT INTO gc_session_profiles (session_id, room_id, agent_id, profile_name, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET room_id = excluded.room_id, agent_id = excluded.agent_id, profile_name = excluded.profile_name' ).run(sessionId, roomId, agentId, profileName, Date.now()) } + registerSessionProfileForActiveAgent(args: { + sessionId: string + roomId: string + agentId: string + profileName: string + agentName: string + sessionSeed: string + roomAuthorizationRevision: number + actorId: string + actorAuthorizationRevision: number + actorContextRevision: number + requireRunCapabilities?: boolean + cleanupAfterMs?: number + }): boolean { + const db = this.db() + if (!db) throw new Error('Group chat database is unavailable') + return this.withImmediateTransaction(db, () => { + const now = Date.now() + const requestedCleanupDelay = Number(args.cleanupAfterMs) + const cleanupDelay = Number.isFinite(requestedCleanupDelay) && requestedCleanupDelay >= 0 + ? Math.floor(requestedCleanupDelay) + : null + const scheduleCrashCleanup = () => { + if (cleanupDelay === null) return + db.prepare( + `INSERT INTO gc_pending_session_deletes (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'pending', 0, NULL, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + profile_name = excluded.profile_name, + status = 'pending', + updated_at = excluded.updated_at, + next_attempt_at = excluded.next_attempt_at` + ).run(args.sessionId, args.profileName, now, now, now + cleanupDelay) + } + const room = db.prepare( + 'SELECT sessionSeed, authorizationRevision FROM gc_rooms WHERE id = ?' + ).get(args.roomId) as { sessionSeed: string; authorizationRevision: number } | undefined + const agent = db.prepare( + 'SELECT 1 AS present FROM gc_room_agents WHERE roomId = ? AND agentId = ? AND profile = ? AND name = ?' + ).get(args.roomId, args.agentId, args.profileName, args.agentName) + const actor = db.prepare( + `SELECT id, authorizationRevision, contextRevision + FROM gc_room_actors + WHERE roomId = ? AND agentId = ? AND active = 1` + ).get(args.roomId, args.agentId) as { + id: string + authorizationRevision: number + contextRevision: number + } | undefined + const requiredCapabilityCount = actor + ? Number((db.prepare( + `SELECT COUNT(DISTINCT capability) AS count + FROM gc_room_actor_capabilities + WHERE actorId = ? AND active = 1 AND capability IN ('room.read', 'room.write')` + ).get(actor.id) as { count: number }).count || 0) + : 0 + if ( + !/^gc_h_[0-9a-f]{32}$/.test(args.sessionId) + || !room + || !agent + || !actor + || (args.requireRunCapabilities !== false && requiredCapabilityCount !== 2) + || room.sessionSeed !== args.sessionSeed + || Number(room.authorizationRevision || 0) !== args.roomAuthorizationRevision + || actor.id !== args.actorId + || Number(actor.authorizationRevision || 0) !== args.actorAuthorizationRevision + || Number(actor.contextRevision || 0) !== args.actorContextRevision + ) { + return false + } + const existing = db.prepare( + 'SELECT room_id, agent_id, profile_name FROM gc_session_profiles WHERE session_id = ?' + ).get(args.sessionId) as { + room_id: string + agent_id: string + profile_name: string + } | undefined + if (existing) { + const matches = existing.room_id === args.roomId + && existing.agent_id === args.agentId + && existing.profile_name === args.profileName + if (matches) scheduleCrashCleanup() + return matches + } + db.prepare( + 'INSERT INTO gc_session_profiles (session_id, room_id, agent_id, profile_name, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(args.sessionId, args.roomId, args.agentId, args.profileName, now) + scheduleCrashCleanup() + return true + }) + } + getSessionProfile(sessionId: string): GroupChatSessionProfile | null { return (this.db()?.prepare( 'SELECT session_id, room_id, agent_id, profile_name, created_at FROM gc_session_profiles WHERE session_id = ?' ).get(sessionId) as GroupChatSessionProfile | undefined) ?? null } + getSessionProfilesForRoomAgent(roomId: string, agentId: string): GroupChatSessionProfile[] { + return (this.db()?.prepare( + 'SELECT session_id, room_id, agent_id, profile_name, created_at FROM gc_session_profiles WHERE room_id = ? AND agent_id = ? ORDER BY created_at ASC' + ).all(roomId, agentId) as GroupChatSessionProfile[] | undefined) ?? [] + } + deleteSessionProfile(sessionId: string): void { this.db()?.prepare('DELETE FROM gc_session_profiles WHERE session_id = ?').run(sessionId) } @@ -252,24 +531,6 @@ class ChatStorage { ).run(sessionId, profileName, now, now) } - claimPendingSessionDeletes(profileName: string, limit = 50): PendingSessionDelete[] { - const rows = this.listPendingSessionDeletes(profileName, limit) - if (rows.length === 0) return [] - const now = Date.now() - const stmt = this.db()?.prepare( - `UPDATE gc_pending_session_deletes - SET status = 'processing', updated_at = ? - WHERE session_id = ? AND status = 'pending'` - ) - const claimed: PendingSessionDelete[] = [] - for (const row of rows) { - const result = stmt?.run(now, row.session_id) - if (result?.changes) { - claimed.push({ ...row, status: 'processing', updated_at: now }) - } - } - return claimed - } markPendingSessionDeleteFailed(sessionId: string, error: string): void { const now = Date.now() @@ -298,15 +559,20 @@ class ChatStorage { // ─── Rooms ──────────────────────────────────────────────── getRoom(roomId: string): RoomInfo | undefined { - return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId FROM gc_rooms WHERE id = ?').get(roomId) as any + return this.db()?.prepare('SELECT id, name, inviteCode, inviteGeneration, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId, authorizationRevision FROM gc_rooms WHERE id = ?').get(roomId) as RoomInfo | undefined } getRoomByInviteCode(code: string): RoomInfo | undefined { - return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId FROM gc_rooms WHERE inviteCode = ?').get(code) as any + let matchingRoom: RoomInfo | undefined + for (const room of this.getAllRooms()) { + const matches = groupChatInviteCodeMatches(code, room.inviteCode) + if (matches && !matchingRoom) matchingRoom = room + } + return matchingRoom } getAllRooms(): RoomInfo[] { - return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId FROM gc_rooms ORDER BY id').all() || []) as any[] + return (this.db()?.prepare('SELECT id, name, inviteCode, inviteGeneration, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId, authorizationRevision FROM gc_rooms ORDER BY id').all() || []) as unknown as RoomInfo[] } getRoomsForProfiles(profiles: string[]): RoomInfo[] { @@ -314,7 +580,7 @@ class ChatStorage { if (!uniqueProfiles.length) return [] const placeholders = uniqueProfiles.map(() => '?').join(', ') return (this.db()?.prepare( - `SELECT DISTINCT r.id, r.name, r.inviteCode, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed, r.workspace, r.ownerAuthUserId + `SELECT DISTINCT r.id, r.name, r.inviteCode, r.inviteGeneration, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed, r.workspace, r.ownerAuthUserId, r.authorizationRevision FROM gc_rooms r INNER JOIN gc_room_agents a ON a.roomId = r.id WHERE a.profile IN (${placeholders}) @@ -325,7 +591,7 @@ class ChatStorage { getRoomsForAuthUser(authUserId: number): RoomInfo[] { if (!Number.isFinite(authUserId) || authUserId <= 0) return [] return (this.db()?.prepare( - `SELECT DISTINCT r.id, r.name, r.inviteCode, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed, r.workspace, r.ownerAuthUserId + `SELECT DISTINCT r.id, r.name, r.inviteCode, r.inviteGeneration, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed, r.workspace, r.ownerAuthUserId, r.authorizationRevision FROM gc_rooms r INNER JOIN gc_room_members m ON m.roomId = r.id WHERE m.authUserId = ? @@ -336,7 +602,7 @@ class ChatStorage { getOwnedRoomsForAuthUser(authUserId: number): RoomInfo[] { if (!Number.isFinite(authUserId) || authUserId <= 0) return [] return (this.db()?.prepare( - `SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId + `SELECT id, name, inviteCode, inviteGeneration, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed, workspace, ownerAuthUserId, authorizationRevision FROM gc_rooms WHERE ownerAuthUserId = ? ORDER BY id` @@ -346,14 +612,68 @@ class ChatStorage { saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number; workspace?: string; ownerAuthUserId?: number | null }): void { const rawOwnerAuthUserId = Number(config?.ownerAuthUserId ?? 0) const ownerAuthUserId = Number.isFinite(rawOwnerAuthUserId) && rawOwnerAuthUserId > 0 ? Math.floor(rawOwnerAuthUserId) : null + const sessionSeed = this.newRoomSessionSeed() this.db()?.prepare( - 'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, workspace, ownerAuthUserId) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' - ).run(id, name, inviteCode || null, config?.triggerTokens ?? 100000, config?.maxHistoryTokens ?? 32000, config?.tailMessageCount ?? 10, config?.workspace || '', ownerAuthUserId) + 'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, sessionSeed, workspace, ownerAuthUserId, authorizationRevision) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' + ).run(id, name, inviteCode || null, config?.triggerTokens ?? 100000, config?.maxHistoryTokens ?? 32000, config?.tailMessageCount ?? 10, sessionSeed, config?.workspace || '', ownerAuthUserId) + } + + createRoomWithOwner(args: CreateRoomWithOwnerArgs): void { + const db = this.db() + const owner = args.owner + const ownerAuthUserId = owner?.kind === 'authenticated' ? owner.authUserId : undefined + if (!db) { + this.saveRoom(args.id, args.name, args.inviteCode, { + ...args.config, + ownerAuthUserId, + }) + return + } + this.withImmediateTransaction(db, () => { + this.saveRoom(args.id, args.name, args.inviteCode, { + ...args.config, + ownerAuthUserId, + }) + if (!owner) return + if (owner.kind === 'authenticated') { + this.ensureAuthenticatedHumanActor({ + roomId: args.id, + authUserId: owner.authUserId, + userId: authenticatedGroupUserId(owner.authUserId), + userName: owner.username, + description: owner.description || '', + avatar: owner.avatar || '', + capabilities: ['room.read', 'room.write', 'room.type', 'room.manage', 'agent.invoke', 'approval.respond'], + }) + return + } + this.ensureLocalActor({ + roomId: args.id, + localSubjectId: owner.localSubjectId, + userName: owner.username, + description: owner.description || '', + avatar: owner.avatar || '', + grantDefaultCapabilities: true, + }) + }) } setRoomOwnerAuthUserId(roomId: string, authUserId: number): void { if (!Number.isFinite(authUserId) || authUserId <= 0) return - this.db()?.prepare('UPDATE gc_rooms SET ownerAuthUserId = ? WHERE id = ?').run(authUserId, roomId) + this.db()?.prepare( + `UPDATE gc_rooms + SET ownerAuthUserId = ?, authorizationRevision = authorizationRevision + 1 + WHERE id = ? AND COALESCE(ownerAuthUserId, -1) <> ?` + ).run(authUserId, roomId, authUserId) + } + + clearRoomOwnerAuthUserId(roomId: string, authUserId: number): void { + if (!Number.isFinite(authUserId) || authUserId <= 0) return + this.db()?.prepare( + `UPDATE gc_rooms + SET ownerAuthUserId = NULL, authorizationRevision = authorizationRevision + 1 + WHERE id = ? AND ownerAuthUserId = ?` + ).run(roomId, authUserId) } updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void { @@ -363,12 +683,20 @@ class ChatStorage { if (config.maxHistoryTokens !== undefined) { sets.push('maxHistoryTokens = ?'); vals.push(config.maxHistoryTokens) } if (config.tailMessageCount !== undefined) { sets.push('tailMessageCount = ?'); vals.push(config.tailMessageCount) } if (sets.length === 0) return + sets.push('authorizationRevision = authorizationRevision + 1') vals.push(roomId) this.db()?.prepare(`UPDATE gc_rooms SET ${sets.join(', ')} WHERE id = ?`).run(...vals) } updateRoomInviteCode(roomId: string, inviteCode: string): void { - this.db()?.prepare('UPDATE gc_rooms SET inviteCode = ? WHERE id = ?').run(inviteCode, roomId) + this.db()?.prepare( + 'UPDATE gc_rooms SET inviteCode = ?, inviteGeneration = inviteGeneration + 1, authorizationRevision = authorizationRevision + 1 WHERE id = ?' + ).run(inviteCode, roomId) + } + + incrementRoomAuthorizationRevision(roomId: string): number { + this.db()?.prepare('UPDATE gc_rooms SET authorizationRevision = authorizationRevision + 1 WHERE id = ?').run(roomId) + return Number(this.getRoom(roomId)?.authorizationRevision || 0) } updateRoomTotalTokens(roomId: string, tokens: number): void { @@ -390,7 +718,7 @@ class ChatStorage { } private newRoomSessionSeed(): string { - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` + return randomBytes(16).toString('hex') } rotateRoomSessionSeed(roomId: string): string { @@ -631,15 +959,18 @@ class ChatStorage { deleteWorkspaceRunChangesForRoom(db, roomId, beforeTimestamp) } - private withImmediateTransaction(db: any, fn: () => void): void { + private withImmediateTransaction( + db: DatabaseSync & { readonly inTransaction?: boolean; readonly isTransaction?: boolean }, + fn: () => T, + ): T { if (db.inTransaction || db.isTransaction) { - fn() - return + return fn() } db.exec('BEGIN IMMEDIATE') try { - fn() + const result = fn() db.exec('COMMIT') + return result } catch (err) { try { db.exec('ROLLBACK') } catch { /* ignore */ } throw err @@ -653,7 +984,7 @@ class ChatStorage { this.deleteWorkspaceDiffChanges(roomId) db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId) db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId) - db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId) + db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(this.newRoomSessionSeed(), roomId) }) } @@ -684,10 +1015,17 @@ class ChatStorage { } addRoomAgent(roomId: string, agentId: string, profile: string, name: string, description: string, invited: number): RoomAgent { - const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) - this.db()?.prepare( - 'INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(id, roomId, agentId, profile, name, description, invited) + const id = randomBytes(12).toString('hex') + const db = this.db() + if (!db) return { id, roomId, agentId, profile, name, description, invited } + this.withImmediateTransaction(db, () => { + const createdAt = Date.now() + db.prepare( + 'INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(id, roomId, agentId, profile, name, description, invited, createdAt) + this.ensureAgentActor(roomId, agentId, name, description) + this.incrementRoomAuthorizationRevision(roomId) + }) return { id, roomId, agentId, profile, name, description, invited } } @@ -729,8 +1067,17 @@ class ChatStorage { const db = this.db() if (!db) return this.withImmediateTransaction(db, () => { + const sessions = db.prepare( + 'SELECT session_id, profile_name FROM gc_session_profiles WHERE room_id = ?' + ).all(roomId) as Array<{ session_id: string; profile_name: string }> + for (const session of sessions) { + this.enqueuePendingSessionDelete(session.session_id, session.profile_name) + } this.deleteWorkspaceDiffChanges(roomId) db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_session_profiles WHERE room_id = ?').run(roomId) + db.prepare('DELETE FROM gc_room_actor_capabilities WHERE roomId = ?').run(roomId) + db.prepare('DELETE FROM gc_room_actors WHERE roomId = ?').run(roomId) db.prepare('DELETE FROM gc_room_agents WHERE roomId = ?').run(roomId) db.prepare('DELETE FROM gc_room_members WHERE roomId = ?').run(roomId) db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId) @@ -815,7 +1162,7 @@ class ChatStorage { ).run(userId, userName, description, nextAvatar, nextAuthUserId, Date.now(), existing.id) return } - const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + const id = `gcm_${randomBytes(16).toString('hex')}` const now = Date.now() this.db()?.prepare( 'INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' @@ -834,6 +1181,376 @@ class ChatStorage { ).get(roomId, authUserId) as any) ?? null } + admitHumanMember(args: HumanRoomAdmissionArgs): HumanRoomAdmissionResult { + const db = this.db() + const authUserId = typeof args.authUser?.id === 'number' && args.authUser.id > 0 + ? Math.floor(args.authUser.id) + : null + const localSubjectId = typeof args.localSubjectId === 'string' && args.localSubjectId.trim() + ? args.localSubjectId.trim() + : null + const fallbackExistingMember = this.getMemberByUserId(args.roomId, args.userId) + || (authUserId !== null ? this.getMemberByAuthUserId(args.roomId, authUserId) : null) + + const finalizeMember = (room: RoomInfo, existingMember: Member | null): AdmittedHumanRoomAdmission => { + const userName = args.requestedName || existingMember?.name || `User-${args.userId.slice(0, 6)}` + const description = args.requestedDescription || existingMember?.description || '' + const avatar = args.avatar || existingMember?.avatar || '' + return { + status: 'admitted', + room, + userName, + description, + avatar, + } + } + + if (!db) { + const room = this.getRoom(args.roomId) + if (!room) { + return { status: 'not_found' } + } + const existingMember = fallbackExistingMember + const inviteMatches = groupChatInviteCodeMatches(args.inviteCode, room.inviteCode) + if (args.authUser) { + const subject = createAuthenticatedGroupChatSubject(args.authUser) + const canRead = subject ? evaluateGroupChatAccessPolicy(this, args.roomId, subject).canRead : false + if (!existingMember && !canRead && !inviteMatches) { + return { status: 'not_found' } + } + const admitted = finalizeMember(room, existingMember) + this.addRoomMember(args.roomId, args.userId, admitted.userName, admitted.description, admitted.avatar, authUserId ?? undefined) + return admitted + } + if (!localSubjectId) { + return { status: 'not_found' } + } + const canRead = !room.inviteCode + || inviteMatches + || evaluateGroupChatAccessPolicy(this, args.roomId, createLocalGroupChatSubject(localSubjectId)).canRead + if (!canRead) { + return { status: 'not_found' } + } + const admitted = finalizeMember(room, existingMember) + this.addRoomMember(args.roomId, args.userId, admitted.userName, admitted.description, admitted.avatar) + return admitted + } + + return this.withImmediateTransaction(db, () => { + const room = this.getRoom(args.roomId) + if (!room) { + return { status: 'not_found' } satisfies HumanRoomAdmissionResult + } + const existingMember = this.getMemberByUserId(args.roomId, args.userId) + || (authUserId !== null ? this.getMemberByAuthUserId(args.roomId, authUserId) : null) + const admitted = finalizeMember(room, existingMember) + const inviteMatches = groupChatInviteCodeMatches(args.inviteCode, room.inviteCode) + + if (args.authUser) { + const subject = createAuthenticatedGroupChatSubject(args.authUser) + const policy = subject + ? evaluateGroupChatAccessPolicy(this, args.roomId, subject) + : null + if (!existingMember && !policy?.canRead && !inviteMatches) { + return { status: 'not_found' } satisfies HumanRoomAdmissionResult + } + if (authUserId !== null) { + persistAuthenticatedHumanActor(db, { + roomId: args.roomId, + authUserId, + userName: admitted.userName, + description: admitted.description, + avatar: admitted.avatar, + capabilities: !policy?.canRead && (inviteMatches || existingMember) + ? [...(policy?.capabilities ?? []), 'room.read'] + : undefined, + }) + } + this.addRoomMember( + args.roomId, + args.userId, + admitted.userName, + admitted.description, + admitted.avatar, + authUserId ?? undefined, + ) + return admitted + } + + if (!localSubjectId) { + return { status: 'not_found' } satisfies HumanRoomAdmissionResult + } + const policy = evaluateGroupChatAccessPolicy(this, args.roomId, createLocalGroupChatSubject(localSubjectId)) + if (room.inviteCode && !inviteMatches && !policy.canRead) { + return { status: 'not_found' } satisfies HumanRoomAdmissionResult + } + persistLocalActor(db, { + roomId: args.roomId, + localSubjectId, + userName: admitted.userName, + description: admitted.description, + avatar: admitted.avatar, + grantDefaultCapabilities: inviteMatches || !room.inviteCode, + }) + this.addRoomMember(args.roomId, args.userId, admitted.userName, admitted.description, admitted.avatar) + return admitted + }) + } + + ensureAuthenticatedHumanActor(args: { + roomId: string + authUserId: number + userId: string + userName: string + description: string + avatar: string + capabilities?: string[] + }): GroupActor { + const db = this.db() + if (!db) { + return { + id: args.userId, + roomId: args.roomId, + actorType: 'authenticated_human', + authUserId: args.authUserId, + agentId: null, + localSubjectId: null, + systemKey: null, + name: args.userName, + description: args.description, + avatar: args.avatar, + active: 1, + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: Date.now(), + updatedAt: Date.now(), + } + } + let actor: GroupActor | null = null + this.withImmediateTransaction(db, () => { + actor = persistAuthenticatedHumanActor(db, { + roomId: args.roomId, + authUserId: args.authUserId, + userName: args.userName, + description: args.description, + avatar: args.avatar, + capabilities: args.capabilities, + }) + this.addRoomMember(args.roomId, args.userId, args.userName, args.description, args.avatar, args.authUserId) + }) + if (!actor) { + throw new Error('failed to persist authenticated group actor') + } + return actor + } + + ensureLocalActor(args: { + roomId: string + localSubjectId: string + userId?: string + userName: string + description: string + avatar: string + grantDefaultCapabilities?: boolean + }): GroupActor { + const db = this.db() + if (!db) { + return { + id: args.userId || args.localSubjectId, + roomId: args.roomId, + actorType: 'local', + authUserId: null, + agentId: null, + localSubjectId: args.localSubjectId, + systemKey: null, + name: args.userName, + description: args.description, + avatar: args.avatar, + active: 1, + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: Date.now(), + updatedAt: Date.now(), + } + } + let actor: GroupActor | null = null + const memberUserId = args.userId + this.withImmediateTransaction(db, () => { + actor = persistLocalActor(db, { + roomId: args.roomId, + localSubjectId: args.localSubjectId, + userName: args.userName, + description: args.description, + avatar: args.avatar, + grantDefaultCapabilities: args.grantDefaultCapabilities, + }) + if (memberUserId) { + this.addRoomMember(args.roomId, memberUserId, args.userName, args.description, args.avatar) + } + }) + if (!actor) { + throw new Error('failed to persist local group actor') + } + return actor + } + + ensureAgentActor(roomId: string, agentId: string, name: string, description: string): GroupActor { + const db = this.db() + if (!db) { + return { + id: agentId, + roomId, + actorType: 'agent', + authUserId: null, + agentId, + localSubjectId: null, + systemKey: null, + name, + description, + avatar: '', + active: 1, + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: Date.now(), + updatedAt: Date.now(), + } + } + return persistAgentActor(db, { roomId, agentId, name, description }) + } + + ensureSystemActor(roomId: string, systemKey = 'room-system'): GroupActor { + const db = this.db() + if (!db) { + return { + id: systemKey, + roomId, + actorType: 'system', + authUserId: null, + agentId: null, + localSubjectId: null, + systemKey, + name: 'system', + description: '', + avatar: '', + active: 1, + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: Date.now(), + updatedAt: Date.now(), + } + } + return persistSystemActor(db, { roomId, systemKey }) + } + + removeAgentActorWithRetention(roomId: string, agentRef: string): RemovedAgentRetention | null { + const db = this.db() + if (!db) { + const agent = this.getRoomAgent(roomId, agentRef) + if (!agent) return null + this.removeRoomAgent(roomId, agentRef) + return { + agent, + actorId: null, + sessionProfiles: [], + } + } + let removed: RemovedAgentRetention | null = null + this.withImmediateTransaction(db, () => { + const agent = this.getRoomAgent(roomId, agentRef) + if (!agent) return + const sessionProfiles = this.getSessionProfilesForRoomAgent(roomId, agent.agentId) + const actor = deactivatePersistedAgentActorWithRetention(db, roomId, agent.agentId) + for (const session of sessionProfiles) { + this.enqueuePendingSessionDelete(session.session_id, session.profile_name) + } + db.prepare('DELETE FROM gc_session_profiles WHERE room_id = ? AND agent_id = ?').run(roomId, agent.agentId) + this.removeRoomMembersForAgent(roomId, agent) + this.removeRoomAgent(roomId, agentRef) + this.incrementRoomAuthorizationRevision(roomId) + removed = { + agent, + actorId: actor?.id || null, + sessionProfiles, + } + }) + return removed + } + + deactivateAuthenticatedHumanActorWithRetention(roomId: string, authUserId: number): GroupActor | null { + const db = this.db() + if (!db || !Number.isFinite(authUserId) || authUserId <= 0) { + return null + } + let actorId: string | null = null + this.withImmediateTransaction(db, () => { + const actor = this.findActiveActorByAuthUserId(roomId, Math.floor(authUserId)) + let changed = false + if (actor) { + actorId = actor.id + const now = Date.now() + db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + const tombstoned = db.prepare( + `UPDATE gc_room_actors + SET active = 0, + authUserId = NULL, + agentId = NULL, + localSubjectId = NULL, + systemKey = NULL, + name = ?, + description = '', + avatar = '', + authorizationRevision = authorizationRevision + 1, + contextRevision = contextRevision + 1, + tombstonedAt = ?, + updatedAt = ? + WHERE id = ? AND active = 1` + ).run('Revoked user', now, now, actor.id) + changed = tombstoned.changes > 0 + } + const removedMembership = db.prepare( + 'DELETE FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).run(roomId, Math.floor(authUserId)) + const removedOwnership = db.prepare( + 'UPDATE gc_rooms SET ownerAuthUserId = NULL WHERE id = ? AND ownerAuthUserId = ?', + ).run(roomId, Math.floor(authUserId)) + changed = changed || removedMembership.changes > 0 || removedOwnership.changes > 0 + if (changed) this.incrementRoomAuthorizationRevision(roomId) + }) + if (!actorId) return null + return (db.prepare( + 'SELECT id, roomId, actorType, authUserId, agentId, localSubjectId, systemKey, name, description, avatar, active, authorizationRevision, contextRevision, tombstonedAt, createdAt, updatedAt FROM gc_room_actors WHERE id = ?' + ).get(actorId) as GroupActor | undefined) ?? null + } + + findActiveActorByAuthUserId(roomId: string, authUserId: number): GroupActor | null { + const db = this.db() + return db ? readActiveActorByAuthUserId(db, roomId, authUserId) : null + } + + findActiveActorByAgentIdentity(roomId: string, agentId: string): GroupActor | null { + const db = this.db() + return db ? readActiveActorByAgentIdentity(db, roomId, agentId) : null + } + + findActiveActorByLocalSubjectId(roomId: string, localSubjectId: string): GroupActor | null { + const db = this.db() + return db ? readActiveActorByLocalSubjectId(db, roomId, localSubjectId) : null + } + + findActiveActorBySystemKey(roomId: string, systemKey: string): GroupActor | null { + const db = this.db() + return db ? readActiveActorBySystemKey(db, roomId, systemKey) : null + } + + getActorCapabilities(actorId: string): string[] { + const db = this.db() + return db ? [...readPersistedActorCapabilities(db, actorId)] : [] + } + updateMemberActivity(roomId: string, userId: string): void { this.db()?.prepare( 'UPDATE gc_room_members SET updatedAt = ? WHERE roomId = ? AND userId = ?' @@ -855,6 +1572,8 @@ class ChatRoom { readonly id: string name: string readonly members = new Map() + private readonly userIdBySocketId = new Map() + private readonly socketIdsByUserId = new Map>() constructor(id: string, name?: string) { this.id = id @@ -862,25 +1581,50 @@ class ChatRoom { } addOrUpdateMember(socketId: string, userId: string, name: string, description: string, source: 'human' | 'agent' = 'human', avatar: string = ''): Member { + const previousUserId = this.userIdBySocketId.get(socketId) + if (previousUserId && previousUserId !== userId) this.removeMember(socketId) + const existing = this.members.get(userId) - if (existing) { - existing.name = name - existing.description = description - existing.online = true - existing.socketId = socketId - existing.source = source - if (avatar) existing.avatar = avatar - return existing - } - const member: Member = { id: socketId, userId, name, description, joinedAt: Date.now(), online: true, socketId, source, avatar } + const member = existing || { + id: socketId, + userId, + name, + description, + joinedAt: Date.now(), + online: true, + socketId, + source, + avatar, + } + member.name = name + member.description = description + member.online = true + member.socketId = socketId + member.source = source + if (avatar) member.avatar = avatar this.members.set(userId, member) + this.userIdBySocketId.set(socketId, userId) + const socketIds = this.socketIdsByUserId.get(userId) || new Set() + socketIds.add(socketId) + this.socketIdsByUserId.set(userId, socketIds) return member } removeMember(socketId: string): void { - for (const member of this.members.values()) { - if (member.socketId === socketId) { - member.online = false + const userId = this.userIdBySocketId.get(socketId) + if (!userId) return + this.userIdBySocketId.delete(socketId) + const socketIds = this.socketIdsByUserId.get(userId) + socketIds?.delete(socketId) + const member = this.members.get(userId) + if (!socketIds || socketIds.size === 0) { + this.socketIdsByUserId.delete(userId) + if (member) member.online = false + return + } + if (member?.socketId === socketId) { + for (const remainingSocketId of socketIds) { + member.socketId = remainingSocketId break } } @@ -891,15 +1635,19 @@ class ChatRoom { } getOnlineMemberBySocketId(socketId: string): Member | undefined { - for (const member of this.members.values()) { - if (member.socketId === socketId && member.online) return member - } - return undefined + const userId = this.userIdBySocketId.get(socketId) + if (!userId) return undefined + const member = this.members.get(userId) + return member?.online ? member : undefined } hasOnlineMember(socketId: string): boolean { return this.getOnlineMemberBySocketId(socketId) !== undefined } + + hasOnlineUser(userId: string): boolean { + return (this.socketIdsByUserId.get(userId)?.size || 0) > 0 + } } // ─── GroupChat Server ──────────────────────────────────────── @@ -909,14 +1657,17 @@ export class GroupChatServer { private nsp: Namespace private storage: ChatStorage private rooms = new Map() - /** Map: socket.id → persistent userId */ + /** Map: socket.id → display/routing userId */ private socketUserMap = new Map() + /** Map: socket.id → authoritative local principal for unauthenticated users */ + private socketLocalSubjectIdMap = new Map() /** Map: userId → { name, description } (from auth) */ private userInfoMap = new Map() /** Map: socket.id → requested participant source from handshake */ private socketRequestedSourceMap = new Map() /** Map: socket.id → numeric users.id from the web UI auth (for avatar resolution) */ private socketAuthUserIdMap = new Map() + private localIdentitySecretPromise: Promise | null = null readonly agentClients = new AgentClients() private _contextEngine: ContextEngine | null = null private _restoreScheduled = false @@ -926,6 +1677,11 @@ export class GroupChatServer { private contextStatusState = new Map>() /** roomId -> blocked Bridge session ids from room-level interrupts/rotations. */ private fencedRoomAgentSessions = new Map>() + /** Bridge approval id -> server-observed originating room and live agent run. */ + private pendingApprovals = new Map() + private inviteAttemptLimiter?: GroupChatInviteAttemptLimiter + /** Test-only join checkpoint hook for deterministic invite-admission races. */ + private joinAdmissionCheckpointForTests: ((args: { roomId: string; userId: string; inviteCode?: string }) => void) | null = null constructor(httpServers: HttpServer | HttpServer[]) { this.storage = new ChatStorage() @@ -963,23 +1719,12 @@ export class GroupChatServer { // Initialize context engine for group chat compression const contextEngine = new ContextEngine({ messageFetcher: this.storage, - sessionCleaner: async (sessionId: string) => { - // TODO: re-enable session deletion after confirming it doesn't - // accidentally remove user-created sessions outside group chat. - // try { - // const profile = this.storage.getSessionProfile(sessionId) - // const profileName = profile?.profile_name || 'default' - // this.storage.enqueuePendingSessionDelete(sessionId, profileName) - // } catch (err: any) { - // logger.warn(`[GroupChat] failed to enqueue compression session delete ${sessionId}: ${err.message}`) - // } - }, }) this.agentClients.setContextEngine(contextEngine) this.agentClients.setStorage(this.storage) this.agentClients.setWorkspaceDiffBroadcaster((roomId, msg, totalTokens) => { - this.nsp.to(roomId).emit('message', msg) - this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens }) + this.emitToRoomReaders(roomId, 'message', msg) + this.emitToRoomReaders(roomId, 'room_updated', { roomId, totalTokens }) }) this._contextEngine = contextEngine @@ -995,12 +1740,119 @@ export class GroupChatServer { return this.storage } + revokeAuthenticatedUser(authUserId: number): void { + if (!Number.isInteger(authUserId) || authUserId <= 0) return + for (const room of this.storage.getAllRooms()) { + this.storage.deactivateAuthenticatedHumanActorWithRetention(room.id, authUserId) + } + const sockets = Array.from(this.nsp.sockets.values()) + .filter((socket) => this.socketAuthUserIdMap.get(socket.id) === authUserId) + for (const socket of sockets) { + this.leaveAllRooms(socket, socket.id) + socket.disconnect(true) + } + } + + leaveAuthenticatedUserRoom(roomId: string, authUserId: number): void { + if (!roomId || !Number.isInteger(authUserId) || authUserId <= 0) return + const room = this.rooms.get(roomId) + const removedMembers = new Map() + const sockets = Array.from(this.nsp.sockets.values()) + .filter((socket) => this.socketAuthUserIdMap.get(socket.id) === authUserId) + for (const socket of sockets) { + const removed = this.removeUnauthorizedRoomSocket(socket, roomId) + if (removed) removedMembers.set(removed.userId, removed) + } + for (const removed of removedMembers.values()) { + if (removed.source === 'agent' || room?.hasOnlineUser(removed.userId)) continue + this.emitToRoomReaders(roomId, 'member_left', { + roomId, + memberId: removed.userId, + memberName: removed.name, + members: room?.getMembersList() ?? [], + }) + } + } + + getInviteAttemptLimiter(): GroupChatInviteAttemptLimiter { + this.inviteAttemptLimiter ??= new GroupChatInviteAttemptLimiter() + return this.inviteAttemptLimiter + } + + async resolveLocalCredentialSubject(localCredential: unknown): Promise { + if (typeof localCredential !== 'string' || !localCredential.trim()) return null + return decodeLocalCredential(await this.getLocalIdentitySecret(), localCredential) + } + + async resolveInviteAttemptSubjectKey( + authUserId: number | null | undefined, + fallback: string | null | undefined, + localCredential: unknown, + ): Promise { + if (typeof authUserId === 'number' && Number.isInteger(authUserId) && authUserId > 0) { + return groupChatInviteAttemptSubjectKey(authUserId, fallback) + } + const localSubjectId = await this.resolveLocalCredentialSubject(localCredential) + return groupChatInviteAttemptSubjectKey(null, fallback, localSubjectId) + } + getContextEngine(): ContextEngine | null { return this._contextEngine || null } - getRoomIds(): string[] { - return Array.from(this.rooms.keys()) + private getLocalIdentitySecret(): Promise { + if (!this.localIdentitySecretPromise) { + this.localIdentitySecretPromise = getGroupChatLocalIdentitySecret() + } + return this.localIdentitySecretPromise + } + + private async resolveLocalSocketIdentity(credential: unknown): Promise<{ localSubjectId: string; localUserId: string; localCredential: string }> { + const secret = await this.getLocalIdentitySecret() + if (typeof credential === 'string' && credential.trim()) { + const localSubjectId = decodeLocalCredential(secret, credential) + if (localSubjectId) { + return { + localSubjectId, + localUserId: localGroupUserId(secret, localSubjectId), + localCredential: credential.trim(), + } + } + } + const localSubjectId = createLocalGroupChatSubjectId() + return { + localSubjectId, + localUserId: localGroupUserId(secret, localSubjectId), + localCredential: encodeLocalCredential(secret, localSubjectId), + } + } + + private getDiscoverableRoomIds(socket: Socket): string[] { + return Array.from(this.rooms.keys()).filter((roomId) => this.socketAccessPolicy(socket, roomId).canDiscover) + } + + setJoinAdmissionCheckpointForTests( + hook: ((args: { roomId: string; userId: string; inviteCode?: string }) => void) | null, + ): void { + this.joinAdmissionCheckpointForTests = hook + } + + private roomAgentSessionIdentity(roomId: string, roomAgent: Pick) { + const room = typeof this.storage.getRoom === 'function' ? this.storage.getRoom(roomId) : undefined + const sessionSeed = String(room?.sessionSeed || '') + if (!room || !/^[0-9a-f]{32}$/i.test(sessionSeed)) { + throw new Error(`Group chat room ${roomId} is missing a cryptographic session seed`) + } + const actor = typeof this.storage.findActiveActorByAgentIdentity === 'function' + ? this.storage.findActiveActorByAgentIdentity(roomId, roomAgent.agentId) + : null + return { + sessionSeed, + actorId: actor?.id || null, + roomAuthorizationRevision: room.authorizationRevision, + actorAuthorizationRevision: actor?.authorizationRevision, + actorContextRevision: actor?.contextRevision, + } } fenceCurrentRoomAgentSessions(roomId: string): () => void { @@ -1008,7 +1860,8 @@ export class GroupChatServer { if (!room) return () => {} const ids = new Set() for (const agent of this.storage.getRoomAgents(roomId) || []) { - ids.add(groupBridgeSessionId(roomId, agent.profile, agent.name, String(room.sessionSeed || '0'))) + const sessionIdentity = this.roomAgentSessionIdentity(roomId, agent) + ids.add(groupBridgeSessionId(roomId, agent.profile, agent.name, sessionIdentity.sessionSeed, sessionIdentity)) } if (!ids.size) return () => {} if (!this.fencedRoomAgentSessions) this.fencedRoomAgentSessions = new Map>() @@ -1033,39 +1886,66 @@ export class GroupChatServer { return this.fencedRoomAgentSessions?.get(roomId)?.has(sessionId) === true } - async clearRoomRuntimeState(roomId: string): Promise { - const roomTyping = this.typingState.get(roomId) - if (roomTyping) { - for (const entry of roomTyping.values()) clearTimeout(entry.timer) - this.typingState.delete(roomId) - } - this.contextStatusState.delete(roomId) + async clearRoomRuntimeState(roomId: string, assertAuthorized: () => void): Promise { const releaseSessionFence = this.fenceCurrentRoomAgentSessions(roomId) try { await this.agentClients.interruptRoom(roomId) + assertAuthorized() } catch (err) { releaseSessionFence() throw err } - this.agentClients.resetRoomContext(roomId) - this.nsp.to(roomId).emit('room_cleared', { roomId, totalTokens: 0 }) - this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens: 0 }) - } - - async deleteRoomRuntimeState(roomId: string): Promise { const roomTyping = this.typingState.get(roomId) if (roomTyping) { for (const entry of roomTyping.values()) clearTimeout(entry.timer) this.typingState.delete(roomId) } this.contextStatusState.delete(roomId) + this.clearPendingApprovals(roomId) + this.agentClients.resetRoomContext(roomId) + this.emitToRoomReaders(roomId, 'room_cleared', { roomId, totalTokens: 0 }) + this.emitToRoomReaders(roomId, 'room_updated', { roomId, totalTokens: 0 }) + } + + async cleanupRemovedAgentRuntime(removal: RemovedAgentRetention | null): Promise { + if (!removal) return + this.clearPendingApprovals(removal.agent.roomId, removal.agent.agentId) + const bridge = new AgentBridgeClient() + for (const session of removal.sessionProfiles) { + try { + await bridge.interrupt(session.session_id, 'Interrupted by group chat user', session.profile_name) + } catch (err) { + if (!isUnknownBridgeSessionError(err)) { + logger.warn(`[GroupChat] failed to interrupt deleted agent session ${session.session_id}: ${(err as Error).message || err}`) + } + } + try { + await bridge.destroy(session.session_id, session.profile_name) + } catch (err) { + if (!isUnknownBridgeSessionError(err)) { + logger.warn(`[GroupChat] failed to destroy deleted agent session ${session.session_id}: ${(err as Error).message || err}`) + } + } + } + this.agentClients.removeAgentFromRoom(removal.agent.roomId, removal.agent.agentId) + } + + async deleteRoomRuntimeState(roomId: string, assertAuthorized: () => void): Promise { const releaseSessionFence = this.fenceCurrentRoomAgentSessions(roomId) try { await this.agentClients.interruptRoom(roomId) + assertAuthorized() } catch (err) { releaseSessionFence() throw err } + const roomTyping = this.typingState.get(roomId) + if (roomTyping) { + for (const entry of roomTyping.values()) clearTimeout(entry.timer) + this.typingState.delete(roomId) + } + this.contextStatusState.delete(roomId) + this.clearPendingApprovals(roomId) this.agentClients.disconnectRoom(roomId) this.rooms.delete(roomId) this.nsp.in(roomId).socketsLeave(roomId) @@ -1115,10 +1995,37 @@ export class GroupChatServer { // ─── Auth ─────────────────────────────────────────────────── + private readActiveAuthenticatedUser(authUserId: number | undefined): AuthenticatedUser | null { + if (!Number.isInteger(authUserId) || !authUserId || authUserId <= 0) return null + try { + const user = findUserById(authUserId) + if (!user || user.status !== 'active') return null + return { + id: user.id, + username: user.username, + role: user.role, + profiles: user.role === 'super_admin' + ? [] + : listUserProfiles(user.id).map((profile) => profile.profile_name), + } + } catch (err) { + logger.warn(`[GroupChat] failed to refresh authenticated authority for user ${authUserId}: ${(err as Error).message}`) + return null + } + } + + private currentAuthenticatedSocketUser(socket: Socket): AuthenticatedUser | null { + return this.readActiveAuthenticatedUser(this.socketAuthUserIdMap?.get(socket.id)) + } + private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise { - const auth = socket.handshake.auth as { source?: string; agentSocketSecret?: string; token?: string } + const auth = socket.handshake.auth as { source?: string; agentSocketSecret?: string; token?: string; localCredential?: string } const isAgentSocket = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET if (isAgentSocket) { + delete socket.data.authUserId + delete socket.data.localSubjectId + delete socket.data.localUserId + delete socket.data.localCredential next() return } @@ -1127,31 +2034,76 @@ export class GroupChatServer { if (await isAuthEnabled()) { const user = await authenticateUserToken(String(token)) if (!user) return next(new Error('Unauthorized')) - socket.data.authUser = user + socket.data.authUserId = user.id + delete socket.data.localSubjectId + delete socket.data.localUserId + delete socket.data.localCredential + next() + return } + + const localIdentity = await this.resolveLocalSocketIdentity(auth.localCredential) + delete socket.data.authUserId + socket.data.localSubjectId = localIdentity.localSubjectId + socket.data.localUserId = localIdentity.localUserId + socket.data.localCredential = localIdentity.localCredential next() } // ─── Connection ───────────────────────────────────────────── private onConnection(socket: Socket): void { - const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string; source?: string; agentSocketSecret?: string; authUserId?: number } + const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string; source?: string; agentSocketSecret?: string } const requestedSource = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET ? 'agent' : 'human' - const authenticatedUser = socket.data.authUser as AuthenticatedUser | undefined - const authUserId = requestedSource === 'human' - ? authenticatedUser?.id ?? (typeof auth.authUserId === 'number' && auth.authUserId > 0 ? auth.authUserId : undefined) + const storedAuthUserId = requestedSource === 'human' && typeof socket.data.authUserId === 'number' + ? socket.data.authUserId : undefined - const userId = authUserId ? authenticatedGroupUserId(authUserId) : auth.userId || socket.id + const authenticatedUser = this.readActiveAuthenticatedUser(storedAuthUserId) + if (storedAuthUserId && !authenticatedUser) { + socket.disconnect(true) + return + } + const authUserId = authenticatedUser?.id + const localSubjectId = requestedSource === 'human' && !authUserId && typeof socket.data.localSubjectId === 'string' + ? socket.data.localSubjectId + : null + const localUserId = requestedSource === 'human' && !authUserId && typeof socket.data.localUserId === 'string' + ? socket.data.localUserId + : null + let userId: string + if (requestedSource === 'agent') { + userId = typeof auth.userId === 'string' && auth.userId.trim() ? auth.userId.trim() : socket.id + } else if (authUserId) { + userId = authenticatedGroupUserId(authUserId) + } else { + if (!localSubjectId || !localUserId) { + socket.disconnect(true) + return + } + userId = localUserId + } const userName = auth.name || authenticatedUser?.username || `User-${userId.slice(0, 6)}` const description = auth.description || '' this.socketUserMap.set(socket.id, userId) + if (localSubjectId) { + this.socketLocalSubjectIdMap.set(socket.id, localSubjectId) + } this.socketRequestedSourceMap.set(socket.id, requestedSource) this.userInfoMap.set(userId, { name: userName, description }) if (typeof authUserId === 'number') { this.socketAuthUserIdMap.set(socket.id, authUserId) } + if (requestedSource === 'human' && !authUserId && typeof socket.data.localCredential === 'string') { + setTimeout(() => { + socket.emit('local_identity', { + localCredential: socket.data.localCredential, + userId, + }) + }, 0) + } + logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`) socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack)) @@ -1165,37 +2117,104 @@ export class GroupChatServer { socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data)) socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data)) socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack)) - socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data)) - socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data)) + socket.on('approval.requested', (data: { roomId?: string; agentName?: string; agentSessionId?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data)) + socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; agentSessionId?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data)) socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack)) socket.on('disconnect', () => this.handleDisconnect(socket)) } // ─── Handlers ─────────────────────────────────────────────── - private canSocketJoinRoom(socket: Socket, roomId: string, room: RoomInfo | undefined, existingMember: Member | null, inviteCode?: string): boolean { - if (!room) return typeof this.storage.getRoom !== 'function' - const requested = typeof inviteCode === 'string' ? inviteCode.trim() : '' - if (requested && room.inviteCode && requested === room.inviteCode) return true - const authUser = socket.data?.authUser as AuthenticatedUser | undefined - if (!authUser) return Boolean(existingMember || !room.inviteCode) - if (authUser.role === 'super_admin') return true - if (typeof authUser.id === 'number' && Number(room.ownerAuthUserId || 0) === authUser.id) return true - if (existingMember) return true - const profiles = authenticatedUserProfiles(authUser) - return profiles.length > 0 && typeof this.storage.getRoomsForProfiles === 'function' && this.storage.getRoomsForProfiles(profiles).some(candidate => candidate.id === roomId) + private socketAccessPolicy(socket: Socket, roomId: string) { + const requestedSource = this.socketRequestedSourceMap?.get(socket.id) || 'human' + const userId = this.socketUserMap?.get(socket.id) || socket.id + const localSubjectId = this.socketLocalSubjectIdMap?.get(socket.id) || null + const authUser = this.currentAuthenticatedSocketUser(socket) + const storedRoom = typeof this.storage.getRoom === 'function' ? this.storage.getRoom(roomId) : null + + const policy = requestedSource === 'agent' + ? evaluateGroupChatAccessPolicy(this.storage, roomId, createAgentGroupChatSubject(userId)) + : authUser + ? (() => { + const subject = createAuthenticatedGroupChatSubject({ + id: authUser.id, + role: authUser.role, + profiles: authUser.profiles ?? [], + }) + return subject + ? evaluateGroupChatAccessPolicy(this.storage, roomId, subject) + : noAccessPolicyDecision(roomId, storedRoom) + })() + : localSubjectId + ? evaluateGroupChatAccessPolicy(this.storage, roomId, createLocalGroupChatSubject(localSubjectId)) + : noAccessPolicyDecision(roomId, storedRoom) + + return policy + } + + private removeUnauthorizedRoomSocket(socket: Socket, roomId: string): Member | null { + const room = this.rooms.get(roomId) + const member = room?.getOnlineMemberBySocketId(socket.id) ?? null + room?.removeMember(socket.id) + socket.leave(roomId) + + const userId = this.socketUserMap.get(socket.id) || socket.id + const roomTyping = this.typingState.get(roomId) + const typingEntry = roomTyping?.get(userId) + if (typingEntry) clearTimeout(typingEntry.timer) + roomTyping?.delete(userId) + if (roomTyping?.size === 0) this.typingState.delete(roomId) + + if (member?.source === 'agent' && !room?.hasOnlineUser(member.userId)) { + const roomStatuses = this.contextStatusState.get(roomId) + roomStatuses?.delete(member.name) + if (roomStatuses?.size === 0) this.contextStatusState.delete(roomId) + } + return member + } + + private emitToRoomReaders(roomId: string, event: string, payload: unknown, excludeSocketId?: string): void { + const socketIds = this.nsp?.adapter?.rooms?.get(roomId) + if (!socketIds) return + + const recipients: Socket[] = [] + const removedMembers = new Map() + for (const socketId of Array.from(socketIds)) { + const recipient = this.nsp.sockets?.get(socketId) + if (!recipient) continue + if (!this.socketAccessPolicy(recipient, roomId).canRead) { + const removed = this.removeUnauthorizedRoomSocket(recipient, roomId) + if (removed) removedMembers.set(removed.userId, removed) + continue + } + if (recipient.id !== excludeSocketId) recipients.push(recipient) + } + + for (const recipient of recipients) recipient.emit(event, payload) + if (event === 'member_left') return + const room = this.rooms.get(roomId) + for (const removed of removedMembers.values()) { + if (room?.hasOnlineUser(removed.userId)) continue + if (removed.source === 'agent') continue + const memberPayload = { + roomId, + memberId: removed.userId, + memberName: removed.name, + members: room?.getMembersList() ?? [], + } + for (const recipient of recipients) recipient.emit('member_left', memberPayload) + } + } + + private runJoinAdmissionCheckpointForTests(roomId: string, userId: string, inviteCode?: string): void { + const hook = this.joinAdmissionCheckpointForTests + if (!hook) return + this.joinAdmissionCheckpointForTests = null + hook({ roomId, userId, inviteCode }) } private canSocketManageRoom(socket: Socket, roomId: string): boolean { - if (this.socketRequestedSourceMap?.get(socket.id) === 'agent') return false - const room = typeof this.storage.getRoom === 'function' ? this.storage.getRoom(roomId) : undefined - if (!room) return false - const authUser = socket.data?.authUser as AuthenticatedUser | undefined - if (!authUser) return true - if (authUser.role === 'super_admin') return true - if (typeof authUser.id === 'number' && Number(room.ownerAuthUserId || 0) === authUser.id) return true - const profiles = authenticatedUserProfiles(authUser) - return profiles.length > 0 && typeof this.storage.getRoomsForProfiles === 'function' && this.storage.getRoomsForProfiles(profiles).some(candidate => candidate.id === roomId) + return this.socketAccessPolicy(socket, roomId).canManage } private getOnlineRoomMember(socket: Socket, roomId: string): { room: ChatRoom; member: Member } | null { @@ -1210,30 +2229,48 @@ export class GroupChatServer { return !agentName || joined.member.name === agentName } - private emitToRoomManagers(roomId: string, event: string, payload: Record): void { - const room = this.rooms.get(roomId) - if (!room) return - const emitted = new Set() - for (const member of room.members.values()) { - if (!member.online || member.source === 'agent') continue - const socket = this.nsp.sockets.get(member.socketId) - if (!socket || emitted.has(socket.id)) continue - if (!this.canSocketManageRoom(socket, roomId)) continue + private emitToRoomApprovalResponders(roomId: string, event: string, payload: Record): void { + const socketIds = this.nsp?.adapter?.rooms?.get(roomId) + if (!socketIds) return + for (const socketId of Array.from(socketIds)) { + const socket = this.nsp.sockets?.get(socketId) + if (!socket) continue + const joined = this.getOnlineRoomMember(socket, roomId) + if (!joined) { + socket.leave(roomId) + continue + } + const policy = this.socketAccessPolicy(socket, roomId) + if (!policy.canRead) { + this.removeUnauthorizedRoomSocket(socket, roomId) + continue + } + if (joined.member.source === 'agent' || !policy.canApprove) continue socket.emit(event, payload) - emitted.add(socket.id) } } - private agentSessionIsCurrent(roomId: string, member: Member | undefined, agentSessionId: unknown): boolean { + private agentIdentitySessionIsCurrent(roomId: string, agentId: string, agentSessionId: unknown): boolean { const sessionId = typeof agentSessionId === 'string' ? agentSessionId.trim() : '' - if (!sessionId || member?.source !== 'agent') return false - const room = typeof this.storage.getRoom === 'function' ? this.storage.getRoom(roomId) : undefined - if (!room) return false - const roomAgent = this.storage.getRoomAgentByAgentId(roomId, member.userId) + if (!sessionId) return false + const roomAgent = this.storage.getRoomAgentByAgentId(roomId, agentId) if (!roomAgent) return false - const expected = groupBridgeSessionId(roomId, roomAgent.profile, roomAgent.name, String(room.sessionSeed || '0')) - if (sessionId !== expected) return false - return !this.isRoomAgentSessionFenced(roomId, sessionId) + try { + const sessionIdentity = this.roomAgentSessionIdentity(roomId, roomAgent) + const expected = groupBridgeSessionId(roomId, roomAgent.profile, roomAgent.name, sessionIdentity.sessionSeed, sessionIdentity) + if (sessionId !== expected || this.isRoomAgentSessionFenced(roomId, sessionId)) return false + const actor = this.storage.findActiveActorByAgentIdentity(roomId, agentId) + if (!actor) return false + const capabilities = new Set(this.storage.getActorCapabilities(actor.id)) + return capabilities.has('room.read') && capabilities.has('room.write') + } catch { + return false + } + } + + private agentSessionIsCurrent(roomId: string, member: Member | undefined, agentSessionId: unknown): boolean { + return member?.source === 'agent' + && this.agentIdentitySessionIsCurrent(roomId, member.userId, agentSessionId) } private canPersistAgentMessageForCurrentSession(roomId: string, member: Member | undefined, data: Partial): boolean { @@ -1249,15 +2286,16 @@ export class GroupChatServer { if (!joined || joined.member.source !== 'agent') return null if (agentName && joined.member.name !== agentName) return null if (!this.agentSessionIsCurrent(roomId, joined.member, agentSessionId)) return null + if (!this.socketAccessPolicy(socket, roomId).canWrite) return null return joined.member } private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string; inviteCode?: string }, ack?: (res: any) => void): void { const socketId = socket.id const userId = this.socketUserMap.get(socketId) || socketId + const localSubjectId = this.socketLocalSubjectIdMap?.get(socketId) || null const requestedSource = this.socketRequestedSourceMap.get(socketId) || 'human' const roomId = data.roomId || 'general' - const storedRoom = typeof this.storage.getRoom === 'function' ? this.storage.getRoom(roomId) : undefined const roomAgent = this.storage.getRoomAgentByAgentId(roomId, userId) if (requestedSource === 'agent' && !roomAgent) { ack?.({ error: 'Access denied' }) @@ -1268,14 +2306,39 @@ export class GroupChatServer { ack?.({ error: 'Reserved member identity' }) return } - const socketAuthUserId = this.socketAuthUserIdMap.get(socket.id) - const existingMember = this.storage.getMemberByUserId(roomId, userId) || - (typeof socketAuthUserId === 'number' ? this.storage.getMemberByAuthUserId(roomId, socketAuthUserId) : null) - if (source !== 'agent' && !this.canSocketJoinRoom(socket, roomId, storedRoom, existingMember, data.inviteCode)) { - ack?.({ error: 'Access denied' }) + const inviteAttemptKey = source === 'human' && typeof data.inviteCode === 'string' + ? groupChatInviteAttemptSubjectKey( + this.socketAuthUserIdMap.get(socket.id), + socket.handshake?.address, + localSubjectId, + ) + : null + const inviteAttemptLimiter = this.getInviteAttemptLimiter() + const persistedRoomForAdmission = typeof this.storage.getRoom === 'function' + ? this.storage.getRoom(roomId) + : null + const canReadWithoutInvite = source !== 'agent' + && (this.socketAccessPolicy(socket, roomId).canRead + || Boolean(persistedRoomForAdmission && !persistedRoomForAdmission.inviteCode)) + if (inviteAttemptKey && !canReadWithoutInvite && !inviteAttemptLimiter.isAllowed(inviteAttemptKey)) { + ack?.({ error: 'Room not found' }) return } - const userInfo = this.userInfoMap.get(userId) || { + let authUserId: number | undefined + if (source !== 'agent') { + authUserId = this.socketAuthUserIdMap?.get(socket.id) + } + const getExistingMemberByUserId = typeof this.storage.getMemberByUserId === 'function' + ? this.storage.getMemberByUserId.bind(this.storage) + : () => null + const getExistingMemberByAuthUserId = typeof this.storage.getMemberByAuthUserId === 'function' + ? this.storage.getMemberByAuthUserId.bind(this.storage) + : () => null + const existingMember = source !== 'agent' + ? getExistingMemberByUserId(roomId, userId) + || (typeof authUserId === 'number' ? getExistingMemberByAuthUserId(roomId, authUserId) : null) + : null + const userInfo = this.userInfoMap?.get(userId) || { name: `User-${userId.slice(0, 6)}`, description: '', } @@ -1285,29 +2348,15 @@ export class GroupChatServer { // so switching rooms doesn't overwrite a member's per-room identity. // The DB is authoritative for existing members; requestedName only // applies on first join (when there's no DB record yet). - const userName = existingMember?.name || requestedName || userInfo.name - const description = existingMember?.description || requestedDescription || userInfo.description - - // Update stored user info - this.userInfoMap.set(userId, { name: userName, description }) + let userName = existingMember?.name || requestedName || userInfo.name + let description = existingMember?.description || requestedDescription || userInfo.description let room = this.rooms.get(roomId) - if (!room) { - if (!storedRoom && typeof this.storage.getRoom === 'function') { - ack?.({ error: 'Room not found' }) - return - } - room = new ChatRoom(roomId) - this.rooms.set(roomId, room) - if (!storedRoom) this.storage.saveRoom(roomId, roomId) - } // Look up the user's avatar via their numeric users.id from the web UI session. // Falls back to name-based lookup for clients that don't pass authUserId. let userAvatar = '' - let authUserId: number | undefined if (source !== 'agent') { - authUserId = this.socketAuthUserIdMap.get(socket.id) if (typeof authUserId === 'number') { try { userAvatar = getUserAvatar(authUserId) || '' @@ -1324,24 +2373,89 @@ export class GroupChatServer { } } + let admittedRoom: RoomInfo | undefined // Persist only human members. Agent sockets are runtime participants // tracked through gc_room_agents and AgentClients; storing them in // gc_room_members makes member counts grow on reconnect/restore. - if (source !== 'agent') { - this.storage.addRoomMember(roomId, userId, userName, description, userAvatar, authUserId) + const joinedAgent = source === 'agent' ? roomAgent : null + if (joinedAgent) { + admittedRoom = this.storage.getRoom(roomId) + if (!admittedRoom) { + ack?.({ error: 'Room not found' }) + return + } + this.storage.ensureAgentActor?.(roomId, joinedAgent.agentId, joinedAgent.name, joinedAgent.description) + } else { + this.runJoinAdmissionCheckpointForTests(roomId, userId, data.inviteCode) + let admission: any + if (typeof this.storage.admitHumanMember === 'function') { + admission = this.storage.admitHumanMember({ + roomId, + userId, + localSubjectId, + inviteCode: data.inviteCode, + requestedName: userName, + requestedDescription: description, + avatar: userAvatar, + authUser: this.currentAuthenticatedSocketUser(socket) ?? undefined, + }) + } else { + const roomForAdmission = typeof this.storage.getRoom === 'function' + ? this.storage.getRoom(roomId) + : null + const inviteMatches = groupChatInviteCodeMatches(data.inviteCode, roomForAdmission?.inviteCode) + const canRead = this.socketAccessPolicy(socket, roomId).canRead + if (!roomForAdmission || (!existingMember && !canRead && !inviteMatches && Boolean(roomForAdmission.inviteCode))) { + admission = { status: 'not_found' } + } else { + this.storage.addRoomMember?.(roomId, userId, userName, description, userAvatar, authUserId) + admission = { + status: 'admitted', + room: roomForAdmission, + userName, + description, + avatar: userAvatar, + } + } + } + if (admission.status !== 'admitted') { + if (inviteAttemptKey) inviteAttemptLimiter.recordFailure(inviteAttemptKey) + ack?.({ error: 'Room not found' }) + return + } + admittedRoom = admission.room + userName = admission.userName + description = admission.description + userAvatar = admission.avatar + } + + if (!this.socketAccessPolicy(socket, roomId).canRead) { + ack?.({ error: 'Room not found' }) + return + } + + // Update stored user info only after admission succeeds. + this.userInfoMap?.set(userId, { name: userName, description }) + + if (!room) { + room = new ChatRoom(roomId, admittedRoom?.name) + this.rooms.set(roomId, room) + } else if (admittedRoom?.name) { + room.name = admittedRoom.name } - // Add to in-memory online participants (keyed by userId) + // Add to in-memory online participants while preserving every socket for a shared subject. + const userAlreadyOnline = room.hasOnlineUser(userId) room.addOrUpdateMember(socketId, userId, userName, description, source, userAvatar) socket.join(roomId) - if (source !== 'agent') { - socket.to(roomId).emit('member_joined', { + if (source !== 'agent' && !userAlreadyOnline) { + this.emitToRoomReaders(roomId, 'member_joined', { roomId, memberId: userId, memberName: userName, members: room.getMembersList(), - }) + }, socket.id) } // Load history from SQLite @@ -1351,10 +2465,11 @@ export class GroupChatServer { ack?.({ roomId, roomName: room.name, + currentUserId: userId, members: room.getMembersList(), messages, agents, - rooms: this.getRoomIds(), + rooms: this.getDiscoverableRoomIds(socket), typingUsers: this.getTypingUsers(roomId), contextStatuses: this.getContextStatuses(roomId), }) @@ -1384,6 +2499,24 @@ export class GroupChatServer { ack?.({ error: 'Access denied' }) return } + const access = this.socketAccessPolicy(socket, roomId) + if (!access.canRead) { + const removed = this.removeUnauthorizedRoomSocket(socket, roomId) + if (removed && !joined.room.hasOnlineUser(removed.userId)) { + this.emitToRoomReaders(roomId, 'member_left', { + roomId, + memberId: removed.userId, + memberName: removed.name, + members: joined.room.getMembersList(), + }, socket.id) + } + ack?.({ error: 'Access denied' }) + return + } + if (!access.canWrite) { + ack?.({ error: 'Access denied' }) + return + } try { const userId = joined.member.userId @@ -1394,12 +2527,13 @@ export class GroupChatServer { this.userInfoMap.set(userId, { name, description }) const members = joined.room.getMembersList() - this.nsp.to(roomId).emit('member_updated', { + const payload = { roomId, memberId: userId, memberName: name, members, - }) + } + this.emitToRoomReaders(roomId, 'member_updated', payload) ack?.({ member: joined.room.getOnlineMemberBySocketId(socket.id), members }) } catch (err) { logger.error(`[GroupChat] Failed to update member profile: ${(err as Error).message}`) @@ -1422,6 +2556,11 @@ export class GroupChatServer { ack?.({ error: 'Stale room session' }) return } + const access = this.socketAccessPolicy(socket, roomId) + if (!access.canWrite) { + ack?.({ error: 'Access denied' }) + return + } const userId = member?.userId || socketId const userName = member?.name || `User-${socketId.slice(0, 6)}` const role = normalizeMessageRole(data.role) @@ -1447,14 +2586,16 @@ export class GroupChatServer { const savedMsg = saved.message const totalTokens = saved.totalTokens - this.nsp.to(roomId).emit('message', savedMsg) - this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens }) + this.emitToRoomReaders(roomId, 'message', savedMsg) + this.emitToRoomReaders(roomId, 'room_updated', { roomId, totalTokens }) ack?.({ id: savedMsg.id }) const mentionDepth = normalizeMentionDepth(data.mentionDepth) const isAgentReply = savedMsg.role === 'assistant' && member?.source === 'agent' - const shouldRouteMentions = (savedMsg.role === 'user' && this.canSocketManageRoom(socket, roomId)) || + const shouldRouteMentions = access.canInvokeAgents && ( + savedMsg.role === 'user' || (isAgentReply && mentionDepth < maxAgentMentionDepth()) + ) if (shouldRouteMentions) { // Server-side @mention routing — parse mentions and invoke agents directly. @@ -1482,7 +2623,7 @@ export class GroupChatServer { const id = this.normalizeClientMessageId(data.id) if (!id) return - this.nsp.to(roomId).emit('message_stream_start', { + this.emitToRoomReaders(roomId, 'message_stream_start', { id, roomId, senderId: member.userId, @@ -1499,7 +2640,7 @@ export class GroupChatServer { if (!this.getCurrentAgentEventMember(socket, roomId, '', data.agentSessionId)) return const id = this.normalizeClientMessageId(data.id) if (!id || !data.delta) return - this.nsp.to(roomId).emit('message_stream_delta', { + this.emitToRoomReaders(roomId, 'message_stream_delta', { roomId, id, delta: String(data.delta), @@ -1511,7 +2652,7 @@ export class GroupChatServer { if (!this.getCurrentAgentEventMember(socket, roomId, '', data.agentSessionId)) return const id = this.normalizeClientMessageId(data.id) if (!id || !data.delta) return - this.nsp.to(roomId).emit('message_reasoning_delta', { + this.emitToRoomReaders(roomId, 'message_reasoning_delta', { roomId, id, delta: String(data.delta), @@ -1523,11 +2664,13 @@ export class GroupChatServer { if (!this.getCurrentAgentEventMember(socket, roomId, '', data.agentSessionId)) return const id = this.normalizeClientMessageId(data.id) if (!id) return - this.nsp.to(roomId).emit('message_stream_end', { roomId, id }) + this.emitToRoomReaders(roomId, 'message_stream_end', { roomId, id }) } private handleTyping(socket: Socket, data: { roomId?: string }): void { const roomId = data.roomId || 'general' + const access = this.socketAccessPolicy(socket, roomId) + if (!access.canType) return const userId = this.socketUserMap.get(socket.id) || socket.id const userName = this.userInfoMap.get(userId)?.name || `User-${socket.id.slice(0, 6)}` @@ -1547,15 +2690,17 @@ export class GroupChatServer { }, 30000), }) - socket.to(roomId).emit('typing', { + this.emitToRoomReaders(roomId, 'typing', { roomId, userId, userName, - }) + }, socket.id) } private handleStopTyping(socket: Socket, data: { roomId?: string }): void { const roomId = data.roomId || 'general' + const access = this.socketAccessPolicy(socket, roomId) + if (!access.canType) return const userId = this.socketUserMap.get(socket.id) || socket.id // Remove from typing state @@ -1567,10 +2712,10 @@ export class GroupChatServer { if (roomTyping.size === 0) this.typingState.delete(roomId) } - socket.to(roomId).emit('stop_typing', { + this.emitToRoomReaders(roomId, 'stop_typing', { roomId, userId, - }) + }, socket.id) } private handleContextStatus(socket: Socket, data: { roomId?: string; agentName?: string; status?: string; totalTokens?: number; agentSessionId?: string }): void { @@ -1594,16 +2739,16 @@ export class GroupChatServer { roomStatuses.set(agentName, { agentName, status }) } - // Relay to all other sockets in the room - socket.to(roomId).emit('context_status', { + // Relay to all other authorized sockets in the room + this.emitToRoomReaders(roomId, 'context_status', { roomId, agentName, status, - }) + }, socket.id) if (typeof data.totalTokens === 'number' && Number.isFinite(data.totalTokens) && data.totalTokens >= 0) { this.storage.updateRoomTotalTokens(roomId, Math.floor(data.totalTokens)) - this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens: Math.floor(data.totalTokens) }) + this.emitToRoomReaders(roomId, 'room_updated', { roomId, totalTokens: Math.floor(data.totalTokens) }) } } @@ -1625,7 +2770,7 @@ export class GroupChatServer { } try { await this.agentClients.interruptAgent(roomId, agentName) - this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' }) + this.emitToRoomReaders(roomId, 'context_status', { roomId, agentName, status: 'ready' }) ack?.({ ok: true }) } catch (err: any) { logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`) @@ -1633,38 +2778,139 @@ export class GroupChatServer { } } - private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean; agentSessionId?: string }): void { + private normalizeApprovalId(value: unknown): string | null { + if (typeof value !== 'string' || !value || Buffer.byteLength(value, 'utf8') > 512) return null + return value + } + + private normalizeApprovalChoice(value: unknown): ApprovalChoice | null { + if (typeof value !== 'string') return null + const normalized = value.trim().toLowerCase() + return APPROVAL_CHOICE_SET.has(normalized) ? normalized as ApprovalChoice : null + } + + private normalizeApprovalChoices(value: unknown, allowPermanent: boolean): ApprovalChoice[] { + const source = Array.isArray(value) ? value : ['once', 'session', 'deny'] + const normalized: ApprovalChoice[] = [] + for (const rawChoice of source) { + const choice = this.normalizeApprovalChoice(rawChoice) + if (!choice || (choice === 'always' && !allowPermanent) || normalized.includes(choice)) continue + normalized.push(choice) + } + if (!normalized.includes('deny')) normalized.push('deny') + return normalized + } + + private pruneStalePendingApprovals(): void { + for (const [approvalId, binding] of this.pendingApprovals) { + if (binding.conflicted) continue + if (!this.agentIdentitySessionIsCurrent(binding.roomId, binding.agentId, binding.agentSessionId)) { + this.pendingApprovals.delete(approvalId) + } + } + } + + private bindPendingApproval(approvalId: string, binding: Omit): boolean { + const existing = this.pendingApprovals.get(approvalId) + if (existing) { + const sameOrigin = existing.roomId === binding.roomId + && existing.agentId === binding.agentId + && existing.agentSessionId === binding.agentSessionId + && existing.allowPermanent === binding.allowPermanent + && existing.allowedChoices.length === binding.allowedChoices.length + && existing.allowedChoices.every((choice, index) => choice === binding.allowedChoices[index]) + if (!sameOrigin) { + existing.conflicted = true + existing.responding = false + existing.responded = true + } + // Duplicate events are suppressed; a mismatched origin permanently poisons + // this globally addressed Bridge approval id for the server lifetime. + return false + } + if (this.pendingApprovals.size >= MAX_PENDING_APPROVAL_BINDINGS) { + this.pruneStalePendingApprovals() + if (this.pendingApprovals.size >= MAX_PENDING_APPROVAL_BINDINGS) return false + } + this.pendingApprovals.set(approvalId, { + ...binding, + responding: false, + responded: false, + conflicted: false, + }) + return true + } + + private clearPendingApprovals(roomId: string, agentId?: string): void { + for (const [approvalId, binding] of this.pendingApprovals) { + if (binding.conflicted) continue + if (binding.roomId === roomId && (!agentId || binding.agentId === agentId)) { + this.pendingApprovals.delete(approvalId) + } + } + } + + private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; agentSessionId?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void { const roomId = data.roomId const agentName = data.agentName || '' - if (!roomId || !data.approval_id || !this.getCurrentAgentEventMember(socket, roomId, agentName, data.agentSessionId)) return - this.emitToRoomManagers(roomId, 'approval.requested', { + const approvalId = this.normalizeApprovalId(data.approval_id) + const agentSessionId = typeof data.agentSessionId === 'string' ? data.agentSessionId.trim() : '' + const member = roomId && approvalId + ? this.getCurrentAgentEventMember(socket, roomId, agentName, agentSessionId) + : null + if (!roomId || !approvalId || !agentSessionId || !member) return + const allowPermanent = data.allow_permanent === true + const allowedChoices = this.normalizeApprovalChoices(data.choices, allowPermanent) + if (!this.bindPendingApproval(approvalId, { + roomId, + agentId: member.userId, + agentSessionId, + allowedChoices, + allowPermanent, + })) return + this.emitToRoomApprovalResponders(roomId, 'approval.requested', { event: 'approval.requested', roomId, agentName, - approval_id: data.approval_id, + approval_id: approvalId, command: data.command || '', description: data.description || '', - choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'], - allow_permanent: Boolean(data.allow_permanent), + choices: allowedChoices, + allow_permanent: allowPermanent, }) } private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string; agentSessionId?: string }): void { const roomId = data.roomId const agentName = data.agentName || '' - if (!roomId || !data.approval_id || !this.getCurrentAgentEventMember(socket, roomId, agentName, data.agentSessionId)) return - this.emitToRoomManagers(roomId, 'approval.resolved', { + const approvalId = this.normalizeApprovalId(data.approval_id) + const agentSessionId = typeof data.agentSessionId === 'string' ? data.agentSessionId.trim() : '' + const member = roomId && approvalId + ? this.getCurrentAgentEventMember(socket, roomId, agentName, agentSessionId) + : null + const binding = approvalId ? this.pendingApprovals.get(approvalId) : undefined + if (!roomId || !approvalId || !agentSessionId || !member || !binding || binding.conflicted) return + if (binding.roomId !== roomId || binding.agentId !== member.userId || binding.agentSessionId !== agentSessionId) return + const resolvedChoice = this.normalizeApprovalChoice(data.choice) + const choice = resolvedChoice + && binding.allowedChoices.includes(resolvedChoice) + && (resolvedChoice !== 'always' || binding.allowPermanent) + ? resolvedChoice + : 'deny' + this.pendingApprovals.delete(approvalId) + this.emitToRoomApprovalResponders(roomId, 'approval.resolved', { event: 'approval.resolved', roomId, agentName, - approval_id: data.approval_id, - choice: data.choice || '', + approval_id: approvalId, + choice, }) } private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise { const roomId = data.roomId - if (!roomId || !data.approval_id) { + const approvalId = this.normalizeApprovalId(data.approval_id) + if (!roomId || !approvalId) { ack?.({ error: 'roomId and approval_id are required' }) return } @@ -1673,15 +2919,47 @@ export class GroupChatServer { ack?.({ error: 'Not in room' }) return } - if (!this.canSocketManageRoom(socket, roomId)) { + const policy = this.socketAccessPolicy(socket, roomId) + if (!policy.canRead) { + this.removeUnauthorizedRoomSocket(socket, roomId) + ack?.({ error: 'Access denied' }) + return + } + if (!policy.canApprove) { + ack?.({ error: 'Access denied' }) + return + } + const binding = this.pendingApprovals.get(approvalId) + const choice = this.normalizeApprovalChoice(data.choice) + const targetIsCurrent = binding + ? this.agentIdentitySessionIsCurrent(binding.roomId, binding.agentId, binding.agentSessionId) + : false + if (!binding + || binding.roomId !== roomId + || binding.conflicted + || binding.responding + || binding.responded + || !targetIsCurrent + || !choice + || !binding.allowedChoices.includes(choice) + || (choice === 'always' && !binding.allowPermanent)) { + if (binding && !targetIsCurrent) this.pendingApprovals.delete(approvalId) ack?.({ error: 'Access denied' }) return } + binding.responding = true try { - const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny') - ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) }) + const result = await new AgentBridgeClient().approvalRespond(approvalId, choice) + if (this.pendingApprovals.get(approvalId) !== binding || binding.conflicted) { + ack?.({ error: 'Access denied' }) + return + } + binding.responding = false + binding.responded = true + ack?.({ ok: true, resolved: Boolean(result?.resolved) }) } catch (err: any) { - logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`) + if (this.pendingApprovals.get(approvalId) === binding && !binding.conflicted) binding.responding = false + logger.warn(`[GroupChat] failed to respond approval ${approvalId}: ${err.message}`) ack?.({ error: err.message || 'approval response failed' }) } } @@ -1705,6 +2983,7 @@ export class GroupChatServer { this.leaveAllRooms(socket, socketId) this.socketUserMap.delete(socketId) + this.socketLocalSubjectIdMap.delete(socketId) this.socketRequestedSourceMap.delete(socketId) this.socketAuthUserIdMap.delete(socketId) // Don't delete userInfoMap — it persists across reconnects @@ -1730,11 +3009,11 @@ export class GroupChatServer { const member = room.getOnlineMemberBySocketId(socketId) room.removeMember(socketId) socket.leave(rid) - if (member?.source !== 'agent') { - this.nsp.to(rid).emit('member_left', { + if (member && member.source !== 'agent' && !room.hasOnlineUser(member.userId)) { + this.emitToRoomReaders(rid, 'member_left', { roomId: rid, - memberId: member?.userId || socketId, - memberName: member?.name || `User-${socketId.slice(0, 6)}`, + memberId: member.userId, + memberName: member.name, members: room.getMembersList(), }) } @@ -1743,7 +3022,7 @@ export class GroupChatServer { } private generateId(): string { - return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) + return `gcm_${randomBytes(16).toString('hex')}` } private normalizeClientMessageId(id?: string): string | null { diff --git a/packages/server/src/services/hermes/group-chat/invite-attempt-limiter.ts b/packages/server/src/services/hermes/group-chat/invite-attempt-limiter.ts new file mode 100644 index 0000000000..af6bc23bec --- /dev/null +++ b/packages/server/src/services/hermes/group-chat/invite-attempt-limiter.ts @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto' + +interface InviteAttemptEntry { + failures: number + windowStartedAt: number + blockedUntil: number +} + +export interface GroupChatInviteAttemptLimiterOptions { + maxFailures?: number + windowMs?: number + lockMs?: number + maxEntries?: number + now?: () => number +} + +export class GroupChatInviteAttemptLimiter { + private readonly entries = new Map() + private readonly maxFailures: number + private readonly windowMs: number + private readonly lockMs: number + private readonly maxEntries: number + private readonly now: () => number + + constructor(options: GroupChatInviteAttemptLimiterOptions = {}) { + this.maxFailures = Math.max(1, Math.trunc(options.maxFailures ?? 8)) + this.windowMs = Math.max(1, Math.trunc(options.windowMs ?? 60_000)) + this.lockMs = Math.max(1, Math.trunc(options.lockMs ?? 60_000)) + this.maxEntries = Math.max(1, Math.trunc(options.maxEntries ?? 4_096)) + this.now = options.now ?? Date.now + } + + isAllowed(key: string): boolean { + const now = this.now() + const entry = this.entries.get(key) + if (!entry) { + this.removeExpiredEntries(now) + return this.entries.size < this.maxEntries + } + if (entry.blockedUntil > now) return false + if (now - entry.windowStartedAt >= this.windowMs) { + this.entries.delete(key) + } + return true + } + + recordFailure(key: string): void { + const now = this.now() + let entry = this.entries.get(key) + if (!entry || now - entry.windowStartedAt >= this.windowMs) { + if (!this.ensureCapacity(key, now)) return + entry = { failures: 0, windowStartedAt: now, blockedUntil: 0 } + this.entries.set(key, entry) + } + entry.failures += 1 + if (entry.failures >= this.maxFailures) { + entry.blockedUntil = now + this.lockMs + } + } + + private removeExpiredEntries(now: number): void { + for (const [candidate, entry] of this.entries) { + if (entry.blockedUntil <= now && now - entry.windowStartedAt >= this.windowMs) { + this.entries.delete(candidate) + } + } + } + + private ensureCapacity(key: string, now: number): boolean { + if (this.entries.has(key) || this.entries.size < this.maxEntries) return true + this.removeExpiredEntries(now) + return this.entries.size < this.maxEntries + } +} + +export function groupChatInviteAttemptSubjectKey( + authUserId: number | null | undefined, + networkAddress: string | null | undefined, + localSubjectId?: string | null, +): string { + const normalizedLocalSubjectId = String(localSubjectId || '').trim() + const subject = typeof authUserId === 'number' && Number.isInteger(authUserId) && authUserId > 0 + ? `auth:${authUserId}` + : normalizedLocalSubjectId + ? `local:${normalizedLocalSubjectId}` + : `network:${networkAddress || 'unknown'}` + return createHash('sha256') + .update('group-chat-invite-attempt-v1\0', 'utf8') + .update(subject, 'utf8') + .digest('hex') + .slice(0, 32) +} diff --git a/packages/server/src/services/hermes/session-deleter.ts b/packages/server/src/services/hermes/session-deleter.ts index 8d362614df..9724aac994 100644 --- a/packages/server/src/services/hermes/session-deleter.ts +++ b/packages/server/src/services/hermes/session-deleter.ts @@ -2,15 +2,21 @@ * Session Deleter — periodically drains pending session deletes. * * Reads from gc_pending_session_deletes table, executes deletion via - * Hermes CLI, tracks failures (max 3 attempts), and auto-drains on + * Hermes CLI, tracks failures with bounded backoff, and auto-drains on * a timer + profile switch. */ import { getDb } from '../../db/index' -import { deleteSession as hermesDeleteSession } from './hermes-cli' +import { deleteSessionForProfile as hermesDeleteSessionForProfile } from './hermes-cli' import { logger } from '../logger' -const MAX_ATTEMPTS = 3 const DRAIN_INTERVAL_MS = 300_000 +const RETRY_BASE_MS = 60_000 +const RETRY_MAX_MS = 3_600_000 + +function retryDelayMs(previousAttempts: number): number { + const exponent = Math.min(10, Math.max(0, Math.floor(previousAttempts))) + return Math.min(RETRY_MAX_MS, RETRY_BASE_MS * (2 ** exponent)) +} export class SessionDeleter { private static _instance: SessionDeleter | null = null @@ -29,10 +35,10 @@ export class SessionDeleter { this.currentProfile = profile this.stop() logger.info('[SessionDeleter] started, profile=%s, interval=%dms', profile, DRAIN_INTERVAL_MS) - // Drain immediately on start, then on interval - this.drain(profile).catch(() => {}) + // Drain every recorded profile immediately, then on interval. + this.drainAllProfiles(profile).catch(() => {}) this.timer = setInterval(() => { - this.drain(profile).catch(() => {}) + this.drainAllProfiles(profile).catch(() => {}) }, DRAIN_INTERVAL_MS) } @@ -52,19 +58,43 @@ export class SessionDeleter { } } + /** Drain all profiles represented in the durable outbox, plus the active fallback profile. */ + async drainAllProfiles(fallbackProfile = this.currentProfile): Promise { + const db = getDb() + if (!db) return + const rows = db.prepare( + `SELECT DISTINCT profile_name + FROM gc_pending_session_deletes + WHERE status IN ('pending', 'processing')`, + ).all() as Array<{ profile_name: string }> + const profiles = new Set([fallbackProfile]) + for (const row of rows) { + const profile = String(row.profile_name || '').trim() + if (profile) profiles.add(profile) + } + for (const profile of profiles) { + await this.drain(profile) + } + } + /** Drain pending deletes for a specific profile (called on profile switch or manually) */ async drain(profile: string): Promise<{ deleted: string[]; skipped: string[]; failed: string[] }> { const db = getDb() if (!db) return { deleted: [], skipped: [], failed: [] } const now = Date.now() + db.prepare( + `UPDATE gc_pending_session_deletes + SET status = 'pending', updated_at = ?, next_attempt_at = 0 + WHERE status = 'processing'`, + ).run(now) const rows = db.prepare(` SELECT session_id, profile_name, status, attempt_count, last_error FROM gc_pending_session_deletes - WHERE profile_name = ? AND status = 'pending' AND attempt_count < ? AND next_attempt_at <= ? + WHERE profile_name = ? AND status = 'pending' AND next_attempt_at <= ? ORDER BY created_at ASC LIMIT 50 - `).all(profile, MAX_ATTEMPTS, now) as Array<{ + `).all(profile, now) as Array<{ session_id: string profile_name: string status: string @@ -80,28 +110,46 @@ export class SessionDeleter { for (const row of rows) { try { - const ok = await hermesDeleteSession(row.session_id) + const ok = await hermesDeleteSessionForProfile(row.session_id, row.profile_name) if (ok) { db.prepare('DELETE FROM gc_pending_session_deletes WHERE session_id = ?').run(row.session_id) db.prepare('DELETE FROM gc_session_profiles WHERE session_id = ?').run(row.session_id) deleted.push(row.session_id) } else { + const failedAt = Date.now() + db.prepare( + `UPDATE gc_pending_session_deletes + SET status = 'pending', attempt_count = attempt_count + 1, last_error = ?, updated_at = ?, next_attempt_at = ? + WHERE session_id = ?`, + ).run( + 'Hermes CLI session delete returned false', + failedAt, + failedAt + retryDelayMs(row.attempt_count), + row.session_id, + ) skipped.push(row.session_id) } } catch (err: any) { const msg = err?.message || 'Unknown error' + const failedAt = Date.now() db.prepare( `UPDATE gc_pending_session_deletes SET status = 'pending', attempt_count = attempt_count + 1, last_error = ?, updated_at = ?, next_attempt_at = ? WHERE session_id = ?`, - ).run(msg, now, now + 60_000, row.session_id) + ).run(msg, failedAt, failedAt + retryDelayMs(row.attempt_count), row.session_id) failed.push(row.session_id) logger.warn('[SessionDeleter] failed to delete %s (attempt %d): %s', row.session_id, row.attempt_count + 1, msg) } } - if (deleted.length || failed.length) { - logger.info('[SessionDeleter] profile=%s: deleted=%d, failed=%d', profile, deleted.length, failed.length) + if (deleted.length || failed.length || skipped.length) { + logger.info( + '[SessionDeleter] profile=%s: deleted=%d, skipped=%d, failed=%d', + profile, + deleted.length, + skipped.length, + failed.length, + ) } return { deleted, skipped, failed } diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts index df902dfa23..0f9120d0ca 100644 --- a/tests/client/api.test.ts +++ b/tests/client/api.test.ts @@ -17,6 +17,7 @@ import { downloadFile, getDownloadUrl } from '../../packages/client/src/api/herm import { uploadFiles } from '../../packages/client/src/api/hermes/files' import { importSkill } from '../../packages/client/src/api/hermes/skills' import { archiveSession, batchDeleteSessions, exportSession, fetchHermesSessionGroups, fetchHermesSessionPage, importHermesSession, unarchiveSession } from '../../packages/client/src/api/hermes/sessions' +import { createRoom as createGroupChatRoom } from '../../packages/client/src/api/hermes/group-chat' import router from '@/router' function fakeJwt(payload: Record) { @@ -77,6 +78,21 @@ describe('API Client', () => { expect(options.headers.Authorization).toBe('Bearer secret-key') }) + it('emits one valid JSON content type for Group Chat room creation', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ room: { id: 'room-1' }, agents: [] }), + }) + + await createGroupChatRoom({ name: 'Room 1', inviteCode: '' }) + + expect(mockFetch).toHaveBeenCalledOnce() + const [, options] = mockFetch.mock.calls[0] + expect(new Headers(options.headers).get('content-type')).toBe('application/json') + expect(JSON.parse(options.body)).toMatchObject({ name: 'Room 1', inviteCode: '' }) + }) + it('adds the active profile header, including default', async () => { localStorage.setItem('hermes_active_profile_name', 'default') mockFetch.mockResolvedValue({ ok: true, status: 200, json: () => ({ data: 1 }) }) diff --git a/tests/client/group-chat-api-invite.test.ts b/tests/client/group-chat-api-invite.test.ts new file mode 100644 index 0000000000..170e0afa22 --- /dev/null +++ b/tests/client/group-chat-api-invite.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMock = vi.hoisted(() => ({ + request: vi.fn(), + getApiKey: vi.fn(() => ''), + fetchAuthenticatedBlob: vi.fn(), + saveBlob: vi.fn(), +})) + +vi.mock('../../packages/client/src/api/client', () => ({ + request: apiMock.request, + getApiKey: apiMock.getApiKey, +})) + +vi.mock('../../packages/client/src/api/hermes/binary-content', () => ({ + fetchAuthenticatedBlob: apiMock.fetchAuthenticatedBlob, + saveBlob: apiMock.saveBlob, +})) + +import { + fetchGroupWorkspaceAttachmentBlob, + fetchGroupWorkspaceFileBlob, + joinRoomByCode, + listRooms, +} from '../../packages/client/src/api/hermes/group-chat' + +describe('group chat invite REST client', () => { + beforeEach(() => { + apiMock.request.mockReset() + apiMock.request.mockResolvedValue({ room: { id: 'room-1' } }) + apiMock.fetchAuthenticatedBlob.mockReset() + apiMock.fetchAuthenticatedBlob.mockResolvedValue(new Blob(['data'])) + localStorage.clear() + }) + + it('URL-encodes exact invite bytes and carries the signed local subject credential', async () => { + const code = ' A/B?#MiXeD ' + localStorage.setItem('gc_local_credential', 'signed-local-credential') + + await joinRoomByCode(code) + + expect(apiMock.request).toHaveBeenCalledWith( + `/api/hermes/group-chat/rooms/join/${encodeURIComponent(code)}`, + { + headers: { + 'X-Group-Chat-Local-Credential': 'signed-local-credential', + }, + }, + ) + }) + + it('carries the signed local subject on discovery and binary workspace reads', async () => { + localStorage.setItem('gc_local_credential', 'signed-local-credential') + const signal = new AbortController().signal + + await listRooms() + await fetchGroupWorkspaceFileBlob('room/one', 'folder/file.txt', signal) + await fetchGroupWorkspaceAttachmentBlob('room/one', 'folder/file.txt', signal) + + expect(apiMock.request).toHaveBeenCalledWith('/api/hermes/group-chat/rooms', { + headers: { 'X-Group-Chat-Local-Credential': 'signed-local-credential' }, + }) + expect(apiMock.fetchAuthenticatedBlob).toHaveBeenCalledWith( + '/api/hermes/group-chat/rooms/room%2Fone/workspace-file/content?path=folder%2Ffile.txt', + { + signal, + headers: { 'X-Group-Chat-Local-Credential': 'signed-local-credential' }, + }, + ) + expect(apiMock.fetchAuthenticatedBlob).toHaveBeenCalledWith( + '/api/hermes/group-chat/rooms/room%2Fone/workspace-file/content?path=folder%2Ffile.txt&download=1', + { + signal, + headers: { 'X-Group-Chat-Local-Credential': 'signed-local-credential' }, + }, + ) + }) +}) diff --git a/tests/client/group-chat-invite-code.test.ts b/tests/client/group-chat-invite-code.test.ts new file mode 100644 index 0000000000..e4fc35abf8 --- /dev/null +++ b/tests/client/group-chat-invite-code.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { + generateGroupChatInviteCode, + groupChatInviteCodeForClone, + groupChatInviteCodeForCreate, + GROUP_CHAT_INVITE_CODE_LENGTH, +} from '../../packages/client/src/utils/group-chat-invite' + +describe('group chat invite code generation', () => { + it('uses a 32-character alphabet for at least 80 bits of generated entropy', () => { + const codes = Array.from({ length: 64 }, () => generateGroupChatInviteCode()) + + expect(GROUP_CHAT_INVITE_CODE_LENGTH).toBe(16) + expect(new Set(codes).size).toBe(codes.length) + for (const code of codes) { + expect(code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + } + }) + + it('preserves explicit invite bytes and only treats the exact empty string as absent', () => { + expect(groupChatInviteCodeForCreate(' ROOM1 ')).toBe(' ROOM1 ') + expect(groupChatInviteCodeForClone(' Room1 ')).toBe(' Room1 ') + expect(groupChatInviteCodeForClone('')).toBeUndefined() + expect(groupChatInviteCodeForCreate('')).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + }) +}) diff --git a/tests/client/group-chat-panel-approval.test.ts b/tests/client/group-chat-panel-approval.test.ts new file mode 100644 index 0000000000..6cde0ad31d --- /dev/null +++ b/tests/client/group-chat-panel-approval.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { shallowMount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { defineComponent, nextTick } from 'vue' + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ push: vi.fn() }), + } +}) + +vi.mock('naive-ui', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useMessage: () => messageApi, + } +}) + +import GroupChatPanel from '@/components/hermes/group-chat/GroupChatPanel.vue' +import { useGroupChatStore } from '@/stores/hermes/group-chat' + +const NButtonStub = defineComponent({ + name: 'NButton', + emits: ['click'], + template: '', +}) + +describe('GroupChatPanel approval capability', () => { + beforeEach(() => { + vi.clearAllMocks() + window.innerWidth = 1280 + window.localStorage.clear() + }) + + it('lets an approval responder act without room.manage', async () => { + const pinia = createTestingPinia({ stubActions: true, createSpy: vi.fn }) + const store = useGroupChatStore(pinia) + store.currentRoomId = 'room-1' + store.rooms = [{ + id: 'room-1', + name: 'Approval room', + canManage: false, + canApprove: true, + } as any] + store.pendingApprovals.set('approval-1', { + roomId: 'room-1', + agentName: 'Worker', + approvalId: 'approval-1', + command: 'npm test', + description: 'Run tests', + choices: ['once', 'deny'], + allowPermanent: false, + isMemoryWrite: false, + requestedAt: Date.now(), + }) + + const wrapper = shallowMount(GroupChatPanel, { + global: { + plugins: [pinia], + stubs: { + Transition: false, + NButton: NButtonStub, + 'n-button': NButtonStub, + Button: NButtonStub, + }, + }, + }) + await nextTick() + + expect(wrapper.find('.approval-float-panel').exists()).toBe(true) + const actions = wrapper.findAll('.approval-float-actions button') + expect(actions).toHaveLength(2) + await actions[0].trigger('click') + + expect(store.respondApproval).toHaveBeenCalledOnce() + expect(store.respondApproval).toHaveBeenCalledWith('once') + }) +}) diff --git a/tests/client/group-chat-panel-workspace-source.test.ts b/tests/client/group-chat-panel-workspace-source.test.ts index 70ef6345fe..9c816bd2ea 100644 --- a/tests/client/group-chat-panel-workspace-source.test.ts +++ b/tests/client/group-chat-panel-workspace-source.test.ts @@ -13,7 +13,10 @@ describe('GroupChatPanel workspace save handling', () => { const source = readFileSync('packages/client/src/components/hermes/group-chat/GroupChatPanel.vue', 'utf8') expect(source).toContain('const currentRoomCanManage = computed(() => canManageRoom(currentRoom.value))') - expect(source).toContain('const visibleApproval = computed(() => currentRoomCanManage.value ? store.activePendingApproval : null)') + expect(source).toContain('const currentRoomCanApprove = computed(() => canApproveRoom(currentRoom.value))') + expect(source).toContain('const visibleApproval = computed(() => currentRoomCanApprove.value ? store.activePendingApproval : null)') + expect(source).not.toContain('currentRoomCanManage.value ? store.activePendingApproval') + expect(source).toContain('if (!currentRoomCanApprove.value) return') expect(source).toContain('if (!currentRoomCanManage.value) return') expect(source).toContain('if (!canManageRoom(room)) return') expect(source).toContain("options.push({ label: t('chat.setWorkspace'), key: 'set-workspace' })") @@ -51,10 +54,45 @@ describe('GroupChatPanel workspace save handling', () => { expect(source).toContain("const inviteCodeDraft = ref('')") expect(source).toContain('const canUpdateInviteCode = computed(() => {') expect(source).toContain('await store.setRoomInviteCode(store.currentRoomId, nextCode)') + expect(source).toContain('async function handleCopyInviteCode()') + expect(source).toContain('const code = inviteCodeDraft.value') + expect(source).toContain('const ok = await copyToClipboard(code)') expect(source).toContain("

{{ t('groupChat.roomSettings') }}

") expect(source).toContain("

{{ t('groupChat.inviteCodeSettings') }}

") expect(source).toContain('v-model:value="inviteCodeDraft"') + expect(source).toContain(":title=\"t('groupChat.copyInviteCode')\"") + expect(source).toContain('@click="handleCopyInviteCode"') expect(source).toContain('@click="handleSaveInviteCode"') expect(source).toContain(":title=\"t('groupChat.roomSettings')\"") }) + + it('renders a localized leave-room action in the marked room-list action area', () => { + const source = readFileSync('packages/client/src/components/hermes/group-chat/GroupChatPanel.vue', 'utf8') + + expect(source).toContain('const leavingRoomIds = ref>(new Set())') + expect(source).toContain('async function handleLeaveRoom(roomId: string)') + expect(source).toContain('await store.leaveRoom(roomId)') + expect(source).toContain('message.success(t(\'groupChat.roomLeft\'))') + expect(source).toContain('') + expect(source).toContain('class="room-action-btn leave"') + expect(source).toContain(":title=\"t('groupChat.leaveRoom')\"") + expect(source).toContain(":aria-label=\"t('groupChat.leaveRoom')\"") + expect(source).toContain("{{ t('groupChat.leaveRoomConfirm') }}") + }) + + it('exposes invite-code joining in the room sidebar', () => { + const source = readFileSync('packages/client/src/components/hermes/group-chat/GroupChatPanel.vue', 'utf8') + + expect(source).toContain("const joinInviteCode = ref('')") + expect(source).toContain('const canJoinByInviteCode = computed(() => !!joinInviteCode.value.trim() && !isJoiningByInviteCode.value)') + expect(source).toContain('async function handleJoinByInviteCode()') + expect(source).toContain('const code = joinInviteCode.value') + expect(source).toContain('const room = await store.joinByCode(code)') + expect(source).toContain('await router.push({ name: \'hermes.groupChatRoom\', params: { roomId: room.id } })') + expect(source).toContain('
') + expect(source).toContain('v-model:value="joinInviteCode"') + expect(source).toContain(':placeholder="t(\'groupChat.enterCode\')"') + expect(source).toContain('attr-type="submit"') + expect(source).toContain("{{ t('groupChat.joinByCode') }}") + }) }) diff --git a/tests/client/group-chat-store-baseline.test.ts b/tests/client/group-chat-store-baseline.test.ts index 9e8b7235c7..556a17fc0d 100644 --- a/tests/client/group-chat-store-baseline.test.ts +++ b/tests/client/group-chat-store-baseline.test.ts @@ -9,6 +9,7 @@ const groupChatApiMock = vi.hoisted(() => { const socket: any = { connected: true, id: 'socket-1', + auth: { localIdentityVerified: true, localCredential: 'signed-test-credential' }, on: vi.fn((event: string, cb: Function) => { const existing = handlers.get(event) || [] existing.push(cb) @@ -36,6 +37,7 @@ const groupChatApiMock = vi.hoisted(() => { if (event === 'message' && ack) ack({ id: data?.id }) return socket }), + connect: vi.fn(() => socket), disconnect: vi.fn(), } return { @@ -145,9 +147,11 @@ describe('group chat store baseline lifecycle', () => { clientApiMock.getApiKey.mockReturnValue('test-token') clientApiMock.getActiveProfileName.mockReturnValue('research') clientApiMock.getStoredUsername.mockReturnValue(null) + authApiMock.fetchCurrentUser.mockReset() authApiMock.fetchCurrentUser.mockRejectedValue(new Error('not signed in')) fetchMock.mockReset() groupChatApiMock.socket.connected = true + groupChatApiMock.socket.auth = { localIdentityVerified: true, localCredential: 'signed-test-credential' } groupChatApiMock.socket.on.mockClear() groupChatApiMock.socket.once.mockClear() groupChatApiMock.socket.off.mockClear() @@ -158,6 +162,7 @@ describe('group chat store baseline lifecycle', () => { return groupChatApiMock.socket }) groupChatApiMock.socket.disconnect.mockClear() + groupChatApiMock.socket.connect.mockClear() }) it('connects with stored user data and registers realtime handlers', async () => { @@ -173,6 +178,115 @@ describe('group chat store baseline lifecycle', () => { expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('message', expect.any(Function)) expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('approval.requested', expect.any(Function)) expect(groupChatApiMock.socket.on).toHaveBeenCalledWith('room_cleared', expect.any(Function)) + expect(groupChatApiMock.socket.connect).toHaveBeenCalledOnce() + }) + + it('coalesces concurrent connection setup before protected REST discovery', async () => { + let resolveCurrentUser!: (user: { id: number; username: string; avatar: string }) => void + const currentUser = new Promise<{ id: number; username: string; avatar: string }>((resolve) => { + resolveCurrentUser = resolve + }) + authApiMock.fetchCurrentUser.mockReturnValue(currentUser) + groupChatApiMock.listRooms.mockResolvedValue({ rooms: [room] }) + const store = await loadStore() + + const connecting = store.connect() + const loadingRooms = store.loadRooms() + resolveCurrentUser({ id: 42, username: 'alice', avatar: '' }) + await Promise.all([connecting, loadingRooms]) + + expect(authApiMock.fetchCurrentUser).toHaveBeenCalledOnce() + expect(groupChatApiMock.connectGroupChat).toHaveBeenCalledOnce() + expect(groupChatApiMock.listRooms).toHaveBeenCalledOnce() + expect(store.rooms).toEqual([room]) + }) + + it('cancels in-flight connection setup when disconnected before identity lookup resolves', async () => { + let resolveCurrentUser!: (user: { id: number; username: string; avatar: string }) => void + authApiMock.fetchCurrentUser.mockReturnValue(new Promise((resolve) => { + resolveCurrentUser = resolve + })) + const store = await loadStore() + + const connecting = store.connect() + await vi.waitFor(() => expect(authApiMock.fetchCurrentUser).toHaveBeenCalledOnce()) + store.disconnect() + resolveCurrentUser({ id: 42, username: 'alice', avatar: '' }) + await connecting + + expect(groupChatApiMock.connectGroupChat).not.toHaveBeenCalled() + expect(groupChatApiMock.socket.connect).not.toHaveBeenCalled() + expect(groupChatApiMock.disconnectGroupChat).toHaveBeenCalledOnce() + expect(store.connected).toBe(false) + }) + + it('cancels a disconnected socket waiter before a reconnect succeeds', async () => { + const store = await loadStore() + groupChatApiMock.socket.connected = false + groupChatApiMock.getSocket.mockImplementation((options?: { requireConnected?: boolean }) => ( + groupChatApiMock.socket.connected || options?.requireConnected === false ? groupChatApiMock.socket : null + )) + groupChatApiMock.joinRoomByCode.mockResolvedValue({ room }) + + const waitingOnOldSocket = store.joinByCode('ROOM1') + await vi.waitFor(() => { + expect(groupChatApiMock.handlers.get('connect')).toHaveLength(2) + expect(groupChatApiMock.handlers.get('connect_error')).toHaveLength(2) + expect(groupChatApiMock.handlers.get('disconnect')).toHaveLength(2) + }) + store.disconnect() + await expect(waitingOnOldSocket).rejects.toThrow('Group chat socket connection cancelled') + expect(groupChatApiMock.handlers.get('connect')).toHaveLength(1) + expect(groupChatApiMock.handlers.get('connect_error')).toHaveLength(1) + expect(groupChatApiMock.handlers.get('disconnect')).toHaveLength(1) + + groupChatApiMock.socket.connected = true + groupChatApiMock.socket.auth = { localIdentityVerified: true, localCredential: 'signed-reconnected-credential' } + await expect(store.joinByCode('ROOM1')).resolves.toEqual(room) + expect(groupChatApiMock.joinRoomByCode).toHaveBeenCalledOnce() + expect(store.currentRoomId).toBe('room-1') + }) + + it('cancels a local identity waiter before a reconnect succeeds', async () => { + const store = await loadStore() + groupChatApiMock.socket.connected = true + groupChatApiMock.socket.auth = {} + groupChatApiMock.joinRoomByCode.mockResolvedValue({ room }) + + const waitingOnLocalIdentity = store.joinByCode('ROOM1') + await vi.waitFor(() => { + expect(groupChatApiMock.handlers.get('local_identity')).toHaveLength(2) + expect(groupChatApiMock.handlers.get('disconnect')).toHaveLength(2) + }) + + store.disconnect() + await expect(waitingOnLocalIdentity).rejects.toThrow('Group chat connection setup was cancelled') + expect(groupChatApiMock.handlers.get('local_identity')).toHaveLength(1) + expect(groupChatApiMock.handlers.get('disconnect')).toHaveLength(1) + + groupChatApiMock.socket.connected = true + groupChatApiMock.socket.auth = { localIdentityVerified: true, localCredential: 'signed-reconnected-credential' } + await expect(store.joinByCode('ROOM1')).resolves.toEqual(room) + expect(groupChatApiMock.joinRoomByCode).toHaveBeenCalledOnce() + }) + + it('persists the server-issued local credential and routing id for reconnect handshakes', async () => { + const store = await loadStore() + await store.connect() + const authoritativeUserId = `local-user:${'b'.repeat(64)}` + + emitSocket('local_identity', { + localCredential: 'signed-local-credential', + userId: authoritativeUserId, + }) + + expect(store.userId).toBe(authoritativeUserId) + expect(localStorage.getItem('gc_user_id')).toBe(authoritativeUserId) + expect(localStorage.getItem('gc_local_credential')).toBe('signed-local-credential') + expect(groupChatApiMock.socket.auth).toEqual(expect.objectContaining({ + userId: authoritativeUserId, + localCredential: 'signed-local-credential', + })) }) it('joins a room from REST detail and realtime ack state', async () => { @@ -282,16 +396,17 @@ describe('group chat store baseline lifecycle', () => { const order: string[] = [] groupChatApiMock.socket.connected = false + groupChatApiMock.socket.auth = { localCredential: 'v1.stale.signature' } groupChatApiMock.getSocket.mockImplementation((options?: { requireConnected?: boolean }) => ( groupChatApiMock.socket.connected || options?.requireConnected === false ? groupChatApiMock.socket : null )) - groupChatApiMock.socket.once.mockImplementation((event: string, cb: Function) => { - if (event === 'connect') { - setTimeout(() => { - groupChatApiMock.socket.connected = true - cb() - }, 0) - } + groupChatApiMock.socket.connect.mockImplementationOnce(() => { + setTimeout(() => { + groupChatApiMock.socket.connected = true + emitSocket('connect', undefined) + order.push('identity') + emitSocket('local_identity', { localCredential: 'v1.local-subject.signature' }) + }, 0) return groupChatApiMock.socket }) groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => { @@ -301,7 +416,11 @@ describe('group chat store baseline lifecycle', () => { } return groupChatApiMock.socket }) - groupChatApiMock.joinRoomByCode.mockResolvedValue({ room }) + groupChatApiMock.joinRoomByCode.mockImplementation(async () => { + order.push('lookup') + return { room } + }) + localStorage.setItem('gc_local_credential', 'v1.stale.signature') groupChatApiMock.getRoomDetail.mockImplementation(async () => { order.push('detail') return { room, messages: [], agents: [], members: [member], total: 0, hasMore: false } @@ -311,10 +430,39 @@ describe('group chat store baseline lifecycle', () => { expect(groupChatApiMock.connectGroupChat).toHaveBeenCalled() expect(groupChatApiMock.getRoomDetail).toHaveBeenCalledWith('room-1') - expect(order).toEqual(['invite-join', 'detail', 'detail-join']) + expect(order).toEqual(['identity', 'lookup', 'invite-join', 'detail', 'detail-join']) + expect(localStorage.getItem('gc_local_credential')).toBe('v1.local-subject.signature') expect(store.currentRoomId).toBe('room-1') }) + it('uses the authenticated subject for invite lookup without waiting for a local identity', async () => { + authApiMock.fetchCurrentUser.mockResolvedValue({ id: 42, username: 'alice', avatar: '' }) + groupChatApiMock.joinRoomByCode.mockResolvedValue({ room }) + const store = await loadStore() + + await store.connect() + await store.joinByCode('ROOM1') + + expect(groupChatApiMock.joinRoomByCode).toHaveBeenCalledWith('ROOM1') + expect(groupChatApiMock.socket.once).not.toHaveBeenCalledWith('local_identity', expect.any(Function)) + }) + + it('restores invite identity readiness from an already connected socket', async () => { + groupChatApiMock.socket.connected = true + groupChatApiMock.socket.auth = { + localCredential: 'v1.connected.signature', + localIdentityVerified: true, + } + groupChatApiMock.joinRoomByCode.mockResolvedValue({ room }) + localStorage.setItem('gc_local_credential', 'v1.stale.signature') + const store = await loadStore() + + await store.joinByCode('ROOM1') + + expect(localStorage.getItem('gc_local_credential')).toBe('v1.connected.signature') + expect(groupChatApiMock.joinRoomByCode).toHaveBeenCalledWith('ROOM1') + }) + it('sends text-only messages through the room socket', async () => { const store = await loadStore() await store.connect() diff --git a/tests/client/group-chat-store-streaming.test.ts b/tests/client/group-chat-store-streaming.test.ts index acff42dabb..6094e3acef 100644 --- a/tests/client/group-chat-store-streaming.test.ts +++ b/tests/client/group-chat-store-streaming.test.ts @@ -8,16 +8,25 @@ const groupChatApiMock = vi.hoisted(() => { const socket: any = { connected: true, id: 'socket-1', + auth: { localIdentityVerified: true, localCredential: 'signed-test-credential' }, on: vi.fn((event: string, cb: Function) => { const existing = handlers.get(event) || [] existing.push(cb) handlers.set(event, existing) return socket }), + once: vi.fn((event: string, cb: Function) => { + if (event === 'local_identity') { + queueMicrotask(() => cb({ localCredential: 'signed-test-credential' })) + } + return socket + }), + off: vi.fn(() => socket), emit: vi.fn((event: string, _data?: unknown, ack?: Function) => { if (event === 'join' && ack) ack({ members: [], agents: [], typingUsers: [], contextStatuses: [] }) return socket }), + connect: vi.fn(() => socket), disconnect: vi.fn(), } return { @@ -90,7 +99,7 @@ async function createJoinedStore(initialMessages: ChatMessage[] = []) { }) const { useGroupChatStore } = await import('@/stores/hermes/group-chat') const store = useGroupChatStore() - store.connect() + await store.connect() await store.joinRoom('room-1') groupChatApiMock.getRoomDetail.mockClear() return store diff --git a/tests/client/group-chat-store-workspace.test.ts b/tests/client/group-chat-store-workspace.test.ts index 7ed11a7c46..aa27ea1023 100644 --- a/tests/client/group-chat-store-workspace.test.ts +++ b/tests/client/group-chat-store-workspace.test.ts @@ -2,10 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' +const workspaceSocket = vi.hoisted(() => { + const socket: any = { + id: 'socket-1', + connected: true, + auth: { localIdentityVerified: true, localCredential: 'signed-test-credential' }, + io: { on: vi.fn() }, + on: vi.fn(() => socket), + once: vi.fn(() => socket), + off: vi.fn(() => socket), + emit: vi.fn(() => socket), + connect: vi.fn(() => socket), + disconnect: vi.fn(), + } + return socket +}) + const groupChatApiMock = vi.hoisted(() => ({ - connectGroupChat: vi.fn(), + connectGroupChat: vi.fn(() => workspaceSocket), disconnectGroupChat: vi.fn(), - getSocket: vi.fn(() => null), + getSocket: vi.fn(() => workspaceSocket), getStoredUserId: vi.fn(() => 'user-1'), getStoredUserName: vi.fn(() => 'tester'), createRoom: vi.fn(), @@ -101,8 +117,8 @@ describe('group chat store workspace', () => { await store.setRoomInviteCode('room-1', ' NEW456 ') - expect(groupChatApiMock.updateInviteCode).toHaveBeenCalledWith('room-1', 'NEW456') - expect(store.rooms[0].inviteCode).toBe('NEW456') + expect(groupChatApiMock.updateInviteCode).toHaveBeenCalledWith('room-1', ' NEW456 ') + expect(store.rooms[0].inviteCode).toBe(' NEW456 ') }) it('does not mutate local invite code when the API rejects', async () => { diff --git a/tests/client/group-chat-workspace-diff.test.ts b/tests/client/group-chat-workspace-diff.test.ts index 298a47c855..019f3417dc 100644 --- a/tests/client/group-chat-workspace-diff.test.ts +++ b/tests/client/group-chat-workspace-diff.test.ts @@ -12,6 +12,7 @@ const groupChatApiMock = vi.hoisted(() => { const socket: any = { id: 'socket-1', connected: true, + auth: { localIdentityVerified: true, localCredential: 'signed-test-credential' }, on: vi.fn(() => socket), once: vi.fn(() => socket), off: vi.fn(() => socket), @@ -29,6 +30,7 @@ const groupChatApiMock = vi.hoisted(() => { } return socket }), + connect: vi.fn(() => socket), } return { socket, diff --git a/tests/e2e/group-chat-room-deeplink.spec.ts b/tests/e2e/group-chat-room-deeplink.spec.ts index 2e2f65bb1c..b2fec72677 100644 --- a/tests/e2e/group-chat-room-deeplink.spec.ts +++ b/tests/e2e/group-chat-room-deeplink.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page, type Route } from '@playwright/test' import { authenticate, TEST_MODEL_GROUP } from './fixtures' +const LOCAL_CREDENTIAL = 'e2e-server-issued-local-credential' + const baseRooms = [ { id: 'room-alpha', name: 'Alpha Room', inviteCode: 'ALPHA1', canManage: true, workspace: '/tmp/alpha', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 123 }, { id: 'room-beta', name: 'Beta Room', inviteCode: 'BETA22', canManage: true, workspace: '/tmp/beta', triggerTokens: 100000, maxHistoryTokens: 32000, tailMessageCount: 10, totalTokens: 456 }, @@ -49,12 +51,19 @@ async function mockGroupChatApi(page: Page) { const { pathname } = url if (!(pathname === '/health' || pathname.startsWith('/api/'))) { - await route.continue() + await route.fallback() return } const json = (body: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + if (pathname.startsWith('/api/hermes/group-chat/')) { + const localCredential = request.headers()['x-group-chat-local-credential'] + if (localCredential !== LOCAL_CREDENTIAL) { + return json({ error: 'Group chat local identity required' }, 401) + } + } + if (pathname === '/health') return json({ status: 'ok' }) if (pathname === '/api/auth/status') return json({ hasPasswordLogin: false, username: null }) if (pathname === '/api/hermes/profiles') return json({ profiles: [{ name: 'default', active: true, model: 'test-model', gateway: 'test' }] }) @@ -123,10 +132,18 @@ async function mockGroupChatSocket(page: Page) { body: ` const state = window.__PW_GROUP_SOCKET__ || (window.__PW_GROUP_SOCKET__ = { sockets: [], emitted: [] }) const roomMessages = ${JSON.stringify(messagesByRoom)} +const roomNames = ${JSON.stringify(Object.fromEntries(baseRooms.map(room => [room.id, room.name])))} +const localCredential = ${JSON.stringify(LOCAL_CREDENTIAL)} +const localUserId = ${JSON.stringify(`local-user:${'c'.repeat(64)}`)} function makeSocket(url, options) { const listeners = new Map() + const dispatch = (event, payload) => { + for (const handler of listeners.get(event) || []) handler(payload) + } const socket = { - connected: true, + connected: false, + id: 'pw-group-socket-' + (state.sockets.length + 1), + auth: { ...(options && options.auth) }, url, options, on(event, handler) { @@ -135,27 +152,53 @@ function makeSocket(url, options) { listeners.set(event, handlers) return this }, + once(event, handler) { + const wrapped = (payload) => { + this.off(event, wrapped) + handler(payload) + } + return this.on(event, wrapped) + }, + off(event, handler) { + if (!listeners.has(event)) return this + if (!handler) { + listeners.delete(event) + return this + } + listeners.set(event, (listeners.get(event) || []).filter(candidate => candidate !== handler)) + return this + }, emit(event, payload, ack) { state.emitted.push({ event, payload }) if (event === 'join' && typeof ack === 'function') { const roomId = payload && payload.roomId - setTimeout(() => ack({ roomId, roomName: roomId, members: [], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0) + setTimeout(() => ack({ roomId, roomName: roomNames[roomId] || roomId, currentUserId: localUserId, members: [{ id: 'member-local', userId: localUserId, name: 'User One', description: '', joinedAt: 1_790_000_000 }], messages: roomMessages[roomId] || [], agents: [], rooms: [], typingUsers: [], contextStatuses: [] }), 0) } if (event === 'message' && typeof ack === 'function') { setTimeout(() => ack({ id: payload && payload.id }), 0) } return this }, + connect() { + if (this.connected) return this + setTimeout(() => { + this.connected = true + dispatch('connect') + setTimeout(() => dispatch('local_identity', { localCredential, userId: localUserId }), 0) + }, 0) + return this + }, removeAllListeners() { listeners.clear() return this }, disconnect() { this.connected = false + dispatch('disconnect', 'client disconnect') return this }, __trigger(event, payload) { - for (const handler of listeners.get(event) || []) handler(payload) + dispatch(event, payload) }, } state.sockets.push(socket) @@ -176,6 +219,7 @@ async function setup(page: Page, path: string) { await mockGroupChatSocket(page) const api = await mockGroupChatApi(page) await page.goto(path) + await expect(page.getByText('Alpha Room', { exact: true }).first()).toBeVisible({ timeout: 15_000 }) return api } @@ -243,8 +287,8 @@ test.describe('group chat room deep links', () => { const successResponse = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().includes('/api/hermes/group-chat/rooms/room-alpha/invite-code')) await updateButton.click() await expect((await successResponse).status()).toBe(200) - expect(api.inviteCodeUpdates.at(-1)).toEqual({ roomId: 'room-alpha', body: { inviteCode: 'NEW456' } }) - await expect(inviteInput).toHaveValue('NEW456') + expect(api.inviteCodeUpdates.at(-1)).toEqual({ roomId: 'room-alpha', body: { inviteCode: ' NEW456 ' } }) + await expect(inviteInput).toHaveValue(' NEW456 ') await expect(updateButton).toBeDisabled() await inviteInput.fill('FAILCODE') @@ -254,7 +298,7 @@ test.describe('group chat room deep links', () => { await modal.getByRole('button', { name: 'Cancel' }).click() await settingsButton.click() - await expect(modal.getByPlaceholder('Enter a new invite code')).toHaveValue('NEW456') + await expect(modal.getByPlaceholder('Enter a new invite code')).toHaveValue(' NEW456 ') }) test('read-only room members cannot open room settings', async ({ page }) => { diff --git a/tests/server/agent-bridge-python-concurrency.test.ts b/tests/server/agent-bridge-python-concurrency.test.ts index 5a7f633500..d22746606a 100644 --- a/tests/server/agent-bridge-python-concurrency.test.ts +++ b/tests/server/agent-bridge-python-concurrency.test.ts @@ -891,6 +891,39 @@ assert [(msg["role"], msg["content"]) for msg in messages] == [ `) }) + it('fails closed when a Bridge approval response was not offered by the request', () => { + runPython(String.raw` +${harness} + +pool, _fake_db = make_pool() +events = [] +pool._append_event = lambda session_id, event: events.append((session_id, event)) +result = {} + +callback = pool._approval_callback("session-no-permanent") +thread = threading.Thread( + target=lambda: result.setdefault("choice", callback("touch file", "needs approval", allow_permanent=False)), + daemon=True, +) +thread.start() +assert wait_for(lambda: bool(pool._approval_requests)) +approval_id = next(iter(pool._approval_requests.keys())) +requested = next(event for _session_id, event in events if event.get("event") == "approval.requested") +assert requested["choices"] == ["once", "session", "deny"] +assert requested["allow_permanent"] is False + +response = pool.respond_approval(approval_id, "always") +assert response == {"approval_id": approval_id, "resolved": True, "choice": "deny"} +thread.join(timeout=2) +assert not thread.is_alive() +assert result["choice"] == "deny" +assert approval_id not in pool._approval_requests +assert approval_id not in pool._approval_allowed_choices +resolved = next(event for _session_id, event in events if event.get("event") == "approval.resolved") +assert resolved["choice"] == "deny" +`) + }) + it('remembers execute_code approvals inside the bridge without patching upstream files', () => { runPython(String.raw` ${harness} diff --git a/tests/server/auth.test.ts b/tests/server/auth.test.ts index e87749d3c3..f726103075 100644 --- a/tests/server/auth.test.ts +++ b/tests/server/auth.test.ts @@ -23,6 +23,7 @@ async function loadAuth(overrides: Partial & { home?: string } = {}) { mocks: { readFile, writeFile, mkdir }, appHome: join(home, '.hermes-web-ui'), tokenFile: join(home, '.hermes-web-ui', '.token'), + localIdentitySecretFile: join(home, '.hermes-web-ui', '.group-chat-local-identity-secret'), } } @@ -103,6 +104,44 @@ describe('Auth Service', () => { }) }) + describe('getGroupChatLocalIdentitySecret', () => { + it('uses a dedicated 256-bit secret rather than the API bearer token', async () => { + process.env.AUTH_TOKEN = 'public-api-bearer' + process.env.GROUP_CHAT_LOCAL_IDENTITY_SECRET = 'a'.repeat(64) + const { getToken, getGroupChatLocalIdentitySecret, mocks } = await loadAuth() + + await expect(getToken()).resolves.toBe('public-api-bearer') + await expect(getGroupChatLocalIdentitySecret()).resolves.toBe('a'.repeat(64)) + expect(mocks.readFile).not.toHaveBeenCalled() + }) + + it('rejects weak configured signing secrets', async () => { + process.env.GROUP_CHAT_LOCAL_IDENTITY_SECRET = 'weak' + const { getGroupChatLocalIdentitySecret } = await loadAuth() + + await expect(getGroupChatLocalIdentitySecret()).rejects.toThrow(/exactly 32 bytes/) + }) + + it('creates the durable private secret exclusively with owner-only permissions', async () => { + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const readFile = vi.fn().mockRejectedValue(missing) + const writeFile = vi.fn() + const mkdir = vi.fn() + const { getGroupChatLocalIdentitySecret, appHome, localIdentitySecretFile } = await loadAuth({ readFile, writeFile, mkdir }) + + await expect(getGroupChatLocalIdentitySecret()).resolves.toMatch(/^[a-f0-9]{64}$/) + expect(mkdir).toHaveBeenCalledWith(appHome, { recursive: true }) + const expectedOptions = process.platform === 'win32' + ? { flag: 'wx' } + : { flag: 'wx', mode: 0o600 } + expect(writeFile).toHaveBeenCalledWith( + localIdentitySecretFile, + expect.stringMatching(/^[a-f0-9]{64}\n$/), + expectedOptions, + ) + }) + }) + describe('requireAuth', () => { it('skips /health', async () => { const { requireAuth } = await loadAuth() diff --git a/tests/server/coding-agent-resume-config.test.ts b/tests/server/coding-agent-resume-config.test.ts index 6dde2b723f..186a09ca33 100644 --- a/tests/server/coding-agent-resume-config.test.ts +++ b/tests/server/coding-agent-resume-config.test.ts @@ -26,6 +26,16 @@ vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({ getProfileDir: (profile: string) => `/tmp/hermes-profile/${profile}`, })) +vi.doMock('../../packages/server/src/services/hermes/model-context', async () => { + const actual = await vi.importActual( + '../../packages/server/src/services/hermes/model-context', + ) + return { + ...actual, + getModelContextLength: vi.fn(() => null), + } +}) + vi.doMock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ codingAgentRunManager: { start: startRunMock, diff --git a/tests/server/coding-agent-run-manager-windows.test.ts b/tests/server/coding-agent-run-manager-windows.test.ts index 4e3eb72327..5f6c7dd5e0 100644 --- a/tests/server/coding-agent-run-manager-windows.test.ts +++ b/tests/server/coding-agent-run-manager-windows.test.ts @@ -45,6 +45,16 @@ vi.mock('child_process', () => ({ }), })) +vi.mock('../../packages/server/src/db/hermes/session-store', async () => { + const actual = await vi.importActual( + '../../packages/server/src/db/hermes/session-store', + ) + return { + ...actual, + updateSessionStats: vi.fn(), + } +}) + import { CodingAgentRunManager } from '../../packages/server/src/services/agent-runner/coding-agent-run-manager' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') diff --git a/tests/server/compression-cursor-store.test.ts b/tests/server/compression-cursor-store.test.ts index 82b41d4dd4..a528484358 100644 --- a/tests/server/compression-cursor-store.test.ts +++ b/tests/server/compression-cursor-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('compression cursor persistence', () => { let db: any = null @@ -12,6 +13,7 @@ describe('compression cursor persistence', () => { isSqliteAvailable: () => true, getStoragePath: () => ':memory:', })) + await claimTestHermesDbOwnership(db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() }) diff --git a/tests/server/context-engine-gateway.test.ts b/tests/server/context-engine-gateway.test.ts new file mode 100644 index 0000000000..d731afa6af --- /dev/null +++ b/tests/server/context-engine-gateway.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const requestMock = vi.hoisted(() => vi.fn()) +const destroyMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({ + AgentBridgeClient: class { + request(payload: unknown, options: unknown) { + return requestMock(payload, options) + } + + destroy(...args: unknown[]) { + return destroyMock(...args) + } + }, +})) + +import { GatewaySummarizer } from '../../packages/server/src/services/hermes/context-engine/gateway-client' +import type { StoredMessage } from '../../packages/server/src/services/hermes/context-engine/types' + +const message: StoredMessage = { + id: 'message-1', + roomId: 'private-room', + senderId: 'user-1', + senderName: 'Alice', + content: 'private context', + timestamp: 1, +} + +const registeredSessionId = `gc_h_${'a'.repeat(32)}` + +describe('GatewaySummarizer registered sessions', () => { + beforeEach(() => { + requestMock.mockReset() + destroyMock.mockReset() + }) + + it('registers before Bridge use and releases the durable lease afterward', async () => { + const order: string[] = [] + const release = vi.fn(() => order.push('release')) + const registrar = vi.fn(() => { + order.push('register') + return { sessionId: registeredSessionId, authorizationGuard: () => true, release } + }) + requestMock.mockImplementation(async () => { + order.push('request') + return { status: 'complete', result: { final_response: 'summary' } } + }) + + const result = await new GatewaySummarizer().summarize( + '', + null, + 'system prompt', + [message], + 'private-room', + 'default', + undefined, + registrar, + ) + + expect(result).toEqual({ summary: 'summary', sessionId: registeredSessionId }) + expect(order).toEqual(['register', 'request', 'release']) + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ session_id: registeredSessionId }), + expect.anything(), + ) + expect(release).toHaveBeenCalledOnce() + expect(destroyMock).not.toHaveBeenCalled() + }) + + it('aborts after the Bridge await when the registered lease is revoked', async () => { + let authorized = true + const release = vi.fn() + requestMock.mockImplementation(async () => { + authorized = false + return { status: 'complete', result: { final_response: 'must not be accepted' } } + }) + + await expect(new GatewaySummarizer().summarize( + '', + null, + 'system prompt', + [message], + 'private-room', + 'default', + undefined, + () => ({ + sessionId: registeredSessionId, + authorizationGuard: () => authorized, + release, + }), + )).rejects.toThrow('summary session authorization changed') + + expect(requestMock).toHaveBeenCalledOnce() + expect(release).toHaveBeenCalledOnce() + }) + + it('has no Bridge side effect when registration fails', async () => { + const registrar = vi.fn(() => { + throw new Error('registration denied') + }) + + await expect(new GatewaySummarizer().summarize( + '', + null, + 'system prompt', + [message], + 'private-room', + 'default', + undefined, + registrar, + )).rejects.toThrow('registration denied') + + expect(requestMock).not.toHaveBeenCalled() + expect(destroyMock).not.toHaveBeenCalled() + }) + + it('releases the durable lease when the Bridge request fails', async () => { + const release = vi.fn() + requestMock.mockRejectedValue(new Error('bridge failed')) + + await expect(new GatewaySummarizer().summarize( + '', + null, + 'system prompt', + [message], + 'private-room', + 'default', + undefined, + () => ({ sessionId: registeredSessionId, authorizationGuard: () => true, release }), + )).rejects.toThrow('bridge failed') + + expect(release).toHaveBeenCalledOnce() + expect(destroyMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/server/context-engine.test.ts b/tests/server/context-engine.test.ts index 1f585f3c1d..a24e44b9a4 100644 --- a/tests/server/context-engine.test.ts +++ b/tests/server/context-engine.test.ts @@ -6,8 +6,8 @@ import { buildFullSummaryPrompt, buildIncrementalUpdatePrompt, } from '../../packages/server/src/services/hermes/context-engine/prompt' -import { ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor' -import type { StoredMessage, MessageFetcher, GatewayCaller } from '../../packages/server/src/services/hermes/context-engine/types' +import { ContextAuthorizationChangedError, ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor' +import type { BuildContextInput, StoredMessage, MessageFetcher, GatewayCaller } from '../../packages/server/src/services/hermes/context-engine/types' // ─── Helpers ───────────────────────────────────────────────── @@ -34,6 +34,29 @@ function makeMessages(count: number, roomId = 'room-1', startTimestamp = 1000): })) } +function makeBuildInput( + messages: StoredMessage[], + authorizationGuard: () => boolean, + overrides: Partial = {}, +): BuildContextInput { + return { + roomId: 'room-1', + agentId: 'agent-1', + agentName: 'Claude', + agentDescription: 'Helper', + agentSocketId: 'agent-socket', + roomName: 'general', + memberNames: ['Alice'], + members: [{ userId: 'u1', name: 'Alice', description: '' }], + upstream: '', + apiKey: null, + currentMessage: messages[messages.length - 1] || makeMessage(), + authorizationGuard, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), + ...overrides, + } +} + // ─── SummaryCache ───────────────────────────────────────────── describe('SummaryCache', () => { @@ -177,6 +200,288 @@ describe('ContextEngine.buildContext', () => { }) }) + it('denies before reading private messages when authorization is already stale', async () => { + const messages = makeMessages(3) + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + + await expect(engine.buildContext(makeBuildInput(messages, () => false))) + .rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(mockFetcher.getMessagesForContext).not.toHaveBeenCalled() + expect(mockSummarize).not.toHaveBeenCalled() + }) + + it('rechecks authorization after waiting for an existing room compression lock', async () => { + const messages = makeMessages(5) + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + let releaseSummary!: (value: { summary: string; sessionId: string }) => void + const summarize = vi.fn(() => new Promise<{ summary: string; sessionId: string }>((resolve) => { + releaseSummary = resolve + })) + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller: { summarize }, + }) + let secondAuthorized = true + + const first = guardedEngine.buildContext(makeBuildInput(messages, () => true)) + await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce()) + const second = guardedEngine.buildContext(makeBuildInput(messages, () => secondAuthorized)) + const secondExpectation = expect(second).rejects.toBeInstanceOf(ContextAuthorizationChangedError) + secondAuthorized = false + releaseSummary({ summary: 'first summary', sessionId: 'summary-first' }) + + await expect(first).resolves.toBeDefined() + await secondExpectation + expect(mockFetcher.getMessagesForContext).toHaveBeenCalledTimes(1) + expect(summarize).toHaveBeenCalledTimes(1) + }) + + it('serializes three same-room compression callers without a waiter stampede', async () => { + const messages = makeMessages(5) + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + const pending: Array<() => void> = [] + let active = 0 + let maxActive = 0 + const summarize = vi.fn(async (...args: Parameters) => { + const registrar = args[7] + const lease = registrar() + active += 1 + maxActive = Math.max(maxActive, active) + try { + await new Promise(resolve => pending.push(resolve)) + return { summary: `summary-${summarize.mock.calls.length}`, sessionId: lease.sessionId } + } finally { + active -= 1 + lease.release() + } + }) + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller: { summarize }, + }) + + const first = guardedEngine.buildContext(makeBuildInput(messages, () => true)) + await vi.waitFor(() => expect(summarize).toHaveBeenCalledTimes(1)) + const second = guardedEngine.buildContext(makeBuildInput(messages, () => true)) + const third = guardedEngine.buildContext(makeBuildInput(messages, () => true)) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(summarize).toHaveBeenCalledTimes(1) + + pending.shift()?.() + await vi.waitFor(() => expect(summarize).toHaveBeenCalledTimes(2)) + expect(maxActive).toBe(1) + pending.shift()?.() + await vi.waitFor(() => expect(summarize).toHaveBeenCalledTimes(3)) + expect(maxActive).toBe(1) + pending.shift()?.() + + await expect(Promise.all([first, second, third])).resolves.toHaveLength(3) + expect(maxActive).toBe(1) + }) + + it('serializes forced compression behind an in-flight automatic compression', async () => { + const messages = makeMessages(5) + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + let releaseFirst: (() => void) | undefined + let callCount = 0 + const summarize = vi.fn(async (...args: Parameters) => { + const lease = args[7]() + callCount += 1 + try { + if (callCount === 1) { + await new Promise(resolve => { releaseFirst = resolve }) + } + return { summary: `summary-${callCount}`, sessionId: lease.sessionId } + } finally { + lease.release() + } + }) + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller: { summarize }, + }) + const forceRegistrar = vi.fn(() => ({ + sessionId: `gc_h_${'b'.repeat(32)}`, + authorizationGuard: () => true, + release: vi.fn(), + })) + + const automatic = guardedEngine.buildContext(makeBuildInput(messages, () => true)) + await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce()) + const forced = guardedEngine.forceCompress('room-1', 'default', forceRegistrar) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(forceRegistrar).not.toHaveBeenCalled() + expect(mockFetcher.getMessagesForContext).toHaveBeenCalledTimes(1) + + releaseFirst?.() + await expect(automatic).resolves.toBeDefined() + await expect(forced).resolves.toBe('summary-2') + expect(forceRegistrar).toHaveBeenCalledOnce() + expect(mockFetcher.getMessagesForContext).toHaveBeenCalledTimes(2) + }) + + it('does not fall back to heuristic compression when authorization disappears during token estimation', async () => { + const messages = makeMessages(20) + let authorized = true + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + const contextTokenEstimator = vi.fn(async () => { + authorized = false + return undefined + }) + + await expect(engine.buildContext(makeBuildInput(messages, () => authorized, { contextTokenEstimator }))) + .rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(mockSummarize).not.toHaveBeenCalled() + expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() + }) + + it('rechecks after progress callbacks before sending private messages to the summarizer', async () => { + const messages = makeMessages(20) + let authorized = true + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + + await expect(engine.buildContext(makeBuildInput(messages, () => authorized, { + contextTokenEstimator: vi.fn().mockResolvedValue(120_000), + onProgress: () => { authorized = false }, + }))).rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(mockSummarize).not.toHaveBeenCalled() + expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() + }) + + it('cleans the external summary session and skips snapshot persistence when authority changes in flight', async () => { + const messages = makeMessages(5) + let authorized = true + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + const sessionCleaner = vi.fn() + const summarize = vi.fn(async () => { + authorized = false + return { summary: 'must not persist', sessionId: 'summary-revoked' } + }) + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller: { summarize }, + sessionCleaner, + }) + + await expect(guardedEngine.buildContext(makeBuildInput(messages, () => authorized))) + .rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(summarize).toHaveBeenCalledOnce() + expect(sessionCleaner).toHaveBeenCalledWith('summary-revoked') + expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() + }) + + it('does not persist a summary when authority changes during the post-summary estimate', async () => { + const messages = makeMessages(5) + let authorized = true + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + const contextTokenEstimator = vi.fn() + .mockResolvedValueOnce(120_000) + .mockImplementationOnce(async () => { + authorized = false + return 100 + }) + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller: mockGatewayCaller, + }) + + await expect(guardedEngine.buildContext(makeBuildInput(messages, () => authorized, { contextTokenEstimator }))) + .rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(mockSummarize).toHaveBeenCalledOnce() + expect(contextTokenEstimator).toHaveBeenCalledTimes(2) + expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() + }) + + it('registers a forced-compression lease before reading private room history', async () => { + const messages = makeMessages(5) + const order: string[] = [] + const release = vi.fn(() => order.push('release')) + mockFetcher.getMessagesForContext = vi.fn(() => { + order.push('read') + return messages + }) + const gatewayCaller: GatewayCaller = { + summarize: vi.fn(async (_upstream, _key, _prompt, _messages, _room, _profile, _previous, registrar) => { + const lease = registrar() + order.push('request') + try { + return { summary: 'forced summary', sessionId: lease.sessionId } + } finally { + lease.release() + } + }), + } + const guardedEngine = new ContextEngine({ + config: { maxHistoryTokens: 4000, tailMessageCount: 2, triggerTokens: 1, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, + messageFetcher: mockFetcher, + gatewayCaller, + }) + + const result = await guardedEngine.forceCompress('room-1', 'default', () => { + order.push('register') + return { sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release } + }) + + expect(result).toBe('forced summary') + expect(order).toEqual(['register', 'read', 'request', 'release']) + expect(release).toHaveBeenCalledOnce() + expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledOnce() + }) + + it('aborts forced compression when its exact lease changes during summarization', async () => { + const messages = makeMessages(5) + let authorized = true + const release = vi.fn() + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) + const gatewayCaller: GatewayCaller = { + summarize: vi.fn(async (...args: Parameters) => { + const lease = args[7]() + try { + authorized = false + return { summary: 'must not persist', sessionId: lease.sessionId } + } finally { + lease.release() + } + }), + } + const guardedEngine = new ContextEngine({ messageFetcher: mockFetcher, gatewayCaller }) + + await expect(guardedEngine.forceCompress('room-1', 'default', () => ({ + sessionId: `gc_h_${'a'.repeat(32)}`, + authorizationGuard: () => authorized, + release, + }))).rejects.toBeInstanceOf(ContextAuthorizationChangedError) + + expect(release).toHaveBeenCalledOnce() + expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() + }) + + it('releases a forced-compression lease when the room has no messages', async () => { + const release = vi.fn() + mockFetcher.getMessagesForContext = vi.fn().mockReturnValue([]) + const gatewayCaller: GatewayCaller = { summarize: vi.fn() } + const guardedEngine = new ContextEngine({ messageFetcher: mockFetcher, gatewayCaller }) + + await expect(guardedEngine.forceCompress('room-1', 'default', () => ({ + sessionId: `gc_h_${'a'.repeat(32)}`, + authorizationGuard: () => true, + release, + }))).resolves.toBe('') + + expect(release).toHaveBeenCalledOnce() + expect(gatewayCaller.summarize).not.toHaveBeenCalled() + }) + it('returns all messages as history when under threshold', async () => { const messages = makeMessages(10) // 10 messages, under trigger threshold mockFetcher.getMessagesForContext = vi.fn().mockReturnValue(messages) @@ -192,6 +497,8 @@ describe('ContextEngine.buildContext', () => { members: [{ userId: 'u1', name: 'Alice', description: '' }], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) @@ -220,6 +527,8 @@ describe('ContextEngine.buildContext', () => { members: [{ userId: 'u1', name: 'Alice', description: '' }], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator, onProgress, @@ -252,6 +561,8 @@ describe('ContextEngine.buildContext', () => { members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), onProgress, @@ -284,6 +595,8 @@ describe('ContextEngine.buildContext', () => { members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), })).rejects.toThrow('Context window is too small') @@ -314,6 +627,8 @@ describe('ContextEngine.buildContext', () => { members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), })).rejects.toThrow('Context window is too small') @@ -337,6 +652,8 @@ describe('ContextEngine.buildContext', () => { members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, // Force compression with tiny threshold }) @@ -355,6 +672,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) @@ -377,6 +696,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) @@ -394,6 +715,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) @@ -430,6 +753,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: updatedMessages[updatedMessages.length - 1], compression: { triggerTokens: 10 }, onProgress, @@ -455,6 +780,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) @@ -485,6 +812,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) @@ -508,6 +837,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) @@ -530,6 +861,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) @@ -550,6 +883,8 @@ describe('ContextEngine.buildContext', () => { { userId: 'u2', name: 'Bob', description: 'designer' }, ], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[0], }) @@ -577,6 +912,8 @@ describe('ContextEngine.buildContext', () => { roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) diff --git a/tests/server/db-test-helpers.ts b/tests/server/db-test-helpers.ts new file mode 100644 index 0000000000..009426b4e1 --- /dev/null +++ b/tests/server/db-test-helpers.ts @@ -0,0 +1,12 @@ +import type { DatabaseSync } from 'node:sqlite' + +export async function claimTestHermesDbOwnership(database: DatabaseSync): Promise { + const ownership = await import('../../packages/server/src/db/ownership') + const row = database.prepare('PRAGMA database_list').get() as { file?: string } | undefined + const path = String(row?.file || '') + if (!path || path === ':memory:' || path.startsWith('file::memory:') || path.includes('mode=memory')) { + ownership.claimHermesDatabaseOwnershipForTesting(database) + return + } + ownership.acquireHermesDatabaseOwnership(database, path) +} diff --git a/tests/server/devices-controller.test.ts b/tests/server/devices-controller.test.ts index f59d3c81d3..ed6547bcba 100644 --- a/tests/server/devices-controller.test.ts +++ b/tests/server/devices-controller.test.ts @@ -1,6 +1,7 @@ import { createHash, generateKeyPairSync, sign } from 'crypto' import { createServer, type Server } from 'http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' import type { LanDeviceInfo } from '../../packages/server/src/services/lan-discovery' const keyPair = generateKeyPairSync('ed25519', { @@ -37,6 +38,7 @@ describe('devices controller', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/devices-store.test.ts b/tests/server/devices-store.test.ts index 63d832d89d..18bf4a5405 100644 --- a/tests/server/devices-store.test.ts +++ b/tests/server/devices-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' import type { LanDeviceInfo } from '../../packages/server/src/services/lan-discovery' describe('devices store', () => { @@ -29,6 +30,7 @@ describe('devices store', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/fixtures/hermes-db-process-ownership-child.ts b/tests/server/fixtures/hermes-db-process-ownership-child.ts new file mode 100644 index 0000000000..2a54493a13 --- /dev/null +++ b/tests/server/fixtures/hermes-db-process-ownership-child.ts @@ -0,0 +1,65 @@ +import { getDb, closeDb, getStoragePath } from '../../../packages/server/src/db/index' +import { initAllStores } from '../../../packages/server/src/db/hermes/init' + +type ChildStatus = + | { status: 'ready'; path: string } + | { status: 'acquired'; path: string } + | { status: 'closed' } + | { status: 'crashing' } + +function writeStatus(status: ChildStatus): void { + process.stdout.write(`${JSON.stringify(status)}\n`) +} + +function fail(error: unknown): never { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`${message}\n`) + process.exit(2) +} + +function initializeDb(): void { + const db = getDb() + if (!db) { + throw new Error('sqlite backend is unavailable') + } + initAllStores() +} + +function holdOwnership(): void { + initializeDb() + writeStatus({ status: 'ready', path: getStoragePath() }) + process.stdin.setEncoding('utf8') + process.stdin.on('data', (chunk: string) => { + const command = chunk.trim() + if (command === 'close') { + closeDb() + writeStatus({ status: 'closed' }) + process.exit(0) + return + } + if (command === 'crash') { + writeStatus({ status: 'crashing' }) + process.exit(17) + } + }) +} + +function probeOwnership(): void { + initializeDb() + writeStatus({ status: 'acquired', path: getStoragePath() }) + closeDb() + process.exit(0) +} + +try { + const mode = process.argv[2] + if (mode === 'hold') { + holdOwnership() + } else if (mode === 'probe') { + probeOwnership() + } else { + throw new Error(`unknown mode: ${mode}`) + } +} catch (error) { + fail(error) +} diff --git a/tests/server/group-chat-access-policy.test.ts b/tests/server/group-chat-access-policy.test.ts new file mode 100644 index 0000000000..c0d9f0a52f --- /dev/null +++ b/tests/server/group-chat-access-policy.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest' + +import { + decideGroupChatAccessPolicy, + evaluateGroupChatAccessPolicy, + groupChatInviteCodeMatches, + type GroupChatAccessStore, +} from '../../packages/server/src/services/hermes/group-chat/access-policy' + +describe('group chat access policy', () => { + const room = { + id: 'room-1', + name: 'Room 1', + ownerAuthUserId: 7, + inviteCode: 'ROOM1', + authorizationRevision: 3, + sessionSeed: '11111111111111111111111111111111', + } + + it('compares transport-decoded invite bytes exactly without normalization', () => { + expect(groupChatInviteCodeMatches('ROOM1', 'ROOM1')).toBe(true) + expect(groupChatInviteCodeMatches(' ROOM1', 'ROOM1')).toBe(false) + expect(groupChatInviteCodeMatches('ROOM1 ', 'ROOM1')).toBe(false) + expect(groupChatInviteCodeMatches('room1', 'ROOM1')).toBe(false) + expect(groupChatInviteCodeMatches('ROOM1', 'ROOM1')).toBe(false) + expect(groupChatInviteCodeMatches('', '')).toBe(false) + }) + + it('keeps invite-only authenticated actors read-only while super admins stay writable', () => { + const storage: GroupChatAccessStore = { + getRoom: () => room, + findActiveActorByAuthUserId: (_roomId: string, authUserId: number) => authUserId === 42 ? { + id: 'actor-readonly-1', + roomId: 'room-1', + actorType: 'authenticated_human', + active: 1, + authUserId: 42, + agentId: null, + localSubjectId: null, + systemKey: null, + name: 'Read Only', + description: '', + avatar: '', + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: 1, + updatedAt: 1, + } : null, + getActorCapabilities: (actorId: string) => actorId === 'actor-readonly-1' + ? ['room.read'] + : [], + } + + const readonly = evaluateGroupChatAccessPolicy(storage, 'room-1', { + kind: 'authenticated_human', + authUserId: 42, + role: 'admin', + profiles: [], + }) + const superAdmin = evaluateGroupChatAccessPolicy(storage, 'room-1', { + kind: 'authenticated_human', + authUserId: 1, + role: 'super_admin', + profiles: [], + }) + + expect(readonly).toMatchObject({ + actorId: 'actor-readonly-1', + canDiscover: true, + canJoin: true, + canRead: true, + canWrite: false, + canType: false, + canManage: false, + canInvokeAgent: false, + canInvokeAgents: false, + canRespondApproval: false, + canApprove: false, + }) + expect(superAdmin).toMatchObject({ + canRead: true, + canWrite: true, + canType: true, + canManage: true, + canInvokeAgent: true, + canInvokeAgents: true, + canRespondApproval: true, + canApprove: true, + }) + }) + + it('does not treat matching agent profiles as invite or membership authority', () => { + const storage: GroupChatAccessStore = { + getRoom: () => room, + findActiveActorByAuthUserId: () => null, + getActorCapabilities: () => [], + getMemberByAuthUserId: () => null, + } + + const matchingProfileUser = evaluateGroupChatAccessPolicy(storage, 'room-1', { + kind: 'authenticated_human', + authUserId: 42, + role: 'admin', + profiles: ['default', 'researcher'], + }) + + expect(matchingProfileUser).toMatchObject({ + actorId: null, + canDiscover: false, + canJoin: false, + canRead: false, + canWrite: false, + canType: false, + canManage: false, + canInvokeAgent: false, + canInvokeAgents: false, + canRespondApproval: false, + canApprove: false, + }) + }) + + it('treats persisted room agents as writable without granting approval controls', () => { + const storage: GroupChatAccessStore = { + getRoom: () => room, + findActiveActorByAgentIdentity: (_roomId: string, agentId: string) => agentId === 'agent-1' ? { + id: 'actor-agent-1', + roomId: 'room-1', + actorType: 'agent', + active: 1, + authUserId: null, + agentId: 'agent-1', + localSubjectId: null, + systemKey: null, + name: 'Agent', + description: '', + avatar: '', + authorizationRevision: 2, + contextRevision: 4, + tombstonedAt: null, + createdAt: 1, + updatedAt: 1, + } : null, + getActorCapabilities: (actorId: string) => actorId === 'actor-agent-1' + ? ['room.read', 'room.write', 'room.type', 'agent.invoke'] + : [], + } + + const agent = evaluateGroupChatAccessPolicy(storage, 'room-1', { + kind: 'agent', + agentId: 'agent-1', + }) + + expect(agent).toMatchObject({ + actorId: 'actor-agent-1', + canRead: true, + canWrite: true, + canType: true, + canManage: false, + canInvokeAgents: true, + canApprove: false, + actorAuthorizationRevision: 2, + actorContextRevision: 4, + roomAuthorizationRevision: 3, + }) + }) + + it('fails closed for active non-human actors with no persisted grants', () => { + const actorBase = { + id: 'actor-1', + roomId: room.id, + active: 1, + authUserId: null, + agentId: null, + localSubjectId: null, + systemKey: null, + name: 'Actor', + description: '', + avatar: '', + authorizationRevision: 4, + contextRevision: 2, + tombstonedAt: null, + createdAt: 1, + updatedAt: 1, + } as const + const subjects = [ + { + subject: { kind: 'agent' as const, agentId: 'agent-1' }, + actor: { ...actorBase, actorType: 'agent' as const, agentId: 'agent-1' }, + }, + { + subject: { kind: 'local' as const, localSubjectId: 'local-1' }, + actor: { ...actorBase, actorType: 'local' as const, localSubjectId: 'local-1' }, + }, + { + subject: { kind: 'system' as const, systemKey: 'room-system' }, + actor: { ...actorBase, actorType: 'system' as const, systemKey: 'room-system' }, + }, + ] + + for (const { subject, actor } of subjects) { + expect(decideGroupChatAccessPolicy({ + roomId: room.id, + room, + subject, + actor, + storedCapabilities: [], + hasMembership: false, + })).toMatchObject({ + actorId: actor.id, + canDiscover: false, + canJoin: false, + canRead: false, + canWrite: false, + canType: false, + canManage: false, + canInvokeAgents: false, + canApprove: false, + }) + } + }) + + it('exposes a pure typed decision contract and never treats a routing id as local authority', () => { + const decision = decideGroupChatAccessPolicy({ + roomId: room.id, + room, + subject: { kind: 'local', localSubjectId: 'client-routing-id' }, + actor: null, + storedCapabilities: [], + hasMembership: true, + }) + + expect(decision).toMatchObject({ + canDiscover: false, + canJoin: false, + canRead: false, + canWrite: false, + canType: false, + canInvokeAgent: false, + canRespondApproval: false, + canManage: false, + }) + }) +}) diff --git a/tests/server/group-chat-actor-identity.test.ts b/tests/server/group-chat-actor-identity.test.ts new file mode 100644 index 0000000000..899edaff55 --- /dev/null +++ b/tests/server/group-chat-actor-identity.test.ts @@ -0,0 +1,707 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + connectGroupChatClient, + createTestGroupChatServer, + emitAck, + once, + seedAuthenticatedUser, +} from './group-chat-test-helpers' +import { authenticateUserToken, isAuthEnabled } from '../../packages/server/src/middleware/user-auth' +import { groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' +import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' + +describe('group chat actor identity', () => { + let harness: Awaited> + let groupServer: GroupChatServer + let port: number + + beforeEach(async () => { + vi.clearAllMocks() + harness = await createTestGroupChatServer() + groupServer = harness.groupServer + port = harness.port + }) + + afterEach(() => { + vi.mocked(isAuthEnabled).mockResolvedValue(false) + vi.mocked(authenticateUserToken).mockResolvedValue(null as any) + harness?.cleanup() + }) + + it('creates actor tables with revision and active-state columns', () => { + const tables = harness.db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as Array<{ name: string }> + + expect(tables.map(table => table.name)).toContain('gc_room_actors') + expect(tables.map(table => table.name)).toContain('gc_room_actor_capabilities') + + const actorColumns = harness.db.prepare(`PRAGMA table_info("gc_room_actors")`).all() as Array<{ name: string }> + const capabilityColumns = harness.db.prepare(`PRAGMA table_info("gc_room_actor_capabilities")`).all() as Array<{ name: string }> + + expect(actorColumns.map(column => column.name)).toEqual(expect.arrayContaining([ + 'id', + 'roomId', + 'actorType', + 'active', + 'authorizationRevision', + 'contextRevision', + ])) + expect(capabilityColumns.map(column => column.name)).toEqual(expect.arrayContaining([ + 'actorId', + 'capability', + 'active', + ])) + }) + + it('keeps authenticated invite joins read-only for public messaging and typing', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'readonly-token') { + return { id: 42, username: 'readonly', role: 'admin', profiles: [] } as any + } + if (token === 'manager-token') { + return { id: 7, username: 'owner', role: 'super_admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'readonly' }) + seedAuthenticatedUser(harness.db, { id: 7, username: 'owner', role: 'super_admin' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1', { ownerAuthUserId: 7 }) + groupServer.getStorage().addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + + const readonly = await connectGroupChatClient(port, 'ignored-readonly', 'ReadOnly', { token: 'readonly-token' }) + const manager = await connectGroupChatClient(port, 'ignored-manager', 'Owner', { token: 'manager-token' }) + harness.sockets.push(readonly, manager) + + await emitAck(manager, 'join', { roomId: 'room-1' }) + await emitAck(readonly, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + const processMentions = vi.spyOn(groupServer.agentClients, 'processMentions').mockResolvedValue(undefined) + + const messageAck = await emitAck(readonly, 'message', { + roomId: 'room-1', + id: 'readonly-msg-1', + content: '@Worker hello', + }) + + let typingPayload: unknown = null + let stopTypingPayload: unknown = null + manager.on('typing', payload => { typingPayload = payload }) + manager.on('stop_typing', payload => { stopTypingPayload = payload }) + readonly.emit('typing', { roomId: 'room-1' }) + readonly.emit('stop_typing', { roomId: 'room-1' }) + await new Promise(resolve => setTimeout(resolve, 30)) + + expect(messageAck).toEqual({ error: 'Access denied' }) + expect(processMentions).not.toHaveBeenCalled() + expect(typingPayload).toBeNull() + expect(stopTypingPayload).toBeNull() + }) + + it('requires an invite for a non-member even when their allowed profile matches a room agent', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'profile-token') { + return { id: 42, username: 'profile-user', role: 'admin', profiles: ['default'] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'profile-user', profiles: ['default'] }) + const storage = groupServer.getStorage() + storage.saveRoom('room-profile', 'Profile Room', 'ROOM1', { ownerAuthUserId: 7 }) + storage.addRoomAgent('room-profile', 'agent-1', 'default', 'Worker', '', 0) + + const client = await connectGroupChatClient(port, 'ignored-profile-user', 'Profile User', { token: 'profile-token' }) + harness.sockets.push(client) + + await expect(emitAck(client, 'join', { roomId: 'room-profile' })).resolves.toEqual({ error: 'Room not found' }) + expect(storage.getMemberByAuthUserId('room-profile', 42)).toBeNull() + expect(storage.findActiveActorByAuthUserId('room-profile', 42)).toBeNull() + + await expect(emitAck(client, 'join', { + roomId: 'room-profile', + inviteCode: 'ROOM1', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-profile' })) + expect(storage.getMemberByAuthUserId('room-profile', 42)).not.toBeNull() + }) + + it('preserves authoritative authenticated grants during an ordinary authorized reconnect', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const actor = storage.ensureAuthenticatedHumanActor({ + roomId: 'room-1', + authUserId: 42, + userId: 'auth:42', + userName: 'Manager', + description: '', + avatar: '', + capabilities: ['room.read', 'room.write', 'room.manage'], + }) + const authorizationRevision = actor.authorizationRevision + + const admission = storage.admitHumanMember({ + roomId: 'room-1', + userId: 'new-routing-id', + requestedName: 'Manager', + requestedDescription: '', + avatar: '', + authUser: { id: 42, username: 'manager', role: 'admin', profiles: [] }, + }) + + expect(admission.status).toBe('admitted') + expect(storage.getActorCapabilities(actor.id)).toEqual([ + 'room.read', + 'room.write', + 'room.manage', + ]) + expect(storage.findActiveActorByAuthUserId('room-1', 42)?.authorizationRevision).toBe(authorizationRevision) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 42) as { count: number }).count).toBe(1) + expect(storage.getMemberByAuthUserId('room-1', 42)?.userId).toBe('new-routing-id') + }) + + it('repairs a legacy authenticated member row on invite-less rejoin without creating a duplicate', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-legacy', 'Legacy Room', 'LEGACY') + const now = Date.now() + harness.db.prepare( + `INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run('member-legacy-42', 'room-legacy', 'auth:42', 'Legacy Name', '', now, now, '', null) + + const admission = storage.admitHumanMember({ + roomId: 'room-legacy', + userId: 'auth:42', + requestedName: 'Updated Name', + requestedDescription: '', + avatar: '', + authUser: { id: 42, username: 'legacy', role: 'admin', profiles: [] }, + }) + + expect(admission.status).toBe('admitted') + expect(harness.db.prepare( + 'SELECT userId, userName, authUserId FROM gc_room_members WHERE roomId = ?', + ).all('room-legacy')).toEqual([ + { userId: 'auth:42', userName: 'Updated Name', authUserId: 42 }, + ]) + expect(storage.findActiveActorByAuthUserId('room-legacy', 42)).not.toBeNull() + expect(storage.getActorCapabilities(storage.findActiveActorByAuthUserId('room-legacy', 42)!.id)).toContain('room.read') + }) + + it('adds invite read authority without deleting an authenticated actor\'s other grants', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const actor = storage.ensureAuthenticatedHumanActor({ + roomId: 'room-1', + authUserId: 42, + userId: 'auth:42', + userName: 'Writer', + description: '', + avatar: '', + capabilities: ['room.write'], + }) + harness.db.prepare('DELETE FROM gc_room_members WHERE roomId = ?').run('room-1') + + const admission = storage.admitHumanMember({ + roomId: 'room-1', + userId: 'routing-id', + inviteCode: 'ROOM1', + requestedName: 'Writer', + requestedDescription: '', + avatar: '', + authUser: { id: 42, username: 'writer', role: 'admin', profiles: [] }, + }) + + expect(admission.status).toBe('admitted') + expect(storage.getActorCapabilities(actor.id)).toEqual(['room.read', 'room.write']) + }) + + it('keeps one active actor while authorizing every concurrent socket for that user', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'shared-token') { + return { id: 11, username: 'alice', role: 'admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 11, username: 'alice' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1', { ownerAuthUserId: 11 }) + + const first = await connectGroupChatClient(port, 'local-a', 'Alice A', { token: 'shared-token' }) + const second = await connectGroupChatClient(port, 'local-b', 'Alice B', { token: 'shared-token' }) + harness.sockets.push(first, second) + + await Promise.all([ + emitAck(first, 'join', { roomId: 'room-1' }), + emitAck(second, 'join', { roomId: 'room-1' }), + ]) + + await expect(emitAck(first, 'message', { + roomId: 'room-1', + id: 'multi-socket-first', + content: 'from first socket', + })).resolves.toEqual({ id: 'multi-socket-first' }) + await expect(emitAck(second, 'message', { + roomId: 'room-1', + id: 'multi-socket-second', + content: 'from second socket', + })).resolves.toEqual({ id: 'multi-socket-second' }) + + let memberLeftEvents = 0 + second.on('member_left', () => { memberLeftEvents += 1 }) + first.disconnect() + await new Promise(resolve => setTimeout(resolve, 30)) + expect(memberLeftEvents).toBe(0) + await expect(emitAck(second, 'message', { + roomId: 'room-1', + id: 'multi-socket-survivor', + content: 'still joined', + })).resolves.toEqual({ id: 'multi-socket-survivor' }) + + const rows = harness.db.prepare( + `SELECT id FROM gc_room_actors WHERE roomId = ? AND authUserId = ? AND active = 1` + ).all('room-1', 11) as Array<{ id: string }> + + expect(rows).toHaveLength(1) + }) + + it('revalidates account status for every realtime ingress and recipient decision', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'revoked-token') { + return { id: 21, username: 'revoked', role: 'super_admin', profiles: [] } as any + } + if (token === 'observer-token') { + return { id: 23, username: 'observer', role: 'super_admin', profiles: [] } as any + } + return null + }) + seedAuthenticatedUser(harness.db, { id: 21, username: 'revoked', role: 'super_admin' }) + seedAuthenticatedUser(harness.db, { id: 23, username: 'observer', role: 'super_admin' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + + const authenticated = await connectGroupChatClient(port, 'ignored-auth', 'Revoked', { token: 'revoked-token' }) + const observer = await connectGroupChatClient(port, 'ignored-observer', 'Observer', { token: 'observer-token' }) + harness.sockets.push(authenticated, observer) + await emitAck(authenticated, 'join', { roomId: 'room-1' }) + await emitAck(observer, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + let leaked = false + authenticated.on('message', () => { leaked = true }) + harness.db.prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?").run(Date.now(), 21) + await emitAck(observer, 'message', { roomId: 'room-1', id: 'post-disable', content: 'secret after disable' }) + await new Promise(resolve => setTimeout(resolve, 30)) + + expect(leaked).toBe(false) + await expect(emitAck(authenticated, 'message', { + roomId: 'room-1', + id: 'revoked-write', + content: 'must fail', + })).resolves.toEqual({ error: 'Not in room' }) + }) + + it('tombstones account authority and disconnects every socket on explicit revocation', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'delete-token') { + return { id: 22, username: 'deleted', role: 'super_admin', profiles: [] } as any + } + return null + }) + seedAuthenticatedUser(harness.db, { id: 22, username: 'deleted', role: 'super_admin' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1', { ownerAuthUserId: 22 }) + const authenticated = await connectGroupChatClient(port, 'ignored-delete', 'Deleted', { token: 'delete-token' }) + harness.sockets.push(authenticated) + await emitAck(authenticated, 'join', { roomId: 'room-1' }) + expect(groupServer.getStorage().findActiveActorByAuthUserId('room-1', 22)).not.toBeNull() + + groupServer.revokeAuthenticatedUser(22) + await new Promise(resolve => setTimeout(resolve, 30)) + + expect(authenticated.connected).toBe(false) + expect(groupServer.getStorage().findActiveActorByAuthUserId('room-1', 22)).toBeNull() + expect(groupServer.getStorage().getRoom('room-1')?.ownerAuthUserId).toBeNull() + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 22) as { count: number }).count).toBe(0) + }) + + it('rejects invite-less local rejoins when a client forges an existing local user id without a server-issued credential', async () => { + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + + const original = await connectGroupChatClient(port, 'forged-local', 'Original') + harness.sockets.push(original) + await expect(emitAck(original, 'join', { + roomId: 'room-1', + inviteCode: 'ROOM1', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-1' })) + original.disconnect() + + const forged = await connectGroupChatClient(port, 'forged-local', 'Forged') + harness.sockets.push(forged) + + await expect(emitAck(forged, 'join', { + roomId: 'room-1', + })).resolves.toEqual({ error: 'Room not found' }) + }) + + it('preserves unauthenticated reconnects with a server-issued local credential instead of client userId authority', async () => { + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + + const first = await connectGroupChatClient(port, 'ignored-local-a', 'Original') + harness.sockets.push(first) + const issuedIdentity = await once<{ localCredential: string }>(first, 'local_identity') + + await expect(emitAck(first, 'join', { + roomId: 'room-1', + inviteCode: 'ROOM1', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-1' })) + first.disconnect() + + const second = await connectGroupChatClient(port, 'ignored-local-b', 'Reconnect', { + localCredential: issuedIdentity.localCredential, + }) + harness.sockets.push(second) + + await expect(emitAck(second, 'join', { + roomId: 'room-1', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-1' })) + + const localActor = harness.db.prepare( + `SELECT id FROM gc_room_actors WHERE roomId = ? AND actorType = 'local' AND active = 1`, + ).get('room-1') as { id: string } + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(localActor.id) + + await expect(emitAck(second, 'message', { + roomId: 'room-1', + content: 'must be denied', + })).resolves.toEqual({ error: 'Access denied' }) + second.disconnect() + + const deniedReconnect = await connectGroupChatClient(port, 'ignored-local-c', 'Denied Reconnect', { + localCredential: issuedIdentity.localCredential, + }) + harness.sockets.push(deniedReconnect) + await expect(emitAck(deniedReconnect, 'join', { + roomId: 'room-1', + })).resolves.toEqual({ error: 'Room not found' }) + }) + + it('derives local routing identity server-side and cannot collide with an authenticated actor', async () => { + seedAuthenticatedUser(harness.db, { id: 42, username: 'authenticated', role: 'super_admin' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => token === 'auth-token' + ? { id: 42, username: 'authenticated', role: 'super_admin', profiles: [] } as any + : null) + + const authenticated = await connectGroupChatClient(port, 'ignored-auth-routing', 'Authenticated', { token: 'auth-token' }) + harness.sockets.push(authenticated) + await expect(emitAck(authenticated, 'join', { roomId: 'room-1' })).resolves.toEqual( + expect.objectContaining({ roomId: 'room-1' }), + ) + + vi.mocked(isAuthEnabled).mockResolvedValue(false) + const local = await connectGroupChatClient(port, 'auth:42', 'Local impostor') + harness.sockets.push(local) + const localIdentity = await once<{ localCredential: string; userId: string }>(local, 'local_identity') + const joined = await emitAck(local, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + expect(localIdentity.userId).toMatch(/^local-user:[0-9a-f]{64}$/) + expect(localIdentity.userId).not.toBe('auth:42') + expect(joined.members.map((member: { userId: string }) => member.userId).sort()).toEqual( + ['auth:42', localIdentity.userId].sort(), + ) + expect(harness.db.prepare( + 'SELECT userId, userName, authUserId FROM gc_room_members WHERE roomId = ? ORDER BY userId', + ).all('room-1')).toEqual([ + { userId: 'auth:42', userName: 'Authenticated', authUserId: 42 }, + { userId: localIdentity.userId, userName: 'Local impostor', authUserId: null }, + ].sort((left, right) => left.userId.localeCompare(right.userId))) + + const delivered = once(authenticated, 'message') + await expect(emitAck(local, 'message', { + roomId: 'room-1', + id: 'local-attribution-message', + content: 'local attribution', + })).resolves.toEqual({ id: 'local-attribution-message' }) + await expect(delivered).resolves.toEqual(expect.objectContaining({ + senderId: localIdentity.userId, + senderName: 'Local impostor', + })) + }) + + it('keeps distinct local subjects separate when they request the same routing id', async () => { + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + const first = await connectGroupChatClient(port, 'shared-client-routing', 'Local A') + const second = await connectGroupChatClient(port, 'shared-client-routing', 'Local B') + harness.sockets.push(first, second) + const firstIdentity = await once<{ localCredential: string; userId: string }>(first, 'local_identity') + const secondIdentity = await once<{ localCredential: string; userId: string }>(second, 'local_identity') + + await emitAck(first, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + const joined = await emitAck(second, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + expect(firstIdentity.userId).toMatch(/^local-user:[0-9a-f]{64}$/) + expect(secondIdentity.userId).toMatch(/^local-user:[0-9a-f]{64}$/) + expect(secondIdentity.userId).not.toBe(firstIdentity.userId) + expect(joined.members.map((member: { userId: string }) => member.userId).sort()).toEqual( + [firstIdentity.userId, secondIdentity.userId].sort(), + ) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ?', + ).get('room-1') as { count: number }).count).toBe(2) + }) + + it('deduplicates multiple sockets for the same verified local subject', async () => { + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + const first = await connectGroupChatClient(port, 'first-client-routing', 'Local A') + harness.sockets.push(first) + const firstIdentity = await once<{ localCredential: string; userId: string }>(first, 'local_identity') + await emitAck(first, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + const second = await connectGroupChatClient(port, 'second-client-routing', 'Local A second tab', { + localCredential: firstIdentity.localCredential, + }) + harness.sockets.push(second) + const secondIdentity = await once<{ localCredential: string; userId: string }>(second, 'local_identity') + const joined = await emitAck(second, 'join', { roomId: 'room-1' }) + + expect(secondIdentity.userId).toBe(firstIdentity.userId) + expect(joined.members.filter((member: { userId: string }) => member.userId === firstIdentity.userId)).toHaveLength(1) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND userId = ?', + ).get('room-1', firstIdentity.userId) as { count: number }).count).toBe(1) + const room = (groupServer as any).rooms.get('room-1') + expect(room.hasOnlineMember(first.id!)).toBe(true) + expect(room.hasOnlineMember(second.id!)).toBe(true) + }) + + it('does not restore a local actor\'s removed grants when identity metadata is re-ensured', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const input = { + roomId: 'room-1', + localSubjectId: 'local-subject-1', + userId: 'routing-id', + userName: 'Local User', + description: '', + avatar: '', + } + const actor = storage.ensureLocalActor(input) + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + + storage.ensureLocalActor({ ...input, description: 'updated metadata' }) + + expect(storage.getActorCapabilities(actor.id)).toEqual([]) + }) + + it('persists local default grants only when a valid invite explicitly re-admits the actor', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const actor = storage.ensureLocalActor({ + roomId: 'room-1', + localSubjectId: 'local-subject-1', + userId: 'routing-id', + userName: 'Local User', + description: '', + avatar: '', + }) + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + + const admission = storage.admitHumanMember({ + roomId: 'room-1', + localSubjectId: 'local-subject-1', + userId: 'new-routing-id', + inviteCode: 'ROOM1', + requestedName: 'Local User', + requestedDescription: '', + avatar: '', + }) + + expect(admission.status).toBe('admitted') + expect(storage.getActorCapabilities(actor.id)).toEqual([ + 'room.read', + 'room.write', + 'room.type', + 'room.manage', + 'agent.invoke', + 'approval.respond', + ]) + }) + + it('bumps actor revisions only when capabilities or projection metadata change', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const baseInput = { + roomId: 'room-1', + authUserId: 42, + userId: 'auth:42', + userName: 'Read Only', + description: '', + avatar: '', + capabilities: ['room.read'], + } + + const created = storage.ensureAuthenticatedHumanActor(baseInput) + const unchanged = storage.ensureAuthenticatedHumanActor(baseInput) + const metadataChanged = storage.ensureAuthenticatedHumanActor({ + ...baseInput, + description: 'updated', + }) + const capabilitiesChanged = storage.ensureAuthenticatedHumanActor({ + ...baseInput, + description: 'updated', + capabilities: [], + }) + const idempotent = storage.ensureAuthenticatedHumanActor({ + ...baseInput, + description: 'updated', + capabilities: [], + }) + + expect(created).toMatchObject({ authorizationRevision: 0, contextRevision: 0 }) + expect(unchanged).toMatchObject({ authorizationRevision: 0, contextRevision: 0 }) + expect(metadataChanged).toMatchObject({ authorizationRevision: 0, contextRevision: 1 }) + expect(capabilitiesChanged).toMatchObject({ authorizationRevision: 1, contextRevision: 1 }) + expect(idempotent).toMatchObject({ authorizationRevision: 1, contextRevision: 1 }) + }) + + it('does not create actor rows when read lookups miss', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + + const before = harness.db.prepare( + `SELECT COUNT(*) AS count FROM gc_room_actors` + ).get() as { count: number } + + expect(storage.findActiveActorByAuthUserId('room-1', 9)).toBeNull() + expect(storage.findActiveActorByAgentIdentity('room-1', 'agent-missing')).toBeNull() + expect(storage.findActiveActorByLocalSubjectId('room-1', 'local-missing')).toBeNull() + expect(storage.findActiveActorBySystemKey('room-1', 'room-system')).toBeNull() + + const after = harness.db.prepare( + `SELECT COUNT(*) AS count FROM gc_room_actors` + ).get() as { count: number } + + expect(after.count).toBe(before.count) + }) + + it('uses cryptographic room seeds and advances room revisions for authorization/config mutations', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const created = storage.getRoom('room-1') + + expect(created?.sessionSeed).toMatch(/^[0-9a-f]{32}$/) + expect(created?.authorizationRevision).toBe(0) + + storage.setRoomOwnerAuthUserId('room-1', 7) + expect(storage.getRoom('room-1')?.authorizationRevision).toBe(1) + storage.setRoomOwnerAuthUserId('room-1', 7) + expect(storage.getRoom('room-1')?.authorizationRevision).toBe(1) + + storage.updateRoomConfig('room-1', { triggerTokens: 2048 }) + expect(storage.getRoom('room-1')?.authorizationRevision).toBe(2) + + storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + expect(storage.getRoom('room-1')?.authorizationRevision).toBe(3) + + const beforeWorkspace = storage.getRoom('room-1')?.sessionSeed + storage.updateRoomWorkspace('room-1', '/tmp/workspace') + const afterWorkspace = storage.getRoom('room-1')?.sessionSeed + expect(afterWorkspace).toMatch(/^[0-9a-f]{32}$/) + expect(afterWorkspace).not.toBe(beforeWorkspace) + + storage.clearRoomContext('room-1') + expect(storage.getRoom('room-1')?.sessionSeed).toMatch(/^[0-9a-f]{32}$/) + expect(storage.getRoom('room-1')?.sessionSeed).not.toBe(afterWorkspace) + }) + + it('changes public bridge session fingerprints when actor or room revisions change', () => { + const base = (groupBridgeSessionId as any)( + 'room-1', + 'default', + 'Worker', + '11111111111111111111111111111111', + { + actorId: 'actor-1', + actorAuthorizationRevision: 0, + actorContextRevision: 0, + roomAuthorizationRevision: 0, + }, + ) + const authRevision = (groupBridgeSessionId as any)( + 'room-1', + 'default', + 'Worker', + '11111111111111111111111111111111', + { + actorId: 'actor-1', + actorAuthorizationRevision: 1, + actorContextRevision: 0, + roomAuthorizationRevision: 0, + }, + ) + const roomRevision = (groupBridgeSessionId as any)( + 'room-1', + 'default', + 'Worker', + '11111111111111111111111111111111', + { + actorId: 'actor-1', + actorAuthorizationRevision: 0, + actorContextRevision: 0, + roomAuthorizationRevision: 1, + }, + ) + + expect(base).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(base).not.toContain('room-1') + expect(base).not.toContain('default') + expect(base).not.toContain('Worker') + expect(base).not.toContain('11111111111111111111111111111111') + expect(base).not.toContain('actor-1') + expect(base).not.toBe(authRevision) + expect(base).not.toBe(roomRevision) + }) + + it('keeps the complete session id opaque and rotates it when the actor incarnation changes', () => { + const first = (groupBridgeSessionId as any)( + 'room-1', + 'default', + 'Worker', + '11111111111111111111111111111111', + { + actorId: 'actor-secret-1', + actorAuthorizationRevision: 0, + actorContextRevision: 0, + roomAuthorizationRevision: 0, + }, + ) + const second = (groupBridgeSessionId as any)( + 'room-1', + 'default', + 'Worker', + '11111111111111111111111111111111', + { + actorId: 'actor-secret-2', + actorAuthorizationRevision: 0, + actorContextRevision: 0, + roomAuthorizationRevision: 0, + }, + ) + + const firstSuffix = first.match(/_h_([0-9a-f]+)$/)?.[1] ?? '' + const secondSuffix = second.match(/_h_([0-9a-f]+)$/)?.[1] ?? '' + + expect(first).not.toContain('actor-secret-1') + expect(second).not.toContain('actor-secret-2') + expect(first).not.toBe(second) + expect(firstSuffix).toHaveLength(32) + expect(secondSuffix).toHaveLength(32) + }) +}) diff --git a/tests/server/group-chat-actor-retention.test.ts b/tests/server/group-chat-actor-retention.test.ts new file mode 100644 index 0000000000..bfc375deac --- /dev/null +++ b/tests/server/group-chat-actor-retention.test.ts @@ -0,0 +1,401 @@ +import Koa from 'koa' +import bodyParser from '@koa/bodyparser' +import { createServer, type Server as HttpServer } from 'http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const interruptMock = vi.fn(async () => ({ ok: true })) +const destroyMock = vi.fn(async () => ({ ok: true })) + +vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({ + AgentBridgeClient: vi.fn().mockImplementation(() => ({ + interrupt: interruptMock, + destroy: destroyMock, + })), +})) + +import { + createTestGroupChatServer, + currentRoomAgentSessionId, + seedAuthenticatedUser, +} from './group-chat-test-helpers' +import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' +import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' +import { groupBridgeSummarySessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' + +function listen(server: HttpServer): Promise { + return new Promise(resolve => server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (!addr || typeof addr === 'string') throw new Error('missing address') + resolve(`http://127.0.0.1:${addr.port}`) + })) +} + +describe('group chat actor retention', () => { + let harness: Awaited> + let groupServer: GroupChatServer + let httpServer: HttpServer + let baseUrl: string + + beforeEach(async () => { + interruptMock.mockReset() + interruptMock.mockResolvedValue({ ok: true }) + destroyMock.mockReset() + destroyMock.mockResolvedValue({ ok: true }) + harness = await createTestGroupChatServer() + seedAuthenticatedUser(harness.db, { id: 1, username: 'root', role: 'super_admin' }) + groupServer = harness.groupServer + setGroupChatServer(groupServer) + + const app = new Koa() + app.use(async (ctx, next) => { + ctx.state.user = { id: 1, username: 'root', role: 'super_admin', profiles: [] } + await next() + }) + app.use(bodyParser()) + app.use(groupChatRoutes.routes()) + httpServer = createServer(app.callback()) + baseUrl = await listen(httpServer) + }) + + afterEach(() => { + httpServer?.close() + harness?.cleanup() + setGroupChatServer(null as any) + }) + + it('atomically grants a signed local creator without persisting the authority id as a member routing id', () => { + const storage = groupServer.getStorage() + storage.createRoomWithOwner({ + id: 'room-local-owner', + name: 'Local room', + inviteCode: 'LOCAL1', + owner: { + kind: 'local', + localSubjectId: 'local:11111111111111111111111111111111', + username: 'Local user', + }, + }) + + const room = storage.getRoom('room-local-owner') + const actor = storage.findActiveActorByLocalSubjectId( + 'room-local-owner', + 'local:11111111111111111111111111111111', + ) + expect(room).toMatchObject({ id: 'room-local-owner', ownerAuthUserId: null }) + expect(actor).toMatchObject({ actorType: 'local', active: 1 }) + expect(actor && storage.getActorCapabilities(actor.id)).toEqual([ + 'room.read', + 'room.write', + 'room.type', + 'room.manage', + 'agent.invoke', + 'approval.respond', + ]) + expect(storage.getRoomMembers('room-local-owner')).toEqual([]) + }) + + it('atomically registers active Bridge sessions and rejects post-revocation registration', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const agent = storage.addRoomAgent('room-1', 'agent-1', 'reviewer', 'Worker', '', 0) + const actor = storage.findActiveActorByAgentIdentity('room-1', 'agent-1') + const room = storage.getRoom('room-1') + expect(actor).not.toBeNull() + expect(room).not.toBeNull() + if (!actor || !room || typeof room.sessionSeed !== 'string') { + throw new Error('missing active room agent identity') + } + const sessionId = currentRoomAgentSessionId( + groupServer, + 'room-1', + 'agent-1', + 'reviewer', + 'Worker', + ) + const registration = { + sessionId, + roomId: 'room-1', + agentId: 'agent-1', + profileName: 'reviewer', + agentName: 'Worker', + sessionSeed: room.sessionSeed, + roomAuthorizationRevision: room.authorizationRevision, + actorId: actor.id, + actorAuthorizationRevision: actor.authorizationRevision, + actorContextRevision: actor.contextRevision, + } + + expect(storage.registerSessionProfileForActiveAgent(registration)).toBe(true) + expect(storage.getSessionProfile(sessionId)).toEqual(expect.objectContaining({ + session_id: sessionId, + room_id: 'room-1', + agent_id: 'agent-1', + profile_name: 'reviewer', + })) + + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + const missingGrantSessionId = `${sessionId.slice(0, -1)}${sessionId.endsWith('a') ? 'b' : 'a'}` + expect(storage.registerSessionProfileForActiveAgent({ + ...registration, + sessionId: missingGrantSessionId, + })).toBe(false) + expect(storage.getSessionProfile(missingGrantSessionId)).toBeNull() + expect(storage.registerSessionProfileForActiveAgent({ + ...registration, + sessionId: missingGrantSessionId, + requireRunCapabilities: false, + })).toBe(true) + expect(storage.getSessionProfile(missingGrantSessionId)).not.toBeNull() + storage.deleteSessionProfile(missingGrantSessionId) + + const removed = storage.removeAgentActorWithRetention('room-1', agent.id) + expect(removed?.sessionProfiles).toEqual([ + expect.objectContaining({ session_id: sessionId, profile_name: 'reviewer' }), + ]) + expect(harness.db.prepare( + 'SELECT session_id, profile_name, status FROM gc_pending_session_deletes WHERE session_id = ?', + ).get(sessionId)).toEqual({ session_id: sessionId, profile_name: 'reviewer', status: 'pending' }) + + const lateSessionId = `${sessionId.slice(0, -1)}${sessionId.endsWith('a') ? 'b' : 'a'}` + expect(storage.registerSessionProfileForActiveAgent({ + ...registration, + sessionId: lateSessionId, + })).toBe(false) + expect(storage.getSessionProfile(lateSessionId)).toBeNull() + }) + + it('atomically registers summary sessions with a crash cleanup intent', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + storage.addRoomAgent('room-1', 'agent-1', 'reviewer', 'Worker', '', 0) + const actor = storage.findActiveActorByAgentIdentity('room-1', 'agent-1') + const room = storage.getRoom('room-1') + if (!actor || !room) throw new Error('missing active room agent identity') + const revisions = { + actorId: actor.id, + roomAuthorizationRevision: room.authorizationRevision, + actorAuthorizationRevision: actor.authorizationRevision, + actorContextRevision: actor.contextRevision, + } + const sessionId = groupBridgeSummarySessionId('room-1', 'reviewer', 'Worker', room.sessionSeed, revisions) + const registeredAt = Date.now() + const registration = { + sessionId, + roomId: 'room-1', + agentId: 'agent-1', + profileName: 'reviewer', + agentName: 'Worker', + sessionSeed: room.sessionSeed, + ...revisions, + cleanupAfterMs: 600_000, + } + + expect(storage.registerSessionProfileForActiveAgent(registration)).toBe(true) + expect(storage.getSessionProfile(sessionId)).toEqual(expect.objectContaining({ + session_id: sessionId, + room_id: 'room-1', + agent_id: 'agent-1', + profile_name: 'reviewer', + })) + const cleanup = harness.db.prepare( + 'SELECT session_id, profile_name, status, next_attempt_at FROM gc_pending_session_deletes WHERE session_id = ?', + ).get(sessionId) as { session_id: string; profile_name: string; status: string; next_attempt_at: number } + expect(cleanup).toEqual(expect.objectContaining({ + session_id: sessionId, + profile_name: 'reviewer', + status: 'pending', + })) + expect(cleanup.next_attempt_at).toBeGreaterThanOrEqual(registeredAt + 600_000) + + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + const deniedSessionId = groupBridgeSummarySessionId('room-1', 'reviewer', 'Worker', room.sessionSeed, revisions) + expect(storage.registerSessionProfileForActiveAgent({ ...registration, sessionId: deniedSessionId })).toBe(false) + expect(storage.getSessionProfile(deniedSessionId)).toBeNull() + expect(harness.db.prepare( + 'SELECT 1 AS present FROM gc_pending_session_deletes WHERE session_id = ?', + ).get(deniedSessionId)).toBeUndefined() + }) + + it('keeps an inactive tombstone and removes grants/session mappings when an agent is deleted', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const agent = storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + const actor = storage.findActiveActorByAgentIdentity('room-1', 'agent-1') + expect(actor).not.toBeNull() + if (!actor) throw new Error('missing active agent actor') + storage.saveSessionProfile('session-1', 'room-1', 'agent-1', 'default') + + const response = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/${agent.id}`, { + method: 'DELETE', + }) + + expect(response.status).toBe(200) + + const actorRow = harness.db.prepare( + `SELECT id, active, authUserId, agentId, localSubjectId, systemKey, name, description, avatar, tombstonedAt + FROM gc_room_actors + WHERE id = ?` + ).get(actor.id) as { + id: string + active: number + authUserId: number | null + agentId: string | null + localSubjectId: string | null + systemKey: string | null + name: string + description: string + avatar: string + tombstonedAt: number | null + } | undefined + const capabilityRows = harness.db.prepare( + `SELECT capability FROM gc_room_actor_capabilities WHERE roomId = ? AND actorId = ?` + ).all('room-1', actor.id) as Array<{ capability: string }> + const sessionRows = harness.db.prepare( + `SELECT session_id FROM gc_session_profiles WHERE room_id = ? AND agent_id = ?` + ).all('room-1', 'agent-1') as Array<{ session_id: string }> + const pendingRows = harness.db.prepare( + `SELECT session_id, profile_name, status FROM gc_pending_session_deletes WHERE session_id = ?` + ).all('session-1') as Array<{ session_id: string; profile_name: string; status: string }> + + expect(actorRow).toEqual(expect.objectContaining({ + id: actor.id, + active: 0, + authUserId: null, + agentId: null, + localSubjectId: null, + systemKey: null, + name: 'Deleted agent', + description: '', + avatar: '', + tombstonedAt: expect.any(Number), + })) + expect(capabilityRows).toEqual([]) + expect(sessionRows).toEqual([]) + expect(pendingRows).toEqual([{ session_id: 'session-1', profile_name: 'default', status: 'pending' }]) + expect(interruptMock).toHaveBeenCalledWith('session-1', 'Interrupted by group chat user', 'default') + expect(destroyMock).toHaveBeenCalledWith('session-1', 'default') + }) + + it('deletes room actors, grants, and session mappings while retaining session cleanup outbox rows', () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + storage.ensureAuthenticatedHumanActor({ + roomId: 'room-1', + authUserId: 42, + userId: 'auth:42', + userName: 'Read Only', + description: '', + avatar: '', + capabilities: ['room.read'], + }) + storage.saveSessionProfile('session-1', 'room-1', 'agent-1', 'default') + + storage.deleteRoom('room-1') + + for (const [table, column] of [ + ['gc_room_actor_capabilities', 'roomId'], + ['gc_room_actors', 'roomId'], + ['gc_session_profiles', 'room_id'], + ['gc_room_agents', 'roomId'], + ['gc_room_members', 'roomId'], + ['gc_rooms', 'id'], + ] as const) { + const row = harness.db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE ${column} = ?`).get('room-1') as { count: number } + expect(row.count, table).toBe(0) + } + expect(harness.db.prepare( + 'SELECT session_id, profile_name, status FROM gc_pending_session_deletes WHERE session_id = ?', + ).get('session-1')).toEqual({ session_id: 'session-1', profile_name: 'default', status: 'pending' }) + + storage.saveRoom('room-1', 'Replacement', 'ROOM2') + expect(storage.findActiveActorByAuthUserId('room-1', 42)).toBeNull() + expect(storage.findActiveActorByAgentIdentity('room-1', 'agent-1')).toBeNull() + }) + + it('treats repeated agent deletion as idempotent', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const agent = storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + + const first = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/${agent.id}`, { + method: 'DELETE', + }) + const second = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/${agent.id}`, { + method: 'DELETE', + }) + + expect(first.status).toBe(200) + expect(second.status).toBe(200) + await expect(second.json()).resolves.toEqual({ + success: true, + agents: [], + members: [], + }) + }) + + it('keeps authority revoked and the durable outbox pending when runtime cleanup fails', async () => { + interruptMock.mockRejectedValueOnce(new Error('bridge unavailable')) + destroyMock.mockRejectedValueOnce(new Error('bridge destroy unavailable')) + + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const agent = storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + const actor = storage.findActiveActorByAgentIdentity('room-1', 'agent-1') + expect(actor).not.toBeNull() + if (!actor) throw new Error('missing active agent actor') + storage.saveSessionProfile('session-1', 'room-1', 'agent-1', 'default') + + const response = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/${agent.id}`, { + method: 'DELETE', + }) + + expect(response.status).toBe(200) + expect(harness.db.prepare( + `SELECT active, agentId, tombstonedAt FROM gc_room_actors WHERE id = ?` + ).get(actor.id)).toEqual({ + active: 0, + agentId: null, + tombstonedAt: expect.any(Number), + }) + expect(harness.db.prepare( + `SELECT session_id, profile_name, status FROM gc_pending_session_deletes WHERE session_id = ?` + ).get('session-1')).toEqual({ + session_id: 'session-1', + profile_name: 'default', + status: 'pending', + }) + expect(interruptMock).toHaveBeenCalledWith('session-1', 'Interrupted by group chat user', 'default') + expect(destroyMock).toHaveBeenCalledWith('session-1', 'default') + }) + + it('creates a new actor incarnation when the same agent identity is re-added after deletion', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const agent = storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + storage.ensureAgentActor('room-1', 'agent-1', 'Worker', '') + + const removed = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/${agent.id}`, { + method: 'DELETE', + }) + + expect(removed.status).toBe(200) + + storage.addRoomAgent('room-1', 'agent-1', 'default', 'Worker', '', 0) + storage.ensureAgentActor('room-1', 'agent-1', 'Worker', '') + + const actorRows = harness.db.prepare( + `SELECT id, active, agentId, tombstonedAt, name FROM gc_room_actors WHERE roomId = ? ORDER BY createdAt` + ).all('room-1') as Array<{ id: string; active: number; agentId: string | null; tombstonedAt: number | null; name: string }> + + expect(actorRows).toHaveLength(2) + expect(actorRows.map(row => row.active)).toEqual([0, 1]) + expect(actorRows[0]?.id).not.toBe(actorRows[1]?.id) + expect(actorRows[0]?.tombstonedAt).toEqual(expect.any(Number)) + expect(actorRows[0]?.agentId).toBeNull() + expect(actorRows[0]?.name).toBe('Deleted agent') + expect(actorRows[1]?.tombstonedAt).toBeNull() + expect(actorRows[1]?.agentId).toBe('agent-1') + expect(actorRows[1]?.name).toBe('Worker') + }) +}) diff --git a/tests/server/group-chat-agent-routing-baseline.test.ts b/tests/server/group-chat-agent-routing-baseline.test.ts index f8b3c2bf5b..c3cf9a84d7 100644 --- a/tests/server/group-chat-agent-routing-baseline.test.ts +++ b/tests/server/group-chat-agent-routing-baseline.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connectGroupChatClient, + currentRoomAgentSessionId, createTestGroupChatServer, emitAck, + seedAuthenticatedUser, } from './group-chat-test-helpers' -import { GROUP_CHAT_AGENT_SOCKET_SECRET, groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' +import { GROUP_CHAT_AGENT_SOCKET_SECRET } from '../../packages/server/src/services/hermes/group-chat/agent-clients' import { authenticateUserToken, isAuthEnabled } from '../../packages/server/src/middleware/user-auth' import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' @@ -41,8 +43,7 @@ describe('group chat agent routing baseline', () => { } function currentAgentSessionId() { - const room = groupServer.getStorage().getRoom('room-1') - return groupBridgeSessionId('room-1', 'default', 'Worker', String(room?.sessionSeed || '0')) + return currentRoomAgentSessionId(groupServer, 'room-1', 'agent-worker', 'default', 'Worker') } it('routes human messages through mention processing', async () => { @@ -64,6 +65,7 @@ describe('group chat agent routing baseline', () => { if (token === 'read-only-token') return { id: 2, username: 'bob', role: 'admin', profiles: [] } as any return null }) + seedAuthenticatedUser(harness.db, { id: 2, username: 'bob' }) const human = await connectGroupChatClient(port, 'ignored-user', 'Bob', { token: 'read-only-token' }) const agent = await connectGroupChatClient(port, 'agent-worker', 'Worker', { source: 'agent', diff --git a/tests/server/group-chat-agent-workspace.test.ts b/tests/server/group-chat-agent-workspace.test.ts index 9bbb46ab2f..947c33ecdd 100644 --- a/tests/server/group-chat-agent-workspace.test.ts +++ b/tests/server/group-chat-agent-workspace.test.ts @@ -18,9 +18,10 @@ const mockSocket = vi.hoisted(() => ({ })) const bridgeMock = vi.hoisted(() => ({ - chat: vi.fn(async (_sessionId: string) => { + chat: vi.fn(async (...args: any[]) => { + const sessionId = String(args[0] || '') order.push('chat') - return { ok: true, run_id: 'bridge-run-id', session_id: _sessionId, status: 'running' } + return { ok: true, run_id: 'bridge-run-id', session_id: sessionId, status: 'running' } }), streamOutput: vi.fn(async function* (runId: string) { yield { @@ -64,9 +65,10 @@ describe('group chat agent workspace bridge runs', () => { vi.clearAllMocks() trackerMock.completeWorkspaceRunCheckpointDraft.mockReset() trackerMock.completeWorkspaceRunCheckpointDraft.mockReturnValue(null) - bridgeMock.chat.mockImplementation(async (_sessionId: string) => { + bridgeMock.chat.mockImplementation(async (...args: any[]) => { + const sessionId = String(args[0] || '') order.push('chat') - return { ok: true, run_id: 'bridge-run-id', session_id: _sessionId, status: 'running' } + return { ok: true, run_id: 'bridge-run-id', session_id: sessionId, status: 'running' } }) bridgeMock.streamOutput.mockImplementation(async function* (runId: string) { yield { @@ -108,32 +110,58 @@ describe('group chat agent workspace bridge runs', () => { } } - async function workerSessionId(seed = 'seed-1') { - const { groupBridgeSessionId } = await import('../../packages/server/src/services/hermes/group-chat/agent-clients') - return groupBridgeSessionId('room-1', 'default', 'Worker', seed) + const TEST_SESSION_IDENTITY = { + actorId: 'actor-agent-1', + roomAuthorizationRevision: 0, + actorAuthorizationRevision: 0, + actorContextRevision: 0, } - it('keeps the session key freshness suffix when long names force bridge session id truncation', async () => { + async function workerSessionId(seed = '11111111111111111111111111111111') { const { groupBridgeSessionId } = await import('../../packages/server/src/services/hermes/group-chat/agent-clients') + return groupBridgeSessionId('room-1', 'default', 'Worker', seed, TEST_SESSION_IDENTITY) + } + + it('keeps bridge session ids fixed-length and opaque for long and non-ASCII inputs', async () => { + const { groupBridgeSessionId, groupBridgeSummarySessionId } = await import('../../packages/server/src/services/hermes/group-chat/agent-clients') const longAgentName = 'Worker'.repeat(40) - const first = groupBridgeSessionId('room-1', 'default', longAgentName, 'seed-1') - const second = groupBridgeSessionId('room-1', 'default', longAgentName, 'seed-2') + expect(() => groupBridgeSessionId('room-1', 'default', 'Worker', '0')).toThrow(/cryptographic room seed/i) + + const first = groupBridgeSessionId('room-1', 'default', longAgentName, '11111111111111111111111111111111') + const second = groupBridgeSessionId('room-1', 'default', longAgentName, '22222222222222222222222222222222') + const summaryA = groupBridgeSummarySessionId('room-1', 'default', longAgentName, '11111111111111111111111111111111') + const summaryB = groupBridgeSummarySessionId('room-1', 'default', longAgentName, '11111111111111111111111111111111') const roomA = `room-${'a'.repeat(130)}` const roomB = `room-${'a'.repeat(129)}b` - const collidingPrefixA = groupBridgeSessionId(roomA, 'default', longAgentName, '0') - const collidingPrefixB = groupBridgeSessionId(roomB, 'default', longAgentName, '0') - - const nonAsciiA = groupBridgeSessionId('room-1', 'default', '丫鬟', '0') - const nonAsciiB = groupBridgeSessionId('room-1', 'default', '书童', '0') - - expect(first).toHaveLength(120) - expect(second).toHaveLength(120) + const collidingPrefixA = groupBridgeSessionId(roomA, 'default', longAgentName, '33333333333333333333333333333333') + const collidingPrefixB = groupBridgeSessionId(roomB, 'default', longAgentName, '33333333333333333333333333333333') + + const nonAsciiA = groupBridgeSessionId('room-1', 'default', '丫鬟', '33333333333333333333333333333333') + const nonAsciiB = groupBridgeSessionId('room-1', 'default', '书童', '33333333333333333333333333333333') + const nulBoundaryA = groupBridgeSessionId('room\0profile', 'agent', 'Worker', '33333333333333333333333333333333') + const nulBoundaryB = groupBridgeSessionId('room', 'profile\0agent', 'Worker', '33333333333333333333333333333333') + + expect(first).toHaveLength(37) + expect(second).toHaveLength(37) + expect(summaryA).toHaveLength(37) + expect(summaryB).toHaveLength(37) + expect(summaryA).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(summaryB).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(summaryA).not.toBe(summaryB) + expect(summaryA).not.toContain('room-1') + expect(summaryA).not.toContain('default') + expect(summaryA).not.toContain('Worker') expect(first).not.toBe(second) - expect(first).toMatch(/_h_[0-9a-f]{16}$/) - expect(second).toMatch(/_h_[0-9a-f]{16}$/) + expect(first).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(second).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(first).not.toContain('room-1') + expect(first).not.toContain('default') + expect(first).not.toContain('Worker') + expect(first).not.toContain('11111111111111111111111111111111') expect(collidingPrefixA).not.toBe(collidingPrefixB) expect(nonAsciiA).not.toBe(nonAsciiB) + expect(nulBoundaryA).not.toBe(nulBoundaryB) }) it('does not block room-wide interrupts for idle agents with no bridge session', async () => { @@ -148,7 +176,16 @@ describe('group chat agent workspace bridge runs', () => { invited: 0, backgroundDelegationEnabled: false, } as any) as any - const storage = { getRoom: vi.fn(() => ({ sessionSeed: 'seed-1', workspace: '' })) } + const storage = { + getRoom: vi.fn(() => ({ sessionSeed: '11111111111111111111111111111111', workspace: '', authorizationRevision: 0 })), + findActiveActorByAgentIdentity: vi.fn(() => ({ + id: TEST_SESSION_IDENTITY.actorId, + authorizationRevision: 0, + contextRevision: 0, + })), + getActorCapabilities: vi.fn(() => ['room.read', 'room.write']), + registerSessionProfileForActiveAgent: vi.fn(() => true), + } client.setStorage(storage as any) ;(clients as any).rooms.set('room-1', new Map([[client.agentId, client]])) @@ -228,7 +265,18 @@ describe('group chat agent workspace bridge runs', () => { backgroundDelegationEnabled: false, } as any) const storage = { - getRoom: vi.fn(() => ({ sessionSeed: 'seed-1', workspace })), + getRoom: vi.fn(() => ({ sessionSeed: '11111111111111111111111111111111', workspace, authorizationRevision: 0 })), + findActiveActorByAgentIdentity: vi.fn(() => ({ + id: TEST_SESSION_IDENTITY.actorId, + authorizationRevision: 0, + contextRevision: 0, + })), + getActorCapabilities: vi.fn(() => ['room.read', 'room.write']), + registerSessionProfileForActiveAgent: vi.fn(() => { + order.push('mapping') + return true + }), + enqueuePendingSessionDelete: vi.fn(), saveWorkspaceDiffMessageForRun: vi.fn(), updateRoomTotalTokens: vi.fn(), getMessagesForContext: vi.fn(() => []), @@ -241,6 +289,208 @@ describe('group chat agent workspace bridge runs', () => { return client as any } + it('durably registers the Bridge session before starting the external run', async () => { + const client = await createClient('') + const sessionId = await workerSessionId() + + await client.replyToMention('room-1', { + content: '@Worker hi', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + expect(client.__testStorage.registerSessionProfileForActiveAgent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId, + roomId: 'room-1', + agentId: 'agent-1', + profileName: 'default', + agentName: 'Worker', + }), + ) + expect(order.slice(0, 2)).toEqual(['mapping', 'chat']) + }) + + it('registers an opaque crash-cleanup lease before summary Bridge use', async () => { + const client = await createClient('') + client.__testStorage.getRoomMembers = vi.fn(() => []) + const { ContextAuthorizationChangedError } = await import('../../packages/server/src/services/hermes/context-engine/compressor') + let summarySessionId = '' + client.setContextEngine({ + buildContext: vi.fn(async (input: { summarySessionRegistrar: () => { sessionId: string; authorizationGuard: () => boolean; release: () => void } }) => { + const lease = input.summarySessionRegistrar() + summarySessionId = lease.sessionId + expect(summarySessionId).toMatch(/^gc_h_[0-9a-f]{32}$/) + expect(summarySessionId).not.toBe(await workerSessionId()) + expect(lease.authorizationGuard()).toBe(true) + client.__testStorage.getActorCapabilities.mockReturnValue([]) + expect(lease.authorizationGuard()).toBe(false) + lease.release() + throw new ContextAuthorizationChangedError() + }), + }) + + await client.replyToMention('room-1', { + content: '@Worker private prompt', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + expect(client.__testStorage.registerSessionProfileForActiveAgent).toHaveBeenCalledTimes(2) + expect(client.__testStorage.registerSessionProfileForActiveAgent).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: summarySessionId, + roomId: 'room-1', + agentId: 'agent-1', + profileName: 'default', + requireRunCapabilities: true, + cleanupAfterMs: 600_000, + }), + ) + expect(client.__testStorage.enqueuePendingSessionDelete).toHaveBeenCalledWith(summarySessionId, 'default') + expect(bridgeMock.chat).not.toHaveBeenCalled() + }) + + it('does not build or send private context when durable registration denies the agent', async () => { + const client = await createClient('') + const buildContext = vi.fn() + const onStatus = vi.fn() + client.__testStorage.registerSessionProfileForActiveAgent.mockReturnValue(false) + client.setContextEngine({ buildContext }) + + await client.replyToMention('room-1', { + content: '@Worker hi', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }, onStatus) + + expect(buildContext).not.toHaveBeenCalled() + expect(bridgeMock.chat).not.toHaveBeenCalled() + expect(mockSocket.emit).not.toHaveBeenCalledWith('typing', expect.anything()) + expect(onStatus).not.toHaveBeenCalled() + }) + + it('does not start private context construction when grants disappear during model resolution', async () => { + const client = await createClient('') + const originalProfile = (client as any).profile + const originalContextEngine = (client as any).contextEngine + ;(client as any).profile = 'revocation-during-model-resolution' + const buildContext = vi.fn() + client.setContextEngine({ buildContext }) + const { readConfigYamlForProfile } = await import('../../packages/server/src/services/config-helpers') + vi.mocked(readConfigYamlForProfile).mockImplementationOnce(async () => { + client.__testStorage.getActorCapabilities.mockReturnValue([]) + return { model: { default: 'model-a', provider: 'provider-a' } } + }) + + try { + await client.replyToMention('room-1', { + content: '@Worker private prompt', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + expect(buildContext).not.toHaveBeenCalled() + expect(bridgeMock.contextEstimate).not.toHaveBeenCalled() + expect(bridgeMock.chat).not.toHaveBeenCalled() + } finally { + client.__testStorage.getActorCapabilities.mockReturnValue(['room.read', 'room.write']) + ;(client as any).contextEngine = originalContextEngine + ;(client as any).profile = originalProfile + } + }) + + it('stops without degrading when the context engine reports an authorization change', async () => { + const client = await createClient('') + client.__testStorage.getRoomMembers = vi.fn(() => []) + const { ContextAuthorizationChangedError } = await import('../../packages/server/src/services/hermes/context-engine/compressor') + const buildContext = vi.fn(async (input: { authorizationGuard: () => boolean }) => { + expect(input.authorizationGuard()).toBe(true) + client.__testStorage.getActorCapabilities.mockReturnValue([]) + expect(input.authorizationGuard()).toBe(false) + throw new ContextAuthorizationChangedError() + }) + client.setContextEngine({ buildContext }) + + try { + await client.replyToMention('room-1', { + content: '@Worker private prompt', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + expect(buildContext).toHaveBeenCalledOnce() + expect(bridgeMock.contextEstimate).not.toHaveBeenCalled() + expect(bridgeMock.chat).not.toHaveBeenCalled() + } finally { + client.__testStorage.getActorCapabilities.mockReturnValue(['room.read', 'room.write']) + } + }) + + it('stops context estimation and Bridge chat when run grants disappear after registration', async () => { + const client = await createClient('') + client.__testStorage.getRoomMembers = vi.fn(() => []) + client.setContextEngine({ + buildContext: vi.fn(async ({ contextTokenEstimator }: { contextTokenEstimator: (history: Array<{ role: 'user' | 'assistant'; content: string }>, instructions: string) => Promise }) => { + client.__testStorage.getActorCapabilities.mockReturnValue([]) + await expect(contextTokenEstimator([], '')).resolves.toBeUndefined() + return { conversationHistory: [], instructions: '', meta: {} } + }), + }) + + await client.replyToMention('room-1', { + content: '@Worker private prompt', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + expect(bridgeMock.contextEstimate).not.toHaveBeenCalled() + expect(bridgeMock.chat).not.toHaveBeenCalled() + }) + + it('interrupts an in-flight Bridge run before relaying output when run grants disappear', async () => { + const client = await createClient('') + bridgeMock.interrupt.mockResolvedValueOnce({ ok: true, synced: false }) + bridgeMock.streamOutput.mockImplementationOnce(async function* (runId: string) { + client.__testStorage.getActorCapabilities.mockReturnValue([]) + yield { + ok: true, + run_id: runId, + session_id: await workerSessionId(), + status: 'complete', + delta: 'private result', + cursor: 1, + output: 'private result', + done: true, + events: [], + event_cursor: 0, + } + }) + + await client.replyToMention('room-1', { + content: '@Worker private prompt', + senderName: 'Alice', + senderId: 'user-1', + timestamp: 1, + }) + + const sessionId = await workerSessionId() + expect(bridgeMock.interrupt).toHaveBeenCalledWith( + sessionId, + 'Interrupted because group chat room state changed', + 'default', + ) + expect(bridgeMock.destroy).toHaveBeenCalledWith(sessionId, 'default') + expect(mockSocket.emit).not.toHaveBeenCalledWith('message_stream_delta', expect.objectContaining({ delta: 'private result' })) + expect(mockSocket.emit).not.toHaveBeenCalledWith('message', expect.objectContaining({ content: 'private result' }), expect.any(Function)) + }) + it('omits workspace when the room has no workspace', async () => { const client = await createClient('') @@ -294,8 +544,8 @@ describe('group chat agent workspace bridge runs', () => { const client = await createClient('/tmp/workspace') const storage = (client as any).__testStorage storage.getRoom - .mockReturnValueOnce({ sessionSeed: 'seed-1', workspace: '/tmp/workspace' }) - .mockReturnValue({ sessionSeed: 'seed-2', workspace: '/tmp/workspace' }) + .mockReturnValueOnce({ sessionSeed: '11111111111111111111111111111111', workspace: '/tmp/workspace' }) + .mockReturnValue({ sessionSeed: '22222222222222222222222222222222', workspace: '/tmp/workspace' }) await client.replyToMention('room-1', { content: '@Worker hi', @@ -343,7 +593,7 @@ describe('group chat agent workspace bridge runs', () => { runId: 'bridge-run-id', workspace: '/tmp/workspace', })) - expect(order.slice(0, 2)).toEqual(['chat', 'checkpoint']) + expect(order.slice(0, 3)).toEqual(['mapping', 'chat', 'checkpoint']) }) it('uses the bridge-assigned run_id when finalizing the workspace diff', async () => { @@ -450,7 +700,7 @@ describe('group chat agent workspace bridge runs', () => { it('discards workspace diff finalization when the room session generation changed', async () => { const client = await createClient('/tmp/workspace') - const staleSessionId = await workerSessionId('old-seed') + const staleSessionId = await workerSessionId('44444444444444444444444444444444') const runId = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' const state = client.beginWorkspaceDiffIfNeeded({ roomId: 'room-1', sessionId: staleSessionId, runId, workspace: '/tmp/workspace' }) const saveWorkspaceDiffMessageForRun = client.__testStorage.saveWorkspaceDiffMessageForRun @@ -467,9 +717,10 @@ describe('group chat agent workspace bridge runs', () => { bridgeMock.interrupt.mockResolvedValueOnce({ ok: true, synced: false }) const client = await createClient('/tmp/workspace') client.__testStorage.getRoom - .mockReturnValueOnce({ sessionSeed: 'seed-1', workspace: '/tmp/workspace' }) - .mockReturnValueOnce({ sessionSeed: 'seed-1', workspace: '/tmp/workspace' }) - .mockReturnValue({ sessionSeed: 'seed-2', workspace: '/tmp/workspace' }) + .mockReturnValueOnce({ sessionSeed: '11111111111111111111111111111111', workspace: '/tmp/workspace' }) + .mockReturnValueOnce({ sessionSeed: '11111111111111111111111111111111', workspace: '/tmp/workspace' }) + .mockReturnValueOnce({ sessionSeed: '11111111111111111111111111111111', workspace: '/tmp/workspace' }) + .mockReturnValue({ sessionSeed: '22222222222222222222222222222222', workspace: '/tmp/workspace' }) await client.replyToMention('room-1', { content: '@Worker hi', diff --git a/tests/server/group-chat-approval.test.ts b/tests/server/group-chat-approval.test.ts index 65713498fa..14eb5295c5 100644 --- a/tests/server/group-chat-approval.test.ts +++ b/tests/server/group-chat-approval.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connectGroupChatClient, + currentRoomAgentSessionId, createTestGroupChatServer, emitAck, once, } from './group-chat-test-helpers' -import { GROUP_CHAT_AGENT_SOCKET_SECRET, groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' +import { AgentBridgeClient } from '../../packages/server/src/services/hermes/agent-bridge' +import { GROUP_CHAT_AGENT_SOCKET_SECRET } from '../../packages/server/src/services/hermes/group-chat/agent-clients' import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' describe('group chat approval and context baseline', () => { @@ -27,12 +29,6 @@ describe('group chat approval and context baseline', () => { }) async function joinPair() { - const agentSessionId = groupBridgeSessionId( - 'room-1', - 'default', - 'Agent', - String(groupServer.getStorage().getRoom('room-1')?.sessionSeed || '0'), - ) const agent = await connectGroupChatClient(port, 'agent-1', 'Agent', { source: 'agent', agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET, @@ -41,6 +37,7 @@ describe('group chat approval and context baseline', () => { harness.sockets.push(agent, human) await emitAck(agent, 'join', { roomId: 'room-1' }) await emitAck(human, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + const agentSessionId = currentRoomAgentSessionId(groupServer, 'room-1', 'agent-1', 'default', 'Agent') return { agent, human, agentSessionId } } @@ -48,6 +45,26 @@ describe('group chat approval and context baseline', () => { return new Promise(resolve => setTimeout(resolve, ms)) } + function replaceLocalCapabilities(socketId: string, capabilities: string[]): void { + const localSubjectId = (groupServer as any).socketLocalSubjectIdMap.get(socketId) + const actor = groupServer.getStorage().findActiveActorByLocalSubjectId('room-1', localSubjectId) + if (!actor) throw new Error('missing local actor') + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + const insert = harness.db.prepare(` + INSERT INTO gc_room_actor_capabilities (id, roomId, actorId, capability, active, createdAt, updatedAt) + VALUES (?, 'room-1', ?, ?, 1, ?, ?) + `) + capabilities.forEach((capability, index) => { + const now = Date.now() + insert.run(`test-cap-${actor.id}-${index}`, actor.id, capability, now, now) + }) + harness.db.prepare(` + UPDATE gc_room_actors + SET authorizationRevision = authorizationRevision + 1 + WHERE id = ? + `).run(actor.id) + } + it('relays context status and updates room token count', async () => { const { agent, human, agentSessionId } = await joinPair() const statusEvent = once(human, 'context_status') @@ -107,12 +124,93 @@ describe('group chat approval and context baseline', () => { }) }) + it('delivers approval requested and resolved once to every authorized socket for one persisted subject', async () => { + const { agent, human, agentSessionId } = await joinPair() + const identity = await once<{ localCredential: string }>(human, 'local_identity') + const second = await connectGroupChatClient(port, 'human-second-tab', 'Human second tab', { + localCredential: identity.localCredential, + }) + harness.sockets.push(second) + await emitAck(second, 'join', { roomId: 'room-1' }) + + let firstRequestedCount = 0 + let secondRequestedCount = 0 + let firstResolvedCount = 0 + let secondResolvedCount = 0 + human.on('approval.requested', () => { firstRequestedCount += 1 }) + second.on('approval.requested', () => { secondRequestedCount += 1 }) + human.on('approval.resolved', () => { firstResolvedCount += 1 }) + second.on('approval.resolved', () => { secondResolvedCount += 1 }) + const firstRequested = once(human, 'approval.requested') + const secondRequested = once(second, 'approval.requested') + + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-multi-socket', + command: 'touch file', + }) + expect((await firstRequested).approval_id).toBe('approval-multi-socket') + expect((await secondRequested).approval_id).toBe('approval-multi-socket') + + const firstResolved = once(human, 'approval.resolved') + const secondResolved = once(second, 'approval.resolved') + agent.emit('approval.resolved', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-multi-socket', + choice: 'deny', + }) + expect((await firstResolved).approval_id).toBe('approval-multi-socket') + expect((await secondResolved).approval_id).toBe('approval-multi-socket') + await wait() + expect([firstRequestedCount, secondRequestedCount, firstResolvedCount, secondResolvedCount]).toEqual([1, 1, 1, 1]) + + replaceLocalCapabilities(human.id!, ['room.read']) + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-after-revocation', + command: 'cat secret', + }) + await wait() + expect([firstRequestedCount, secondRequestedCount]).toEqual([1, 1]) + }) + + it('routes approval payloads by approval.respond rather than room.manage', async () => { + const { agent, human, agentSessionId } = await joinPair() + const managerOnly = await connectGroupChatClient(port, 'manager-only', 'ManagerOnly') + harness.sockets.push(managerOnly) + await emitAck(managerOnly, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + replaceLocalCapabilities(human.id!, ['room.read', 'approval.respond']) + replaceLocalCapabilities(managerOnly.id!, ['room.read', 'room.manage']) + const requested = once(human, 'approval.requested') + let managerLeak: unknown = null + managerOnly.on('approval.requested', payload => { managerLeak = payload }) + + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-capability-split', + command: 'touch file', + }) + + expect(await requested).toMatchObject({ approval_id: 'approval-capability-split' }) + await wait() + expect(managerLeak).toBeNull() + }) + it('does not relay approval payloads to read-only invite members', async () => { const { agent, human, agentSessionId } = await joinPair() const readonly = await connectGroupChatClient(port, 'human-readonly', 'ReadOnly') harness.sockets.push(readonly) - groupServer.getIO().of('/group-chat').sockets.get(readonly.id!)!.data.authUser = { id: 7, role: 'user', profiles: [] } await emitAck(readonly, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + replaceLocalCapabilities(readonly.id!, ['room.read']) let leaked: unknown = null readonly.on('approval.requested', payload => { leaked = payload }) @@ -149,6 +247,15 @@ describe('group chat approval and context baseline', () => { it('relays approval resolved with normalized choice', async () => { const { agent, human, agentSessionId } = await joinPair() + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-1', + command: 'touch file', + }) + await requested const resolved = once(human, 'approval.resolved') agent.emit('approval.resolved', { roomId: 'room-1', agentName: 'Agent', agentSessionId, approval_id: 'approval-1', choice: 'deny' }) @@ -162,6 +269,230 @@ describe('group chat approval and context baseline', () => { }) }) + it('forwards a response only for the room-bound authorized approval id', async () => { + const { agent, human, agentSessionId } = await joinPair() + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-bound', + command: 'touch file', + }) + await requested + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-bound', + choice: 'once', + })).resolves.toEqual({ ok: true, resolved: true }) + expect(approvalRespond).toHaveBeenCalledWith('approval-bound', 'once') + } finally { + approvalRespond.mockRestore() + } + }) + + it('rejects a raw permanent response when the bound request disallows permanent approval', async () => { + const { agent, human, agentSessionId } = await joinPair() + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-no-permanent', + command: 'touch file', + choices: ['once', 'session', 'always', 'deny'], + allow_permanent: false, + }) + expect(await requested).toMatchObject({ + approval_id: 'approval-no-permanent', + choices: ['once', 'session', 'deny'], + allow_permanent: false, + }) + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-no-permanent', + choice: 'always', + })).resolves.toEqual({ error: 'Access denied' }) + expect(approvalRespond).not.toHaveBeenCalled() + + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-no-permanent', + choice: 'once', + })).resolves.toEqual({ ok: true, resolved: true }) + expect(approvalRespond).toHaveBeenCalledOnce() + expect(approvalRespond).toHaveBeenCalledWith('approval-no-permanent', 'once') + } finally { + approvalRespond.mockRestore() + } + }) + + it('evicts and rejects a responder that retains approval.respond after losing room.read', async () => { + const { agent, human, agentSessionId } = await joinPair() + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-after-read-revocation', + command: 'cat private-file', + }) + await requested + replaceLocalCapabilities(human.id!, ['approval.respond']) + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-after-read-revocation', + choice: 'once', + })).resolves.toEqual({ error: 'Access denied' }) + expect(approvalRespond).not.toHaveBeenCalled() + const room = (groupServer as any).rooms.get('room-1') + expect(room.hasOnlineMember(human.id!)).toBe(false) + expect((groupServer as any).nsp.adapter.rooms.get('room-1')?.has(human.id!)).not.toBe(true) + } finally { + approvalRespond.mockRestore() + } + }) + + it('rejects approval responses whose id was not bound by an authorized agent event', async () => { + const { human } = await joinPair() + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-from-another-room', + choice: 'allow', + })).resolves.toEqual({ error: 'Access denied' }) + expect(approvalRespond).not.toHaveBeenCalled() + } finally { + approvalRespond.mockRestore() + } + }) + + it('rejects a valid approval id when the responder supplies a different authorized room', async () => { + const { human } = await joinPair() + groupServer.getStorage().saveRoom('room-2', 'Room 2', 'ROOM2') + groupServer.getStorage().addRoomAgent('room-2', 'agent-2', 'default', 'AgentTwo', '', 0) + const agentTwo = await connectGroupChatClient(port, 'agent-2', 'AgentTwo', { + source: 'agent', + agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET, + }) + harness.sockets.push(agentTwo) + await emitAck(agentTwo, 'join', { roomId: 'room-2' }) + const agentTwoSessionId = currentRoomAgentSessionId(groupServer, 'room-2', 'agent-2', 'default', 'AgentTwo') + agentTwo.emit('approval.requested', { + roomId: 'room-2', + agentName: 'AgentTwo', + agentSessionId: agentTwoSessionId, + approval_id: 'approval-room-2', + command: 'cat secret', + }) + await wait() + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-room-2', + choice: 'once', + })).resolves.toEqual({ error: 'Access denied' }) + expect(approvalRespond).not.toHaveBeenCalled() + } finally { + approvalRespond.mockRestore() + } + }) + + it('poisons a globally colliding approval id across rooms and never calls Bridge', async () => { + const { agent, human, agentSessionId } = await joinPair() + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-collision', + command: 'touch room-a', + }) + await requested + + groupServer.getStorage().saveRoom('room-2', 'Room 2', 'ROOM2') + groupServer.getStorage().addRoomAgent('room-2', 'agent-2', 'default', 'AgentTwo', '', 0) + const agentTwo = await connectGroupChatClient(port, 'agent-2', 'AgentTwo', { + source: 'agent', + agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET, + }) + harness.sockets.push(agentTwo) + await emitAck(agentTwo, 'join', { roomId: 'room-2' }) + const agentTwoSessionId = currentRoomAgentSessionId(groupServer, 'room-2', 'agent-2', 'default', 'AgentTwo') + agentTwo.emit('approval.requested', { + roomId: 'room-2', + agentName: 'AgentTwo', + agentSessionId: agentTwoSessionId, + approval_id: 'approval-collision', + command: 'cat room-b', + }) + await wait() + + // Neither a matching resolved event nor room-local cleanup may make a globally + // conflicted Bridge id usable again. + agent.emit('approval.resolved', { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-collision', + choice: 'deny', + }) + await wait() + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-collision', + choice: 'once', + })).resolves.toEqual({ error: 'Access denied' }) + expect(approvalRespond).not.toHaveBeenCalled() + } finally { + approvalRespond.mockRestore() + } + }) + + it('suppresses a same-origin duplicate without poisoning the approval id', async () => { + const { agent, human, agentSessionId } = await joinPair() + const request = { + roomId: 'room-1', + agentName: 'Agent', + agentSessionId, + approval_id: 'approval-duplicate', + command: 'touch once', + } + const requested = once(human, 'approval.requested') + agent.emit('approval.requested', request) + await requested + agent.emit('approval.requested', request) + await wait() + const approvalRespond = vi.spyOn(AgentBridgeClient.prototype, 'approvalRespond').mockResolvedValue({ resolved: true } as any) + + try { + await expect(emitAck(human, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-duplicate', + choice: 'once', + })).resolves.toEqual({ ok: true, resolved: true }) + expect(approvalRespond).toHaveBeenCalledOnce() + } finally { + approvalRespond.mockRestore() + } + }) + it('rejects approval responses from sockets that have not joined the room', async () => { const outsider = await connectGroupChatClient(port, 'outsider', 'Outsider') harness.sockets.push(outsider) @@ -169,12 +500,107 @@ describe('group chat approval and context baseline', () => { await expect(emitAck(outsider, 'approval.respond', { roomId: 'room-1', approval_id: 'approval-1', choice: 'deny' })).resolves.toEqual({ error: 'Not in room' }) }) + it('rejects approval responses from read-only invite members', async () => { + const { human } = await joinPair() + const readonly = await connectGroupChatClient(port, 'human-readonly', 'ReadOnly') + harness.sockets.push(readonly) + await emitAck(readonly, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + replaceLocalCapabilities(readonly.id!, ['room.read']) + + await expect(emitAck(readonly, 'approval.respond', { + roomId: 'room-1', + approval_id: 'approval-1', + choice: 'deny', + })).resolves.toEqual({ error: 'Access denied' }) + + human.disconnect() + }) + + it('rechecks authorization before finalizing runtime clear after interruption', async () => { + const { human } = await joinPair() + let resolveInterrupt!: () => void + const interruptGate = new Promise((resolve) => { resolveInterrupt = resolve }) + const agentClients = (groupServer as any).agentClients + const interruptRoom = vi.spyOn(agentClients, 'interruptRoom').mockReturnValue(interruptGate) + const resetRoomContext = vi.spyOn(agentClients, 'resetRoomContext') + const typingTimer = setTimeout(() => {}, 60_000) + ;(groupServer as any).typingState.set('room-1', new Map([['human-1', { userName: 'Human', timer: typingTimer }]])) + ;(groupServer as any).contextStatusState.set('room-1', new Map([['Agent', { agentName: 'Agent', status: 'replying' }]])) + ;(groupServer as any).pendingApprovals.set('approval-preserved', { + roomId: 'room-1', + agentId: 'agent-1', + agentSessionId: 'session-1', + allowedChoices: ['deny'], + allowPermanent: false, + responding: false, + responded: false, + conflicted: false, + }) + let cleared = false + human.on('room_cleared', () => { cleared = true }) + let authorized = true + + try { + const clearing = groupServer.clearRoomRuntimeState('room-1', () => { + if (!authorized) throw new Error('authorization revoked') + }) + await vi.waitFor(() => expect(interruptRoom).toHaveBeenCalledWith('room-1')) + authorized = false + resolveInterrupt() + + await expect(clearing).rejects.toThrow('authorization revoked') + expect(resetRoomContext).not.toHaveBeenCalled() + expect(cleared).toBe(false) + expect((groupServer as any).typingState.has('room-1')).toBe(true) + expect((groupServer as any).contextStatusState.has('room-1')).toBe(true) + expect((groupServer as any).pendingApprovals.has('approval-preserved')).toBe(true) + expect((groupServer as any).fencedRoomAgentSessions?.has('room-1')).not.toBe(true) + } finally { + clearTimeout(typingTimer) + ;(groupServer as any).typingState.delete('room-1') + ;(groupServer as any).contextStatusState.delete('room-1') + ;(groupServer as any).pendingApprovals.delete('approval-preserved') + interruptRoom.mockRestore() + resetRoomContext.mockRestore() + } + }) + + it('rechecks authorization before disconnecting runtime room state after interruption', async () => { + await joinPair() + let resolveInterrupt!: () => void + const interruptGate = new Promise((resolve) => { resolveInterrupt = resolve }) + const agentClients = (groupServer as any).agentClients + const interruptRoom = vi.spyOn(agentClients, 'interruptRoom').mockReturnValue(interruptGate) + const disconnectRoom = vi.spyOn(agentClients, 'disconnectRoom') + ;(groupServer as any).contextStatusState.set('room-1', new Map([['Agent', { agentName: 'Agent', status: 'replying' }]])) + let authorized = true + + try { + const deleting = groupServer.deleteRoomRuntimeState('room-1', () => { + if (!authorized) throw new Error('authorization revoked') + }) + await vi.waitFor(() => expect(interruptRoom).toHaveBeenCalledWith('room-1')) + authorized = false + resolveInterrupt() + + await expect(deleting).rejects.toThrow('authorization revoked') + expect(disconnectRoom).not.toHaveBeenCalled() + expect((groupServer as any).rooms.has('room-1')).toBe(true) + expect((groupServer as any).contextStatusState.has('room-1')).toBe(true) + expect((groupServer as any).fencedRoomAgentSessions?.has('room-1')).not.toBe(true) + } finally { + ;(groupServer as any).contextStatusState.delete('room-1') + interruptRoom.mockRestore() + disconnectRoom.mockRestore() + } + }) + it('emits room_cleared and room_updated when runtime state is cleared', async () => { const { human } = await joinPair() const cleared = once(human, 'room_cleared') const updated = once(human, 'room_updated') - groupServer.clearRoomRuntimeState('room-1') + void groupServer.clearRoomRuntimeState('room-1', () => {}) expect(await cleared).toEqual({ roomId: 'room-1', totalTokens: 0 }) expect(await updated).toEqual({ roomId: 'room-1', totalTokens: 0 }) diff --git a/tests/server/group-chat-avatar.test.ts b/tests/server/group-chat-avatar.test.ts index 4bcf6a6a3d..5f8f19b83d 100644 --- a/tests/server/group-chat-avatar.test.ts +++ b/tests/server/group-chat-avatar.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server as HttpServer } from 'http' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('group chat member avatars', () => { let db: any = null @@ -11,6 +12,7 @@ describe('group chat member avatars', () => { vi.stubEnv('AUTH_JWT_SECRET', 'test-secret') const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/group-chat-capability-policy.test.ts b/tests/server/group-chat-capability-policy.test.ts new file mode 100644 index 0000000000..57a355b66f --- /dev/null +++ b/tests/server/group-chat-capability-policy.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' + +import { + SUPPORTED_GROUP_CHAT_CAPABILITIES, + groupChatCapabilityFlags, + normalizeGroupChatCapabilities, +} from '../../packages/server/src/services/hermes/group-chat/identity/capability-policy' + +describe('group chat capability policy', () => { + it('uses a finite supported capability registry', () => { + expect(SUPPORTED_GROUP_CHAT_CAPABILITIES).toEqual([ + 'room.read', + 'room.write', + 'room.type', + 'room.manage', + 'agent.invoke', + 'approval.respond', + ]) + }) + + it('reports exactly the capabilities it enforces', () => { + const decision = groupChatCapabilityFlags([ + 'room.read', + 'room.type', + 'approval.respond', + 'room.read', + ]) + + expect(decision.capabilities).toEqual([ + 'room.read', + 'room.type', + 'approval.respond', + ]) + expect(decision.canRead).toBe(true) + expect(decision.canWrite).toBe(false) + expect(decision.canType).toBe(true) + expect(decision.canManage).toBe(false) + expect(decision.canInvokeAgents).toBe(false) + expect(decision.canApprove).toBe(true) + expect(decision.isReadOnly).toBe(false) + }) + + it('ignores unsupported grants and fails closed', () => { + const normalized = normalizeGroupChatCapabilities([ + 'room.read', + 'channel.read', + 'private-fact.read', + 'artifact.write', + 'room.read', + ]) + const decision = groupChatCapabilityFlags([ + 'channel.read', + 'private-fact.read', + 'room.read', + ]) + + expect(normalized).toEqual(['room.read']) + expect(decision.capabilities).toEqual(['room.read']) + expect(decision.canRead).toBe(true) + expect(decision.canWrite).toBe(false) + expect(decision.canType).toBe(false) + expect(decision.canManage).toBe(false) + expect(decision.canInvokeAgents).toBe(false) + expect(decision.canApprove).toBe(false) + expect(decision.isReadOnly).toBe(true) + }) +}) diff --git a/tests/server/group-chat-context-cache.test.ts b/tests/server/group-chat-context-cache.test.ts index 7b7daaa6a4..d1d4272f49 100644 --- a/tests/server/group-chat-context-cache.test.ts +++ b/tests/server/group-chat-context-cache.test.ts @@ -139,6 +139,8 @@ describe('group chat context cursors', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[1], }) @@ -176,6 +178,8 @@ describe('group chat context cursors', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[2], }) @@ -213,6 +217,8 @@ describe('group chat context cursors', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[1], }) @@ -256,6 +262,8 @@ describe('group chat fallback trimming', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(999), }) @@ -299,6 +307,8 @@ describe('group chat fallback trimming', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(999), }) @@ -336,6 +346,8 @@ describe('group chat fallback trimming', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(999), }) @@ -376,6 +388,8 @@ describe('group chat fallback trimming', () => { members: [{ userId: 'user-1', name: 'Alice', description: '' }], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(999), }) diff --git a/tests/server/group-chat-context-projection.test.ts b/tests/server/group-chat-context-projection.test.ts index 4f625256a1..c6c9fa421c 100644 --- a/tests/server/group-chat-context-projection.test.ts +++ b/tests/server/group-chat-context-projection.test.ts @@ -157,6 +157,8 @@ describe('group chat context projection', () => { members: [], upstream: '', apiKey: null, + authorizationGuard: () => true, + summarySessionRegistrar: () => ({ sessionId: `gc_h_${'a'.repeat(32)}`, authorizationGuard: () => true, release: () => undefined }), currentMessage: messages[messages.length - 1], }) diff --git a/tests/server/group-chat-history-window.test.ts b/tests/server/group-chat-history-window.test.ts index c615bbed11..f131a38cca 100644 --- a/tests/server/group-chat-history-window.test.ts +++ b/tests/server/group-chat-history-window.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DatabaseSync } from 'node:sqlite' import { createServer, type Server as HttpServer } from 'http' +import { claimTestHermesDbOwnership } from './db-test-helpers' const dbMock = vi.hoisted(() => ({ current: null as DatabaseSync | null, @@ -24,9 +25,14 @@ const { mockIo, mockSocket } = vi.hoisted(() => { } }) -vi.mock('../../packages/server/src/db/index', () => ({ - getDb: () => dbMock.current, -})) +vi.mock('../../packages/server/src/db/index', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getDb: () => dbMock.current, + getStoragePath: () => ':memory:', + } +}) vi.mock('socket.io-client', () => ({ io: mockIo, @@ -81,9 +87,11 @@ describe('group chat history windows', () => { beforeEach(() => { vi.clearAllMocks() dbMock.current = makeDb() - initAllHermesTables() - httpServer = createServer() - groupServer = new GroupChatServer(httpServer) + return claimTestHermesDbOwnership(dbMock.current).then(() => { + initAllHermesTables() + httpServer = createServer() + groupServer = new GroupChatServer(httpServer) + }) }) afterEach(() => { @@ -199,11 +207,23 @@ describe('group chat history windows', () => { role: 'user', timestamp: index + 1, })) - const sessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1') + const sessionIdentity = { + actorId: 'actor-agent-1', + roomAuthorizationRevision: 0, + actorAuthorizationRevision: 0, + actorContextRevision: 0, + } + const sessionId = groupBridgeSessionId('room-1', 'default', 'Worker', '11111111111111111111111111111111', sessionIdentity) const storage = { getMessagesForContext: vi.fn(() => messages), getRecentMessagesForUI: vi.fn(() => messages.slice(-150)), - getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-1' })), + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: '11111111111111111111111111111111', authorizationRevision: 0 })), + findActiveActorByAgentIdentity: vi.fn(() => ({ + id: sessionIdentity.actorId, + authorizationRevision: 0, + contextRevision: 0, + })), + getActorCapabilities: vi.fn(() => ['room.read', 'room.write']), getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Worker' })), updateRoomTotalTokens: vi.fn(), } diff --git a/tests/server/group-chat-identity-migration.test.ts b/tests/server/group-chat-identity-migration.test.ts new file mode 100644 index 0000000000..af8d973079 --- /dev/null +++ b/tests/server/group-chat-identity-migration.test.ts @@ -0,0 +1,504 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DatabaseSync } from 'node:sqlite' + +type SchemaModule = typeof import('../../packages/server/src/db/hermes/schemas') +type MigrationModule = typeof import('../../packages/server/src/db/hermes/group-chat-identity-migration') + +let db: DatabaseSync | null = null + +function createLegacyGroupChatTables(database: DatabaseSync): void { + database.exec(` + CREATE TABLE gc_rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + inviteCode TEXT UNIQUE, + triggerTokens INTEGER NOT NULL DEFAULT 100000, + maxHistoryTokens INTEGER NOT NULL DEFAULT 32000, + tailMessageCount INTEGER NOT NULL DEFAULT 10, + totalTokens INTEGER NOT NULL DEFAULT 0, + sessionSeed TEXT NOT NULL DEFAULT '0', + workspace TEXT NOT NULL DEFAULT '', + ownerAuthUserId INTEGER + ); + CREATE TABLE gc_room_agents ( + id TEXT PRIMARY KEY, + roomId TEXT NOT NULL, + agentId TEXT NOT NULL, + profile TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + invited INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE gc_room_members ( + id TEXT PRIMARY KEY, + roomId TEXT NOT NULL, + userId TEXT NOT NULL, + userName TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + joinedAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + avatar TEXT NOT NULL DEFAULT '', + authUserId INTEGER + ); + `) +} + +function hasTable(database: DatabaseSync, tableName: string): boolean { + return Boolean(database.prepare( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?` + ).get(tableName)) +} + +function userTableNames(database: DatabaseSync): string[] { + return (database.prepare( + `SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name` + ).all() as Array<{ name: string }>).map((row) => row.name) +} + +function tableColumns(database: DatabaseSync, tableName: string): string[] { + return (database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>) + .map((row) => row.name) +} + +async function claimTestOwnership(database: DatabaseSync): Promise { + const ownership = await import('../../packages/server/src/db/ownership') + ownership.claimHermesDatabaseOwnershipForTesting(database) +} + +async function loadSchemasWithDb(database: DatabaseSync): Promise<{ + initAllHermesTables: SchemaModule['initAllHermesTables'] + runGroupChatIdentityV1Migration: MigrationModule['runGroupChatIdentityV1Migration'] + setGroupChatIdentityMigrationFailpointForTesting: MigrationModule['setGroupChatIdentityMigrationFailpointForTesting'] +}> { + vi.doMock('../../packages/server/src/db/index', () => ({ + getDb: () => database, + getStoragePath: () => ':memory:', + })) + + const schemas = await import('../../packages/server/src/db/hermes/schemas') + const migration = await import('../../packages/server/src/db/hermes/group-chat-identity-migration') + + return { + initAllHermesTables: schemas.initAllHermesTables, + runGroupChatIdentityV1Migration: migration.runGroupChatIdentityV1Migration, + setGroupChatIdentityMigrationFailpointForTesting: migration.setGroupChatIdentityMigrationFailpointForTesting, + } +} + +afterEach(() => { + db?.close() + db = null + vi.doUnmock('../../packages/server/src/db/index') + vi.resetModules() +}) + +describe('group chat identity migration', () => { + it('rejects init without an explicit ownership claim before any schema writes', async () => { + db = new DatabaseSync(':memory:') + const { initAllHermesTables } = await loadSchemasWithDb(db) + + expect(() => initAllHermesTables()).toThrow(/ownership/i) + expect(userTableNames(db)).toEqual([]) + }) + + it('creates schema state and actor tables on a fresh database', async () => { + db = new DatabaseSync(':memory:') + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + initAllHermesTables() + + expect(hasTable(db, 'gc_schema_state')).toBe(true) + expect(hasTable(db, 'gc_room_actors')).toBe(true) + expect(hasTable(db, 'gc_room_actor_capabilities')).toBe(true) + expect(db.prepare( + `SELECT schema_name, version, status, min_reader_epoch + FROM gc_schema_state + WHERE schema_name = ?` + ).get('groupChatIdentityV1')).toEqual({ + schema_name: 'groupChatIdentityV1', + version: 1, + status: 'complete', + min_reader_epoch: 1, + }) + }) + + it('upgrades latest-main group chat state by backfilling canonical actors and supported grants', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace, ownerAuthUserId) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', '', 7); + INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) + VALUES ('agent-row-1', 'room-1', 'agent-1', 'default', 'Worker', 'Build things', 0); + INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) + VALUES + ('member-auth-1', 'room-1', 'auth:7', 'Alice', 'Owner', 1, 1, 'avatar-a', 7), + ('member-local-1', 'room-1', 'local-1', 'Local User', '', 2, 2, '', NULL); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + initAllHermesTables() + + expect(db.prepare( + `SELECT actorType, authUserId, name, description, avatar + FROM gc_room_actors + WHERE roomId = ? AND authUserId = ?` + ).get('room-1', 7)).toEqual({ + actorType: 'authenticated_human', + authUserId: 7, + name: 'Alice', + description: 'Owner', + avatar: 'avatar-a', + }) + expect(db.prepare( + `SELECT actorType, agentId, name, description + FROM gc_room_actors + WHERE roomId = ? AND agentId = ?` + ).get('room-1', 'agent-1')).toEqual({ + actorType: 'agent', + agentId: 'agent-1', + name: 'Worker', + description: 'Build things', + }) + const localActor = db.prepare( + `SELECT actorType, localSubjectId, name + FROM gc_room_actors + WHERE roomId = ? AND actorType = 'local'` + ).get('room-1') as { actorType: string; localSubjectId: string; name: string } + expect(localActor).toEqual({ + actorType: 'local', + localSubjectId: expect.stringMatching(/^local:[0-9a-f]{32}$/), + name: 'Local User', + }) + expect(localActor.localSubjectId).not.toBe('local-1') + + const grants = (db.prepare( + `SELECT capability + FROM gc_room_actor_capabilities + WHERE roomId = ? AND actorId = ( + SELECT id FROM gc_room_actors WHERE roomId = ? AND agentId = ? + ) + ORDER BY capability` + ).all('room-1', 'room-1', 'agent-1') as Array<{ capability: string }>).map((row) => row.capability) + expect(grants).toEqual([ + 'agent.invoke', + 'room.read', + 'room.type', + 'room.write', + ]) + }) + + it('reconciles duplicate authenticated and agent identities by canonical timestamps instead of insertion rowid', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace, ownerAuthUserId) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', '', 7); + ALTER TABLE gc_room_agents ADD COLUMN createdAt INTEGER NOT NULL DEFAULT 0; + INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) + VALUES + ('agent-row-z', 'room-1', 'agent-dup', 'default', 'Worker New', 'second', 0), + ('agent-row-a', 'room-1', 'agent-dup', 'default', 'Worker Old', 'first', 0); + UPDATE gc_room_agents SET createdAt = 20 WHERE id = 'agent-row-z'; + UPDATE gc_room_agents SET createdAt = 10 WHERE id = 'agent-row-a'; + INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) + VALUES + ('member-auth-z', 'room-1', 'auth:7-new', 'Alice New', 'second', 20, 20, 'avatar-new', 7), + ('member-auth-a', 'room-1', 'auth:7-old', 'Alice Old', 'first', 10, 10, 'avatar-old', 7); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + initAllHermesTables() + + expect(db.prepare( + `SELECT COUNT(*) AS count + FROM gc_room_actors + WHERE roomId = ? AND agentId = ?` + ).get('room-1', 'agent-dup')).toEqual({ count: 1 }) + expect(db.prepare( + `SELECT name, description + FROM gc_room_actors + WHERE roomId = ? AND agentId = ?` + ).get('room-1', 'agent-dup')).toEqual({ + name: 'Worker Old', + description: 'first', + }) + expect(db.prepare( + `SELECT COUNT(*) AS count + FROM gc_room_actors + WHERE roomId = ? AND authUserId = ?` + ).get('room-1', 7)).toEqual({ count: 1 }) + expect(db.prepare( + `SELECT name, description, avatar + FROM gc_room_actors + WHERE roomId = ? AND authUserId = ?` + ).get('room-1', 7)).toEqual({ + name: 'Alice Old', + description: 'first', + avatar: 'avatar-old', + }) + }) + + it('repeats idempotently once groupChatIdentityV1 is recorded', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', ''); + INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) + VALUES ('agent-row-1', 'room-1', 'agent-1', 'default', 'Worker', '', 0); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + initAllHermesTables() + const firstState = db.prepare( + `SELECT schema_name, version, status, min_reader_epoch, applied_at + FROM gc_schema_state + WHERE schema_name = ?` + ).get('groupChatIdentityV1') + const firstActors = db.prepare( + `SELECT id, roomId, actorType, agentId, authUserId, localSubjectId + FROM gc_room_actors + ORDER BY id` + ).all() + const firstCapabilities = db.prepare( + `SELECT actorId, capability + FROM gc_room_actor_capabilities + ORDER BY actorId, capability` + ).all() + + initAllHermesTables() + + expect(db.prepare( + `SELECT schema_name, version, status, min_reader_epoch, applied_at + FROM gc_schema_state + WHERE schema_name = ?` + ).get('groupChatIdentityV1')).toEqual(firstState) + expect(db.prepare( + `SELECT id, roomId, actorType, agentId, authUserId, localSubjectId + FROM gc_room_actors + ORDER BY id` + ).all()).toEqual(firstActors) + expect(db.prepare( + `SELECT actorId, capability + FROM gc_room_actor_capabilities + ORDER BY actorId, capability` + ).all()).toEqual(firstCapabilities) + }) + + it('rotates invalid legacy room session seeds once while preserving valid 32-hex seeds', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES + ('room-empty', 'Empty', 'EMPTY1', '', ''), + ('room-zero', 'Zero', 'ZERO01', '0', ''), + ('room-legacy', 'Legacy', 'LEGACY1', 'seed-1', ''), + ('room-valid', 'Valid', 'VALID1', '0123456789abcdefABCDEF0123456789', ''); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + initAllHermesTables() + + const firstSeeds = db.prepare( + 'SELECT id, sessionSeed FROM gc_rooms ORDER BY id' + ).all() as Array<{ id: string; sessionSeed: string }> + expect(firstSeeds).toEqual([ + { id: 'room-empty', sessionSeed: expect.stringMatching(/^[0-9a-f]{32}$/) }, + { id: 'room-legacy', sessionSeed: expect.stringMatching(/^[0-9a-f]{32}$/) }, + { id: 'room-valid', sessionSeed: '0123456789abcdefABCDEF0123456789' }, + { id: 'room-zero', sessionSeed: expect.stringMatching(/^[0-9a-f]{32}$/) }, + ]) + expect(firstSeeds.find(row => row.id === 'room-empty')?.sessionSeed).not.toBe('') + expect(firstSeeds.find(row => row.id === 'room-legacy')?.sessionSeed).not.toBe('seed-1') + expect(firstSeeds.find(row => row.id === 'room-zero')?.sessionSeed).not.toBe('0') + + initAllHermesTables() + + expect(db.prepare( + 'SELECT id, sessionSeed FROM gc_rooms ORDER BY id' + ).all()).toEqual(firstSeeds) + + db.prepare("UPDATE gc_rooms SET sessionSeed = '0' WHERE id = 'room-valid'").run() + initAllHermesTables() + const repairedSeed = (db.prepare( + 'SELECT sessionSeed FROM gc_rooms WHERE id = ?' + ).get('room-valid') as { sessionSeed: string }).sessionSeed + expect(repairedSeed).toMatch(/^[0-9a-f]{32}$/) + expect(repairedSeed).not.toBe('0') + }) + + it('rolls back the entire migration when an injected failpoint fires', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', ''); + INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) + VALUES ('agent-row-1', 'room-1', 'agent-1', 'default', 'Worker', '', 0); + `) + const { + initAllHermesTables, + runGroupChatIdentityV1Migration: _runGroupChatIdentityV1Migration, + setGroupChatIdentityMigrationFailpointForTesting, + } = await loadSchemasWithDb(db) + + await claimTestOwnership(db) + setGroupChatIdentityMigrationFailpointForTesting('after-backfill') + const beforeRoomColumns = tableColumns(db, 'gc_rooms') + const beforeRooms = db.prepare( + 'SELECT id, sessionSeed FROM gc_rooms ORDER BY id' + ).all() + expect(() => initAllHermesTables()).toThrow(/groupChatIdentityV1/i) + setGroupChatIdentityMigrationFailpointForTesting(null) + + expect(tableColumns(db, 'gc_rooms')).toEqual(beforeRoomColumns) + expect(db.prepare( + 'SELECT id, sessionSeed FROM gc_rooms ORDER BY id' + ).all()).toEqual(beforeRooms) + expect(hasTable(db, 'gc_schema_state')).toBe(false) + expect(hasTable(db, 'gc_room_actors')).toBe(false) + expect(hasTable(db, 'gc_room_actor_capabilities')).toBe(false) + expect(db.prepare('SELECT COUNT(*) AS count FROM gc_room_agents').get()).toEqual({ count: 1 }) + }) + + it('rejects orphaned legacy room references and rolls back every PR1 group chat change', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', ''); + INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) + VALUES ('member-orphan', 'ghost-room', 'ghost-user', 'Ghost', '', 10, 10, '', NULL); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + const beforeColumns = tableColumns(db, 'gc_rooms') + + expect(() => initAllHermesTables()).toThrow(/room/i) + expect(tableColumns(db, 'gc_rooms')).toEqual(beforeColumns) + expect(hasTable(db, 'gc_schema_state')).toBe(false) + expect(hasTable(db, 'gc_room_actors')).toBe(false) + expect(hasTable(db, 'gc_room_actor_capabilities')).toBe(false) + }) + + it('rejects invalid active identity shapes before creating actor indexes', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', ''); + INSERT INTO gc_room_members (id, roomId, userId, userName, description, joinedAt, updatedAt, avatar, authUserId) + VALUES + ('member-valid', 'room-1', 'local-valid', 'Local', '', 1, 1, '', NULL), + ('member-invalid', 'room-1', '', 'Broken', '', 2, 2, '', NULL); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + + expect(() => initAllHermesTables()).toThrow(/identity|localSubjectId|userId/i) + expect(hasTable(db, 'gc_room_actors')).toBe(false) + expect(hasTable(db, 'gc_room_actor_capabilities')).toBe(false) + }) + + it('refuses to run when a recorded minimum reader epoch is newer than PR1 epoch 1', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + CREATE TABLE gc_schema_state ( + schema_name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + status TEXT NOT NULL, + min_reader_epoch INTEGER NOT NULL, + applied_at INTEGER NOT NULL + ); + INSERT INTO gc_schema_state (schema_name, version, status, min_reader_epoch, applied_at) + VALUES ('groupChatIdentityV1', 1, 'complete', 2, 123); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + const beforeTables = userTableNames(db) + const beforeColumns = tableColumns(db, 'gc_rooms') + + expect(() => initAllHermesTables()).toThrow(/minimum reader epoch 2/i) + expect(userTableNames(db)).toEqual(beforeTables) + expect(tableColumns(db, 'gc_rooms')).toEqual(beforeColumns) + }) + + it('rejects pending or foreign recorded state before any schema write', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + CREATE TABLE gc_schema_state ( + schema_name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + status TEXT NOT NULL, + min_reader_epoch INTEGER NOT NULL, + applied_at INTEGER NOT NULL + ); + INSERT INTO gc_schema_state (schema_name, version, status, min_reader_epoch, applied_at) + VALUES ('groupChatIdentityV1', 1, 'pending', 1, 123); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + const beforeTables = userTableNames(db) + const beforeColumns = tableColumns(db, 'gc_rooms') + + expect(() => initAllHermesTables()).toThrow(/unsupported recorded state pending@1/i) + expect(userTableNames(db)).toEqual(beforeTables) + expect(tableColumns(db, 'gc_rooms')).toEqual(beforeColumns) + }) + + it('rejects a foreign Group Chat schema-state row before any schema write', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + CREATE TABLE gc_schema_state ( + schema_name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + status TEXT NOT NULL, + min_reader_epoch INTEGER NOT NULL, + applied_at INTEGER NOT NULL + ); + INSERT INTO gc_schema_state (schema_name, version, status, min_reader_epoch, applied_at) + VALUES ('groupChatIdentityV2', 2, 'pending', 2, 123); + `) + await claimTestOwnership(db) + const { initAllHermesTables } = await loadSchemasWithDb(db) + const beforeTables = userTableNames(db) + const beforeColumns = tableColumns(db, 'gc_rooms') + + expect(() => initAllHermesTables()).toThrow(/foreign.*groupChatIdentityV2/i) + expect(userTableNames(db)).toEqual(beforeTables) + expect(tableColumns(db, 'gc_rooms')).toEqual(beforeColumns) + }) + + it('asserts ownership and composes with an existing transaction for standalone migration', async () => { + db = new DatabaseSync(':memory:') + createLegacyGroupChatTables(db) + db.exec(` + INSERT INTO gc_rooms (id, name, inviteCode, sessionSeed, workspace) + VALUES ('room-1', 'Room 1', 'ROOM1', 'seed-1', ''); + INSERT INTO gc_room_agents (id, roomId, agentId, profile, name, description, invited) + VALUES ('agent-row-1', 'room-1', 'agent-1', 'default', 'Worker', '', 0); + `) + await claimTestOwnership(db) + const { runGroupChatIdentityV1Migration } = await loadSchemasWithDb(db) + + db.exec('BEGIN IMMEDIATE') + expect(() => runGroupChatIdentityV1Migration(db!)).not.toThrow() + db.exec('ROLLBACK') + + expect(hasTable(db, 'gc_schema_state')).toBe(false) + expect(hasTable(db, 'gc_room_actors')).toBe(false) + }) +}) diff --git a/tests/server/group-chat-invite-admission.test.ts b/tests/server/group-chat-invite-admission.test.ts new file mode 100644 index 0000000000..b54b1c99f7 --- /dev/null +++ b/tests/server/group-chat-invite-admission.test.ts @@ -0,0 +1,788 @@ +import Koa from 'koa' +import { createServer } from 'http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + connectGroupChatClient, + createTestGroupChatServer, + emitAck, + once, + seedAuthenticatedUser, +} from './group-chat-test-helpers' +import { authenticateUserToken, isAuthEnabled } from '../../packages/server/src/middleware/user-auth' +import { setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' +import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' + +async function routeHandler(path: string, method: string) { + const { groupChatRoutes } = await import('../../packages/server/src/routes/hermes/group-chat') + const layer = (groupChatRoutes as any).stack.find((item: any) => item.path === path && item.methods.includes(method)) + if (!layer) throw new Error(`Route not found: ${method} ${path}`) + return layer.stack[0] +} + +async function listenHttp(server: ReturnType): Promise { + return await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') throw new Error('missing HTTP address') + resolve(`http://127.0.0.1:${address.port}`) + }) + }) +} + +describe('group chat invite admission', () => { + let harness: Awaited> + let groupServer: GroupChatServer + let port: number + + beforeEach(async () => { + vi.clearAllMocks() + harness = await createTestGroupChatServer() + groupServer = harness.groupServer + port = harness.port + setGroupChatServer(groupServer) + }) + + afterEach(() => { + groupServer?.setJoinAdmissionCheckpointForTests(null) + setGroupChatServer(null) + vi.mocked(isAuthEnabled).mockResolvedValue(false) + vi.mocked(authenticateUserToken).mockResolvedValue(null as any) + harness?.cleanup() + }) + + it('denies a join when the invite rotates before transactional reload and writes no identity rows', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'readonly-token') { + return { id: 42, username: 'readonly', role: 'admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'readonly' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + groupServer.setJoinAdmissionCheckpointForTests(() => { + storage.updateRoomInviteCode('room-1', 'ROOM2') + }) + + const readonly = await connectGroupChatClient(port, 'ignored-readonly', 'ReadOnly', { token: 'readonly-token' }) + harness.sockets.push(readonly) + + const joined = await emitAck(readonly, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) + + expect(joined).toEqual({ error: 'Room not found' }) + expect(storage.getRoom('room-1')).toMatchObject({ inviteCode: 'ROOM2', inviteGeneration: 1, authorizationRevision: 1 }) + expect((harness.db.prepare('SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ?').get('room-1') as { count: number }).count).toBe(0) + expect((harness.db.prepare('SELECT COUNT(*) AS count FROM gc_room_actors WHERE roomId = ?').get('room-1') as { count: number }).count).toBe(0) + expect((harness.db.prepare('SELECT COUNT(*) AS count FROM gc_room_actor_capabilities WHERE roomId = ?').get('room-1') as { count: number }).count).toBe(0) + }) + + it('allows a returning invite member to rejoin after rotation without resubmitting the old code', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'readonly-token') { + return { id: 42, username: 'readonly', role: 'admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'readonly' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + + const first = await connectGroupChatClient(port, 'ignored-readonly-a', 'ReadOnly', { token: 'readonly-token' }) + harness.sockets.push(first) + await expect(emitAck(first, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' })).resolves.toEqual( + expect.objectContaining({ roomId: 'room-1' }), + ) + first.disconnect() + + storage.updateRoomInviteCode('room-1', 'ROOM2') + + const second = await connectGroupChatClient(port, 'ignored-readonly-b', 'ReadOnly', { token: 'readonly-token' }) + harness.sockets.push(second) + const rejoined = await emitAck(second, 'join', { roomId: 'room-1' }) + const messageAck = await emitAck(second, 'message', { + roomId: 'room-1', + id: 'readonly-msg-after-rotation', + content: 'still readonly', + }) + + expect(rejoined).toEqual(expect.objectContaining({ roomId: 'room-1' })) + expect(messageAck).toEqual({ error: 'Access denied' }) + const actor = storage.findActiveActorByAuthUserId('room-1', 42) + expect(actor).not.toBeNull() + expect(actor ? storage.getActorCapabilities(actor.id) : []).toEqual(['room.read']) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 42) as { count: number }).count).toBe(1) + }) + + it('lets an authenticated member leave an invite room without deleting it or retaining invite-less access', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'member-token') { + return { id: 42, username: 'member', role: 'admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'member' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + + const member = await connectGroupChatClient(port, 'ignored-member', 'Member', { token: 'member-token' }) + harness.sockets.push(member) + await expect(emitAck(member, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' })).resolves.toEqual( + expect.objectContaining({ roomId: 'room-1' }), + ) + expect(storage.findActiveActorByAuthUserId('room-1', 42)).not.toBeNull() + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 42) as { count: number }).count).toBe(1) + + const leave = await routeHandler('/api/hermes/group-chat/rooms/:roomId/members/me', 'DELETE') + const ctx: any = { + params: { roomId: 'room-1' }, + state: { user: { id: 42, username: 'member', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + await leave(ctx, async () => {}) + + expect(ctx.body).toEqual({ success: true, left: true }) + expect(storage.getRoom('room-1')).toEqual(expect.objectContaining({ id: 'room-1', inviteCode: 'ROOM1' })) + expect(storage.findActiveActorByAuthUserId('room-1', 42)).toBeNull() + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 42) as { count: number }).count).toBe(0) + await expect(emitAck(member, 'message', { + roomId: 'room-1', + id: 'after-leave', + content: 'must fail', + })).resolves.toEqual({ error: 'Not in room' }) + await expect(emitAck(member, 'join', { roomId: 'room-1' })).resolves.toEqual({ error: 'Room not found' }) + }) + + it('lets an authenticated room owner leave and clears owner-only access', async () => { + seedAuthenticatedUser(harness.db, { id: 42, username: 'owner' }) + const storage = groupServer.getStorage() + storage.createRoomWithOwner({ + id: 'owner-room', + name: 'Owner Room', + inviteCode: 'OWNER1', + owner: { + kind: 'authenticated', + authUserId: 42, + username: 'owner', + description: '', + avatar: '', + }, + }) + + const detail = await routeHandler('/api/hermes/group-chat/rooms/:roomId', 'GET') + const beforeCtx: any = { + params: { roomId: 'owner-room' }, + query: {}, + state: { user: { id: 42, username: 'owner', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + await detail(beforeCtx, async () => {}) + expect(beforeCtx.body.room).toEqual(expect.objectContaining({ id: 'owner-room', canLeave: true, canManage: true })) + + const leave = await routeHandler('/api/hermes/group-chat/rooms/:roomId/members/me', 'DELETE') + const leaveCtx: any = { + params: { roomId: 'owner-room' }, + state: { user: { id: 42, username: 'owner', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + await leave(leaveCtx, async () => {}) + + expect(leaveCtx.body).toEqual({ success: true, left: true }) + expect(storage.getRoom('owner-room')).toEqual(expect.objectContaining({ id: 'owner-room', ownerAuthUserId: null })) + expect(storage.getMemberByAuthUserId('owner-room', 42)).toBeNull() + + const afterCtx: any = { + params: { roomId: 'owner-room' }, + query: {}, + state: { user: { id: 42, username: 'owner', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + await detail(afterCtx, async () => {}) + expect(afterCtx.status).toBe(404) + expect(afterCtx.body).toEqual({ error: 'Room not found' }) + }) + + it('keeps repeated authenticated rejoins idempotent for member and actor persistence', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'readonly-token') { + return { id: 42, username: 'readonly', role: 'admin', profiles: [] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 42, username: 'readonly' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + + const first = await connectGroupChatClient(port, 'ignored-readonly-a', 'ReadOnly A', { token: 'readonly-token' }) + const second = await connectGroupChatClient(port, 'ignored-readonly-b', 'ReadOnly B', { token: 'readonly-token' }) + harness.sockets.push(first, second) + + await expect(emitAck(first, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' })).resolves.toEqual( + expect.objectContaining({ roomId: 'room-1' }), + ) + await expect(emitAck(second, 'join', { roomId: 'room-1' })).resolves.toEqual( + expect.objectContaining({ roomId: 'room-1' }), + ) + + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?', + ).get('room-1', 42) as { count: number }).count).toBe(1) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_actors WHERE roomId = ? AND authUserId = ? AND active = 1', + ).get('room-1', 42) as { count: number }).count).toBe(1) + }) + + it('uses the same missing-room shape for direct detail on strangers and absent rooms', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-private', 'Private Room', 'ROOM1') + storage.addRoomAgent('room-private', 'agent-1', 'research', 'Researcher', '', 0) + + const detail = await routeHandler('/api/hermes/group-chat/rooms/:roomId', 'GET') + + const strangerCtx: any = { + params: { roomId: 'room-private' }, + query: {}, + state: { user: { id: 9, username: 'stranger', role: 'admin', profiles: ['default'] } }, + status: 200, + body: undefined, + } + const missingCtx: any = { + params: { roomId: 'room-missing' }, + query: {}, + state: { user: { id: 9, username: 'stranger', role: 'admin', profiles: ['default'] } }, + status: 200, + body: undefined, + } + + await detail(strangerCtx, async () => {}) + await detail(missingCtx, async () => {}) + + expect(strangerCtx.status).toBe(404) + expect(strangerCtx.body).toEqual({ error: 'Room not found' }) + expect(missingCtx.status).toBe(404) + expect(missingCtx.body).toEqual({ error: 'Room not found' }) + }) + + it('uses the same missing-room shape for stranger and missing Socket.IO joins', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'stranger-token') { + return { id: 84, username: 'stranger', role: 'admin', profiles: ['default'] } as any + } + return null + }) + + seedAuthenticatedUser(harness.db, { id: 84, username: 'stranger', profiles: ['default'] }) + const storage = groupServer.getStorage() + storage.saveRoom('room-private', 'Private Room', 'ROOM1') + storage.addRoomAgent('room-private', 'agent-1', 'research', 'Researcher', '', 0) + + const stranger = await connectGroupChatClient(port, 'ignored-stranger', 'Stranger', { token: 'stranger-token' }) + harness.sockets.push(stranger) + + const existing = await emitAck(stranger, 'join', { roomId: 'room-private' }) + const missing = await emitAck(stranger, 'join', { roomId: 'room-missing' }) + + expect(existing).toEqual({ error: 'Room not found' }) + expect(missing).toEqual({ error: 'Room not found' }) + }) + + it('filters successful join room ids through the joining subject discover policy', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-visible', 'Visible Room', 'VISIBLE') + storage.saveRoom('room-hidden', 'Hidden Room', 'HIDDEN') + + const hiddenMember = await connectGroupChatClient(port, 'hidden-routing', 'Hidden Member') + const visibleMember = await connectGroupChatClient(port, 'visible-routing', 'Visible Member') + harness.sockets.push(hiddenMember, visibleMember) + await expect(emitAck(hiddenMember, 'join', { + roomId: 'room-hidden', + inviteCode: 'HIDDEN', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-hidden' })) + + const joined = await emitAck(visibleMember, 'join', { + roomId: 'room-visible', + inviteCode: 'VISIBLE', + }) + expect(joined.rooms).toEqual(['room-visible']) + }) + + it('hides mutator room existence from strangers but returns 403 to known read-only members', async () => { + seedAuthenticatedUser(harness.db, { id: 42, username: 'readonly', role: 'admin' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-private', 'Private Room', 'ROOM1') + storage.ensureAuthenticatedHumanActor({ + roomId: 'room-private', + authUserId: 42, + userId: 'auth:42', + userName: 'Read Only', + description: '', + avatar: '', + capabilities: ['room.read'], + }) + + const handler = await routeHandler('/api/hermes/group-chat/rooms/:roomId/config', 'PUT') + const request = { body: { triggerTokens: 42 } } + const strangerCtx: any = { + params: { roomId: 'room-private' }, + request, + state: { user: { id: 9, username: 'stranger', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + const readOnlyCtx: any = { + params: { roomId: 'room-private' }, + request, + state: { user: { id: 42, username: 'readonly', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + const missingCtx: any = { + params: { roomId: 'room-missing' }, + request, + state: { user: { id: 9, username: 'stranger', role: 'admin', profiles: [] } }, + status: 200, + body: undefined, + } + + await handler(strangerCtx, async () => {}) + await handler(readOnlyCtx, async () => {}) + await handler(missingCtx, async () => {}) + + expect(strangerCtx.status).toBe(404) + expect(strangerCtx.body).toEqual({ error: 'Room not found' }) + expect(missingCtx.status).toBe(404) + expect(missingCtx.body).toEqual({ error: 'Room not found' }) + expect(readOnlyCtx.status).toBe(403) + expect(readOnlyCtx.body).toEqual({ error: 'Access denied' }) + }) + + it('rate-limits failed REST invite lookups per subject without exposing valid codes', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + const handler = await routeHandler('/api/hermes/group-chat/rooms/join/:code', 'GET') + + for (let attempt = 0; attempt < 8; attempt += 1) { + const ctx: any = { + params: { code: `INVALID-${attempt}` }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(ctx, async () => {}) + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Room not found' }) + } + + const blockedValidCtx: any = { + params: { code: 'ROOM1' }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(blockedValidCtx, async () => {}) + expect(blockedValidCtx.status).toBe(404) + expect(blockedValidCtx.body).toEqual({ error: 'Room not found' }) + + const otherSubjectCtx: any = { + params: { code: 'ROOM1' }, + state: { user: { id: 78 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(otherSubjectCtx, async () => {}) + expect(otherSubjectCtx.body).toEqual({ room: expect.objectContaining({ id: 'room-1' }) }) + }) + + it('does not reset the REST invite failure budget after a successful lookup in another room', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-target', 'Target Room', 'TARGET1') + storage.saveRoom('room-known', 'Known Room', 'KNOWN99') + const handler = await routeHandler('/api/hermes/group-chat/rooms/join/:code', 'GET') + + for (let attempt = 0; attempt < 7; attempt += 1) { + const ctx: any = { + params: { code: `MISS-${attempt}` }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(ctx, async () => {}) + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Room not found' }) + } + + const knownCtx: any = { + params: { code: 'KNOWN99' }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(knownCtx, async () => {}) + expect(knownCtx.body).toEqual({ room: expect.objectContaining({ id: 'room-known' }) }) + + const exhaustingCtx: any = { + params: { code: 'MISS-final' }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(exhaustingCtx, async () => {}) + expect(exhaustingCtx.status).toBe(404) + expect(exhaustingCtx.body).toEqual({ error: 'Room not found' }) + + for (const code of ['TARGET1', 'MISS-after-lock']) { + const blockedCtx: any = { + params: { code }, + state: { user: { id: 77 } }, + ip: '127.0.0.1', + status: 200, + body: undefined, + } + await handler(blockedCtx, async () => {}) + expect(blockedCtx.status).toBe(404) + expect(blockedCtx.body).toEqual({ error: 'Room not found' }) + } + }) + + it('isolates local invite budgets by signed subject and preserves them across reconnects', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-local-limit', 'Local Limit', 'LOCAL-SECRET') + + const first = await connectGroupChatClient(port, 'routing-a', 'Local A') + harness.sockets.push(first) + const identity = await once<{ localCredential: string }>(first, 'local_identity') + for (let attempt = 0; attempt < 8; attempt += 1) { + await expect(emitAck(first, 'join', { + roomId: 'room-local-limit', + inviteCode: `MISS-${attempt}`, + })).resolves.toEqual({ error: 'Room not found' }) + } + + const second = await connectGroupChatClient(port, 'routing-b', 'Local B') + harness.sockets.push(second) + await expect(emitAck(second, 'join', { + roomId: 'room-local-limit', + inviteCode: 'LOCAL-SECRET', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-local-limit' })) + + first.disconnect() + const reconnectedFirst = await connectGroupChatClient(port, 'routing-a-changed', 'Local A', { + localCredential: identity.localCredential, + }) + harness.sockets.push(reconnectedFirst) + await expect(emitAck(reconnectedFirst, 'join', { + roomId: 'room-local-limit', + inviteCode: 'LOCAL-SECRET', + })).resolves.toEqual({ error: 'Room not found' }) + }) + + it('rate-limits failed Socket.IO invite admissions per authenticated subject', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'blocked-token') return { id: 77, username: 'blocked', role: 'admin', profiles: [] } as any + if (token === 'other-token') return { id: 78, username: 'other', role: 'admin', profiles: [] } as any + return null + }) + + seedAuthenticatedUser(harness.db, { id: 77, username: 'blocked' }) + seedAuthenticatedUser(harness.db, { id: 78, username: 'other' }) + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + const blocked = await connectGroupChatClient(port, 'ignored-blocked', 'Blocked', { token: 'blocked-token' }) + harness.sockets.push(blocked) + + for (let attempt = 0; attempt < 8; attempt += 1) { + await expect(emitAck(blocked, 'join', { + roomId: 'room-1', + inviteCode: `INVALID-${attempt}`, + })).resolves.toEqual({ error: 'Room not found' }) + } + await expect(emitAck(blocked, 'join', { + roomId: 'room-1', + inviteCode: 'ROOM1', + })).resolves.toEqual({ error: 'Room not found' }) + + const other = await connectGroupChatClient(port, 'ignored-other', 'Other', { token: 'other-token' }) + harness.sockets.push(other) + await expect(emitAck(other, 'join', { + roomId: 'room-1', + inviteCode: 'ROOM1', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-1' })) + }) + + it('does not reset the Socket.IO invite failure budget after a successful join in another room', async () => { + vi.mocked(isAuthEnabled).mockResolvedValue(true) + vi.mocked(authenticateUserToken).mockImplementation(async (token: string) => { + if (token === 'blocked-token') return { id: 77, username: 'blocked', role: 'admin', profiles: [] } as any + return null + }) + + seedAuthenticatedUser(harness.db, { id: 77, username: 'blocked' }) + const storage = groupServer.getStorage() + storage.saveRoom('room-target', 'Target Room', 'TARGET1') + storage.saveRoom('room-known', 'Known Room', 'KNOWN99') + const blocked = await connectGroupChatClient(port, 'ignored-blocked', 'Blocked', { token: 'blocked-token' }) + harness.sockets.push(blocked) + + for (let attempt = 0; attempt < 7; attempt += 1) { + await expect(emitAck(blocked, 'join', { + roomId: 'room-target', + inviteCode: `MISS-${attempt}`, + })).resolves.toEqual({ error: 'Room not found' }) + } + + await expect(emitAck(blocked, 'join', { + roomId: 'room-known', + inviteCode: 'KNOWN99', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-known' })) + + await expect(emitAck(blocked, 'join', { + roomId: 'room-target', + inviteCode: 'MISS-final', + })).resolves.toEqual({ error: 'Room not found' }) + + await expect(emitAck(blocked, 'join', { + roomId: 'room-known', + })).resolves.toEqual(expect.objectContaining({ roomId: 'room-known' })) + + await expect(emitAck(blocked, 'join', { + roomId: 'room-target', + inviteCode: 'TARGET1', + })).resolves.toEqual({ error: 'Room not found' }) + await expect(emitAck(blocked, 'join', { + roomId: 'room-target', + inviteCode: 'MISS-after-lock', + })).resolves.toEqual({ error: 'Room not found' }) + }) + + it('shares a signed local subject invite budget across Socket.IO and REST', async () => { + groupServer.getStorage().saveRoom('room-1', 'Room 1', 'ROOM1') + const local = await connectGroupChatClient(port, 'display-only', 'Local') + harness.sockets.push(local) + const identity = await once<{ localCredential: string }>(local, 'local_identity') + + for (let attempt = 0; attempt < 8; attempt += 1) { + await expect(emitAck(local, 'join', { + roomId: 'room-1', + inviteCode: `INVALID-${attempt}`, + })).resolves.toEqual({ error: 'Room not found' }) + } + + const handler = await routeHandler('/api/hermes/group-chat/rooms/join/:code', 'GET') + const ctx: any = { + params: { code: 'ROOM1' }, + state: {}, + ip: '127.0.0.1', + status: 200, + body: undefined, + get: (name: string) => name.toLowerCase() === 'x-group-chat-local-credential' + ? identity.localCredential + : '', + } + + await handler(ctx, async () => {}) + + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Room not found' }) + }) + + it('preserves explicit invite bytes on create, clone, and update room routes', async () => { + seedAuthenticatedUser(harness.db, { id: 1, username: 'root', role: 'super_admin' }) + const admin = { id: 1, username: 'root', role: 'super_admin', profiles: [] } + const create = await routeHandler('/api/hermes/group-chat/rooms', 'POST') + const clone = await routeHandler('/api/hermes/group-chat/rooms/:roomId/clone', 'POST') + const update = await routeHandler('/api/hermes/group-chat/rooms/:roomId/invite-code', 'PUT') + + const createdInviteCode = ' MiXeD Create ' + const createCtx: any = { + request: { body: { name: 'Created Room', inviteCode: createdInviteCode, agents: [] } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await create(createCtx, async () => {}) + expect(groupServer.getStorage().getRoom(createCtx.body.room.id)).toEqual( + expect.objectContaining({ inviteCode: createdInviteCode, ownerAuthUserId: 1 }), + ) + expect(groupServer.getStorage().findActiveActorByAuthUserId(createCtx.body.room.id, 1)).toEqual( + expect.objectContaining({ roomId: createCtx.body.room.id, authUserId: 1, active: 1 }), + ) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?' + ).get(createCtx.body.room.id, 1) as { count: number }).count).toBe(1) + expect(createCtx.body.room).toEqual(expect.objectContaining({ inviteCode: createdInviteCode })) + + groupServer.getStorage().saveRoom('room-source', 'Source Room', 'SOURCE1', { ownerAuthUserId: 1 }) + const clonedInviteCode = ' cLoNe Invite ' + const cloneCtx: any = { + params: { roomId: 'room-source' }, + request: { body: { name: 'Clone Room', inviteCode: clonedInviteCode } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await clone(cloneCtx, async () => {}) + expect(groupServer.getStorage().getRoom(cloneCtx.body.room.id)).toEqual( + expect.objectContaining({ inviteCode: clonedInviteCode, ownerAuthUserId: 1 }), + ) + expect(groupServer.getStorage().findActiveActorByAuthUserId(cloneCtx.body.room.id, 1)).toEqual( + expect.objectContaining({ roomId: cloneCtx.body.room.id, authUserId: 1, active: 1 }), + ) + expect((harness.db.prepare( + 'SELECT COUNT(*) AS count FROM gc_room_members WHERE roomId = ? AND authUserId = ?' + ).get(cloneCtx.body.room.id, 1) as { count: number }).count).toBe(1) + expect(cloneCtx.body.room).toEqual(expect.objectContaining({ inviteCode: clonedInviteCode })) + + const updatedInviteCode = ' UpDaTe Invite ' + const updateCtx: any = { + params: { roomId: cloneCtx.body.room.id }, + request: { body: { inviteCode: updatedInviteCode } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await update(updateCtx, async () => {}) + expect(groupServer.getStorage().getRoom(cloneCtx.body.room.id)).toEqual( + expect.objectContaining({ inviteCode: updatedInviteCode }), + ) + }) + + it('round-trips URL-significant invite bytes through the real HTTP router', async () => { + const inviteCode = ' A/B?#MiXeD ' + groupServer.getStorage().saveRoom('room-special', 'Special Room', inviteCode) + const app = new Koa() + app.use((await import('../../packages/server/src/routes/hermes/group-chat')).groupChatRoutes.routes()) + const server = createServer(app.callback()) + const baseUrl = await listenHttp(server) + + try { + const response = await fetch( + `${baseUrl}/api/hermes/group-chat/rooms/join/${encodeURIComponent(inviteCode)}`, + ) + const body = await response.json() as { room?: { id?: string; inviteCode?: string } } + + expect(response.status).toBe(200) + expect(body.room).toEqual(expect.objectContaining({ + id: 'room-special', + inviteCode: null, + })) + expect(groupServer.getStorage().getRoom('room-special')).toEqual( + expect.objectContaining({ inviteCode }), + ) + } finally { + server.close() + } + }) + + it('generates cryptographic invite defaults server-side for create and clone', async () => { + seedAuthenticatedUser(harness.db, { id: 1, username: 'root', role: 'super_admin' }) + const admin = { id: 1, username: 'root', role: 'super_admin', profiles: [] } + const create = await routeHandler('/api/hermes/group-chat/rooms', 'POST') + const createCtx: any = { + request: { body: { name: 'Generated Room', agents: [] } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await create(createCtx, async () => {}) + expect(createCtx.body.room.inviteCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + + const clone = await routeHandler('/api/hermes/group-chat/rooms/:roomId/clone', 'POST') + const cloneCtx: any = { + params: { roomId: createCtx.body.room.id }, + request: { body: { name: 'Generated Clone', inviteCode: '' } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await clone(cloneCtx, async () => {}) + expect(cloneCtx.body.room.inviteCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + expect(cloneCtx.body.room.inviteCode).not.toBe(createCtx.body.room.inviteCode) + }) + + it('treats whitespace-only invite code input as blank without trimming nonblank codes', async () => { + seedAuthenticatedUser(harness.db, { id: 1, username: 'root', role: 'super_admin' }) + const admin = { id: 1, username: 'root', role: 'super_admin', profiles: [] } + const create = await routeHandler('/api/hermes/group-chat/rooms', 'POST') + const clone = await routeHandler('/api/hermes/group-chat/rooms/:roomId/clone', 'POST') + const update = await routeHandler('/api/hermes/group-chat/rooms/:roomId/invite-code', 'PUT') + + const createCtx: any = { + request: { body: { name: 'Whitespace Create', inviteCode: ' ', agents: [] } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await create(createCtx, async () => {}) + expect(createCtx.body.room.inviteCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + + const cloneCtx: any = { + params: { roomId: createCtx.body.room.id }, + request: { body: { name: 'Whitespace Clone', inviteCode: '\t ' } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await clone(cloneCtx, async () => {}) + expect(cloneCtx.body.room.inviteCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + expect(cloneCtx.body.room.inviteCode).not.toBe(createCtx.body.room.inviteCode) + + const updateCtx: any = { + params: { roomId: cloneCtx.body.room.id }, + request: { body: { inviteCode: ' ' } }, + state: { user: admin }, + status: 200, + body: undefined, + } + await update(updateCtx, async () => {}) + expect(updateCtx.status).toBe(400) + expect(updateCtx.body).toEqual({ error: 'inviteCode is required' }) + expect(groupServer.getStorage().getRoom(cloneCtx.body.room.id)).toEqual( + expect.objectContaining({ inviteCode: cloneCtx.body.room.inviteCode }), + ) + }) + + it('returns the same invite lookup miss for stale and unknown codes without echoing raw codes', async () => { + const storage = groupServer.getStorage() + storage.saveRoom('room-1', 'Room 1', 'ROOM1') + storage.updateRoomInviteCode('room-1', 'ROOM2') + + const handler = await routeHandler('/api/hermes/group-chat/rooms/join/:code', 'GET') + const staleCtx: any = { params: { code: 'ROOM1' }, status: 200, body: undefined } + const missingCtx: any = { params: { code: 'NOPE99' }, status: 200, body: undefined } + + await handler(staleCtx, async () => {}) + await handler(missingCtx, async () => {}) + + expect(staleCtx.status).toBe(404) + expect(staleCtx.body).toEqual({ error: 'Room not found' }) + expect(missingCtx.status).toBe(404) + expect(missingCtx.body).toEqual({ error: 'Room not found' }) + expect(JSON.stringify(staleCtx.body)).not.toContain('ROOM1') + expect(JSON.stringify(missingCtx.body)).not.toContain('NOPE99') + }) +}) diff --git a/tests/server/group-chat-invite-rate-limit.test.ts b/tests/server/group-chat-invite-rate-limit.test.ts new file mode 100644 index 0000000000..3ddfbaf3bc --- /dev/null +++ b/tests/server/group-chat-invite-rate-limit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import { + GroupChatInviteAttemptLimiter, + groupChatInviteAttemptSubjectKey, +} from '../../packages/server/src/services/hermes/group-chat/invite-attempt-limiter' + +describe('group chat invite attempt limiter', () => { + it('locks after the configured failures and expires without timers', () => { + let now = 1_000 + const limiter = new GroupChatInviteAttemptLimiter({ + maxFailures: 2, + windowMs: 1_000, + lockMs: 2_000, + now: () => now, + }) + + expect(limiter.isAllowed('subject')).toBe(true) + limiter.recordFailure('subject') + expect(limiter.isAllowed('subject')).toBe(true) + limiter.recordFailure('subject') + expect(limiter.isAllowed('subject')).toBe(false) + + now += 2_001 + expect(limiter.isAllowed('subject')).toBe(true) + limiter.recordFailure('subject') + expect(limiter.isAllowed('subject')).toBe(true) + expect((limiter as any).clear).toBeUndefined() + }) + + it('preserves active penalties when the bounded map reaches capacity', () => { + let now = 1_000 + const limiter = new GroupChatInviteAttemptLimiter({ + maxFailures: 1, + windowMs: 1_000, + lockMs: 2_000, + maxEntries: 2, + now: () => now, + }) + + limiter.recordFailure('subject-a') + limiter.recordFailure('subject-b') + if (limiter.isAllowed('subject-c')) limiter.recordFailure('subject-c') + + expect(limiter.isAllowed('subject-a')).toBe(false) + expect(limiter.isAllowed('subject-b')).toBe(false) + expect(limiter.isAllowed('subject-c')).toBe(false) + + now += 2_001 + expect(limiter.isAllowed('subject-c')).toBe(true) + }) + + it('uses opaque, stable, subject-specific keys without including network or account values', () => { + const authKey = groupChatInviteAttemptSubjectKey(42, '192.0.2.1') + const sameAuthKey = groupChatInviteAttemptSubjectKey(42, '198.51.100.9') + const networkKey = groupChatInviteAttemptSubjectKey(null, '192.0.2.1') + const localKey = groupChatInviteAttemptSubjectKey(null, '192.0.2.1', 'local:subject-a') + const sameLocalKey = groupChatInviteAttemptSubjectKey(null, '198.51.100.9', 'local:subject-a') + const otherLocalKey = groupChatInviteAttemptSubjectKey(null, '192.0.2.1', 'local:subject-b') + const authWinsOverLocalKey = groupChatInviteAttemptSubjectKey(42, '203.0.113.9', 'local:subject-a') + + expect(authKey).toMatch(/^[0-9a-f]{32}$/) + expect(authKey).toBe(sameAuthKey) + expect(authKey).toBe(authWinsOverLocalKey) + expect(authKey).not.toBe(networkKey) + expect(localKey).toBe(sameLocalKey) + expect(localKey).not.toBe(otherLocalKey) + expect(localKey).not.toBe(networkKey) + expect(authKey).not.toContain('42') + expect(networkKey).not.toContain('192.0.2.1') + }) +}) diff --git a/tests/server/group-chat-member-sync.test.ts b/tests/server/group-chat-member-sync.test.ts index 440bc1188f..fcb61c4d14 100644 --- a/tests/server/group-chat-member-sync.test.ts +++ b/tests/server/group-chat-member-sync.test.ts @@ -26,6 +26,16 @@ vi.mock('../../packages/server/src/services/auth', () => ({ getToken: vi.fn(async () => 'test-token'), })) +vi.mock('../../packages/server/src/middleware/user-auth', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + loadActiveAuthenticatedUser: vi.fn((id: number | string) => Number(id) === 2 + ? { id: 2, username: 'ops', role: 'admin', profiles: ['default', 'research'] } + : { id: Number(id), username: 'root', role: 'super_admin', profiles: [] }), + } +}) + import { AgentClients, groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' import { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' @@ -33,7 +43,11 @@ import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/r function routeHandler(path: string, method: string) { const layer = (groupChatRoutes as any).stack.find((item: any) => item.path === path && item.methods.includes(method)) if (!layer) throw new Error(`Route not found: ${method} ${path}`) - return layer.stack[0] + const handler = layer.stack[0] + return async (ctx: any, next: () => Promise) => { + ctx.state ??= { user: { id: 1, username: 'root', role: 'super_admin', profiles: [] } } + return handler(ctx, next) + } } describe('Group Chat member/agent identity sync', () => { @@ -42,6 +56,40 @@ describe('Group Chat member/agent identity sync', () => { socketHandlers.clear() }) + it('does not promote runtime members when canonical actor lookups are unavailable', () => { + for (const subject of [ + { source: 'agent', userId: 'agent-1', localSubjectId: null }, + { source: 'human', userId: 'local-1', localSubjectId: 'local-subject-1' }, + ]) { + const server: any = Object.create(GroupChatServer.prototype) + server.storage = { getRoom: vi.fn(() => ({ id: 'room-1', authorizationRevision: 0 })) } + server.rooms = new Map([['room-1', { + getOnlineMemberBySocketId: vi.fn(() => ({ + socketId: 'socket-1', + userId: subject.userId, + name: 'Runtime member', + source: subject.source, + online: true, + })), + }]]) + server.socketRequestedSourceMap = new Map([['socket-1', subject.source]]) + server.socketUserMap = new Map([['socket-1', subject.userId]]) + server.socketLocalSubjectIdMap = new Map(subject.localSubjectId ? [['socket-1', subject.localSubjectId]] : []) + + const decision = server.socketAccessPolicy({ id: 'socket-1', data: {} }, 'room-1') + expect(decision).toMatchObject({ + canDiscover: false, + canJoin: false, + canRead: false, + canWrite: false, + canType: false, + canManage: false, + canInvokeAgents: false, + canApprove: false, + }) + } + }) + it('uses the persisted group-chat agent id as the runtime agent id and socket user id', async () => { const clients = new AgentClients() @@ -76,6 +124,7 @@ describe('Group Chat member/agent identity sync', () => { }) const chatServer = { getStorage: () => ({ + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1' })), getRoomAgents: vi.fn(() => []), addRoomAgent, removeRoomAgent: vi.fn(), @@ -110,6 +159,7 @@ describe('Group Chat member/agent identity sync', () => { const addRoomAgent = vi.fn() const chatServer = { getStorage: () => ({ + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1' })), getRoomAgents: vi.fn(() => []), addRoomAgent, }), @@ -148,6 +198,7 @@ describe('Group Chat member/agent identity sync', () => { }) const chatServer = { getStorage: () => ({ + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1' })), getRoomAgents: vi.fn(() => []), addRoomAgent, removeRoomAgent: vi.fn(), @@ -182,13 +233,14 @@ describe('Group Chat member/agent identity sync', () => { it('does not leave a persisted agent row and disconnects runtime state when room join fails', async () => { const addRoomAgent = vi.fn((roomId: string, agentId: string, profile: string, name: string, description: string, invited: number) => ({ id: 'row-1', roomId, agentId, profile, name, description, invited })) - const removeRoomAgent = vi.fn() + const removeAgentActorWithRetention = vi.fn() const runtimeClient = { agentId: 'agent-stable-1' } const chatServer = { getStorage: () => ({ + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1' })), getRoomAgents: vi.fn(() => []), addRoomAgent, - removeRoomAgent, + removeAgentActorWithRetention, }), agentClients: { createAgent: vi.fn(async () => runtimeClient), @@ -216,7 +268,7 @@ describe('Group Chat member/agent identity sync', () => { reason: 'join failed', }) expect(addRoomAgent).toHaveBeenCalledWith('room-1', expect.any(String), 'default', 'Worker', '', 0) - expect(removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-1') + expect(removeAgentActorWithRetention).toHaveBeenCalledWith('room-1', 'row-1') expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1') }) @@ -239,16 +291,20 @@ describe('Group Chat member/agent identity sync', () => { it('removes the runtime agent by persisted agentId and returns synchronized room state', async () => { const agentsBefore = [{ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 }] + const removal = { agent: agentsBefore[0], actorId: 'actor-1', sessionProfiles: [] } const storage = { + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1' })), getRoomAgent: vi.fn(() => agentsBefore[0]), getRoomAgents: vi.fn(() => []), - removeRoomMembersForAgent: vi.fn(), - removeRoomAgent: vi.fn(), + removeAgentActorWithRetention: vi.fn(() => removal), getRoomMembers: vi.fn(() => [{ id: 'member-1', userId: 'human-1', name: 'Han', description: '', joinedAt: 1 }]), } const chatServer = { getStorage: () => storage, agentClients: { removeAgentFromRoom: vi.fn() }, + cleanupRemovedAgentRuntime: vi.fn(async (retained: typeof removal) => { + chatServer.agentClients.removeAgentFromRoom(retained.agent.roomId, retained.agent.agentId) + }), } setGroupChatServer(chatServer as any) @@ -260,9 +316,9 @@ describe('Group Chat member/agent identity sync', () => { } await handler(ctx, async () => {}) + expect(storage.removeAgentActorWithRetention).toHaveBeenCalledWith('room-1', 'row-1') + expect(chatServer.cleanupRemovedAgentRuntime).toHaveBeenCalledWith(removal) expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1') - expect(storage.removeRoomMembersForAgent).toHaveBeenCalledWith('room-1', agentsBefore[0]) - expect(storage.removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-1') expect(ctx.body).toEqual({ success: true, agents: [], @@ -278,7 +334,10 @@ describe('Group Chat member/agent identity sync', () => { } const chatServer = { getStorage: () => storage, - deleteRoomRuntimeState: vi.fn(async () => { calls.push('runtime-delete') }), + deleteRoomRuntimeState: vi.fn(async (_roomId: string, assertAuthorized: () => void) => { + assertAuthorized() + calls.push('runtime-delete') + }), } setGroupChatServer(chatServer as any) @@ -292,7 +351,7 @@ describe('Group Chat member/agent identity sync', () => { await handler(ctx, async () => {}) expect(calls).toEqual(['runtime-delete', 'storage-delete']) - expect(chatServer.deleteRoomRuntimeState).toHaveBeenCalledWith('room-1') + expect(chatServer.deleteRoomRuntimeState).toHaveBeenCalledWith('room-1', expect.any(Function)) expect(ctx.body).toEqual({ success: true }) }) @@ -329,6 +388,7 @@ describe('Group Chat member/agent identity sync', () => { server.rooms = new Map([['room-1', { hasOnlineMember: vi.fn(() => true) }]]) server.typingState = new Map([['room-1', new Map([['human-1', { userName: 'Human', timer: setTimeout(() => {}, 1000) }]])]]) server.contextStatusState = new Map([['room-1', new Map([['Worker', { agentName: 'Worker', status: 'replying' }]])]]) + server.pendingApprovals = new Map() server.agentClients = { interruptRoom: vi.fn(async () => { calls.push('interrupt') }), disconnectRoom: vi.fn(() => { calls.push('disconnect') }), @@ -339,7 +399,7 @@ describe('Group Chat member/agent identity sync', () => { } server.storage = { saveMessageAndRefreshRoom } - await server.deleteRoomRuntimeState('room-1') + await server.deleteRoomRuntimeState('room-1', () => {}) const ack = vi.fn() server.handleMessage({ id: 'socket-1' }, { roomId: 'room-1', content: 'late', role: 'user' }, ack) @@ -352,10 +412,46 @@ describe('Group Chat member/agent identity sync', () => { expect(ack).toHaveBeenCalledWith({ error: 'Not in room' }) }) + it('fails closed before deriving a Bridge session from a weak room seed', () => { + const server = Object.create(GroupChatServer.prototype) as any + server.storage = { + getRoom: vi.fn(() => ({ id: 'room-1', sessionSeed: '0' })), + getRoomAgents: vi.fn(() => ([{ + id: 'row-1', + roomId: 'room-1', + agentId: 'agent-1', + profile: 'default', + name: 'Worker', + }])), + findActiveActorByAgentIdentity: vi.fn(() => null), + } + + expect(() => server.fenceCurrentRoomAgentSessions('room-1')).toThrow(/cryptographic session seed/i) + }) + it('rejects stale agent context and stream side-channel events after session rotation', () => { const broadcastEmit = vi.fn() const roomEmit = vi.fn() const updateRoomTotalTokens = vi.fn() + const getActorCapabilities = vi.fn((): string[] => ['room.read', 'room.write']) + const agentActor = { + id: 'actor-agent-1', + roomId: 'room-1', + actorType: 'agent', + active: 1, + authUserId: null, + agentId: 'agent-stable-1', + localSubjectId: null, + systemKey: null, + name: 'Worker', + description: '', + avatar: '', + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: 1, + updatedAt: 1, + } const agentMember = { id: 'agent-socket-1', userId: 'agent-stable-1', @@ -371,16 +467,30 @@ describe('Group Chat member/agent identity sync', () => { server.rooms = new Map([['room-1', { getOnlineMemberBySocketId: vi.fn(() => agentMember), }]]) + server.socketUserMap = new Map([['agent-socket-1', 'agent-stable-1']]) + server.socketRequestedSourceMap = new Map([['agent-socket-1', 'agent']]) server.contextStatusState = new Map() server.storage = { - getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-2' })), + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: '22222222222222222222222222222222', authorizationRevision: 0 })), getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker' })), + findActiveActorByAgentIdentity: vi.fn(() => agentActor), + getActorCapabilities, + getRoomsForProfiles: vi.fn(() => []), updateRoomTotalTokens, } server.nsp = { to: vi.fn(() => ({ emit: broadcastEmit })) } - const socket = { id: 'agent-socket-1', to: vi.fn(() => ({ emit: roomEmit })) } - const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1') - const currentSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-2') + server.emitToRoomReaders = vi.fn((_roomId: string, event: string, payload: unknown, excludeSocketId?: string) => { + ;(excludeSocketId ? roomEmit : broadcastEmit)(event, payload) + }) + const socket = { id: 'agent-socket-1', data: {}, to: vi.fn(() => ({ emit: roomEmit })) } + const sessionIdentity = { + actorId: agentActor.id, + roomAuthorizationRevision: 0, + actorAuthorizationRevision: 0, + actorContextRevision: 0, + } + const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', '11111111111111111111111111111111', sessionIdentity) + const currentSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', '22222222222222222222222222222222', sessionIdentity) server.handleContextStatus(socket, { roomId: 'room-1', @@ -417,6 +527,25 @@ describe('Group Chat member/agent identity sync', () => { expect(roomEmit).toHaveBeenCalledWith('context_status', expect.objectContaining({ roomId: 'room-1', agentName: 'Worker', status: 'replying' })) expect(broadcastEmit).toHaveBeenCalledWith('room_updated', { roomId: 'room-1', totalTokens: 456 }) expect(broadcastEmit).toHaveBeenCalledWith('message_stream_start', expect.objectContaining({ id: 'current-stream', senderName: 'Worker' })) + + getActorCapabilities.mockReturnValue([]) + expect(server.socketAccessPolicy(socket, 'room-1').canWrite).toBe(false) + server.handleContextStatus(socket, { + roomId: 'room-1', + agentName: 'Worker', + status: 'ready', + totalTokens: 789, + agentSessionId: currentSessionId, + }) + server.handleMessageStreamStart(socket, { + roomId: 'room-1', + id: 'revoked-stream', + agentSessionId: currentSessionId, + }) + + expect(updateRoomTotalTokens).not.toHaveBeenCalledWith('room-1', 789) + expect(roomEmit).not.toHaveBeenCalledWith('context_status', expect.objectContaining({ status: 'ready' })) + expect(broadcastEmit).not.toHaveBeenCalledWith('message_stream_start', expect.objectContaining({ id: 'revoked-stream' })) }) it('does not drop queued mentions when room interrupt is not synchronized', async () => { @@ -454,13 +583,13 @@ describe('Group Chat member/agent identity sync', () => { getOnlineMemberBySocketId: vi.fn(() => agentMember), }]]) server.storage = { - getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-2' })), + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: '22222222222222222222222222222222' })), getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker' })), saveMessageAndRefreshRoom, } server.nsp = { to: vi.fn(() => ({ emit })) } const ack = vi.fn() - const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1') + const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', '11111111111111111111111111111111') server.handleMessage({ id: 'agent-socket-1' }, { roomId: 'room-1', @@ -483,7 +612,10 @@ describe('Group Chat member/agent identity sync', () => { } const chatServer = { getStorage: () => storage, - clearRoomRuntimeState: vi.fn(async () => { calls.push('runtime-clear') }), + clearRoomRuntimeState: vi.fn(async (_roomId: string, assertAuthorized: () => void) => { + assertAuthorized() + calls.push('runtime-clear') + }), } setGroupChatServer(chatServer as any) @@ -497,7 +629,7 @@ describe('Group Chat member/agent identity sync', () => { await handler(ctx, async () => {}) expect(calls).toEqual(['runtime-clear', 'storage-clear']) - expect(chatServer.clearRoomRuntimeState).toHaveBeenCalledWith('room-1') + expect(chatServer.clearRoomRuntimeState).toHaveBeenCalledWith('room-1', expect.any(Function)) expect(ctx.body).toEqual({ success: true, room: expect.objectContaining({ id: 'room-1', workspace: '/tmp/workspace' }) }) }) @@ -538,19 +670,15 @@ describe('Group Chat member/agent identity sync', () => { server.typingState = new Map() server.contextStatusState = new Map() server.storage = { - getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })), + admitHumanMember: vi.fn(() => ({ status: 'not_found' })), getRoomAgentByAgentId: vi.fn(() => null), - getMemberByUserId: vi.fn(() => null), - getMemberByAuthUserId: vi.fn(() => null), - getRoomsForProfiles: vi.fn(() => []), - saveRoom: vi.fn(), - addRoomMember: vi.fn(), getRecentMessagesForUI: vi.fn(() => []), getRoomAgents: vi.fn(() => []), } + server.currentAuthenticatedSocketUser = vi.fn(() => ({ id: 42, username: 'alice', role: 'admin', profiles: ['other'] })) const socket = { id: 'socket-1', - data: { authUser: { id: 42, username: 'alice', role: 'admin', profiles: ['other'] } }, + data: {}, join: vi.fn(), to: vi.fn(() => ({ emit })), } @@ -558,8 +686,12 @@ describe('Group Chat member/agent identity sync', () => { server.handleJoin(socket, { roomId: 'room-1' }, ack) - expect(ack).toHaveBeenCalledWith({ error: 'Access denied' }) - expect(server.storage.addRoomMember).not.toHaveBeenCalled() + expect(server.storage.admitHumanMember).toHaveBeenCalledWith(expect.objectContaining({ + roomId: 'room-1', + userId: 'auth:42', + inviteCode: undefined, + })) + expect(ack).toHaveBeenCalledWith({ error: 'Room not found' }) expect(socket.join).not.toHaveBeenCalled() }) @@ -573,9 +705,11 @@ describe('Group Chat member/agent identity sync', () => { } server.agentClients = { interruptAgent: vi.fn() } server.nsp = { to: vi.fn(() => ({ emit })) } + server.removeUnauthorizedRoomSocket = vi.fn() + server.currentAuthenticatedSocketUser = vi.fn(() => ({ id: 42, username: 'member', role: 'admin', profiles: ['other'] })) const socket = { id: 'socket-1', - data: { authUser: { id: 42, username: 'member', role: 'admin', profiles: ['other'] } }, + data: {}, } const interruptAck = vi.fn() const approvalAck = vi.fn() @@ -585,6 +719,7 @@ describe('Group Chat member/agent identity sync', () => { expect(interruptAck).toHaveBeenCalledWith({ error: 'Access denied' }) expect(approvalAck).toHaveBeenCalledWith({ error: 'Access denied' }) + expect(server.removeUnauthorizedRoomSocket).toHaveBeenCalledWith(socket, 'room-1') expect(server.agentClients.interruptAgent).not.toHaveBeenCalled() }) @@ -599,6 +734,7 @@ describe('Group Chat member/agent identity sync', () => { } server.agentClients = { interruptAgent: vi.fn() } server.nsp = { to: vi.fn(() => ({ emit })) } + server.removeUnauthorizedRoomSocket = vi.fn() const socket = { id: 'agent-socket', data: {} } const interruptAck = vi.fn() const approvalAck = vi.fn() @@ -608,10 +744,12 @@ describe('Group Chat member/agent identity sync', () => { expect(interruptAck).toHaveBeenCalledWith({ error: 'Access denied' }) expect(approvalAck).toHaveBeenCalledWith({ error: 'Access denied' }) + expect(server.removeUnauthorizedRoomSocket).toHaveBeenCalledWith(socket, 'room-1') expect(server.agentClients.interruptAgent).not.toHaveBeenCalled() }) - it('allows pre-persisted agent sockets to join without creating human membership', () => { + it('requires a persisted read grant before pre-persisted agent sockets can join', () => { + const getActorCapabilities = vi.fn((): string[] => []) const server = Object.create(GroupChatServer.prototype) as any server.rooms = new Map() server.socketUserMap = new Map([['socket-agent', 'agent-stable-1']]) @@ -623,6 +761,14 @@ describe('Group Chat member/agent identity sync', () => { server.storage = { getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })), getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 })), + findActiveActorByAgentIdentity: vi.fn(() => ({ + id: 'actor-agent-1', roomId: 'room-1', actorType: 'agent', active: 1, + authUserId: null, agentId: 'agent-stable-1', localSubjectId: null, systemKey: null, + name: 'Worker', description: '', avatar: '', authorizationRevision: 0, + contextRevision: 0, tombstonedAt: null, createdAt: 1, updatedAt: 1, + })), + getActorCapabilities, + getRoomsForProfiles: vi.fn(() => []), getMemberByUserId: vi.fn(() => null), getMemberByAuthUserId: vi.fn(() => null), saveRoom: vi.fn(), @@ -638,6 +784,12 @@ describe('Group Chat member/agent identity sync', () => { } const ack = vi.fn() + server.handleJoin(socket, { roomId: 'room-1' }, ack) + expect(ack).toHaveBeenLastCalledWith({ error: 'Room not found' }) + expect(socket.join).not.toHaveBeenCalled() + + getActorCapabilities.mockReturnValue(['room.read']) + ack.mockClear() server.handleJoin(socket, { roomId: 'room-1' }, ack) expect(server.storage.getRoomAgentByAgentId).toHaveBeenCalledWith('room-1', 'agent-stable-1') @@ -658,18 +810,29 @@ describe('Group Chat member/agent identity sync', () => { server.contextStatusState = new Map() server.storage = { getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })), + admitHumanMember: vi.fn(() => ({ + status: 'admitted', + room: { id: 'room-1', name: 'Room', inviteCode: 'secret', inviteGeneration: 0, ownerAuthUserId: 7 }, + userName: 'alice', + description: '', + avatar: '', + })), getRoomAgentByAgentId: vi.fn(() => null), - getMemberByUserId: vi.fn(() => null), - getMemberByAuthUserId: vi.fn(() => null), + findActiveActorByAuthUserId: vi.fn(() => ({ + id: 'actor-human-42', roomId: 'room-1', actorType: 'authenticated_human', active: 1, + authUserId: 42, agentId: null, localSubjectId: null, systemKey: null, + name: 'alice', description: '', avatar: '', authorizationRevision: 0, + contextRevision: 0, tombstonedAt: null, createdAt: 1, updatedAt: 1, + })), + getActorCapabilities: vi.fn(() => ['room.read']), getRoomsForProfiles: vi.fn(() => []), - saveRoom: vi.fn(), - addRoomMember: vi.fn(), getRecentMessagesForUI: vi.fn(() => []), getRoomAgents: vi.fn(() => []), } + server.currentAuthenticatedSocketUser = vi.fn(() => ({ id: 42, username: 'alice', role: 'admin', profiles: ['other'] })) const socket = { id: 'socket-1', - data: { authUser: { id: 42, username: 'alice', role: 'admin', profiles: ['other'] } }, + data: {}, join: vi.fn(), to: vi.fn(() => ({ emit })), } @@ -677,7 +840,12 @@ describe('Group Chat member/agent identity sync', () => { server.handleJoin(socket, { roomId: 'room-1', inviteCode: 'secret' }, ack) - expect(server.storage.addRoomMember).toHaveBeenCalledWith('room-1', 'auth:42', 'alice', '', expect.any(String), 42) + expect(server.storage.admitHumanMember).toHaveBeenCalledWith(expect.objectContaining({ + roomId: 'room-1', + userId: 'auth:42', + inviteCode: 'secret', + requestedName: 'alice', + })) expect(socket.join).toHaveBeenCalledWith('room-1') expect(ack.mock.calls[0][0]).toEqual(expect.objectContaining({ roomId: 'room-1', messages: [], agents: [] })) }) @@ -693,24 +861,30 @@ describe('Group Chat member/agent identity sync', () => { server.typingState = new Map() server.contextStatusState = new Map() server.storage = { - getRoomAgentByAgentId: vi.fn(() => null), - getMemberByUserId: vi.fn(() => null), - getMemberByAuthUserId: vi.fn(() => ({ - id: 'member-old', - userId: 'browser-local-id', - name: 'Alice Display', + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })), + admitHumanMember: vi.fn(() => ({ + status: 'admitted', + room: { id: 'room-1', name: 'Room', inviteCode: 'secret', inviteGeneration: 0, ownerAuthUserId: 7 }, + userName: 'Alice Display', description: 'saved description', - joinedAt: 1, avatar: '', - authUserId: 42, })), - saveRoom: vi.fn(), - addRoomMember: vi.fn(), + getRoomAgentByAgentId: vi.fn(() => null), + findActiveActorByAuthUserId: vi.fn(() => ({ + id: 'actor-human-42', roomId: 'room-1', actorType: 'authenticated_human', active: 1, + authUserId: 42, agentId: null, localSubjectId: null, systemKey: null, + name: 'Alice Display', description: 'saved description', avatar: '', authorizationRevision: 0, + contextRevision: 0, tombstonedAt: null, createdAt: 1, updatedAt: 1, + })), + getActorCapabilities: vi.fn(() => ['room.read']), + getRoomsForProfiles: vi.fn(() => []), getRecentMessagesForUI: vi.fn(() => []), getRoomAgents: vi.fn(() => []), } + server.currentAuthenticatedSocketUser = vi.fn(() => ({ id: 42, username: 'alice-login', role: 'admin', profiles: ['other'] })) const socket = { id: 'socket-1', + data: {}, join: vi.fn(), to: vi.fn(() => ({ emit })), } @@ -718,14 +892,12 @@ describe('Group Chat member/agent identity sync', () => { server.handleJoin(socket, { roomId: 'room-1' }, ack) - expect(server.storage.addRoomMember).toHaveBeenCalledWith( - 'room-1', - 'auth:42', - 'Alice Display', - 'saved description', - '', - 42, - ) + expect(server.storage.admitHumanMember).toHaveBeenCalledWith(expect.objectContaining({ + roomId: 'room-1', + userId: 'auth:42', + requestedName: 'alice-login', + requestedDescription: '', + })) expect(ack.mock.calls[0][0].members).toEqual([ expect.objectContaining({ userId: 'auth:42', name: 'Alice Display' }), ]) @@ -807,7 +979,8 @@ describe('Group Chat member/agent identity sync', () => { server.socketAuthUserIdMap = new Map([['socket-1', 42]]) server.userInfoMap = new Map([['auth:42', { name: 'alice-login', description: '' }]]) server.storage = { addRoomMember: vi.fn() } - server.nsp = { to: vi.fn(() => ({ emit })) } + server.socketAccessPolicy = vi.fn(() => ({ canRead: true, canWrite: true })) + server.emitToRoomReaders = vi.fn((_roomId: string, _event: string, payload: any) => emit('member_updated', payload)) const socket = { id: 'socket-1' } const ack = vi.fn() @@ -838,15 +1011,106 @@ describe('Group Chat member/agent identity sync', () => { })) }) - it('filters room list to rooms containing one of the regular admin profiles', async () => { + it('rejects stale sockets when profile authority is revoked before member profile persistence', () => { + const liveMember = { + userId: 'auth:42', + name: 'alice-login', + description: '', + avatar: 'avatar-data', + source: 'human', + online: true, + socketId: 'socket-1', + } + const room = { + getOnlineMemberBySocketId: vi.fn(() => liveMember), + addOrUpdateMember: vi.fn(), + hasOnlineUser: vi.fn(() => false), + getMembersList: vi.fn(() => []), + } + const server = Object.create(GroupChatServer.prototype) as any + server.rooms = new Map([['room-family', room]]) + server.socketAuthUserIdMap = new Map([['socket-1', 42]]) + server.userInfoMap = new Map([['auth:42', { name: 'alice-login', description: '' }]]) + server.storage = { addRoomMember: vi.fn() } + server.socketAccessPolicy = vi.fn(() => ({ canRead: false, canWrite: false })) + server.removeUnauthorizedRoomSocket = vi.fn(() => liveMember) + server.emitToRoomReaders = vi.fn() + const socket = { id: 'socket-1' } + const ack = vi.fn() + + server.handleUpdateMemberProfile(socket, { + roomId: 'room-family', + name: 'Revived member', + description: 'should not persist', + }, ack) + + expect(server.removeUnauthorizedRoomSocket).toHaveBeenCalledWith(socket, 'room-family') + expect(server.storage.addRoomMember).not.toHaveBeenCalled() + expect(room.addOrUpdateMember).not.toHaveBeenCalled() + expect(server.emitToRoomReaders).toHaveBeenCalledWith('room-family', 'member_left', { + roomId: 'room-family', + memberId: 'auth:42', + memberName: 'alice-login', + members: [], + }, 'socket-1') + expect(ack).toHaveBeenCalledWith({ error: 'Access denied' }) + }) + + it('rejects read-only member profile updates without evicting the still-authorized reader', () => { + const liveMember = { + userId: 'auth:42', + name: 'alice-login', + description: '', + avatar: 'avatar-data', + source: 'human', + online: true, + socketId: 'socket-1', + } + const room = { + getOnlineMemberBySocketId: vi.fn(() => liveMember), + addOrUpdateMember: vi.fn(), + hasOnlineUser: vi.fn(() => true), + getMembersList: vi.fn(() => [liveMember]), + } + const server = Object.create(GroupChatServer.prototype) as any + server.rooms = new Map([['room-family', room]]) + server.socketAuthUserIdMap = new Map([['socket-1', 42]]) + server.userInfoMap = new Map([['auth:42', { name: 'alice-login', description: '' }]]) + server.storage = { addRoomMember: vi.fn() } + server.socketAccessPolicy = vi.fn(() => ({ canRead: true, canWrite: false })) + server.removeUnauthorizedRoomSocket = vi.fn() + server.emitToRoomReaders = vi.fn() + const socket = { id: 'socket-1' } + const ack = vi.fn() + + server.handleUpdateMemberProfile(socket, { + roomId: 'room-family', + name: 'Read Only', + description: 'should reject without eviction', + }, ack) + + expect(server.removeUnauthorizedRoomSocket).not.toHaveBeenCalled() + expect(server.storage.addRoomMember).not.toHaveBeenCalled() + expect(room.addOrUpdateMember).not.toHaveBeenCalled() + expect(server.emitToRoomReaders).not.toHaveBeenCalled() + expect(ack).toHaveBeenCalledWith({ error: 'Access denied' }) + }) + + it('does not expose rooms to regular admins solely because an agent profile matches', async () => { const allRooms = [ { id: 'room-default', name: 'Default', inviteCode: null }, { id: 'room-private', name: 'Private', inviteCode: null }, ] - const visibleRooms = [allRooms[0]] const storage = { getAllRooms: vi.fn(() => allRooms), - getRoomsForProfiles: vi.fn(() => visibleRooms), + getRoom: vi.fn((roomId: string) => allRooms.find(room => room.id === roomId) || null), + getRoomAgents: vi.fn((roomId: string) => roomId === 'room-default' + ? [{ profile: 'default' }] + : [{ profile: 'private' }]), + getRoomsForProfiles: vi.fn(() => [allRooms[0]]), + findActiveActorByAuthUserId: vi.fn(() => null), + getMemberByAuthUserId: vi.fn(() => null), + getActorCapabilities: vi.fn(() => []), } setGroupChatServer({ getStorage: () => storage } as any) @@ -858,15 +1122,16 @@ describe('Group Chat member/agent identity sync', () => { } await handler(ctx, async () => {}) - expect(storage.getRoomsForProfiles).toHaveBeenCalledWith(['default', 'research']) - expect(storage.getAllRooms).not.toHaveBeenCalled() - expect(ctx.body).toEqual({ rooms: [expect.objectContaining({ id: 'room-default', inviteCode: null, canManage: true })] }) + expect(storage.getAllRooms).toHaveBeenCalledOnce() + expect(storage.getRoomsForProfiles).not.toHaveBeenCalled() + expect(ctx.body).toEqual({ rooms: [] }) }) it('keeps room list unrestricted for super admins', async () => { const rooms = [{ id: 'room-1', name: 'All', inviteCode: null }] const storage = { getAllRooms: vi.fn(() => rooms), + getRoom: vi.fn((roomId: string) => rooms.find(room => room.id === roomId) || null), getRoomsForProfiles: vi.fn(() => []), } setGroupChatServer({ getStorage: () => storage } as any) @@ -903,14 +1168,47 @@ describe('Group Chat member/agent identity sync', () => { ['human-socket', 'human'], ['agent-socket', 'agent'], ]) + server.socketLocalSubjectIdMap = new Map([ + ['human-socket', 'local-subject-1'], + ]) server.userInfoMap = new Map([ ['human-1', { name: 'Human', description: '' }], ['agent-1', { name: '丫鬟', description: '' }], ]) server.agentClients = { processMentions: vi.fn(async () => undefined) } - const agentSessionId = groupBridgeSessionId('room-1', 'default', '丫鬟', 'seed-1') + const agentSessionId = groupBridgeSessionId( + 'room-1', + 'default', + '丫鬟', + '11111111111111111111111111111111', + { + actorId: 'actor-agent-1', + roomAuthorizationRevision: 0, + actorAuthorizationRevision: 0, + actorContextRevision: 0, + }, + ) server.storage = { - getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-1' })), + getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: '11111111111111111111111111111111', authorizationRevision: 0 })), + findActiveActorByLocalSubjectId: vi.fn(() => ({ + id: 'actor-human-1', + roomId: 'room-1', + actorType: 'local', + localSubjectId: 'local-subject-1', + active: true, + authorizationRevision: 0, + contextRevision: 0, + })), + findActiveActorByAgentIdentity: vi.fn(() => ({ + id: 'actor-agent-1', + roomId: 'room-1', + actorType: 'agent', + agentId: 'agent-1', + active: true, + authorizationRevision: 0, + contextRevision: 0, + })), + getActorCapabilities: vi.fn(() => ['room.read', 'room.write', 'agent.invoke']), getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: '丫鬟' })), saveMessageAndRefreshRoom: vi.fn((msg: any) => ({ message: msg, totalTokens: 123 })), } @@ -933,6 +1231,17 @@ describe('Group Chat member/agent identity sync', () => { mentionDepth: 1, })) + server.agentClients.processMentions.mockClear() + server.storage.getActorCapabilities.mockReturnValue(['room.read', 'room.write']) + server.handleMessage({ id: 'agent-socket' }, { + roomId: 'room-1', + content: '@all no invoke authority', + role: 'assistant', + mentionDepth: 1, + agentSessionId, + }, vi.fn()) + expect(server.agentClients.processMentions).not.toHaveBeenCalled() + server.agentClients.processMentions.mockClear() server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all too deep', role: 'assistant', mentionDepth: 4, agentSessionId }, vi.fn()) expect(server.agentClients.processMentions).not.toHaveBeenCalled() diff --git a/tests/server/group-chat-routes-baseline.test.ts b/tests/server/group-chat-routes-baseline.test.ts index 0f553512e6..abfdd7a9ff 100644 --- a/tests/server/group-chat-routes-baseline.test.ts +++ b/tests/server/group-chat-routes-baseline.test.ts @@ -2,6 +2,23 @@ import Koa from 'koa' import bodyParser from '@koa/bodyparser' import { createServer, type Server as HttpServer } from 'http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const refreshedAuth = vi.hoisted(() => ({ + active: true, + role: 'super_admin' as 'super_admin' | 'admin' | 'user', + profiles: [] as string[], +})) + +vi.mock('../../packages/server/src/middleware/user-auth', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + loadActiveAuthenticatedUser: vi.fn((id: number | string) => refreshedAuth.active + ? { id: Number(id), username: 'root', role: refreshedAuth.role, profiles: [...refreshedAuth.profiles] } + : null), + } +}) + import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' function listen(server: HttpServer): Promise { @@ -12,21 +29,66 @@ function listen(server: HttpServer): Promise { })) } +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe('group chat REST route baseline', () => { let httpServer: HttpServer let baseUrl: string let storage: any let agentClients: any let clearRoomRuntimeState: ReturnType + let deleteRoomRuntimeState: ReturnType + let forceCompress: ReturnType + let authenticated: boolean beforeEach(async () => { + authenticated = true + refreshedAuth.active = true + refreshedAuth.role = 'super_admin' + refreshedAuth.profiles = [] storage = { rooms: new Map(), agents: new Map(), messages: new Map(), members: new Map(), - saveRoom: vi.fn((id, name, inviteCode, config) => storage.rooms.set(id, { id, name, inviteCode, totalTokens: 0, sessionSeed: '0', ...config })), + actors: new Map(), + capabilities: new Map(), + saveRoom: vi.fn((id, name, inviteCode, config) => storage.rooms.set(id, { id, name, inviteCode, totalTokens: 0, sessionSeed: '11111111111111111111111111111111', ...config })), + createRoomWithOwner: vi.fn(({ id, name, inviteCode, config, owner }) => { + storage.rooms.set(id, { + id, + name, + inviteCode, + totalTokens: 0, + sessionSeed: '11111111111111111111111111111111', + ownerAuthUserId: owner?.kind === 'authenticated' ? owner.authUserId : null, + ...config, + }) + if (owner?.kind === 'local') { + const actorId = `actor-${id}` + storage.actors.set(`${id}:${owner.localSubjectId}`, { + id: actorId, + roomId: id, + actorType: 'local', + localSubjectId: owner.localSubjectId, + active: 1, + authorizationRevision: 0, + contextRevision: 0, + }) + storage.capabilities.set(actorId, ['room.read', 'room.manage', 'approval.respond']) + } + }), getRoom: vi.fn((id) => storage.rooms.get(id)), + findActiveActorByLocalSubjectId: vi.fn((roomId, localSubjectId) => storage.actors.get(`${roomId}:${localSubjectId}`) || null), + getActorCapabilities: vi.fn((actorId) => storage.capabilities.get(actorId) || []), getAllRooms: vi.fn(() => [...storage.rooms.values()]), getRoomsForProfiles: vi.fn(() => [...storage.rooms.values()]), getRecentMessagesForUI: vi.fn((roomId, limit = 150, offset = 0) => (storage.messages.get(roomId) || []).slice(offset, offset + limit)), @@ -40,6 +102,12 @@ describe('group chat REST route baseline', () => { return row }), getRoomAgent: vi.fn((roomId, ref) => (storage.agents.get(roomId) || []).find((a: any) => a.id === ref || a.agentId === ref) || null), + removeAgentActorWithRetention: vi.fn((roomId, ref) => { + const agent = (storage.agents.get(roomId) || []).find((candidate: any) => candidate.id === ref || candidate.agentId === ref) || null + if (!agent) return null + storage.agents.set(roomId, (storage.agents.get(roomId) || []).filter((candidate: any) => candidate.id !== ref && candidate.agentId !== ref)) + return { agent, actorId: null, sessionProfiles: [] } + }), removeRoomMembersForAgent: vi.fn(), removeRoomAgent: vi.fn((roomId, ref) => storage.agents.set(roomId, (storage.agents.get(roomId) || []).filter((a: any) => a.id !== ref && a.agentId !== ref))), clearRoomContext: vi.fn((roomId) => { const room = storage.rooms.get(roomId); if (room) Object.assign(room, { totalTokens: 0, sessionSeed: 'rotated' }) }), @@ -53,10 +121,43 @@ describe('group chat REST route baseline', () => { addAgentToRoom: vi.fn(async () => ({})), removeAgentFromRoom: vi.fn(), disconnectRoom: vi.fn(), + getSummarySessionContext: vi.fn(() => ({ + profile: 'default', + sessionRegistrar: () => ({ + sessionId: 'gc_h_test', + authorizationGuard: () => true, + release: vi.fn(), + }), + })), } clearRoomRuntimeState = vi.fn() - setGroupChatServer({ getStorage: () => storage, agentClients, clearRoomRuntimeState } as any) + deleteRoomRuntimeState = vi.fn() + forceCompress = vi.fn(async () => ({ summary: 'summary' })) + const cleanupRemovedAgentRuntime = vi.fn(async (removal: any) => { + if (removal?.agent) agentClients.removeAgentFromRoom(removal.agent.roomId, removal.agent.agentId) + }) + const resolveLocalCredentialSubject = vi.fn(async (credential: unknown) => credential === 'signed-local' ? 'local:11111111111111111111111111111111' : null) + setGroupChatServer({ + getStorage: () => storage, + agentClients, + clearRoomRuntimeState, + deleteRoomRuntimeState, + cleanupRemovedAgentRuntime, + resolveLocalCredentialSubject, + getContextEngine: () => ({ forceCompress }), + } as any) const app = new Koa() + app.use(async (ctx, next) => { + if (authenticated) { + ctx.state.user = { + id: 1, + username: 'root', + role: refreshedAuth.role, + profiles: [...refreshedAuth.profiles], + } + } + await next() + }) app.use(bodyParser()) app.use(groupChatRoutes.routes()) httpServer = createServer(app.callback()) @@ -68,15 +169,17 @@ describe('group chat REST route baseline', () => { setGroupChatServer(null as any) }) - it('requires name and inviteCode when creating a room', async () => { + it('generates an invite code when creating a room without an explicit code', async () => { const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Room' }), }) - expect(res.status).toBe(400) - await expect(res.json()).resolves.toEqual({ error: 'name and inviteCode are required' }) + expect(res.status).toBe(200) + const body = await res.json() as any + expect(body.room.inviteCode).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{16}$/) + expect(body.room).not.toHaveProperty('sessionSeed') }) it('rejects reserved @all agent names when creating a room', async () => { @@ -111,7 +214,155 @@ describe('group chat REST route baseline', () => { expect.objectContaining({ profile: 'default', ok: true }), expect.objectContaining({ profile: 'bad-profile', ok: false, code: 'PROFILE_AGENT_CONNECT_FAILED' }), ]) - expect(storage.saveRoom).toHaveBeenCalled() + expect(storage.createRoomWithOwner).toHaveBeenCalled() + }) + + it('rejects a nested create profile outside the authenticated requester assignments before room creation', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['default'] + + const response = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Room', + agents: [{ profile: 'restricted-profile', name: 'Restricted' }], + }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "restricted-profile" is not available for this user' }) + expect(storage.createRoomWithOwner).not.toHaveBeenCalled() + expect(agentClients.createAgent).not.toHaveBeenCalled() + }) + + it('rejects cloning source agents outside the authenticated requester assignments before room creation', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['default'] + storage.rooms.set('source-room', { + id: 'source-room', + name: 'Source', + inviteCode: 'SOURCE1', + ownerAuthUserId: 1, + }) + storage.agents.set('source-room', [{ + id: 'row-restricted', + roomId: 'source-room', + agentId: 'agent-restricted', + profile: 'restricted-profile', + name: 'Restricted', + description: '', + invited: 0, + }]) + + const response = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/source-room/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Clone' }), + }) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "restricted-profile" is not available for this user' }) + expect(storage.createRoomWithOwner).not.toHaveBeenCalled() + expect(agentClients.createAgent).not.toHaveBeenCalled() + }) + + it('binds auth-disabled REST discovery, reads, management, and creation to the signed local subject', async () => { + authenticated = false + const credentialHeaders = { 'X-Group-Chat-Local-Credential': 'signed-local' } + storage.rooms.set('room-private', { + id: 'room-private', + name: 'Private', + inviteCode: 'PRIVATE1', + workspace: '/private/workspace', + authorizationRevision: 0, + }) + storage.messages.set('room-private', [{ id: 'secret-message' }]) + + const anonymousList = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`) + await expect(anonymousList.json()).resolves.toEqual({ rooms: [] }) + expect((await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-private`)).status).toBe(404) + expect((await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-private`, { + headers: { 'X-Group-Chat-Local-Credential': 'forged' }, + })).status).toBe(404) + + const missingIdentityCreate = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Unowned' }), + }) + expect(missingIdentityCreate.status).toBe(401) + await expect(missingIdentityCreate.json()).resolves.toEqual({ error: 'Group chat identity required' }) + + const ungrantedList = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { headers: credentialHeaders }) + await expect(ungrantedList.json()).resolves.toEqual({ rooms: [] }) + + const actor = { + id: 'local-actor-1', + roomId: 'room-private', + actorType: 'local', + authUserId: null, + agentId: null, + localSubjectId: 'local:11111111111111111111111111111111', + systemKey: null, + name: 'Local user', + description: '', + avatar: '', + active: 1, + authorizationRevision: 0, + contextRevision: 0, + tombstonedAt: null, + createdAt: 1, + updatedAt: 1, + } + storage.actors.set('room-private:local:11111111111111111111111111111111', actor) + storage.capabilities.set(actor.id, ['room.read', 'approval.respond']) + + const readableList = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { headers: credentialHeaders }) + const readableBody = await readableList.json() as any + expect(readableBody.rooms).toEqual([ + expect.objectContaining({ + id: 'room-private', + inviteCode: null, + workspace: '', + canManage: false, + canApprove: true, + }), + ]) + const readableDetail = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-private`, { headers: credentialHeaders }) + expect(readableDetail.status).toBe(200) + await expect(readableDetail.json()).resolves.toMatchObject({ messages: [{ id: 'secret-message' }] }) + expect((await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-private/invite-code`, { + method: 'PUT', + headers: { ...credentialHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode: 'ROTATED' }), + })).status).toBe(403) + + storage.capabilities.set(actor.id, ['room.read', 'room.manage', 'approval.respond']) + const managedList = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { headers: credentialHeaders }) + await expect(managedList.json()).resolves.toEqual({ + rooms: [expect.objectContaining({ + id: 'room-private', + inviteCode: 'PRIVATE1', + workspace: '/private/workspace', + canManage: true, + canApprove: true, + })], + }) + + const localCreate = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { + method: 'POST', + headers: { ...credentialHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Owned locally' }), + }) + expect(localCreate.status).toBe(200) + expect(storage.createRoomWithOwner).toHaveBeenLastCalledWith(expect.objectContaining({ + owner: { + kind: 'local', + localSubjectId: 'local:11111111111111111111111111111111', + username: 'Local user', + }, + })) }) it('returns room detail with paging metadata, agents, and members', async () => { @@ -136,6 +387,223 @@ describe('group chat REST route baseline', () => { }) }) + it('rejects an in-flight agent add when the requester is disabled before persistence', async () => { + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const runtimeClient = { agentId: 'provisional-agent', disconnect: vi.fn() } + const creation = deferred() + agentClients.createAgent.mockReturnValueOnce(creation.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: 'default', name: 'Agent' }), + }) + await vi.waitFor(() => expect(agentClients.createAgent).toHaveBeenCalledOnce()) + + refreshedAuth.active = false + creation.resolve(runtimeClient) + const response = await pendingResponse + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Room not found' }) + expect(storage.addRoomAgent).not.toHaveBeenCalled() + expect(agentClients.addAgentToRoom).not.toHaveBeenCalled() + expect(runtimeClient.disconnect).toHaveBeenCalledOnce() + expect(agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'provisional-agent') + }) + + it('rejects an in-flight agent add when the requested profile assignment is revoked before persistence', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['default'] + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const runtimeClient = { agentId: 'provisional-profile-agent', disconnect: vi.fn() } + const creation = deferred() + agentClients.createAgent.mockReturnValueOnce(creation.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: 'default', name: 'Agent' }), + }) + await vi.waitFor(() => expect(agentClients.createAgent).toHaveBeenCalledOnce()) + + refreshedAuth.profiles = [] + creation.resolve(runtimeClient) + const response = await pendingResponse + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "default" is not available for this user' }) + expect(storage.addRoomAgent).not.toHaveBeenCalled() + expect(agentClients.addAgentToRoom).not.toHaveBeenCalled() + expect(runtimeClient.disconnect).toHaveBeenCalledOnce() + expect(agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'provisional-profile-agent') + }) + + it('rolls back an agent when the requested profile assignment is revoked during room join', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['default'] + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const runtimeClient = { agentId: 'joined-profile-agent', disconnect: vi.fn() } + const roomJoin = deferred() + agentClients.createAgent.mockResolvedValueOnce(runtimeClient) + agentClients.addAgentToRoom.mockReturnValueOnce(roomJoin.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: 'default', name: 'Agent' }), + }) + await vi.waitFor(() => expect(agentClients.addAgentToRoom).toHaveBeenCalledOnce()) + expect(storage.addRoomAgent).toHaveBeenCalledOnce() + + refreshedAuth.profiles = [] + roomJoin.resolve() + const response = await pendingResponse + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "default" is not available for this user' }) + expect(storage.removeAgentActorWithRetention).toHaveBeenCalledOnce() + expect(storage.getRoomAgents('room-1')).toEqual([]) + expect(runtimeClient.disconnect).toHaveBeenCalledOnce() + expect(agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'joined-profile-agent') + }) + + it('rolls back a new room when an earlier agent profile is revoked during a later agent join', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['profile-a', 'profile-b'] + const secondJoin = deferred() + agentClients.addAgentToRoom + .mockResolvedValueOnce(undefined) + .mockReturnValueOnce(secondJoin.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'Provisioning Room', + agents: [ + { profile: 'profile-a', name: 'Agent A' }, + { profile: 'profile-b', name: 'Agent B' }, + ], + }), + }) + await vi.waitFor(() => expect(agentClients.addAgentToRoom).toHaveBeenCalledTimes(2)) + const roomId = storage.createRoomWithOwner.mock.calls[0][0].id + expect(storage.getRoomAgents(roomId)).toHaveLength(2) + + refreshedAuth.profiles = ['profile-b'] + secondJoin.resolve() + const response = await pendingResponse + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "profile-a" is not available for this user' }) + expect(storage.rooms.has(roomId)).toBe(false) + expect(storage.getRoomAgents(roomId)).toEqual([]) + expect(agentClients.disconnectRoom).toHaveBeenCalledWith(roomId) + }) + + it('rolls back only the clone when an earlier copied profile is revoked during a later agent join', async () => { + refreshedAuth.role = 'admin' + refreshedAuth.profiles = ['profile-a', 'profile-b'] + storage.rooms.set('source-room', { + id: 'source-room', + name: 'Source', + inviteCode: 'SOURCE1', + ownerAuthUserId: 1, + }) + storage.agents.set('source-room', [ + { id: 'source-a', roomId: 'source-room', agentId: 'source-agent-a', profile: 'profile-a', name: 'Agent A', description: '', invited: 0 }, + { id: 'source-b', roomId: 'source-room', agentId: 'source-agent-b', profile: 'profile-b', name: 'Agent B', description: '', invited: 0 }, + ]) + const secondJoin = deferred() + agentClients.addAgentToRoom + .mockResolvedValueOnce(undefined) + .mockReturnValueOnce(secondJoin.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/source-room/clone`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Clone' }), + }) + await vi.waitFor(() => expect(agentClients.addAgentToRoom).toHaveBeenCalledTimes(2)) + const clonedRoomId = storage.createRoomWithOwner.mock.calls[0][0].id + expect(storage.getRoomAgents(clonedRoomId)).toHaveLength(2) + + refreshedAuth.profiles = ['profile-b'] + secondJoin.resolve() + const response = await pendingResponse + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Profile "profile-a" is not available for this user' }) + expect(storage.rooms.has('source-room')).toBe(true) + expect(storage.getRoomAgents('source-room')).toHaveLength(2) + expect(storage.rooms.has(clonedRoomId)).toBe(false) + expect(storage.getRoomAgents(clonedRoomId)).toEqual([]) + expect(agentClients.disconnectRoom).toHaveBeenCalledWith(clonedRoomId) + }) + + it('rejects an in-flight local agent add after durable room.manage revocation', async () => { + authenticated = false + const localSubjectId = 'local:11111111111111111111111111111111' + const actorId = 'actor-local-manager' + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1' }) + storage.actors.set(`room-1:${localSubjectId}`, { + id: actorId, + roomId: 'room-1', + actorType: 'local', + localSubjectId, + active: 1, + authorizationRevision: 0, + contextRevision: 0, + }) + storage.capabilities.set(actorId, ['room.read', 'room.manage']) + const runtimeClient = { agentId: 'provisional-local-agent', disconnect: vi.fn() } + const creation = deferred() + agentClients.createAgent.mockReturnValueOnce(creation.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Group-Chat-Local-Credential': 'signed-local', + }, + body: JSON.stringify({ profile: 'default', name: 'Agent' }), + }) + await vi.waitFor(() => expect(agentClients.createAgent).toHaveBeenCalledOnce()) + + storage.capabilities.set(actorId, ['room.read']) + creation.resolve(runtimeClient) + const response = await pendingResponse + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Access denied' }) + expect(storage.addRoomAgent).not.toHaveBeenCalled() + expect(runtimeClient.disconnect).toHaveBeenCalledOnce() + }) + + it('rolls back an agent persisted before management authority is revoked during room join', async () => { + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const roomJoin = deferred() + agentClients.addAgentToRoom.mockReturnValueOnce(roomJoin.promise) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: 'default', name: 'Agent' }), + }) + await vi.waitFor(() => expect(agentClients.addAgentToRoom).toHaveBeenCalledOnce()) + expect(storage.addRoomAgent).toHaveBeenCalledOnce() + + refreshedAuth.active = false + roomJoin.resolve() + const response = await pendingResponse + + expect(response.status).toBe(404) + expect(storage.removeAgentActorWithRetention).toHaveBeenCalledOnce() + expect(storage.getRoomAgents('room-1')).toEqual([]) + expect(agentClients.removeAgentFromRoom).toHaveBeenCalledOnce() + }) + it('rejects duplicate room agent profiles', async () => { storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1' }) storage.agents.set('room-1', [{ id: 'row-agent', agentId: 'agent-1', profile: 'default', name: 'Agent' }]) @@ -152,18 +620,82 @@ describe('group chat REST route baseline', () => { it('removes an agent by row id and disconnects runtime by persisted agent id', async () => { const agent = { id: 'row-agent', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Agent' } + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1' }) storage.agents.set('room-1', [agent]) const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/row-agent`, { method: 'DELETE' }) const body = await res.json() expect(res.status).toBe(200) - expect(storage.removeRoomMembersForAgent).toHaveBeenCalledWith('room-1', agent) - expect(storage.removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-agent') + expect(storage.removeAgentActorWithRetention).toHaveBeenCalledWith('room-1', 'row-agent') + expect(storage.removeRoomMembersForAgent).not.toHaveBeenCalled() + expect(storage.removeRoomAgent).not.toHaveBeenCalled() expect(agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-1') expect(body).toMatchObject({ success: true, agents: [], members: [] }) }) + it('aborts manual compression when requester authority changes during asynchronous compression', async () => { + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const compression = deferred() + forceCompress.mockImplementationOnce(async (_roomId: string, _profile: string, registerSession: () => { authorizationGuard: () => boolean }) => { + const session = registerSession() + await compression.promise + if (!session.authorizationGuard()) throw new Error('authorization changed') + return { summary: 'should not be returned' } + }) + + const pending = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/compress`, { method: 'POST' }) + await vi.waitFor(() => expect(forceCompress).toHaveBeenCalledOnce()) + refreshedAuth.active = false + compression.resolve() + const response = await pending + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Room not found' }) + }) + + it('does not delete persisted room data when management authority is revoked during runtime cleanup', async () => { + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', ownerAuthUserId: 1 }) + const runtimeCleanup = deferred() + deleteRoomRuntimeState.mockImplementationOnce(async (_roomId: string, assertAuthorized: () => void) => { + await runtimeCleanup.promise + assertAuthorized() + }) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1`, { method: 'DELETE' }) + await vi.waitFor(() => expect(deleteRoomRuntimeState).toHaveBeenCalledWith('room-1', expect.any(Function))) + + refreshedAuth.active = false + runtimeCleanup.resolve() + const response = await pendingResponse + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Room not found' }) + expect(storage.deleteRoom).not.toHaveBeenCalled() + expect(storage.rooms.has('room-1')).toBe(true) + }) + + it('does not clear persisted context when management authority is revoked during runtime cleanup', async () => { + storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', totalTokens: 99, ownerAuthUserId: 1 }) + const runtimeCleanup = deferred() + clearRoomRuntimeState.mockImplementationOnce(async (_roomId: string, assertAuthorized: () => void) => { + await runtimeCleanup.promise + assertAuthorized() + }) + + const pendingResponse = fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/clear-context`, { method: 'POST' }) + await vi.waitFor(() => expect(clearRoomRuntimeState).toHaveBeenCalledWith('room-1', expect.any(Function))) + + refreshedAuth.active = false + runtimeCleanup.resolve() + const response = await pendingResponse + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Room not found' }) + expect(storage.clearRoomContext).not.toHaveBeenCalled() + expect(storage.rooms.get('room-1').totalTokens).toBe(99) + }) + it('clears room context and runtime state while returning the updated room', async () => { storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', totalTokens: 99, sessionSeed: 'old' }) @@ -172,7 +704,8 @@ describe('group chat REST route baseline', () => { expect(res.status).toBe(200) expect(storage.clearRoomContext).toHaveBeenCalledWith('room-1') - expect(clearRoomRuntimeState).toHaveBeenCalledWith('room-1') - expect(body).toMatchObject({ success: true, room: { id: 'room-1', totalTokens: 0, sessionSeed: 'rotated' } }) + expect(clearRoomRuntimeState).toHaveBeenCalledWith('room-1', expect.any(Function)) + expect(body).toMatchObject({ success: true, room: { id: 'room-1', totalTokens: 0 } }) + expect(body.room).not.toHaveProperty('sessionSeed') }) }) diff --git a/tests/server/group-chat-streaming.test.ts b/tests/server/group-chat-streaming.test.ts index 13d454f995..e52397a9b7 100644 --- a/tests/server/group-chat-streaming.test.ts +++ b/tests/server/group-chat-streaming.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connectGroupChatClient, + currentRoomAgentSessionId, createTestGroupChatServer, emitAck, once, } from './group-chat-test-helpers' -import { GROUP_CHAT_AGENT_SOCKET_SECRET, groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' +import { GROUP_CHAT_AGENT_SOCKET_SECRET } from '../../packages/server/src/services/hermes/group-chat/agent-clients' import type { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' describe('group chat streaming baseline', () => { @@ -37,12 +38,7 @@ describe('group chat streaming baseline', () => { await emitAck(alice, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) await emitAck(bob, 'join', { roomId: 'room-1', inviteCode: 'ROOM1' }) await emitAck(worker, 'join', { roomId: 'room-1' }) - const agentSessionId = groupBridgeSessionId( - 'room-1', - 'default', - 'Worker', - String(groupServer.getStorage().getRoom('room-1')?.sessionSeed || '0'), - ) + const agentSessionId = currentRoomAgentSessionId(groupServer, 'room-1', 'agent-worker', 'default', 'Worker') return { alice, bob, worker, agentSessionId } } @@ -72,6 +68,35 @@ describe('group chat streaming baseline', () => { expect(await streamEnd).toEqual({ roomId: 'room-1', id: 'stream-1' }) }) + it('evicts actors whose read grant is revoked before emitting confidential room output', async () => { + const { alice, bob, worker, agentSessionId } = await joinPair() + const localSubjectId = (groupServer as any).socketLocalSubjectIdMap.get(bob.id) as string + const actor = groupServer.getStorage().findActiveActorByLocalSubjectId('room-1', localSubjectId) + expect(actor).not.toBeNull() + if (!actor) throw new Error('missing local actor') + harness.db.prepare('DELETE FROM gc_room_actor_capabilities WHERE actorId = ?').run(actor.id) + + const authorizedDelta = once(alice, 'message_stream_delta') + const revokedDelta = once(bob, 'message_stream_delta', 150) + worker.emit('message_stream_delta', { + roomId: 'room-1', + id: 'stream-after-revocation', + delta: 'private output', + agentSessionId, + }) + + await expect(authorizedDelta).resolves.toEqual({ + roomId: 'room-1', + id: 'stream-after-revocation', + delta: 'private output', + }) + await expect(revokedDelta).rejects.toThrow('timeout waiting for message_stream_delta') + await expect(emitAck(bob, 'message', { + roomId: 'room-1', + content: 'still here?', + })).resolves.toEqual({ error: 'Not in room' }) + }) + it('ignores stream events emitted by human sockets', async () => { const { alice, bob } = await joinPair() const unexpectedStart = once(bob, 'message_stream_start', 100) diff --git a/tests/server/group-chat-test-helpers.ts b/tests/server/group-chat-test-helpers.ts index 197ce0f6fa..1595d9397e 100644 --- a/tests/server/group-chat-test-helpers.ts +++ b/tests/server/group-chat-test-helpers.ts @@ -5,16 +5,57 @@ import { vi } from 'vitest' const groupChatDbMock = vi.hoisted(() => ({ current: null as DatabaseSync | null })) -vi.mock('../../packages/server/src/db/index', () => ({ getDb: () => groupChatDbMock.current })) +type BufferedClientSocket = ClientSocket & { + __bufferedEvents__?: Map +} + +vi.mock('../../packages/server/src/db/index', async importOriginal => { + const actual = await importOriginal() + return { ...actual, getDb: () => groupChatDbMock.current } +}) vi.mock('../../packages/server/src/middleware/user-auth', () => ({ isAuthEnabled: vi.fn(async () => false), authenticateUserToken: vi.fn(), + loadActiveAuthenticatedUser: vi.fn((id: number | string) => { + const db = groupChatDbMock.current + if (!db) return null + try { + const user = db.prepare( + 'SELECT id, username, role, status FROM users WHERE id = ?', + ).get(Number(id)) as { id: number; username: string; role: string; status: string } | undefined + if (!user || user.status !== 'active') return null + const profiles = db.prepare( + 'SELECT profile_name FROM user_profiles WHERE user_id = ? ORDER BY created_at, profile_name', + ).all(user.id) as Array<{ profile_name: string }> + return { + id: user.id, + username: user.username, + role: user.role, + profiles: profiles.map(profile => profile.profile_name), + } + } catch { + return null + } + }), })) +vi.mock('../../packages/server/src/services/auth', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getGroupChatLocalIdentitySecret: vi.fn(async () => 'a'.repeat(64)), + } +}) import { initAllHermesTables } from '../../packages/server/src/db/hermes/schemas' +import { claimHermesDatabaseOwnershipForTesting } from '../../packages/server/src/db/ownership' import { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat' +import { groupBridgeSessionId } from '../../packages/server/src/services/hermes/group-chat/agent-clients' export function once(socket: ClientSocket, event: string, timeoutMs = 2_000): Promise { + const bufferedArgs = (socket as BufferedClientSocket).__bufferedEvents__?.get(event) + if (bufferedArgs) { + return Promise.resolve(bufferedArgs[0] as T) + } return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeoutMs) socket.once(event, (payload: T) => { clearTimeout(timer); resolve(payload) }) @@ -49,7 +90,14 @@ export async function connectGroupChatClient( forceNew: true, reconnection: false, auth: { userId, name, ...auth }, + autoConnect: false, + }) + const bufferedSocket = socket as BufferedClientSocket + bufferedSocket.__bufferedEvents__ = new Map() + socket.onAny((event, ...args) => { + bufferedSocket.__bufferedEvents__?.set(event, args) }) + socket.connect() return await once(socket as any, 'connect').then(() => socket) } @@ -62,6 +110,7 @@ export async function createTestGroupChatServer(): Promise<{ cleanup: () => void }> { const db = new DatabaseSync(':memory:') + claimHermesDatabaseOwnershipForTesting(db) groupChatDbMock.current = db initAllHermesTables() const httpServer = createServer() @@ -84,3 +133,48 @@ export async function createTestGroupChatServer(): Promise<{ }, } } + +export function seedAuthenticatedUser( + db: DatabaseSync, + input: { id: number; username: string; role?: 'super_admin' | 'admin'; status?: 'active' | 'disabled'; profiles?: string[] }, +): void { + const now = Date.now() + db.prepare(` + INSERT INTO users (id, username, password_hash, role, status, created_at, updated_at, last_login_at, avatar) + VALUES (?, ?, 'test-only', ?, ?, ?, ?, NULL, '') + `).run(input.id, input.username, input.role || 'admin', input.status || 'active', now, now) + const insertProfile = db.prepare(` + INSERT INTO user_profiles (user_id, profile_name, is_default, created_at) + VALUES (?, ?, ?, ?) + `) + ;(input.profiles || []).forEach((profile, index) => { + insertProfile.run(input.id, profile, index === 0 ? 1 : 0, now) + }) +} + +export function currentRoomAgentSessionId( + groupServer: GroupChatServer, + roomId: string, + agentId: string, + profile: string, + name: string, +): string { + const storage = groupServer.getStorage() + const room = storage.getRoom(roomId) + if (!room || !/^[0-9a-f]{32}$/i.test(String(room.sessionSeed || ''))) { + throw new Error(`room ${roomId} does not have a cryptographic session seed`) + } + const actor = storage.findActiveActorByAgentIdentity?.(roomId, agentId) ?? null + return groupBridgeSessionId( + roomId, + profile, + name, + room.sessionSeed, + { + actorId: actor?.id || null, + roomAuthorizationRevision: room?.authorizationRevision, + actorAuthorizationRevision: actor?.authorizationRevision, + actorContextRevision: actor?.contextRevision, + }, + ) +} diff --git a/tests/server/group-chat-workspace-diff.test.ts b/tests/server/group-chat-workspace-diff.test.ts index 4b44620aa0..fb5ec62691 100644 --- a/tests/server/group-chat-workspace-diff.test.ts +++ b/tests/server/group-chat-workspace-diff.test.ts @@ -4,15 +4,21 @@ import { createServer, type Server as HttpServer } from 'http' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +import { claimTestHermesDbOwnership } from './db-test-helpers' const dbState = vi.hoisted(() => ({ db: null as DatabaseSync | null, })) -vi.mock('../../packages/server/src/db/index', () => ({ - getDb: () => dbState.db, - isSqliteAvailable: () => Boolean(dbState.db), -})) +vi.mock('../../packages/server/src/db/index', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getDb: () => dbState.db, + getStoragePath: () => ':memory:', + isSqliteAvailable: () => Boolean(dbState.db), + } +}) vi.mock('socket.io-client', () => ({ io: vi.fn(() => ({ @@ -40,6 +46,7 @@ describe('group chat workspace diff persistence', () => { workspace = join(root, 'workspace') mkdirSync(workspace) dbState.db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(dbState.db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() httpServer = createServer() diff --git a/tests/server/group-chat-workspace-files.test.ts b/tests/server/group-chat-workspace-files.test.ts index a7f9f09159..d0de803d99 100644 --- a/tests/server/group-chat-workspace-files.test.ts +++ b/tests/server/group-chat-workspace-files.test.ts @@ -1,7 +1,22 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' + +const refreshedAuth = vi.hoisted(() => ({ active: true })) + +vi.mock('../../packages/server/src/middleware/user-auth', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + loadActiveAuthenticatedUser: vi.fn((id: number | string) => refreshedAuth.active + ? Number(id) === 1 + ? { id: 1, username: 'root', role: 'super_admin', profiles: [] } + : { id: Number(id), username: 'reader', role: 'admin', profiles: [] } + : null), + } +}) + import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' function routeHandler(path: string, method: string) { @@ -10,13 +25,13 @@ function routeHandler(path: string, method: string) { return layer.stack[0] } -function createContext(path = '') { +function createContext(path = ''): any { const headers: Record = {} return { params: { roomId: 'room-1' }, query: path ? { path } : {}, request: { body: {} }, - state: { user: { role: 'super_admin' } }, + state: { user: { id: 1, username: 'root', role: 'super_admin', profiles: [] } }, status: 200, body: undefined as unknown, headers, @@ -33,6 +48,7 @@ describe('group chat workspace file routes', () => { let storage: any beforeEach(async () => { + refreshedAuth.active = true root = await mkdtemp(join(tmpdir(), 'hermes-group-files-')) workspace = join(root, 'room-workspace') await mkdir(workspace) @@ -91,13 +107,48 @@ describe('group chat workspace file routes', () => { expect(ctx.headers['Cache-Control']).toContain('no-store') }) - it('does not expose workspace files to room members without management access', async () => { + it('blocks a workspace write when the requester is disabled after path resolution starts', async () => { + const target = join(workspace, 'protected.txt') + await writeFile(target, 'original') + const write = routeHandler('/api/hermes/group-chat/rooms/:roomId/workspace-file/write', 'PUT') + const ctx = createContext() + ctx.request.body = { path: 'protected.txt', content: 'revoked write' } + + const operation = write(ctx) + refreshedAuth.active = false + await operation + + expect(ctx.status).toBe(404) + expect(ctx.body).toMatchObject({ error: 'Room not found', code: 'not_found' }) + await expect(readFile(target, 'utf-8')).resolves.toBe('original') + }) + + it('blocks a workspace write when the configured workspace changes during path resolution', async () => { + const originalWorkspace = workspace + const replacementWorkspace = join(root, 'replacement') + await mkdir(replacementWorkspace) + const target = join(originalWorkspace, 'protected.txt') + await writeFile(target, 'original') + const ctx = createContext() + ctx.request.body = { path: 'protected.txt', content: 'changed' } + + const write = routeHandler('/api/hermes/group-chat/rooms/:roomId/workspace-file/write', 'PUT') + const operation = write(ctx) + room.workspace = replacementWorkspace + await operation + + expect(ctx.status).toBe(403) + expect(ctx.body).toEqual({ error: 'Workspace authorization changed', code: 'permission_denied' }) + expect(await readFile(target, 'utf8')).toBe('original') + }) + + it('returns the missing-room shape for workspace file reads without room access', async () => { await writeFile(join(workspace, 'private.txt'), 'secret') const read = routeHandler('/api/hermes/group-chat/rooms/:roomId/workspace-file/read', 'GET') const ctx = createContext('private.txt') ctx.state.user = { role: 'admin', id: 2, profiles: [] } await read(ctx) - expect(ctx.status).toBe(403) - expect(ctx.body).toMatchObject({ code: 'permission_denied' }) + expect(ctx.status).toBe(404) + expect(ctx.body).toMatchObject({ error: 'Room not found', code: 'not_found' }) }) }) diff --git a/tests/server/group-chat-workspace.test.ts b/tests/server/group-chat-workspace.test.ts index 484d8f2fb2..7cd7263dc5 100644 --- a/tests/server/group-chat-workspace.test.ts +++ b/tests/server/group-chat-workspace.test.ts @@ -4,15 +4,21 @@ import { createServer, type Server as HttpServer } from 'http' import { mkdir, mkdtemp, rm } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' +import { claimTestHermesDbOwnership } from './db-test-helpers' const dbState = vi.hoisted(() => ({ db: null as DatabaseSync | null, })) -vi.mock('../../packages/server/src/db/index', () => ({ - getDb: () => dbState.db, - isSqliteAvailable: () => Boolean(dbState.db), -})) +vi.mock('../../packages/server/src/db/index', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getDb: () => dbState.db, + getStoragePath: () => ':memory:', + isSqliteAvailable: () => Boolean(dbState.db), + } +}) vi.mock('socket.io-client', () => ({ io: vi.fn(() => ({ @@ -33,7 +39,37 @@ async function routeHandler(path: string, method: string) { const { groupChatRoutes } = await import('../../packages/server/src/routes/hermes/group-chat') const layer = (groupChatRoutes as any).stack.find((item: any) => item.path === path && item.methods.includes(method)) if (!layer) throw new Error(`Route not found: ${method} ${path}`) - return layer.stack[0] + const handler = layer.stack[0] + return async (ctx: any, next: () => Promise) => { + ctx.state ??= { user: { id: 1, username: 'root', role: 'super_admin', profiles: [] } } + return handler(ctx, next) + } +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function seedWorkspaceTestUser( + db: DatabaseSync, + input: { id: number; username: string; role: 'super_admin' | 'admin'; profiles?: string[] }, +): void { + const now = Date.now() + db.prepare(` + INSERT INTO users (id, username, password_hash, role, status, created_at, updated_at, last_login_at, avatar) + VALUES (?, ?, 'test-only', ?, 'active', ?, ?, NULL, '') + `).run(input.id, input.username, input.role, now, now) + const insertProfile = db.prepare(` + INSERT INTO user_profiles (user_id, profile_name, is_default, created_at) + VALUES (?, ?, ?, ?) + `) + ;(input.profiles || []).forEach((profile, index) => { + insertProfile.run(input.id, profile, index === 0 ? 1 : 0, now) + }) } describe('group chat room workspace', () => { @@ -44,11 +80,15 @@ describe('group chat room workspace', () => { beforeEach(async () => { vi.resetModules() dbState.db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(dbState.db) root = await mkdtemp(join(tmpdir(), 'hermes-gc-workspace-')) originalWorkspaceBase = process.env.WORKSPACE_BASE process.env.WORKSPACE_BASE = root const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() + seedWorkspaceTestUser(dbState.db, { id: 1, username: 'root', role: 'super_admin' }) + seedWorkspaceTestUser(dbState.db, { id: 2, username: 'bob', role: 'admin', profiles: ['default'] }) + seedWorkspaceTestUser(dbState.db, { id: 7, username: 'alice', role: 'admin', profiles: ['default'] }) httpServer = createServer() }) @@ -164,6 +204,41 @@ describe('group chat room workspace', () => { server.getIO().close() }) + it('rechecks durable requester status after interrupting agents and before replacing workspace', async () => { + const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat') + const { setGroupChatServer } = await import('../../packages/server/src/routes/hermes/group-chat') + const server = new GroupChatServer(httpServer) + const storage = server.getStorage() + const workspace = join(root, 'repo') + await mkdir(workspace) + storage.saveRoom('room-1', 'Room 1', null, { ownerAuthUserId: 1 }) + setGroupChatServer(server) + const releaseSessionFence = vi.fn() + vi.spyOn(server, 'fenceCurrentRoomAgentSessions').mockReturnValue(releaseSessionFence) + const interrupt = deferred() + const interruptRoom = vi.spyOn(server.agentClients, 'interruptRoom').mockReturnValue(interrupt.promise) + + const handler = await routeHandler('/api/hermes/group-chat/rooms/:roomId/workspace', 'PUT') + const ctx: any = { + params: { roomId: 'room-1' }, + request: { body: { workspace } }, + status: 200, + body: undefined, + } + + const pending = handler(ctx, async () => {}) + await vi.waitFor(() => expect(interruptRoom).toHaveBeenCalledWith('room-1')) + dbState.db?.prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = 1").run(Date.now()) + interrupt.resolve() + await pending + + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Room not found' }) + expect(releaseSessionFence).toHaveBeenCalledOnce() + expect(storage.getRoom('room-1')?.workspace).toBe('') + server.getIO().close() + }) + it('does not switch workspace when active room agents do not finish interrupting', async () => { const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat') const { setGroupChatServer } = await import('../../packages/server/src/routes/hermes/group-chat') @@ -312,7 +387,7 @@ describe('group chat room workspace', () => { server.getIO().close() }) - it('rejects workspace updates for rooms outside a regular admin profile scope', async () => { + it('returns the missing-room shape for workspace updates outside a regular admin profile scope', async () => { const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat') const { setGroupChatServer } = await import('../../packages/server/src/routes/hermes/group-chat') const server = new GroupChatServer(httpServer) @@ -334,13 +409,13 @@ describe('group chat room workspace', () => { await handler(ctx, async () => {}) - expect(ctx.status).toBe(403) - expect(ctx.body).toEqual({ error: 'Access denied' }) + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Room not found' }) expect(storage.getRoom('room-private')?.workspace).toBe('') server.getIO().close() }) - it('rejects room detail and clone reads outside a regular admin profile scope', async () => { + it('uses the missing-room shape for stranger detail and clone access outside a regular admin profile scope', async () => { const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat') const { setGroupChatServer } = await import('../../packages/server/src/routes/hermes/group-chat') const server = new GroupChatServer(httpServer) @@ -356,14 +431,14 @@ describe('group chat room workspace', () => { const detail = await routeHandler('/api/hermes/group-chat/rooms/:roomId', 'GET') const detailCtx: any = { params: { roomId: 'room-private' }, query: {}, state: { user }, status: 200, body: undefined } await detail(detailCtx, async () => {}) - expect(detailCtx.status).toBe(403) - expect(detailCtx.body).toEqual({ error: 'Access denied' }) + expect(detailCtx.status).toBe(404) + expect(detailCtx.body).toEqual({ error: 'Room not found' }) const clone = await routeHandler('/api/hermes/group-chat/rooms/:roomId/clone', 'POST') const cloneCtx: any = { params: { roomId: 'room-private' }, request: { body: { name: 'Copy' } }, state: { user }, status: 200, body: undefined } await clone(cloneCtx, async () => {}) - expect(cloneCtx.status).toBe(403) - expect(cloneCtx.body).toEqual({ error: 'Access denied' }) + expect(cloneCtx.status).toBe(404) + expect(cloneCtx.body).toEqual({ error: 'Room not found' }) expect(storage.getAllRooms()).toHaveLength(1) server.getIO().close() }) @@ -385,8 +460,12 @@ describe('group chat room workspace', () => { const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() - const row = dbState.db?.prepare('SELECT workspace FROM gc_rooms WHERE id = ?').get('old-room') as { workspace: string } + const row = dbState.db?.prepare('SELECT workspace, inviteGeneration FROM gc_rooms WHERE id = ?').get('old-room') as { + workspace: string + inviteGeneration: number + } expect(row.workspace).toBe('') + expect(row.inviteGeneration).toBe(0) }) it('validates workspace updates through the group room REST route', async () => { diff --git a/tests/server/hermes-db-process-ownership.test.ts b/tests/server/hermes-db-process-ownership.test.ts new file mode 100644 index 0000000000..6fa5a463e9 --- /dev/null +++ b/tests/server/hermes-db-process-ownership.test.ts @@ -0,0 +1,138 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const childEntry = fileURLToPath(new URL('./fixtures/hermes-db-process-ownership-child.ts', import.meta.url)) +const viteNodeEntry = fileURLToPath(new URL('../../node_modules/vite-node/vite-node.mjs', import.meta.url)) + +type SpawnedChild = { + child: ChildProcessWithoutNullStreams + stdout: string[] + stderr: string[] +} + +const spawnedChildren: SpawnedChild[] = [] +const tempDirs: string[] = [] + +function spawnOwnershipChild(mode: 'hold' | 'probe', dbDir: string): SpawnedChild { + const child = spawn(process.execPath, [viteNodeEntry, childEntry, mode], { + env: { + ...process.env, + HERMES_WEB_UI_TEST_DB_DIR: dbDir, + VITEST: 'true', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + const stdout: string[] = [] + const stderr: string[] = [] + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout.push(chunk) }) + child.stderr.on('data', (chunk: string) => { stderr.push(chunk) }) + const handle = { child, stdout, stderr } + spawnedChildren.push(handle) + return handle +} + +function waitForOutput( + stream: 'stdout' | 'stderr', + handle: SpawnedChild, + pattern: RegExp, + timeoutMs = 10_000, +): Promise { + return new Promise((resolve, reject) => { + const startedAt = Date.now() + const interval = setInterval(() => { + const output = handle[stream].join('') + if (pattern.test(output)) { + clearInterval(interval) + resolve() + return + } + if (Date.now() - startedAt >= timeoutMs) { + clearInterval(interval) + reject(new Error( + `timed out waiting for ${stream} to match ${pattern}; ` + + `exit=${handle.child.exitCode}; stdout=${handle.stdout.join('')}; stderr=${handle.stderr.join('')}`, + )) + } + }, 25) + }) +} + +function waitForExit(handle: SpawnedChild, timeoutMs = 10_000): Promise<{ code: number | null }> { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out waiting for child exit')), timeoutMs) + handle.child.once('exit', (code) => { + clearTimeout(timer) + resolve({ code }) + }) + }) +} + +afterEach(async () => { + for (const handle of spawnedChildren.splice(0)) { + if (handle.child.exitCode === null) { + handle.child.stdin.end() + handle.child.kill() + try { + await waitForExit(handle, 1_500) + } catch { + // best effort + } + } + } + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }) + } +}) + +describe('Hermes DB process ownership', () => { + it('fails closed in a second process before schema/application use', async () => { + const dbDir = await mkdtemp(join(tmpdir(), 'hermes-db-owner-')) + tempDirs.push(dbDir) + + const owner = spawnOwnershipChild('hold', dbDir) + await waitForOutput('stdout', owner, /"status":"ready"/) + + const contender = spawnOwnershipChild('probe', dbDir) + const result = await waitForExit(contender) + + expect(result.code).toBe(2) + expect(contender.stdout.join('')).not.toContain('"status":"acquired"') + expect(contender.stderr.join('')).toMatch(/database is locked/i) + }, 20_000) + + it('reacquires ownership after closeDb releases the lock', async () => { + const dbDir = await mkdtemp(join(tmpdir(), 'hermes-db-owner-')) + tempDirs.push(dbDir) + + const owner = spawnOwnershipChild('hold', dbDir) + await waitForOutput('stdout', owner, /"status":"ready"/) + + owner.child.stdin.write('close\n') + expect(await waitForExit(owner)).toEqual({ code: 0 }) + + const reacquired = spawnOwnershipChild('probe', dbDir) + expect(await waitForExit(reacquired)).toEqual({ code: 0 }) + expect(reacquired.stdout.join('')).toContain('"status":"acquired"') + }, 20_000) + + it('reacquires ownership after the owning process terminates unexpectedly', async () => { + const dbDir = await mkdtemp(join(tmpdir(), 'hermes-db-owner-')) + tempDirs.push(dbDir) + + const owner = spawnOwnershipChild('hold', dbDir) + await waitForOutput('stdout', owner, /"status":"ready"/) + + owner.child.stdin.write('crash\n') + expect(await waitForExit(owner)).toEqual({ code: 17 }) + + const reacquired = spawnOwnershipChild('probe', dbDir) + expect(await waitForExit(reacquired)).toEqual({ code: 0 }) + expect(reacquired.stdout.join('')).toContain('"status":"acquired"') + }, 20_000) +}) diff --git a/tests/server/hermes-schemas.test.ts b/tests/server/hermes-schemas.test.ts index 14bf2f57b5..55ccbd6360 100644 --- a/tests/server/hermes-schemas.test.ts +++ b/tests/server/hermes-schemas.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('Hermes schema initialization', () => { let db: any = null @@ -7,6 +8,7 @@ describe('Hermes schema initialization', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/mcu-devices-store.test.ts b/tests/server/mcu-devices-store.test.ts index 37378c332a..c709b6b3dc 100644 --- a/tests/server/mcu-devices-store.test.ts +++ b/tests/server/mcu-devices-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('MCU devices store', () => { let db: any = null @@ -7,6 +8,7 @@ describe('MCU devices store', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/mcu-login-controller.test.ts b/tests/server/mcu-login-controller.test.ts index f873807bc0..49a2c25a86 100644 --- a/tests/server/mcu-login-controller.test.ts +++ b/tests/server/mcu-login-controller.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' const { startOutboundRelayClientMock, stopOutboundRelayClientMock } = vi.hoisted(() => ({ startOutboundRelayClientMock: vi.fn(), @@ -22,6 +23,7 @@ describe('MCU login controller', () => { const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/provider-model-refresh.test.ts b/tests/server/provider-model-refresh.test.ts index a85d7a828f..fdbcde8632 100644 --- a/tests/server/provider-model-refresh.test.ts +++ b/tests/server/provider-model-refresh.test.ts @@ -7,6 +7,7 @@ let hermesHome = '' let webUiHome = '' const originalHermesHome = process.env.HERMES_HOME const originalWebUiHome = process.env.HERMES_WEB_UI_HOME +const originalWebUiTestDbDir = process.env.HERMES_WEB_UI_TEST_DB_DIR function profileDir(profile: string): string { return profile === 'default' ? hermesHome : join(hermesHome, 'profiles', profile) @@ -29,6 +30,7 @@ beforeEach(() => { webUiHome = mkdtempSync(join(tmpdir(), 'provider-refresh-webui-')) process.env.HERMES_HOME = hermesHome process.env.HERMES_WEB_UI_HOME = webUiHome + process.env.HERMES_WEB_UI_TEST_DB_DIR = webUiHome vi.resetModules() }) @@ -41,6 +43,8 @@ afterEach(() => { else process.env.HERMES_HOME = originalHermesHome if (originalWebUiHome === undefined) delete process.env.HERMES_WEB_UI_HOME else process.env.HERMES_WEB_UI_HOME = originalWebUiHome + if (originalWebUiTestDbDir === undefined) delete process.env.HERMES_WEB_UI_TEST_DB_DIR + else process.env.HERMES_WEB_UI_TEST_DB_DIR = originalWebUiTestDbDir }) describe('provider model refresh', () => { diff --git a/tests/server/schema-sync.test.ts b/tests/server/schema-sync.test.ts index 042c3fc229..4a86f22337 100644 --- a/tests/server/schema-sync.test.ts +++ b/tests/server/schema-sync.test.ts @@ -77,7 +77,7 @@ describe('Database Schema Synchronization', () => { } }) - beforeEach(() => { + beforeEach(async () => { // Clean up any existing test database try { unlinkSync(TEST_DB_PATH) } catch {} try { unlinkSync(TEST_DB_PATH + '-wal') } catch {} @@ -90,6 +90,8 @@ describe('Database Schema Synchronization', () => { // Reset modules to ensure fresh imports vi.resetModules() + const ownership = await import('../../packages/server/src/db/ownership') + ownership.acquireHermesDatabaseOwnership(testDbInstance, TEST_DB_PATH) }) afterEach(() => { diff --git a/tests/server/session-category-store.test.ts b/tests/server/session-category-store.test.ts index 58f487c693..ee28416fe4 100644 --- a/tests/server/session-category-store.test.ts +++ b/tests/server/session-category-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('session category store', () => { let db: any = null @@ -12,6 +13,7 @@ describe('session category store', () => { getStoragePath: () => ':memory:', isSqliteAvailable: () => true, })) + await claimTestHermesDbOwnership(db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() }) diff --git a/tests/server/session-deleter.test.ts b/tests/server/session-deleter.test.ts new file mode 100644 index 0000000000..a0cf0d7297 --- /dev/null +++ b/tests/server/session-deleter.test.ts @@ -0,0 +1,127 @@ +import { DatabaseSync } from 'node:sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { claimTestHermesDbOwnership } from './db-test-helpers' + +const dbState = vi.hoisted(() => ({ db: null as DatabaseSync | null })) +const cliMock = vi.hoisted(() => ({ + deleteSessionForProfile: vi.fn(), +})) + +vi.mock('../../packages/server/src/db/index', () => ({ + getDb: () => dbState.db, + getStoragePath: () => ':memory:', +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ + deleteSessionForProfile: cliMock.deleteSessionForProfile, +})) + +import { initAllHermesTables } from '../../packages/server/src/db/hermes/schemas' +import { SessionDeleter } from '../../packages/server/src/services/hermes/session-deleter' + +describe('SessionDeleter durable Group Chat cleanup', () => { + beforeEach(async () => { + dbState.db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(dbState.db) + initAllHermesTables() + cliMock.deleteSessionForProfile.mockReset() + }) + + afterEach(() => { + dbState.db?.close() + dbState.db = null + }) + + it('requeues crash-stranded work and deletes it through the recorded profile', async () => { + const db = dbState.db! + const now = Date.now() + db.prepare( + `INSERT INTO gc_session_profiles (session_id, room_id, agent_id, profile_name, created_at) + VALUES (?, ?, ?, ?, ?)`, + ).run('session-reviewer', 'room-1', 'agent-1', 'reviewer', now) + db.prepare( + `INSERT INTO gc_pending_session_deletes + (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'processing', 0, NULL, ?, ?, 0)`, + ).run('session-reviewer', 'reviewer', now, now) + cliMock.deleteSessionForProfile.mockResolvedValue(true) + + const result = await new SessionDeleter().drain('reviewer') + + expect(cliMock.deleteSessionForProfile).toHaveBeenCalledWith('session-reviewer', 'reviewer') + expect(result).toEqual({ deleted: ['session-reviewer'], skipped: [], failed: [] }) + expect(db.prepare( + 'SELECT COUNT(*) AS count FROM gc_pending_session_deletes WHERE session_id = ?', + ).get('session-reviewer')).toEqual({ count: 0 }) + expect(db.prepare( + 'SELECT COUNT(*) AS count FROM gc_session_profiles WHERE session_id = ?', + ).get('session-reviewer')).toEqual({ count: 0 }) + }) + + it('drains every profile represented in the outbox on startup replay', async () => { + const db = dbState.db! + const now = Date.now() + const insert = db.prepare( + `INSERT INTO gc_pending_session_deletes + (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'pending', 0, NULL, ?, ?, 0)`, + ) + insert.run('session-writer', 'writer', now, now) + insert.run('session-reviewer-2', 'reviewer', now + 1, now + 1) + cliMock.deleteSessionForProfile.mockResolvedValue(true) + + await new SessionDeleter().drainAllProfiles('default') + + expect(cliMock.deleteSessionForProfile).toHaveBeenCalledWith('session-writer', 'writer') + expect(cliMock.deleteSessionForProfile).toHaveBeenCalledWith('session-reviewer-2', 'reviewer') + expect(db.prepare('SELECT COUNT(*) AS count FROM gc_pending_session_deletes').get()).toEqual({ count: 0 }) + }) + + it('backs off a false CLI result instead of retrying immediately', async () => { + const db = dbState.db! + const now = Date.now() + db.prepare( + `INSERT INTO gc_pending_session_deletes + (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'pending', 0, NULL, ?, ?, 0)`, + ).run('session-failed', 'writer', now, now) + cliMock.deleteSessionForProfile.mockResolvedValue(false) + + const result = await new SessionDeleter().drain('writer') + const row = db.prepare( + `SELECT status, attempt_count, last_error, next_attempt_at + FROM gc_pending_session_deletes WHERE session_id = ?`, + ).get('session-failed') as { + status: string + attempt_count: number + last_error: string | null + next_attempt_at: number + } + + expect(cliMock.deleteSessionForProfile).toHaveBeenCalledWith('session-failed', 'writer') + expect(result).toEqual({ deleted: [], skipped: ['session-failed'], failed: [] }) + expect(row.status).toBe('pending') + expect(row.attempt_count).toBe(1) + expect(row.last_error).toMatch(/returned false/i) + expect(row.next_attempt_at).toBeGreaterThan(now) + }) + + it('keeps retrying durable cleanup after three transient failures', async () => { + const db = dbState.db! + const now = Date.now() + db.prepare( + `INSERT INTO gc_pending_session_deletes + (session_id, profile_name, status, attempt_count, last_error, created_at, updated_at, next_attempt_at) + VALUES (?, ?, 'pending', 3, 'transient', ?, ?, 0)`, + ).run('session-retry', 'reviewer', now, now) + cliMock.deleteSessionForProfile.mockResolvedValue(true) + + await new SessionDeleter().drainAllProfiles('default') + + expect(cliMock.deleteSessionForProfile).toHaveBeenCalledWith('session-retry', 'reviewer') + expect(db.prepare( + 'SELECT COUNT(*) AS count FROM gc_pending_session_deletes WHERE session_id = ?', + ).get('session-retry')).toEqual({ count: 0 }) + }) +}) diff --git a/tests/server/session-store-search.test.ts b/tests/server/session-store-search.test.ts index a160289d8a..82a4a8a607 100644 --- a/tests/server/session-store-search.test.ts +++ b/tests/server/session-store-search.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('session store search', () => { let db: any = null @@ -12,6 +13,7 @@ describe('session store search', () => { isSqliteAvailable: () => true, getStoragePath: () => ':memory:', })) + await claimTestHermesDbOwnership(db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() }) diff --git a/tests/server/session-sync.test.ts b/tests/server/session-sync.test.ts index 39dbec72e7..deea51b3e2 100644 --- a/tests/server/session-sync.test.ts +++ b/tests/server/session-sync.test.ts @@ -2,6 +2,7 @@ * Tests for the disabled Hermes session import path. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('session-sync', () => { let db: any = null @@ -10,6 +11,7 @@ describe('session-sync', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/stt-settings-controller.test.ts b/tests/server/stt-settings-controller.test.ts index fe688a724b..0f55a3ef71 100644 --- a/tests/server/stt-settings-controller.test.ts +++ b/tests/server/stt-settings-controller.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('stt settings controller', () => { let db: any = null @@ -7,6 +8,7 @@ describe('stt settings controller', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/stt-settings-store.test.ts b/tests/server/stt-settings-store.test.ts index 1ed57d4346..1d97139d79 100644 --- a/tests/server/stt-settings-store.test.ts +++ b/tests/server/stt-settings-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('STT provider settings schema', () => { let db: any = null @@ -7,6 +8,7 @@ describe('STT provider settings schema', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', @@ -117,6 +119,7 @@ describe('stt settings store', () => { vi.resetModules() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/stt-transcribe-controller.test.ts b/tests/server/stt-transcribe-controller.test.ts index 36279ade08..df421dcc06 100644 --- a/tests/server/stt-transcribe-controller.test.ts +++ b/tests/server/stt-transcribe-controller.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) @@ -90,6 +91,7 @@ describe('stt transcribe controller', () => { vi.clearAllMocks() const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/tts-synthesize-controller.test.ts b/tests/server/tts-synthesize-controller.test.ts index fdd6d226d0..7ecfa36afd 100644 --- a/tests/server/tts-synthesize-controller.test.ts +++ b/tests/server/tts-synthesize-controller.test.ts @@ -1,3 +1,4 @@ +import { claimTestHermesDbOwnership } from './db-test-helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' function createMockCtx(body: Record = {}) { @@ -87,6 +88,7 @@ describe('tts synthesize controller', () => { it('saves TTS settings when the legacy provider table has no unique index', async () => { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', @@ -161,6 +163,7 @@ describe('tts synthesize controller', () => { it('deletes a stored TTS provider row and falls back to Edge when it was active', async () => { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', @@ -207,6 +210,7 @@ describe('tts synthesize controller', () => { it('rejects deleting the built-in Edge TTS provider', async () => { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', @@ -235,6 +239,7 @@ describe('tts synthesize controller', () => { it('preserves numeric Edge TTS rate and pitch settings on save', async () => { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', @@ -293,6 +298,7 @@ describe('tts synthesize controller', () => { it('repairs preexisting profile TTS tables before saving settings', async () => { const { DatabaseSync } = await import('node:sqlite') const db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/user-auth.test.ts b/tests/server/user-auth.test.ts index a7d8e96c93..d7494ea2e0 100644 --- a/tests/server/user-auth.test.ts +++ b/tests/server/user-auth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('user auth tables and middleware', () => { let db: any = null @@ -8,6 +9,7 @@ describe('user auth tables and middleware', () => { vi.stubEnv('AUTH_JWT_SECRET', 'test-secret') const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/user-avatar.test.ts b/tests/server/user-avatar.test.ts index 99803c0c66..80d7a66467 100644 --- a/tests/server/user-avatar.test.ts +++ b/tests/server/user-avatar.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' describe('user avatar storage and controller', () => { let db: any = null @@ -8,6 +9,7 @@ describe('user avatar storage and controller', () => { vi.stubEnv('AUTH_JWT_SECRET', 'test-secret') const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) vi.doMock('../../packages/server/src/db/index', () => ({ getDb: () => db, getStoragePath: () => ':memory:', diff --git a/tests/server/workflow-edge-evidence-migration.test.ts b/tests/server/workflow-edge-evidence-migration.test.ts index 5020844c93..335e77a613 100644 --- a/tests/server/workflow-edge-evidence-migration.test.ts +++ b/tests/server/workflow-edge-evidence-migration.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { claimTestHermesDbOwnership } from './db-test-helpers' let db: import('node:sqlite').DatabaseSync | null = null @@ -13,6 +14,7 @@ describe('workflow edge evidence schema migration', () => { it('recreates canonical indexes on the replacement table after archiving a legacy table', async () => { const { DatabaseSync } = await import('node:sqlite') db = new DatabaseSync(':memory:') + await claimTestHermesDbOwnership(db) db.exec(` CREATE TABLE workflow_run_edge_evaluations ( id TEXT PRIMARY KEY, diff --git a/tests/server/workflow-store.test.ts b/tests/server/workflow-store.test.ts index f58acd7da9..a5e95df70b 100644 --- a/tests/server/workflow-store.test.ts +++ b/tests/server/workflow-store.test.ts @@ -3,6 +3,7 @@ import { DatabaseSync } from 'node:sqlite' import { existsSync, mkdtempSync, rmSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +import { claimTestHermesDbOwnership } from './db-test-helpers' const state = vi.hoisted(() => ({ db: null as DatabaseSync | null, @@ -31,6 +32,7 @@ describe('workflow store', () => { root = mkdtempSync(join(tmpdir(), 'hermes-workflow-store-')) state.appHome = join(root, 'home') state.db = new DatabaseSync(join(root, 'workflow.db')) + await claimTestHermesDbOwnership(state.db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables() }) diff --git a/tests/server/workspace-diff-tracker.test.ts b/tests/server/workspace-diff-tracker.test.ts index 452e349f6d..77c3d04e4b 100644 --- a/tests/server/workspace-diff-tracker.test.ts +++ b/tests/server/workspace-diff-tracker.test.ts @@ -4,6 +4,7 @@ import { execFileSync } from 'child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { join } from 'path' import { tmpdir } from 'os' +import { claimTestHermesDbOwnership } from './db-test-helpers' const state = vi.hoisted(() => ({ db: null as DatabaseSync | null, @@ -38,6 +39,7 @@ describe('workspace diff tracker', () => { root = mkdtempSync(join(tmpdir(), 'hermes-workspace-diff-')) state.appHome = join(root, 'home') state.db = new DatabaseSync(join(root, 'diffs.db')) + await claimTestHermesDbOwnership(state.db) const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas') initAllHermesTables()