mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-30 03:11:42 +00:00
489 lines
15 KiB
Python
489 lines
15 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import api as brain_api
|
|
from app.auth.local_control import LOCAL_CONTROL_CAPABILITY_HEADER
|
|
from app.controller import file_controller
|
|
|
|
|
|
def test_brain_cors_exposes_file_preview_metadata_headers():
|
|
cors = next(
|
|
middleware
|
|
for middleware in brain_api.user_middleware
|
|
if middleware.cls is CORSMiddleware
|
|
)
|
|
exposed = {header.lower() for header in cors.kwargs["expose_headers"]}
|
|
|
|
assert {
|
|
"accept-ranges",
|
|
"content-disposition",
|
|
"content-length",
|
|
"content-range",
|
|
"last-modified",
|
|
} <= exposed
|
|
|
|
|
|
def test_resolve_project_root_prefers_user_id_root(
|
|
monkeypatch, tmp_path, caplog
|
|
):
|
|
eigent_root = tmp_path / "eigent"
|
|
user_project = eigent_root / "user_20" / "project_p1"
|
|
user_project.mkdir(parents=True)
|
|
(eigent_root / "other_user" / "project_p1").mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(
|
|
file_controller, "_get_eigent_root", lambda: eigent_root
|
|
)
|
|
|
|
resolved = file_controller._resolve_project_root(
|
|
"yueming.lai@example.com", "p1", "20"
|
|
)
|
|
|
|
assert resolved == user_project
|
|
assert "Resolved project root via fallback lookup" not in caplog.text
|
|
|
|
|
|
def test_resolve_project_root_falls_back_to_legacy_email_root(
|
|
monkeypatch, tmp_path
|
|
):
|
|
eigent_root = tmp_path / "eigent"
|
|
legacy_project = eigent_root / "yueming.lai" / "project_p1"
|
|
legacy_project.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(
|
|
file_controller, "_get_eigent_root", lambda: eigent_root
|
|
)
|
|
|
|
resolved = file_controller._resolve_project_root(
|
|
"yueming.lai@example.com", "p1", "20"
|
|
)
|
|
|
|
assert resolved == legacy_project
|
|
|
|
|
|
def test_resolve_project_root_does_not_fallback_to_other_user_root(
|
|
monkeypatch, tmp_path
|
|
):
|
|
eigent_root = tmp_path / "eigent"
|
|
(eigent_root / "user_20" / "project_p1").mkdir(parents=True)
|
|
expected = eigent_root / "user_42" / "project_p1"
|
|
|
|
monkeypatch.setattr(
|
|
file_controller, "_get_eigent_root", lambda: eigent_root
|
|
)
|
|
|
|
resolved = file_controller._resolve_project_root(
|
|
"yueming.lai@example.com", "p1", "42"
|
|
)
|
|
|
|
assert resolved == expected
|
|
|
|
|
|
def test_resolve_project_root_without_user_id_stays_email_scoped(
|
|
monkeypatch, tmp_path
|
|
):
|
|
eigent_root = tmp_path / "eigent"
|
|
(eigent_root / "user_20" / "project_p1").mkdir(parents=True)
|
|
expected = eigent_root / "yueming.lai" / "project_p1"
|
|
|
|
monkeypatch.setattr(
|
|
file_controller, "_get_eigent_root", lambda: eigent_root
|
|
)
|
|
|
|
resolved = file_controller._resolve_project_root(
|
|
"yueming.lai@example.com", "p1"
|
|
)
|
|
|
|
assert resolved == expected
|
|
|
|
|
|
def test_task_changes_include_all_outputs_but_only_recent_workspace_edits(
|
|
tmp_path,
|
|
):
|
|
output_root = tmp_path / "outputs"
|
|
working_root = tmp_path / "workspace"
|
|
output_root.mkdir()
|
|
working_root.mkdir()
|
|
started_at = time.time()
|
|
|
|
copied_output = output_root / "copied-report.csv"
|
|
copied_output.write_text("output", encoding="utf-8")
|
|
old_workspace_file = working_root / "existing.md"
|
|
old_workspace_file.write_text("old", encoding="utf-8")
|
|
old_time = started_at - 60
|
|
os.utime(copied_output, (old_time, old_time))
|
|
os.utime(old_workspace_file, (old_time, old_time))
|
|
# Old files must be filtered before the 500-item result bound. Otherwise
|
|
# a large selected folder can hide a recent artifact later in the walk.
|
|
for index in range(510):
|
|
old_file = working_root / f"old-{index:03d}.txt"
|
|
old_file.write_text("old", encoding="utf-8")
|
|
os.utime(old_file, (old_time, old_time))
|
|
edited_workspace_file = working_root / "reports" / "final.md"
|
|
edited_workspace_file.parent.mkdir()
|
|
edited_workspace_file.write_text("new", encoding="utf-8")
|
|
|
|
files = file_controller._list_task_changed_files(
|
|
SimpleNamespace(
|
|
task_output_root=str(output_root),
|
|
working_directory=str(working_root),
|
|
task_start_time=started_at,
|
|
)
|
|
)
|
|
|
|
assert {item["path"] for item in files} == {
|
|
str(copied_output.resolve()),
|
|
str(edited_workspace_file.resolve()),
|
|
}
|
|
assert {item["relativePath"] for item in files} == {
|
|
"copied-report.csv",
|
|
"reports/final.md",
|
|
}
|
|
assert {item["path"]: item["changeType"] for item in files} == {
|
|
str(copied_output.resolve()): "generated",
|
|
str(edited_workspace_file.resolve()): "changed",
|
|
}
|
|
assert {item["path"]: item["size"] for item in files} == {
|
|
str(copied_output.resolve()): 6,
|
|
str(edited_workspace_file.resolve()): 3,
|
|
}
|
|
assert all(item["modifiedAt"] > 0 for item in files)
|
|
|
|
|
|
def test_task_changes_exclude_files_written_after_run_attempt(tmp_path):
|
|
output_root = tmp_path / "outputs"
|
|
working_root = tmp_path / "shared-space"
|
|
output_root.mkdir()
|
|
working_root.mkdir()
|
|
started_at = time.time() - 120
|
|
ended_at = started_at + 30
|
|
|
|
current_run_file = working_root / "bank-transfer.csv"
|
|
current_run_file.write_text("current", encoding="utf-8")
|
|
os.utime(
|
|
current_run_file,
|
|
(started_at + 10, started_at + 10),
|
|
)
|
|
future_run_file = working_root / "long-horizon" / "report.md"
|
|
future_run_file.parent.mkdir()
|
|
future_run_file.write_text("future", encoding="utf-8")
|
|
os.utime(
|
|
future_run_file,
|
|
(ended_at + 60, ended_at + 60),
|
|
)
|
|
|
|
files = file_controller._list_task_changed_files(
|
|
SimpleNamespace(
|
|
task_output_root=str(output_root),
|
|
working_directory=str(working_root),
|
|
task_start_time=started_at,
|
|
),
|
|
modification_windows=((started_at - 1, ended_at),),
|
|
)
|
|
|
|
assert [item["relativePath"] for item in files] == ["bank-transfer.csv"]
|
|
|
|
|
|
def test_task_changes_skip_file_deleted_between_walk_and_stat(
|
|
monkeypatch, tmp_path
|
|
):
|
|
output_root = tmp_path / "outputs"
|
|
output_root.mkdir()
|
|
disappearing = output_root / "ephemeral.txt"
|
|
disappearing.write_text("temporary", encoding="utf-8")
|
|
original_stat = Path.stat
|
|
target_stat_calls = 0
|
|
|
|
monkeypatch.setattr(
|
|
file_controller,
|
|
"list_files",
|
|
lambda *_args, **_kwargs: [str(disappearing)],
|
|
)
|
|
|
|
def disappearing_stat(path, *args, **kwargs):
|
|
nonlocal target_stat_calls
|
|
if path == disappearing:
|
|
target_stat_calls += 1
|
|
if target_stat_calls >= 2:
|
|
raise FileNotFoundError(path)
|
|
return original_stat(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "stat", disappearing_stat)
|
|
|
|
files = file_controller._list_task_changed_files(
|
|
SimpleNamespace(
|
|
task_output_root=str(output_root),
|
|
working_directory=str(output_root),
|
|
task_start_time=time.time() - 10,
|
|
)
|
|
)
|
|
|
|
assert files == []
|
|
|
|
|
|
def test_task_changes_endpoint_freezes_completed_run_manifest(
|
|
monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
|
|
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
|
|
started_at = time.time() - 120
|
|
ended_at = started_at + 30
|
|
changed_file = tmp_path / "report.csv"
|
|
changed_file.write_text("report", encoding="utf-8")
|
|
os.utime(changed_file, (started_at + 10, started_at + 10))
|
|
snapshot = SimpleNamespace(
|
|
project_id="project-1",
|
|
task_output_root=str(tmp_path / "outputs"),
|
|
working_directory=str(tmp_path),
|
|
task_start_time=started_at,
|
|
artifact_manifest=None,
|
|
)
|
|
resolver = MagicMock()
|
|
resolver.store.get_snapshot.return_value = snapshot
|
|
journal = MagicMock()
|
|
journal.get_run.return_value = SimpleNamespace(
|
|
project_id="project-1",
|
|
status="completed",
|
|
created_at=started_at,
|
|
updated_at=ended_at,
|
|
)
|
|
journal.list_run_attempts.return_value = [
|
|
SimpleNamespace(started_at=started_at, ended_at=ended_at)
|
|
]
|
|
monkeypatch.setattr(
|
|
file_controller, "get_workspace_resolver", lambda: resolver
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_default_run_journal", lambda: journal
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(file_controller.router)
|
|
client = TestClient(app, client=("127.0.0.1", 50000))
|
|
response = client.get(
|
|
"/files/changes",
|
|
params={
|
|
"task_id": "task-1",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
},
|
|
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert [item["relativePath"] for item in body["artifacts"]] == [
|
|
"report.csv"
|
|
]
|
|
assert body["scan_status"] == "complete"
|
|
assert body["truncated"] is False
|
|
resolver.store.freeze_artifact_manifest.assert_called_once_with(
|
|
"user@example.com", snapshot, body["artifacts"]
|
|
)
|
|
|
|
|
|
def test_task_changes_endpoint_reads_canonical_manifest_without_filesystem_scan(
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
|
|
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
|
|
resolver = MagicMock()
|
|
journal = MagicMock()
|
|
journal.get_run_artifact_manifest_event.return_value = SimpleNamespace(
|
|
payload={
|
|
"scan_status": "complete",
|
|
"artifacts": [
|
|
{
|
|
"artifact_id": "art-1",
|
|
"filename": "report.csv",
|
|
"path": "/workspace/report.csv",
|
|
"relativePath": "report.csv",
|
|
"changeType": "generated",
|
|
"size": 12,
|
|
"modifiedAt": 1234,
|
|
"supportsRanges": True,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_workspace_resolver", lambda: resolver
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_default_run_journal", lambda: journal
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller,
|
|
"_list_task_changed_files",
|
|
MagicMock(side_effect=AssertionError("filesystem scan must not run")),
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(file_controller.router)
|
|
client = TestClient(app, client=("127.0.0.1", 50000))
|
|
response = client.get(
|
|
"/files/changes",
|
|
params={
|
|
"task_id": "task-1",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
},
|
|
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["artifacts"][0]["artifact_id"] == "art-1"
|
|
assert response.json()["scan_status"] == "complete"
|
|
assert response.json()["truncated"] is False
|
|
resolver.store.get_snapshot.assert_not_called()
|
|
|
|
|
|
def test_task_changes_treats_unavailable_manifest_as_finalized_history(
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
|
|
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
|
|
resolver = MagicMock()
|
|
journal = MagicMock()
|
|
journal.get_run_artifact_manifest_event.return_value = SimpleNamespace(
|
|
payload={
|
|
"scan_status": "workspace_unavailable",
|
|
"artifacts": [],
|
|
}
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_workspace_resolver", lambda: resolver
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_default_run_journal", lambda: journal
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(file_controller.router)
|
|
client = TestClient(app, client=("127.0.0.1", 50000))
|
|
response = client.get(
|
|
"/files/changes",
|
|
params={
|
|
"task_id": "task-1",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
},
|
|
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"artifacts": [],
|
|
"scan_status": "workspace_unavailable",
|
|
"truncated": False,
|
|
}
|
|
resolver.store.get_snapshot.assert_not_called()
|
|
|
|
|
|
def test_stream_file_supports_byte_ranges(monkeypatch, tmp_path):
|
|
project_root = tmp_path / "project"
|
|
project_root.mkdir()
|
|
payload = b"0123456789"
|
|
(project_root / "report.pdf").write_bytes(payload)
|
|
monkeypatch.setattr(
|
|
file_controller,
|
|
"_resolve_file_root",
|
|
lambda *_args, **_kwargs: project_root,
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(file_controller.router)
|
|
client = TestClient(app)
|
|
response = client.get(
|
|
"/files/stream",
|
|
params={
|
|
"path": "report.pdf",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
},
|
|
headers={"Range": "bytes=2-5"},
|
|
)
|
|
|
|
assert response.status_code == 206
|
|
assert response.content == b"2345"
|
|
assert response.headers["accept-ranges"] == "bytes"
|
|
assert response.headers["content-range"] == "bytes 2-5/10"
|
|
|
|
head = client.head(
|
|
"/files/stream",
|
|
params={
|
|
"path": "report.pdf",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
},
|
|
)
|
|
assert head.status_code == 200
|
|
assert head.content == b""
|
|
assert head.headers["content-length"] == str(len(payload))
|
|
assert head.headers["accept-ranges"] == "bytes"
|
|
|
|
|
|
def test_task_changes_endpoint_requires_local_capability(
|
|
monkeypatch, tmp_path
|
|
):
|
|
monkeypatch.setenv("EIGENT_RUNTIME", "electron")
|
|
monkeypatch.setenv("EIGENT_LOCAL_CONTROL_CAPABILITY", "secret-1")
|
|
snapshot = SimpleNamespace(
|
|
project_id="project-1",
|
|
task_output_root=str(tmp_path),
|
|
working_directory=str(tmp_path),
|
|
task_start_time=time.time(),
|
|
)
|
|
resolver = MagicMock()
|
|
resolver.store.get_snapshot.return_value = snapshot
|
|
journal = MagicMock()
|
|
journal.get_run.return_value = None
|
|
monkeypatch.setattr(
|
|
file_controller, "get_workspace_resolver", lambda: resolver
|
|
)
|
|
monkeypatch.setattr(
|
|
file_controller, "get_default_run_journal", lambda: journal
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(file_controller.router)
|
|
client = TestClient(app, client=("127.0.0.1", 50000))
|
|
params = {
|
|
"task_id": "task-1",
|
|
"project_id": "project-1",
|
|
"email": "user@example.com",
|
|
}
|
|
|
|
assert client.get("/files/changes", params=params).status_code == 401
|
|
response = client.get(
|
|
"/files/changes",
|
|
params=params,
|
|
headers={LOCAL_CONTROL_CAPABILITY_HEADER: "secret-1"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"artifacts": [],
|
|
"scan_status": "complete",
|
|
"truncated": False,
|
|
}
|