mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
refactor: move strix/viewer under strix/interface
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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__/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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!"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
14
strix.spec
14
strix.spec
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[/]")
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -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()],
|
||||||
@@ -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,
|
||||||
@@ -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)
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user