mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-21 14:22:19 +02:00
fix(tasks): scope action_tidy_research broken-file sweep to admins (#6069)
action_tidy_research took an `owner` argument and never used it. Any user's scheduled tidy task swept data/deep_research globally, unlinking every empty or unparseable file regardless of who owned it. A broken file has no readable owner stamp, so it cannot be matched against `owner` the way _find_owned_research_path does, which is why the HTTP path and manage_research already treat parse failure as not-owned. Clearing one is a privileged act rather than an ownership one, so gate it on the canonical owner_is_admin_or_single_user helper: admins and the single-user operator keep the janitor, a regular user does not, and neither does the pre-setup window before an admin exists. Returns before the directory glob rather than filtering inside the loop, so a denied run reports why instead of reporting "none broken" over files it never inspected. That reason string surfaces in Activity as a skipped row.
This commit is contained in:
+15
-1
@@ -810,13 +810,27 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
Research history lives entirely in data/deep_research/<id>.json and is NOT
|
Research history lives entirely in data/deep_research/<id>.json and is NOT
|
||||||
backed by chat-session rows — so a file must never be deleted just because
|
backed by chat-session rows — so a file must never be deleted just because
|
||||||
no chat session matches its id. Only prune files that fail to load."""
|
no chat session matches its id. Only prune files that fail to load.
|
||||||
|
|
||||||
|
A broken file has no readable owner stamp, so it cannot be matched against
|
||||||
|
`owner`. Clearing one is privileged: admins and the single-user operator
|
||||||
|
(AUTH_ENABLED=false) may, a regular user may not, and neither may anyone
|
||||||
|
during the pre-setup window before an admin exists.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json as _json
|
import json as _json
|
||||||
|
from src.tool_security import owner_is_admin_or_single_user
|
||||||
research_dir = Path(DEEP_RESEARCH_DIR)
|
research_dir = Path(DEEP_RESEARCH_DIR)
|
||||||
if not research_dir.exists():
|
if not research_dir.exists():
|
||||||
raise TaskNoop("no research directory")
|
raise TaskNoop("no research directory")
|
||||||
|
if not owner_is_admin_or_single_user(owner):
|
||||||
|
# Return before the glob rather than filtering inside the loop: the
|
||||||
|
# loop reports "none broken" off an empty `removed`, which reaches
|
||||||
|
# Activity as a false report to a user whose files it skipped, and a
|
||||||
|
# regular user need not read every owner's file to learn it may
|
||||||
|
# delete none of them.
|
||||||
|
raise TaskNoop("not permitted to remove unattributable research files")
|
||||||
files = list(research_dir.glob("*.json"))
|
files = list(research_dir.glob("*.json"))
|
||||||
removed = []
|
removed = []
|
||||||
for p in files:
|
for p in files:
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Owner-scope tests for action_tidy_research.
|
||||||
|
|
||||||
|
Broken research files (empty or unparseable JSON) have no readable owner
|
||||||
|
stamp. The HTTP path and _find_owned_research_path treat parse failure as
|
||||||
|
not-owned, so a regular user's tidy task must not unlink them. Clearing a
|
||||||
|
corrupt record is a privileged act instead: admins, and the operator in
|
||||||
|
single-user mode (AUTH_ENABLED=false), keep the janitor.
|
||||||
|
|
||||||
|
Every case installs a fake AuthManager rather than relying on ambient state.
|
||||||
|
Setting AUTH_ENABLED=true alone leaves is_configured False, so the admin check
|
||||||
|
these tests exist to pin would never be reached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.builtin_actions import TaskNoop, action_tidy_research
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def research_dir(tmp_path, monkeypatch):
|
||||||
|
data_dir = tmp_path / "deep_research"
|
||||||
|
data_dir.mkdir()
|
||||||
|
monkeypatch.setattr("src.builtin_actions.DEEP_RESEARCH_DIR", str(data_dir))
|
||||||
|
return data_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _install_auth(monkeypatch, *, is_configured: bool):
|
||||||
|
"""Auth enabled, with `root` as the only admin. `is_configured` False models
|
||||||
|
the pre-setup window, where no admin exists yet."""
|
||||||
|
|
||||||
|
import core.auth as core_auth
|
||||||
|
|
||||||
|
configured = is_configured
|
||||||
|
|
||||||
|
class FakeAuthManager:
|
||||||
|
is_configured = configured
|
||||||
|
|
||||||
|
def is_admin(self, user):
|
||||||
|
return user == "root"
|
||||||
|
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setattr(core_auth, "AuthManager", FakeAuthManager)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def configured_auth(monkeypatch):
|
||||||
|
_install_auth(monkeypatch, is_configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _write(data_dir, name: str, text: str):
|
||||||
|
path = data_dir / f"{name}.json"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_owned(data_dir, name: str, owner: str):
|
||||||
|
return _write(data_dir, name, json.dumps({"owner": owner, "query": name}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configured_non_admin_skips_unparseable_and_empty_files(
|
||||||
|
research_dir, configured_auth
|
||||||
|
):
|
||||||
|
alice = _write_owned(research_dir, "alice-ok", "alice")
|
||||||
|
bob = _write_owned(research_dir, "bob-ok", "bob")
|
||||||
|
empty = _write(research_dir, "broken-empty", "")
|
||||||
|
garbage = _write(research_dir, "broken-json", "{not json")
|
||||||
|
alice_txt = alice.read_text(encoding="utf-8")
|
||||||
|
bob_txt = bob.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(TaskNoop, match="not permitted"):
|
||||||
|
await action_tidy_research("alice")
|
||||||
|
|
||||||
|
assert alice.exists()
|
||||||
|
assert alice.read_text(encoding="utf-8") == alice_txt
|
||||||
|
assert bob.exists()
|
||||||
|
assert bob.read_text(encoding="utf-8") == bob_txt
|
||||||
|
assert empty.exists(), "empty file has no owner stamp; non-admin tidy must not unlink it"
|
||||||
|
assert garbage.exists(), "unparseable file has no owner stamp; non-admin tidy must not unlink it"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configured_non_admin_skip_reason_does_not_claim_none_broken(
|
||||||
|
research_dir, configured_auth
|
||||||
|
):
|
||||||
|
"""The skip reason reaches Activity as `skipped -- <reason>`, so it must not
|
||||||
|
report a clean scan over files it declined to look at."""
|
||||||
|
_write(research_dir, "broken-empty", "")
|
||||||
|
|
||||||
|
with pytest.raises(TaskNoop) as excinfo:
|
||||||
|
await action_tidy_research("alice")
|
||||||
|
|
||||||
|
assert "none broken" not in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configured_admin_still_removes_broken_files(research_dir, configured_auth):
|
||||||
|
valid = _write_owned(research_dir, "ok", "alice")
|
||||||
|
empty = _write(research_dir, "broken-empty", " ")
|
||||||
|
garbage = _write(research_dir, "broken-json", "not-json")
|
||||||
|
|
||||||
|
message, ok = await action_tidy_research("root")
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert "Removed 2" in message
|
||||||
|
assert valid.exists(), "admin sweep clears corrupt records, not valid ones"
|
||||||
|
assert not empty.exists()
|
||||||
|
assert not garbage.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pre_setup_denies_owner_who_would_be_admin_once_configured(
|
||||||
|
research_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Auth on, no admin created yet: fail closed even for `root`.
|
||||||
|
|
||||||
|
The owner here is non-empty and would satisfy is_admin, so this fails if the
|
||||||
|
is_configured guard is removed. An empty owner would not discriminate -- it
|
||||||
|
is denied by the `owner and ...` short-circuit either way.
|
||||||
|
"""
|
||||||
|
_install_auth(monkeypatch, is_configured=False)
|
||||||
|
empty = _write(research_dir, "broken-empty", "")
|
||||||
|
garbage = _write(research_dir, "broken-json", "{")
|
||||||
|
|
||||||
|
with pytest.raises(TaskNoop, match="not permitted"):
|
||||||
|
await action_tidy_research("root")
|
||||||
|
|
||||||
|
assert empty.exists()
|
||||||
|
assert garbage.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_owner_does_not_delete_broken_files(research_dir, configured_auth):
|
||||||
|
empty = _write(research_dir, "broken-empty", "")
|
||||||
|
garbage = _write(research_dir, "broken-json", "{")
|
||||||
|
|
||||||
|
with pytest.raises(TaskNoop, match="not permitted"):
|
||||||
|
await action_tidy_research("")
|
||||||
|
|
||||||
|
assert empty.exists()
|
||||||
|
assert garbage.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_disabled_still_removes_broken_files(research_dir, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
valid = _write_owned(research_dir, "ok", "alice")
|
||||||
|
empty = _write(research_dir, "broken-empty", " ")
|
||||||
|
garbage = _write(research_dir, "broken-json", "not-json")
|
||||||
|
|
||||||
|
message, ok = await action_tidy_research("alice")
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert "Removed 2" in message
|
||||||
|
assert valid.exists()
|
||||||
|
assert not empty.exists()
|
||||||
|
assert not garbage.exists()
|
||||||
Reference in New Issue
Block a user