* feat(llm): log the upstream provider behind OpenRouter
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* feat(llm): retry 4xx model errors
* test: let the blocked-provider test run out of 4xx retries
* refactor: retry any 4xx or 5xx status
* fix: don't retry 401, 402 or 403
* fix: don't retry 404
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* fix(execution): only strip images on image-rejection errors
The image-strip recovery fired on any 400/404/422, wiping a session's screenshots and replaying the turn on unrelated errors (e.g. an upstream incomplete-tool-call 400, a context overflow). Require an image-related message too.
* fix(execution): match only replies that mean the model takes no images
* fix(execution): match DeepSeek's image rejection
* fix(execution): guess at Vercel AI Gateway's image rejection
* feat(llm): scrub images from every request to a text-only model
Replace images at the MCP call before any result_transform, give load_skill the
same agent_browser screenshot swap as the system prompt, and add a
call_model_input_filter that turns any image left in the input into a tool error
while chaining any filter already set.
* chore: fix the ruff and bandit failures on main
* feat(llm): keep images away from text-only models
Leave view_image out of the tools, replace MCP image blocks with a text note,
and swap agent_browser's screenshot section for text-only guidance when the
run's model does not accept images.
* refactor(llm): disable view_image via is_enabled and gate skill image guidance by modality
* refactor(skills): render opt-in Jinja skills instead of comment markers
* Tweaked skill
* feat(llm): detect whether a model accepts image input
Add model_supports_images(): OpenRouter models are checked against architecture.input_modalities from OpenRouter's public model list (fetched once per process), others against LiteLLM's supports_vision. Unknown models and lookup failures count as image-capable. Not wired in yet.
* refactor(llm): read image support from litellm's catalog, assuming it for unknown models
* style: drop a trailing space in the image-support docstring
Expose Strix's own tools over MCP stdio so a client such as Claude Code,
Cursor, or Zed drives them with its own model and agent loop. This mode
needs no STRIX_LLM and no LLM_API_KEY — the client supplies the model, so
an existing coding-agent subscription can run Strix. Strix still provides
its sandbox container, HTTP proxy, skill packs, and CVSS-scored reporting
tools.
- `strix mcp-server`: 21 host tools plus lazily-bound sandbox shell/FS
tools, served over stdio. The sandbox session starts on the first tool
call that needs it and is reused for the life of the server.
- `--target-path` (repeatable): mount a local repository read-only at
/workspace/<name> for white-box review. Repos whose final path component
collides are de-duplicated (api, api-2, ...).
- `--run-name`: run state (reporting, threat-model, coverage) persists under
strix_runs/<name> and hydrates on startup, so passing a name resumes that
run. The default is unique per launch so two servers started from the same
directory do not share a run and overwrite each other's reports.
- Proxy tools bring the sandbox up on first use so their Caido client is
available without an unrelated shell call first.
- Oversized tool results are trimmed to a bounded preview; the full text is
spilled to the run directory and is retrievable through the exposed
`read_tool_output` tool, so nothing is lost and the client is not flooded.
- `--verbose` adjusts the installed stderr handlers directly (basicConfig
could not), so DEBUG output shows; stdout stays the MCP transport.
- Orchestration-owning tools (agent graph, finish_scan, wait_for_user,
notes, todos, think) are intentionally not exposed; the client owns
planning and memory.
- Tests cover discovery, invocation, context wiring, mounting, de-dup,
run-state setup, proxy bring-up, output truncation + retrieval, log
config, and a Docker-gated integration test of the sandbox read-only mount.
No new dependency: the `mcp` SDK is already transitive via openai-agents.
A stop that lands between the silent-wait check and mark_running was
overwritten with running. resume_silent_user_wait does the check and the
state change under the coordinator lock and leaves anything else alone.
Plain text is now the only channel to the user. wait_for_user takes no
arguments and only parks the agent, so a reply can no longer be written
twice (once as text, once as a tool argument). The driver bounces a
wait_for_user call that follows no assistant text back into recovery.
TUI and viewer render wait_for_user as the waiting marker alone and keep
rendering recorded respond_to_user events with their message.
docker.from_env() ignores the current docker context, so Docker Desktop on
macOS (socket under ~/.docker/run unless the default-socket option is on),
OrbStack and Colima reported DOCKER NOT AVAILABLE while `docker ps` worked.
Every failure also printed the same "ensure Docker Desktop is running" text
followed by a RuntimeError traceback.
- resolve the endpoint as the CLI does: DOCKER_HOST, then DOCKER_CONTEXT or
the current context, then the default socket; the sandbox backend uses the
same resolution so startup and scan talk to the same daemon
- classify the SDK error (socket missing, permission denied, connection
refused, Windows named pipe) and print the fix for the current platform,
the endpoint that was tried and the underlying error
- exit 1 cleanly instead of raising after the panel
- telemetry reports docker_unavailable_<reason>
parse_arguments() rejected --fail-on without -n before main() could switch
to headless, so a CI job running `strix -t x --fail-on high` without -n
stopped at an argument error instead of scanning. The terminal check now
lives in cli_args as terminal_attached(); the parser only enforces the
headless-only flag when a terminal is attached and the TUI would open.
Without a tty on stdin and stdout (CI, nohup, pipes, cron, TERM=dumb) the Go
TUI handshakes fine and then exits 1 the moment Bubble Tea tries to take over
the screen. Because the backend was already activated, that exit escaped the
pre-activation mapping and surfaced as a raw traceback:
RuntimeError: Bubble Tea TUI exited with status 1.
Now main() checks for a terminal right after argument parsing. With a target
it switches to the headless path (same as -n) and prints one dim notice.
Without a target the start screen is the only way to enter one, so it prints
a panel telling the user to pass -t <target> -n and exits 1. A bare --resume
is left to the picker, which already lists runs when there is no terminal.
If the sidecar still dies after startup, check_return_code raises
TuiProcessExitedError, run_tui maps it to InteractiveInterfaceExitedError,
and main() prints an INTERACTIVE INTERFACE STOPPED panel with the -n hint
under its own telemetry name instead of re-raising.
preflight_request takes api_base_setting so a dedupe model that timed out
points the user at DEDUPE_LLM_API_BASE, not LLM_API_BASE. warm_up_llm is
now exercised with a dedicated dedupe model: own headers, same preflight
timeout, resolved through resolve_dedupe_model.
The warm-up request used settings.llm.timeout (LLM_TIMEOUT, 300s) both as
the request timeout and the wait_for bound, so a wrong LLM_API_BASE or a
dead proxy hung five minutes and then printed an empty Error line.
Add LlmSettings.preflight_timeout (LLM_PREFLIGHT_TIMEOUT, default 30) and
a shared preflight_request() used for the main and dedupe models. When it
expires the panel names the model, the limit and the setting.
Add uv lock --check, a wheel install smoke test, a PyInstaller dry run,
actionlint and zizmor on the workflows, CodeQL, and Dependabot for action
pins, uv, Go and npm dependencies. Release caches are disabled so zizmor's
cache-poisoning audit passes.