Compare commits

...
16 changed files with 349 additions and 75 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "strix-agent" name = "strix-agent"
version = "1.4.0" version = "1.4.1"
description = "Open-source AI Hackers for your apps" description = "Open-source AI Hackers for your apps"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
+13 -20
View File
@@ -18,12 +18,12 @@ import logging
import secrets import secrets
import threading import threading
import time import time
import urllib.error
import urllib.parse import urllib.parse
import urllib.request
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import requests
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@@ -221,26 +221,19 @@ def _first(query: dict[str, list[str]], key: str) -> str | None:
def _post_form(payload: dict[str, str]) -> dict[str, Any]: def _post_form(payload: dict[str, str]) -> dict[str, Any]:
body = urllib.parse.urlencode(payload).encode("ascii")
request = urllib.request.Request( # noqa: S310 - fixed https OAuth endpoint
TOKEN_URL,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
method="POST",
)
try: try:
with urllib.request.urlopen( # noqa: S310 # nosec B310 - fixed https endpoint response = requests.post(
request, timeout=_TOKEN_TIMEOUT TOKEN_URL,
) as response: data=payload,
data = json.loads(response.read() or b"{}") headers={"Accept": "application/json"},
except urllib.error.HTTPError as exc: timeout=_TOKEN_TIMEOUT,
detail = exc.read().decode("utf-8", "replace")[:300] )
raise CodexAuthError("token_http_error", f"HTTP {exc.code}: {detail}") from exc except requests.RequestException as exc:
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise CodexAuthError("unavailable", str(exc)) from exc raise CodexAuthError("unavailable", str(exc)) from exc
if response.status_code >= 400:
detail = response.text[:300]
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
data = json.loads(response.content or b"{}")
if not isinstance(data, dict): if not isinstance(data, dict):
raise CodexAuthError("bad_response", "token endpoint returned non-object") raise CodexAuthError("bad_response", "token endpoint returned non-object")
return data return data
+5 -3
View File
@@ -200,7 +200,9 @@ class AgentCoordinator:
logger.info("agent.status %s=%s", agent_id, status) logger.info("agent.status %s=%s", agent_id, status)
await self._maybe_snapshot() await self._maybe_snapshot()
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool: async def send(
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
) -> bool:
"""Deliver a user/peer message by appending it to the target SDK session.""" """Deliver a user/peer message by appending it to the target SDK session."""
if message.get("from") == "user" and self._budget_paused: if message.get("from") == "user" and self._budget_paused:
await self.resume_from_budget_pause(exclude=target_agent_id) await self.resume_from_budget_pause(exclude=target_agent_id)
@@ -211,7 +213,7 @@ class AgentCoordinator:
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime()) runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
session = runtime.session session = runtime.session
stream = runtime.stream stream = runtime.stream
interrupt = runtime.interrupt_on_message interrupt_on_message = runtime.interrupt_on_message
if session is None: if session is None:
logger.warning( logger.warning(
"agent.send dropped target=%s because its SDK session is not attached", "agent.send dropped target=%s because its SDK session is not attached",
@@ -230,7 +232,7 @@ class AgentCoordinator:
async with self._lock: async with self._lock:
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1 self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set() self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
if stream is not None and interrupt: if stream is not None and interrupt and interrupt_on_message:
stream.cancel(mode="immediate") stream.cancel(mode="immediate")
await self._maybe_snapshot() await self._maybe_snapshot()
return True return True
+30 -1
View File
@@ -21,6 +21,7 @@ from openai import (
RateLimitError, RateLimitError,
) )
from strix.config import codex
from strix.core.hooks import ( from strix.core.hooks import (
BudgetExceededError, BudgetExceededError,
BudgetPausedError, BudgetPausedError,
@@ -88,6 +89,11 @@ async def _compact_session(
) )
_GUARDRAIL_PARK_ERROR = (
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
)
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504}) _TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
_MAX_TRANSIENT_MODEL_RETRIES = 4 _MAX_TRANSIENT_MODEL_RETRIES = 4
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0 _TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
@@ -304,6 +310,7 @@ async def respawn_subagents(
if coordinator.parent_of.get(aid) is None or aid == root_id: if coordinator.parent_of.get(aid) is None or aid == root_id:
continue continue
md["_restored_status"] = status md["_restored_status"] = status
md["_restored_error"] = coordinator.errors.get(aid)
candidates.append( candidates.append(
( (
aid, aid,
@@ -316,7 +323,8 @@ async def respawn_subagents(
for child_id, name, parent_id, md in candidates: for child_id, name, parent_id, md in candidates:
try: try:
restored_status = str(md.get("_restored_status") or "running") restored_status = str(md.get("_restored_status") or "running")
start_parked = interactive and restored_status != "running" recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
start_parked = interactive and restored_status != "running" and not recoverable_park
if start_parked: if start_parked:
logger.warning( logger.warning(
@@ -572,6 +580,10 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
if session is not None: if session is not None:
input_data = [] input_data = []
continue continue
if codex.is_content_guardrail_error(exc):
return await _handle_content_guardrail(
coordinator, agent_id, exc, interactive=interactive
)
if not interactive: if not interactive:
raise raise
if isinstance(exc, MaxTurnsExceeded): if isinstance(exc, MaxTurnsExceeded):
@@ -589,6 +601,22 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
return stream return stream
async def _handle_content_guardrail(
coordinator: AgentCoordinator,
agent_id: str,
exc: BaseException,
*,
interactive: bool,
) -> RunResultBase | None:
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
if interactive:
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
return None
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
async def _settle_run_result( async def _settle_run_result(
coordinator: AgentCoordinator, coordinator: AgentCoordinator,
agent_id: str, agent_id: str,
@@ -685,6 +713,7 @@ async def _notify_parent_on_terminal(
"priority": "high", "priority": "high",
"content": template.format(name=name, agent_id=agent_id), "content": template.format(name=name, agent_id=agent_id),
}, },
interrupt=False,
) )
+7 -1
View File
@@ -376,6 +376,12 @@ async def run_strix_scan(
async with coordinator._lock: async with coordinator._lock:
root_status = coordinator.statuses.get(root_id) root_status = coordinator.statuses.get(root_id)
root_error = coordinator.errors.get(root_id)
root_recoverable_park = root_status == "waiting" and bool(root_error)
root_start_parked = bool(
interactive and is_resume and root_status != "running" and not root_recoverable_park
)
result = await run_agent_loop( result = await run_agent_loop(
agent=root_agent, agent=root_agent,
@@ -387,7 +393,7 @@ async def run_strix_scan(
agent_id=root_id, agent_id=root_id,
interactive=interactive, interactive=interactive,
session=root_session, session=root_session,
start_parked=bool(interactive and is_resume and root_status != "running"), start_parked=root_start_parked,
event_sink=event_sink, event_sink=event_sink,
hooks=hooks, hooks=hooks,
) )
+1 -1
View File
@@ -884,7 +884,7 @@ def display_completion_message(args: argparse.Namespace, results_path: Path) ->
view_text = Text() view_text = Text()
view_text.append("\n") view_text.append("\n")
view_text.append("View", style="dim") view_text.append("View", style="dim")
view_text.append(" ") view_text.append(" ")
view_text.append(f"strix view {args.run_name}", style="#22c55e") view_text.append(f"strix view {args.run_name}", style="#22c55e")
panel_parts.extend(["\n", view_text]) panel_parts.extend(["\n", view_text])
+7 -5
View File
@@ -20,7 +20,7 @@ class TuiLiveView:
self.events: list[dict[str, Any]] = [] self.events: list[dict[str, Any]] = []
self._next_event_id = 1 self._next_event_id = 1
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {} self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {} self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
def hydrate_from_run_dir(self, run_dir: Path) -> None: def hydrate_from_run_dir(self, run_dir: Path) -> None:
state_dir = runtime_state_dir(run_dir) state_dir = runtime_state_dir(run_dir)
@@ -223,7 +223,8 @@ class TuiLiveView:
timestamp: str | None = None, timestamp: str | None = None,
) -> None: ) -> None:
call_id = call["call_id"] call_id = call["call_id"]
existing = self._tool_event_by_call_id.get(call_id) event_key = (agent_id, call_id)
existing = self._tool_event_by_agent_and_call_id.get(event_key)
tool_data = { tool_data = {
"tool_name": call["tool_name"], "tool_name": call["tool_name"],
"args": call["args"], "args": call["args"],
@@ -233,7 +234,7 @@ class TuiLiveView:
} }
if existing is None: if existing is None:
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp) event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
self._tool_event_by_call_id[call_id] = event self._tool_event_by_agent_and_call_id[event_key] = event
else: else:
existing["data"].update(tool_data) existing["data"].update(tool_data)
self._bump_event(existing, timestamp=timestamp) self._bump_event(existing, timestamp=timestamp)
@@ -249,7 +250,8 @@ class TuiLiveView:
timestamp: str | None = None, timestamp: str | None = None,
) -> None: ) -> None:
call_id = output["call_id"] call_id = output["call_id"]
event = self._tool_event_by_call_id.get(call_id) event_key = (agent_id, call_id)
event = self._tool_event_by_agent_and_call_id.get(event_key)
if event is None: if event is None:
event = self._append_event( event = self._append_event(
agent_id, agent_id,
@@ -263,7 +265,7 @@ class TuiLiveView:
}, },
timestamp=timestamp, timestamp=timestamp,
) )
self._tool_event_by_call_id[call_id] = event self._tool_event_by_agent_and_call_id[event_key] = event
result = _parse_json_value(output["output"]) result = _parse_json_value(output["output"])
event["data"]["result"] = result event["data"]["result"] = result
+6 -8
View File
@@ -11,11 +11,10 @@ import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen
import docker import docker
import requests
from docker.errors import DockerException, ImageNotFound from docker.errors import DockerException, ImageNotFound
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
@@ -1088,13 +1087,12 @@ def resolve_diff_scope_context(
def _is_http_git_repo(url: str) -> bool: def _is_http_git_repo(url: str) -> bool:
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack" check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
try: try:
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310 resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310 except (requests.RequestException, ValueError):
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
except HTTPError as e:
return e.code == 401
except (URLError, OSError, ValueError):
return False return False
if resp.status_code >= 400:
return resp.status_code == 401
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911 def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
+10 -14
View File
@@ -15,12 +15,12 @@ import base64
import contextlib import contextlib
import json import json
import logging import logging
import urllib.error
import urllib.request
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import requests
from strix.config.loader import load_settings from strix.config.loader import load_settings
@@ -147,21 +147,17 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
map, not raised. map, not raised.
""" """
url = f"{_app_url()}{path}" url = f"{_app_url()}{path}"
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
url,
data=body,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
try: try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 # nosec B310 response = requests.post(
return response.status, _parse_body(response.read()) url,
except urllib.error.HTTPError as exc: json=payload,
return exc.code, _parse_body(exc.read()) headers={"Accept": "application/json"},
except (urllib.error.URLError, TimeoutError, OSError) as exc: timeout=timeout,
)
except requests.RequestException as exc:
logger.warning("relay request to %s failed: %s", path, exc) logger.warning("relay request to %s failed: %s", path, exc)
raise RelayError("unavailable") from exc raise RelayError("unavailable") from exc
return response.status_code, _parse_body(response.content)
def _parse_body(raw: bytes) -> dict[str, Any]: def _parse_body(raw: bytes) -> dict[str, Any]:
+11 -4
View File
@@ -107,8 +107,11 @@ def resolve_run_dir(base_dir: Path, run_param: str | None, default_run_dir: Path
return candidate return candidate
# Name of the cookie carrying the per-process session capability. # Prefix of the cookie carrying the per-process session capability. The bound
SESSION_COOKIE = "strix_viewer_session" # port is appended (``strix_viewer_session_<port>``) because browsers scope
# cookies by host only, never by port: concurrent viewers on 127.0.0.1 would
# otherwise share one cookie slot and clobber each other's session.
SESSION_COOKIE_PREFIX = "strix_viewer_session"
class _ViewerState: class _ViewerState:
@@ -135,6 +138,9 @@ class _ViewerState:
# enough to steer a live scan, trigger a report, or browse history -- # enough to steer a live scan, trigger a report, or browse history --
# the token is never handed to a caller who merely reaches ``/``. # the token is never handed to a caller who merely reaches ``/``.
self.session_token = secrets.token_urlsafe(32) self.session_token = secrets.token_urlsafe(32)
# Finalized in ``serve()`` once the port is known (the server binds
# after this state is constructed); see SESSION_COOKIE_PREFIX.
self.cookie_name = SESSION_COOKIE_PREFIX
def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
@@ -476,7 +482,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
the browser this process handed the page to can pass. A direct the browser this process handed the page to can pass. A direct
caller on an exposed port has no cookie and is rejected. caller on an exposed port has no cookie and is rejected.
""" """
supplied = self._cookies().get(SESSION_COOKIE, "") supplied = self._cookies().get(state.cookie_name, "")
return bool(supplied) and secrets.compare_digest(supplied, state.session_token) return bool(supplied) and secrets.compare_digest(supplied, state.session_token)
def _token_presented(self, query: dict[str, list[str]]) -> bool: def _token_presented(self, query: dict[str, list[str]]) -> bool:
@@ -512,7 +518,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
# SameSite=Strict (never sent from a cross-site context). # SameSite=Strict (never sent from a cross-site context).
self.send_header( self.send_header(
"Set-Cookie", "Set-Cookie",
f"{SESSION_COOKIE}={state.session_token}; Path=/; HttpOnly; SameSite=Strict", f"{state.cookie_name}={state.session_token}; Path=/; HttpOnly; SameSite=Strict",
) )
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
@@ -586,6 +592,7 @@ def serve(
httpd.daemon_threads = True httpd.daemon_threads = True
bound_port = int(httpd.server_address[1]) bound_port = int(httpd.server_address[1])
state.cookie_name = f"{SESSION_COOKIE_PREFIX}_{bound_port}"
url = f"http://{host}:{bound_port}" url = f"http://{host}:{bound_port}"
thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True) thread = threading.Thread(target=httpd.serve_forever, name="strix-viewer", daemon=True)
+3 -9
View File
@@ -1,9 +1,9 @@
import json
import logging import logging
import urllib.request
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import requests
from strix.config import load_settings from strix.config import load_settings
from strix.telemetry._common import ( from strix.telemetry._common import (
SESSION_ID, SESSION_ID,
@@ -37,13 +37,7 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
"distinct_id": SESSION_ID, "distinct_id": SESSION_ID,
"properties": properties, "properties": properties,
} }
req = urllib.request.Request( # noqa: S310 requests.post(f"{_POSTHOG_HOST}/capture/", json=payload, timeout=10)
f"{_POSTHOG_HOST}/capture/",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
pass
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("posthog send failed for event %s", event, exc_info=True) logger.debug("posthog send failed for event %s", event, exc_info=True)
return False return False
+3 -4
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
import logging import logging
import urllib.parse import urllib.parse
import urllib.request
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import requests
from strix.config import load_settings from strix.config import load_settings
from strix.telemetry._common import ( from strix.telemetry._common import (
SESSION_ID, SESSION_ID,
@@ -42,9 +43,7 @@ def _send(event: str, properties: dict[str, Any]) -> bool:
url = f"{_SCARF_ENDPOINT}{path}" url = f"{_SCARF_ENDPOINT}{path}"
if query: if query:
url = f"{url}?{query}" url = f"{url}?{query}"
req = urllib.request.Request(url, method="POST") # noqa: S310 requests.post(url, timeout=10)
with urllib.request.urlopen(req, timeout=10): # noqa: S310 # nosec B310
pass
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("scarf send failed for event %s", event, exc_info=True) logger.debug("scarf send failed for event %s", event, exc_info=True)
return False return False
+14
View File
@@ -7,8 +7,10 @@ import hashlib
import json import json
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from unittest import mock
import pytest import pytest
import requests
from strix.config import codex from strix.config import codex
@@ -52,6 +54,18 @@ def test_authorize_url_carries_pkce_and_client() -> None:
assert "state=st8" in url assert "state=st8" in url
def test_post_form_returns_parsed_body() -> None:
resp = mock.MagicMock()
resp.status_code = 200
resp.content = b'{"access_token": "tok"}'
with mock.patch.object(requests, "post", return_value=resp) as post:
data = codex._post_form({"grant_type": "refresh_token"})
assert data == {"access_token": "tok"}
assert post.call_args.kwargs["timeout"] == codex._TOKEN_TIMEOUT
@pytest.mark.parametrize( @pytest.mark.parametrize(
("value", "expected"), ("value", "expected"),
[ [
+112 -1
View File
@@ -6,13 +6,21 @@ import asyncio
import contextlib import contextlib
import json import json
from typing import Any from typing import Any
from unittest.mock import MagicMock
import pytest import pytest
from agents.memory import SQLiteSession from agents.memory import SQLiteSession
from agents.tool_context import ToolContext from agents.tool_context import ToolContext
from strix.config import codex
from strix.core import execution
from strix.core.agents import AgentCoordinator from strix.core.agents import AgentCoordinator
from strix.core.execution import _notify_parent_on_terminal, _notify_root_on_budget_reserve from strix.core.execution import (
_handle_content_guardrail,
_notify_parent_on_terminal,
_notify_root_on_budget_reserve,
respawn_subagents,
)
from strix.tools.finish.tool import finish_scan from strix.tools.finish.tool import finish_scan
@@ -465,3 +473,106 @@ async def test_notify_parent_on_terminal_ignores_non_terminal_status(tmp_path: A
assert coordinator.pending_counts.get("root", 0) == 0 assert coordinator.pending_counts.get("root", 0) == 0
session.close() session.close()
class _RecordingStream:
def __init__(self) -> None:
self.cancelled = False
self.cancel_mode: str | None = None
def cancel(self, mode: str = "immediate") -> None:
self.cancelled = True
self.cancel_mode = mode
@pytest.mark.asyncio
async def test_terminal_notice_does_not_cancel_parent_stream(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
stream = _RecordingStream()
await coordinator.attach_runtime("root", session=session, interrupt_on_message=True)
await coordinator.attach_stream("root", stream)
await _notify_parent_on_terminal(coordinator, "child", "crashed")
assert stream.cancelled is False
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_guardrail_interactive_parks_agent_wakeable(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=True)
assert result is None
assert coordinator.statuses["child"] == "waiting"
assert "STRIX_LLM" in coordinator.errors["child"]
waiter = asyncio.create_task(coordinator.wait_for_message("child"))
await asyncio.sleep(0)
assert not waiter.done()
session = SQLiteSession("child", tmp_path / "agents.db")
await coordinator.attach_runtime("child", session=session)
await coordinator.send("child", {"from": "user", "content": "switched model, resume"})
await asyncio.wait_for(waiter, timeout=1.0)
session.close()
@pytest.mark.asyncio
async def test_guardrail_noninteractive_fails_only_blocked_agent(tmp_path: Any) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
session = SQLiteSession("root", tmp_path / "agents.db")
await coordinator.attach_runtime("root", session=session)
exc = codex.CodexContentGuardrailError("chatgpt/gpt-5.6-sol")
result = await _handle_content_guardrail(coordinator, "child", exc, interactive=False)
assert result is None
assert coordinator.statuses["child"] == "failed"
assert "STRIX_LLM" in coordinator.errors["child"]
assert coordinator.statuses["root"] == "running"
assert coordinator.pending_counts.get("root", 0) > 0
session.close()
@pytest.mark.asyncio
async def test_resume_revives_guardrail_parked_child_but_not_plain_waiting(
tmp_path: Any, monkeypatch: pytest.MonkeyPatch
) -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("blocked", "recon", parent_id="root")
await coordinator.register("peer_waiter", "recon", parent_id="root")
await coordinator.set_status("blocked", "waiting", error="STRIX_LLM guardrail")
await coordinator.set_status("peer_waiter", "waiting")
parked: dict[str, bool] = {}
async def _fake_start_child_runner(**kwargs: Any) -> None:
parked[kwargs["child_id"]] = bool(kwargs["start_parked"])
monkeypatch.setattr(execution, "_start_child_runner", _fake_start_child_runner)
await respawn_subagents(
coordinator=coordinator,
factory=lambda **_kwargs: object(),
agents_db_path=tmp_path / "agents.db",
sessions_to_close=[],
run_config=MagicMock(),
max_turns=10,
interactive=True,
parent_ctx={"agent_id": "root", "parent_id": None},
root_id="root",
)
assert parked["blocked"] is False
assert parked["peer_waiter"] is True
+125 -2
View File
@@ -4,9 +4,11 @@ from __future__ import annotations
import json import json
import os import os
import sqlite3
import urllib.error import urllib.error
import urllib.request import urllib.request
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from strix.core.paths import latest_run_dir, runs_base_dir from strix.core.paths import latest_run_dir, runs_base_dir
from strix.interface.viewer.server import serve from strix.interface.viewer.server import serve
@@ -86,6 +88,75 @@ def test_build_run_state_from_agents_json(tmp_path: Path) -> None:
assert state["events"] == [] assert state["events"] == []
def test_build_run_state_keeps_same_call_id_separate_per_agent(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "tools", status="completed", end_time=None)
agents_db = run_dir / ".state" / "agents.db"
rows = [
(
"root",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo root"}),
},
),
(
"root",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "root"}),
},
),
(
"child",
{
"type": "function_call",
"call_id": "exec_command_0",
"name": "exec_command",
"arguments": json.dumps({"cmd": "echo child"}),
},
),
(
"child",
{
"type": "function_call_output",
"call_id": "exec_command_0",
"output": json.dumps({"success": True, "output": "child"}),
},
),
]
with sqlite3.connect(agents_db) as conn:
conn.execute(
"""
create table agent_messages (
id integer primary key,
session_id text not null,
message_data text not null,
created_at text not null
)
"""
)
conn.executemany(
"""
insert into agent_messages (session_id, message_data, created_at)
values (?, ?, '2026-01-01T00:00:00+00:00')
""",
[(agent_id, json.dumps(message)) for agent_id, message in rows],
)
state = build_run_state(run_dir)
tools = [event for event in state["events"] if event["type"] == "tool"]
assert len(tools) == 2
by_agent = {event["agent_id"]: event for event in tools}
assert by_agent["root"]["data"]["args"] == {"cmd": "echo root"}
assert by_agent["root"]["data"]["result"]["output"] == "root"
assert by_agent["child"]["data"]["args"] == {"cmd": "echo child"}
assert by_agent["child"]["data"]["result"]["output"] == "child"
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
@@ -271,6 +342,11 @@ def _session_cookie(url: str, token: str) -> str:
return raw.split(";", 1)[0] return raw.split(";", 1)[0]
def _cookie_name(url: str) -> str:
"""The per-server session cookie name, derived from the bound port."""
return f"strix_viewer_session_{urlsplit(url).port}"
def _get_status(url: str, *, cookie: str | None = None) -> int: 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
@@ -311,7 +387,7 @@ def test_capability_issued_only_for_tokened_bootstrap(
# 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 # nosec B310 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 f"{_cookie_name(url)}=" 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.
@@ -344,7 +420,7 @@ def test_unauthorized_client_cannot_acquire_capability(
url, url,
"/api/agents/steer", "/api/agents/steer",
{"agent_id": "root", "message": "pwn"}, {"agent_id": "root", "message": "pwn"},
cookie="strix_viewer_session=", cookie=f"{_cookie_name(url)}=",
) )
assert status == 403 assert status == 403
assert delivered == [] assert delivered == []
@@ -541,6 +617,53 @@ def test_runs_list_requires_session_and_verification(
httpd.server_close() httpd.server_close()
def test_concurrent_servers_use_distinct_cookies(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Cookies are host-scoped, not port-scoped: two viewers on 127.0.0.1 must
not share a cookie slot, and one server's cookie must not pass the other's
session gate."""
run_a = _make_run(tmp_path / "a", "run-a", status="running", end_time=None)
run_b = _make_run(tmp_path / "b", "run-b", status="running", end_time=None)
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr(
"strix.interface.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"}
)
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
httpd_a, url_a, token_a = serve(run_a, open_browser=False)
httpd_b, url_b, token_b = serve(run_b, open_browser=False)
try:
cookie_a = _session_cookie(url_a, token_a)
cookie_b = _session_cookie(url_b, token_b)
# The two servers mint differently named cookies, so a browser stores both.
assert cookie_a.split("=", 1)[0] == _cookie_name(url_a)
assert cookie_b.split("=", 1)[0] == _cookie_name(url_b)
assert cookie_a.split("=", 1)[0] != cookie_b.split("=", 1)[0]
def _status(url: str, cookie: str) -> dict[str, object]:
_, _, body = _get(f"{url}/api/auth/status", cookie=cookie)
return dict(json.loads(body))
# Each server honors its own cookie...
assert _status(url_a, cookie_a)["verified"] is True
assert _status(url_b, cookie_b)["verified"] is True
# ...but treats the other server's cookie as session-less.
assert _status(url_a, cookie_b)["verified"] is False
assert _status(url_b, cookie_a)["verified"] is False
# Even both cookies together (what a real browser would send) only
# match the token minted by the receiving server.
both = f"{cookie_a}; {cookie_b}"
assert _status(url_a, both)["verified"] is True
assert _status(url_b, both)["verified"] is True
finally:
httpd_a.shutdown()
httpd_a.server_close()
httpd_b.shutdown()
httpd_b.server_close()
def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z") run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z")
secret = tmp_path / "secret.txt" secret = tmp_path / "secret.txt"
Generated
+1 -1
View File
@@ -2411,7 +2411,7 @@ wheels = [
[[package]] [[package]]
name = "strix-agent" name = "strix-agent"
version = "1.4.0" version = "1.4.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "caido-sdk-client" }, { name = "caido-sdk-client" },