refactor: move strix/viewer under strix/interface

This commit is contained in:
Ahmed Allam
2026-07-26 20:00:51 +00:00
committed by Ahmed Allam
parent 8157ccba27
commit d1e8225d5f
88 changed files with 87 additions and 81 deletions

6
.gitignore vendored
View File

@@ -1,8 +1,8 @@
# Node / local-viewer SPA source (the built bundle in # Node / local-viewer SPA source (the built bundle in
# strix/viewer/static/ is committed and shipped; do not ignore it) # strix/interface/viewer/static/ is committed and shipped; do not ignore it)
node_modules/ node_modules/
strix/viewer/frontend/node_modules/ strix/interface/viewer/frontend/node_modules/
strix/viewer/frontend/.vite/ strix/interface/viewer/frontend/.vite/
# Python # Python
__pycache__/ __pycache__/

View File

@@ -102,16 +102,16 @@ We welcome feature ideas! Please:
## 🖥️ Local viewer SPA ## 🖥️ Local viewer SPA
`strix view` serves a prebuilt web UI whose source lives in `strix view` serves a prebuilt web UI whose source lives in
`strix/viewer/frontend/` (a Vite + React project) and whose built output is `strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
committed to `strix/viewer/static/` and shipped in the package. End users never committed to `strix/interface/viewer/static/` and shipped in the package. End users never
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
and commit the output: and commit the output:
```bash ```bash
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
``` ```
Commit both the source change and the regenerated `strix/viewer/static/`. Commit both the source change and the regenerated `strix/interface/viewer/static/`.
## 🤝 Community ## 🤝 Community

View File

@@ -69,8 +69,8 @@ clean:
viewer: viewer:
@echo "🖥️ Building the local-viewer SPA..." @echo "🖥️ Building the local-viewer SPA..."
cd strix/viewer/frontend && npm ci && npm run build cd strix/interface/viewer/frontend && npm ci && npm run build
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)." @echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
dev: format lint type-check dev: format lint type-check
@echo "✅ Development cycle complete!" @echo "✅ Development cycle complete!"

View File

@@ -79,10 +79,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["strix"] packages = ["strix"]
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically # The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
# (hatchling includes non-.py files under the package). The Vite SOURCE lives # (hatchling includes non-.py files under the package). The Vite SOURCE lives
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel. # under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"] exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
# ============================================================================ # ============================================================================
# Type Checking Configuration # Type Checking Configuration
@@ -222,10 +222,10 @@ ignore = [
"tests/test_codex_streaming.py" = ["N802"] "tests/test_codex_streaming.py" = ["N802"]
"tests/test_report_pdf.py" = ["S105", "S106"] "tests/test_report_pdf.py" = ["S105", "S106"]
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a # Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
# circular dependency with strix.telemetry / strix.viewer.report_pdf. # circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
"strix/viewer/server.py" = ["N802", "PLC0415"] "strix/interface/viewer/server.py" = ["N802", "PLC0415"]
# Lazy telemetry import to avoid importing PostHog before the viewer starts. # Lazy telemetry import to avoid importing PostHog before the viewer starts.
"strix/viewer/cli.py" = ["PLC0415"] "strix/interface/viewer/cli.py" = ["PLC0415"]
# Lazy imports inside functions to avoid circular dependency with # Lazy imports inside functions to avoid circular dependency with
# strix.telemetry / strix.report.dedupe / cvss. # strix.telemetry / strix.report.dedupe / cvss.
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"] "strix/tools/notes/tools.py" = ["PLC0415", "TC002"]

View File

@@ -26,7 +26,7 @@ for tcss_file in strix_root.rglob('*.tcss'):
datas.append((str(tcss_file), str(rel_path.parent))) datas.append((str(tcss_file), str(rel_path.parent)))
# Prebuilt local-viewer SPA (served by `strix view`). # Prebuilt local-viewer SPA (served by `strix view`).
viewer_static = strix_root / 'viewer' / 'static' viewer_static = strix_root / 'interface' / 'viewer' / 'static'
for asset in viewer_static.rglob('*'): for asset in viewer_static.rglob('*'):
if asset.is_file(): if asset.is_file():
rel_path = asset.relative_to(project_root) rel_path = asset.relative_to(project_root)
@@ -158,12 +158,12 @@ hiddenimports = [
'strix.report.dedupe', 'strix.report.dedupe',
'strix.report.state', 'strix.report.state',
'strix.report.writer', 'strix.report.writer',
'strix.viewer', 'strix.interface.viewer',
'strix.viewer.auth', 'strix.interface.viewer.auth',
'strix.viewer.cli', 'strix.interface.viewer.cli',
'strix.viewer.report_pdf', 'strix.interface.viewer.report_pdf',
'strix.viewer.server', 'strix.interface.viewer.server',
'strix.viewer.transcript', 'strix.interface.viewer.transcript',
# PDF report generation + encryption # PDF report generation + encryption
'reportlab', 'reportlab',

View File

@@ -952,7 +952,7 @@ def main() -> None:
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the # `strix view [<run>]` is a viewer-only subcommand, dispatched before the
# scan argument parser (which requires a target) and before any scan setup. # scan argument parser (which requires a target) and before any scan setup.
if len(sys.argv) > 1 and sys.argv[1] == "view": if len(sys.argv) > 1 and sys.argv[1] == "view":
from strix.viewer.cli import run_view from strix.interface.viewer.cli import run_view
run_view(sys.argv[2:]) run_view(sys.argv[2:])
return return

View File

@@ -1862,7 +1862,7 @@ class StrixTUIApp(App): # type: ignore[misc]
webbrowser.open(self._viewer_url) webbrowser.open(self._viewer_url)
return return
try: try:
from strix.viewer.server import authorized_url, bundle_is_built, serve from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
if not bundle_is_built(): if not bundle_is_built():
self._set_viewer_cta("[#eab308]Viewer UI not built[/]") self._set_viewer_cta("[#eab308]Viewer UI not built[/]")

View File

@@ -6,7 +6,7 @@ directly from the run's on-disk files. No cloud dependency, no file picker.
from __future__ import annotations from __future__ import annotations
from strix.viewer.server import serve from strix.interface.viewer.server import serve
__all__ = ["serve"] __all__ = ["serve"]

View File

@@ -4,7 +4,7 @@ The local viewer proxies email verification and encrypted-report delivery to
the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay the Strix relay (``STRIX_APP_URL``). The browser never talks to the relay
directly, and the report password generated locally is never sent to it. directly, and the report password generated locally is never sent to it.
State lives in ``~/.strix/viewer-auth.json`` (0600). ``is_verified`` is a local State lives in ``~/.strix/interface/viewer-auth.json`` (0600). ``is_verified`` is a local
flag that unlocks browsing the run history list; the relay still enforces token flag that unlocks browsing the run history list; the relay still enforces token
expiry when a report is actually sent. expiry when a report is actually sent.
""" """
@@ -155,7 +155,7 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
method="POST", method="POST",
) )
try: try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 # nosec B310
return response.status, _parse_body(response.read()) return response.status, _parse_body(response.read())
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, _parse_body(exc.read()) return exc.code, _parse_body(exc.read())

View File

@@ -16,8 +16,8 @@ from strix.core.paths import (
run_record_path, run_record_path,
runs_base_dir, runs_base_dir,
) )
from strix.viewer.server import authorized_url, bundle_is_built, serve from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
from strix.viewer.transcript import read_run_summary from strix.interface.viewer.transcript import read_run_summary
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -58,7 +58,7 @@ def run_view(argv: list[str]) -> None:
if not bundle_is_built(): if not bundle_is_built():
console.print( console.print(
"[bold red]Viewer UI is not built.[/]\n" "[bold red]Viewer UI is not built.[/]\n"
"Build it with: [cyan]cd strix/viewer/frontend && npm ci && npm run build[/]" "Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
) )
raise SystemExit(1) raise SystemExit(1)

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -5,7 +5,7 @@ import { fileURLToPath, URL } from "node:url";
// The viewer is served as static files by a stdlib Python server on an // The viewer is served as static files by a stdlib Python server on an
// arbitrary ephemeral port, so all asset URLs must be relative (base: "./"). // arbitrary ephemeral port, so all asset URLs must be relative (base: "./").
// The build output is committed at strix/viewer/static and shipped. // The build output is committed at strix/interface/viewer/static and shipped.
export default defineConfig({ export default defineConfig({
base: "./", base: "./",
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],

View File

@@ -38,7 +38,7 @@ from reportlab.platypus import (
TableStyle, TableStyle,
) )
from strix.viewer.transcript import ( from strix.interface.viewer.transcript import (
primary_target, primary_target,
read_run_summary, read_run_summary,
read_vulnerabilities, read_vulnerabilities,

View File

@@ -27,8 +27,8 @@ from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, unquote, urlencode, urlsplit from urllib.parse import parse_qs, unquote, urlencode, urlsplit
from strix.core.paths import run_record_path from strix.core.paths import run_record_path
from strix.viewer import auth from strix.interface.viewer import auth
from strix.viewer.transcript import ( from strix.interface.viewer.transcript import (
build_run_state, build_run_state,
primary_target, primary_target,
read_report_markdown, read_report_markdown,
@@ -367,7 +367,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._send_json(HTTPStatus.CONFLICT, {"error": "run_not_finished"}) self._send_json(HTTPStatus.CONFLICT, {"error": "run_not_finished"})
return return
from strix.viewer.report_pdf import build_encrypted_report from strix.interface.viewer.report_pdf import build_encrypted_report
pdf_bytes, password, filename = build_encrypted_report(run_dir) pdf_bytes, password, filename = build_encrypted_report(run_dir)
run_name = str(summary.get("run_name") or run_dir.name) run_name = str(summary.get("run_name") or run_dir.name)

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -44,8 +44,8 @@ def build_run_state(run_dir: Path) -> dict[str, Any]:
Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI Reuses the Textual-free ``TuiLiveView`` projection so the viewer and the TUI
share one parser for ``agents.json`` + ``agents.db`` and never drift. share one parser for ``agents.json`` + ``agents.db`` and never drift.
""" """
# Imported lazily so importing strix.viewer does not eagerly pull the TUI. # Imported lazily so importing strix.interface.viewer does not eagerly pull the TUI.
from strix.interface.tui.live_view import TuiLiveView # noqa: PLC0415 from strix.interface.tui.live_view import TuiLiveView
view = TuiLiveView() view = TuiLiveView()
view.hydrate_from_run_dir(run_dir) view.hydrate_from_run_dir(run_dir)

View File

@@ -4,13 +4,13 @@ from __future__ import annotations
from pygments.lexers import BashLexer, PythonLexer from pygments.lexers import BashLexer, PythonLexer
from strix.interface.viewer.report_pdf import _strip_code_fence
from strix.report.writer import ( from strix.report.writer import (
guess_language_name, guess_language_name,
parse_fenced_code, parse_fenced_code,
resolve_lexer, resolve_lexer,
safe_fence, safe_fence,
) )
from strix.viewer.report_pdf import _strip_code_fence
def test_parse_fenced_code_extracts_language_and_body() -> None: def test_parse_fenced_code_extracts_language_and_body() -> None:

View File

@@ -10,7 +10,7 @@ import pytest
from pypdf import PdfReader from pypdf import PdfReader
from pypdf.errors import WrongPasswordError from pypdf.errors import WrongPasswordError
from strix.viewer.report_pdf import ( from strix.interface.viewer.report_pdf import (
build_encrypted_report, build_encrypted_report,
encrypt_pdf, encrypt_pdf,
generate_password, generate_password,
@@ -91,7 +91,7 @@ def test_wrong_password_is_rejected(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path) run_dir = _make_run(tmp_path)
encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery") encrypted = encrypt_pdf(generate_report_pdf(run_dir), "correct-horse-battery")
with pytest.raises(WrongPasswordError): with pytest.raises(WrongPasswordError):
PdfReader(BytesIO(encrypted), password="not-the-password") PdfReader(BytesIO(encrypted), password="not-the-password") # nosec B106
def test_build_encrypted_report(tmp_path: Path) -> None: def test_build_encrypted_report(tmp_path: Path) -> None:

View File

@@ -1,4 +1,4 @@
"""Tests for the local run viewer (strix.viewer) and its path helpers.""" """Tests for the local run viewer (strix.interface.viewer) and its path helpers."""
from __future__ import annotations from __future__ import annotations
@@ -9,8 +9,8 @@ import urllib.request
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from strix.core.paths import latest_run_dir, runs_base_dir from strix.core.paths import latest_run_dir, runs_base_dir
from strix.viewer.server import serve from strix.interface.viewer.server import serve
from strix.viewer.transcript import ( from strix.interface.viewer.transcript import (
build_run_state, build_run_state,
read_report_markdown, read_report_markdown,
read_run_summary, read_run_summary,
@@ -89,7 +89,7 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]: def _get(url: str, *, cookie: str | None = None) -> tuple[int, str, bytes]:
headers = {"Cookie": cookie} if cookie else {} headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server # nosec B310
return resp.status, resp.headers.get("Content-Type", ""), resp.read() return resp.status, resp.headers.get("Content-Type", ""), resp.read()
@@ -100,7 +100,7 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
(assets / "assets").mkdir(parents=True) (assets / "assets").mkdir(parents=True)
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8") (assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8") (assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
httpd, url, _ = serve(run_dir, open_browser=False) httpd, url, _ = serve(run_dir, open_browser=False)
try: try:
@@ -132,7 +132,7 @@ def test_server_event_endpoint_forwards_cta(
assets = tmp_path / "bundle" assets = tmp_path / "bundle"
assets.mkdir() assets.mkdir()
(assets / "index.html").write_text("x", encoding="utf-8") (assets / "index.html").write_text("x", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
seen: list[tuple[str, str | None]] = [] seen: list[tuple[str, str | None]] = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -148,7 +148,7 @@ def test_server_event_endpoint_forwards_cta(
req = urllib.request.Request( # noqa: S310 - localhost test server req = urllib.request.Request( # noqa: S310 - localhost test server
f"{url}/api/event", data=body, headers={"Content-Type": "application/json"} f"{url}/api/event", data=body, headers={"Content-Type": "application/json"}
) )
with urllib.request.urlopen(req) as resp: # noqa: S310 with urllib.request.urlopen(req) as resp: # noqa: S310 # nosec B310
assert resp.status == 204 assert resp.status == 204
assert seen == [("PR reviews", "sidebar_nav")] assert seen == [("PR reviews", "sidebar_nav")]
finally: finally:
@@ -163,7 +163,7 @@ def test_server_event_endpoint_forwards_email_funnel(
assets = tmp_path / "bundle" assets = tmp_path / "bundle"
assets.mkdir() assets.mkdir()
(assets / "index.html").write_text("x", encoding="utf-8") (assets / "index.html").write_text("x", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
seen: list[tuple[str, str | None]] = [] seen: list[tuple[str, str | None]] = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -183,7 +183,7 @@ def test_server_event_endpoint_forwards_email_funnel(
data=json.dumps(payload).encode(), data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
with urllib.request.urlopen(req) as resp: # noqa: S310 with urllib.request.urlopen(req) as resp: # noqa: S310 # nosec B310
assert resp.status == 204 assert resp.status == 204
assert seen == expected assert seen == expected
finally: finally:
@@ -207,7 +207,7 @@ def test_server_event_endpoint_forwards_agent_steered(
data=json.dumps({"event": "agent_steered"}).encode(), data=json.dumps({"event": "agent_steered"}).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
with urllib.request.urlopen(req) as resp: # noqa: S310 with urllib.request.urlopen(req) as resp: # noqa: S310 # nosec B310
assert resp.status == 204 assert resp.status == 204
assert seen == [True] assert seen == [True]
finally: finally:
@@ -222,7 +222,7 @@ def test_feedback_records_telemetry_on_success(
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
sent: list[bool] = [] sent: list[bool] = []
monkeypatch.setattr("strix.viewer.auth.feedback_submit", lambda *_a: None) monkeypatch.setattr("strix.interface.viewer.auth.feedback_submit", lambda *_a: None)
monkeypatch.setattr( monkeypatch.setattr(
"strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True) "strix.telemetry.posthog.viewer_feedback_submitted", lambda: sent.append(True)
) )
@@ -257,7 +257,7 @@ def _post(
url + path, data=json.dumps(payload).encode(), headers=headers, method="POST" url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
) )
try: try:
with urllib.request.urlopen(req) as resp: # noqa: S310 with urllib.request.urlopen(req) as resp: # noqa: S310 # nosec B310
return resp.status, resp.read() return resp.status, resp.read()
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, exc.read() return exc.code, exc.read()
@@ -266,7 +266,7 @@ def _post(
def _session_cookie(url: str, token: str) -> str: def _session_cookie(url: str, token: str) -> str:
"""Bootstrap a session via the tokened URL and return its ``name=value`` cookie.""" """Bootstrap a session via the tokened URL and return its ``name=value`` cookie."""
bootstrap = f"{url}/?token={token}" bootstrap = f"{url}/?token={token}"
with urllib.request.urlopen(bootstrap) as resp: # noqa: S310 - localhost test server with urllib.request.urlopen(bootstrap) as resp: # noqa: S310 - localhost test server # nosec B310
raw = str(resp.headers.get("Set-Cookie", "")) raw = str(resp.headers.get("Set-Cookie", ""))
return raw.split(";", 1)[0] return raw.split(";", 1)[0]
@@ -275,7 +275,7 @@ def _get_status(url: str, *, cookie: str | None = None) -> int:
headers = {"Cookie": cookie} if cookie else {} headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server req = urllib.request.Request(url, headers=headers) # noqa: S310 - localhost test server
try: try:
with urllib.request.urlopen(req) as resp: # noqa: S310 with urllib.request.urlopen(req) as resp: # noqa: S310 # nosec B310
return int(resp.status) return int(resp.status)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return int(exc.code) return int(exc.code)
@@ -285,7 +285,7 @@ def _bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assets = tmp_path / "bundle" assets = tmp_path / "bundle"
assets.mkdir() assets.mkdir()
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8") (assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
def test_capability_issued_only_for_tokened_bootstrap( def test_capability_issued_only_for_tokened_bootstrap(
@@ -296,26 +296,26 @@ def test_capability_issued_only_for_tokened_bootstrap(
(assets / "assets").mkdir(parents=True) (assets / "assets").mkdir(parents=True)
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8") (assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
(assets / "assets" / "app.js").write_text("1", encoding="utf-8") (assets / "assets" / "app.js").write_text("1", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
httpd, url, token = serve(run_dir, open_browser=False) httpd, url, token = serve(run_dir, open_browser=False)
try: try:
# A bare index load -- all a reachable client can do -- hands out nothing. # A bare index load -- all a reachable client can do -- hands out nothing.
with urllib.request.urlopen(url + "/") as resp: # noqa: S310 with urllib.request.urlopen(url + "/") as resp: # noqa: S310 # nosec B310
assert resp.headers.get("Set-Cookie") is None assert resp.headers.get("Set-Cookie") is None
# A wrong token is likewise refused the capability. # A wrong token is likewise refused the capability.
with urllib.request.urlopen(f"{url}/?token=wrong") as resp: # noqa: S310 with urllib.request.urlopen(f"{url}/?token=wrong") as resp: # noqa: S310 # nosec B310
assert resp.headers.get("Set-Cookie") is None assert resp.headers.get("Set-Cookie") is None
# Only the correct bootstrap token mints the session cookie. # Only the correct bootstrap token mints the session cookie.
with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 with urllib.request.urlopen(f"{url}/?token={token}") as resp: # noqa: S310 # nosec B310
cookie = str(resp.headers.get("Set-Cookie", "")) cookie = str(resp.headers.get("Set-Cookie", ""))
assert "strix_viewer_session=" in cookie assert "strix_viewer_session=" in cookie
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
# Static assets never carry it. # Static assets never carry it.
with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310 with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310 # nosec B310
assert resp.headers.get("Set-Cookie") is None assert resp.headers.get("Set-Cookie") is None
finally: finally:
httpd.shutdown() httpd.shutdown()
@@ -338,7 +338,7 @@ def test_unauthorized_client_cannot_acquire_capability(
try: try:
# A direct network client can reach the page but is handed no capability, # A direct network client can reach the page but is handed no capability,
# so replaying an empty/guessed cookie cannot steer a live scan. # so replaying an empty/guessed cookie cannot steer a live scan.
with urllib.request.urlopen(url + "/") as resp: # noqa: S310 with urllib.request.urlopen(url + "/") as resp: # noqa: S310 # nosec B310
assert resp.headers.get("Set-Cookie") is None assert resp.headers.get("Set-Cookie") is None
status, _ = _post( status, _ = _post(
url, url,
@@ -356,9 +356,11 @@ def test_unauthorized_client_cannot_acquire_capability(
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "status", status="running", end_time=None) run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}) monkeypatch.setattr(
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
)
verified = {"value": True} verified = {"value": True}
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"]) monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
httpd, url, token = serve(run_dir, open_browser=False) httpd, url, token = serve(run_dir, open_browser=False)
try: try:
@@ -384,7 +386,7 @@ def test_auth_mutations_require_session(tmp_path: Path, monkeypatch: pytest.Monk
run_dir = _make_run(tmp_path, "authmut", status="running", end_time=None) run_dir = _make_run(tmp_path, "authmut", status="running", end_time=None)
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
forgotten = {"value": False} forgotten = {"value": False}
monkeypatch.setattr("strix.viewer.auth.forget", lambda: forgotten.update(value=True)) monkeypatch.setattr("strix.interface.viewer.auth.forget", lambda: forgotten.update(value=True))
httpd, url, _ = serve(run_dir, open_browser=False) httpd, url, _ = serve(run_dir, open_browser=False)
try: try:
@@ -432,7 +434,9 @@ def test_report_send_requires_session_cookie(
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
# A verified machine token exists, but that alone must not authorize a caller. # A verified machine token exists, but that alone must not authorize a caller.
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}) monkeypatch.setattr(
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
)
httpd, url, token = serve(run_dir, open_browser=False) httpd, url, token = serve(run_dir, open_browser=False)
try: try:
@@ -456,7 +460,9 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
# fail closed even for a verified, session-holding caller. # fail closed even for a verified, session-holding caller.
run_dir = _make_run(tmp_path, "live", status="running", end_time=None) run_dir = _make_run(tmp_path, "live", status="running", end_time=None)
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}) monkeypatch.setattr(
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
)
httpd, url, token = serve(run_dir, open_browser=False) httpd, url, token = serve(run_dir, open_browser=False)
try: try:
@@ -475,7 +481,7 @@ def test_historical_run_data_requires_verification(
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
verified = {"value": False} verified = {"value": False}
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"]) monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
httpd, url, token = serve(launched, open_browser=False) httpd, url, token = serve(launched, open_browser=False)
try: try:
@@ -509,12 +515,12 @@ def test_runs_list_requires_session_and_verification(
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z") _make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: True) monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
def _runs(cookie: str | None) -> dict[str, object]: def _runs(cookie: str | None) -> dict[str, object]:
headers = {"Cookie": cookie} if cookie else {} headers = {"Cookie": cookie} if cookie else {}
req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310 req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310
with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server # nosec B310
return dict(json.loads(resp.read())) return dict(json.loads(resp.read()))
httpd, url, token = serve(launched, open_browser=False) httpd, url, token = serve(launched, open_browser=False)
@@ -543,7 +549,7 @@ def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.Monke
assets = tmp_path / "bundle" assets = tmp_path / "bundle"
assets.mkdir() assets.mkdir()
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8") (assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
httpd, url, _ = serve(run_dir, open_browser=False) httpd, url, _ = serve(run_dir, open_browser=False)
try: try:

View File

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
import pytest import pytest
from strix.viewer import auth from strix.interface.viewer import auth
def _iso(delta: timedelta) -> str: def _iso(delta: timedelta) -> str:
@@ -30,7 +30,7 @@ def test_write_read_forget_roundtrip() -> None:
assert auth.read_auth() is None assert auth.read_auth() is None
assert auth.is_verified() is False assert auth.is_verified() is False
auth.write_auth(email="user@example.com", token="tok-123", verified_at=_iso(timedelta(days=30))) auth.write_auth(email="user@example.com", token="tok-123", verified_at=_iso(timedelta(days=30))) # nosec B106
record = auth.read_auth() record = auth.read_auth()
assert record is not None assert record is not None
@@ -47,23 +47,23 @@ def test_write_read_forget_roundtrip() -> None:
def test_is_verified_enforces_expiry() -> None: def test_is_verified_enforces_expiry() -> None:
# An expired record still reads back, but no longer unlocks history. # An expired record still reads back, but no longer unlocks history.
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=-1))) auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=-1))) # nosec B106
assert auth.read_auth() is not None assert auth.read_auth() is not None
assert auth.is_verified() is False assert auth.is_verified() is False
# A future expiry unlocks it. # A future expiry unlocks it.
auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=1))) auth.write_auth(email="a@b.com", token="t", verified_at=_iso(timedelta(hours=1))) # nosec B106
assert auth.is_verified() is True assert auth.is_verified() is True
def test_is_verified_fails_closed_when_expiry_absent_or_unparseable() -> None: def test_is_verified_fails_closed_when_expiry_absent_or_unparseable() -> None:
# No/blank expiry: fail closed rather than unlocking history forever. # No/blank expiry: fail closed rather than unlocking history forever.
auth.write_auth(email="a@b.com", token="t", verified_at="") auth.write_auth(email="a@b.com", token="t", verified_at="") # nosec B106
assert auth.read_auth() is not None assert auth.read_auth() is not None
assert auth.is_verified() is False assert auth.is_verified() is False
# Garbage expiry likewise requires re-verification. # Garbage expiry likewise requires re-verification.
auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date") auth.write_auth(email="a@b.com", token="t", verified_at="not-a-date") # nosec B106
assert auth.is_verified() is False assert auth.is_verified() is False
@@ -73,9 +73,9 @@ def test_is_verified_accepts_epoch_expiry() -> None:
past = (datetime.now(UTC) - timedelta(hours=1)).timestamp() past = (datetime.now(UTC) - timedelta(hours=1)).timestamp()
# As a numeric string (how write_auth persists it). # As a numeric string (how write_auth persists it).
auth.write_auth(email="a@b.com", token="t", verified_at=str(future)) auth.write_auth(email="a@b.com", token="t", verified_at=str(future)) # nosec B106
assert auth.is_verified() is True assert auth.is_verified() is True
auth.write_auth(email="a@b.com", token="t", verified_at=str(past)) auth.write_auth(email="a@b.com", token="t", verified_at=str(past)) # nosec B106
assert auth.is_verified() is False assert auth.is_verified() is False
# As a raw JSON number, if a record is written that way. # As a raw JSON number, if a record is written that way.
@@ -87,7 +87,7 @@ def test_is_verified_accepts_epoch_expiry() -> None:
def test_write_auth_is_0600() -> None: def test_write_auth_is_0600() -> None:
auth.write_auth(email="a@b.com", token="t", verified_at="") auth.write_auth(email="a@b.com", token="t", verified_at="") # nosec B106
mode = stat.S_IMODE(auth.AUTH_PATH.stat().st_mode) mode = stat.S_IMODE(auth.AUTH_PATH.stat().st_mode)
assert mode == 0o600 assert mode == 0o600

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from strix.viewer.server import build_runs_payload, resolve_run_dir from strix.interface.viewer.server import build_runs_payload, resolve_run_dir
if TYPE_CHECKING: if TYPE_CHECKING: