diff --git a/control/chat_exports.py b/control/chat_exports.py new file mode 100644 index 0000000..7aa06ad --- /dev/null +++ b/control/chat_exports.py @@ -0,0 +1,53 @@ +"""Private bounded conversation exports; no public sharing or provider execution.""" +import asyncio +import json + +from fastapi import APIRouter, Depends, HTTPException, Request, Response + +from .app import current_user + +MAX_EXPORT_BYTES = 16 * 1024 * 1024 +MAX_EXPORT_MESSAGES = 10_000 + + +def export_conversation(store, owner, identifier, format='json', byte_limit=MAX_EXPORT_BYTES): + if format not in ('json', 'markdown'): + raise HTTPException(422, 'Export format must be json or markdown') + with store.lock: + try: + conversation = store._conversation(store._row('chat_conversations', owner, identifier)) + except (KeyError, ValueError): + raise HTTPException(404, 'Conversation not found') from None + count, content_bytes = store.db.execute('SELECT COUNT(*),COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM chat_messages WHERE owner=? AND conversation_id=?', (owner, identifier)).fetchone() + if count > MAX_EXPORT_MESSAGES or content_bytes > byte_limit: + raise HTTPException(413, 'Conversation export limit exceeded') + messages = [store._message(row) for row in store.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position', (owner, identifier))] + if format == 'json': + # Only public projection; no Hermes IDs, API URLs, credentials or tool events. + encoded = json.dumps({'version': 1, 'conversation': conversation, 'messages': messages}, ensure_ascii=False, indent=2).encode('utf-8') + mime, suffix = 'application/json', '.json' + else: + # Preserve original message text/citations. Always attachment+plain text: + # user markdown/HTML is never executed or rendered by this API. + sections = ['# ' + conversation['title'], ''] + for message in messages: + sections += ['## ' + message['role'].capitalize() + ' — ' + message['status'], '', message['content'], ''] + if message['attachments']: + sections += ['Attachments (IDs; private bytes not embedded): ' + ', '.join(message['attachments']), ''] + encoded = '\n'.join(sections).encode('utf-8') + mime, suffix = 'text/plain', '.md' + if len(encoded) > byte_limit: + raise HTTPException(413, 'Conversation export limit exceeded') + return encoded, mime, 'conversation-' + conversation['id'] + suffix + + +def create_export_router(): + router = APIRouter() + + @router.get('/api/conversations/{identifier}/export') + async def conversation_export(identifier: str, request: Request, format: str = 'json', user=Depends(current_user)): + data, mime, filename = await asyncio.to_thread(export_conversation, request.app.state.chat_store, user['id'], identifier, format) + await current_user(request) # Revocation during export denies final response. + return Response(data, media_type=mime, headers={'Content-Disposition': 'attachment; filename="' + filename + '"', + 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}) + return router diff --git a/control/chat_features.py b/control/chat_features.py index 03a73c7..e56cc58 100644 --- a/control/chat_features.py +++ b/control/chat_features.py @@ -361,6 +361,36 @@ class FeatureProvider: raise HTTPException(503, 'Hermes not ready') return user + async def toolset_status(self, owner): + user = await self._account(owner) + try: + response = await self.http.get(user['hermes_url'].rstrip('/') + '/v1/toolsets', headers={'Authorization': 'Bearer ' + user['hermes_api_key']}) + if response.status_code != 200: + raise ValueError() + body = response.json() + if not isinstance(body, dict) or not isinstance(body.get('data'), list): + raise ValueError() + return [{'name': row['name'], 'enabled': row.get('enabled') is True, 'configured': row.get('configured') is True, + 'tools': [tool for tool in row.get('tools', []) if isinstance(tool, str)][:200]} + for row in body['data'][:100] if isinstance(row, dict) and isinstance(row.get('name'), str) and isinstance(row.get('tools'), list)] + except Exception: + raise HTTPException(503, 'Hermes tool capability status unavailable') from None + + async def research_status(self, owner): + toolsets = await self.toolset_status(owner) + available = any(row['name'] == 'web' and row['enabled'] and row['configured'] and + {'web_search', 'web_extract'} <= set(row['tools']) for row in toolsets) + return {'available': available, 'mode': 'hermes_tools' if available else None, + 'reason': None if available else 'Hermes web search and extraction must be enabled and configured'} + + async def research_instruction(self, owner): + status = await self.research_status(owner) + if not status['available']: + raise HTTPException(409, 'Research tools are unavailable for this account') + return ('Research mode requested by the user. Use your Hermes web search and extraction tools to investigate the question. ' + 'Prefer primary sources, corroborate material claims, distinguish evidence from uncertainty, and cite source URLs in the final answer. ' + 'Do not claim sources were checked if tools failed or access was unavailable. Complete this research in this same Hermes run.') + async def feature_capabilities(self, owner): await self._account(owner) settings = await asyncio.to_thread(self.accounts.get_model_settings, owner) @@ -372,8 +402,12 @@ class FeatureProvider: return True except HTTPException: return False + try: + research = (await self.research_status(owner))['available'] + except HTTPException: + research = False return {'scheduled_tasks': True, 'memory_controls': await asyncio.to_thread(memory_available), - 'image_input': settings['model'] == 'auto', 'connectors': False, 'research': False} + 'image_input': settings['model'] == 'auto', 'connectors': False, 'research': research} async def feature_request(self, owner, method, path, *, json=None, params=None): user = await self._account(owner) @@ -712,12 +746,24 @@ def create_feature_router(store, provider=None): @router.get('/api/connectors') async def connectors(user=Depends(current_user)): # The pinned gateway has no connector management API. Do not invent one. - return {'connectors': [], 'available': False, 'reason': 'Hermes connector management API is not qualified'} + statuses = [] + if provider is not None and hasattr(provider, 'toolset_status'): + try: + statuses = await provider.toolset_status(user['id']) + except HTTPException: + pass + return {'connectors': [], 'available': False, 'reason': 'Pinned Hermes has no connector credential/settings write API', + 'toolsets': [{'name': row['name'], 'enabled': row['enabled'], 'configured': row['configured']} for row in statuses]} @router.get('/api/research') async def research(user=Depends(current_user)): # Research remains normal Hermes execution, never a Hope reasoning loop. - return {'available': False, 'reason': 'Dedicated research mode is not qualified; normal Hermes tool use remains available'} + if provider is not None and hasattr(provider, 'research_status'): + try: + return await provider.research_status(user['id']) + except HTTPException: + pass + return {'available': False, 'mode': None, 'reason': 'Hermes research tool availability could not be qualified'} return router diff --git a/docs/CHAT_FEATURES_CONTRACT.md b/docs/CHAT_FEATURES_CONTRACT.md index af5c85a..0411b85 100644 --- a/docs/CHAT_FEATURES_CONTRACT.md +++ b/docs/CHAT_FEATURES_CONTRACT.md @@ -49,8 +49,17 @@ Revision SHA256 hex content digest. Empty content explicitly clears that section ## Honest statuses and sync -- GET `/api/connectors` → `{connectors:[],available:false,reason}`. No qualified connector management API; do not present fabricated working integration switches. -- GET `/api/research` → `{available:false,reason}`. Normal Hermes web/browser tools remain available; dedicated research-mode API is not qualified. +- GET `/api/connectors` → `{connectors:[],available:false,reason,toolsets:[{name,enabled,configured}]}`. Read-only native tool configuration statuses from exact authenticated `/v1/toolsets`. No qualified connector credential/settings write API; do not present fabricated working OAuth/integration switches. Raw credentials/configuration are never returned. +- GET `/api/research` → `{available:bool,mode:"hermes_tools"|null,reason:string|null}`. Enabled only when the actual account manifest advertises enabled/configured `web` with both `web_search` and `web_extract`. Capability lookup failure is unavailable, never optimistic true. Native research means one ordinary Hermes run with source-verification/citation instruction, not a second Hope reasoning loop or special upstream research endpoint. +- Core message extension target POST `/api/conversations/{id}/messages` adds `mode?:"normal"|"research"`. Core must persist mode in idempotency payload and run settings; research calls `FeatureProvider.research_instruction(owner)` and supplies the returned static evidence/citations instruction to that same native Hermes run. Unqualified tools409. This module does not itself edit core message acceptance. - GET `/api/features/sync?after=0` → `{changes:[{sequence,kind:"attachment"|"artifact",id,deleted,created_at}],cursor}`; max200 per response, owner-filtered tombstones. This cursor is independent of core conversation sync. -Core merges `local_capabilities(store)` and account-qualified `FeatureProvider.feature_capabilities(owner)` into its one `/api/capabilities`. Local helper reports image/tasks/memory false by default; provider qualifies jobs, managed auto vision, and available fixed memory storage. Constructor `FeatureStore(accounts, root_path, limits=None)`; router `create_feature_router(store, provider=None)`; default `FeatureProvider(accounts,http_client=None,state_dir=None)` has `aclose()` which app shutdown must call. Memory root defaults HOPE_HERMES_DATA_DIR matching provisioner. No live/deploy qualification performed here. +Core merges `local_capabilities(store)` and account-qualified `FeatureProvider.feature_capabilities(owner)` into its one `/api/capabilities`. Local helper reports image/tasks/memory false by default; provider qualifies jobs, managed auto vision, and available fixed memory storage. Constructor `FeatureStore(accounts, root_path, limits=None)`; router `create_feature_router(store, provider=None)`; default `FeatureProvider(accounts,http_client=None,state_dir=None)` has `aclose()` which app shutdown must call. Memory root defaults HOPE_HERMES_DATA_DIR matching provisioner. Research source verified pinned `749220ef` gateway handler `_handle_toolsets` and exact web toolset expansion. No live/deploy qualification performed here. + +## Private conversation exports + +`create_export_router()` from `control.chat_exports` uses injected `app.state.chat_store`. + +- GET `/api/conversations/{id}/export?format=json|markdown` → authenticated attachment download. Defaults json. JSON `{version:1,conversation,messages:[]}` includes entire chronological message history, not a paginated first page. Only existing public projections, no private Hermes IDs, tool reasoning/events, account credentials or URLs. Markdown preserves original text/citations, exports as text/plain `.md` attachment rather than executable HTML. +- Full export is bounded16MiB encoded output and10000 messages; oversized413, unsupported format422, cross-account/deleted404, revoked401. Session revalidated before final response. Attachment IDs preserved but private upload bytes not embedded. No public share links, remote upload or public artifacts. +- Existing artifact export remains `/api/artifacts/{id}/export?revision=N`; exports selected exact stored content. Native model-generated artifacts require core capture/edit linkage, not an invented Hermes file API. diff --git a/tests/unit/test_chat_exports.py b/tests/unit/test_chat_exports.py new file mode 100644 index 0000000..317283b --- /dev/null +++ b/tests/unit/test_chat_exports.py @@ -0,0 +1,64 @@ +"""Offline owned exports; no Hermes/LiveKit/deployment calls.""" +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from control.accounts import Accounts +from control.app import Settings +from control.chat_exports import create_export_router, export_conversation +from control.chat_store import ChatStore + + +@pytest.fixture +def env(tmp_path): + accounts = Accounts(str(tmp_path / 'accounts.db')) + users = [accounts.create(name, name, 'user', 'correct-horse-password') for name in ('alice', 'bob')] + tokens = [accounts.issue_session(u['id'], 3600, u['password_hash'])[0] for u in users] + store = ChatStore(accounts) + conversation = store.create_conversation(users[0]['id'], title='Private') + store.add_message(users[0]['id'], conversation['id'], 'user', 'Original question') + store.add_message(users[0]['id'], conversation['id'], 'assistant', ' [Source](https://example.test)') + app = FastAPI() + app.state.accounts = accounts + app.state.settings = Settings() + app.state.chat_store = store + app.include_router(create_export_router()) + with TestClient(app) as client: + yield accounts, store, client, users, tokens, conversation + accounts.close() + + +def test_full_projection_and_safe_markdown(env): + accounts, store, client, users, tokens, conversation = env + headers = {'Authorization': 'Bearer ' + tokens[0]} + url = '/api/conversations/' + conversation['id'] + '/export' + response = client.get(url, headers=headers) + assert response.status_code == 200 + document = response.json() + assert len(document['messages']) == 2 + assert document['messages'][0]['content'] == 'Original question' + assert 'hermes_session_id' not in response.text + assert response.headers['cache-control'] == 'no-store' + assert response.headers['content-disposition'].startswith('attachment;') + markdown = client.get(url + '?format=markdown', headers=headers) + assert markdown.status_code == 200 + assert markdown.headers['content-type'].startswith('text/plain') + assert '[Source](https://example.test)' in markdown.text + assert '' in markdown.text + assert client.get(url + '?format=html', headers=headers).status_code == 422 + + +def test_owned_revoked_deleted_and_bounded(env): + accounts, store, client, users, tokens, conversation = env + url = '/api/conversations/' + conversation['id'] + '/export' + assert client.get(url).status_code == 401 + assert client.get(url, headers={'Authorization': 'Bearer ' + tokens[1]}).status_code == 404 + with pytest.raises(HTTPException) as error: + export_conversation(store, users[0]['id'], conversation['id'], byte_limit=4) + assert error.value.status_code == 413 + accounts.revoke(tokens[0]) + assert client.get(url, headers={'Authorization': 'Bearer ' + tokens[0]}).status_code == 401 + store.delete_conversation(users[0]['id'], conversation['id']) + with pytest.raises(HTTPException) as error: + export_conversation(store, users[0]['id'], conversation['id']) + assert error.value.status_code == 404 diff --git a/tests/unit/test_chat_features.py b/tests/unit/test_chat_features.py index d91250e..be5dd37 100644 --- a/tests/unit/test_chat_features.py +++ b/tests/unit/test_chat_features.py @@ -310,3 +310,31 @@ def test_pdf_subprocess_limits_and_admission(env, monkeypatch): finally: env.store.upload_admission.release() env.store.upload_admission.release() + + +def test_research_exact_tool_manifest_and_status_projection(env): + import httpx + manifest = {'object': 'list', 'platform': 'api_server', 'data': [ + {'name': 'web', 'enabled': True, 'configured': True, 'tools': ['web_search', 'web_extract'], 'secret': 'never expose'}]} + requests = [] + def handle(request): + requests.append(request) + assert request.url.path == '/v1/toolsets' + return httpx.Response(200, json=manifest) + http = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private') + provider = FeatureProvider(env.accounts, http_client=http) + assert asyncio.run(provider.research_status(env.users[0]['id'])) == {'available': True, 'mode': 'hermes_tools', 'reason': None} + instruction = asyncio.run(provider.research_instruction(env.users[0]['id'])) + assert 'same Hermes run' in instruction and 'cite source URLs' in instruction + assert requests[-1].headers['authorization'] == 'Bearer private' + assert 'secret' not in str(asyncio.run(provider.toolset_status(env.users[0]['id']))) + manifest['data'][0]['configured'] = False + assert not asyncio.run(provider.research_status(env.users[0]['id']))['available'] + with pytest.raises(HTTPException) as error: + asyncio.run(provider.research_instruction(env.users[0]['id'])) + assert error.value.status_code == 409 + manifest['data'][0]['configured'] = True + manifest['data'][0]['tools'] = ['web_search'] + assert not asyncio.run(provider.research_status(env.users[0]['id']))['available'] + asyncio.run(http.aclose())