mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 17:27:26 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc9fa9faa | ||
|
|
174c16fa26 | ||
|
|
94a2586aaa | ||
|
|
372e27fa17 | ||
|
|
ad727edd66 |
+17
-31
@@ -58,9 +58,7 @@ RUN apt-get update && \
|
||||
libcap2-bin \
|
||||
gdb \
|
||||
libnss3-tools \
|
||||
chromium fonts-liberation fonts-noto-core fonts-noto-color-emoji \
|
||||
xvfb x11-utils xdotool dbus-x11 \
|
||||
imagemagick
|
||||
chromium fonts-liberation
|
||||
|
||||
|
||||
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
|
||||
@@ -116,37 +114,25 @@ RUN npm install -g retire@latest && \
|
||||
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
|
||||
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
# Must match the installed Chromium major; the build asserts it below.
|
||||
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
|
||||
# AGENT_BROWSER_ARGS is comma-separated, so no flag value may contain a comma
|
||||
# (window geometry comes from the virtual display instead).
|
||||
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US,--password-store=basic,--use-mock-keychain,--disable-dev-shm-usage"
|
||||
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
|
||||
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
|
||||
|
||||
# `agent-browser --headed` exits 0 even when Chrome fails to launch, so assert
|
||||
# headed mode here. `--no-sandbox` is build-only: buildkit has no unprivileged
|
||||
# user namespaces. The stale X lock/socket must go or the runtime Xvfb refuses.
|
||||
USER root
|
||||
RUN set -eu; \
|
||||
chrome_major="$(chromium --version | grep -oE '[0-9]+' | head -1)"; \
|
||||
printf '%s' "${AGENT_BROWSER_USER_AGENT}" | grep -q "Chrome/${chrome_major}\." || { \
|
||||
echo "AGENT_BROWSER_USER_AGENT disagrees with installed Chromium ${chrome_major}"; exit 1; \
|
||||
}; \
|
||||
Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \
|
||||
xvfb_pid=$!; \
|
||||
for _ in $(seq 1 20); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.5; done; \
|
||||
xdpyinfo -display :99 >/dev/null; \
|
||||
DISPLAY=:99 agent-browser --headed --args "${AGENT_BROWSER_ARGS},--no-sandbox" \
|
||||
open about:blank >/tmp/headed-check.log 2>&1 || true; \
|
||||
if grep -qi 'Missing X server\|exited early' /tmp/headed-check.log; then \
|
||||
echo "headed browser check failed:"; cat /tmp/headed-check.log; exit 1; \
|
||||
fi; \
|
||||
DISPLAY=:99 xwininfo -root -children | grep -qi chromium; \
|
||||
agent-browser close >/dev/null 2>&1 || true; \
|
||||
kill "${xvfb_pid}" 2>/dev/null || true; \
|
||||
wait "${xvfb_pid}" 2>/dev/null || true; \
|
||||
rm -rf /tmp/.X99-lock /tmp/.X11-unix/X99 /tmp/xvfb-build.log /tmp/headed-check.log
|
||||
{ \
|
||||
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; \
|
||||
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
|
||||
|
||||
@@ -117,46 +117,6 @@ echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
|
||||
|
||||
echo "✅ System-wide proxy configuration complete"
|
||||
|
||||
# A virtual display so the agent can fall back to headed Chrome when a target
|
||||
# rejects headless; without it headed mode dies but still exits 0.
|
||||
DISPLAY_NUM="${STRIX_DISPLAY_NUM:-99}"
|
||||
DISPLAY_GEOMETRY="${STRIX_DISPLAY_GEOMETRY:-1280x800x24}"
|
||||
|
||||
if ! xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
|
||||
# Nothing is answering, so a leftover lock/socket is stale; Xvfb refuses to
|
||||
# start with one present.
|
||||
rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true
|
||||
Xvfb ":${DISPLAY_NUM}" -screen 0 "${DISPLAY_GEOMETRY}" -nolisten tcp \
|
||||
> /tmp/xvfb.log 2>&1 &
|
||||
for _ in $(seq 1 20); do
|
||||
xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
if xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
|
||||
echo "✅ Virtual display :${DISPLAY_NUM} ready (${DISPLAY_GEOMETRY})"
|
||||
else
|
||||
echo "⚠️ Xvfb failed to start; headed browsing is unavailable. Xvfb log:"
|
||||
cat /tmp/xvfb.log 2>/dev/null || echo "(no log available)"
|
||||
fi
|
||||
|
||||
# Best-effort session bus: without it headed Chrome spews dbus errors that read
|
||||
# like fatal failures in tool output.
|
||||
if [ ! -S /run/dbus/system_bus_socket ]; then
|
||||
sudo mkdir -p /run/dbus
|
||||
sudo dbus-daemon --system --fork > /tmp/dbus.log 2>&1 || true
|
||||
fi
|
||||
|
||||
cat << EOF | sudo tee /etc/profile.d/browser.sh
|
||||
export DISPLAY=:${DISPLAY_NUM}
|
||||
EOF
|
||||
|
||||
echo ". /etc/profile.d/browser.sh" >> ~/.bashrc
|
||||
echo ". /etc/profile.d/browser.sh" >> ~/.zshrc
|
||||
|
||||
. /etc/profile.d/browser.sh
|
||||
|
||||
echo "Adding CA to browser trust store..."
|
||||
sudo -u pentester mkdir -p /home/pentester/.pki/nssdb
|
||||
sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "strix-agent"
|
||||
version = "1.5.2"
|
||||
version = "1.5.3"
|
||||
description = "Open-source AI Hackers for your apps"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -652,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:
|
||||
|
||||
+15
-1
@@ -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,
|
||||
)
|
||||
@@ -203,12 +205,13 @@ def make_model_settings(
|
||||
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 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
|
||||
@@ -231,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,
|
||||
|
||||
@@ -189,59 +189,6 @@ After any page-changing action, pick one:
|
||||
Avoid bare `wait 2000` except when debugging — it makes scripts slow and
|
||||
flaky. Timeouts default to 25 seconds.
|
||||
|
||||
## Bot protection (read before fighting a login)
|
||||
|
||||
Many targets sit behind a bot check (Cloudflare Turnstile, hCaptcha, Datadome).
|
||||
You will see it in a snapshot as a challenge iframe plus inputs that go
|
||||
`[disabled]` when you submit:
|
||||
|
||||
```
|
||||
- textbox "Email address" [disabled, ref=e18]: user@example.com
|
||||
- button "Continue with email" [disabled, ref=e12]
|
||||
- Iframe "Widget containing a Cloudflare security challenge" [ref=e15]
|
||||
- checkbox "Verify you are human" [checked=false, ref=e22]
|
||||
```
|
||||
|
||||
`checked=false` that never flips means the challenge is refusing you, not that
|
||||
the click missed. Headless Chrome is itself one of the strongest signals these
|
||||
systems key on, so switch to a real browser window instead of retrying:
|
||||
|
||||
```bash
|
||||
agent-browser close --all # a running daemon makes --headed a no-op
|
||||
export AGENT_BROWSER_HEADED=1 # applies to every later command
|
||||
agent-browser open https://target.tld/login
|
||||
agent-browser get url # confirm it actually launched
|
||||
```
|
||||
|
||||
The sandbox provides a virtual display and `DISPLAY` is already exported, so this
|
||||
works with no setup. Three traps:
|
||||
|
||||
- **Set the env var, don't just pass `--headed` to `open`.** The flag is
|
||||
per-invocation: the next bare `agent-browser snapshot` tries to start a
|
||||
*headless* daemon and dies with `Multiple targets are not supported in headless
|
||||
mode`. Export `AGENT_BROWSER_HEADED=1` (or pass `--headed` to every command).
|
||||
- **`--headed` is ignored when a daemon is already running** — it prints
|
||||
`⚠ --headed ignored: daemon already running`. Always `close --all` first.
|
||||
- **A failed launch still exits 0.** Read the output text: `✗ Chrome exited
|
||||
early` or `Missing X server` means you are not headed, whatever the exit code
|
||||
says. Confirm with `agent-browser get url` before concluding anything.
|
||||
|
||||
Then behave like a person rather than a script:
|
||||
|
||||
- Drive real input events — `click`, `hover`, `keyboard type` — never `eval` with
|
||||
`element.value = ...`. Assigning `value` directly leaves React-controlled
|
||||
inputs internally empty, so the form submits blank or stays disabled.
|
||||
- `focus` the field, then `keyboard type "text"` when `fill` appears to work but
|
||||
the app doesn't react.
|
||||
- Click the challenge checkbox by its ref inside the iframe, then
|
||||
`wait --text` / `wait --url` for the *result*; don't re-click while it verifies.
|
||||
- Save the session once you're through (`state save`, or `--session-name`) so a
|
||||
browser restart doesn't send you back to the challenge.
|
||||
|
||||
If the challenge still refuses after a couple of honest attempts, stop. Report
|
||||
that the target is gated and hand back a bounded result — burning your whole
|
||||
window on one login costs more coverage than the login was worth.
|
||||
|
||||
## Common workflows
|
||||
|
||||
### Log in
|
||||
@@ -450,12 +397,12 @@ agent-browser dialog dismiss # cancel
|
||||
|
||||
## Readiness & recovery
|
||||
|
||||
The first `agent-browser open` in a session launches the Chrome daemon (headless
|
||||
unless you pass `--headed`); 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:
|
||||
The first `agent-browser open` in a session launches the headless-Chrome
|
||||
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
|
||||
@@ -543,7 +490,7 @@ and [references/authentication.md](references/authentication.md).
|
||||
```bash
|
||||
--session <name> # isolated browser session
|
||||
--json # JSON output (for machine parsing)
|
||||
--headed # real browser window (default is headless); see "Bot protection"
|
||||
--headed # show the window (default is headless)
|
||||
--auto-connect # connect to an already-running Chrome
|
||||
--cdp <port> # connect to a specific CDP port
|
||||
--profile <name|path> # use a Chrome profile (login state survives)
|
||||
|
||||
@@ -361,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"
|
||||
|
||||
Reference in New Issue
Block a user