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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/chat-chain-changes/2026-07-17-group-chat-invite-admission.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions docs/chat-chain-changes/2026-07-17-group-chat-v2-actor-access.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 33 additions & 1 deletion docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4619,7 +4619,6 @@
}
},
"required": [
"inviteCode",
"name"
]
}
Expand Down Expand Up @@ -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": [
Expand Down
33 changes: 27 additions & 6 deletions packages/client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,26 +156,47 @@ function responseErrorMessage(text: string, statusText: string): string {
}
}

function setHeaderCaseInsensitive(headers: Record<string, string>, 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<string, string>, 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<T>(path: string, options: RequestInit = {}): Promise<T> {
await ensureDesktopAuthReady()
const base = getBaseUrl()
const url = `${base}${path}`
const isFormDataBody = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers: Record<string, string> = {
...(isFormDataBody ? {} : { 'Content-Type': 'application/json' }),
...options.headers as Record<string, string>,
}
const headers: Record<string, string> = {}
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 })
Expand Down
3 changes: 2 additions & 1 deletion packages/client/src/api/hermes/binary-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blob> {
await ensureDesktopAuthReady()
const headers: Record<string, string> = {}
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()
Expand Down
Loading
Loading