fix(sessions): old phone-app chats continue in Desktop instead of opening read-only

Before the gateway owned sessions, `hermes serve` stored whatever `source` a client
sent, so phone apps signed in through the dashboard login left routeless rows labelled
`mobile`, `ios` and the like. Resume only adopted the local surfaces' labels, so those
chats opened read-only (#143601) and the next prompt answered `session_read_only`.

Adopt any routeless, owner-owned row whose label is not a local surface, a built-in
messaging platform, or an automation / foreign-writer label (cron, subagent, kanban,
bot_room, a2a, webhook, api_server, import, recovered, webui, local, unknown). It
resumes as a `gui` chat, and binding relabels it `desktop` (created_source keeps the
app's label), because once bound the row has a route and its old label would read as
a messaging chat. Routed rows never qualify, so the hermes-mobile plugin platform,
which registers `mobile`, keeps its messaging route. Third-party WebUI rows stay
read-only: that process runs its own agent on the same state.db.

The same predicate feeds the read-only mount (skips them), sidebar edits, the stored
picker and `hermes sessions adopt`.

Refs #146814, #144814.
This commit is contained in:
Teknium committed 2026-11-01 21:30:21 -08:00
1 parent 30b6d8b1f1
commit fcee404be6
9 files changed
+144 -27

No files matched your search

+2 -2
View File
@@ -629,14 +629,14 @@ class AuthorityConnection:
ref = SessionRef(ref.profile_id, resolved_id)
from gateway.session_local_migration import resolve_local_target
from gateway.session_policy_refresh import refresh_settled
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
from hermes_state_local_migration import legacy_history_row
# Another viewer's refresh mid-cleanup has committed a policy it has not published yet.
await refresh_settled(self.authority, ref.session_id)
row = self.authority.db.get_session(ref.session_id)
# Canonical LOCAL compression/reset segments keep the receipt on their logical
# owner, not on the physical transcript ID the history picker / Desktop supplies
# after list_sessions projects the tip (#139306).
if row and row['source'] in LEGACY_LOCAL_SOURCES | {Platform.LOCAL.value}:
if row and (row['source'] == Platform.LOCAL.value or legacy_history_row(row)):
ref = resolve_local_target(self.authority, self.actor,
self.authority.db.get_compression_tip(ref.session_id) or ref.session_id)
elif row:
+5 -4
View File
@@ -6,8 +6,9 @@ from ``state.db``. Nothing can run them (no frozen policy, no lane, and adopting
writable would legitimise its second writer), so the owner mounts them for reading only. The mount
is keyed by no lane's route, so nothing route-keyed acts through it, and every capability but
``session:read`` is refused by name. Archive, pin and delete still work as for any read-only mount
(gateway/session_mutations.py), but only a leaf row deletes. Old local CLI/TUI/Desktop rows are not mounted
here: resume adopts them as writable conversations (gateway/session_local_migration.py).
(gateway/session_mutations.py), but only a leaf row deletes. Old local CLI/TUI/Desktop rows and the rows phone
apps stored under their own label (``mobile``, ``ios``) are not mounted here: resume adopts them as writable
conversations (gateway/session_local_migration.py).
"""
from hermes_state_runtime import RuntimeStoreError
@@ -31,9 +32,9 @@ def _reader(authority, actor, sid):
def mount_history(authority, actor, sid):
"""Mount *sid* read-only for its owner; False when another loader owns it or the actor may not."""
from hermes_state_local import API_BINDING_PREFIX
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
from hermes_state_local_migration import legacy_history_row
row = authority.db.get_session(sid)
if (row['source'] in LEGACY_LOCAL_SOURCES | {'local'}
if (row['source'] == 'local' or legacy_history_row(row)
or row.get('profile_name') not in (None, '', authority.db._own_profile_name() or 'default')
or not _reader(authority, actor, sid)):
return False
+2 -2
View File
@@ -1,5 +1,5 @@
"""Read-only native picker history; enumeration never adopts a transcript."""
from hermes_state_local_migration import validate_legacy_row
from hermes_state_local_migration import client_history_row, validate_legacy_row
from hermes_state_mutation_binding import authorize_history
from hermes_state_runtime import RuntimeStoreError
@@ -42,7 +42,7 @@ def stored_picker_sessions(authority, actor, *, limit, include_hidden=False):
rows = db.list_sessions_rich(exclude_sources=_excluded_sources(), limit=200, offset=offset,
order_by_last_active=True, compact_rows=True, include_hidden=include_hidden)
for row in rows:
local = row['source'] in _LOCAL_SOURCES
local = row['source'] in _LOCAL_SOURCES or client_history_row(row)
if not local and _messaging_platform(row['source']) is None:
continue # neither a local nor a messaging label (acp, unknown, ...): nothing this picker resumes
sid = db.get_compression_tip(row['id']) or row['id']
+2 -2
View File
@@ -241,7 +241,7 @@ def adopt_legacy_session(authority, actor, row):
if (getattr(authority, '_native_legacy_transports', {}).get(actor.transport_id) != actor
or not {'session:read', 'session:create'} <= actor.capabilities):
raise RuntimeStoreError('not_found')
from hermes_state_local_migration import LEGACY_CREATION_SOURCES, validate_legacy_row
from hermes_state_local_migration import legacy_creation_source, validate_legacy_row
validate_legacy_row(authority.db, row, subject=actor.subject)
authority._require_admission_open()
from gateway.run import _load_gateway_config, _resolve_gateway_model
@@ -253,7 +253,7 @@ def adopt_legacy_session(authority, actor, row):
request_id = 'legacy:' + row['id']
sid = row['id']
params, api_mode = legacy_launch_params(row)
params['source'] = LEGACY_CREATION_SOURCES.get(row['source'], row['source'])
params['source'] = legacy_creation_source(row)
private = {}
config = _load_gateway_config()
params, api_mode = legacy_bot_chat_launch(row, params, api_mode, config)
+5 -4
View File
@@ -25,16 +25,17 @@ def adoptable_sessions(path) -> list[dict]:
offered again, which makes a second run a no-op."""
from hermes_state import SessionDB
from hermes_state_local import POLICY_PREFIX
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES, LEGACY_PREFIX, validate_legacy_row
from hermes_state_local_migration import LEGACY_PREFIX, validate_legacy_row
from hermes_state_runtime import RuntimeStoreError
db = SessionDB(db_path=path, read_only=True)
try:
sources = tuple(sorted(LEGACY_LOCAL_SOURCES))
with db._read_ctx() as conn:
# Every routeless row: validate_legacy_row judges the label (local surfaces and phone apps'
# own labels adopt; automation and foreign-writer rows do not).
ids = [row[0] for row in conn.execute(
f"SELECT id FROM sessions WHERE source IN ({','.join('?' * len(sources))}) ORDER BY started_at, id",
sources)]
"SELECT id FROM sessions WHERE ifnull(session_key,'')='' AND ifnull(chat_id,'')=''"
" AND ifnull(origin_json,'')='' ORDER BY started_at, id")]
bound = {row[0] for row in conn.execute(
'SELECT key FROM state_meta WHERE key LIKE ? OR key LIKE ?', (POLICY_PREFIX + '%', LEGACY_PREFIX + '%'))}
found, seen = [], set()
+37 -4
View File
@@ -15,6 +15,36 @@ LEGACY_PREFIX = 'gateway.local_legacy.v1:'
LEGACY_LOCAL_SOURCES = frozenset({'cli', 'tui', 'gui', 'desktop', 'oneshot', 'tool', 'acp'})
# Legacy labels that are not creation sources of their own (session_policy.SURFACES).
LEGACY_CREATION_SOURCES = {'desktop': 'gui'}
# Labels a routeless row can carry that never name a chat app: automation and recovery rows, and a
# third-party WebUI that runs its own agent on this state.db (adopting its rows would legitimise a
# second writer, so they stay read-only: gateway/session_history_mount.py).
NOT_CLIENT_LABELS = frozenset({'cron', 'subagent', 'kanban', 'bot_room', 'a2a', 'webhook', 'api_server',
'import', 'recovered', 'webui', 'local', 'unknown'})
def client_history_row(row):
"""A chat a client app wrote under its own label before ``session.create`` took only surfaces.
The pre-authority ``hermes serve`` stored whatever ``source`` a client sent, so phone apps signed
in through the dashboard login left routeless rows labelled ``mobile``, ``ios`` and the like. They
are the owner's Desktop-style chats: adopted as ``gui``. A routed row is never one, which keeps a
plugin platform sharing the label (hermes-mobile registers ``mobile``) on its messaging route."""
source = row.get('source')
if (not isinstance(source, str) or not source.strip() or source in LEGACY_LOCAL_SOURCES
or source in NOT_CLIENT_LABELS or any(row.get(k) for k in ('session_key', 'chat_id', 'origin_json'))):
return False
from gateway.config import Platform
return source not in {platform.value for platform in Platform}
def legacy_history_row(row):
"""Whether resume adopts *row* as old local history: a local surface's label, or a client app's."""
return row.get('source') in LEGACY_LOCAL_SOURCES or client_history_row(row)
def legacy_creation_source(row):
"""The creation source an adopted legacy row resumes on (session_policy.SURFACES)."""
return 'gui' if client_history_row(row) else LEGACY_CREATION_SOURCES.get(row['source'], row['source'])
def legacy_account_label(row):
@@ -46,7 +76,7 @@ def validate_legacy_row(db, row, *, subject=None):
# stamp equal to ``subject`` itself names that principal: it is already its history by
# ``authorize_history``'s rule, so it adopts like an unstamped row.
user_id = row.get('user_id')
if (row['source'] not in LEGACY_LOCAL_SOURCES
if (not legacy_history_row(row)
or any(row.get(k) for k in ('session_key', 'chat_id', 'origin_json'))
or (user_id and not legacy_account_label(row) and not login_owned_by(row, subject))
or row.get('profile_name') not in (None, '', db._own_profile_name() or 'default')):
@@ -79,9 +109,12 @@ def bind_legacy_target(db, conn, receipt):
raise RuntimeStoreError('storage_unavailable')
source = receipt['entry']['origin']
# Binding is a read-side claim: the row keeps main's ``source`` label and its end stamp. The first
# admitted turn reopens it (``reopen_local_session``), exactly as the classic surfaces did.
conn.execute('UPDATE sessions SET session_key=?,chat_id=?,user_id=?,chat_type=?,origin_json=? WHERE id=?',
(receipt['route'], receipt['session_id'], receipt['principal_id'], 'dm', _json(source), target))
# admitted turn reopens it (``reopen_local_session``), exactly as the classic surfaces did. A client
# app's own label is the exception: bound, the row has a route, so the label alone would read it as
# a messaging chat; it becomes the Desktop chat it resumes as (``created_source`` keeps provenance).
label = 'desktop' if client_history_row(dict(row)) else row['source']
conn.execute('UPDATE sessions SET source=?,session_key=?,chat_id=?,user_id=?,chat_type=?,origin_json=? WHERE id=?',
(label, receipt['route'], receipt['session_id'], receipt['principal_id'], 'dm', _json(source), target))
conn.execute('INSERT INTO state_meta(key,value) VALUES(?,?)', (key, _json({
'session_id': receipt['session_id'], 'profile_id': receipt['profile_id'],
'principal_id': receipt['principal_id'], 'legacy_session_id': target})))
+9 -8
View File
@@ -5,12 +5,13 @@ from hermes_state_runtime import RuntimeStoreError
BINDING_PREFIX = 'gateway.history_control.v1.'
def _history_sources():
"""Local surfaces whose unrouted rows are owner history: every source the native resume adopts
(``LEGACY_LOCAL_SOURCES``: old ``desktop`` chats and ``oneshot``/``tool`` CLI runs too) plus imports.
A row resume can adopt must also take a sidebar edit (pin/archive/hide/rename)."""
from hermes_state_local_migration import LEGACY_LOCAL_SOURCES
return LEGACY_LOCAL_SOURCES | {'import'}
def _history_row(row):
"""Local surfaces' unrouted rows are owner history: every row the native resume adopts
(``legacy_history_row``: old ``desktop`` chats, ``oneshot``/``tool`` CLI runs and phone apps'
self-labelled chats too) plus imports. A row resume can adopt must also take a sidebar edit
(pin/archive/hide/rename)."""
from hermes_state_local_migration import legacy_history_row
return row['source'] == 'import' or legacy_history_row(dict(row))
def _owner_user(row):
@@ -85,7 +86,7 @@ def authorize_history(conn, actor, session_id, *, claim=False):
if not _bound_to(saved[0], actor):
raise RuntimeStoreError('permission_denied')
return True
if row['source'] not in _history_sources():
if not _history_row(row):
return False
from gateway.owner_identity import same_owner
if _owner_user(row) and not same_owner(row['user_id'], actor.subject):
@@ -125,5 +126,5 @@ def same_history_owner(conn, anchor_id, session_id):
routeless_peer = row['source'] == anchor['source'] and not any(tuple(anchor)[2:5])
from gateway.owner_identity import owners_agree
return binding is not None or (
(row['source'] in _history_sources() or routeless_peer)
(_history_row(row) or routeless_peer)
and (_owner_user(row) in (None, '') or owners_agree(_owner_user(row), anchor['user_id'])))
@@ -0,0 +1,78 @@
"""A phone app's self-labelled chat continues in Desktop; a plugin platform sharing the label is untouched.
The pre-authority ``hermes serve`` stored whatever ``source`` a client sent, so phone apps signed in
through the dashboard login left routeless rows labelled ``mobile``, ``ios`` and the like (#146814).
They are the owner's chats: Desktop's resume adopts them as writable conversations, while a third-party
WebUI's rows (its own agent writes them) still open read-only. The catalog plugin hermes-mobile
registers ``Platform('mobile')``; its routed chats keep their messaging route.
"""
from types import SimpleNamespace
import pytest
_CAPS = ['session:create', 'session:read', 'session:submit', 'session:control', 'session:approve',
'session:respond']
async def _owner(tmp_path, monkeypatch, toolsets):
from gateway import run
from gateway.config import GatewayConfig
from gateway.owner_identity import host_owner_subject
from gateway.session import SessionStore
from gateway.session_authority import initialize_session_authority
from gateway.session_controls import AuthorityConnection
monkeypatch.setenv('HERMES_HOME', str(tmp_path))
monkeypatch.setattr(run, '_load_gateway_config', lambda: {'platform_toolsets': toolsets})
store = SessionStore(tmp_path / 'sessions', GatewayConfig())
runner = SimpleNamespace(session_store=store, _session_db=store._db, adapters={}, _draining=False,
_cached_agent_for=lambda route: None, _restored_source=lambda entry: entry.origin,
_delivery_adapter_for=lambda source: None)
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
authority = await initialize_session_authority(runner, profile_id=str(tmp_path), instance_id='first')
desktop = AuthorityConnection(authority, object(), {'provider': 'local', 'user_id': host_owner_subject(),
'profile_id': str(tmp_path), 'instance_id': 'first', 'capabilities': _CAPS})
return store, desktop
@pytest.mark.asyncio
async def test_a_phone_apps_own_label_resumes_writable_and_a_webui_row_stays_read_only(tmp_path, monkeypatch):
store, desktop = await _owner(tmp_path, monkeypatch, {'gui': []})
db = store._db
for sid, source, user_id in (('phone-mobile', 'mobile', None), ('phone-ios', 'ios', 'basic:admin'),
('webui-row', 'webui', None)):
db.create_session(sid, source=source, model='m', cwd=str(tmp_path), user_id=user_id)
db.append_message(sid, role='user', content='hi from ' + sid)
db.end_session(sid, 'ws_orphan_reap')
for i, sid in enumerate(('phone-mobile', 'phone-ios')):
resumed = await desktop.dispatch({'id': i, 'method': 'session.resume', 'params': {'session_id': sid}})
assert resumed.get('result', {}).get('session_id') == sid, resumed
assert [m['content'] for m in resumed['result']['messages']] == ['hi from ' + sid]
sent = await desktop.dispatch({'id': 10 + i, 'method': 'prompt.submit',
'params': {'session_id': sid, 'text': 'go on', 'submission_id': 's-' + sid}})
assert 'error' not in sent, sent
# Bound, it is the Desktop chat it resumes as; a second open still finds it.
assert db.get_session(sid)['source'] == 'desktop'
again = await desktop.dispatch({'id': 20 + i, 'method': 'session.resume', 'params': {'session_id': sid}})
assert again.get('result', {}).get('session_id') == sid, again
await desktop.dispatch({'id': 30, 'method': 'session.resume', 'params': {'session_id': 'webui-row'}})
sent = await desktop.dispatch({'id': 31, 'method': 'prompt.submit',
'params': {'session_id': 'webui-row', 'text': 'go on', 'submission_id': 's-w'}})
assert (sent.get('error', {}).get('data') or {}).get('reason') == 'session_read_only', sent
assert db.get_session('webui-row')['source'] == 'webui'
@pytest.mark.asyncio
async def test_a_plugin_platform_sharing_the_label_keeps_its_messaging_route(tmp_path, monkeypatch):
from gateway.config import Platform
from gateway.session import SessionSource
store, desktop = await _owner(tmp_path, monkeypatch, {})
Platform._add_pseudo_member('mobile') # what the hermes-mobile plugin's platform registration yields
entry = store.get_or_create_session(SessionSource(platform=Platform('mobile'), chat_id='device-1',
chat_type='dm', user_id='device-1'))
store._db.append_message(entry.session_id, role='user', content='from the plugin app')
resumed = await desktop.dispatch({'id': 1, 'method': 'session.resume',
'params': {'session_id': entry.session_id}})
assert [m['content'] for m in resumed.get('result', {}).get('messages', [])] == ['from the plugin app'], resumed
assert store._db.get_session(entry.session_id)['source'] == 'mobile'
+4 -1
View File
@@ -116,7 +116,10 @@ always-on-top overlays, OS-window inspection, external-terminal launch and the u
Chats written before the gateway owned sessions show their history but cannot be continued from the
browser until they are adopted: run `hermes sessions adopt` once on the host (`hermes -p <profile>
sessions adopt` for another profile), or open them once in the native Desktop, TUI or CLI.
sessions adopt` for another profile), or open them once in the native Desktop, TUI or CLI. That
includes chats a phone app started through the dashboard login under its own label (`mobile`, `ios`):
they continue like Desktop chats. Rows a separate WebUI process wrote with its own agent, and old cron
or subagent runs, stay read-only.
### Open it from your phone or another computer