feat: add owned advanced chat storage and qualified Hermes surfaces
This commit is contained in:
1 parent
bba522bc26
commit
be2b9f83d0
3 files changed
+1099
No files matched your search
@@ -0,0 +1,731 @@
|
||||
"""Authenticated advanced chat surfaces. Hope stores metadata; Hermes executes.
|
||||
|
||||
Construct FeatureStore with the application's Accounts and include
|
||||
create_feature_router(store, provider). No provider operation is enabled merely
|
||||
because a method exists: the adapter must explicitly qualify that capability.
|
||||
All persistence and extraction is offloaded by the async public methods.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from .app import current_user
|
||||
|
||||
MIMES = ('text/plain', 'text/markdown', 'application/pdf', 'image/png', 'image/jpeg', 'image/webp')
|
||||
ID = re.compile(r'[a-f0-9]{32}\Z')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureLimits:
|
||||
upload_bytes: int = 8 * 1024 * 1024
|
||||
account_bytes: int = 64 * 1024 * 1024
|
||||
disk_bytes: int = 512 * 1024 * 1024
|
||||
extracted_chars: int = 100_000
|
||||
artifact_bytes: int = 512 * 1024
|
||||
artifact_count: int = 200
|
||||
account_artifact_bytes: int = 32 * 1024 * 1024
|
||||
global_artifact_bytes: int = 256 * 1024 * 1024
|
||||
artifact_versions: int = 100
|
||||
attachments_per_message: int = 8
|
||||
|
||||
|
||||
class FeatureStore:
|
||||
def __init__(self, accounts, root_path, limits=None):
|
||||
self.accounts = accounts
|
||||
self.db = accounts.db
|
||||
self.lock = accounts.lock
|
||||
self.limits = limits or FeatureLimits()
|
||||
self.upload_admission = threading.BoundedSemaphore(2)
|
||||
raw = Path(root_path).absolute()
|
||||
if raw.is_symlink():
|
||||
raise ValueError('Private storage cannot be a symlink')
|
||||
raw.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.root = raw.resolve()
|
||||
os.chmod(self.root, 0o700)
|
||||
with self.lock, self.db:
|
||||
self.db.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS hope_attachments (
|
||||
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL,
|
||||
extracted TEXT, extraction_status TEXT NOT NULL, created_at REAL NOT NULL,
|
||||
deleted_at REAL);
|
||||
CREATE INDEX IF NOT EXISTS hope_attachments_owner ON hope_attachments(owner);
|
||||
CREATE TABLE IF NOT EXISTS hope_artifacts (
|
||||
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL,
|
||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, deleted_at REAL);
|
||||
CREATE TABLE IF NOT EXISTS hope_artifact_versions (
|
||||
artifact_id TEXT NOT NULL REFERENCES hope_artifacts(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL, content TEXT NOT NULL, created_at REAL NOT NULL,
|
||||
PRIMARY KEY(artifact_id, revision));
|
||||
CREATE TABLE IF NOT EXISTS hope_feature_changes (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, object_id TEXT NOT NULL, deleted INTEGER NOT NULL,
|
||||
created_at REAL NOT NULL);
|
||||
''')
|
||||
|
||||
def _owner(self, owner):
|
||||
row = self.db.execute("SELECT id FROM users WHERE id=? AND desired!='deleted'", (owner,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(401, 'Account unavailable')
|
||||
|
||||
def _path(self, identifier):
|
||||
if not isinstance(identifier, str) or not ID.fullmatch(identifier):
|
||||
raise HTTPException(404, 'Attachment not found')
|
||||
path = self.root / identifier
|
||||
if path.parent.resolve() != self.root or path.is_symlink():
|
||||
raise HTTPException(404, 'Attachment not found')
|
||||
return path
|
||||
|
||||
def _change(self, owner, kind, identifier, deleted=False):
|
||||
self.db.execute('INSERT INTO hope_feature_changes(owner,kind,object_id,deleted,created_at) VALUES(?,?,?,?,?)',
|
||||
(owner, kind, identifier, int(deleted), time.time()))
|
||||
|
||||
@staticmethod
|
||||
def _attachment(row):
|
||||
return {k: row[k] for k in ('id', 'filename', 'mime', 'size', 'extraction_status', 'created_at')}
|
||||
|
||||
def _extract(self, mime, data):
|
||||
if mime.startswith('text/'):
|
||||
try:
|
||||
text = data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(415, 'Text must be UTF-8') from None
|
||||
if '\x00' in text:
|
||||
raise HTTPException(415, 'Binary text is not supported')
|
||||
return text[:self.limits.extracted_chars], 'truncated' if len(text) > self.limits.extracted_chars else 'ready'
|
||||
signatures = {'application/pdf': b'%PDF-', 'image/png': b'\x89PNG\r\n\x1a\n', 'image/jpeg': b'\xff\xd8\xff'}
|
||||
if mime in signatures and not data.startswith(signatures[mime]):
|
||||
raise HTTPException(415, 'File does not match MIME type')
|
||||
if mime == 'image/webp' and not (data.startswith(b'RIFF') and data[8:12] == b'WEBP'):
|
||||
raise HTTPException(415, 'File does not match MIME type')
|
||||
if mime == 'application/pdf':
|
||||
if importlib.util.find_spec('pypdf') is None:
|
||||
return None, 'unavailable'
|
||||
# Untrusted compressed PDF structures must not run in the control
|
||||
# process. Child has CPU/address-space/output limits and a wall timeout.
|
||||
script = '''import resource,sys,io,json
|
||||
resource.setrlimit(resource.RLIMIT_AS,(384*1024*1024,384*1024*1024))
|
||||
resource.setrlimit(resource.RLIMIT_CPU,(4,4))
|
||||
from pypdf import PdfReader
|
||||
limit=int(sys.argv[1]); reader=PdfReader(io.BytesIO(sys.stdin.buffer.read()))
|
||||
if reader.is_encrypted:
|
||||
print(json.dumps([None,'encrypted'])); sys.exit(0)
|
||||
parts=[]; size=0; truncated=False
|
||||
for page in reader.pages:
|
||||
text=page.extract_text() or ''
|
||||
parts.append(text[:max(0,limit-size)])
|
||||
size+=len(text)
|
||||
if size>=limit:
|
||||
truncated=True; break
|
||||
print(json.dumps(['\\n'.join(parts)[:limit],'truncated' if truncated else 'ready']))
|
||||
'''
|
||||
try:
|
||||
result = subprocess.run([sys.executable, '-I', '-c', script, str(self.limits.extracted_chars)],
|
||||
input=data, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=6, check=True)
|
||||
text, status = json.loads(result.stdout)
|
||||
return text, status
|
||||
except Exception:
|
||||
return None, 'failed'
|
||||
return None, 'image'
|
||||
|
||||
def _upload(self, owner, filename, mime, data):
|
||||
if mime not in MIMES:
|
||||
raise HTTPException(415, 'Unsupported MIME type')
|
||||
if not isinstance(data, bytes) or not 0 < len(data) <= self.limits.upload_bytes:
|
||||
raise HTTPException(413, 'Upload byte limit exceeded')
|
||||
if not isinstance(filename, str) or not 1 <= len(filename) <= 160 or any(c in filename for c in ('/', '\\', '\x00', '\r', '\n')) or any(ord(c) < 32 for c in filename):
|
||||
raise HTTPException(422, 'Invalid filename')
|
||||
# Reserve parser concurrency before quota/extraction work; fail fast.
|
||||
if not self.upload_admission.acquire(blocking=False):
|
||||
raise HTTPException(429, 'Upload processing busy')
|
||||
try:
|
||||
with self.lock:
|
||||
self._owner(owner)
|
||||
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
|
||||
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
|
||||
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
|
||||
raise HTTPException(413, 'Private storage quota exceeded')
|
||||
text, status = self._extract(mime, data)
|
||||
finally:
|
||||
self.upload_admission.release()
|
||||
identifier = uuid.uuid4().hex
|
||||
path = self._path(identifier)
|
||||
with self.lock, self.db:
|
||||
self._owner(owner)
|
||||
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
|
||||
# Include retained/orphaned files after account deletion in global quota.
|
||||
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
|
||||
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
|
||||
raise HTTPException(413, 'Private storage quota exceeded')
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
self.db.execute('INSERT INTO hope_attachments VALUES(?,?,?,?,?,?,?,?,NULL)',
|
||||
(identifier, owner, filename, mime, len(data), text, status, time.time()))
|
||||
self._change(owner, 'attachment', identifier)
|
||||
except BaseException:
|
||||
# _path has verified the resolved parent and server-generated ID.
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
return self._attachment(self._get_attachment(owner, identifier))
|
||||
|
||||
def _get_attachment(self, owner, identifier):
|
||||
self._owner(owner)
|
||||
row = self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, 'Attachment not found')
|
||||
return row
|
||||
|
||||
def _read_attachment(self, owner, identifier):
|
||||
with self.lock:
|
||||
row = self._get_attachment(owner, identifier)
|
||||
path = self._path(identifier)
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
|
||||
with os.fdopen(fd, 'rb') as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size != row['size']:
|
||||
raise HTTPException(404, 'Attachment unavailable')
|
||||
data = stream.read(self.limits.upload_bytes + 1)
|
||||
except OSError:
|
||||
raise HTTPException(404, 'Attachment unavailable') from None
|
||||
return self._attachment(row), data
|
||||
|
||||
def _delete_attachment(self, owner, identifier):
|
||||
with self.lock, self.db:
|
||||
self._get_attachment(owner, identifier)
|
||||
path = self._path(identifier)
|
||||
path.unlink(missing_ok=True)
|
||||
self.db.execute('UPDATE hope_attachments SET deleted_at=? WHERE owner=? AND id=?', (time.time(), owner, identifier))
|
||||
self._change(owner, 'attachment', identifier, True)
|
||||
return {'ok': True}
|
||||
|
||||
async def upload(self, owner, filename, mime, data):
|
||||
return await asyncio.to_thread(self._upload, owner, filename, mime, data)
|
||||
|
||||
async def read_attachment(self, owner, identifier):
|
||||
return await asyncio.to_thread(self._read_attachment, owner, identifier)
|
||||
|
||||
async def delete_attachment(self, owner, identifier):
|
||||
return await asyncio.to_thread(self._delete_attachment, owner, identifier)
|
||||
|
||||
async def provider_content(self, owner, identifiers, *, vision=False):
|
||||
def build():
|
||||
if not isinstance(identifiers, list) or len(identifiers) > self.limits.attachments_per_message or any(not isinstance(identifier, str) or not ID.fullmatch(identifier) for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
|
||||
raise HTTPException(422, 'Invalid attachment selection')
|
||||
parts = []
|
||||
with self.lock:
|
||||
for identifier in identifiers:
|
||||
row = self._get_attachment(owner, identifier)
|
||||
if row['mime'].startswith('image/'):
|
||||
if not vision:
|
||||
raise HTTPException(409, 'Selected model is not qualified for image input')
|
||||
_, data = self._read_attachment(owner, identifier)
|
||||
parts.append({'type': 'image_url', 'image_url': {'url': 'data:' + row['mime'] + ';base64,' + base64.b64encode(data).decode('ascii')}})
|
||||
elif row['extraction_status'] in ('ready', 'truncated'):
|
||||
parts.append({'type': 'text', 'text': 'Attachment: ' + row['filename'] + '\n' + (row['extracted'] or '')})
|
||||
else:
|
||||
raise HTTPException(409, 'Attachment text extraction unavailable')
|
||||
return parts
|
||||
return await asyncio.to_thread(build)
|
||||
|
||||
@staticmethod
|
||||
def _validate_artifact(title, kind, content, limit):
|
||||
if not isinstance(title, str) or not 1 <= len(title.strip()) <= 160:
|
||||
raise HTTPException(422, 'Invalid artifact title')
|
||||
if kind not in ('text', 'markdown', 'code'):
|
||||
raise HTTPException(422, 'Unsupported artifact kind')
|
||||
if not isinstance(content, str) or len(content.encode('utf-8')) > limit:
|
||||
raise HTTPException(413, 'Artifact byte limit exceeded')
|
||||
|
||||
def _get_artifact(self, owner, identifier, revision=None):
|
||||
self._owner(owner)
|
||||
row = self.db.execute('SELECT * FROM hope_artifacts WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, 'Artifact not found')
|
||||
result = {k: row[k] for k in ('id', 'title', 'kind', 'revision', 'created_at', 'updated_at')}
|
||||
version = self.db.execute('SELECT content FROM hope_artifact_versions WHERE artifact_id=? AND revision=?', (identifier, revision if revision is not None else row['revision'])).fetchone()
|
||||
if version is None:
|
||||
raise HTTPException(404, 'Artifact version not found')
|
||||
result['content'] = version['content']
|
||||
if revision is not None:
|
||||
result['revision'] = revision
|
||||
return result
|
||||
|
||||
def _save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
|
||||
self._validate_artifact(title, kind, content, self.limits.artifact_bytes)
|
||||
now = time.time()
|
||||
with self.lock, self.db:
|
||||
self._owner(owner)
|
||||
used = self.db.execute('SELECT COALESCE(SUM(length(CAST(v.content AS BLOB))),0) FROM hope_artifact_versions v JOIN hope_artifacts a ON a.id=v.artifact_id WHERE a.owner=?', (owner,)).fetchone()[0]
|
||||
total = self.db.execute('SELECT COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM hope_artifact_versions').fetchone()[0]
|
||||
incoming = len(content.encode('utf-8'))
|
||||
if used + incoming > self.limits.account_artifact_bytes or total + incoming > self.limits.global_artifact_bytes:
|
||||
raise HTTPException(413, 'Artifact storage quota exceeded')
|
||||
if identifier is None:
|
||||
count = self.db.execute('SELECT COUNT(*) FROM hope_artifacts WHERE owner=?', (owner,)).fetchone()[0]
|
||||
if count >= self.limits.artifact_count:
|
||||
raise HTTPException(413, 'Artifact quota exceeded')
|
||||
identifier = uuid.uuid4().hex
|
||||
self.db.execute('INSERT INTO hope_artifacts VALUES(?,?,?,?,1,?,?,NULL)', (identifier, owner, title.strip(), kind, now, now))
|
||||
version = 1
|
||||
else:
|
||||
old = self._get_artifact(owner, identifier)
|
||||
if type(revision) is not int or revision != old['revision']:
|
||||
raise HTTPException(409, 'Artifact revision conflict')
|
||||
if revision >= self.limits.artifact_versions:
|
||||
raise HTTPException(413, 'Artifact version quota exceeded')
|
||||
version = revision + 1
|
||||
self.db.execute('UPDATE hope_artifacts SET title=?,kind=?,revision=?,updated_at=? WHERE owner=? AND id=?',
|
||||
(title.strip(), kind, version, now, owner, identifier))
|
||||
self.db.execute('INSERT INTO hope_artifact_versions VALUES(?,?,?,?)', (identifier, version, content, now))
|
||||
self._change(owner, 'artifact', identifier)
|
||||
return self._get_artifact(owner, identifier)
|
||||
|
||||
async def save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
|
||||
return await asyncio.to_thread(self._save_artifact, owner, title, kind, content, identifier, revision)
|
||||
|
||||
async def get_artifact(self, owner, identifier, revision=None):
|
||||
def get():
|
||||
with self.lock:
|
||||
return self._get_artifact(owner, identifier, revision)
|
||||
return await asyncio.to_thread(get)
|
||||
|
||||
async def delete_artifact(self, owner, identifier):
|
||||
def remove():
|
||||
with self.lock, self.db:
|
||||
self._get_artifact(owner, identifier)
|
||||
self.db.execute('UPDATE hope_artifacts SET deleted_at=?,updated_at=? WHERE owner=? AND id=?', (time.time(), time.time(), owner, identifier))
|
||||
self._change(owner, 'artifact', identifier, True)
|
||||
return {'ok': True}
|
||||
return await asyncio.to_thread(remove)
|
||||
|
||||
async def list_objects(self, owner, kind):
|
||||
def collect():
|
||||
with self.lock:
|
||||
self._owner(owner)
|
||||
if kind == 'attachments':
|
||||
return [self._attachment(r) for r in self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200', (owner,))]
|
||||
return [dict(row) for row in self.db.execute('SELECT id,title,kind,revision,created_at,updated_at FROM hope_artifacts WHERE owner=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 200', (owner,))]
|
||||
return await asyncio.to_thread(collect)
|
||||
|
||||
async def changes(self, owner, after=0):
|
||||
def collect():
|
||||
with self.lock:
|
||||
self._owner(owner)
|
||||
rows = self.db.execute('SELECT * FROM hope_feature_changes WHERE owner=? AND sequence>? ORDER BY sequence LIMIT 200', (owner, after)).fetchall()
|
||||
return {'changes': [{'sequence': r['sequence'], 'kind': r['kind'], 'id': r['object_id'], 'deleted': bool(r['deleted']), 'created_at': r['created_at']} for r in rows], 'cursor': rows[-1]['sequence'] if rows else after}
|
||||
return await asyncio.to_thread(collect)
|
||||
|
||||
|
||||
class FeatureProvider:
|
||||
"""Fixed-route private gateway adapter; no client-selected URL or tool loop."""
|
||||
def __init__(self, accounts, http_client=None, state_dir=None):
|
||||
import httpx
|
||||
self.accounts = accounts
|
||||
self.http = http_client or httpx.AsyncClient(timeout=30, follow_redirects=False)
|
||||
self.owns_http = http_client is None
|
||||
self.state_dir = Path(state_dir or os.getenv('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
|
||||
|
||||
async def aclose(self):
|
||||
if self.owns_http:
|
||||
await self.http.aclose()
|
||||
|
||||
async def _account(self, owner):
|
||||
user = await asyncio.to_thread(self.accounts.get, owner)
|
||||
if not user or user['desired'] == 'deleted':
|
||||
raise HTTPException(401, 'Account unavailable')
|
||||
if user['status'] != 'ready' or not user.get('hermes_url') or not user.get('hermes_api_key'):
|
||||
raise HTTPException(503, 'Hermes not ready')
|
||||
return user
|
||||
|
||||
async def feature_capabilities(self, owner):
|
||||
await self._account(owner)
|
||||
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
|
||||
# Exact pinned jobs API and previously qualified managed auto vision only.
|
||||
def memory_available():
|
||||
try:
|
||||
self._memory_file(owner, 'memory')
|
||||
self._memory_file(owner, 'user')
|
||||
return True
|
||||
except HTTPException:
|
||||
return False
|
||||
return {'scheduled_tasks': True, 'memory_controls': await asyncio.to_thread(memory_available),
|
||||
'image_input': settings['model'] == 'auto', 'connectors': False, 'research': False}
|
||||
|
||||
async def feature_request(self, owner, method, path, *, json=None, params=None):
|
||||
user = await self._account(owner)
|
||||
permitted = ((path == '/api/jobs' and method in ('GET', 'POST')) or
|
||||
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}', path) and method in ('PATCH', 'DELETE')) or
|
||||
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}/(?:pause|resume|run)', path) and method == 'POST'))
|
||||
if not permitted:
|
||||
raise HTTPException(501, 'Unqualified provider operation')
|
||||
try:
|
||||
response = await self.http.request(method, user['hermes_url'].rstrip('/') + path,
|
||||
headers={'Authorization': 'Bearer ' + user['hermes_api_key']}, json=json, params=params)
|
||||
if response.status_code >= 400:
|
||||
# Remote detail can include config, paths or credentials.
|
||||
status = response.status_code if response.status_code in (400, 404, 409, 422, 429, 503) else 502
|
||||
raise HTTPException(status, 'Hermes task operation rejected')
|
||||
return response.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(502, 'Hermes task operation failed') from None
|
||||
|
||||
def _memory_file(self, owner, section):
|
||||
if not ID.fullmatch(owner) or section not in ('memory', 'user'):
|
||||
raise HTTPException(404, 'Memory section not found')
|
||||
# Fixed layout shared with Provisioner._paths; never accept caller paths.
|
||||
components = (self.state_dir, self.state_dir / owner, self.state_dir / owner / 'home',
|
||||
self.state_dir / owner / 'home' / 'memories')
|
||||
for component in components:
|
||||
if component.is_symlink():
|
||||
raise HTTPException(409, 'Unsafe memory storage')
|
||||
directory = components[-1]
|
||||
if not directory.is_dir():
|
||||
raise HTTPException(503, 'Hermes memory storage unavailable')
|
||||
path = directory / ('MEMORY.md' if section == 'memory' else 'USER.md')
|
||||
if path.is_symlink():
|
||||
raise HTTPException(409, 'Unsafe memory storage')
|
||||
return path
|
||||
|
||||
def _memory_open(self, owner, section, flags):
|
||||
self._memory_file(owner, section)
|
||||
directory = os.open(self.state_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0))
|
||||
try:
|
||||
for name in (owner, 'home', 'memories'):
|
||||
next_directory = os.open(name, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0), dir_fd=directory)
|
||||
os.close(directory)
|
||||
directory = next_directory
|
||||
fd = os.open('MEMORY.md' if section == 'memory' else 'USER.md', flags | getattr(os, 'O_NOFOLLOW', 0) | os.O_NONBLOCK, 0o600, dir_fd=directory)
|
||||
file_info = os.fstat(fd)
|
||||
if not stat.S_ISREG(file_info.st_mode) or file_info.st_nlink != 1:
|
||||
os.close(fd)
|
||||
raise HTTPException(409, 'Unsafe memory storage')
|
||||
if flags & os.O_CREAT and os.geteuid() == 0:
|
||||
info = os.fstat(directory)
|
||||
os.fchown(fd, info.st_uid, info.st_gid)
|
||||
return fd
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
def _read_memory_section(self, owner, section):
|
||||
path = self._memory_file(owner, section)
|
||||
try:
|
||||
fd = self._memory_open(owner, section, os.O_RDONLY)
|
||||
except FileNotFoundError:
|
||||
data = b''
|
||||
else:
|
||||
with os.fdopen(fd, 'rb') as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise HTTPException(409, 'Unsafe memory storage')
|
||||
data = stream.read(65537)
|
||||
if len(data) > 65536:
|
||||
raise HTTPException(413, 'Memory section too large')
|
||||
try:
|
||||
content = data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(409, 'Invalid memory encoding') from None
|
||||
return {'content': content, 'revision': hashlib.sha256(data).hexdigest()}
|
||||
|
||||
async def read_memory(self, owner):
|
||||
await self._account(owner)
|
||||
def read():
|
||||
with self.accounts.lock:
|
||||
return {section: self._read_memory_section(owner, section) for section in ('memory', 'user')}
|
||||
return await asyncio.to_thread(read)
|
||||
|
||||
async def write_memory(self, owner, section, content, revision):
|
||||
await self._account(owner)
|
||||
def write():
|
||||
with self.accounts.lock:
|
||||
current = self.accounts.get(owner)
|
||||
if not current or current['desired'] == 'deleted':
|
||||
raise HTTPException(401, 'Account unavailable')
|
||||
old = self._read_memory_section(owner, section)
|
||||
if old['revision'] != revision:
|
||||
raise HTTPException(409, 'Memory revision conflict')
|
||||
path = self._memory_file(owner, section)
|
||||
data = content.encode('utf-8')
|
||||
if len(data) > 65536:
|
||||
raise HTTPException(413, 'Memory section too large')
|
||||
# Reopen without following symlinks and compare under the same
|
||||
# descriptor before in-place writing. Preserve runtime ownership.
|
||||
fd = self._memory_open(owner, section, os.O_RDWR | os.O_CREAT)
|
||||
with os.fdopen(fd, 'r+b') as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or hashlib.sha256(stream.read(65537)).hexdigest() != revision:
|
||||
raise HTTPException(409, 'Memory revision conflict')
|
||||
stream.seek(0)
|
||||
stream.write(data)
|
||||
stream.truncate()
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return {'revision': hashlib.sha256(data).hexdigest()}
|
||||
return await asyncio.to_thread(write)
|
||||
|
||||
|
||||
async def feature_json(request, limit):
|
||||
if request.headers.get('content-type', '').split(';')[0].lower() != 'application/json':
|
||||
raise HTTPException(415, 'JSON required')
|
||||
data = bytearray()
|
||||
async for chunk in request.stream():
|
||||
if len(data) + len(chunk) > limit:
|
||||
raise HTTPException(413, 'Request body too large')
|
||||
data.extend(chunk)
|
||||
try:
|
||||
result = json.loads(data)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(400, 'Invalid JSON') from None
|
||||
if not isinstance(result, dict):
|
||||
raise HTTPException(422, 'JSON object required')
|
||||
return result
|
||||
|
||||
|
||||
def create_feature_router(store, provider=None):
|
||||
router = APIRouter()
|
||||
|
||||
@router.post('/api/attachments', status_code=201)
|
||||
async def upload(request: Request, user=Depends(current_user)):
|
||||
data = bytearray()
|
||||
async for chunk in request.stream():
|
||||
if len(data) + len(chunk) > store.limits.upload_bytes:
|
||||
raise HTTPException(413, 'Upload byte limit exceeded')
|
||||
data.extend(chunk)
|
||||
user = await current_user(request) # Revalidate after potentially slow body upload.
|
||||
attachment = await store.upload(user['id'], request.headers.get('x-filename', 'attachment'), request.headers.get('content-type', '').split(';')[0].strip().lower(), bytes(data))
|
||||
return {'attachment': attachment}
|
||||
|
||||
@router.get('/api/attachments')
|
||||
async def attachments(user=Depends(current_user)):
|
||||
return {'attachments': await store.list_objects(user['id'], 'attachments'), 'next_cursor': None}
|
||||
|
||||
@router.get('/api/attachments/{identifier}')
|
||||
async def attachment(identifier: str, user=Depends(current_user)):
|
||||
metadata, _ = await store.read_attachment(user['id'], identifier)
|
||||
return {'attachment': metadata}
|
||||
|
||||
@router.get('/api/attachments/{identifier}/content')
|
||||
async def download(identifier: str, user=Depends(current_user)):
|
||||
metadata, data = await store.read_attachment(user['id'], identifier)
|
||||
return Response(data, media_type=metadata['mime'], headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(metadata['filename'], safe=''), 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store'})
|
||||
|
||||
@router.delete('/api/attachments/{identifier}')
|
||||
async def delete_attachment(identifier: str, user=Depends(current_user)):
|
||||
return await store.delete_attachment(user['id'], identifier)
|
||||
|
||||
@router.get('/api/artifacts')
|
||||
async def artifacts(user=Depends(current_user)):
|
||||
return {'artifacts': await store.list_objects(user['id'], 'artifacts'), 'next_cursor': None}
|
||||
|
||||
@router.post('/api/artifacts', status_code=201)
|
||||
async def create_artifact(request: Request, user=Depends(current_user)):
|
||||
body = await feature_json(request, store.limits.artifact_bytes + 8192)
|
||||
if set(body) - {'title', 'kind', 'content'}:
|
||||
raise HTTPException(422, 'Unknown artifact fields')
|
||||
user = await current_user(request)
|
||||
return {'artifact': await store.save_artifact(user['id'], body.get('title'), body.get('kind', 'markdown'), body.get('content', ''))}
|
||||
|
||||
@router.get('/api/artifacts/{identifier}')
|
||||
async def artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
|
||||
return {'artifact': await store.get_artifact(user['id'], identifier, revision)}
|
||||
|
||||
@router.patch('/api/artifacts/{identifier}')
|
||||
async def edit_artifact(identifier: str, request: Request, user=Depends(current_user)):
|
||||
body = await feature_json(request, store.limits.artifact_bytes + 8192)
|
||||
if set(body) - {'revision', 'title', 'kind', 'content'}:
|
||||
raise HTTPException(422, 'Unknown artifact fields')
|
||||
user = await current_user(request)
|
||||
old = await store.get_artifact(user['id'], identifier)
|
||||
return {'artifact': await store.save_artifact(user['id'], body.get('title', old['title']), body.get('kind', old['kind']), body.get('content', old['content']), identifier, body.get('revision'))}
|
||||
|
||||
@router.get('/api/artifacts/{identifier}/versions')
|
||||
async def artifact_versions(identifier: str, user=Depends(current_user)):
|
||||
def collect():
|
||||
with store.lock:
|
||||
store._get_artifact(user['id'], identifier)
|
||||
return [dict(row) for row in store.db.execute('SELECT revision,created_at FROM hope_artifact_versions WHERE artifact_id=? ORDER BY revision DESC', (identifier,))]
|
||||
return {'versions': await asyncio.to_thread(collect), 'next_cursor': None}
|
||||
|
||||
@router.get('/api/artifacts/{identifier}/export')
|
||||
async def export_artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
|
||||
item = await store.get_artifact(user['id'], identifier, revision)
|
||||
suffix = '.md' if item['kind'] == 'markdown' else '.txt'
|
||||
return Response(item['content'].encode('utf-8'), media_type='text/plain', headers={'Content-Disposition': 'attachment; filename="artifact-' + item['id'] + suffix + '"', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
|
||||
|
||||
@router.delete('/api/artifacts/{identifier}')
|
||||
async def delete_artifact(identifier: str, user=Depends(current_user)):
|
||||
return await store.delete_artifact(user['id'], identifier)
|
||||
|
||||
@router.get('/api/features/sync')
|
||||
async def sync(after: int = 0, user=Depends(current_user)):
|
||||
if after < 0:
|
||||
raise HTTPException(422, 'Invalid cursor')
|
||||
return await store.changes(user['id'], after)
|
||||
|
||||
async def qualified(owner, capability):
|
||||
if provider is None or not hasattr(provider, 'feature_capabilities'):
|
||||
raise HTTPException(501, 'Operation is not qualified for this account')
|
||||
try:
|
||||
capabilities = await provider.feature_capabilities(owner)
|
||||
except Exception:
|
||||
raise HTTPException(503, 'Hermes capabilities unavailable') from None
|
||||
if not isinstance(capabilities, dict) or capabilities.get(capability) is not True:
|
||||
raise HTTPException(501, 'Operation is not qualified for this account')
|
||||
|
||||
async def job_request(owner, method, suffix='', body=None, params=None):
|
||||
await qualified(owner, 'scheduled_tasks')
|
||||
try:
|
||||
return await provider.feature_request(owner, method, '/api/jobs' + suffix, json=body, params=params)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(502, 'Hermes task operation failed') from None
|
||||
|
||||
def task(job):
|
||||
if not isinstance(job, dict) or not isinstance(job.get('id'), str) or not re.fullmatch(r'[a-f0-9]{12}', job['id']):
|
||||
raise HTTPException(502, 'Invalid Hermes task response')
|
||||
# Raw job rows contain upstream credentials/configuration. Never return them.
|
||||
keys = ('id', 'name', 'schedule', 'prompt', 'enabled', 'repeat', 'created_at', 'last_run_at', 'next_run_at')
|
||||
return {key: job[key] for key in keys if key in job}
|
||||
|
||||
def task_id(identifier):
|
||||
if not re.fullmatch(r'[a-f0-9]{12}', identifier):
|
||||
raise HTTPException(404, 'Task not found')
|
||||
return identifier
|
||||
|
||||
async def task_body(request, *, patch=False):
|
||||
body = await feature_json(request, 32768)
|
||||
allowed = {'name', 'schedule', 'prompt', 'repeat', 'enabled'} if patch else {'name', 'schedule', 'prompt', 'repeat', 'paused'}
|
||||
if set(body) - allowed or not body:
|
||||
raise HTTPException(422, 'Invalid task fields')
|
||||
for key, maximum in (('name', 200), ('schedule', 200), ('prompt', 5000)):
|
||||
if key in body and (not isinstance(body[key], str) or not 1 <= len(body[key].strip()) <= maximum):
|
||||
raise HTTPException(422, 'Invalid task ' + key)
|
||||
if not patch and key not in body:
|
||||
raise HTTPException(422, 'Task ' + key + ' required')
|
||||
if 'repeat' in body and (type(body['repeat']) is not int or body['repeat'] <= 0):
|
||||
raise HTTPException(422, 'Invalid task repeat')
|
||||
for key in ('enabled', 'paused'):
|
||||
if key in body and type(body[key]) is not bool:
|
||||
raise HTTPException(422, 'Invalid task ' + key)
|
||||
body['deliver'] = 'local'
|
||||
return body
|
||||
|
||||
@router.get('/api/tasks')
|
||||
async def tasks(user=Depends(current_user)):
|
||||
result = await job_request(user['id'], 'GET', params={'include_disabled': 'true'})
|
||||
if not isinstance(result, dict) or not isinstance(result.get('jobs'), list):
|
||||
raise HTTPException(502, 'Invalid Hermes task response')
|
||||
return {'tasks': [task(job) for job in result['jobs']], 'next_cursor': None}
|
||||
|
||||
@router.post('/api/tasks', status_code=201)
|
||||
async def create_task(request: Request, user=Depends(current_user)):
|
||||
body = await task_body(request)
|
||||
user = await current_user(request)
|
||||
result = await job_request(user['id'], 'POST', body=body)
|
||||
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||
|
||||
@router.patch('/api/tasks/{identifier}')
|
||||
async def update_task(identifier: str, request: Request, user=Depends(current_user)):
|
||||
body = await task_body(request, patch=True)
|
||||
user = await current_user(request)
|
||||
result = await job_request(user['id'], 'PATCH', '/' + task_id(identifier), body)
|
||||
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||
|
||||
@router.delete('/api/tasks/{identifier}')
|
||||
async def delete_task(identifier: str, user=Depends(current_user)):
|
||||
result = await job_request(user['id'], 'DELETE', '/' + task_id(identifier))
|
||||
if not isinstance(result, dict) or result.get('ok') is not True:
|
||||
raise HTTPException(502, 'Invalid Hermes task response')
|
||||
return {'ok': True}
|
||||
|
||||
@router.post('/api/tasks/{identifier}/{action}')
|
||||
async def task_action(identifier: str, action: str, user=Depends(current_user)):
|
||||
if action not in ('pause', 'resume', 'run'):
|
||||
raise HTTPException(404, 'Task action not found')
|
||||
result = await job_request(user['id'], 'POST', '/' + task_id(identifier) + '/' + action)
|
||||
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
|
||||
|
||||
@router.get('/api/memory')
|
||||
async def memory(user=Depends(current_user)):
|
||||
await qualified(user['id'], 'memory_controls')
|
||||
try:
|
||||
result = await provider.read_memory(user['id'])
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(502, 'Memory unavailable') from None
|
||||
# Dedicated trusted adapter returns only fixed sections, never paths/config.
|
||||
if not isinstance(result, dict):
|
||||
raise HTTPException(502, 'Invalid memory response')
|
||||
sections = []
|
||||
for section in ('memory', 'user'):
|
||||
value = result.get(section)
|
||||
if not isinstance(value, dict) or not isinstance(value.get('content'), str) or not isinstance(value.get('revision'), str):
|
||||
raise HTTPException(502, 'Invalid memory response')
|
||||
sections.append({'section': section, 'content': value['content'], 'revision': value['revision']})
|
||||
return {'sections': sections}
|
||||
|
||||
@router.put('/api/memory/{section}')
|
||||
async def update_memory(section: str, request: Request, user=Depends(current_user)):
|
||||
if section not in ('memory', 'user'):
|
||||
raise HTTPException(404, 'Memory section not found')
|
||||
await qualified(user['id'], 'memory_controls')
|
||||
body = await feature_json(request, 131072)
|
||||
if set(body) != {'content', 'revision'} or not isinstance(body['content'], str) or len(body['content'].encode('utf-8')) > 65536 or not isinstance(body['revision'], str) or len(body['revision']) > 128:
|
||||
raise HTTPException(422, 'Invalid memory update')
|
||||
user = await current_user(request)
|
||||
try:
|
||||
result = await provider.write_memory(user['id'], section, body['content'], body['revision'])
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(502, 'Memory update failed') from None
|
||||
if not isinstance(result, dict) or not isinstance(result.get('revision'), str):
|
||||
raise HTTPException(502, 'Invalid memory response')
|
||||
return {'section': section, 'content': body['content'], 'revision': result['revision']}
|
||||
|
||||
@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'}
|
||||
|
||||
@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'}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Core merges these into its single /api/capabilities response.
|
||||
def local_capabilities(store):
|
||||
return {'attachments': True, 'artifacts': True, 'pdf_extraction': importlib.util.find_spec('pypdf') is not None,
|
||||
'attachment_mimes': list(MIMES), 'upload_bytes': store.limits.upload_bytes,
|
||||
'account_attachment_bytes': store.limits.account_bytes, 'artifact_bytes': store.limits.artifact_bytes,
|
||||
'image_input': False, 'scheduled_tasks': False, 'memory_controls': False,
|
||||
'connectors': False, 'research': False}
|
||||
Reference in new issue
Block a user