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}
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user