refactor(monitoring): N2 - tach monitoring_tab.py, CanonicalAuditLogger, MonitoringQueryService, go circular import, sandbox matrix

- ui/monitoring_tab.py (1546 dong) tach thanh presentation/monitoring/**
  (container + 7 tab/card + shared helper), ui/monitoring_tab.py con lai
  re-export shim de app.py khong doi.
- infrastructure/telemetry/audit_logger.py: CanonicalAuditLogger, core/audit_log.py
  thanh wrapper mong, tuong thich nguoc 100% voi schema .jsonl cu.
- application/monitoring/monitoring_query_service.py: MonitoringQueryService
  read-only, filter/sort/pagination, khong import PySide6.
- Go circular import model_pricing<->usage_tracker va agent_security<->
  agent_security_alert (core/agent_security_types.py moi).
- infrastructure/sandbox/sandbox_capabilities.py: SandboxCapabilityMatrix
  theo OS (Windows/Linux/macOS), chua dau noi vao core/sandbox_manager.py.
- conftest.py: sua loi checkout khong ten cowork_local khien pytest import
  nham thu muc khac.
- 77 test moi, 167/167 pass. QA da xac nhan UI/business logic khong doi
  (xem evidence/report/unified_report.html).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hiep Ha Van
2026-08-25 23:52:36 +09:00
co-authored by Claude Sonnet 5
parent 86c27e2e79
commit 40b12ecb15
54 changed files with 3506 additions and 1637 deletions
+75
View File
@@ -0,0 +1,75 @@
"""Task 4b — agent_security <-> agent_security_alert circular dependency is gone.
Before this fix, ``agent_security_alert.py`` imported ``SecurityVerdict`` from
``agent_security.py`` at module level (for the ``notify_admin`` type
annotation), while ``agent_security.py`` deferred-imported
``agent_security_alert.notify_admin`` inside ``enforce_prompt``/
``enforce_command`` — an architectural cycle only avoided at runtime by
pushing that second import inside a function body.
``SecurityVerdict``/``SecurityBlocked`` now live in the dependency-free leaf
module ``agent_security_types.py``. ``agent_security_alert.py`` imports the
type from there instead of from ``agent_security.py``, which lets
``agent_security.py`` import ``agent_security_alert.notify_admin`` at module
top level with no cycle.
"""
from __future__ import annotations
from cowork_local.core import (
agent_security,
agent_security_alert,
agent_security_types,
)
def test_shared_types_live_in_the_leaf_module() -> None:
assert agent_security.SecurityVerdict is agent_security_types.SecurityVerdict
assert agent_security.SecurityBlocked is agent_security_types.SecurityBlocked
assert agent_security_alert.SecurityVerdict is agent_security_types.SecurityVerdict
def test_agent_security_alert_no_longer_imports_agent_security() -> None:
assert "agent_security" not in agent_security_alert.__dict__
def test_notify_admin_imported_at_module_top_level_in_agent_security() -> None:
assert agent_security.notify_admin is agent_security_alert.notify_admin
def test_enforce_command_still_blocks_and_alerts_like_before(monkeypatch) -> None:
class _FakeProvider:
def chat(self, messages, tools=None):
return {"content": '{"allowed": false, "reason": "destructive"}'}
class _Config:
data = {"agent_security": {"enabled": True, "validate_commands": True,
"command_ai_check": True}}
ms365 = {}
@property
def agent_security(self):
return self.data["agent_security"]
notify_calls = []
record_calls = []
monkeypatch.setattr(agent_security, "notify_admin",
lambda config, verdict, detail="": notify_calls.append((verdict, detail)))
from cowork_local.core import audit_log
monkeypatch.setattr(audit_log, "record",
lambda *a, **k: record_calls.append((a, k)))
emitted = []
raised = False
try:
agent_security.enforce_command(
_FakeProvider(), "run_command", {"command": "rm -rf /"}, _Config(),
emit=emitted.append,
)
except agent_security.SecurityBlocked as exc:
raised = True
assert exc.verdict.layer == "command"
assert raised is True
assert notify_calls
assert record_calls
assert emitted and emitted[0]["type"] == "notice"
+95
View File
@@ -0,0 +1,95 @@
"""Task 2 — CanonicalAuditLogger.
Verifies: (1) the infrastructure class itself round-trips events correctly
and mirrors to a shared dir, (2) ``core/audit_log.py``'s wrapper functions
still behave exactly as before (same signatures, same dict schema, same
never-raise guarantee), and (3) old-format raw dicts (as written by the
pre-refactor ``core/audit_log.py``) still load correctly for backward
compatibility.
"""
from __future__ import annotations
import json
from cowork_local.core import audit_log
from cowork_local.infrastructure.telemetry.audit_logger import (
KIND_MCP_CALL,
KIND_SECURITY_BLOCK,
CanonicalAuditEvent,
CanonicalAuditLogger,
)
def test_record_and_load_round_trip(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.set_identity("alice", "machine-1", role="admin")
logger.record(KIND_SECURITY_BLOCK, "run_command", False, detail="blocked it")
events = logger.load_events()
assert len(events) == 1
e = events[0]
assert e.kind == KIND_SECURITY_BLOCK
assert e.name == "run_command"
assert e.ok is False
assert e.detail == "blocked it"
assert e.account == "alice"
assert e.machine == "machine-1"
assert e.role == "admin"
def test_load_events_filters_by_kind(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.record(KIND_SECURITY_BLOCK, "a", False)
logger.record(KIND_MCP_CALL, "b", True)
only_mcp = logger.load_events(kind=KIND_MCP_CALL)
assert [e.name for e in only_mcp] == ["b"]
def test_shared_dir_mirroring(tmp_path) -> None:
shared = tmp_path / "shared"
logger = CanonicalAuditLogger(tmp_path / "audit")
logger.set_identity("bob", "machine-2", shared_dir=str(shared))
logger.record(KIND_MCP_CALL, "tool_x", True)
mirrored_files = list((shared / "telemetry" / "audit").glob("machine-2-*.jsonl"))
assert len(mirrored_files) == 1
def test_from_dict_is_tolerant_of_old_partial_rows() -> None:
old_row = {"ts": "2024-01-01T00:00:00", "kind": "tool_call", "name": "x", "ok": True}
event = CanonicalAuditEvent.from_dict(old_row)
assert event.detail == ""
assert event.account == ""
def test_record_never_raises_on_bad_directory(tmp_path) -> None:
bad_dir = tmp_path / "some_file.txt"
bad_dir.write_text("not a directory")
logger = CanonicalAuditLogger(bad_dir / "audit")
logger.record(KIND_SECURITY_BLOCK, "x", False) # must not raise
def test_core_audit_log_wrapper_same_schema_as_before(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path)
monkeypatch.setattr(audit_log, "_logger",
audit_log.CanonicalAuditLogger(tmp_path))
audit_log.set_identity("carol", "machine-3", role="user")
audit_log.record("permission", "install_package", True, detail="ok", agent_role="cowork")
events = audit_log.load_events()
assert len(events) == 1
e = events[0]
assert set(e.keys()) == {"ts", "kind", "agent_role", "name", "ok", "detail",
"account", "role", "machine"}
assert e["kind"] == "permission"
assert e["name"] == "install_package"
assert e["ok"] is True
assert e["agent_role"] == "cowork"
assert e["account"] == "carol"
# Raw file on disk still uses the exact pre-refactor schema/keys.
raw_line = next((tmp_path).glob("*.jsonl")).read_text(encoding="utf-8").splitlines()[0]
raw = json.loads(raw_line)
assert list(raw.keys()) == ["ts", "kind", "agent_role", "name", "ok", "detail",
"account", "role", "machine"]
+60
View File
@@ -0,0 +1,60 @@
"""Task 4a — model_pricing <-> usage_tracker circular dependency is gone.
Before this fix, ``model_pricing.turn_cost_usd`` deferred-imported
``usage_tracker`` for its flat fallback rates, while ``usage_tracker.set_budget``
deferred-imported ``model_pricing`` for currency conversion — a real
architectural cycle, only avoided at runtime by pushing both imports inside
function bodies. Now ``model_pricing`` is a leaf module (it owns its own
fallback rates) and ``usage_tracker`` imports it at module top level.
"""
from __future__ import annotations
import copy
import sys
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.core import model_pricing, usage_tracker
def test_model_pricing_does_not_depend_on_usage_tracker_module_level() -> None:
assert "usage_tracker" not in model_pricing.__dict__
assert "usage_tracker" not in getattr(model_pricing, "__all__", [])
def test_usage_tracker_imports_model_pricing_at_top_level() -> None:
assert usage_tracker.mp is model_pricing
def test_turn_cost_usd_fallback_matches_pre_refactor_default_rates(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
# No matching row in the price table and no override in config.data["usage"]
# -> falls back to the flat rates that used to live in
# usage_tracker.DEFAULT_PRICING (0.5 in / 1.5 out USD per 1M tokens).
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
assert cost == 0.5 + 1.5
def test_turn_cost_usd_honours_usage_override_like_before(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
config.data.setdefault("usage", {})["price_per_mtok_in_usd"] = 2.0
config.data["usage"]["price_per_mtok_out_usd"] = 4.0
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
assert cost == 2.0 + 4.0
def test_set_budget_still_converts_via_model_pricing(tmp_path) -> None:
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
usage_tracker.set_budget(config, 100.0, currency="USD")
status = usage_tracker.budget_status(config)
assert status is not None
assert status["amount_usd"] == 100.0
def test_no_import_time_cycle_when_loaded_fresh() -> None:
for name in ("cowork_local.core.model_pricing", "cowork_local.core.usage_tracker"):
sys.modules.pop(name, None)
import importlib
mp = importlib.import_module("cowork_local.core.model_pricing")
ut = importlib.import_module("cowork_local.core.usage_tracker")
assert ut.mp is mp
+56
View File
@@ -0,0 +1,56 @@
"""Task 1 (sub-step 6d) — AgentStatusTab widget smoke test."""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.agent_status_tab import AgentStatusTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCowork:
def active_workers(self):
return [1, 2]
class _FakeTaskScheduler:
def running_count(self):
return 3
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.config.data["agent_security"]["enabled"] = True
def test_agent_status_tab_has_no_search_or_detail(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
assert not hasattr(tab, "filter_edit")
assert not hasattr(tab, "detail_panel")
def test_refresh_populates_six_rows_with_live_counts(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None,
cowork=_FakeCowork(), task_scheduler=_FakeTaskScheduler())
tab.refresh()
assert tab.table.rowCount() == 6
assert tab.table.item(0, 0).text() # Cowork row has a label
assert tab.table.cellWidget(0, 1) is not None # badge pill widget
def test_retranslate_does_not_raise(qapp, tmp_path) -> None:
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
tab.retranslate()
+59
View File
@@ -0,0 +1,59 @@
"""Task 1 (sub-step 6c) — SecurityEventsTab / McpTab / ActionLogsTab.
Widget smoke tests against the offscreen QPA platform (see
test_monitoring_event_widgets.py for why no pytest-qt is needed). Verifies
each tab wires build_filter_scaffold correctly, exposes the attributes the
container's retranslate loop needs, and forwards set_events to its table.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.presentation.monitoring.tabs.action_logs_tab import ActionLogsTab
from cowork_local.presentation.monitoring.tabs.mcp_tab import McpTab
from cowork_local.presentation.monitoring.tabs.security_events_tab import SecurityEventsTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.mark.parametrize("cls,expected_title_key", [
(SecurityEventsTab, "monitoring.security_events_title"),
(McpTab, "monitoring.mcp_history_title"),
(ActionLogsTab, "monitoring.action_logs_title"),
])
def test_tab_exposes_container_facing_attributes(qapp, cls, expected_title_key) -> None:
refresh_calls = []
tab = cls(ctx=None, on_refresh_all=lambda: refresh_calls.append(1))
assert tab.title_key == expected_title_key
assert tab.filter_edit is not None
assert tab.detail_panel is not None
tab.title_refresh_btn.click()
assert refresh_calls == [1]
def test_set_events_forwards_to_table(qapp) -> None:
tab = SecurityEventsTab(ctx=None, on_refresh_all=lambda: None)
tab.set_events([{"ts": "2026-01-01T00:00:00", "kind": "security_block", "name": "x",
"ok": False, "detail": "d", "account": "a", "machine": "m"}])
assert tab.table.rowCount() == 1
def test_retranslate_does_not_raise(qapp) -> None:
tab = McpTab(ctx=None, on_refresh_all=lambda: None)
tab.retranslate() # must not raise
def test_ai_filter_noop_when_search_box_empty(qapp) -> None:
tab = ActionLogsTab(ctx=None, on_refresh_all=lambda: None)
tab.ai_filter_btn.click() # empty search text -> start_ai_filter no-ops, must not raise
+79
View File
@@ -0,0 +1,79 @@
"""Task 1 (sub-step 6b) — EventTable / EventDetailPanel widget smoke tests.
These instantiate real PySide6 widgets against the offscreen QPA platform
(no display needed, no pytest-qt dependency — a plain QApplication instance
is enough to construct/query widgets, only an actual event loop would need
more). Verifies the extraction into presentation/monitoring/shared/ wires up
without error and preserves the pre-refactor row/column behaviour.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.presentation.monitoring.shared.event_detail_panel import EventDetailPanel
from cowork_local.presentation.monitoring.shared.event_table import EventTable
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def _sample_event(**overrides):
ev = {"ts": "2026-05-25T15:03:00", "kind": "security_block", "name": "run_command",
"ok": False, "detail": "blocked it", "account": "alice", "machine": "m1",
"agent_role": "cowork"}
ev.update(overrides)
return ev
def test_event_table_security_events_hides_result_column(qapp) -> None:
table = EventTable(show_result=False)
table.retranslate()
assert table.columnCount() == 6
def test_event_table_generic_shows_result_column(qapp) -> None:
table = EventTable(show_result=True)
table.retranslate()
assert table.columnCount() == 7
def test_set_events_populates_rows_newest_first(qapp) -> None:
table = EventTable(show_result=True)
table.set_events([
_sample_event(ts="2026-05-25T10:00:00", name="first"),
_sample_event(ts="2026-05-25T12:00:00", name="second"),
])
assert table.rowCount() == 2
assert table.item(0, 4).text() == "second"
assert table.item(1, 4).text() == "first"
def test_event_at_row_round_trips_full_event(qapp) -> None:
table = EventTable(show_result=False)
ev = _sample_event()
table.set_events([ev])
assert table.event_at_row(0) == ev
def test_apply_filter_hides_non_matching_rows(qapp) -> None:
table = EventTable(show_result=True)
table.set_events([_sample_event(name="run_command"), _sample_event(name="fetch_url")])
table.apply_filter("fetch")
hidden = [table.isRowHidden(r) for r in range(table.rowCount())]
assert hidden.count(True) == 1
def test_detail_panel_shows_event_without_error(qapp) -> None:
panel = EventDetailPanel()
panel.retranslate()
panel.show_event(_sample_event(), 0)
assert panel._detail_text == "blocked it"
+80
View File
@@ -0,0 +1,80 @@
"""Task 1 (sub-step 6f) — OverviewTab widget smoke test."""
from __future__ import annotations
import copy
import os
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.overview_tab import OverviewTab
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.started_at = time.time() - 30
def save(self):
pass
def cowork_output_dir(self):
return "."
def _make_tab(tmp_path):
return OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
action_logs_tab_visible=True)
def test_construction_and_retranslate(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate() # must not raise
def test_refresh_with_no_events_shows_no_activity_text(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate()
tab.refresh(events=[])
assert tab.activity_lbl.text() != ""
def test_refresh_with_events_renders_activity_lines(qapp, tmp_path) -> None:
tab = _make_tab(tmp_path)
tab.retranslate()
tab.refresh(events=[
{"ts": "2026-01-01T00:00:00", "kind": "tool_call", "name": "run_command", "ok": True},
{"ts": "2026-01-01T00:00:05", "kind": "security_block", "name": "blocked", "ok": False},
])
assert "run_command" in tab.activity_lbl.text() or "blocked" in tab.activity_lbl.text()
def test_view_all_button_invokes_callback(qapp, tmp_path) -> None:
calls = []
tab = OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: calls.append(1),
action_logs_tab_visible=True)
tab.view_all_btn.click()
assert calls == [1]
def test_audit_group_visibility_follows_constructor_flag(qapp, tmp_path) -> None:
hidden_tab = OverviewTab(
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
action_logs_tab_visible=False)
assert hidden_tab.audit_group.isHidden() is True
+49
View File
@@ -0,0 +1,49 @@
"""Task 1 (sub-step 6f, follow-up) — PricingPanel, split out of overview_tab.py
to keep that file under the 400-line quality rule."""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.pricing_panel import PricingPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
def save(self):
pass
def test_construction_and_retranslate(qapp, tmp_path) -> None:
panel = PricingPanel(_FakeCtx(tmp_path), on_status_message=lambda _m: None)
panel.retranslate()
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path)
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
panel._add_pricing_row()
assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test"
panel.table.selectRow(0)
panel._delete_pricing_row()
assert panel.table.rowCount() == 0
+95
View File
@@ -0,0 +1,95 @@
"""Task 3 — MonitoringQueryService: read-only filter/sort/pagination over
audit events, fully testable without file I/O (InMemoryAuditEventRepository)
and with a real CanonicalAuditLogger wired through CanonicalAuditEventRepository.
"""
from __future__ import annotations
from cowork_local.application.monitoring.dto.audit_event_dto import AuditEventDTO
from cowork_local.application.monitoring.monitoring_query_service import (
MonitoringQueryService,
)
from cowork_local.application.monitoring.repository.audit_event_repository import (
CanonicalAuditEventRepository,
InMemoryAuditEventRepository,
)
from cowork_local.infrastructure.telemetry.audit_logger import CanonicalAuditLogger
def _event(ts, kind="tool_call", name="x", ok=True, detail="") -> AuditEventDTO:
return AuditEventDTO(ts=ts, kind=kind, name=name, ok=ok, detail=detail)
def test_query_filters_by_kind() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", kind="mcp_call", name="a"),
_event("2026-01-01T00:00:01", kind="security_block", name="b"),
])
service = MonitoringQueryService(repo)
page = service.query(kind="mcp_call")
assert [e.name for e in page.items] == ["a"]
def test_query_filters_by_ok_and_text() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", name="run_command", ok=False, detail="blocked"),
_event("2026-01-01T00:00:01", name="run_command", ok=True, detail="fine"),
_event("2026-01-01T00:00:02", name="fetch_url", ok=False, detail="blocked"),
])
service = MonitoringQueryService(repo)
page = service.query(ok=False, text="run_command")
assert len(page.items) == 1
assert page.items[0].detail == "blocked"
def test_query_sorts_newest_first_by_default() -> None:
repo = InMemoryAuditEventRepository([
_event("2026-01-01T00:00:00", name="first"),
_event("2026-01-02T00:00:00", name="second"),
])
service = MonitoringQueryService(repo)
page = service.query()
assert [e.name for e in page.items] == ["second", "first"]
def test_query_paginates() -> None:
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 11)]
repo = InMemoryAuditEventRepository(events)
service = MonitoringQueryService(repo)
page1 = service.query(sort_by="ts", descending=False, page=1, page_size=4)
page2 = service.query(sort_by="ts", descending=False, page=2, page_size=4)
assert page1.total == 10
assert [e.name for e in page1.items] == ["1", "2", "3", "4"]
assert [e.name for e in page2.items] == ["5", "6", "7", "8"]
assert page1.has_more is True
def test_large_page_size_returns_everything_matching_current_ui_behaviour() -> None:
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 6)]
repo = InMemoryAuditEventRepository(events)
service = MonitoringQueryService(repo)
page = service.query(page_size=10_000)
assert len(page.items) == 5
assert page.has_more is False
def test_repository_is_read_only_no_pyside6_import() -> None:
import cowork_local.application.monitoring.monitoring_query_service as mod
import cowork_local.application.monitoring.repository.audit_event_repository as repo_mod
assert "PySide6" not in mod.__dict__
assert "PySide6" not in repo_mod.__dict__
assert not hasattr(mod.MonitoringQueryService, "record")
def test_canonical_repository_wires_to_real_logger(tmp_path) -> None:
logger = CanonicalAuditLogger(tmp_path)
logger.record("security_block", "run_command", False, detail="nope")
logger.record("mcp_call", "search", True)
repo = CanonicalAuditEventRepository(logger)
service = MonitoringQueryService(repo)
page = service.query(kind="security_block")
assert len(page.items) == 1
assert page.items[0].name == "run_command"
@@ -0,0 +1,73 @@
"""Task 1 (sub-step 6e) — SandboxDetailsCard / PermissionsCard.
Verifies the Permissions card is nested INSIDE the Sandbox card's fold
(matching the pre-refactor layout exactly) and that refresh()/retranslate()
compute the same values the original MonitoringTab methods did.
"""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.tabs.sandbox_tab import SandboxDetailsCard
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
class _FakeCtx:
def __init__(self, tmp_path, block_network=False):
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
self.config.data["agent_security"]["block_network"] = block_network
self.config.data["agent_security"]["resource_limit_cpu_percent"] = 50
import time
self.started_at = time.time() - 65 # ~1m5s uptime
def test_permissions_card_nested_inside_sandbox_fold(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
assert card.permissions_card.parent() is card._detail
def test_refresh_computes_uptime_and_resource_limits(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert "CPU 50%" in card.limits_lbl.text()
assert "m" in card.uptime_val.text()
def test_refresh_reflects_network_blocked_on_both_cards(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=True), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert card.net_val.objectName() == "badgeWarn"
assert card.permissions_card.network_val.objectName() == "badgeWarn"
def test_refresh_reflects_network_allowed(qapp, tmp_path) -> None:
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=False), on_settings_changed=lambda: None)
card.retranslate()
card.refresh()
assert card.net_val.objectName() == "badgeSuccess"
assert card.permissions_card.network_val.objectName() == "badgeSuccess"
def test_fold_starts_collapsed(qapp, tmp_path) -> None:
# isVisible() alone can't tell (it also depends on ancestors actually
# being shown on screen, which nothing here is) — isHidden() reflects
# the explicit setVisible(False) call regardless of the parent chain.
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
assert card._detail.isHidden() is True
card.more_btn.setChecked(True)
assert card._detail.isHidden() is False
+78
View File
@@ -0,0 +1,78 @@
"""Task 1 (sub-step 6a) — presentation/monitoring/shared helpers.
Only the pure-Python functions are covered here (no PySide6 widget
instantiation needed, no pytest-qt required). Values are asserted against
what ``ui/monitoring_tab.py``'s original, now-removed private functions
produced, so this doubles as a characterization test proving the extraction
didn't change output.
"""
from __future__ import annotations
from cowork_local.presentation.monitoring.shared import badges, formatters
def test_fmt_bytes() -> None:
assert formatters.fmt_bytes(500) == "500 B"
assert formatters.fmt_bytes(2048) == "2 KB"
def test_fmt_event_time_and_full_and_relative_are_unparsable_safe() -> None:
assert formatters.fmt_event_time("not-a-timestamp") == "not-a-timestamp"
assert formatters.fmt_event_time_full("not-a-timestamp") == "not-a-timestamp"
assert formatters.relative_time("not-a-timestamp") == ""
def test_fmt_event_time_formats_valid_iso_timestamp() -> None:
assert formatters.fmt_event_time("2026-05-25T15:03:00") == "25/05 15:03"
# The separator is computed via datetime.strftime with a literal
# non-ASCII character in the format string, same as the pre-refactor
# ui/monitoring_tab.py code — on a Windows box whose locale ANSI codepage
# has no direct mapping for U+00B7 (MIDDLE DOT), strftime's encode/decode
# round trip through that codepage can substitute a different but
# visually similar character (observed: U+30FB on a ja_JP/cp932 locale).
# That behavior is unchanged by this refactor either way, so the test
# computes the expected separator the exact same way the implementation
# does, rather than assuming byte 0xB7 survives on every locale.
import datetime as _dt
expected_full = _dt.datetime(2026, 5, 25, 15, 3, 7).strftime(
f"%d/%m/%Y {formatters._MIDDLE_DOT} %H:%M:%S")
assert formatters.fmt_event_time_full("2026-05-25T15:03:07") == expected_full
def test_event_id_shape() -> None:
assert formatters.event_id("2026-05-25T15:03:00", 7) == "evt_202605251503_007"
def test_agent_initials() -> None:
assert formatters.agent_initials("Cowork Agent") == "CA"
assert formatters.agent_initials("graphrag") == "G"
def test_agent_avatar_colour_identity_mapping() -> None:
assert formatters.agent_avatar_colour("Security Agent") == "#D13438"
assert formatters.agent_avatar_colour("Cowork Agent") == "#0078D4"
assert formatters.agent_avatar_colour("unknown-agent") == "#0078D4"
def test_action_label_falls_back_to_raw_name_when_unmapped() -> None:
assert badges.action_label("some_custom_tool") == "some_custom_tool"
def test_status_info_security_block_unmapped_defaults_to_blocked() -> None:
tone, key = badges.status_info("some_new_rule", kind="security_block", ok=False)
assert (tone, key) == ("badgePurple", "monitoring.status_blocked")
def test_status_info_mcp_call_falls_back_to_ok_flag() -> None:
assert badges.status_info("search", kind="mcp_call", ok=True) == ("badgeSuccess", "monitoring.status_ok")
assert badges.status_info("search", kind="mcp_call", ok=False) == ("badgeDanger", "monitoring.status_failed")
def test_severity_info_dangerous_command_is_critical() -> None:
assert badges.severity_info("run_command") == ("badgeDanger", "monitoring.severity_critical")
def test_agent_badge_name_identity_mapping() -> None:
assert badges.agent_badge_name("Security Agent") == "badgeDanger"
assert badges.agent_badge_name("graphrag") == "badgePurple"
assert badges.agent_badge_name("unknown") == "badge"
+97
View File
@@ -0,0 +1,97 @@
"""Task 1 (sub-step 6g) — the new presentation/monitoring/monitoring_tab.py
container. Builds a real MonitoringTab against a real AppConfig/AppContext
(same convention as tests/routing/test_service.py: real config/context, only
the true external collaborators — here, none — get a fake), and verifies the
public API app.py depends on is intact: constructor signature,
``status_message`` signal, ``select_subtab``, ``nav_subtabs``,
``hide_tab_bar``.
"""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.presentation.monitoring.monitoring_tab import MonitoringTab
from cowork_local.state import AppContext
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture()
def ctx(tmp_path):
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
return AppContext(config)
def test_constructor_accepts_apps_py_call_signature(qapp, ctx) -> None:
# app.py:499-502 calls MonitoringTab(self.ctx, cowork=..., structure=...,
# task_scheduler=...) — all optional besides ctx.
tab = MonitoringTab(ctx)
assert tab.tabs.count() >= 1
def test_status_message_signal_exists(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
received = []
tab.status_message.connect(received.append)
tab.status_message.emit("hello")
assert received == ["hello"]
def test_select_subtab_and_nav_subtabs(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
subtabs = tab.nav_subtabs()
assert len(subtabs) == tab.tabs.count()
tab.select_subtab(1)
assert tab.tabs.currentIndex() == 1
def test_hide_tab_bar_does_not_raise(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
tab.hide_tab_bar()
def test_full_refresh_populates_event_tabs_via_query_service(qapp, ctx, monkeypatch) -> None:
from cowork_local.core import audit_log
audit_dir = ctx.config.path.parent / "audit"
monkeypatch.setattr(audit_log, "AUDIT_DIR", audit_dir)
monkeypatch.setattr(audit_log, "_logger", audit_log.CanonicalAuditLogger(audit_dir))
audit_log.record("security_block", "run_command", False, detail="blocked")
audit_log.record("mcp_call", "search", True)
audit_log.record("tool_call", "read_file", True)
tab = MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)
tab.refresh()
assert tab.security_tab.table.rowCount() == 1
assert tab.mcp_tab.table.rowCount() == 1
assert tab.action_tab.table.rowCount() == 3
def test_retranslate_does_not_raise(qapp, ctx) -> None:
tab = MonitoringTab(ctx)
tab._retranslate()
def test_settings_changed_callback_is_the_container_full_refresh(qapp, ctx) -> None:
# Editing Settings from either the Sandbox or the nested Permissions
# card's "Edit" button used to call the same
# MonitoringTab._open_settings_and_refresh -> self.refresh(); the
# container now wires both cards' on_settings_changed to its own
# bound refresh method, so this equality is the direct replacement
# for that identity.
tab = MonitoringTab(ctx)
assert tab.overview_tab.sandbox_card._on_settings_changed == tab.refresh
assert tab.overview_tab.sandbox_card.permissions_card._on_settings_changed == tab.refresh
+101
View File
@@ -0,0 +1,101 @@
"""Task 5 — Sandbox Capability Matrix: pure OS/risk-tier policy, no execution,
no PySide6, no dependency on core/sandbox_manager.py.
"""
from __future__ import annotations
from cowork_local.infrastructure.sandbox import sandbox_capabilities as sc
def test_detect_os_from_injected_platform_name() -> None:
assert sc.detect_os("win32") == sc.WINDOWS
assert sc.detect_os("linux") == sc.LINUX
assert sc.detect_os("darwin") == sc.MACOS
assert sc.detect_os("some-other-os") == sc.UNKNOWN
def test_windows_matches_todays_real_backends() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
names = {b.name for b in matrix.available_backends()}
assert names == {"direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"}
def test_windows_routing_matches_core_sandbox_manager_today() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
assert matrix.select_backend(sc.SAFE) == "integrity_job_wfp"
assert matrix.select_backend(sc.MODERATE) == "integrity_job_wfp"
assert matrix.select_backend(sc.HIGH) == "appcontainer"
assert matrix.select_backend(sc.CRITICAL) == "windows_sandbox"
def test_linux_has_no_real_backend_yet() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.LINUX)
available = {b.name for b in matrix.available_backends()}
assert available == {"direct"} # namespaces_bubblewrap declared but not implemented
assert matrix.select_backend(sc.SAFE) == "direct" # SAFE/MODERATE only ever wanted direct
# HIGH's preferred backend (namespaces_bubblewrap) isn't implemented, and
# HIGH's routing table names "blocked" as the explicit next preference
# (not a silent fallback to unisolated "direct") — a HIGH-risk command
# must never quietly downgrade to no isolation just because the real
# sandbox backend is missing on this OS.
assert matrix.select_backend(sc.HIGH) == "blocked"
assert matrix.select_backend(sc.CRITICAL) == "blocked"
def test_macos_has_no_real_backend_yet() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.MACOS)
available = {b.name for b in matrix.available_backends()}
assert available == {"direct"}
def test_disallowing_direct_fallback_blocks_instead() -> None:
# LINUX's own HIGH routing already names "blocked" explicitly, so it
# doesn't exercise the allow_direct_fallback branch. Register a profile
# whose HIGH tier names only an unavailable backend (no explicit
# "direct"/"blocked" entry) to exercise the bottom-of-select_backend
# fallback path directly.
os_name = "test-os-fallback"
profile = sc.OsSandboxProfile(
operating_system=os_name,
backends=(sc.SandboxBackend("direct", "none", True),),
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
sc.HIGH: ("not_yet_implemented",), sc.CRITICAL: ("not_yet_implemented",)},
)
sc.register_profile(profile)
try:
allowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=True)
disallowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=False)
assert allowed.select_backend(sc.HIGH) == "direct"
assert disallowed.select_backend(sc.HIGH) == "blocked"
# CRITICAL never falls back to direct even when allowed.
assert allowed.select_backend(sc.CRITICAL) == "blocked"
finally:
del sc._PROFILES[os_name]
def test_unknown_os_always_blocks() -> None:
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.UNKNOWN)
assert matrix.available_backends() == ()
for tier in (sc.SAFE, sc.MODERATE, sc.HIGH, sc.CRITICAL):
assert matrix.select_backend(tier) == "blocked"
def test_registering_a_brand_new_os_requires_no_class_changes() -> None:
freebsd = "freebsd"
profile = sc.OsSandboxProfile(
operating_system=freebsd,
backends=(sc.SandboxBackend("direct", "none", True),),
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
sc.HIGH: ("blocked",), sc.CRITICAL: ("blocked",)},
)
sc.register_profile(profile)
try:
matrix = sc.SandboxCapabilityMatrix(operating_system=freebsd)
assert matrix.select_backend(sc.SAFE) == "direct"
assert matrix.select_backend(sc.HIGH) == "blocked"
finally:
del sc._PROFILES[freebsd] # don't leak state into other tests
def test_no_pyside6_or_subprocess_dependency() -> None:
assert "PySide6" not in sc.__dict__
assert "subprocess" not in sc.__dict__