mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
30b6d8b1f1
commit
fcee404be6
9 files changed
+144
-27
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})))
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user