mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
17
Commits
7bdc2424f2
..
v1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc9fa9faa | ||
|
|
174c16fa26 | ||
|
|
94a2586aaa | ||
|
|
372e27fa17 | ||
|
|
ad727edd66 | ||
|
|
7b3c8f9b74 | ||
|
|
ae07af6159 | ||
|
|
649a2e2140 | ||
|
|
597aae6715 | ||
|
|
06b158d1fa | ||
|
|
c29eb73c7f | ||
|
|
72833b8e43 | ||
|
|
1117ba6d4a | ||
|
|
53e4658d88 | ||
|
|
58df71d3db | ||
|
|
0b9e029a5d | ||
|
|
b260a4ee38 |
@@ -117,6 +117,21 @@ ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
|
||||
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
|
||||
ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=180000
|
||||
USER root
|
||||
RUN set -eu; \
|
||||
{ \
|
||||
for var in AGENT_BROWSER_EXECUTABLE_PATH AGENT_BROWSER_USER_AGENT \
|
||||
AGENT_BROWSER_ARGS AGENT_BROWSER_SCREENSHOT_DIR \
|
||||
AGENT_BROWSER_IDLE_TIMEOUT_MS; do \
|
||||
eval "value=\${$var}"; \
|
||||
printf 'export %s="${%s:-%s}"\n' "$var" "$var" "$value"; \
|
||||
done; \
|
||||
} > /tmp/agent-browser.sh; \
|
||||
install -m 0644 /tmp/agent-browser.sh /etc/profile.d/agent-browser.sh; \
|
||||
rm /tmp/agent-browser.sh; \
|
||||
env -i bash -lc 'test "${AGENT_BROWSER_IDLE_TIMEOUT_MS}" = "180000"'
|
||||
USER pentester
|
||||
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
|
||||
|
||||
RUN set -eux; \
|
||||
|
||||
+1
-8
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.5.1"
|
||||
version = "1.5.3"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
@@ -230,7 +230,6 @@ ignore = [
|
||||
# args they intentionally ignore.
|
||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||
"tests/test_grok_auth.py" = ["S105", "S106", "SLF001"]
|
||||
# Hatchling loads the build hook by path, not as an importable package.
|
||||
"scripts/tui_sidecar_hook.py" = ["INP001"]
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||
@@ -245,9 +244,6 @@ ignore = [
|
||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||
# Lazy import of the TUI live-view projection so importing the viewer does not
|
||||
# eagerly pull in the Textual TUI.
|
||||
"strix/interface/viewer/transcript.py" = ["PLC0415"]
|
||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||
# Lazy imports inside functions to avoid circular dependency with
|
||||
@@ -292,9 +288,6 @@ ignore = [
|
||||
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||
# don't pull them in.
|
||||
"strix/config/codex.py" = ["PLC0415"]
|
||||
"strix/config/grok.py" = ["PLC0415"]
|
||||
# Lazy ``import fcntl`` so the module imports on non-POSIX platforms.
|
||||
"strix/config/subscription_store.py" = ["PLC0415"]
|
||||
# Interface utility branches per scope-mode / target-type combination;
|
||||
# splitting would obscure the decision tree without simplifying it.
|
||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||
|
||||
@@ -160,7 +160,9 @@ def _schema_types(spec: dict[str, Any]) -> set[str]:
|
||||
def _decode_structured(value: str, types: set[str]) -> Any:
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return value
|
||||
# An empty string is the model's "no value" for a list/dict param; give it
|
||||
# the empty container so it validates instead of failing the type check.
|
||||
return [] if "array" in types else {}
|
||||
try:
|
||||
decoded = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
|
||||
@@ -39,6 +39,8 @@ INTERACTIVE BEHAVIOR:
|
||||
- To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent).
|
||||
- A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you.
|
||||
- Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge.
|
||||
- If all you want to do is reply and stop, that whole turn is ONE respond_to_user call carrying the answer. Do not write the answer as text and then call respond_to_user as well: the user reads it twice.
|
||||
- If you do end a turn on plain text and the nudge arrives, your words already reached the user. Do not restate them: call respond_to_user with NO message to simply wait, or with only whatever you still need to add.
|
||||
- You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
|
||||
- Respond naturally when the user asks questions or gives instructions.
|
||||
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user.
|
||||
@@ -261,7 +263,13 @@ Remember: A single well-validated high-impact vulnerability is worth more than d
|
||||
<multi_agent_system>
|
||||
AGENT ISOLATION & SANDBOXING:
|
||||
- All agents run in the same shared Docker container for efficiency
|
||||
- Each agent has its own: browser sessions, terminal sessions
|
||||
- Each agent has its own terminal sessions
|
||||
- Browsers are NOT per-agent by default: `agent-browser` with no `--session` is one
|
||||
shared browser, so a concurrent agent's navigation invalidates your page and refs.
|
||||
Pass `--session <your-agent-name>` for any browser work of your own — then it is
|
||||
yours alone. Each session is a full Chromium (~340 MB) on this shared box, so keep
|
||||
one, not several, and `agent-browser --session <name> close` when you're done with
|
||||
the target; an idle browser is reclaimed automatically after 3 minutes
|
||||
- All agents share the same /workspace directory and proxy history
|
||||
- Agents can see each other's files and proxy traffic for better collaboration
|
||||
|
||||
|
||||
+47
-18
@@ -16,6 +16,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
@@ -23,7 +24,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config import subscription_store
|
||||
from strix.utils.secret_files import write_secret_text
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -53,12 +54,26 @@ _ACCOUNT_CLAIM = "https://api.openai.com/auth"
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
# Kept separate from cli-config.json so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def _read_store() -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(AUTH_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_store(data: dict[str, Any]) -> None:
|
||||
write_secret_text(AUTH_PATH, json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = subscription_store.read(AUTH_PATH).get(PROVIDER)
|
||||
record = _read_store().get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh") and record.get("account_id")):
|
||||
@@ -71,31 +86,45 @@ def is_authenticated() -> bool:
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
data = subscription_store.read(AUTH_PATH)
|
||||
data[PROVIDER] = record
|
||||
subscription_store.write(AUTH_PATH, data)
|
||||
data = _read_store()
|
||||
data[PROVIDER] = record
|
||||
_write_store(data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
data = subscription_store.read(AUTH_PATH)
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
subscription_store.write(AUTH_PATH, data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
data = _read_store()
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
_write_store(data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
yield
|
||||
with _refresh_lock:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
lock_path = AUTH_PATH.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = lock_path.open("w")
|
||||
except (ImportError, OSError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
handle.close()
|
||||
|
||||
|
||||
class CodexAuthError(Exception):
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
"""Grok (xAI) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||
client that routes inference through xAI's API.
|
||||
|
||||
Mirrors xAI's Grok CLI: OAuth 2.0 + PKCE against ``auth.x.ai``, with the access
|
||||
token sent as a ``Bearer`` token to ``api.x.ai/v1`` (OpenAI-compatible, so the
|
||||
subscription and a metered API key share one endpoint — only the bearer differs).
|
||||
Using a Grok/SuperGrok subscription outside xAI's own products is not officially
|
||||
supported by xAI; the user chooses this path knowingly. The OAuth constants are
|
||||
xAI's own Grok CLI values (the backend only accepts that client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from strix.config import subscription_store
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER = "grok"
|
||||
|
||||
CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
|
||||
TOKEN_URL = "https://auth.x.ai/oauth2/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||
CALLBACK_HOST = "127.0.0.1"
|
||||
CALLBACK_PORT = 56121
|
||||
CALLBACK_PATH = "/callback"
|
||||
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||
SCOPE = "openid profile email offline_access grok-cli:access api:access"
|
||||
|
||||
XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
|
||||
_TOKEN_TIMEOUT = 30
|
||||
_EXPIRY_SKEW_S = 300
|
||||
|
||||
# Shared with the other subscription providers; kept separate from cli-config.json
|
||||
# so OAuth tokens never land in the env-var config.
|
||||
AUTH_PATH = Path.home() / ".strix" / "subscription-auth.json"
|
||||
|
||||
|
||||
def read_record() -> dict[str, Any] | None:
|
||||
record = subscription_store.read(AUTH_PATH).get(PROVIDER)
|
||||
if not isinstance(record, dict) or record.get("type") != "oauth":
|
||||
return None
|
||||
if not (record.get("access") and record.get("refresh")):
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
return read_record() is not None
|
||||
|
||||
|
||||
def save_record(record: dict[str, Any]) -> None:
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
data = subscription_store.read(AUTH_PATH)
|
||||
data[PROVIDER] = record
|
||||
subscription_store.write(AUTH_PATH, data)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
data = subscription_store.read(AUTH_PATH)
|
||||
if PROVIDER not in data:
|
||||
return
|
||||
del data[PROVIDER]
|
||||
if data:
|
||||
subscription_store.write(AUTH_PATH, data)
|
||||
return
|
||||
with contextlib.suppress(OSError):
|
||||
AUTH_PATH.unlink()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _refresh_guard() -> Iterator[None]:
|
||||
"""Serialize token refresh within (lock) and across (flock) Strix processes,
|
||||
so concurrent runs can't both spend the single-use refresh token."""
|
||||
with subscription_store.guard(AUTH_PATH):
|
||||
yield
|
||||
|
||||
|
||||
class GrokAuthError(Exception):
|
||||
def __init__(self, code: str, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
verifier = _b64url(secrets.token_bytes(64))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def build_authorize_url(challenge: str, state: str) -> str:
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
return f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
def parse_redirect_input(value: str) -> tuple[str | None, str | None]:
|
||||
"""Extract ``(code, state)`` from a pasted redirect URL, ``code#state``,
|
||||
query string, or bare code."""
|
||||
value = (value or "").strip()
|
||||
if not value:
|
||||
return None, None
|
||||
with contextlib.suppress(ValueError):
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
if parsed.scheme and parsed.query:
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
if "#" in value:
|
||||
code, _, state = value.partition("#")
|
||||
return code or None, state or None
|
||||
if "code=" in value:
|
||||
query = urllib.parse.parse_qs(value)
|
||||
return _first(query, "code"), _first(query, "state")
|
||||
return value, None
|
||||
|
||||
|
||||
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
values = query.get(key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _post_form(payload: dict[str, str]) -> dict[str, Any]:
|
||||
try:
|
||||
response = requests.post(
|
||||
TOKEN_URL,
|
||||
data=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise GrokAuthError("unavailable", str(exc)) from exc
|
||||
if response.status_code >= 400:
|
||||
detail = response.text[:300]
|
||||
raise GrokAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||
data = json.loads(response.content or b"{}")
|
||||
if not isinstance(data, dict):
|
||||
raise GrokAuthError("bad_response", "token endpoint returned non-object")
|
||||
return data
|
||||
|
||||
|
||||
def _record_from_token_response(
|
||||
data: dict[str, Any], refresh_fallback: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
access = data.get("access_token")
|
||||
# A refresh response may omit refresh_token when it isn't rotated; keep the old one.
|
||||
refresh = data.get("refresh_token") or refresh_fallback
|
||||
expires_in = data.get("expires_in")
|
||||
if not isinstance(access, str) or not access:
|
||||
raise GrokAuthError("bad_response", "token response missing access_token")
|
||||
if not isinstance(refresh, str) or not refresh:
|
||||
raise GrokAuthError("bad_response", "token response missing refresh_token")
|
||||
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": PROVIDER,
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"expires_at": time.time() + ttl,
|
||||
}
|
||||
|
||||
|
||||
def exchange_code(code: str, verifier: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data)
|
||||
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict[str, Any]:
|
||||
data = _post_form(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": CLIENT_ID,
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
)
|
||||
return _record_from_token_response(data, refresh_fallback=refresh_token)
|
||||
|
||||
|
||||
def _access_token(record: dict[str, Any]) -> str:
|
||||
access = record["access"]
|
||||
if not isinstance(access, str) or not access:
|
||||
raise GrokAuthError("bad_response", "stored access token is missing or malformed")
|
||||
return access
|
||||
|
||||
|
||||
def _near_expiry(record: dict[str, Any]) -> bool:
|
||||
expires_at = record.get("expires_at")
|
||||
if not isinstance(expires_at, int | float):
|
||||
return True
|
||||
return expires_at - _EXPIRY_SKEW_S <= time.time()
|
||||
|
||||
|
||||
def get_valid_token() -> str:
|
||||
"""Return a valid access token, refreshing under the cross-process guard if
|
||||
near expiry."""
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise GrokAuthError("not_authenticated", "not signed in; run: strix auth login grok")
|
||||
if not _near_expiry(record):
|
||||
return _access_token(record)
|
||||
with _refresh_guard():
|
||||
record = read_record()
|
||||
if record is None:
|
||||
raise GrokAuthError("not_authenticated", "not signed in; run: strix auth login grok")
|
||||
if not _near_expiry(record):
|
||||
return _access_token(record)
|
||||
try:
|
||||
refreshed = refresh_tokens(record["refresh"])
|
||||
except GrokAuthError:
|
||||
# A peer process may have already spent this single-use refresh token.
|
||||
latest = read_record()
|
||||
if latest and latest["refresh"] != record["refresh"] and not _near_expiry(latest):
|
||||
return _access_token(latest)
|
||||
raise
|
||||
save_record(refreshed)
|
||||
return _access_token(refreshed)
|
||||
|
||||
|
||||
def build_openai_client() -> AsyncOpenAI:
|
||||
"""An ``AsyncOpenAI`` for xAI's API. A per-request hook re-stamps a fresh
|
||||
bearer token so long scans survive token expiry."""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
get_valid_token() # fail fast at configure time if the sign-in is dead
|
||||
|
||||
async def _auth_hook(request: httpx.Request) -> None:
|
||||
access = await asyncio.to_thread(get_valid_token)
|
||||
request.headers["Authorization"] = f"Bearer {access}"
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
event_hooks={"request": [_auth_hook]},
|
||||
)
|
||||
return AsyncOpenAI(
|
||||
api_key="strix-grok-oauth", # placeholder; the hook overwrites Authorization
|
||||
base_url=XAI_BASE_URL,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
|
||||
_subscription_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_subscription_client() -> AsyncOpenAI:
|
||||
global _subscription_client # noqa: PLW0603
|
||||
if _subscription_client is None:
|
||||
_subscription_client = build_openai_client()
|
||||
return _subscription_client
|
||||
|
||||
|
||||
SUBSCRIPTION_PREFIX = "grok/"
|
||||
|
||||
|
||||
def subscription_model(model_name: str | None) -> str | None:
|
||||
"""The model slug behind a ``grok/<model>`` STRIX_LLM, or None."""
|
||||
name = (model_name or "").strip()
|
||||
if not name.lower().startswith(SUBSCRIPTION_PREFIX):
|
||||
return None
|
||||
return name[len(SUBSCRIPTION_PREFIX) :] or None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if subscription_model(model_name) else "api_key"
|
||||
+11
-12
@@ -20,7 +20,6 @@ from agents.model_settings import ModelSettings
|
||||
from agents.models.fake_id import FAKE_RESPONSES_ID
|
||||
from agents.models.interface import Model
|
||||
from agents.models.multi_provider import MultiProvider
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
from agents.models.openai_responses import OpenAIResponsesModel
|
||||
from agents.retry import (
|
||||
ModelRetryBackoffSettings,
|
||||
@@ -37,7 +36,7 @@ from openai.types.responses import (
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config import codex, grok
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
|
||||
from strix.config.tool_call_limits import TurnToolCallLimiter
|
||||
@@ -482,10 +481,6 @@ class StrixProvider(MultiProvider):
|
||||
codex.get_subscription_client(),
|
||||
reasoning_effort=llm.reasoning_effort,
|
||||
)
|
||||
elif grok_slug := grok.subscription_model(model_name):
|
||||
# xAI's API is OpenAI chat-completions compatible; the subscription
|
||||
# bearer is stamped per-request by the client's auth hook.
|
||||
model = OpenAIChatCompletionsModel(grok_slug, grok.get_subscription_client())
|
||||
else:
|
||||
model = super().get_model(model_name)
|
||||
if llm.disable_streaming:
|
||||
@@ -561,7 +556,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
||||
"""Apply Strix config to SDK-native defaults."""
|
||||
llm = settings.llm
|
||||
set_tracing_disabled(True)
|
||||
if codex.subscription_model(llm.model) or grok.subscription_model(llm.model):
|
||||
if codex.subscription_model(llm.model):
|
||||
return
|
||||
_configure_litellm_compatibility()
|
||||
_configure_openrouter_attribution(llm.model)
|
||||
@@ -657,27 +652,31 @@ def _install_openrouter_stream_cost_capture() -> None:
|
||||
litellm.OpenrouterConfig = _StrixOpenrouterConfig # type: ignore[misc]
|
||||
|
||||
|
||||
_OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
OPENROUTER_ATTRIBUTION_HEADERS = {
|
||||
"HTTP-Referer": "https://strix.ai",
|
||||
"X-Title": "Strix",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
|
||||
|
||||
def is_openrouter_model(model_name: str | None) -> bool:
|
||||
return bool(model_name) and "openrouter/" in (model_name or "").strip().lower()
|
||||
|
||||
|
||||
def _configure_openrouter_attribution(model_name: str | None) -> None:
|
||||
import litellm
|
||||
|
||||
current: object = litellm.headers
|
||||
existing: dict[str, str] = current if isinstance(current, dict) else {}
|
||||
if not model_name or "openrouter/" not in model_name.strip().lower():
|
||||
if any(key in existing for key in _OPENROUTER_ATTRIBUTION_HEADERS):
|
||||
if not is_openrouter_model(model_name):
|
||||
if any(key in existing for key in OPENROUTER_ATTRIBUTION_HEADERS):
|
||||
remaining = {
|
||||
k: v for k, v in existing.items() if k not in _OPENROUTER_ATTRIBUTION_HEADERS
|
||||
k: v for k, v in existing.items() if k not in OPENROUTER_ATTRIBUTION_HEADERS
|
||||
}
|
||||
litellm.headers = remaining or None # type: ignore[assignment]
|
||||
return
|
||||
|
||||
litellm.headers = {**existing, **_OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
litellm.headers = {**existing, **OPENROUTER_ATTRIBUTION_HEADERS} # type: ignore[assignment]
|
||||
|
||||
|
||||
def _configure_extra_headers(llm: LlmSettings) -> None:
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Shared helpers across model-subscription providers (ChatGPT/Codex and Grok).
|
||||
|
||||
Each provider module (:mod:`strix.config.codex`, :mod:`strix.config.grok`)
|
||||
exposes the same small surface — ``subscription_model``, ``auth_mode``,
|
||||
``is_authenticated`` — so callers that only care "is this run on a subscription,
|
||||
and which provider?" can stay provider-agnostic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from strix.config import codex, grok
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
_PROVIDERS: tuple[ModuleType, ...] = (codex, grok)
|
||||
|
||||
# Human-facing provider names keyed by each module's ``PROVIDER`` constant.
|
||||
_DISPLAY_NAMES: dict[str, str] = {codex.PROVIDER: "ChatGPT", grok.PROVIDER: "Grok"}
|
||||
|
||||
|
||||
def provider_for_model(model_name: str | None) -> ModuleType | None:
|
||||
"""Return the subscription provider module that owns ``model_name``'s prefix,
|
||||
or None when the model isn't a subscription model."""
|
||||
for provider in _PROVIDERS:
|
||||
if provider.subscription_model(model_name):
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
def auth_mode(model_name: str | None) -> str:
|
||||
return "subscription" if provider_for_model(model_name) is not None else "api_key"
|
||||
|
||||
|
||||
def provider_label(model_name: str | None) -> str | None:
|
||||
"""Human-facing name of the subscription provider for ``model_name`` (e.g.
|
||||
"ChatGPT" or "Grok"), or None when the model isn't a subscription model."""
|
||||
provider = provider_for_model(model_name)
|
||||
if provider is None:
|
||||
return None
|
||||
return _DISPLAY_NAMES.get(provider.PROVIDER)
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Shared on-disk store for subscription OAuth credentials.
|
||||
|
||||
Every subscription provider (ChatGPT/Codex, Grok) keeps its record under its own
|
||||
key in a single ``~/.strix/subscription-auth.json`` file. Reads and writes go
|
||||
through here so that:
|
||||
|
||||
* tokens are written owner-only (mode 0600) from the moment the file is created,
|
||||
never briefly exposed with umask-derived permissions, and
|
||||
* concurrent read-modify-write mutations — even across different providers or
|
||||
processes — are serialized, so one provider's update can't clobber another's.
|
||||
|
||||
The lock is reentrant, so a provider may nest a ``save`` inside a longer
|
||||
``guard`` (e.g. refreshing a token then persisting it) without deadlocking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from io import TextIOWrapper
|
||||
|
||||
|
||||
class StoreLockError(RuntimeError):
|
||||
"""The cross-process store lock could not be acquired.
|
||||
|
||||
Raised instead of silently proceeding, so a read-modify-write never runs
|
||||
unlocked (which would let concurrent provider logins/refreshes/logouts race).
|
||||
"""
|
||||
|
||||
|
||||
def read(path: Path) -> dict[str, Any]:
|
||||
"""The store's contents, or an empty dict when absent/unreadable."""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def write(path: Path, data: dict[str, Any]) -> None:
|
||||
"""Atomically replace the store, owner-only from creation.
|
||||
|
||||
The temp file is created with a random name via ``mkstemp`` (mode 0600, no
|
||||
symlink following), so a local attacker can't pre-plant a symlink at a
|
||||
predictable path to divert the token write.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
tmp.replace(path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
raise
|
||||
with contextlib.suppress(OSError):
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
class _StoreLock:
|
||||
"""A reentrant lock serializing store mutations within (thread lock) and
|
||||
across (flock) Strix processes. Nesting reuses the single held file lock, so
|
||||
a provider can persist a record inside a longer critical section."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread_lock = threading.RLock()
|
||||
self._flock_handle: TextIOWrapper | None = None
|
||||
self._depth = 0
|
||||
|
||||
@contextlib.contextmanager
|
||||
def hold(self, path: Path) -> Iterator[None]:
|
||||
with self._thread_lock:
|
||||
if self._depth == 0:
|
||||
self._flock_handle = _acquire_flock(path)
|
||||
self._depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._depth -= 1
|
||||
if self._depth == 0:
|
||||
self._release_flock()
|
||||
|
||||
def _release_flock(self) -> None:
|
||||
handle = self._flock_handle
|
||||
self._flock_handle = None
|
||||
if handle is None:
|
||||
return
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
with contextlib.suppress(OSError):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
except ImportError:
|
||||
pass
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
|
||||
_store_lock = _StoreLock()
|
||||
|
||||
|
||||
def guard(path: Path) -> contextlib.AbstractContextManager[None]:
|
||||
"""Serialize store mutation across threads and processes (reentrant)."""
|
||||
return _store_lock.hold(path)
|
||||
|
||||
|
||||
def _acquire_flock(path: Path) -> TextIOWrapper:
|
||||
"""Hold an exclusive cross-process lock on the store, or raise.
|
||||
|
||||
Never returns without the lock held: a missing ``fcntl`` or a failed
|
||||
``flock`` raises :class:`StoreLockError` so the caller aborts rather than
|
||||
mutating the store unlocked.
|
||||
"""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError as exc: # pragma: no cover - non-POSIX
|
||||
msg = "cross-process credential locking requires fcntl (a POSIX platform)"
|
||||
raise StoreLockError(msg) from exc
|
||||
lock_path = path.with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# O_NOFOLLOW rejects a pre-positioned symlink at the predictable lock path
|
||||
# (so an attacker can't redirect the open), and no O_TRUNC since the lock
|
||||
# file is only an flock anchor whose contents we never use.
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
except OSError as exc:
|
||||
msg = f"could not open lock file {lock_path}: {exc}"
|
||||
raise StoreLockError(msg) from exc
|
||||
handle = os.fdopen(fd, "r+")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
break
|
||||
except InterruptedError: # EINTR — retry the blocking acquire
|
||||
continue
|
||||
except OSError as exc:
|
||||
handle.close()
|
||||
msg = f"could not lock {lock_path}: {exc}"
|
||||
raise StoreLockError(msg) from exc
|
||||
return handle
|
||||
@@ -830,7 +830,7 @@ async def _append_tool_required_message(
|
||||
"execution and never hands control to the user: it is shown to the user, and the "
|
||||
"run continues. Continue immediately and call exactly one tool. "
|
||||
"If you have something to tell the user and nothing to do until they reply, "
|
||||
"call respond_to_user. "
|
||||
"call respond_to_user — with no message if you have already said it. "
|
||||
"If you are blocked waiting for another agent, call wait_for_agents. "
|
||||
f"If the whole engagement is complete, call {finish_tool}. "
|
||||
"Otherwise use the appropriate execution or planning tool. "
|
||||
|
||||
+26
-2
@@ -10,10 +10,12 @@ from openai.types.shared import Reasoning
|
||||
|
||||
from strix.config.models import (
|
||||
DEFAULT_MODEL_RETRY,
|
||||
OPENROUTER_ATTRIBUTION_HEADERS,
|
||||
bedrock_route_supports_prompt_caching,
|
||||
is_bedrock_route,
|
||||
is_claude_model,
|
||||
is_known_openai_bare_model,
|
||||
is_openrouter_model,
|
||||
model_supports_reasoning,
|
||||
request_timeout_extra_args,
|
||||
)
|
||||
@@ -138,6 +140,15 @@ def build_root_task(scan_config: dict[str, Any]) -> str:
|
||||
"target to assess: the instructions below are the only source of "
|
||||
"truth for what to do."
|
||||
)
|
||||
elif not parts and user_instructions:
|
||||
# Neither a target nor a directory, but there is an instruction: the user
|
||||
# declined the mount, so the instruction is all there is. Say so, or the
|
||||
# agent goes looking for a scope that was never given.
|
||||
parts.append(
|
||||
"\n\nNo scan target and no working directory were provided. The "
|
||||
"instructions below are the only source of truth for what to do; "
|
||||
"work from them and from what you can reach yourself."
|
||||
)
|
||||
|
||||
parts.extend(_render_diff_scope(diff_scope))
|
||||
|
||||
@@ -192,13 +203,15 @@ def make_model_settings(
|
||||
request_timeout: float | None = None,
|
||||
prompt_cache: bool = True,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
has_tools: bool = True,
|
||||
) -> ModelSettings:
|
||||
headers = _request_headers(model_name, extra_headers)
|
||||
model_settings = ModelSettings(
|
||||
parallel_tool_calls=False,
|
||||
parallel_tool_calls=False if has_tools else None,
|
||||
retry=DEFAULT_MODEL_RETRY,
|
||||
include_usage=True,
|
||||
extra_args=request_timeout_extra_args(request_timeout),
|
||||
extra_headers=dict(extra_headers) if extra_headers else None,
|
||||
extra_headers=headers,
|
||||
)
|
||||
if (
|
||||
reasoning_effort is not None
|
||||
@@ -221,6 +234,17 @@ def make_model_settings(
|
||||
return model_settings
|
||||
|
||||
|
||||
def _request_headers(
|
||||
model_name: str, extra_headers: dict[str, str] | None
|
||||
) -> dict[str, str] | None:
|
||||
headers: dict[str, str] = {}
|
||||
if is_openrouter_model(model_name):
|
||||
headers.update(OPENROUTER_ATTRIBUTION_HEADERS)
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return headers or None
|
||||
|
||||
|
||||
def _reasoning_settings(
|
||||
effort: ReasoningEffort,
|
||||
extra_args: dict[str, Any] | None,
|
||||
|
||||
+12
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
@@ -429,7 +430,6 @@ async def run_strix_scan(
|
||||
except BudgetExceededError as exc:
|
||||
logger.info("Scan %s stopped: %s", scan_id, exc)
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
return None
|
||||
@@ -442,19 +442,28 @@ async def run_strix_scan(
|
||||
scan_id,
|
||||
)
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "stopped")
|
||||
return None
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
logger.info("Scan %s interrupted by the user", scan_id)
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "running")
|
||||
raise
|
||||
except BaseException:
|
||||
logger.exception("Strix scan %s failed", scan_id)
|
||||
if root_id is not None:
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.set_status(root_id, "failed")
|
||||
raise
|
||||
finally:
|
||||
configure_spill_writer(None)
|
||||
# Settle descendants before closing sessions: on a clean finish a child
|
||||
# can still be mid-turn, and closing its session underneath it crashes it.
|
||||
if root_id is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await coordinator.cancel_descendants(root_id)
|
||||
for s in sessions_to_close:
|
||||
with contextlib.suppress(Exception):
|
||||
s.close()
|
||||
|
||||
+20
-2
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
@@ -12,7 +14,7 @@ from agents.memory import SQLiteSession
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from agents.items import TResponseInputItem
|
||||
@@ -22,9 +24,25 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _PooledConnectionSession(SQLiteSession):
|
||||
@contextmanager
|
||||
def _locked_connection(self) -> Iterator[sqlite3.Connection]:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("SQLiteSession is closed")
|
||||
if self._is_memory_db:
|
||||
yield self._shared_connection
|
||||
return
|
||||
connection = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
try:
|
||||
yield connection
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def open_agent_session(agent_id: str, path: Path) -> SQLiteSession:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return SQLiteSession(session_id=agent_id, db_path=path)
|
||||
return _PooledConnectionSession(session_id=agent_id, db_path=path)
|
||||
|
||||
|
||||
async def seed_initial_input(session: Session, initial_input: Any) -> bool:
|
||||
|
||||
+69
-168
@@ -1,8 +1,8 @@
|
||||
"""`strix auth` — model-subscription sign-in (login / status / logout).
|
||||
"""`strix auth` — ChatGPT subscription sign-in (login / status / logout).
|
||||
|
||||
Signing in only stores OAuth tokens (``~/.strix/subscription-auth.json``); model
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on a
|
||||
ChatGPT subscription and a ``grok/<model>`` one on a Grok/SuperGrok subscription.
|
||||
selection stays with ``STRIX_LLM``. A ``chatgpt/<model>`` STRIX_LLM runs on the
|
||||
subscription.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +12,6 @@ import base64
|
||||
import logging
|
||||
import threading
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -22,76 +21,24 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, grok, load_settings, subscription_store
|
||||
from strix.config import codex, load_settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CALLBACK_TIMEOUT_S = 300
|
||||
|
||||
# CLI-facing name for the login provider. Internally this is the Codex OAuth
|
||||
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
|
||||
# command and messaging say. ``codex`` is accepted as an alias.
|
||||
LOGIN_PROVIDER = "chatgpt"
|
||||
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Provider:
|
||||
"""A model-subscription provider the ``strix auth`` command can sign into.
|
||||
|
||||
``module`` is the provider's OAuth module (:mod:`strix.config.codex` or
|
||||
:mod:`strix.config.grok`); both expose the same login surface. ``error`` is
|
||||
that module's auth-error class, caught to report a clean failure.
|
||||
"""
|
||||
|
||||
name: str
|
||||
module: ModuleType
|
||||
error: type[Exception]
|
||||
display: str
|
||||
example_model: str
|
||||
blurb: str
|
||||
|
||||
|
||||
_PROVIDERS: dict[str, _Provider] = {
|
||||
"chatgpt": _Provider(
|
||||
name="chatgpt",
|
||||
module=codex,
|
||||
error=codex.CodexAuthError,
|
||||
display="ChatGPT",
|
||||
example_model="chatgpt/gpt-5.4",
|
||||
blurb="This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.",
|
||||
),
|
||||
"grok": _Provider(
|
||||
name="grok",
|
||||
module=grok,
|
||||
error=grok.GrokAuthError,
|
||||
display="Grok",
|
||||
example_model="grok/grok-4",
|
||||
blurb="This uses your Grok/SuperGrok plan for inference instead of a metered API key.",
|
||||
),
|
||||
}
|
||||
|
||||
# Internal OAuth provider ids and common vendor names accepted as aliases.
|
||||
_PROVIDER_ALIASES: dict[str, str] = {
|
||||
codex.PROVIDER: "chatgpt",
|
||||
grok.PROVIDER: "grok",
|
||||
"xai": "grok",
|
||||
"supergrok": "grok",
|
||||
}
|
||||
|
||||
_DEFAULT_PROVIDER = "chatgpt"
|
||||
|
||||
_USAGE = (
|
||||
"Usage:\n"
|
||||
" strix auth login [chatgpt|grok] [--manual]\n"
|
||||
" strix auth status\n"
|
||||
" strix auth logout [chatgpt|grok]"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_provider(name: str) -> _Provider | None:
|
||||
key = _PROVIDER_ALIASES.get(name.lower(), name.lower())
|
||||
return _PROVIDERS.get(key)
|
||||
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||
|
||||
|
||||
def run_auth(argv: list[str]) -> int:
|
||||
@@ -102,20 +49,20 @@ def run_auth(argv: list[str]) -> int:
|
||||
rest = argv[1:]
|
||||
|
||||
if subcommand in ("-h", "--help", "help"):
|
||||
console.print(_USAGE, markup=False)
|
||||
console.print(_USAGE)
|
||||
return 0
|
||||
|
||||
handlers: dict[str, Callable[[], int]] = {
|
||||
"login": lambda: _login(console, rest),
|
||||
"status": lambda: _status(console),
|
||||
"logout": lambda: _logout(console, rest),
|
||||
"logout": lambda: _logout(console),
|
||||
}
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is not None:
|
||||
return handler()
|
||||
|
||||
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
|
||||
console.print(_USAGE, markup=False)
|
||||
console.print(_USAGE)
|
||||
return 2
|
||||
|
||||
|
||||
@@ -124,8 +71,8 @@ def _login(console: Console, argv: list[str]) -> int:
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=_DEFAULT_PROVIDER,
|
||||
help="Model provider to sign in with (chatgpt or grok; default: chatgpt).",
|
||||
default=LOGIN_PROVIDER,
|
||||
help="Model provider to sign in with (default: chatgpt).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manual",
|
||||
@@ -137,42 +84,39 @@ def _login(console: Console, argv: list[str]) -> int:
|
||||
except SystemExit as exc: # argparse already printed the message
|
||||
return int(exc.code or 2)
|
||||
|
||||
provider = _resolve_provider(args.provider)
|
||||
if provider is None:
|
||||
supported = ", ".join(f"'{name}'" for name in _PROVIDERS)
|
||||
console.print(f"[red]Unsupported provider:[/] {args.provider}. Supported: {supported}.")
|
||||
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
|
||||
console.print(
|
||||
f"[red]Unsupported provider:[/] {args.provider}. "
|
||||
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
|
||||
)
|
||||
return 2
|
||||
|
||||
module = provider.module
|
||||
verifier, challenge = module.generate_pkce()
|
||||
state = module.create_state()
|
||||
authorize_url = module.build_authorize_url(challenge, state)
|
||||
verifier, challenge = codex.generate_pkce()
|
||||
state = codex.create_state()
|
||||
authorize_url = codex.build_authorize_url(challenge, state)
|
||||
|
||||
console.print()
|
||||
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
|
||||
console.print(
|
||||
f"[bold]Signing in with {provider.display}[/] [dim](provider: {provider.name})[/]"
|
||||
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||
)
|
||||
console.print(f"[dim]{provider.blurb}[/]")
|
||||
console.print()
|
||||
|
||||
try:
|
||||
record = _run_oauth_flow(
|
||||
console, provider, authorize_url, verifier, state, manual=args.manual
|
||||
)
|
||||
except provider.error as exc:
|
||||
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
|
||||
except codex.CodexAuthError as exc:
|
||||
return _fail(console, exc)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Sign-in cancelled.[/]")
|
||||
return 130
|
||||
|
||||
module.save_record(record)
|
||||
_print_success(console, provider)
|
||||
codex.save_record(record)
|
||||
_print_success(console)
|
||||
return 0
|
||||
|
||||
|
||||
def _run_oauth_flow(
|
||||
console: Console,
|
||||
provider: _Provider,
|
||||
authorize_url: str,
|
||||
verifier: str,
|
||||
state: str,
|
||||
@@ -180,10 +124,7 @@ def _run_oauth_flow(
|
||||
manual: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||
module = provider.module
|
||||
server = (
|
||||
None if manual else _try_start_callback_server(module.CALLBACK_PORT, module.CALLBACK_PATH)
|
||||
)
|
||||
server = None if manual else _try_start_callback_server()
|
||||
|
||||
console.print("Open this URL in your browser to authorize:")
|
||||
console.print(f"[cyan]{authorize_url}[/]")
|
||||
@@ -201,8 +142,8 @@ def _run_oauth_flow(
|
||||
if result is not None:
|
||||
code, returned_state, error = result
|
||||
if error:
|
||||
raise provider.error("oauth_error", error)
|
||||
return _finish(provider, code, returned_state, verifier, state, require_state=True)
|
||||
raise codex.CodexAuthError("oauth_error", error)
|
||||
return _finish(code, returned_state, verifier, state, require_state=True)
|
||||
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
|
||||
|
||||
# Manual fallback: the user completes sign-in and pastes the redirect URL
|
||||
@@ -212,13 +153,12 @@ def _run_oauth_flow(
|
||||
try:
|
||||
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||
except EOFError as exc:
|
||||
raise provider.error("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = module.parse_redirect_input(pasted)
|
||||
return _finish(provider, code, returned_state, verifier, state, require_state=False)
|
||||
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
|
||||
code, returned_state = codex.parse_redirect_input(pasted)
|
||||
return _finish(code, returned_state, verifier, state, require_state=False)
|
||||
|
||||
|
||||
def _finish(
|
||||
provider: _Provider,
|
||||
code: str | None,
|
||||
returned_state: str | None,
|
||||
verifier: str,
|
||||
@@ -227,17 +167,16 @@ def _finish(
|
||||
require_state: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not code:
|
||||
raise provider.error("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from the provider always carries state, so a missing
|
||||
# or mismatched value there is forged (CSRF) and must be rejected. Manual
|
||||
# paste is user-initiated (the user copies their own redirect), so state is
|
||||
# only validated when the pasted value includes it.
|
||||
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
|
||||
# The loopback callback from OpenAI always carries state, so a missing or
|
||||
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
|
||||
# is user-initiated (the user copies their own redirect), so state is only
|
||||
# validated when the pasted value includes it.
|
||||
if require_state and returned_state is None:
|
||||
raise provider.error("state_mismatch", "missing state in callback; possible CSRF")
|
||||
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||
if returned_state is not None and returned_state != expected_state:
|
||||
raise provider.error("state_mismatch", "state did not match; possible CSRF")
|
||||
record: dict[str, Any] = provider.module.exchange_code(code, verifier)
|
||||
return record
|
||||
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||
return codex.exchange_code(code, verifier)
|
||||
|
||||
|
||||
class _CallbackServer:
|
||||
@@ -264,7 +203,7 @@ class _CallbackServer:
|
||||
self._httpd.server_close()
|
||||
|
||||
|
||||
def _try_start_callback_server(port: int, path: str) -> _CallbackServer | None:
|
||||
def _try_start_callback_server() -> _CallbackServer | None:
|
||||
event = threading.Event()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
@@ -274,7 +213,7 @@ def _try_start_callback_server(port: int, path: str) -> _CallbackServer | None:
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != path:
|
||||
if parsed.path != codex.CALLBACK_PATH:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
@@ -291,9 +230,9 @@ def _try_start_callback_server(port: int, path: str) -> _CallbackServer | None:
|
||||
event.set()
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", port), Handler)
|
||||
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||
except OSError:
|
||||
logger.debug("could not bind callback port %d", port, exc_info=True)
|
||||
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||
return None
|
||||
return _CallbackServer(httpd, event, holder)
|
||||
|
||||
@@ -304,67 +243,30 @@ def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||
|
||||
|
||||
def _status(console: Console) -> int:
|
||||
settings = load_settings()
|
||||
active_model = settings.llm.model
|
||||
signed_in_any = False
|
||||
for provider in _PROVIDERS.values():
|
||||
record = provider.module.read_record()
|
||||
if record is None:
|
||||
continue
|
||||
signed_in_any = True
|
||||
console.print(f"[green]Signed in[/] with a {provider.display} subscription.")
|
||||
account_id = record.get("account_id")
|
||||
if account_id:
|
||||
console.print(f" Account: [bold]{account_id}[/]")
|
||||
if provider.module.subscription_model(active_model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{active_model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
f" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. "
|
||||
f"[cyan]{provider.example_model}[/] to run on this subscription."
|
||||
)
|
||||
if not signed_in_any:
|
||||
console.print(
|
||||
"[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] "
|
||||
"or [cyan]strix auth login grok[/] to sign in."
|
||||
)
|
||||
record = codex.read_record()
|
||||
if record is None:
|
||||
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login chatgpt[/] to sign in.")
|
||||
return 1
|
||||
settings = load_settings()
|
||||
console.print("[green]Signed in[/] with a ChatGPT subscription.")
|
||||
console.print(f" Account: [bold]{record.get('account_id')}[/]")
|
||||
if codex.subscription_model(settings.llm.model):
|
||||
console.print(f" Runs use the subscription (STRIX_LLM=[bold]{settings.llm.model}[/]).")
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]Note:[/] set [cyan]STRIX_LLM[/] to e.g. [cyan]chatgpt/gpt-5.4[/] "
|
||||
"to run on the subscription."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _logout(console: Console, argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog="strix auth logout", add_help=True)
|
||||
parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Provider to sign out of (chatgpt or grok; default: all).",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
except SystemExit as exc:
|
||||
return int(exc.code or 2)
|
||||
|
||||
if args.provider is None:
|
||||
# Hold the store lock across every provider so a concurrent save/refresh
|
||||
# can't slip a credential back in between removals (logout-all is atomic).
|
||||
with subscription_store.guard(codex.AUTH_PATH):
|
||||
for provider in _PROVIDERS.values():
|
||||
provider.module.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
target = _resolve_provider(args.provider)
|
||||
if target is None:
|
||||
supported = ", ".join(f"'{name}'" for name in _PROVIDERS)
|
||||
console.print(f"[red]Unsupported provider:[/] {args.provider}. Supported: {supported}.")
|
||||
return 2
|
||||
target.module.logout()
|
||||
console.print(f"[green]Signed out of {target.display}.[/] Stored credentials removed.")
|
||||
def _logout(console: Console) -> int:
|
||||
codex.logout()
|
||||
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||
return 0
|
||||
|
||||
|
||||
def _fail(console: Console, exc: Exception) -> int:
|
||||
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
|
||||
error_text = Text()
|
||||
error_text.append("SIGN-IN FAILED", style="bold red")
|
||||
error_text.append("\n\n", style="white")
|
||||
@@ -382,18 +284,17 @@ def _fail(console: Console, exc: Exception) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _print_success(console: Console, provider: _Provider) -> None:
|
||||
prefix = provider.module.SUBSCRIPTION_PREFIX
|
||||
def _print_success(console: Console) -> None:
|
||||
text = Text()
|
||||
text.append(f"Signed in with your {provider.display} subscription", style="bold #22c55e")
|
||||
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Set ", style="white")
|
||||
text.append("STRIX_LLM", style="bold white")
|
||||
text.append(" to a ", style="white")
|
||||
text.append(prefix, style="bold cyan")
|
||||
text.append("chatgpt/", style="bold cyan")
|
||||
text.append(" model (e.g. ", style="white")
|
||||
text.append(provider.example_model, style="bold cyan")
|
||||
text.append(f") — runs are billed to your {provider.display} plan.", style="white")
|
||||
text.append("chatgpt/gpt-5.4", style="bold cyan")
|
||||
text.append(") — runs are billed to your ChatGPT plan.", style="white")
|
||||
text.append("\n\n", style="white")
|
||||
text.append("Run a scan as usual, e.g. ", style="white")
|
||||
text.append("strix --target https://example.com", style="bold cyan")
|
||||
|
||||
@@ -328,10 +328,11 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
||||
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
||||
|
||||
args.targets_info = state.get("targets_info") or []
|
||||
# A target-less run has no targets_info at all: it works in a mounted
|
||||
# directory, driven by its instruction.
|
||||
# A target-less run has no targets_info at all. It is driven by its
|
||||
# instruction, over a mounted working directory or over nothing when the
|
||||
# mount was declined, so either of those is enough to resume it.
|
||||
workspace_mount = state.get("workspace_mount") or None
|
||||
if not args.targets_info and not workspace_mount:
|
||||
if not args.targets_info and not workspace_mount and not state.get("user_instruction"):
|
||||
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
||||
|
||||
for target in args.targets_info:
|
||||
|
||||
@@ -8,7 +8,7 @@ from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from strix.config import codex, grok, load_settings
|
||||
from strix.config import codex, load_settings
|
||||
from strix.interface.utils import (
|
||||
check_docker_connection,
|
||||
image_exists,
|
||||
@@ -37,16 +37,6 @@ def validate_environment() -> None:
|
||||
logger.info("Environment OK (ChatGPT subscription)")
|
||||
return
|
||||
|
||||
if grok.subscription_model(settings.llm.model):
|
||||
if not grok.is_authenticated():
|
||||
console.print(
|
||||
f"[red]STRIX_LLM={settings.llm.model} uses your Grok subscription, "
|
||||
"but you're not signed in.[/] Run [cyan]strix auth login grok[/] first."
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Environment OK (Grok subscription)")
|
||||
return
|
||||
|
||||
if not settings.llm.model:
|
||||
missing_required_vars.append("STRIX_LLM")
|
||||
|
||||
|
||||
+2
-10
@@ -224,6 +224,7 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=settings.dedupe.extra_headers,
|
||||
has_tools=False,
|
||||
)
|
||||
if deduper_extra:
|
||||
merged = {**(deduper_settings.extra_args or {}), **deduper_extra}
|
||||
@@ -435,16 +436,7 @@ def main() -> None:
|
||||
start_background_check()
|
||||
if not args.non_interactive and prompt_update_if_available(Console()):
|
||||
if is_binary_install() and sys.platform != "win32":
|
||||
# The PyInstaller onefile bootloader passes its state to the child
|
||||
# process via environment variables; if they leak into the re-exec,
|
||||
# the new binary reuses the old extracted application instead of
|
||||
# unpacking itself, so the pre-update version runs again.
|
||||
env = {
|
||||
key: value
|
||||
for key, value in os.environ.items()
|
||||
if not key.startswith("_PYI_") and key != "_MEIPASS2"
|
||||
}
|
||||
os.execve(sys.executable, sys.argv, env) # noqa: S606 # nosec B606
|
||||
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
||||
sys.exit(0)
|
||||
|
||||
check_docker_installed()
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.config import Settings, load_settings, subscription
|
||||
from strix.config import Settings, codex, load_settings
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.interface.utils import (
|
||||
assign_workspace_subdirs,
|
||||
@@ -78,6 +78,7 @@ async def preflight_model_connection(
|
||||
request_timeout=resolved_settings.llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=resolved_settings.llm.extra_headers,
|
||||
has_tools=False,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
model.get_response(
|
||||
@@ -225,7 +226,7 @@ def telemetry_start(args: argparse.Namespace) -> None:
|
||||
model = load_settings().llm.model
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"auth_mode": subscription.auth_mode(model),
|
||||
"auth_mode": codex.auth_mode(model),
|
||||
"scan_mode": args.scan_mode,
|
||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
||||
"interactive": not args.non_interactive,
|
||||
@@ -240,15 +241,13 @@ def _persist_run_record(args: argparse.Namespace) -> None:
|
||||
|
||||
run_dir = run_dir_for(args.run_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
model = load_settings().llm.model
|
||||
run_record = {
|
||||
"run_id": args.run_name,
|
||||
"run_name": args.run_name,
|
||||
"status": "running",
|
||||
"start_time": datetime.now(UTC).isoformat(),
|
||||
"end_time": None,
|
||||
"auth_mode": subscription.auth_mode(model),
|
||||
"subscription_provider": subscription.provider_label(model),
|
||||
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||
"targets_info": args.targets_info,
|
||||
"scan_mode": args.scan_mode,
|
||||
"instruction": args.instruction,
|
||||
|
||||
@@ -138,13 +138,6 @@ class TuiController:
|
||||
self.error = detail
|
||||
self.notify_changed()
|
||||
|
||||
def enter_setup(self) -> None:
|
||||
"""Return a session to the start screen, e.g. on a declined mount."""
|
||||
self.setup_mode = True
|
||||
self.scan_started = False
|
||||
self.scan_state = "setup"
|
||||
self.notify_changed()
|
||||
|
||||
def add_message(self, text: str, level: str = "info") -> None:
|
||||
self._append_message(text, level)
|
||||
self.notify_changed()
|
||||
@@ -356,14 +349,12 @@ class TuiController:
|
||||
if not isinstance(approved, bool):
|
||||
raise TypeError("approved must be a boolean")
|
||||
self.pending_workspace_mount = None
|
||||
if not approved:
|
||||
# Nothing was prepared, so return to the start screen untouched.
|
||||
self.workspace_mount = None
|
||||
self.enter_setup()
|
||||
return {"approved": False}
|
||||
self.workspace_mount = mount
|
||||
# Declining skips the mount, it does not abandon the scan. The prompt is
|
||||
# the whole of the input either way; the working directory is only an
|
||||
# extra the agent may look at, so the run goes ahead without one.
|
||||
self.workspace_mount = mount if approved else None
|
||||
await self._begin_scan(self._pending_verify)
|
||||
return {"approved": True}
|
||||
return {"approved": approved}
|
||||
|
||||
async def _send_message(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
agent_id = self._required_string(payload, "agent_id")
|
||||
|
||||
@@ -66,13 +66,10 @@ func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// answerMountConfirmation replies to the working-directory mount the backend is
|
||||
// waiting on. Declining returns to the start screen, so the prompt goes back in
|
||||
// the composer to be edited or given a target instead.
|
||||
// waiting on. Either answer starts the scan - declining only means it runs
|
||||
// without the directory - so the prompt stays with the run rather than coming
|
||||
// back to the composer.
|
||||
func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
|
||||
if !approved && m.pendingPrompt != "" {
|
||||
m.input.SetValue(m.pendingPrompt)
|
||||
m.resizeViewport()
|
||||
}
|
||||
m.pendingPrompt = ""
|
||||
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
|
||||
}
|
||||
|
||||
@@ -247,13 +247,10 @@ func TestMountConfirmationAnswers(t *testing.T) {
|
||||
if payload.Approved != tc.approved {
|
||||
t.Fatalf("%s: approved=%v, want %v", tc.name, payload.Approved, tc.approved)
|
||||
}
|
||||
// Declining returns to the start screen, so the prompt comes back.
|
||||
want := ""
|
||||
if !tc.approved {
|
||||
want = "find auth bugs in the login flow"
|
||||
}
|
||||
if got := model.input.Value(); got != want {
|
||||
t.Fatalf("%s: composer = %q, want %q", tc.name, got, want)
|
||||
// Either answer launches, so the prompt stays with the run rather than
|
||||
// coming back to the composer.
|
||||
if got := model.input.Value(); got != "" {
|
||||
t.Fatalf("%s: composer = %q, want it cleared", tc.name, got)
|
||||
}
|
||||
if model.pendingPrompt != "" {
|
||||
t.Fatalf("%s: held prompt was not cleared: %q", tc.name, model.pendingPrompt)
|
||||
@@ -290,3 +287,101 @@ func TestSetupPromptWithTargetLaunches(t *testing.T) {
|
||||
t.Fatalf("setup.start (%d) must come after setup.set_instruction (%d): %v", start, instr, types)
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt's buttons are buttons: clicking Cancel has to answer the backend,
|
||||
// which it could not do while the mouse handler had no case for this modal.
|
||||
func TestMountPromptButtonsAreClickable(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
label string
|
||||
approved bool
|
||||
}{
|
||||
{mountConfirmLabel, true},
|
||||
{mountCancelLabel, false},
|
||||
} {
|
||||
connection := &recordingConn{}
|
||||
model := New(&Client{conn: connection})
|
||||
model.width, model.height = 130, 40
|
||||
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
|
||||
updated, _ := model.submit("find auth bugs in the login flow")
|
||||
model = updated.(Model)
|
||||
connection.Reset()
|
||||
model.snapshot = protocol.Snapshot{
|
||||
ScanStarted: true, ScanState: "preparing", PendingMount: "/Users/me/code/api",
|
||||
}
|
||||
model.syncMountPrompt()
|
||||
|
||||
left, top, panel := model.mountPromptBounds()
|
||||
clicked := false
|
||||
for row, line := range strings.Split(panel, "\n") {
|
||||
plain := ansi.Strip(line)
|
||||
index := strings.Index(plain, testCase.label)
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
updated, cmd := model.updateModalMouse(tea.MouseMsg{
|
||||
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
|
||||
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
|
||||
})
|
||||
model = updated.(Model)
|
||||
envelopes := drainCommands(t, cmd, connection)
|
||||
if len(envelopes) != 1 || envelopes[0].Type != "setup.confirm_mount" {
|
||||
t.Fatalf("clicking %s sent %v", testCase.label, commandTypes(envelopes))
|
||||
}
|
||||
var payload struct {
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
if err := json.Unmarshal(envelopes[0].Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Approved != testCase.approved {
|
||||
t.Fatalf("clicking %s answered approved=%v", testCase.label, payload.Approved)
|
||||
}
|
||||
clicked = true
|
||||
break
|
||||
}
|
||||
if !clicked {
|
||||
t.Fatalf("%s was not found in the prompt", testCase.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skipping the mount runs the scan without a directory. It must not throw the
|
||||
// session back to the start screen, and it must not hand the prompt back: the
|
||||
// run has it.
|
||||
func TestSkippingTheMountKeepsTheScanRunning(t *testing.T) {
|
||||
connection := &recordingConn{}
|
||||
model := New(&Client{conn: connection})
|
||||
model.width, model.height = 130, 40
|
||||
model.snapshot = protocol.Snapshot{SetupMode: true, WorkingDir: "/Users/me/code/api"}
|
||||
updated, _ := model.submit("find auth bugs in the login flow")
|
||||
model = updated.(Model)
|
||||
model.snapshot = protocol.Snapshot{
|
||||
ScanStarted: true, ScanState: "preparing", PendingMount: "/Users/me/code/api",
|
||||
}
|
||||
model.syncMountPrompt()
|
||||
if model.modal != modalConfirmMount {
|
||||
t.Fatal("the prompt did not open")
|
||||
}
|
||||
|
||||
model.modalChoice = 1
|
||||
updated, _ = model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
|
||||
// The backend answers by starting the scan with no mount.
|
||||
model.handleEnvelope(stateEnvelope(t, 2, protocol.Snapshot{
|
||||
ScanStarted: true, ScanState: "running",
|
||||
}))
|
||||
|
||||
if model.modal != modalNone {
|
||||
t.Fatalf("the prompt is still open: %v", model.modal)
|
||||
}
|
||||
if model.snapshot.SetupMode {
|
||||
t.Fatal("skipping the mount fell back to the start screen")
|
||||
}
|
||||
if got := model.input.Value(); got != "" {
|
||||
t.Fatalf("the prompt came back to the composer: %q", got)
|
||||
}
|
||||
if model.pendingPrompt != "" {
|
||||
t.Fatalf("the held prompt was not released: %q", model.pendingPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +474,18 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
m.modalChoice = 1
|
||||
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
}
|
||||
case modalConfirmMount:
|
||||
left, top, panel := m.mountPromptBounds()
|
||||
if labelHitAt(panel, mountConfirmLabel, left, top, msg.X, msg.Y) {
|
||||
m.modalChoice = 0
|
||||
cmd := m.answerMountConfirmation(true)
|
||||
return m, cmd
|
||||
}
|
||||
if labelHitAt(panel, mountCancelLabel, left, top, msg.X, msg.Y) {
|
||||
m.modalChoice = 1
|
||||
cmd := m.answerMountConfirmation(false)
|
||||
return m, cmd
|
||||
}
|
||||
case modalVulnerability:
|
||||
for _, button := range m.reportButtons() {
|
||||
if button == reportCopy || button == reportDone {
|
||||
@@ -507,7 +519,14 @@ func (m Model) centeredViewBounds(view string) (left, top, width, height int) {
|
||||
|
||||
func (m Model) centeredLabelHit(view, label string, x, y int) bool {
|
||||
left, top, _, _ := m.centeredViewBounds(view)
|
||||
for row, line := range strings.Split(view, "\n") {
|
||||
return labelHitAt(view, label, left, top, x, y)
|
||||
}
|
||||
|
||||
// labelHitAt reports whether a click landed on a label drawn in a panel whose
|
||||
// top-left corner is at (left, top). The mount prompt is docked in a corner
|
||||
// rather than centered, so it cannot use the centered bounds.
|
||||
func labelHitAt(panel, label string, left, top, x, y int) bool {
|
||||
for row, line := range strings.Split(panel, "\n") {
|
||||
plain := ansi.Strip(line)
|
||||
index := strings.Index(plain, label)
|
||||
if index < 0 || y != top+row {
|
||||
@@ -593,7 +612,8 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "esc":
|
||||
if m.modal == modalConfirmMount {
|
||||
// The backend is waiting on an answer; escape declines it.
|
||||
return m, m.answerMountConfirmation(false)
|
||||
cmd := m.answerMountConfirmation(false)
|
||||
return m, cmd
|
||||
}
|
||||
m.closeModal()
|
||||
return m, nil
|
||||
@@ -604,7 +624,10 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
modal, choice := m.modal, m.modalChoice
|
||||
if modal == modalConfirmMount {
|
||||
// The snapshot closes this prompt once the backend has the answer.
|
||||
return m, m.answerMountConfirmation(choice == 0)
|
||||
// Bound to a variable first: the call restores the held prompt into
|
||||
// the composer, and that has to be in the model being returned.
|
||||
cmd := m.answerMountConfirmation(choice == 0)
|
||||
return m, cmd
|
||||
}
|
||||
m.closeModal()
|
||||
if choice == 1 {
|
||||
|
||||
@@ -271,6 +271,23 @@ func (m Model) viewInner() string {
|
||||
return m.toastOverlay(main)
|
||||
}
|
||||
|
||||
// mountPromptBounds is where the working-directory prompt is drawn. It is placed
|
||||
// by cornerOverlay rather than centered, so a click has to be tested against
|
||||
// these bounds and not the ones the other modals use.
|
||||
func (m Model) mountPromptBounds() (left, top int, panel string) {
|
||||
panel = m.modalView()
|
||||
if panel == "" {
|
||||
return 0, 0, ""
|
||||
}
|
||||
_, _, chatWidth, _ := m.layout()
|
||||
left = max(0, min(chatWidth, m.width)-lipgloss.Width(panel))
|
||||
statusH := 0
|
||||
if m.statusVisible() {
|
||||
statusH = 1
|
||||
}
|
||||
return left, max(0, m.inputTop()-statusH-lipgloss.Height(panel)), panel
|
||||
}
|
||||
|
||||
// cornerOverlay splices a panel in directly above the composer, right-aligned
|
||||
// with it, leaving the rest of the view visible behind it.
|
||||
func (m Model) cornerOverlay(view, panel string) string {
|
||||
|
||||
@@ -220,6 +220,13 @@ func (m Model) confirmView(title string, width int, border, titleColor lipgloss.
|
||||
return m.confirmDialog(title, "", width, border, titleColor, red, "Yes", "No")
|
||||
}
|
||||
|
||||
// The mount prompt's buttons, named so the renderer and the click test cannot
|
||||
// drift apart.
|
||||
const (
|
||||
mountConfirmLabel = "Mount"
|
||||
mountCancelLabel = "Skip"
|
||||
)
|
||||
|
||||
// mountConfirmView asks before a target-less scan mounts the working directory.
|
||||
// It is a compact prompt docked in the corner of the live view: nothing is
|
||||
// prepared until it is answered, and the directory is a workspace rather than a
|
||||
@@ -232,8 +239,8 @@ func (m Model) mountConfirmView() string {
|
||||
}
|
||||
title := render.Bold(amber).Render("△ Mount working directory?")
|
||||
body := render.Col(white).Render(truncatePath(dir, width-4)) + "\n" +
|
||||
render.Dim().Render("writable in the sandbox")
|
||||
return m.cornerPrompt(title, body, width, "Confirm", "Cancel")
|
||||
render.Dim().Render("writable in the sandbox · skip to run without it")
|
||||
return m.cornerPrompt(title, body, width, mountConfirmLabel, mountCancelLabel)
|
||||
}
|
||||
|
||||
// truncatePath keeps the tail of a path visible, which is the part that
|
||||
|
||||
@@ -262,27 +262,9 @@ def is_subscription_run(report_state: Any) -> bool:
|
||||
record = getattr(report_state, "run_record", None)
|
||||
if isinstance(record, dict) and record.get("auth_mode"):
|
||||
return record.get("auth_mode") == "subscription"
|
||||
from strix.config import subscription
|
||||
from strix.config import codex
|
||||
|
||||
return subscription.auth_mode(load_settings().llm.model) == "subscription"
|
||||
|
||||
|
||||
def _subscription_label(report_state: Any) -> str:
|
||||
"""Human label for the active model subscription (e.g. "Grok subscription").
|
||||
|
||||
Prefers the persisted run record so a resumed run keeps its original provider
|
||||
even if STRIX_LLM later points at a different one; falls back to current
|
||||
settings.
|
||||
"""
|
||||
record = getattr(report_state, "run_record", None)
|
||||
if isinstance(record, dict):
|
||||
provider = record.get("subscription_provider")
|
||||
if isinstance(provider, str) and provider:
|
||||
return f"{provider} subscription"
|
||||
from strix.config import subscription
|
||||
|
||||
label = subscription.provider_label(load_settings().llm.model)
|
||||
return f"{label} subscription" if label else "Subscription"
|
||||
return codex.auth_mode(load_settings().llm.model) == "subscription"
|
||||
|
||||
|
||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
||||
@@ -386,7 +368,7 @@ def build_live_stats_text(report_state: Any) -> Text:
|
||||
stats_text.append(str(model), style="white")
|
||||
if is_subscription_run(report_state):
|
||||
stats_text.append(" · ", style="dim white")
|
||||
stats_text.append(_subscription_label(report_state), style="#22c55e")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
stats_text.append("\n")
|
||||
|
||||
vuln_count = len(report_state.vulnerability_reports)
|
||||
@@ -432,7 +414,7 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
||||
subscription = is_subscription_run(report_state)
|
||||
if subscription:
|
||||
stats_text.append("\n")
|
||||
stats_text.append(_subscription_label(report_state), style="#22c55e")
|
||||
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||
|
||||
usage = _llm_usage(report_state)
|
||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
||||
|
||||
@@ -101,7 +101,6 @@ export function RunDetails({
|
||||
const totalTokens = num(usage.total_tokens);
|
||||
const cost = num(usage.cost);
|
||||
const subscription = str(raw.auth_mode) === "subscription";
|
||||
const subscriptionProvider = str(raw.subscription_provider);
|
||||
|
||||
const sub = (n: number, word: string) => (
|
||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||
@@ -181,7 +180,7 @@ export function RunDetails({
|
||||
<Field label="Provider">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="rounded-full border border-[#22c55e]/40 bg-[#22c55e]/10 px-2 py-0.5 text-[11px] text-[#22c55e]">
|
||||
{subscriptionProvider ? `${subscriptionProvider} subscription` : "Subscription"}
|
||||
ChatGPT subscription
|
||||
</span>
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
+21
-21
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Strix Results</title>
|
||||
<script type="module" crossorigin src="./assets/index-XDX3roAH.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-DBJ-RJqo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DKbLYAbP.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.config import subscription
|
||||
from strix.core.paths import run_record_path
|
||||
from strix.interface.tui.live_view import TuiLiveView
|
||||
|
||||
@@ -58,39 +57,7 @@ def read_run_summary(run_dir: Path) -> dict[str, Any]:
|
||||
record = {}
|
||||
status = record.get("status")
|
||||
finished = status in _TERMINAL_STATUSES and bool(record.get("end_time"))
|
||||
summary = {**record, "finished": finished}
|
||||
_backfill_subscription_provider(summary)
|
||||
return summary
|
||||
|
||||
|
||||
def _first_recorded_model(record: dict[str, Any]) -> str | None:
|
||||
"""The first non-empty per-agent model slug in a run record, or None."""
|
||||
usage = record.get("llm_usage")
|
||||
if not isinstance(usage, dict):
|
||||
return None
|
||||
agents = usage.get("agents")
|
||||
if not isinstance(agents, list):
|
||||
return None
|
||||
for agent in agents:
|
||||
if isinstance(agent, dict):
|
||||
model = agent.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
return model
|
||||
return None
|
||||
|
||||
|
||||
def _backfill_subscription_provider(record: dict[str, Any]) -> None:
|
||||
"""Name the subscription provider for runs recorded before that field
|
||||
existed, deriving it from the recorded ``provider/model`` slug so the viewer
|
||||
labels them correctly without a rescan. Newer runs already carry the field.
|
||||
"""
|
||||
if record.get("subscription_provider"):
|
||||
return
|
||||
if record.get("auth_mode") != "subscription":
|
||||
return
|
||||
label = subscription.provider_label(_first_recorded_model(record))
|
||||
if label:
|
||||
record["subscription_provider"] = label
|
||||
return {**record, "finished": finished}
|
||||
|
||||
|
||||
def primary_target(record: dict[str, Any]) -> str | None:
|
||||
|
||||
@@ -294,6 +294,7 @@ async def _summarize(model: str, prompt: str, max_tokens: int) -> str | None:
|
||||
request_timeout=llm.timeout,
|
||||
prompt_cache=False,
|
||||
extra_headers=llm.extra_headers,
|
||||
has_tools=False,
|
||||
).resolve(ModelSettings(max_tokens=max_tokens))
|
||||
try:
|
||||
response = (
|
||||
|
||||
@@ -62,6 +62,7 @@ def _dedupe_model_settings(
|
||||
# must never receive the main endpoint's credentials. A dedicated model
|
||||
# gets its own DEDUPE_LLM_EXTRA_HEADERS instead.
|
||||
extra_headers=dedupe.extra_headers if dedupe.model else llm.extra_headers,
|
||||
has_tools=False,
|
||||
)
|
||||
extra = _dedupe_extra_args(dedupe)
|
||||
if extra:
|
||||
|
||||
@@ -11,7 +11,7 @@ from uuid import uuid4
|
||||
|
||||
from agents.usage import Usage
|
||||
|
||||
from strix.config import subscription
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.report.sarif import write_sarif
|
||||
@@ -122,8 +122,7 @@ class ReportState:
|
||||
self.scan_results: dict[str, Any] | None = None
|
||||
self.scan_config: dict[str, Any] | None = None
|
||||
self._llm_usage = LLMUsageLedger()
|
||||
model = load_settings().llm.model
|
||||
auth_mode = subscription.auth_mode(model)
|
||||
auth_mode = codex.auth_mode(load_settings().llm.model)
|
||||
self._llm_usage.zero_cost = auth_mode == "subscription"
|
||||
self.run_record: dict[str, Any] = {
|
||||
"run_id": self.run_id,
|
||||
@@ -132,7 +131,6 @@ class ReportState:
|
||||
"end_time": None,
|
||||
"status": "running",
|
||||
"auth_mode": auth_mode,
|
||||
"subscription_provider": subscription.provider_label(model),
|
||||
"targets_info": [],
|
||||
"llm_usage": self._build_llm_usage_record(),
|
||||
}
|
||||
|
||||
@@ -58,6 +58,26 @@ agent-browser screenshot
|
||||
The browser stays running across commands so these feel like a single
|
||||
session. Use `agent-browser close` (or `close --all`) when you're done.
|
||||
|
||||
The default session is **shared with every other agent in the sandbox** — if
|
||||
another agent navigates it, your page and your refs are gone from under you. So
|
||||
claim your own by passing `--session <your-agent-name>` on **every** command:
|
||||
|
||||
```bash
|
||||
agent-browser --session recon-3 open https://example.com
|
||||
agent-browser --session recon-3 snapshot -i
|
||||
agent-browser --session recon-3 close # when done with the target
|
||||
```
|
||||
|
||||
The examples in the rest of this skill omit `--session` to keep them readable;
|
||||
keep passing yours. Each session is a separate Chromium (~340 MB) on a shared
|
||||
box, so hold one rather than several, and close it when you're finished.
|
||||
|
||||
A browser left idle for 3 minutes is reclaimed automatically to free memory for
|
||||
the other agents; the next command relaunches it, but the page, tabs, refs and
|
||||
cookies are gone. If you're authenticated and about to go do something else for a
|
||||
while, save the state first (see
|
||||
[Persist session across runs](#persist-session-across-runs)).
|
||||
|
||||
## Reading a page
|
||||
|
||||
```bash
|
||||
@@ -307,6 +327,16 @@ agent-browser --session b fill @e1 "bob@test.com"
|
||||
`AGENT_BROWSER_SESSION=myapp` sets the default session for the current
|
||||
shell.
|
||||
|
||||
Use a session named after yourself for your own work — that's what keeps a
|
||||
concurrent agent from navigating the page out from under you. Every session is a
|
||||
separate Chromium though, so hold one at a time rather than a collection, and
|
||||
close each one when its flow is finished:
|
||||
|
||||
```bash
|
||||
agent-browser --session a close
|
||||
agent-browser --session b close
|
||||
```
|
||||
|
||||
### Mock network requests
|
||||
|
||||
```bash
|
||||
@@ -368,8 +398,11 @@ agent-browser dialog dismiss # cancel
|
||||
## Readiness & recovery
|
||||
|
||||
The first `agent-browser open` in a session launches the headless-Chrome
|
||||
daemon; later commands reuse it. Distinguish the two failure modes and react
|
||||
differently — do **not** blindly re-run the same failing command in a loop:
|
||||
daemon; later commands reuse it. A daemon left idle for 3 minutes shuts itself
|
||||
down to free memory for the other agents, so an `open` after a long gap is a
|
||||
fresh browser rather than a resumed one — expect to re-navigate, and re-`state
|
||||
load` if you were logged in. Distinguish the failure modes and react differently
|
||||
— do **not** blindly re-run the same failing command in a loop:
|
||||
|
||||
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
|
||||
socket missing, `browser not running`): the daemon isn't up or has died. Run
|
||||
|
||||
@@ -15,7 +15,7 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
||||
|
||||
|
||||
@function_tool
|
||||
async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
|
||||
async def respond_to_user(ctx: RunContextWrapper, message: str = "") -> str:
|
||||
"""Answer the user and hand control back to them.
|
||||
|
||||
This is the ONLY way to yield to the user. Delivering the message and
|
||||
@@ -45,6 +45,10 @@ async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
|
||||
have followed the tool calls that led here. Lead with the
|
||||
answer or the decision you need, and if you are blocked, say
|
||||
exactly what you need from them.
|
||||
|
||||
Omit it when you have just said your piece as plain text and
|
||||
only need to wait: that text has already reached them, and
|
||||
repeating it makes them read the same answer twice.
|
||||
"""
|
||||
inner = _ctx(ctx)
|
||||
coordinator = coordinator_from_context(inner)
|
||||
|
||||
@@ -110,12 +110,19 @@ def _get_agent_todos(agent_id: str) -> dict[str, dict[str, Any]]:
|
||||
|
||||
|
||||
def _normalize_priority(priority: str | None, default: str = "normal") -> str:
|
||||
candidate = (priority or default or "normal").lower()
|
||||
candidate = str(priority or default or "normal").strip().lower()
|
||||
if candidate not in VALID_PRIORITIES:
|
||||
raise ValueError(f"Invalid priority. Must be one of: {', '.join(VALID_PRIORITIES)}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _coerce_priority(priority: str | None, default: str = "normal") -> str:
|
||||
try:
|
||||
return _normalize_priority(priority, default)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _sorted_todos(agent_id: str) -> list[dict[str, Any]]:
|
||||
todos_list = [
|
||||
{**todo, "todo_id": todo_id} for todo_id, todo in _get_agent_todos(agent_id).items()
|
||||
@@ -285,11 +292,16 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
- ``description`` (str, optional): extra context or
|
||||
acceptance criteria.
|
||||
- ``priority`` (str, optional): one of ``"low"`` /
|
||||
``"normal"`` / ``"high"`` / ``"critical"``. Defaults to
|
||||
``"normal"``.
|
||||
``"normal"`` / ``"high"`` / ``"critical"``. Anything else,
|
||||
including omitting it, falls back to ``"normal"`` rather
|
||||
than failing.
|
||||
|
||||
Example: ``[{"title": "Probe /admin", "priority": "high"},
|
||||
{"title": "Check JWT alg=none"}]``.
|
||||
|
||||
A title already on the list, or repeated within this call, is
|
||||
skipped rather than duplicated; skipped titles come back under
|
||||
``skipped``.
|
||||
"""
|
||||
agent_id = _agent_id_from(ctx)
|
||||
try:
|
||||
@@ -302,13 +314,21 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
)
|
||||
|
||||
agent_todos = _get_agent_todos(agent_id)
|
||||
seen = {todo["title"].strip().lower() for todo in agent_todos.values()}
|
||||
created: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for task in tasks:
|
||||
task_priority = _normalize_priority(task.get("priority"))
|
||||
title = task["title"]
|
||||
key = title.lower()
|
||||
if key in seen:
|
||||
skipped.append({"title": title, "reason": "duplicate title"})
|
||||
continue
|
||||
seen.add(key)
|
||||
task_priority = _coerce_priority(task.get("priority"))
|
||||
todo_id = str(uuid.uuid4())[:6]
|
||||
timestamp = datetime.now(UTC).isoformat()
|
||||
agent_todos[todo_id] = {
|
||||
"title": task["title"],
|
||||
"title": title,
|
||||
"description": task.get("description"),
|
||||
"priority": task_priority,
|
||||
"status": "pending",
|
||||
@@ -316,7 +336,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
"updated_at": timestamp,
|
||||
"completed_at": None,
|
||||
}
|
||||
created.append({"todo_id": todo_id, "title": task["title"], "priority": task_priority})
|
||||
created.append({"todo_id": todo_id, "title": title, "priority": task_priority})
|
||||
except (ValueError, TypeError) as e:
|
||||
return json.dumps(
|
||||
{"success": False, "error": f"Failed to create todo: {e}"},
|
||||
@@ -330,6 +350,7 @@ async def create_todo(ctx: RunContextWrapper, todos: str) -> str:
|
||||
"success": True,
|
||||
"created": created,
|
||||
"created_count": len(created),
|
||||
"skipped": skipped,
|
||||
"todos": _sorted_todos(agent_id),
|
||||
"total_count": len(_get_agent_todos(agent_id)),
|
||||
},
|
||||
|
||||
@@ -70,7 +70,6 @@ async def test_encoded_list_is_decoded_for_an_array_parameter(schema: dict[str,
|
||||
"auth",
|
||||
"Endpoint /admin leaks user data, and session tokens never expire",
|
||||
'"auth"',
|
||||
"",
|
||||
],
|
||||
)
|
||||
async def test_free_form_strings_are_never_split_into_an_array(value: str) -> None:
|
||||
@@ -79,6 +78,29 @@ async def test_free_form_strings_are_never_split_into_an_array(value: str) -> No
|
||||
assert parsed["tags"] == value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("schema", [_ARRAY, _NULLABLE_ARRAY])
|
||||
@pytest.mark.parametrize("value", ["", " "])
|
||||
async def test_empty_string_becomes_an_empty_array(schema: dict[str, Any], value: str) -> None:
|
||||
parsed = await _roundtrip(schema, {"tags": value})
|
||||
|
||||
assert parsed["tags"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_becomes_an_empty_object() -> None:
|
||||
parsed = await _roundtrip(_OBJECT, {"modifications": ""})
|
||||
|
||||
assert parsed["modifications"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_string_for_a_string_parameter_is_untouched() -> None:
|
||||
parsed = await _roundtrip(_STRING, {"todos": ""})
|
||||
|
||||
assert parsed["todos"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encoded_mapping_is_decoded_for_an_object_parameter() -> None:
|
||||
parsed = await _roundtrip(_OBJECT, {"modifications": '{"method": "POST"}'})
|
||||
|
||||
+16
-52
@@ -6,34 +6,23 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import codex, grok
|
||||
from strix.config import codex
|
||||
from strix.interface import auth_cli
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_CHATGPT = auth_cli._PROVIDERS["chatgpt"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = tmp_path / "home" / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(codex, "AUTH_PATH", store)
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
||||
monkeypatch.setattr(codex, "AUTH_PATH", tmp_path / "home" / ".strix" / "subscription-auth.json")
|
||||
|
||||
|
||||
def test_default_provider_is_chatgpt() -> None:
|
||||
assert auth_cli._DEFAULT_PROVIDER == "chatgpt"
|
||||
assert set(auth_cli._PROVIDERS) == {"chatgpt", "grok"}
|
||||
|
||||
|
||||
def test_provider_aliases_resolve() -> None:
|
||||
assert auth_cli._resolve_provider(codex.PROVIDER) is _CHATGPT
|
||||
assert auth_cli._resolve_provider("ChatGPT") is _CHATGPT
|
||||
assert auth_cli._resolve_provider("grok") is auth_cli._PROVIDERS["grok"]
|
||||
assert auth_cli._resolve_provider("xai") is auth_cli._PROVIDERS["grok"]
|
||||
assert auth_cli._resolve_provider("gemini") is None
|
||||
def test_login_provider_is_chatgpt() -> None:
|
||||
assert auth_cli.LOGIN_PROVIDER == "chatgpt"
|
||||
assert codex.PROVIDER in auth_cli._ACCEPTED_PROVIDERS
|
||||
assert "chatgpt" in auth_cli._ACCEPTED_PROVIDERS
|
||||
|
||||
|
||||
def test_unknown_subcommand_returns_usage_error() -> None:
|
||||
@@ -62,32 +51,32 @@ def test_finish_requires_state_on_loopback(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
|
||||
# Loopback (require_state=True): missing or mismatched state is rejected.
|
||||
with pytest.raises(codex.CodexAuthError) as missing:
|
||||
auth_cli._finish(_CHATGPT, "code", None, "verifier", "expected", require_state=True)
|
||||
auth_cli._finish("code", None, "verifier", "expected", require_state=True)
|
||||
assert missing.value.code == "state_mismatch"
|
||||
with pytest.raises(codex.CodexAuthError) as mismatch:
|
||||
auth_cli._finish(_CHATGPT, "code", "wrong", "verifier", "expected", require_state=True)
|
||||
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=True)
|
||||
assert mismatch.value.code == "state_mismatch"
|
||||
|
||||
# Matching state proceeds to the exchange.
|
||||
assert auth_cli._finish(
|
||||
_CHATGPT, "code", "expected", "verifier", "expected", require_state=True
|
||||
) == {"ok": True}
|
||||
assert auth_cli._finish("code", "expected", "verifier", "expected", require_state=True) == {
|
||||
"ok": True
|
||||
}
|
||||
|
||||
|
||||
def test_finish_manual_paste_allows_absent_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
|
||||
# Manual paste (require_state=False): a bare code with no state is accepted,
|
||||
# but a present-and-wrong state is still rejected.
|
||||
assert auth_cli._finish(
|
||||
_CHATGPT, "code", None, "verifier", "expected", require_state=False
|
||||
) == {"ok": True}
|
||||
assert auth_cli._finish("code", None, "verifier", "expected", require_state=False) == {
|
||||
"ok": True
|
||||
}
|
||||
with pytest.raises(codex.CodexAuthError):
|
||||
auth_cli._finish(_CHATGPT, "code", "wrong", "verifier", "expected", require_state=False)
|
||||
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=False)
|
||||
|
||||
|
||||
def test_finish_rejects_missing_code() -> None:
|
||||
with pytest.raises(codex.CodexAuthError) as exc:
|
||||
auth_cli._finish(_CHATGPT, None, "expected", "verifier", "expected", require_state=True)
|
||||
auth_cli._finish(None, "expected", "verifier", "expected", require_state=True)
|
||||
assert exc.value.code == "no_code"
|
||||
|
||||
|
||||
@@ -95,31 +84,6 @@ def test_model_subcommand_removed() -> None:
|
||||
assert auth_cli.run_auth(["model", "gpt-5.5"]) == 2
|
||||
|
||||
|
||||
def _sign_in_both() -> None:
|
||||
codex.save_record({"type": "oauth", "access": "c", "refresh": "r", "account_id": "a"})
|
||||
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
||||
|
||||
|
||||
def test_logout_all_removes_every_provider() -> None:
|
||||
_sign_in_both()
|
||||
assert codex.is_authenticated()
|
||||
assert grok.is_authenticated()
|
||||
|
||||
assert auth_cli.run_auth(["logout"]) == 0
|
||||
|
||||
assert not codex.is_authenticated()
|
||||
assert not grok.is_authenticated()
|
||||
|
||||
|
||||
def test_logout_single_provider_leaves_the_other() -> None:
|
||||
_sign_in_both()
|
||||
|
||||
assert auth_cli.run_auth(["logout", "grok"]) == 0
|
||||
|
||||
assert codex.is_authenticated()
|
||||
assert not grok.is_authenticated()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider", ["chatgpt", "codex", "ChatGPT"])
|
||||
def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
reached = {"flow": False}
|
||||
|
||||
@@ -1228,3 +1228,40 @@ async def test_wait_kind_survives_a_snapshot_round_trip() -> None:
|
||||
assert restored.wait_kinds["root"] == "user"
|
||||
assert restored.idle_resume_counts["root"] == 1
|
||||
assert await execution._plain_waiting_timeout(restored, "root") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interactive_nudge_offers_waiting_without_repeating() -> None:
|
||||
"""The nudge is the instruction an agent reads when it is stranded here.
|
||||
|
||||
It is where the option to wait on what was already said has to be, not only
|
||||
in the system prompt: an agent that ended a turn on plain text reasons off
|
||||
this text, and without the clause it restates its answer to reach a tool
|
||||
call, so the user reads it twice.
|
||||
|
||||
The clause holds whatever the turn did, because the agent is the one who
|
||||
knows whether it spoke — this fires for a turn that produced no text at all.
|
||||
"""
|
||||
items = await execution._append_tool_required_message(
|
||||
session=None,
|
||||
context={"parent_id": None},
|
||||
attempt=1,
|
||||
limit=3,
|
||||
interactive=True,
|
||||
)
|
||||
|
||||
assert "with no message if you have already said it" in items[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autonomous_nudge_does_not_offer_the_user() -> None:
|
||||
"""There is nobody attached to an autonomous run to wait for."""
|
||||
items = await execution._append_tool_required_message(
|
||||
session=None,
|
||||
context={"parent_id": None},
|
||||
attempt=1,
|
||||
limit=3,
|
||||
interactive=False,
|
||||
)
|
||||
|
||||
assert "respond_to_user" not in items[0]["content"]
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Tests for Grok (xAI) subscription auth: PKCE, token handling, store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from strix.config import grok
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
path = tmp_path / "home" / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", path)
|
||||
return path
|
||||
|
||||
|
||||
def test_pkce_challenge_matches_verifier_and_is_unpadded() -> None:
|
||||
verifier, challenge = grok.generate_pkce()
|
||||
expected = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||
)
|
||||
assert challenge == expected
|
||||
assert "=" not in verifier
|
||||
assert "=" not in challenge
|
||||
|
||||
|
||||
def test_authorize_url_carries_pkce_client_and_grok_scope() -> None:
|
||||
url = grok.build_authorize_url("chal", "st8")
|
||||
assert grok.AUTHORIZE_URL in url
|
||||
assert "code_challenge=chal" in url
|
||||
assert "code_challenge_method=S256" in url
|
||||
assert f"client_id={grok.CLIENT_ID}" in url
|
||||
assert "state=st8" in url
|
||||
# The Grok-CLI scope is what unlocks subscription inference.
|
||||
assert "grok-cli%3Aaccess" in url
|
||||
assert "offline_access" in url
|
||||
|
||||
|
||||
def test_redirect_uri_is_loopback() -> None:
|
||||
assert grok.REDIRECT_URI == "http://127.0.0.1:56121/callback"
|
||||
|
||||
|
||||
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 = grok._post_form({"grant_type": "refresh_token"})
|
||||
|
||||
assert data == {"access_token": "tok"}
|
||||
assert post.call_args.kwargs["timeout"] == grok._TOKEN_TIMEOUT
|
||||
|
||||
|
||||
def test_post_form_raises_on_http_error() -> None:
|
||||
resp = mock.MagicMock()
|
||||
resp.status_code = 400
|
||||
resp.text = "invalid_grant"
|
||||
|
||||
with (
|
||||
mock.patch.object(requests, "post", return_value=resp),
|
||||
pytest.raises(grok.GrokAuthError) as exc,
|
||||
):
|
||||
grok._post_form({"grant_type": "refresh_token"})
|
||||
assert exc.value.code == "token_http_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("http://127.0.0.1:56121/callback?code=AAA&state=BBB", ("AAA", "BBB")),
|
||||
("AAA#BBB", ("AAA", "BBB")),
|
||||
("code=AAA&state=BBB", ("AAA", "BBB")),
|
||||
("AAA", ("AAA", None)),
|
||||
("", (None, None)),
|
||||
],
|
||||
)
|
||||
def test_parse_redirect_input(value: str, expected: tuple[str | None, str | None]) -> None:
|
||||
assert grok.parse_redirect_input(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("grok/grok-4", "grok-4"),
|
||||
("Grok/Grok-4", "Grok-4"),
|
||||
(" grok/grok-4 ", "grok-4"),
|
||||
("xai/grok-4", None), # metered API path
|
||||
("chatgpt/gpt-5.4", None),
|
||||
("grok-4", None),
|
||||
("grok/", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_subscription_model(model: str | None, expected: str | None) -> None:
|
||||
assert grok.subscription_model(model) == expected
|
||||
|
||||
|
||||
def test_auth_mode() -> None:
|
||||
assert grok.auth_mode("grok/grok-4") == "subscription"
|
||||
assert grok.auth_mode("xai/grok-4") == "api_key"
|
||||
assert grok.auth_mode("chatgpt/gpt-5.4") == "api_key"
|
||||
assert grok.auth_mode(None) == "api_key"
|
||||
|
||||
|
||||
def _record(access: str, refresh: str, expires_at: float) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "oauth",
|
||||
"provider": "grok",
|
||||
"access": access,
|
||||
"refresh": refresh,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
def test_store_roundtrip_and_logout() -> None:
|
||||
assert grok.read_record() is None
|
||||
assert grok.is_authenticated() is False
|
||||
|
||||
grok.save_record(_record("a1", "r1", time.time() + 3600))
|
||||
record = grok.read_record()
|
||||
assert record is not None
|
||||
assert record["access"] == "a1"
|
||||
assert grok.is_authenticated() is True
|
||||
|
||||
grok.logout()
|
||||
assert grok.read_record() is None
|
||||
grok.logout() # no-op when already gone
|
||||
|
||||
|
||||
def test_store_file_permissions_are_owner_only(_tmp_store: Path) -> None:
|
||||
grok.save_record(_record("a1", "r1", time.time() + 3600))
|
||||
assert (_tmp_store.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_store_shares_file_with_other_providers(_tmp_store: Path) -> None:
|
||||
# Grok must not clobber a co-resident ChatGPT record in the shared store.
|
||||
_tmp_store.parent.mkdir(parents=True, exist_ok=True)
|
||||
_tmp_store.write_text(json.dumps({"codex": {"type": "oauth", "access": "x"}}))
|
||||
|
||||
grok.save_record(_record("a1", "r1", time.time() + 3600))
|
||||
on_disk = json.loads(_tmp_store.read_text())
|
||||
assert on_disk["codex"] == {"type": "oauth", "access": "x"}
|
||||
assert on_disk["grok"]["access"] == "a1"
|
||||
|
||||
grok.logout()
|
||||
# Removing grok leaves the other provider's record and the file intact.
|
||||
assert json.loads(_tmp_store.read_text()) == {"codex": {"type": "oauth", "access": "x"}}
|
||||
|
||||
|
||||
def test_read_record_rejects_incomplete_records() -> None:
|
||||
grok.save_record({"type": "oauth", "access": "a"}) # missing refresh
|
||||
assert grok.read_record() is None
|
||||
assert grok.is_authenticated() is False
|
||||
|
||||
|
||||
def test_get_valid_token_returns_stored_when_fresh(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
msg = "should not refresh a fresh token"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(grok, "_post_form", _boom)
|
||||
grok.save_record(_record("access-fresh", "r1", time.time() + 3600))
|
||||
assert grok.get_valid_token() == "access-fresh"
|
||||
|
||||
|
||||
def test_get_valid_token_refreshes_and_persists_rotation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_post(payload: dict[str, str]) -> dict[str, Any]:
|
||||
calls["n"] += 1
|
||||
assert payload["grant_type"] == "refresh_token"
|
||||
assert payload["refresh_token"] == "r1"
|
||||
return {"access_token": "access-new", "refresh_token": "r2", "expires_in": 3600}
|
||||
|
||||
monkeypatch.setattr(grok, "_post_form", _fake_post)
|
||||
grok.save_record(_record("stale", "r1", time.time() - 10)) # already expired
|
||||
|
||||
assert grok.get_valid_token() == "access-new"
|
||||
assert calls["n"] == 1
|
||||
record = grok.read_record()
|
||||
assert record is not None
|
||||
assert record["refresh"] == "r2" # rotated refresh written back
|
||||
|
||||
|
||||
def test_refresh_keeps_old_refresh_when_response_omits_it(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
return {"access_token": "access-new", "expires_in": 3600} # no refresh_token
|
||||
|
||||
monkeypatch.setattr(grok, "_post_form", _fake_post)
|
||||
grok.save_record(_record("stale", "r1", time.time() - 10))
|
||||
|
||||
assert grok.get_valid_token() == "access-new"
|
||||
record = grok.read_record()
|
||||
assert record is not None
|
||||
assert record["refresh"] == "r1" # fell back to the prior refresh token
|
||||
|
||||
|
||||
def test_get_valid_token_uses_token_rotated_by_another_process(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
records = [
|
||||
_record("stale", "r1", time.time() - 10),
|
||||
_record("fresh-from-other-process", "r2", time.time() + 3600),
|
||||
]
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_read() -> dict[str, Any]:
|
||||
record = records[min(calls["n"], len(records) - 1)]
|
||||
calls["n"] += 1
|
||||
return record
|
||||
|
||||
def _boom(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
msg = "must not refresh a token another process already rotated"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(grok, "read_record", _fake_read)
|
||||
monkeypatch.setattr(grok, "_post_form", _boom)
|
||||
|
||||
assert grok.get_valid_token() == "fresh-from-other-process"
|
||||
|
||||
|
||||
def test_get_valid_token_recovers_when_refresh_loses_race(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
grok.save_record(_record("stale", "r1", time.time() - 10))
|
||||
|
||||
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
grok.save_record(_record("fresh-from-peer", "r2", time.time() + 3600))
|
||||
raise grok.GrokAuthError("token_http_error", "HTTP 400: invalid_grant")
|
||||
|
||||
monkeypatch.setattr(grok, "_post_form", _fake_post)
|
||||
assert grok.get_valid_token() == "fresh-from-peer"
|
||||
|
||||
|
||||
def test_get_valid_token_reraises_refresh_error_without_rotation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
grok.save_record(_record("stale", "r1", time.time() - 10))
|
||||
|
||||
def _fake_post(_payload: dict[str, str]) -> dict[str, Any]:
|
||||
raise grok.GrokAuthError("token_http_error", "HTTP 400: invalid_grant")
|
||||
|
||||
monkeypatch.setattr(grok, "_post_form", _fake_post)
|
||||
with pytest.raises(grok.GrokAuthError):
|
||||
grok.get_valid_token()
|
||||
|
||||
|
||||
def test_get_valid_token_raises_when_not_signed_in() -> None:
|
||||
with pytest.raises(grok.GrokAuthError) as exc:
|
||||
grok.get_valid_token()
|
||||
assert exc.value.code == "not_authenticated"
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Grok subscription routing through StrixProvider.get_model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from unittest import mock
|
||||
|
||||
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
|
||||
|
||||
from strix.config import grok, subscription
|
||||
from strix.config.models import StrixProvider, _TurnGuardModel
|
||||
from strix.interface import scan_setup, utils
|
||||
from strix.report import state as state_mod
|
||||
|
||||
|
||||
def test_grok_prefix_routes_to_chat_completions(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
client = mock.MagicMock()
|
||||
monkeypatch.setattr(grok, "get_subscription_client", lambda: client)
|
||||
|
||||
model = StrixProvider().get_model("grok/grok-4")
|
||||
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert isinstance(model._inner, OpenAIChatCompletionsModel)
|
||||
# The provider strips the grok/ prefix and passes xAI's bare model slug.
|
||||
assert model._inner.model == "grok-4"
|
||||
|
||||
|
||||
def test_non_subscription_model_is_not_hijacked_by_grok(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
def _boom() -> object:
|
||||
msg = "grok client must not be built for a non-grok model"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(grok, "get_subscription_client", _boom)
|
||||
|
||||
# A metered xai/* key model must fall through to the normal provider path,
|
||||
# not the subscription route.
|
||||
model = StrixProvider().get_model("xai/grok-4")
|
||||
assert isinstance(model, _TurnGuardModel)
|
||||
assert not isinstance(model._inner, OpenAIChatCompletionsModel)
|
||||
|
||||
|
||||
def test_provider_label_names_the_subscription() -> None:
|
||||
assert subscription.provider_label("grok/grok-4") == "Grok"
|
||||
assert subscription.provider_label("chatgpt/gpt-5.4") == "ChatGPT"
|
||||
# Metered API-key models are not subscriptions.
|
||||
assert subscription.provider_label("xai/grok-4") is None
|
||||
assert subscription.provider_label("openai/gpt-5.4") is None
|
||||
|
||||
|
||||
def test_run_record_reports_grok_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
settings = mock.MagicMock()
|
||||
settings.llm.model = "grok/grok-4"
|
||||
monkeypatch.setattr(state_mod, "load_settings", lambda: settings)
|
||||
|
||||
record = state_mod.ReportState(run_name="run-test").run_record
|
||||
assert record["auth_mode"] == "subscription"
|
||||
assert record["subscription_provider"] == "Grok"
|
||||
|
||||
|
||||
def test_subscription_label_prefers_persisted_provider(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
settings = mock.MagicMock()
|
||||
settings.llm.model = "chatgpt/gpt-5.4" # current settings point at ChatGPT
|
||||
monkeypatch.setattr(utils, "load_settings", lambda: settings)
|
||||
|
||||
# A resumed Grok run keeps its persisted provider even though settings changed.
|
||||
resumed = mock.MagicMock(
|
||||
run_record={"auth_mode": "subscription", "subscription_provider": "Grok"}
|
||||
)
|
||||
assert utils._subscription_label(resumed) == "Grok subscription"
|
||||
|
||||
# With no persisted provider, it derives the label from settings (not a
|
||||
# hardcoded default).
|
||||
fresh = mock.MagicMock(run_record={})
|
||||
assert utils._subscription_label(fresh) == "ChatGPT subscription"
|
||||
|
||||
|
||||
def test_persisted_run_record_carries_provider(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
settings = mock.MagicMock()
|
||||
settings.llm.model = "grok/grok-4"
|
||||
monkeypatch.setattr(scan_setup, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(scan_setup, "run_dir_for", lambda _name: tmp_path)
|
||||
captured: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
"strix.report.writer.write_run_record", lambda _dir, rec: captured.update(rec)
|
||||
)
|
||||
|
||||
args = argparse.Namespace(
|
||||
run_name="run-test",
|
||||
targets_info=[],
|
||||
scan_mode="scan",
|
||||
instruction=None,
|
||||
non_interactive=True,
|
||||
local_sources=[],
|
||||
diff_scope={"active": False},
|
||||
scope_mode="mode",
|
||||
diff_base=None,
|
||||
)
|
||||
scan_setup._persist_run_record(args)
|
||||
|
||||
# The resume/viewer record must carry the provider so resumed runs stay labeled.
|
||||
assert captured["auth_mode"] == "subscription"
|
||||
assert captured["subscription_provider"] == "Grok"
|
||||
@@ -299,6 +299,16 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
|
||||
assert settings.tool_choice == "required"
|
||||
|
||||
|
||||
def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None:
|
||||
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False
|
||||
|
||||
|
||||
def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None:
|
||||
settings = make_model_settings("none", model_name="gpt-4o", has_tools=False)
|
||||
|
||||
assert settings.parallel_tool_calls is None
|
||||
|
||||
|
||||
def test_make_model_settings_sets_request_timeout() -> None:
|
||||
settings = make_model_settings(
|
||||
"none",
|
||||
@@ -351,3 +361,32 @@ def test_make_model_settings_timeout_survives_reasoning_resolve() -> None:
|
||||
|
||||
assert settings.extra_args is not None
|
||||
assert settings.extra_args["timeout"] == 120.0
|
||||
|
||||
|
||||
def test_openrouter_attribution_rides_on_the_request_headers() -> None:
|
||||
# litellm.headers is ignored once a request carries any header of its own,
|
||||
# so the attribution must be part of the per-request headers.
|
||||
headers = make_model_settings(
|
||||
None, model_name="openrouter/anthropic/claude-sonnet-4-5"
|
||||
).extra_headers
|
||||
assert headers == {
|
||||
"HTTP-Referer": "https://strix.ai",
|
||||
"X-Title": "Strix",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
|
||||
|
||||
def test_openrouter_attribution_absent_for_other_providers() -> None:
|
||||
assert make_model_settings(None, model_name="anthropic/claude-sonnet-4-5").extra_headers is None
|
||||
|
||||
|
||||
def test_user_headers_override_openrouter_attribution() -> None:
|
||||
headers = make_model_settings(
|
||||
None,
|
||||
model_name="openrouter/anthropic/claude-sonnet-4-5",
|
||||
extra_headers={"X-Title": "Custom", "X-Tenant": "acme"},
|
||||
).extra_headers
|
||||
assert headers is not None
|
||||
assert headers["X-Title"] == "Custom"
|
||||
assert headers["X-Tenant"] == "acme"
|
||||
assert headers["HTTP-Referer"] == "https://strix.ai"
|
||||
|
||||
@@ -64,3 +64,31 @@ async def test_a_message_that_already_arrived_is_taken_instead_of_parking() -> N
|
||||
assert result["wait_outcome"] == "message_arrived"
|
||||
assert result["pending_messages"] == 1
|
||||
assert coordinator.statuses["root"] == "running"
|
||||
|
||||
|
||||
async def _call_without_message(context: dict[str, Any]) -> dict[str, Any]:
|
||||
ctx = ToolContext(
|
||||
context=context,
|
||||
tool_name="respond_to_user",
|
||||
tool_call_id="call-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw = await respond_to_user.on_invoke_tool(ctx, "{}")
|
||||
return json.loads(raw) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parks_without_a_message() -> None:
|
||||
"""An agent that has already said its piece as plain text can just wait.
|
||||
|
||||
The nudge is what leaves it here, and while a message was required the only
|
||||
way to stop was to send the same answer a second time.
|
||||
"""
|
||||
context = await _context(interactive=True)
|
||||
|
||||
result = await _call_without_message(context)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["wait_outcome"] == "waiting"
|
||||
assert result["message"] == ""
|
||||
assert context["coordinator"].statuses["root"] == "waiting"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agents import ModelSettings
|
||||
|
||||
import strix.tools.notes.tools as notes_tools
|
||||
import strix.tools.todo.tools as todo_tools
|
||||
from strix.core import runner
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.runtime import session_manager
|
||||
|
||||
|
||||
def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
|
||||
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
|
||||
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
|
||||
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
|
||||
|
||||
settings = types.SimpleNamespace(
|
||||
llm=types.SimpleNamespace(
|
||||
model="openai/gpt-4o",
|
||||
reasoning_effort="high",
|
||||
force_required_tool_choice=False,
|
||||
timeout=300,
|
||||
prompt_cache=True,
|
||||
extra_headers=None,
|
||||
),
|
||||
runtime=types.SimpleNamespace(max_context_images=3),
|
||||
)
|
||||
monkeypatch.setattr(runner, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _settings: None)
|
||||
monkeypatch.setattr(
|
||||
runner, "uses_chat_completions_tool_schema", lambda _model, _settings: False
|
||||
)
|
||||
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _state_dir: None)
|
||||
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _state_dir: None)
|
||||
|
||||
async def _create_or_reuse(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
||||
return {"client": object(), "session": object(), "caido_client": None}
|
||||
|
||||
async def _cleanup(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse)
|
||||
monkeypatch.setattr(session_manager, "cleanup", _cleanup)
|
||||
monkeypatch.setattr(runner, "build_root_task", lambda _scan_config: "task")
|
||||
monkeypatch.setattr(runner, "build_scope_context", lambda _scan_config: "")
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings())
|
||||
monkeypatch.setattr(runner, "build_strix_agent", lambda **_kwargs: object())
|
||||
monkeypatch.setattr(runner, "make_child_factory", lambda **_kwargs: lambda **_k: object())
|
||||
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
|
||||
|
||||
|
||||
def _root_status(coordinator: AgentCoordinator) -> str:
|
||||
roots = [aid for aid, parent in coordinator.parent_of.items() if parent is None]
|
||||
assert len(roots) == 1
|
||||
return coordinator.statuses[roots[0]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interrupt", [KeyboardInterrupt, asyncio.CancelledError])
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_interrupt_leaves_the_root_running_for_resume(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, interrupt: type[BaseException]
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
|
||||
async def _interrupt(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise interrupt()
|
||||
|
||||
monkeypatch.setattr(runner, "run_agent_loop", _interrupt)
|
||||
coordinator = AgentCoordinator()
|
||||
|
||||
with pytest.raises(interrupt):
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-test",
|
||||
image="img",
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
assert _root_status(coordinator) == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_real_crash_still_marks_root_failed(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
|
||||
async def _boom(*_args: Any, **_kwargs: Any) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(runner, "run_agent_loop", _boom)
|
||||
coordinator = AgentCoordinator()
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-test",
|
||||
image="img",
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
assert _root_status(coordinator) == "failed"
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agents import ModelSettings
|
||||
|
||||
import strix.tools.notes.tools as notes_tools
|
||||
import strix.tools.todo.tools as todo_tools
|
||||
from strix.core import runner
|
||||
from strix.core.agents import AgentCoordinator
|
||||
from strix.runtime import session_manager
|
||||
|
||||
|
||||
def _wire_runner(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
|
||||
monkeypatch.setattr(runner, "run_dir_for", lambda _scan_id: tmp_path)
|
||||
monkeypatch.setattr(runner, "runtime_state_dir", lambda _run_dir: tmp_path)
|
||||
monkeypatch.setattr(runner, "setup_scan_logging", lambda _run_dir: lambda: None)
|
||||
monkeypatch.setattr(runner, "set_scan_id", lambda _scan_id: None)
|
||||
|
||||
settings = _settings()
|
||||
monkeypatch.setattr(runner, "load_settings", lambda: settings)
|
||||
monkeypatch.setattr(runner, "configure_sdk_model_defaults", lambda _s: None)
|
||||
monkeypatch.setattr(runner, "uses_chat_completions_tool_schema", lambda _m, _s: False)
|
||||
monkeypatch.setattr(todo_tools, "hydrate_todos_from_disk", lambda _d: None)
|
||||
monkeypatch.setattr(notes_tools, "hydrate_notes_from_disk", lambda _d: None)
|
||||
|
||||
async def _create_or_reuse(*_a: Any, **_k: Any) -> dict[str, Any]:
|
||||
return {"client": object(), "session": object(), "caido_client": None}
|
||||
|
||||
async def _cleanup(*_a: Any, **_k: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(session_manager, "create_or_reuse", _create_or_reuse)
|
||||
monkeypatch.setattr(session_manager, "cleanup", _cleanup)
|
||||
monkeypatch.setattr(runner, "build_root_task", lambda _c: "task")
|
||||
monkeypatch.setattr(runner, "build_scope_context", lambda _c: "")
|
||||
monkeypatch.setattr(runner, "make_model_settings", lambda *_a, **_k: ModelSettings())
|
||||
monkeypatch.setattr(runner, "build_strix_agent", lambda **_k: object())
|
||||
monkeypatch.setattr(runner, "make_child_factory", lambda **_k: lambda **_kk: object())
|
||||
monkeypatch.setattr(runner, "open_agent_session", lambda _root_id, _db: object())
|
||||
|
||||
|
||||
def _settings() -> Any:
|
||||
return types.SimpleNamespace(
|
||||
llm=types.SimpleNamespace(
|
||||
model="openai/gpt-4o",
|
||||
reasoning_effort="high",
|
||||
force_required_tool_choice=False,
|
||||
timeout=300,
|
||||
prompt_cache=True,
|
||||
extra_headers=None,
|
||||
),
|
||||
runtime=types.SimpleNamespace(max_context_images=3),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_live_child_is_settled_before_sessions_close(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Any
|
||||
) -> None:
|
||||
_wire_runner(monkeypatch, tmp_path)
|
||||
coordinator = AgentCoordinator()
|
||||
child_started = asyncio.Event()
|
||||
child_task: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
async def _root_finishes(**kwargs: Any) -> None:
|
||||
root_id = kwargs["agent_id"]
|
||||
|
||||
async def _child_mid_turn() -> None:
|
||||
child_started.set()
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
await coordinator.register("child", "Child", parent_id=root_id)
|
||||
task = asyncio.create_task(_child_mid_turn())
|
||||
child_task["t"] = task
|
||||
await coordinator.attach_runtime("child", task=task)
|
||||
await child_started.wait()
|
||||
|
||||
monkeypatch.setattr(runner, "run_agent_loop", _root_finishes)
|
||||
|
||||
await runner.run_strix_scan(
|
||||
scan_config={"targets": [], "scan_mode": "deep"},
|
||||
scan_id="scan-test",
|
||||
image="img",
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
task = child_task["t"]
|
||||
assert task.done(), "the child task was left running past scan teardown"
|
||||
assert task.cancelled(), "the child was not cancelled cleanly on a finish"
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.core.sessions import open_agent_session
|
||||
|
||||
|
||||
def _count_open_fds() -> int | None:
|
||||
for path in (Path("/proc/self/fd"), Path("/dev/fd")):
|
||||
if path.is_dir():
|
||||
return len(list(path.iterdir()))
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_hold_no_descriptors_while_parked(tmp_path: Path) -> None:
|
||||
"""Descriptor use must track live operations, not the number of sessions.
|
||||
|
||||
The SDK keeps a connection per (session, pool thread) open for the session's
|
||||
whole life. An agent parks rather than exits, so its session lives for the
|
||||
scan, and fan-out multiplies those handles until the process runs out of file
|
||||
descriptors (#1018). A session that is not mid-operation should hold none.
|
||||
"""
|
||||
baseline = _count_open_fds()
|
||||
if baseline is None:
|
||||
pytest.skip("no /proc/self/fd or /dev/fd on this platform")
|
||||
|
||||
sessions = [open_agent_session(f"a{i}", tmp_path / f"s{i}.db") for i in range(60)]
|
||||
try:
|
||||
for _ in range(4):
|
||||
await asyncio.gather(
|
||||
*(s.add_items([{"role": "user", "content": "x"}]) for s in sessions)
|
||||
)
|
||||
await asyncio.gather(*(s.get_items() for s in sessions))
|
||||
parked = _count_open_fds()
|
||||
assert parked is not None
|
||||
# 60 parked sessions, yet descriptors are back at the baseline.
|
||||
assert parked - baseline <= 5, f"parked fds grew by {parked - baseline}"
|
||||
finally:
|
||||
for s in sessions:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_flight_descriptors_track_concurrency_not_session_count(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
baseline = _count_open_fds()
|
||||
if baseline is None:
|
||||
pytest.skip("no /proc/self/fd or /dev/fd on this platform")
|
||||
|
||||
sessions = [open_agent_session(f"a{i}", tmp_path / f"s{i}.db") for i in range(200)]
|
||||
peak = baseline
|
||||
try:
|
||||
|
||||
async def sample() -> None:
|
||||
nonlocal peak
|
||||
for _ in range(500):
|
||||
current = _count_open_fds()
|
||||
if current is not None:
|
||||
peak = max(peak, current)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
async def load() -> None:
|
||||
for _ in range(4):
|
||||
await asyncio.gather(
|
||||
*(s.add_items([{"role": "user", "content": "x"}]) for s in sessions)
|
||||
)
|
||||
|
||||
await asyncio.gather(load(), sample())
|
||||
# 200 sessions, but peak is bounded by the thread pool, well under 200.
|
||||
assert peak - baseline < 100, f"in-flight fds peaked at +{peak - baseline}"
|
||||
finally:
|
||||
for s in sessions:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_survives_the_per_operation_connection(tmp_path: Path) -> None:
|
||||
session = open_agent_session("agent-1", tmp_path / "agents.db")
|
||||
try:
|
||||
for i in range(30):
|
||||
await session.add_items([{"role": "user", "content": f"m{i}"}])
|
||||
items = [cast("dict[str, Any]", i) for i in await session.get_items()]
|
||||
assert [i["content"] for i in items] == [f"m{i}" for i in range(30)]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_sessions_sharing_one_file_stay_consistent(tmp_path: Path) -> None:
|
||||
db = tmp_path / "shared.db"
|
||||
sessions = [open_agent_session(f"a{i}", db) for i in range(10)]
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(s.add_items([{"role": "user", "content": s.session_id}]) for s in sessions)
|
||||
)
|
||||
# Each session sees only its own row despite sharing the file.
|
||||
for s in sessions:
|
||||
items = [cast("dict[str, Any]", i) for i in await s.get_items()]
|
||||
assert [i["content"] for i in items] == [s.session_id]
|
||||
finally:
|
||||
for s in sessions:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_closed_session_refuses_operations(tmp_path: Path) -> None:
|
||||
session = open_agent_session("agent-1", tmp_path / "agents.db")
|
||||
await session.add_items([{"role": "user", "content": "x"}])
|
||||
session.close()
|
||||
with pytest.raises(RuntimeError, match="closed"):
|
||||
await session.add_items([{"role": "user", "content": "y"}])
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Shared subscription credential store: secure writes and cross-provider locking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import stat
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.config import codex, grok, subscription_store
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_write_creates_owner_only_file(tmp_path: Path) -> None:
|
||||
path = tmp_path / ".strix" / "subscription-auth.json"
|
||||
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
# No stray temp file is left behind.
|
||||
assert not path.with_suffix(".json.tmp").exists()
|
||||
|
||||
|
||||
def test_write_does_not_follow_a_symlink_at_target(tmp_path: Path) -> None:
|
||||
store_dir = tmp_path / ".strix"
|
||||
store_dir.mkdir()
|
||||
outside = tmp_path / "attacker-target.json"
|
||||
path = store_dir / "subscription-auth.json"
|
||||
path.symlink_to(outside) # attacker pre-plants a symlink at the store path
|
||||
|
||||
subscription_store.write(path, {"grok": {"type": "oauth", "access": "a", "refresh": "r"}})
|
||||
|
||||
# The atomic rename replaced the symlink with a real file; nothing was
|
||||
# written through it to the attacker-chosen location.
|
||||
assert not path.is_symlink()
|
||||
assert not outside.exists()
|
||||
assert subscription_store.read(path)["grok"]["access"] == "a"
|
||||
|
||||
|
||||
def test_providers_share_store_without_clobbering(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store = tmp_path / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(codex, "AUTH_PATH", store)
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
||||
|
||||
codex.save_record({"type": "oauth", "access": "c", "refresh": "r", "account_id": "acct"})
|
||||
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
||||
|
||||
data = subscription_store.read(store)
|
||||
assert data["codex"]["access"] == "c"
|
||||
assert data["grok"]["access"] == "g"
|
||||
|
||||
# Logging one provider out leaves the other's credential intact.
|
||||
grok.logout()
|
||||
remaining = subscription_store.read(store)
|
||||
assert "grok" not in remaining
|
||||
assert remaining["codex"]["access"] == "c"
|
||||
|
||||
|
||||
def test_guard_is_reentrant(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = tmp_path / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
||||
# Persisting while already holding the guard must not deadlock — this mirrors
|
||||
# a token refresh saving its new record inside the refresh critical section.
|
||||
with subscription_store.guard(store):
|
||||
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
||||
record = grok.read_record()
|
||||
assert record is not None
|
||||
assert record["access"] == "g"
|
||||
|
||||
|
||||
def test_mutation_aborts_when_lock_cannot_be_acquired(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store = tmp_path / ".strix" / "subscription-auth.json"
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
||||
|
||||
def _no_lock(*_args: object, **_kwargs: object) -> None:
|
||||
raise OSError("no locks available")
|
||||
|
||||
monkeypatch.setattr(fcntl, "flock", _no_lock)
|
||||
|
||||
# Rather than silently doing an unlocked read-modify-write, the store raises
|
||||
# and writes nothing.
|
||||
with pytest.raises(subscription_store.StoreLockError):
|
||||
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
||||
assert not store.exists()
|
||||
|
||||
|
||||
def test_lock_file_rejects_a_pre_positioned_symlink(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
store_dir = tmp_path / ".strix"
|
||||
store_dir.mkdir()
|
||||
store = store_dir / "subscription-auth.json"
|
||||
monkeypatch.setattr(grok, "AUTH_PATH", store)
|
||||
# Attacker pre-plants a symlink where the lock file would be created.
|
||||
outside = tmp_path / "attacker-target"
|
||||
store.with_suffix(".lock").symlink_to(outside)
|
||||
|
||||
with pytest.raises(subscription_store.StoreLockError):
|
||||
grok.save_record({"type": "oauth", "access": "g", "refresh": "r"})
|
||||
# The symlink target was never created/truncated through the lock open.
|
||||
assert not outside.exists()
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agents.tool_context import ToolContext
|
||||
|
||||
from strix.tools.todo import tools
|
||||
from strix.tools.todo.tools import _coerce_priority, create_todo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_store() -> Any:
|
||||
tools._todos_storage.clear()
|
||||
yield
|
||||
tools._todos_storage.clear()
|
||||
|
||||
|
||||
async def _create(todos: list[Any], agent_id: str = "root") -> dict[str, Any]:
|
||||
ctx = ToolContext(
|
||||
context={"agent_id": agent_id},
|
||||
tool_name="create_todo",
|
||||
tool_call_id="call-1",
|
||||
tool_arguments="{}",
|
||||
)
|
||||
raw = await create_todo.on_invoke_tool(ctx, json.dumps({"todos": json.dumps(todos)}))
|
||||
return json.loads(raw) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def test_unknown_priority_falls_back_to_normal() -> None:
|
||||
assert _coerce_priority("medium") == "normal"
|
||||
assert _coerce_priority("urgent") == "normal"
|
||||
assert _coerce_priority("high") == "high"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_bad_priority_no_longer_discards_the_batch() -> None:
|
||||
result = await _create(
|
||||
[
|
||||
{"title": "Recon", "priority": "medium"},
|
||||
{"title": "Probe /admin", "priority": "sky-high"},
|
||||
{"title": "Report"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["created_count"] == 3
|
||||
by_title = {c["title"]: c["priority"] for c in result["created"]}
|
||||
assert by_title["Recon"] == "normal"
|
||||
assert by_title["Probe /admin"] == "normal"
|
||||
assert by_title["Report"] == "normal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_titles_within_a_batch_are_skipped() -> None:
|
||||
result = await _create(
|
||||
[
|
||||
{"title": "Subdomain enumeration"},
|
||||
{"title": "Content discovery"},
|
||||
{"title": "Subdomain enumeration"},
|
||||
{"title": "content discovery"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["created_count"] == 2
|
||||
assert {c["title"] for c in result["created"]} == {
|
||||
"Subdomain enumeration",
|
||||
"Content discovery",
|
||||
}
|
||||
assert len(result["skipped"]) == 2
|
||||
assert all(s["reason"] == "duplicate title" for s in result["skipped"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_title_already_on_the_list_is_not_created_again() -> None:
|
||||
await _create([{"title": "Crawl with katana"}])
|
||||
result = await _create([{"title": "crawl with katana"}, {"title": "JS analysis"}])
|
||||
|
||||
assert [c["title"] for c in result["created"]] == ["JS analysis"]
|
||||
assert [s["title"] for s in result["skipped"]] == ["crawl with katana"]
|
||||
assert result["total_count"] == 2
|
||||
|
||||
|
||||
def test_coerce_never_raises() -> None:
|
||||
assert _coerce_priority("nonsense") == "normal"
|
||||
assert _coerce_priority(None) == "normal"
|
||||
assert _coerce_priority("high") == "high"
|
||||
for value in (2, ["high"], {"p": 1}, True):
|
||||
assert _coerce_priority(value) == "normal" # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_string_priority_does_not_fail_the_batch() -> None:
|
||||
result = await _create(
|
||||
[
|
||||
{"title": "Recon", "priority": 2},
|
||||
{"title": "Probe", "priority": ["high"]},
|
||||
{"title": "Report"},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["created_count"] == 3
|
||||
assert {c["priority"] for c in result["created"]} == {"normal"}
|
||||
@@ -234,12 +234,11 @@ async def test_confirming_the_mount_starts_the_scan_without_a_target() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_declining_the_mount_returns_to_the_start_screen() -> None:
|
||||
started = False
|
||||
async def test_declining_the_mount_runs_without_one() -> None:
|
||||
started: list[bool] = []
|
||||
|
||||
async def start(_verify: bool = True) -> None:
|
||||
nonlocal started
|
||||
started = True
|
||||
async def start(verify: bool = True) -> None:
|
||||
started.append(verify)
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
@@ -250,14 +249,34 @@ async def test_declining_the_mount_returns_to_the_start_screen() -> None:
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": False})
|
||||
|
||||
assert result == {"approved": False}
|
||||
# Nothing was prepared, so the session goes back to the start screen and can
|
||||
# be launched again.
|
||||
assert started is False
|
||||
# Declining skips the directory; it does not abandon the scan.
|
||||
assert started == [False]
|
||||
assert controller.workspace_mount is None
|
||||
assert controller.pending_workspace_mount is None
|
||||
assert controller.setup_mode is True
|
||||
assert controller.scan_started is False
|
||||
assert controller.scan_state == "setup"
|
||||
assert controller.setup_mode is False
|
||||
assert controller.scan_started is True
|
||||
assert controller.scan_state == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_the_mount_runs_with_it() -> None:
|
||||
started: list[bool] = []
|
||||
|
||||
async def start(verify: bool = True) -> None:
|
||||
started.append(verify)
|
||||
|
||||
os.environ["STRIX_LLM"] = "anthropic/claude-sonnet-4"
|
||||
os.environ["ANTHROPIC_API_KEY"] = "test-key"
|
||||
loader._cached = None
|
||||
controller = TuiController(args(), on_start=start)
|
||||
await controller.handle("setup.start", {"verify": False, "mount_working_dir": True})
|
||||
|
||||
result = await controller.handle("setup.confirm_mount", {"approved": True})
|
||||
|
||||
assert result == {"approved": True}
|
||||
assert started == [False]
|
||||
assert controller.workspace_mount == str(Path.cwd())
|
||||
assert controller.scan_state == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,19 +7,26 @@ shows what the user actually typed; resuming has to match that.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
from strix.core import execution
|
||||
from strix.core.paths import runtime_state_dir
|
||||
from strix.interface.tui.backend.live_view import TuiLiveView as GoTuiLiveView
|
||||
from strix.interface.tui.live_view import TuiLiveView, _is_internal_agent_turn
|
||||
from strix.interface.tui.live_view import (
|
||||
_INTERNAL_TURN_PREFIXES,
|
||||
TuiLiveView,
|
||||
_is_internal_agent_turn,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
def _write_run(run_dir: Path, items: list[dict[str, Any]], agent_id: str = "root") -> None:
|
||||
@@ -176,11 +183,60 @@ def test_internal_turn_classifier_matches_every_injected_form() -> None:
|
||||
"[CRITICAL] Turn budget: 480/500 used (96%).",
|
||||
"== Inherited context from parent (background only) ==",
|
||||
"Your previous message ended a turn without a tool call.",
|
||||
"Your previous response ended the autonomous Strix run without a lifecycle tool call.",
|
||||
"Your previous response ended the autonomous run without a lifecycle tool call.",
|
||||
):
|
||||
assert _is_internal_agent_turn(content), content
|
||||
|
||||
|
||||
def _injected_strings(module: ModuleType) -> list[str]:
|
||||
"""Every string a module can inject, and nothing it merely mentions.
|
||||
|
||||
Parsing rather than searching the text keeps comments out of it, so a stale
|
||||
copy of a message left in a comment cannot pass for the message itself. It
|
||||
also joins adjacent literals for free, which the line wrapping needs, and
|
||||
docstrings are dropped because they describe the code rather than run in it.
|
||||
"""
|
||||
tree = ast.parse(Path(module.__file__ or "").read_text(encoding="utf-8"))
|
||||
docstrings = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
|
||||
continue
|
||||
first = node.body[0] if node.body else None
|
||||
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
|
||||
docstrings.add(id(first.value))
|
||||
|
||||
literals: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant):
|
||||
if isinstance(node.value, str) and id(node) not in docstrings:
|
||||
literals.append(node.value)
|
||||
elif isinstance(node, ast.JoinedStr):
|
||||
literals.append(
|
||||
"".join(
|
||||
part.value
|
||||
for part in node.values
|
||||
if isinstance(part, ast.Constant) and isinstance(part.value, str)
|
||||
)
|
||||
)
|
||||
return literals
|
||||
|
||||
|
||||
def test_internal_turn_prefixes_still_match_what_is_injected() -> None:
|
||||
"""The classifier copies sentences out of another module, so they can drift.
|
||||
|
||||
Both nudges are written inline in strix.core.execution, so there is nothing to
|
||||
import and compare against. Read them back out of what that module can inject.
|
||||
"""
|
||||
injected = _injected_strings(execution)
|
||||
nudges = [prefix for prefix in _INTERNAL_TURN_PREFIXES if prefix.startswith("Your previous")]
|
||||
assert nudges, "the no-tool-call nudges are no longer in the classifier"
|
||||
for nudge in nudges:
|
||||
assert any(nudge in literal for literal in injected), (
|
||||
f"the classifier expects {nudge!r}, which strix.core.execution no longer "
|
||||
f"injects. A resumed scan would show that nudge as the user's own message."
|
||||
)
|
||||
|
||||
|
||||
def test_internal_turn_classifier_keeps_bracketed_user_text() -> None:
|
||||
"""A leading bracket is not enough: typed text often starts with one."""
|
||||
for content in (
|
||||
|
||||
@@ -70,53 +70,6 @@ def test_read_run_summary_finished_flag(tmp_path: Path) -> None:
|
||||
assert read_run_summary(partial)["finished"] is False
|
||||
|
||||
|
||||
def _write_record(base: Path, name: str, record: dict[str, object]) -> Path:
|
||||
run_dir = base / "strix_runs" / name
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
return run_dir
|
||||
|
||||
|
||||
def test_read_run_summary_backfills_subscription_provider(tmp_path: Path) -> None:
|
||||
# An older subscription run recorded no provider name; it is derived from
|
||||
# the recorded provider/model slug so the viewer can label it.
|
||||
run_dir = _write_record(
|
||||
tmp_path,
|
||||
"grok-run",
|
||||
{
|
||||
"auth_mode": "subscription",
|
||||
"llm_usage": {"agents": [{"agent_id": "root", "model": "grok/grok-4"}]},
|
||||
},
|
||||
)
|
||||
assert read_run_summary(run_dir)["subscription_provider"] == "Grok"
|
||||
|
||||
|
||||
def test_read_run_summary_keeps_explicit_provider(tmp_path: Path) -> None:
|
||||
run_dir = _write_record(
|
||||
tmp_path,
|
||||
"chatgpt-run",
|
||||
{
|
||||
"auth_mode": "subscription",
|
||||
"subscription_provider": "ChatGPT",
|
||||
"llm_usage": {"agents": [{"agent_id": "root", "model": "grok/grok-4"}]},
|
||||
},
|
||||
)
|
||||
# An explicit field is authoritative and never overwritten by the slug.
|
||||
assert read_run_summary(run_dir)["subscription_provider"] == "ChatGPT"
|
||||
|
||||
|
||||
def test_read_run_summary_ignores_api_key_runs(tmp_path: Path) -> None:
|
||||
run_dir = _write_record(
|
||||
tmp_path,
|
||||
"api-key-run",
|
||||
{
|
||||
"auth_mode": "api_key",
|
||||
"llm_usage": {"agents": [{"agent_id": "root", "model": "openai/gpt-5.4"}]},
|
||||
},
|
||||
)
|
||||
assert "subscription_provider" not in read_run_summary(run_dir)
|
||||
|
||||
|
||||
def test_read_missing_artifacts_return_defaults(tmp_path: Path) -> None:
|
||||
run_dir = _make_run(tmp_path, "empty", status="running", end_time=None)
|
||||
assert read_vulnerabilities(run_dir) == []
|
||||
|
||||
Reference in New Issue
Block a user