feat: add owned advanced chat storage and qualified Hermes surfaces

This commit is contained in:
snowspeeder committed 2026-10-03 08:49:22 -04:00
1 parent bba522bc26
commit be2b9f83d0
3 files changed
+1099

No files matched your search

+731
View File
@@ -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}
+56
View File
@@ -0,0 +1,56 @@
# Advanced Hope chat surfaces
Implementation contract, not a deployment receipt. All routes authenticate via existing current_user (bearer or cookie with mutation Origin checks). Owner is derived server-side. Missing/cross-account/deleted objects return404; revoked sessions401. Hermes alone runs tools/reasoning. Provider credentials, URLs, raw job configuration and disk paths are never public.
## Attachments
- POST `/api/attachments`: raw bytes, MIME `Content-Type`, `X-Filename` display name. Returns201 `{attachment}`. No multipart wrapper.
- GET `/api/attachments`: `{attachments:[],next_cursor:null}` (bounded first200).
- GET `/api/attachments/{id}`: `{attachment}`.
- GET `/api/attachments/{id}/content`: private bytes, download disposition, no-store, nosniff.
- DELETE `/api/attachments/{id}`: `{ok:true}`. Removes stored bytes and tombstones metadata.
Attachment exactly `{id,filename,mime,size,extraction_status,created_at}`. IDs UUID hex32, Unix timestamps. Extraction statuses ready|truncated|image|unavailable|encrypted|failed. Supported MIME text/plain,text/markdown,application/pdf,image/png,image/jpeg,image/webp. Defaults8MiB per upload,64MiB per account,512MiB global disk; MIME mismatch415, invalid filename422, limit413. No HTML/SVG, arbitrary paths or remote fetch. UTF8 text extracted, PDFs via pypdf (encrypted/failure explicitly blocked as provider input). Text capped100000 characters. Images are inline image_url payloads only for explicitly qualified selected model; otherwise409. Signature validation does not imply antivirus or full image decoder validation.
Core integration `FeatureStore.provider_content(owner, attachment_ids, vision=False)` is async. At most8 distinct IDs. Never provider-input another owner's upload. Images use OpenAI message-content shape, not guessed Responses schema. Core supplies qualified vision flag. Local store persists in injected Accounts DB with FK plus private flat files. Account deletion denies access immediately and retains disk data by policy; no implicit erase.
## Artifacts
- GET `/api/artifacts`: `{artifacts:[],next_cursor:null}` (bounded first200). List entries metadata only, omitting content; select GET detail to load content.
- POST `/api/artifacts` JSON `{title,kind?:"markdown",content?:""}` →201 `{artifact}`.
- GET `/api/artifacts/{id}?revision=N`: `{artifact}`, historical content optional.
- GET `/api/artifacts/{id}/versions`: `{versions:[{revision,created_at}],next_cursor:null}`, descending revision.
- PATCH `/api/artifacts/{id}` JSON `{revision,title?,kind?,content?}` → `{artifact}`; missing/stale revision409.
- GET `/api/artifacts/{id}/export?revision=N`: UTF8 download, text/plain with .md for markdown, otherwise .txt. No executable inline HTML, PDF/DOCX generation claims.
- DELETE `/api/artifacts/{id}` → `{ok:true}`; historical versions inaccessible after tombstone.
Artifact `{id,title,kind,revision,content,created_at,updated_at}`. kind text|markdown|code. Title1–160 chars, default512KiB UTF8 content,200 artifacts including retained tombstones/account,100 versions/artifact. Cumulative retained version UTF8 bytes max32MiB/account and256MiB globally. Versions are not silently trimmed: explicit version limit/storage quota returns413, including deleted retained versions. Revision starts1, increments each edit. Historical versions currently retain content and time but title/kind reflect current metadata (not historical title).
## Scheduled tasks
Qualified gateway exact mapping `/api/jobs`; no second scheduler. Provider operations authenticated with account's private Hermes key. Public delivery is forced local; client cannot select providers, scripts, base URLs, external recipients or tools.
- GET `/api/tasks` → `{tasks:[],next_cursor:null}`; gateway query include_disabled=true.
- POST `/api/tasks` `{name,schedule,prompt,repeat?,paused?}` →201 `{task}`. Schedule is a Hermes schedule string, not a fabricated UI cron dialect. name1–200 chars, schedule1–200 chars, prompt1–5000 chars, repeat positive integer, paused boolean.
- PATCH `/api/tasks/{id}` `{name?,schedule?,prompt?,repeat?,enabled?}` → `{task}`. No optimistic revision supported by pinned Hermes jobs API.
- DELETE `/api/tasks/{id}` → `{ok:true}`.
- POST `/api/tasks/{id}/pause|resume|run` → `{task}`. Run triggers Hermes execution, not a Hope agent loop.
Task projection whitelist `{id,name?,schedule?,prompt?,enabled?,repeat?,created_at?,last_run_at?,next_run_at?}`. IDs hex12. Only id is guaranteed; optional fields preserve exact gateway type, clients must tolerate absent timestamps. No task-output-history API has been qualified. Invalid remote scheduling errors sanitized; unsupported capability501, unavailable503, upstream failure502.
## Memory controls
No gateway memory API exists. Trusted control adapter accesses only retained account `home/memories/MEMORY.md` and `USER.md`, never arbitrary file paths or separately authenticated WebUI.
- GET `/api/memory` → `{sections:[{section:"memory",content,revision},{section:"user",content,revision}]}`.
- PUT `/api/memory/{section}` `{content,revision}` → `{section,content,revision}`.
Revision SHA256 hex content digest. Empty content explicitly clears that section. UTF8 max64KiB/section. Unknown section404, stale revision409, unavailable storage503, unqualified adapter501. Updates persist files; already-running Hermes sessions may retain cached memory until a later run/session reload. No claim of live memory-cache invalidation. Do not confuse clearing files with deleting conversation history or all data. Protected fixed directories and no-follow file opens; runtime has independent filesystem writes, so revision is best-effort against concurrent Hermes edits, not cross-process transaction locking.
## 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/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.
+312
View File
@@ -0,0 +1,312 @@
"""Offline advanced API isolation, quotas, revocation and version tests."""
import asyncio
import os
from types import SimpleNamespace
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_features import FeatureLimits, FeatureProvider, FeatureStore, create_feature_router, local_capabilities
@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')]
for user in users:
accounts.update(user['id'], status='ready')
tokens = [accounts.issue_session(u['id'], 3600, u['password_hash'])[0] for u in users]
limits = FeatureLimits(upload_bytes=128, account_bytes=180, disk_bytes=256, artifact_bytes=128, artifact_count=3, artifact_versions=3)
store = FeatureStore(accounts, tmp_path / 'private', limits)
app = FastAPI()
app.state.accounts = accounts
app.state.settings = Settings(allowed_origins=('https://testserver',))
app.include_router(create_feature_router(store))
with TestClient(app, base_url='https://testserver') as client:
yield SimpleNamespace(accounts=accounts, store=store, client=client, users=users, tokens=tokens, headers=[{'Authorization': 'Bearer ' + t} for t in tokens])
accounts.close()
def upload(env, data=b'hello', mime='text/plain', filename='note.txt', account=0):
return env.client.post('/api/attachments', content=data, headers={**env.headers[account], 'Content-Type': mime, 'X-Filename': filename})
def artifact(env):
result = env.client.post('/api/artifacts', headers=env.headers[0], json={'title': 'Draft', 'kind': 'markdown', 'content': '# Hello'})
assert result.status_code == 201, result.text
return result.json()['artifact']
def test_upload_download_private_and_owned(env):
result = upload(env)
assert result.status_code == 201
item = result.json()['attachment']
assert set(item) == {'id', 'filename', 'mime', 'size', 'extraction_status', 'created_at'}
assert item['extraction_status'] == 'ready'
path = env.store.root / item['id']
assert os.stat(path).st_mode & 0o777 == 0o600
assert os.stat(env.store.root).st_mode & 0o777 == 0o700
url = '/api/attachments/' + item['id']
response = env.client.get(url + '/content', headers=env.headers[0])
assert response.content == b'hello'
assert response.headers['cache-control'] == 'no-store'
for method, suffix in (('get', ''), ('get', '/content'), ('delete', '')):
assert getattr(env.client, method)(url + suffix, headers=env.headers[1]).status_code == 404
assert env.client.get('/api/attachments', headers=env.headers[1]).json()['attachments'] == []
assert env.client.get(url).status_code == 401
content = asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']]))
assert content == [{'type': 'text', 'text': 'Attachment: note.txt\nhello'}]
with pytest.raises(HTTPException) as error:
asyncio.run(env.store.provider_content(env.users[1]['id'], [item['id']]))
assert error.value.status_code == 404
@pytest.mark.parametrize('data,mime,filename,status', [
(b'hello', 'text/html', 'a.html', 415),
(b'hello', 'image/png', 'a.png', 415),
(b'hello', 'application/pdf', 'a.pdf', 415),
(b'RIFFbad', 'image/webp', 'a.webp', 415),
(b'\xff', 'text/plain', 'a.txt', 415),
(b'\x00', 'text/plain', 'a.txt', 415),
(b'hello', 'text/plain', '../escape', 422),
(b'hello', 'text/plain', 'dir\\escape', 422),
(b'x' * 129, 'text/plain', 'a.txt', 413),
(b'', 'text/plain', 'a.txt', 413),
])
def test_mime_bytes_paths(env, data, mime, filename, status):
assert upload(env, data, mime, filename).status_code == status
assert list(env.store.root.iterdir()) == []
def test_quotas_delete_tombstones_and_reuse(env):
a = upload(env, b'x' * 100).json()['attachment']
assert upload(env, b'x' * 90).status_code == 413
assert upload(env, b'y' * 128, account=1).status_code == 201
assert upload(env, b'z' * 29, account=1).status_code == 413
assert env.client.delete('/api/attachments/' + a['id'], headers=env.headers[0]).status_code == 200
assert not (env.store.root / a['id']).exists()
assert env.client.get('/api/attachments/' + a['id'], headers=env.headers[0]).status_code == 404
changes = env.client.get('/api/features/sync', headers=env.headers[0]).json()
assert [c['deleted'] for c in changes['changes']] == [False, True]
assert len(env.client.get('/api/features/sync', headers=env.headers[1]).json()['changes']) == 1
assert upload(env, b'x' * 100).status_code == 201
def test_symlink_denial(env, tmp_path):
item = upload(env).json()['attachment']
path = env.store.root / item['id']
outside = tmp_path / 'outside'
outside.write_bytes(b'private')
path.unlink()
path.symlink_to(outside)
assert env.client.get('/api/attachments/' + item['id'] + '/content', headers=env.headers[0]).status_code == 404
assert env.client.delete('/api/attachments/' + item['id'], headers=env.headers[0]).status_code == 404
assert outside.read_bytes() == b'private'
def test_image_payload_requires_explicit_vision(env):
image = b'\x89PNG\r\n\x1a\n' + b'fake-offline-image'
item = upload(env, image, 'image/png', 'a.png').json()['attachment']
with pytest.raises(HTTPException) as error:
asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']]))
assert error.value.status_code == 409
parts = asyncio.run(env.store.provider_content(env.users[0]['id'], [item['id']], vision=True))
assert parts[0]['image_url']['url'].startswith('data:image/png;base64,')
assert not local_capabilities(env.store)['image_input']
def test_artifact_version_conflict_export_and_tombstones(env):
item = artifact(env)
url = '/api/artifacts/' + item['id']
assert env.client.patch(url, headers=env.headers[0], json={'revision': 0, 'content': 'No'}).status_code == 409
response = env.client.patch(url, headers=env.headers[0], json={'revision': 1, 'content': 'Second'})
assert response.status_code == 200
assert response.json()['artifact']['revision'] == 2
assert env.client.get(url + '?revision=1', headers=env.headers[0]).json()['artifact']['content'] == '# Hello'
assert env.client.get(url + '/export?revision=1', headers=env.headers[0]).content == b'# Hello'
assert env.client.get(url + '/export', headers=env.headers[0]).content == b'Second'
for method, suffix in (('get', ''), ('get', '/export'), ('delete', '')):
assert getattr(env.client, method)(url + suffix, headers=env.headers[1]).status_code == 404
assert env.client.patch(url, headers=env.headers[1], json={'revision': 2, 'content': 'theft'}).status_code == 404
assert env.client.delete(url, headers=env.headers[0]).status_code == 200
assert env.client.get(url + '?revision=1', headers=env.headers[0]).status_code == 404
assert env.client.get('/api/features/sync', headers=env.headers[0]).json()['changes'][-1]['deleted']
def test_revocation_and_delete_fk(env):
attachment = upload(env).json()['attachment']
item = artifact(env)
env.accounts.revoke(env.tokens[0])
assert env.client.get('/api/attachments/' + attachment['id'] + '/content', headers=env.headers[0]).status_code == 401
assert env.client.get('/api/artifacts/' + item['id'], headers=env.headers[0]).status_code == 401
env.accounts.begin_delete(env.users[0]['id'])
with pytest.raises(HTTPException) as error:
asyncio.run(env.store.provider_content(env.users[0]['id'], [attachment['id']]))
assert error.value.status_code == 401
env.accounts.finish_delete(env.users[0]['id'])
assert env.accounts.db.execute('SELECT COUNT(*) FROM hope_attachments').fetchone()[0] == 0
assert env.accounts.db.execute('SELECT COUNT(*) FROM hope_artifact_versions').fetchone()[0] == 0
# Retained private disk is deliberate; account deletion is not guarded erase.
assert (env.store.root / attachment['id']).exists()
def test_cookie_csrf_limits_and_artifact_versions(env):
env.client.cookies.set('hope_session', env.tokens[0])
assert env.client.post('/api/artifacts', json={'title': 'x'}).status_code == 403
item = artifact(env)
url = '/api/artifacts/' + item['id']
assert env.client.patch(url, headers=env.headers[0], json={'revision': 1, 'content': 'x' * 129}).status_code == 413
assert env.client.patch(url, headers=env.headers[0], json={'revision': 1}).status_code == 200
assert env.client.patch(url, headers=env.headers[0], json={'revision': 2}).status_code == 200
assert env.client.patch(url, headers=env.headers[0], json={'revision': 3}).status_code == 413
assert env.client.post('/api/artifacts', headers=env.headers[0], json={'title': 'x', 'user_id': env.users[1]['id']}).status_code == 422
assert env.client.get('/api/features/sync?after=-1', headers=env.headers[0]).status_code == 422
class FakeFeatures:
def __init__(self):
self.calls = []
self.qualified = True
async def feature_capabilities(self, owner):
return {'scheduled_tasks': self.qualified}
async def feature_request(self, owner, method, path, *, json=None, params=None):
self.calls.append((owner, method, path, json, params))
job = {'id': 'abcdef123456', 'name': 'Reminder', 'schedule': 'every 2h', 'prompt': 'Hello', 'enabled': True,
'provider': 'secret', 'base_url': 'secret', 'api_key': 'secret', 'script': 'secret'}
if method == 'GET':
return {'jobs': [job]}
if method == 'DELETE':
return {'ok': True}
return {'job': job}
def test_tasks_exact_gateway_mapping_no_secret_projection(env):
fake = FakeFeatures()
app = FastAPI()
app.state.accounts = env.accounts
app.state.settings = Settings()
app.include_router(create_feature_router(env.store, fake))
with TestClient(app) as client:
result = client.get('/api/tasks', headers=env.headers[0])
assert result.status_code == 200
assert 'secret' not in result.text
assert fake.calls[-1] == (env.users[0]['id'], 'GET', '/api/jobs', None, {'include_disabled': 'true'})
body = {'name': 'Reminder', 'schedule': 'every 2h', 'prompt': 'Hello', 'repeat': 2}
result = client.post('/api/tasks', headers=env.headers[0], json=body)
assert result.status_code == 201
assert fake.calls[-1][3] == {**body, 'deliver': 'local'}
assert client.post('/api/tasks', headers=env.headers[0], json={**body, 'deliver': 'external'}).status_code == 422
assert client.patch('/api/tasks/abcdef123456', headers=env.headers[0], json={'enabled': False}).status_code == 200
for action in ('pause', 'resume', 'run'):
assert client.post('/api/tasks/abcdef123456/' + action, headers=env.headers[1]).status_code == 200
assert fake.calls[-1][0] == env.users[1]['id']
assert fake.calls[-1][2] == '/api/jobs/abcdef123456/' + action
assert client.delete('/api/tasks/abcdef123456', headers=env.headers[0]).status_code == 200
assert client.post('/api/tasks/bad/run', headers=env.headers[0]).status_code == 404
fake.qualified = False
assert client.get('/api/tasks', headers=env.headers[0]).status_code == 501
assert client.get('/api/connectors', headers=env.headers[0]).json()['available'] is False
assert client.get('/api/research', headers=env.headers[0]).json()['available'] is False
assert client.get('/api/memory', headers=env.headers[0]).status_code == 501
def test_default_provider_auth_fixed_paths_memory_conflict(env, tmp_path):
import httpx
requests = []
def handle(request):
requests.append(request)
return httpx.Response(200, json={'jobs': []})
http = httpx.AsyncClient(transport=httpx.MockTransport(handle))
provider = FeatureProvider(env.accounts, http_client=http, state_dir=tmp_path / 'hermes')
env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private')
memory = provider.state_dir / env.users[0]['id'] / 'home' / 'memories'
memory.mkdir(parents=True)
(memory / 'MEMORY.md').write_text('old')
result = asyncio.run(provider.read_memory(env.users[0]['id']))
revision = result['memory']['revision']
updated = asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'new', revision))
assert updated['revision'] != revision
assert (memory / 'MEMORY.md').read_text() == 'new'
with pytest.raises(HTTPException) as error:
asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'stale', revision))
assert error.value.status_code == 409
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/jobs'))
assert requests[-1].headers['authorization'] == 'Bearer private'
with pytest.raises(HTTPException) as error:
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/config'))
assert error.value.status_code == 501
outside = tmp_path / 'private-file'
outside.write_text('never touch')
(memory / 'USER.md').symlink_to(outside)
with pytest.raises(HTTPException) as error:
asyncio.run(provider.read_memory(env.users[0]['id']))
assert error.value.status_code == 409
assert outside.read_text() == 'never touch'
env.accounts.begin_delete(env.users[0]['id'])
with pytest.raises(HTTPException) as error:
asyncio.run(provider.feature_request(env.users[0]['id'], 'GET', '/api/jobs'))
assert error.value.status_code == 401
asyncio.run(http.aclose())
def test_memory_special_file_and_hardlink_rejected(env, tmp_path):
provider = FeatureProvider(env.accounts, state_dir=tmp_path / 'hermes')
env.accounts.update(env.users[0]['id'], hermes_url='http://127.0.0.1:20000', hermes_api_key='private')
directory = provider.state_dir / env.users[0]['id'] / 'home' / 'memories'
directory.mkdir(parents=True)
target = directory / 'MEMORY.md'
os.mkfifo(target)
with pytest.raises(HTTPException) as error:
asyncio.run(provider.read_memory(env.users[0]['id']))
assert error.value.status_code == 409
target.unlink()
outside = tmp_path / 'outside-memory'
outside.write_text('untouched')
os.link(outside, target)
with pytest.raises(HTTPException) as error:
asyncio.run(provider.write_memory(env.users[0]['id'], 'memory', 'bad', 'x'))
assert error.value.status_code == 409
assert outside.read_text() == 'untouched'
asyncio.run(provider.aclose())
def test_cumulative_artifact_quota_and_metadata_list(env):
from dataclasses import replace
env.store.limits = replace(env.store.limits, account_artifact_bytes=12, global_artifact_bytes=20)
item = artifact(env) # Seven bytes.
listing = env.client.get('/api/artifacts', headers=env.headers[0]).json()['artifacts']
assert 'content' not in listing[0]
assert env.client.patch('/api/artifacts/' + item['id'], headers=env.headers[0], json={'revision': 1, 'content': '123456'}).status_code == 413
assert env.client.patch('/api/artifacts/' + item['id'], headers=env.headers[0], json={'revision': 1, 'content': '12345'}).status_code == 200
versions = env.client.get('/api/artifacts/' + item['id'] + '/versions', headers=env.headers[0]).json()['versions']
assert [v['revision'] for v in versions] == [2, 1]
assert env.client.get('/api/artifacts/' + item['id'] + '/versions', headers=env.headers[1]).status_code == 404
def test_pdf_subprocess_limits_and_admission(env, monkeypatch):
import control.chat_features as module
monkeypatch.setattr(module.importlib.util, 'find_spec', lambda name: object())
calls = []
def run(args, **kwargs):
calls.append((args, kwargs))
assert 'RLIMIT_AS' in args[3] and 'RLIMIT_CPU' in args[3]
assert kwargs['timeout'] == 6
return SimpleNamespace(stdout=b'["extracted", "ready"]')
monkeypatch.setattr(module.subprocess, 'run', run)
response = upload(env, b'%PDF-offline', 'application/pdf', 'file.pdf')
assert response.status_code == 201
assert response.json()['attachment']['extraction_status'] == 'ready'
assert len(calls) == 1
env.store.upload_admission.acquire()
env.store.upload_admission.acquire()
try:
assert upload(env, b'%PDF-offline', 'application/pdf', 'file.pdf').status_code == 429
assert len(calls) == 1
finally:
env.store.upload_admission.release()
env.store.upload_admission.release()