diff --git a/openspec/changes/eva-agent-mvp/design.md b/openspec/changes/eva-agent-mvp/design.md index 5be37aa..121ef5d 100644 --- a/openspec/changes/eva-agent-mvp/design.md +++ b/openspec/changes/eva-agent-mvp/design.md @@ -184,3 +184,12 @@ Issue агента — aep/eva-agent#1. Версия агента — 1. Аге - `FakeEva`: id документов отличны от кодов; ссылки по умолчанию `{"id": "", "code"}`, формы `ref_form`: `full`, `id_only`, `string`, `code_id`. Обоснование: прежний `_ref_code` на голой строке-id и dict без `code` молча давал `codes == [root]` при `done`. Цена ошибки: страница с ссылкой на родителя, не попавшего в список проекта, окажется оторванной от дерева (не будет обойдена). + +32. **`wiki_fetch` (задача 5): уточнения.** + - Проверка `Content-Length` вынесена в `Http.fetch_bytes`: заявленная длина больше предела — `TooBig` до чтения тела; без заголовка работает потоковое чтение limit + 1 байт. Предел включительно: ровно 10 МБ принимается. + - Ошибки: чужой хост (`ValueError` из `download`) → «чужой хост»; `TooBig` → «файл больше 10 МБ — откройте в Eva» (как в спеке хаба); HTTP 404 → «файл не найден в Eva»; перенаправление на другой адрес → «Eva перенаправила скачивание на <хост>»; прочее — общие правила `Executor.run` (5xx и сеть повторяются). + - Пустой файл (0 байт): хаб принимает его от агента (`POST /agent/files` → `files.put(allow_empty=True)`, решение 13 хаба; `check_result` сверяет размер 0 с `files.size`, `None` — только «нет файла»), поэтому агент отчитывается `done {file, size: 0}` без особых случаев. Расхождения с брифом нет; `fake_hub` повторяет это и добавил 413 для файла > 10 МБ, как `files.put`. + - Результат `{attachment_id, file, size}`; `size` берётся из ответа хаба. Содержимое в лог не пишется (только размер); в памяти одна копия данных, base64 строится на время отправки. + - Тесты каркаса (`test_runner`, `test_folder`), опиравшиеся на заглушку `wiki_fetch`, ждут теперь «чужой хост» (адрес фикстуры не на хосте Eva). + + Обоснование: сверка с кодом хаба (`attachments.check_result`, `api._put_file`) и спекой «Вложения вики по запросу». Цена ошибки: другой текст ошибки в карточке вложения; ловится живой проверкой. diff --git a/src/agent.py b/src/agent.py index f465700..8c71d34 100644 --- a/src/agent.py +++ b/src/agent.py @@ -113,6 +113,9 @@ class RedirectAway(Exception): self.host = host +MAX_ATTACHMENT = 10 * 1024 * 1024 # предел скачивания вложения, как у хранилища хаба (байт) + + class TooBig(Exception): """Ответ длиннее предела: чтение оборвано.""" @@ -201,11 +204,13 @@ class Http: req.add_header("Accept", "*/*") try: with self._opener().open(req, timeout=timeout or self.timeout) as resp: + if resp.length is not None and resp.length > limit: + raise TooBig("ответ длиннее %d байт (Content-Length %d)" % (limit, resp.length)) # тело не читаем data = resp.read(limit + 1) rest = resp.length # остаток по Content-Length: read(n) при обрыве молча отдаёт меньше if rest and len(data) <= limit: raise ConnectionError("%s: обрыв при чтении ответа: получено %d байт, не дошло %d" % (self.base, len(data), rest)) - except RedirectAway: + except (RedirectAway, TooBig): self._touch() raise except urllib.error.HTTPError as e: @@ -835,7 +840,27 @@ class Executor: return self._out("done", {"root_title": title, "codes": codes, "eva_calls": self.eva.calls}) def do_wiki_fetch(self, p: dict, cmd: dict) -> dict: - return self._out("failed", error="wiki_fetch: не реализовано") + """Файл вложения Eva → хабу (POST /agent/files). Eva только читается; адрес — лишь своего хоста (design 8, 24); + содержимое в лог не попадает, в памяти одна копия (плюс base64 на время отправки).""" + url = str(p.get("url") or "").strip() + if not url: + return self._out("failed", error="wiki_fetch: не задан url") + try: + data = self.eva.download(url, MAX_ATTACHMENT) + except ValueError: + return self._out("failed", error="чужой хост") + except TooBig: + return self._out("failed", error="файл больше 10 МБ — откройте в Eva") + except HttpError as e: + if e.code == 404: + return self._out("failed", error="файл не найден в Eva") + raise + except RedirectAway as e: + return self._out("failed", error="Eva перенаправила скачивание на %s" % e.host) + log(" вложение #%s (%d байт) <- Eva" % (p.get("attachment_id"), len(data))) + got = self._hub(self.hub.upload_file, data) + del data + return self._out("done", {"attachment_id": p.get("attachment_id"), "file": got.get("file"), "size": got.get("size")}) # --------------------------------------------------------------------------- diff --git a/tests/fake_eva.py b/tests/fake_eva.py index 484eda1..ab2ee26 100644 --- a/tests/fake_eva.py +++ b/tests/fake_eva.py @@ -25,7 +25,7 @@ class FakeEva: # форма ссылок parent/tree_parent в ответах: "full" — {"id": "", "code"} (по умолчанию, как в Eva), # "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка) self.ref_form = "full" - self.files = {} # путь → (байты, с Content-Length?) + self.files = {} # путь → (байты, Content-Length: True/False/заявленное число) self.requests = [] # {"method", "path", "body", "auth"} self.failures = [] # [status, retry_after] — по одному на ответ self.rpc_errors = [] @@ -121,8 +121,8 @@ class FakeEva: data, with_length = self.files[u.path] h.send_response(200) h.send_header("Content-Type", "application/octet-stream") - if with_length: - h.send_header("Content-Length", str(len(data))) + if with_length: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка) + h.send_header("Content-Length", str(len(data) if with_length is True else with_length)) h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения h.wfile.write(data) return diff --git a/tests/fake_hub.py b/tests/fake_hub.py index d8337c1..5075480 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -10,6 +10,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer MAX_PAGES = 50 +MAX_FILE = 10 * 1024 * 1024 def check_pages(command, command_id, pages): @@ -185,6 +186,8 @@ class FakeHub: if self.reject_files: return self.reject_files data = base64.b64decode(body.get("content") or "", validate=True) + if len(data) > MAX_FILE: # как files.put хаба; пустой файл от агента принимается (allow_empty) + return 413, {"error": "too_large", "detail": "файл больше %d байт (%d)" % (MAX_FILE, len(data))} self.uploaded.append(data) return 200, {"file": self.put_file(data), "size": len(data)} m = re.match(r"^/agent/commands/(\d+)$", path) diff --git a/tests/test_folder.py b/tests/test_folder.py index 42f46ee..7b8bfc0 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -210,7 +210,7 @@ class FolderIntegrationTest(unittest.TestCase): runner.idle_limit = 1 runner.loop() self.assertEqual(self.hub.results[cid]["status"], "failed") # заглушка Executor - self.assertEqual(self.hub.results[cid]["error"], "wiki_fetch: не реализовано") + self.assertEqual(self.hub.results[cid]["error"], "чужой хост") self.assertEqual(self.hub.results[cid]["agent_version"], agent.AGENT_VERSION) self.assertIn(("acked", cid), [(r["kind"], r["command_id"]) for r in journal.records()]) diff --git a/tests/test_runner.py b/tests/test_runner.py index 7a091a9..721f060 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -12,7 +12,7 @@ from fake_hub import FakeHub FETCH = {"url": "https://eva.example.ru/files/a.png", "name": "a.png"} -STUB_ERROR = "wiki_fetch: не реализовано" # Executor каркаса: обе команды — failed +STUB_ERROR = "чужой хост" # FETCH указывает на чужой хост: wiki_fetch отказывает без запросов к Eva class RunnerTest(unittest.TestCase): diff --git a/tests/test_wiki_fetch.py b/tests/test_wiki_fetch.py new file mode 100644 index 0000000..be6ad03 --- /dev/null +++ b/tests/test_wiki_fetch.py @@ -0,0 +1,93 @@ +import hashlib +import os +import unittest + +from _common import SETTINGS, agent, eva_cfg, no_sleep +from fake_eva import FakeEva +from fake_hub import FakeHub + +MB = 1024 * 1024 +TOO_BIG = "файл больше 10 МБ — откройте в Eva" + + +class WikiFetchTest(unittest.TestCase): + def setUp(self): + self.eva = FakeEva() + self.hub = FakeHub(SETTINGS) + self.addCleanup(self.eva.stop) + self.addCleanup(self.hub.stop) + cfg = eva_cfg(self.eva.base_url, hub_url=self.hub.base_url, hub_token=self.hub.token) + client = agent.EvaClient(cfg) + client.sleep = no_sleep + self.ex = agent.Executor(client, agent.HubClient(cfg), dict(SETTINGS)) + + def fetch(self, url): + return self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7, "url": url}}) + + def file_url(self, path="/files/a.bin"): + return self.eva.base_url + path + + def test_fetch_ok(self): + data = os.urandom(300 * 1024) + self.eva.add_file("/files/a.bin", data) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 307200) + self.assertEqual(out["result"]["file"], hashlib.sha256(data).hexdigest()) + self.assertEqual(self.hub.files[out["result"]["file"]], data) + self.assertEqual(self.eva.requests[0]["auth"], "Bearer " + self.eva.token) + + def test_fetch_foreign_host(self): + out = self.fetch("http://127.0.0.1:1/files/a.bin") + self.assertEqual(out["status"], "failed") + self.assertIn("чужой хост", out["error"]) + self.assertEqual(self.eva.requests, []) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_too_big_header(self): + self.eva.add_file("/files/a.bin", b"x" * 1024, length=11 * MB) # заявлено 11 МБ, тело не дойдёт + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], TOO_BIG) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_too_big_streaming(self): + self.eva.add_file("/files/a.bin", b"x" * (10 * MB + 1), length=False) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], TOO_BIG) + self.assertEqual(self.hub.uploaded, []) + + def test_fetch_exactly_limit(self): + self.eva.add_file("/files/a.bin", b"x" * (10 * MB)) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 10 * MB) + + def test_fetch_404(self): + out = self.fetch(self.file_url("/files/none.bin")) + self.assertEqual(out["status"], "failed") + self.assertEqual(out["error"], "файл не найден в Eva") + + def test_fetch_empty_file(self): + self.eva.add_file("/files/a.bin", b"") + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["size"], 0) + self.assertEqual(self.hub.files[out["result"]["file"]], b"") + + def test_fetch_hub_error_is_hub_error(self): + self.eva.add_file("/files/a.bin", b"abc") + self.hub.reject_files = (500, {"error": "boom"}) + out = self.fetch(self.file_url()) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["error"].startswith("хаб:"), out) + self.assertTrue(out["retryable"]) + + def test_fetch_no_url(self): + out = self.ex.run({"id": 1, "type": "wiki_fetch", "params": {"attachment_id": 7}}) + self.assertEqual(out["status"], "failed") + + +if __name__ == "__main__": + unittest.main()