mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e584514d61 | ||
|
|
e037d8d727 | ||
|
|
fade37025d | ||
|
|
f968f8e5a7 | ||
|
|
ac0014fe65 | ||
|
|
86282e83a8 | ||
|
|
37c7f5a6ba | ||
|
|
082d4ae62c | ||
|
|
c55a8fa4ba | ||
|
|
47617969d3 | ||
|
|
27f9750cdc | ||
|
|
427cdcd9d4 | ||
|
|
384338cf31 | ||
|
|
3b79e97f00 | ||
|
|
66a283b71b | ||
|
|
74f334cb93 | ||
|
|
8e9a6bf903 | ||
|
|
0ebd3c6230 | ||
|
|
6bda366065 | ||
|
|
1f36f5d401 | ||
|
|
a70a87f272 | ||
|
|
d2fbcb726d | ||
|
|
8169e177de | ||
|
|
589bade39a | ||
|
|
d1e8225d5f | ||
|
|
8157ccba27 | ||
|
|
95d2e5fba9 | ||
|
|
21243486e2 | ||
|
|
97ed7e79a1 | ||
|
|
31c18f8f75 | ||
|
|
f23fadfbff | ||
|
|
08126eb518 | ||
|
|
d4f4697533 | ||
|
|
57304b0084 | ||
|
|
cd8270c98b | ||
|
|
93af2b94a2 | ||
|
|
960caf86aa | ||
|
|
7e02b8d8da | ||
|
|
137a42c3e3 | ||
|
|
473b3c4af1 | ||
|
|
d1a73a24f8 | ||
|
|
a2f5e3acb6 | ||
|
|
e8c2564595 | ||
|
|
78594e1645 |
@@ -6,6 +6,9 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
@@ -18,19 +21,23 @@ jobs:
|
|||||||
target: macos-x86_64
|
target: macos-x86_64
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
target: linux-x86_64
|
target: linux-x86_64
|
||||||
|
- os: ubuntu-22.04-arm
|
||||||
|
target: linux-arm64
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
target: windows-x86_64
|
target: windows-x86_64
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -38,6 +45,20 @@ jobs:
|
|||||||
uv sync --frozen
|
uv sync --frozen
|
||||||
uv run pyinstaller strix.spec --noconfirm
|
uv run pyinstaller strix.spec --noconfirm
|
||||||
|
|
||||||
|
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||||
|
dist/strix.exe --version
|
||||||
|
else
|
||||||
|
dist/strix --version
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then
|
||||||
|
file dist/strix
|
||||||
|
file dist/strix | grep -q "ARM aarch64" || {
|
||||||
|
echo "::error::linux-arm64 artifact is not an ARM aarch64 binary"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
mkdir -p dist/release
|
mkdir -p dist/release
|
||||||
|
|
||||||
@@ -50,7 +71,7 @@ jobs:
|
|||||||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: strix-${{ matrix.target }}
|
name: strix-${{ matrix.target }}
|
||||||
path: |
|
path: |
|
||||||
@@ -65,13 +86,13 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
path: release
|
path: release
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||||
with:
|
with:
|
||||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
# Node / local-viewer SPA source (the built bundle in
|
# Node / local-viewer SPA source (the built bundle in
|
||||||
# strix/viewer/static/ is committed and shipped; do not ignore it)
|
# strix/interface/viewer/static/ is committed and shipped; do not ignore it)
|
||||||
node_modules/
|
node_modules/
|
||||||
strix/viewer/frontend/node_modules/
|
strix/interface/viewer/frontend/node_modules/
|
||||||
strix/viewer/frontend/.vite/
|
strix/interface/viewer/frontend/.vite/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
+5
-5
@@ -102,16 +102,16 @@ We welcome feature ideas! Please:
|
|||||||
## 🖥️ Local viewer SPA
|
## 🖥️ Local viewer SPA
|
||||||
|
|
||||||
`strix view` serves a prebuilt web UI whose source lives in
|
`strix view` serves a prebuilt web UI whose source lives in
|
||||||
`strix/viewer/frontend/` (a Vite + React project) and whose built output is
|
`strix/interface/viewer/frontend/` (a Vite + React project) and whose built output is
|
||||||
committed to `strix/viewer/static/` and shipped in the package. End users never
|
committed to `strix/interface/viewer/static/` and shipped in the package. End users never
|
||||||
run a JS build. If you change anything under `strix/viewer/frontend/`, rebuild
|
run a JS build. If you change anything under `strix/interface/viewer/frontend/`, rebuild
|
||||||
and commit the output:
|
and commit the output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make viewer # or: cd strix/viewer/frontend && npm ci && npm run build
|
make viewer # or: cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit both the source change and the regenerated `strix/viewer/static/`.
|
Commit both the source change and the regenerated `strix/interface/viewer/static/`.
|
||||||
|
|
||||||
## 🤝 Community
|
## 🤝 Community
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ clean:
|
|||||||
|
|
||||||
viewer:
|
viewer:
|
||||||
@echo "🖥️ Building the local-viewer SPA..."
|
@echo "🖥️ Building the local-viewer SPA..."
|
||||||
cd strix/viewer/frontend && npm ci && npm run build
|
cd strix/interface/viewer/frontend && npm ci && npm run build
|
||||||
@echo "✅ Viewer built to strix/viewer/static/ (commit the changes)."
|
@echo "✅ Viewer built to strix/interface/viewer/static/ (commit the changes)."
|
||||||
|
|
||||||
dev: format lint type-check
|
dev: format lint type-check
|
||||||
@echo "✅ Development cycle complete!"
|
@echo "✅ Development cycle complete!"
|
||||||
|
|||||||
@@ -267,6 +267,20 @@ export STRIX_REASONING_EFFORT="high" # control thinking effort (default: high,
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
> Strix automatically saves your configuration to `~/.strix/cli-config.json`, so you don't have to re-enter it on every run.
|
||||||
|
|
||||||
|
#### Sign in with a ChatGPT subscription
|
||||||
|
|
||||||
|
Instead of a metered API key, you can run Strix on your ChatGPT Plus/Pro subscription:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
strix auth login chatgpt # sign in with your ChatGPT account
|
||||||
|
|
||||||
|
export STRIX_LLM="chatgpt/gpt-5.4" # chatgpt/<model> runs on the subscription
|
||||||
|
strix --target ./app-directory
|
||||||
|
|
||||||
|
strix auth status # show the active sign-in
|
||||||
|
strix auth logout # forget the sign-in
|
||||||
|
```
|
||||||
|
|
||||||
**Recommended models for best results:**
|
**Recommended models for best results:**
|
||||||
|
|
||||||
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
- [OpenAI GPT-5.4](https://openai.com/api/) - `openai/gpt-5.4`
|
||||||
|
|||||||
@@ -35,6 +35,31 @@ Configure Strix using environment variables or a config file.
|
|||||||
Timeout in seconds for memory compression operations (context summarization).
|
Timeout in seconds for memory compression operations (context summarization).
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
### Dedicated deduplication model
|
||||||
|
|
||||||
|
Finding deduplication is a cheap, structured classification task. By default it
|
||||||
|
runs on the main model, but you can route it to a smaller/cheaper model without
|
||||||
|
affecting the agents that do the actual testing.
|
||||||
|
|
||||||
|
<ParamField path="STRIX_DEDUPE_MODEL" type="string">
|
||||||
|
Model used to judge whether a candidate finding duplicates an existing report.
|
||||||
|
Falls back to `STRIX_LLM` when unset.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="DEDUPE_LLM_API_KEY" type="string">
|
||||||
|
Optional provider key for the deduplication model.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="DEDUPE_LLM_API_BASE" type="string">
|
||||||
|
Optional custom API base URL for the deduplication model. Use when the dedupe
|
||||||
|
model runs on a different endpoint than the main model.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="STRIX_DEDUPE_REASONING_EFFORT" type="string">
|
||||||
|
Reasoning effort for the deduplication model. Defaults to the model's own
|
||||||
|
baseline when unset.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
## Optional Features
|
## Optional Features
|
||||||
|
|
||||||
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
<ParamField path="PERPLEXITY_API_KEY" type="string">
|
||||||
|
|||||||
+38
-4
@@ -61,11 +61,28 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
|||||||
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
Path to a custom config file (JSON) to use instead of `~/.strix/cli-config.json`.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
<ParamField path="--max-budget-usd" type="number">
|
<ParamField path="--max-budget" type="number">
|
||||||
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
|
Maximum LLM spend in USD for the whole scan, counted cumulatively across the
|
||||||
root agent and every child agent. The budget is checked after each model
|
root agent and every child agent. The budget is checked after each model
|
||||||
response; once the running cost reaches the threshold, the scan stops cleanly
|
response.
|
||||||
with a `stopped` status (not a failure) and the sandbox is torn down.
|
|
||||||
|
In non-interactive mode (`-n`), once the running cost reaches the threshold,
|
||||||
|
the scan stops cleanly with a `stopped` status (not a failure) and the sandbox
|
||||||
|
is torn down. Sub-agents are stopped early, at 90% of the budget, reserving
|
||||||
|
the final slice for the root agent to wind down and produce the final report.
|
||||||
|
|
||||||
|
In interactive mode, reaching the budget pauses the scan instead of ending
|
||||||
|
it: every agent parks, and sending any message resumes the scan with the cap
|
||||||
|
extended by the original budget amount. There is no sub-agent reserve in
|
||||||
|
interactive mode.
|
||||||
|
|
||||||
|
As the budget is approached, graduated wrap-up warnings are surfaced to
|
||||||
|
**every** agent so they can finish their work and call their lifecycle tool
|
||||||
|
before the hard stop. The bands sit just below each role's own stop point: the
|
||||||
|
root is warned at **70%, 85% and 95%** (it stops at 100%), while sub-agents are
|
||||||
|
warned at **75%, 80% and 85%** (they stop at the 90% reserve). In interactive
|
||||||
|
mode every agent uses the **70%, 85% and 95%** bands. Percentages shown in the
|
||||||
|
warnings are the real cumulative spend against the full budget.
|
||||||
|
|
||||||
Must be greater than `0`. Omit the flag for no limit.
|
Must be greater than `0`. Omit the flag for no limit.
|
||||||
|
|
||||||
@@ -84,6 +101,19 @@ strix (--target <target> | --target-list <path> | --mount <path>) [options]
|
|||||||
counts.
|
counts.
|
||||||
</ParamField>
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="--max-turns" type="integer" default="500">
|
||||||
|
Maximum number of turns (one model response plus its tool round) allotted to
|
||||||
|
**each** agent, applied per run. When an agent reaches this limit it is
|
||||||
|
force-stopped.
|
||||||
|
|
||||||
|
As the limit is approached, graduated wrap-up warnings (at 70%, 85% and 95%)
|
||||||
|
are injected into that agent's next model turn so it can prioritise its
|
||||||
|
remaining work and call its lifecycle tool (`finish_scan` for the root agent,
|
||||||
|
`agent_finish` for sub-agents) before the hard stop.
|
||||||
|
|
||||||
|
Must be greater than `0`.
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,6 +129,9 @@ strix --target api.example.com --instruction "Focus on IDOR and auth bypass"
|
|||||||
# CI/CD mode
|
# CI/CD mode
|
||||||
strix -n --target ./ --scan-mode quick
|
strix -n --target ./ --scan-mode quick
|
||||||
|
|
||||||
|
# Cap cost and per-agent turns
|
||||||
|
strix --target https://example.com --max-budget 25 --max-turns 300
|
||||||
|
|
||||||
# Force diff-scope against a specific base ref
|
# Force diff-scope against a specific base ref
|
||||||
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
|
||||||
|
|
||||||
@@ -116,5 +149,6 @@ strix --mount ./huge-monorepo
|
|||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| 0 | Scan completed, no vulnerabilities found |
|
| 0 | Scan completed successfully (interactive mode always exits `0`; in headless mode, `0` means no vulnerabilities were found) |
|
||||||
|
| 1 | A fatal error occurred before or during the scan (e.g. missing environment variables, Docker unavailable, invalid config file, diff-scope resolution failure, or an unhandled error) |
|
||||||
| 2 | Vulnerabilities found (headless mode only) |
|
| 2 | Vulnerabilities found (headless mode only) |
|
||||||
|
|||||||
+23
-9
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "strix-agent"
|
name = "strix-agent"
|
||||||
version = "1.3.0"
|
version = "1.4.1"
|
||||||
description = "Open-source AI Hackers for your apps"
|
description = "Open-source AI Hackers for your apps"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
@@ -46,7 +46,9 @@ dependencies = [
|
|||||||
"caido-sdk-client>=0.2.0",
|
"caido-sdk-client>=0.2.0",
|
||||||
"reportlab>=4.0",
|
"reportlab>=4.0",
|
||||||
"pypdf>=5.0",
|
"pypdf>=5.0",
|
||||||
"cryptography>=42",
|
# Cap <49: 49.x drops the universal2 macOS wheel (arm64-only), which breaks
|
||||||
|
# the Intel macOS (macos-x86_64) release build's `uv sync --frozen`.
|
||||||
|
"cryptography>=48.0.1,<49",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -77,10 +79,10 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["strix"]
|
packages = ["strix"]
|
||||||
# The prebuilt viewer bundle under strix/viewer/static/ ships automatically
|
# The prebuilt viewer bundle under strix/interface/viewer/static/ ships automatically
|
||||||
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
# (hatchling includes non-.py files under the package). The Vite SOURCE lives
|
||||||
# under the package dir too (strix/viewer/frontend/) but must never ship in the wheel.
|
# under the package dir too (strix/interface/viewer/frontend/) but must never ship in the wheel.
|
||||||
exclude = ["strix/viewer/frontend", "strix/viewer/frontend/**"]
|
exclude = ["strix/interface/viewer/frontend", "strix/interface/viewer/frontend/**"]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Type Checking Configuration
|
# Type Checking Configuration
|
||||||
@@ -120,6 +122,7 @@ module = [
|
|||||||
"pydantic_settings.*",
|
"pydantic_settings.*",
|
||||||
"reportlab.*",
|
"reportlab.*",
|
||||||
"pypdf.*",
|
"pypdf.*",
|
||||||
|
"pygments.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
disable_error_code = ["import-untyped"]
|
disable_error_code = ["import-untyped"]
|
||||||
@@ -213,12 +216,16 @@ ignore = [
|
|||||||
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
# Test doubles use fixture tokens/passwords and match a callee signature whose
|
||||||
# args they intentionally ignore.
|
# args they intentionally ignore.
|
||||||
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
"tests/test_viewer_auth.py" = ["S105", "S106", "ARG001"]
|
||||||
|
"tests/test_codex_auth.py" = ["S105", "S106", "SLF001"]
|
||||||
|
# Stdlib HTTP handler overrides (do_GET/do_POST).
|
||||||
|
"strix/interface/auth_cli.py" = ["N802"]
|
||||||
|
"tests/test_codex_streaming.py" = ["N802"]
|
||||||
"tests/test_report_pdf.py" = ["S105", "S106"]
|
"tests/test_report_pdf.py" = ["S105", "S106"]
|
||||||
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
# Stdlib HTTP handler overrides (do_GET/do_POST) and lazy imports that avoid a
|
||||||
# circular dependency with strix.telemetry / strix.viewer.report_pdf.
|
# circular dependency with strix.telemetry / strix.interface.viewer.report_pdf.
|
||||||
"strix/viewer/server.py" = ["N802", "PLC0415"]
|
"strix/interface/viewer/server.py" = ["N802", "PLC0415"]
|
||||||
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
# Lazy telemetry import to avoid importing PostHog before the viewer starts.
|
||||||
"strix/viewer/cli.py" = ["PLC0415"]
|
"strix/interface/viewer/cli.py" = ["PLC0415"]
|
||||||
# Lazy imports inside functions to avoid circular dependency with
|
# Lazy imports inside functions to avoid circular dependency with
|
||||||
# strix.telemetry / strix.report.dedupe / cvss.
|
# strix.telemetry / strix.report.dedupe / cvss.
|
||||||
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
"strix/tools/notes/tools.py" = ["PLC0415", "TC002"]
|
||||||
@@ -251,9 +258,16 @@ ignore = [
|
|||||||
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
"strix/core/runner.py" = ["TC003", "PLR0912", "PLR0915", "PLC0415"]
|
||||||
# ReportState carries scan artifact/report fields and
|
# ReportState carries scan artifact/report fields and
|
||||||
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
# a runtime ``Callable`` annotation on ``vulnerability_found_callback``.
|
||||||
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401"]
|
"strix/report/state.py" = ["TC003", "PLR0912", "PLR0915", "E501", "PERF401", "PLC0415"]
|
||||||
"strix/report/usage.py" = ["PLC0415"]
|
"strix/report/usage.py" = ["PLC0415"]
|
||||||
|
# Lazy import of strix.config.models avoids a circular dependency between the
|
||||||
|
# report pipeline and the config layer.
|
||||||
|
"strix/report/dedupe.py" = ["PLC0415"]
|
||||||
|
"strix/telemetry/logging.py" = ["PLC0415"]
|
||||||
"strix/config/models.py" = ["PLC0415"]
|
"strix/config/models.py" = ["PLC0415"]
|
||||||
|
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
|
||||||
|
# don't pull them in.
|
||||||
|
"strix/config/codex.py" = ["PLC0415"]
|
||||||
# Interface utility branches per scope-mode / target-type combination;
|
# Interface utility branches per scope-mode / target-type combination;
|
||||||
# splitting would obscure the decision tree without simplifying it.
|
# splitting would obscure the decision tree without simplifying it.
|
||||||
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
"strix/interface/utils.py" = ["PLR0912", "BLE001", "PLC0415"]
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
APP=strix
|
APP=strix
|
||||||
REPO="usestrix/strix"
|
REPO="usestrix/strix"
|
||||||
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.0.0"
|
STRIX_IMAGE="ghcr.io/usestrix/strix-sandbox:1.1.0"
|
||||||
|
|
||||||
MUTED='\033[0;2m'
|
MUTED='\033[0;2m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -41,7 +41,7 @@ fi
|
|||||||
|
|
||||||
combo="$os-$arch"
|
combo="$os-$arch"
|
||||||
case "$combo" in
|
case "$combo" in
|
||||||
linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64)
|
linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||||
|
|||||||
+7
-7
@@ -26,7 +26,7 @@ for tcss_file in strix_root.rglob('*.tcss'):
|
|||||||
datas.append((str(tcss_file), str(rel_path.parent)))
|
datas.append((str(tcss_file), str(rel_path.parent)))
|
||||||
|
|
||||||
# Prebuilt local-viewer SPA (served by `strix view`).
|
# Prebuilt local-viewer SPA (served by `strix view`).
|
||||||
viewer_static = strix_root / 'viewer' / 'static'
|
viewer_static = strix_root / 'interface' / 'viewer' / 'static'
|
||||||
for asset in viewer_static.rglob('*'):
|
for asset in viewer_static.rglob('*'):
|
||||||
if asset.is_file():
|
if asset.is_file():
|
||||||
rel_path = asset.relative_to(project_root)
|
rel_path = asset.relative_to(project_root)
|
||||||
@@ -158,12 +158,12 @@ hiddenimports = [
|
|||||||
'strix.report.dedupe',
|
'strix.report.dedupe',
|
||||||
'strix.report.state',
|
'strix.report.state',
|
||||||
'strix.report.writer',
|
'strix.report.writer',
|
||||||
'strix.viewer',
|
'strix.interface.viewer',
|
||||||
'strix.viewer.auth',
|
'strix.interface.viewer.auth',
|
||||||
'strix.viewer.cli',
|
'strix.interface.viewer.cli',
|
||||||
'strix.viewer.report_pdf',
|
'strix.interface.viewer.report_pdf',
|
||||||
'strix.viewer.server',
|
'strix.interface.viewer.server',
|
||||||
'strix.viewer.transcript',
|
'strix.interface.viewer.transcript',
|
||||||
|
|
||||||
# PDF report generation + encryption
|
# PDF report generation + encryption
|
||||||
'reportlab',
|
'reportlab',
|
||||||
|
|||||||
+91
-14
@@ -16,6 +16,7 @@ from agents.tool import CustomTool, FunctionTool, Tool
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from strix.agents.prompt import render_system_prompt
|
from strix.agents.prompt import render_system_prompt
|
||||||
|
from strix.config import load_settings
|
||||||
from strix.tools.agents_graph.tools import (
|
from strix.tools.agents_graph.tools import (
|
||||||
agent_finish,
|
agent_finish,
|
||||||
create_agent,
|
create_agent,
|
||||||
@@ -33,6 +34,7 @@ from strix.tools.notes.tools import (
|
|||||||
list_notes,
|
list_notes,
|
||||||
update_note,
|
update_note,
|
||||||
)
|
)
|
||||||
|
from strix.tools.output_store import bound_and_store, bound_text
|
||||||
from strix.tools.proxy.tools import (
|
from strix.tools.proxy.tools import (
|
||||||
list_requests,
|
list_requests,
|
||||||
list_sitemap,
|
list_sitemap,
|
||||||
@@ -41,7 +43,12 @@ from strix.tools.proxy.tools import (
|
|||||||
view_request,
|
view_request,
|
||||||
view_sitemap_entry,
|
view_sitemap_entry,
|
||||||
)
|
)
|
||||||
from strix.tools.reporting.tool import create_dependency_report, create_vulnerability_report
|
from strix.tools.reporting.tool import (
|
||||||
|
create_dependency_report,
|
||||||
|
create_vulnerability_report,
|
||||||
|
get_report,
|
||||||
|
list_reports,
|
||||||
|
)
|
||||||
from strix.tools.thinking.tool import think
|
from strix.tools.thinking.tool import think
|
||||||
from strix.tools.todo.tools import (
|
from strix.tools.todo.tools import (
|
||||||
create_todo,
|
create_todo,
|
||||||
@@ -103,8 +110,36 @@ def _extract_custom_input(tool: CustomTool, raw_input: str | dict[str, Any]) ->
|
|||||||
return value if isinstance(value, str) else ""
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_output_limits() -> tuple[int, int]:
|
||||||
|
context = load_settings().context
|
||||||
|
return context.tool_output_max_lines, context.tool_output_max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def _bound_result(result: Any) -> Any:
|
||||||
|
if not isinstance(result, str):
|
||||||
|
return result
|
||||||
|
max_lines, max_bytes = _tool_output_limits()
|
||||||
|
return await bound_and_store(result, max_lines=max_lines, max_bytes=max_bytes)
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_error(exc: Exception) -> str:
|
def _format_tool_error(exc: Exception) -> str:
|
||||||
return str(exc) or exc.__class__.__name__
|
message = str(exc) or exc.__class__.__name__
|
||||||
|
max_lines, max_bytes = _tool_output_limits()
|
||||||
|
return bound_text(message, max_lines=max_lines, max_bytes=max_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_bounded_result(tool: FunctionTool) -> FunctionTool:
|
||||||
|
"""Cap a tool's result size before it enters history (idempotent)."""
|
||||||
|
if getattr(tool, "_strix_bounded", False):
|
||||||
|
return tool
|
||||||
|
invoke_tool = tool.on_invoke_tool
|
||||||
|
|
||||||
|
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||||
|
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||||
|
|
||||||
|
tool.on_invoke_tool = invoke
|
||||||
|
tool._strix_bounded = True # type: ignore[attr-defined]
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
||||||
@@ -112,7 +147,7 @@ def _function_tool_with_error_result(tool: FunctionTool) -> FunctionTool:
|
|||||||
|
|
||||||
async def invoke(ctx: Any, raw_input: str) -> Any:
|
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||||
try:
|
try:
|
||||||
return await invoke_tool(ctx, raw_input)
|
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||||
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
except Exception as exc: # noqa: BLE001 - tool errors should be model-visible results.
|
||||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||||
return _format_tool_error(exc)
|
return _format_tool_error(exc)
|
||||||
@@ -127,7 +162,7 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
|||||||
if not custom_input:
|
if not custom_input:
|
||||||
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
return f"`{_custom_tool_input_field(tool)}` must be a non-empty string."
|
||||||
try:
|
try:
|
||||||
return await tool.on_invoke_tool(ctx, custom_input)
|
return await _bound_result(await tool.on_invoke_tool(ctx, custom_input))
|
||||||
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
except Exception as exc: # noqa: BLE001 - matches SDK CustomTool error-as-result behavior.
|
||||||
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
logger.debug("Tool %s failed; returning error as result", tool.name, exc_info=True)
|
||||||
return _format_tool_error(exc)
|
return _format_tool_error(exc)
|
||||||
@@ -159,12 +194,35 @@ def _custom_tool_as_function_tool(tool: CustomTool) -> FunctionTool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configure_chat_completions_filesystem_tools(toolset: Any) -> None:
|
def _bound_custom_tool(tool: CustomTool) -> CustomTool:
|
||||||
|
"""Bound a native ``CustomTool`` result in place (Responses path)."""
|
||||||
|
invoke_tool = tool.on_invoke_tool
|
||||||
|
|
||||||
|
async def invoke(ctx: Any, raw_input: str) -> Any:
|
||||||
|
return await _bound_result(await invoke_tool(ctx, raw_input))
|
||||||
|
|
||||||
|
tool.on_invoke_tool = invoke
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_filesystem_tools(toolset: Any, *, chat_completions: bool) -> None:
|
||||||
for name, tool in vars(toolset).items():
|
for name, tool in vars(toolset).items():
|
||||||
if isinstance(tool, CustomTool):
|
if chat_completions:
|
||||||
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
if isinstance(tool, CustomTool):
|
||||||
|
setattr(toolset, name, _custom_tool_as_function_tool(tool))
|
||||||
|
elif isinstance(tool, FunctionTool):
|
||||||
|
setattr(toolset, name, _function_tool_with_error_result(tool))
|
||||||
|
elif isinstance(tool, CustomTool):
|
||||||
|
setattr(toolset, name, _bound_custom_tool(tool))
|
||||||
elif isinstance(tool, FunctionTool):
|
elif isinstance(tool, FunctionTool):
|
||||||
setattr(toolset, name, _function_tool_with_error_result(tool))
|
setattr(toolset, name, _with_bounded_result(tool))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_filesystem_configurator(*, chat_completions: bool) -> Any:
|
||||||
|
def configure(toolset: Any) -> None:
|
||||||
|
_configure_filesystem_tools(toolset, chat_completions=chat_completions)
|
||||||
|
|
||||||
|
return configure
|
||||||
|
|
||||||
|
|
||||||
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
_CHARS_ESCAPE_RE = re.compile(r"\\(?:u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0abtnvfr\\])")
|
||||||
@@ -205,6 +263,16 @@ def _format_validation_error(tool_name: str, exc: ValidationError) -> str:
|
|||||||
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
return f"{tool_name}: invalid arguments — " + "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_shell_output_cap(parsed: dict[str, Any]) -> None:
|
||||||
|
"""Clamp the SDK shell tools' ``max_output_tokens`` to the configured
|
||||||
|
ceiling; a smaller explicit value is respected."""
|
||||||
|
ceiling = load_settings().context.tool_output_max_tokens
|
||||||
|
requested = parsed.get("max_output_tokens")
|
||||||
|
parsed["max_output_tokens"] = (
|
||||||
|
ceiling if not isinstance(requested, int) or requested > ceiling else requested
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
||||||
invoke_tool = tool.on_invoke_tool
|
invoke_tool = tool.on_invoke_tool
|
||||||
|
|
||||||
@@ -213,8 +281,10 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool:
|
|||||||
parsed = json.loads(raw_input)
|
parsed = json.loads(raw_input)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
parsed = None
|
parsed = None
|
||||||
if isinstance(parsed, dict) and "shell" not in parsed:
|
if isinstance(parsed, dict):
|
||||||
parsed["shell"] = "bash"
|
if "shell" not in parsed:
|
||||||
|
parsed["shell"] = "bash"
|
||||||
|
_apply_shell_output_cap(parsed)
|
||||||
raw_input = json.dumps(parsed)
|
raw_input = json.dumps(parsed)
|
||||||
try:
|
try:
|
||||||
return await invoke_tool(ctx, raw_input)
|
return await invoke_tool(ctx, raw_input)
|
||||||
@@ -240,8 +310,10 @@ def _wrap_write_stdin(tool: FunctionTool) -> FunctionTool:
|
|||||||
parsed = json.loads(raw_input)
|
parsed = json.loads(raw_input)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
parsed = None
|
parsed = None
|
||||||
if isinstance(parsed, dict) and isinstance(parsed.get("chars"), str):
|
if isinstance(parsed, dict):
|
||||||
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
if isinstance(parsed.get("chars"), str):
|
||||||
|
parsed["chars"] = _decode_chars_escape(parsed["chars"])
|
||||||
|
_apply_shell_output_cap(parsed)
|
||||||
raw_input = json.dumps(parsed)
|
raw_input = json.dumps(parsed)
|
||||||
try:
|
try:
|
||||||
return await invoke_tool(ctx, raw_input)
|
return await invoke_tool(ctx, raw_input)
|
||||||
@@ -343,6 +415,8 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
|||||||
web_search,
|
web_search,
|
||||||
create_vulnerability_report,
|
create_vulnerability_report,
|
||||||
create_dependency_report,
|
create_dependency_report,
|
||||||
|
list_reports,
|
||||||
|
get_report,
|
||||||
list_requests,
|
list_requests,
|
||||||
view_request,
|
view_request,
|
||||||
repeat_request,
|
repeat_request,
|
||||||
@@ -440,6 +514,9 @@ def build_strix_agent(
|
|||||||
else:
|
else:
|
||||||
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
|
||||||
_ensure_unique_tool_names(tools)
|
_ensure_unique_tool_names(tools)
|
||||||
|
tools = [
|
||||||
|
_with_bounded_result(tool) if isinstance(tool, FunctionTool) else tool for tool in tools
|
||||||
|
]
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
"Built %s agent '%s' (skills=%d, tools=%d, scan_mode=%s, whitebox=%s)",
|
||||||
@@ -459,8 +536,8 @@ def build_strix_agent(
|
|||||||
model=None,
|
model=None,
|
||||||
capabilities=[
|
capabilities=[
|
||||||
Filesystem(
|
Filesystem(
|
||||||
configure_tools=(
|
configure_tools=_make_filesystem_configurator(
|
||||||
_configure_chat_completions_filesystem_tools if chat_completions_tools else None
|
chat_completions=chat_completions_tools,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Shell(
|
Shell(
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ EFFICIENCY TACTICS:
|
|||||||
script fail with `ModuleNotFoundError`.
|
script fail with `ModuleNotFoundError`.
|
||||||
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
- `exec_command` runs each command in a fresh non-interactive shell (plain
|
||||||
pipes, no TTY). To drive an interactive or long-running process with
|
pipes, no TTY). To drive an interactive or long-running process with
|
||||||
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to send Ctrl-C —
|
`write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C —
|
||||||
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
you MUST start it with `exec_command(cmd="...", tty=true)` and then
|
||||||
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
`write_stdin(session_id=<id>, chars="...")`. Calling `write_stdin` on a
|
||||||
default (non-TTY) command or on a process that has already exited fails with
|
default (non-TTY) command or on a process that has already exited fails with
|
||||||
@@ -215,6 +215,7 @@ VALIDATION REQUIREMENTS:
|
|||||||
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
|
||||||
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
|
||||||
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent
|
||||||
|
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
|
||||||
</execution_guidelines>
|
</execution_guidelines>
|
||||||
|
|
||||||
<vulnerability_focus>
|
<vulnerability_focus>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from strix.config.loader import (
|
|||||||
persist_current,
|
persist_current,
|
||||||
)
|
)
|
||||||
from strix.config.settings import (
|
from strix.config.settings import (
|
||||||
|
ContextSettings,
|
||||||
|
DedupeSettings,
|
||||||
IntegrationSettings,
|
IntegrationSettings,
|
||||||
LlmSettings,
|
LlmSettings,
|
||||||
RuntimeSettings,
|
RuntimeSettings,
|
||||||
@@ -26,6 +28,8 @@ from strix.config.settings import (
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ContextSettings",
|
||||||
|
"DedupeSettings",
|
||||||
"IntegrationSettings",
|
"IntegrationSettings",
|
||||||
"LlmSettings",
|
"LlmSettings",
|
||||||
"RuntimeSettings",
|
"RuntimeSettings",
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
"""ChatGPT (Codex) subscription auth: OAuth login, token refresh, and the OpenAI
|
||||||
|
client that routes inference through the ChatGPT backend.
|
||||||
|
|
||||||
|
Mirrors OpenAI's Codex CLI: OAuth 2.0 + PKCE against ``auth.openai.com``, with the
|
||||||
|
access token sent as a ``Bearer`` token to ``chatgpt.com/backend-api/codex``. Using
|
||||||
|
a ChatGPT subscription outside OpenAI's own products is not officially supported by
|
||||||
|
OpenAI; the user chooses this path knowingly. The OAuth constants are OpenAI's own
|
||||||
|
Codex 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 threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER = "codex"
|
||||||
|
|
||||||
|
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
|
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||||
|
TOKEN_URL = "https://auth.openai.com/oauth/token" # noqa: S105 # nosec B105 - URL, not a secret
|
||||||
|
CALLBACK_HOST = "localhost"
|
||||||
|
CALLBACK_PORT = 1455
|
||||||
|
CALLBACK_PATH = "/auth/callback"
|
||||||
|
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
||||||
|
SCOPE = "openid profile email offline_access"
|
||||||
|
|
||||||
|
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||||
|
ORIGINATOR = "codex_cli_rs"
|
||||||
|
_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:
|
||||||
|
AUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = AUTH_PATH.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
tmp.chmod(0o600)
|
||||||
|
tmp.replace(AUTH_PATH)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
AUTH_PATH.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def read_record() -> dict[str, Any] | None:
|
||||||
|
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")):
|
||||||
|
return None
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def is_authenticated() -> bool:
|
||||||
|
return read_record() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def save_record(record: dict[str, Any]) -> None:
|
||||||
|
data = _read_store()
|
||||||
|
data[PROVIDER] = record
|
||||||
|
_write_store(data)
|
||||||
|
|
||||||
|
|
||||||
|
def logout() -> None:
|
||||||
|
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 _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):
|
||||||
|
def __init__(self, code: str, message: str | None = None) -> None:
|
||||||
|
self.code = code
|
||||||
|
super().__init__(message or code)
|
||||||
|
|
||||||
|
|
||||||
|
class CodexContentGuardrailError(Exception):
|
||||||
|
"""The ChatGPT backend refused a request via its content guardrail.
|
||||||
|
Terminal — retrying identical content never clears the block."""
|
||||||
|
|
||||||
|
def __init__(self, model: str, original: BaseException | None = None) -> None:
|
||||||
|
self.model = model
|
||||||
|
self.original = original
|
||||||
|
super().__init__(
|
||||||
|
f"'{model}' was blocked by ChatGPT's content guardrails "
|
||||||
|
f"(flagged as a possible cybersecurity risk). "
|
||||||
|
f"Set STRIX_LLM to a model that isn't blocked and re-run."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GUARDRAIL_MARKERS = (
|
||||||
|
"flagged for possible cybersecurity risk",
|
||||||
|
"trusted access for cyber",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_content_guardrail_error(exc: BaseException) -> bool:
|
||||||
|
if isinstance(exc, CodexContentGuardrailError):
|
||||||
|
return True
|
||||||
|
text = str(exc).lower()
|
||||||
|
return any(marker in text for marker in _GUARDRAIL_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"id_token_add_organizations": "true",
|
||||||
|
"codex_cli_simplified_flow": "true",
|
||||||
|
"originator": ORIGINATOR,
|
||||||
|
}
|
||||||
|
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 CodexAuthError("unavailable", str(exc)) from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
detail = response.text[:300]
|
||||||
|
raise CodexAuthError("token_http_error", f"HTTP {response.status_code}: {detail}")
|
||||||
|
data = json.loads(response.content or b"{}")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CodexAuthError("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 CodexAuthError("bad_response", "token response missing access_token")
|
||||||
|
if not isinstance(refresh, str) or not refresh:
|
||||||
|
raise CodexAuthError("bad_response", "token response missing refresh_token")
|
||||||
|
account_id = _account_id_from_jwt(access) or _account_id_from_jwt(
|
||||||
|
data.get("id_token") if isinstance(data.get("id_token"), str) else ""
|
||||||
|
)
|
||||||
|
if not account_id:
|
||||||
|
raise CodexAuthError("no_account_id", "could not read chatgpt_account_id from token")
|
||||||
|
ttl = expires_in if isinstance(expires_in, int | float) else 3600
|
||||||
|
return {
|
||||||
|
"type": "oauth",
|
||||||
|
"provider": PROVIDER,
|
||||||
|
"access": access,
|
||||||
|
"refresh": refresh,
|
||||||
|
"account_id": account_id,
|
||||||
|
"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 _account_id_from_jwt(token: str | None) -> str | None:
|
||||||
|
"""Read the account id claim without verifying the JWT (the server enforces
|
||||||
|
authenticity on use); it feeds the ``chatgpt-account-id`` header."""
|
||||||
|
if not token or token.count(".") != 2:
|
||||||
|
return None
|
||||||
|
payload_b64 = token.split(".")[1]
|
||||||
|
padding = "=" * (-len(payload_b64) % 4)
|
||||||
|
try:
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
auth = payload.get(_ACCOUNT_CLAIM)
|
||||||
|
if isinstance(auth, dict):
|
||||||
|
account_id = auth.get("chatgpt_account_id")
|
||||||
|
if isinstance(account_id, str) and account_id:
|
||||||
|
return account_id
|
||||||
|
organizations = payload.get("organizations")
|
||||||
|
if isinstance(organizations, list) and organizations and isinstance(organizations[0], dict):
|
||||||
|
org_id = organizations[0].get("id")
|
||||||
|
if isinstance(org_id, str) and org_id:
|
||||||
|
return org_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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() -> tuple[str, str]:
|
||||||
|
"""Return ``(access_token, account_id)``, refreshing under the cross-process
|
||||||
|
guard if near expiry."""
|
||||||
|
record = read_record()
|
||||||
|
if record is None:
|
||||||
|
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||||
|
if not _near_expiry(record):
|
||||||
|
return record["access"], record["account_id"]
|
||||||
|
with _refresh_guard():
|
||||||
|
record = read_record()
|
||||||
|
if record is None:
|
||||||
|
raise CodexAuthError("not_authenticated", "not signed in; run: strix auth login")
|
||||||
|
if not _near_expiry(record):
|
||||||
|
return record["access"], record["account_id"]
|
||||||
|
try:
|
||||||
|
refreshed = refresh_tokens(record["refresh"])
|
||||||
|
except CodexAuthError:
|
||||||
|
# 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 latest["access"], latest["account_id"]
|
||||||
|
raise
|
||||||
|
save_record(refreshed)
|
||||||
|
return refreshed["access"], refreshed["account_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_openai_client() -> AsyncOpenAI:
|
||||||
|
"""An ``AsyncOpenAI`` for the ChatGPT backend. 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, account_id = await asyncio.to_thread(get_valid_token)
|
||||||
|
request.headers["Authorization"] = f"Bearer {access}"
|
||||||
|
request.headers["chatgpt-account-id"] = account_id
|
||||||
|
|
||||||
|
http_client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||||
|
event_hooks={"request": [_auth_hook]},
|
||||||
|
)
|
||||||
|
return AsyncOpenAI(
|
||||||
|
api_key="strix-codex-oauth", # placeholder; the hook overwrites Authorization
|
||||||
|
base_url=CODEX_BASE_URL,
|
||||||
|
http_client=http_client,
|
||||||
|
default_headers={
|
||||||
|
"OpenAI-Beta": "responses=experimental",
|
||||||
|
"originator": ORIGINATOR,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_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 = "chatgpt/"
|
||||||
|
|
||||||
|
|
||||||
|
def subscription_model(model_name: str | None) -> str | None:
|
||||||
|
"""The model slug behind a ``chatgpt/<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"
|
||||||
+170
-12
@@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import inspect
|
||||||
import os
|
import os
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from agents import set_default_openai_api, set_default_openai_key, set_tracing_disabled
|
from agents import (
|
||||||
|
set_default_openai_api,
|
||||||
|
set_default_openai_key,
|
||||||
|
set_tracing_disabled,
|
||||||
|
)
|
||||||
|
from agents.model_settings import ModelSettings
|
||||||
from agents.models.multi_provider import MultiProvider
|
from agents.models.multi_provider import MultiProvider
|
||||||
|
from agents.models.openai_responses import OpenAIResponsesModel
|
||||||
from agents.retry import (
|
from agents.retry import (
|
||||||
ModelRetryBackoffSettings,
|
ModelRetryBackoffSettings,
|
||||||
ModelRetrySettings,
|
ModelRetrySettings,
|
||||||
RetryPolicyContext,
|
RetryPolicyContext,
|
||||||
retry_policies,
|
retry_policies,
|
||||||
)
|
)
|
||||||
|
from openai.types.shared import Reasoning
|
||||||
|
|
||||||
|
from strix.config import codex
|
||||||
|
from strix.config.loader import load_settings
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from agents.models.interface import ModelProvider
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from strix.config.settings import Settings
|
from agents.models.interface import Model, ModelProvider
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from strix.config.settings import ReasoningEffort, Settings
|
||||||
|
|
||||||
|
|
||||||
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
def request_timeout_extra_args(timeout_s: float | None) -> dict[str, float] | None:
|
||||||
@@ -33,9 +48,93 @@ def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
|||||||
normalized = context.normalized
|
normalized = context.normalized
|
||||||
if normalized.is_abort:
|
if normalized.is_abort:
|
||||||
return False
|
return False
|
||||||
|
if codex.is_content_guardrail_error(context.error):
|
||||||
|
return False
|
||||||
return normalized.status_code is None
|
return normalized.status_code is None
|
||||||
|
|
||||||
|
|
||||||
|
class _CodexResponsesModel(OpenAIResponsesModel):
|
||||||
|
"""Responses model for the ChatGPT subscription backend (always streamed, stateless)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
openai_client: AsyncOpenAI,
|
||||||
|
*,
|
||||||
|
reasoning_effort: ReasoningEffort | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(model, openai_client)
|
||||||
|
self._reasoning_effort = reasoning_effort
|
||||||
|
|
||||||
|
def _codex_settings(self, model_settings: ModelSettings) -> ModelSettings:
|
||||||
|
overrides = ModelSettings(store=False, response_include=["reasoning.encrypted_content"])
|
||||||
|
effort = self._reasoning_effort
|
||||||
|
if effort and effort != "none":
|
||||||
|
# Clamp to efforts the backend accepts.
|
||||||
|
if effort == "minimal":
|
||||||
|
effort = "low"
|
||||||
|
elif effort == "xhigh":
|
||||||
|
effort = "high"
|
||||||
|
overrides = overrides.resolve(ModelSettings(reasoning=Reasoning(effort=effort)))
|
||||||
|
return model_settings.resolve(overrides)
|
||||||
|
|
||||||
|
async def _fetch_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any:
|
||||||
|
if len(args) >= 3: # model_settings is positional arg 2
|
||||||
|
args = (*args[:2], self._codex_settings(args[2]), *args[3:])
|
||||||
|
try:
|
||||||
|
events = await super()._fetch_response(*args, stream=True, **kwargs) # type: ignore[call-overload]
|
||||||
|
except Exception as exc:
|
||||||
|
guardrail = self._as_guardrail(exc)
|
||||||
|
if guardrail is not None:
|
||||||
|
raise guardrail from exc
|
||||||
|
raise
|
||||||
|
guarded = self._guarded(events)
|
||||||
|
if stream:
|
||||||
|
return guarded
|
||||||
|
final_response = None
|
||||||
|
async for event in guarded:
|
||||||
|
if getattr(event, "type", None) == "response.completed":
|
||||||
|
final_response = event.response
|
||||||
|
if final_response is None:
|
||||||
|
msg = "ChatGPT backend stream ended without a completed response"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return final_response
|
||||||
|
|
||||||
|
def _as_guardrail(self, exc: BaseException) -> codex.CodexContentGuardrailError | None:
|
||||||
|
if isinstance(exc, codex.CodexContentGuardrailError):
|
||||||
|
return exc
|
||||||
|
if codex.is_content_guardrail_error(exc):
|
||||||
|
return codex.CodexContentGuardrailError(self.model, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _guarded(self, events: Any) -> AsyncIterator[Any]:
|
||||||
|
"""Convert mid-stream guardrail rejections and close the stream on exit."""
|
||||||
|
try:
|
||||||
|
async for event in events:
|
||||||
|
yield event
|
||||||
|
except Exception as exc:
|
||||||
|
guardrail = self._as_guardrail(exc)
|
||||||
|
if guardrail is not None:
|
||||||
|
raise guardrail from exc
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await self._aclose(events)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _aclose(events: Any) -> None:
|
||||||
|
aclose = getattr(events, "aclose", None)
|
||||||
|
if callable(aclose):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await aclose()
|
||||||
|
return
|
||||||
|
close = getattr(events, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
result = close()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
|
||||||
class StrixProvider(MultiProvider):
|
class StrixProvider(MultiProvider):
|
||||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||||
so users type ``deepseek/deepseek-chat`` rather than
|
so users type ``deepseek/deepseek-chat`` rather than
|
||||||
@@ -59,6 +158,16 @@ class StrixProvider(MultiProvider):
|
|||||||
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
return self._get_fallback_provider("litellm"), f"ollama_chat/{stripped_model_name}"
|
||||||
return self._get_fallback_provider("litellm"), original_model_name
|
return self._get_fallback_provider("litellm"), original_model_name
|
||||||
|
|
||||||
|
def get_model(self, model_name: str | None) -> Model:
|
||||||
|
slug = codex.subscription_model(model_name)
|
||||||
|
if slug:
|
||||||
|
return _CodexResponsesModel(
|
||||||
|
slug,
|
||||||
|
codex.get_subscription_client(),
|
||||||
|
reasoning_effort=load_settings().llm.reasoning_effort,
|
||||||
|
)
|
||||||
|
return super().get_model(model_name)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
||||||
max_retries=5,
|
max_retries=5,
|
||||||
@@ -77,39 +186,42 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
RECOMMENDED_MODEL_NAMES = (
|
RECOMMENDED_MODEL_NAMES = (
|
||||||
"openai/gpt-5.6",
|
|
||||||
"openai/gpt-5.6-sol",
|
"openai/gpt-5.6-sol",
|
||||||
"openai/gpt-5.6-terra",
|
"openai/gpt-5.6-terra",
|
||||||
"openai/gpt-5.5",
|
"openai/gpt-5.6-luna",
|
||||||
|
"openai/gpt-5.6",
|
||||||
"openai/gpt-5.5-pro",
|
"openai/gpt-5.5-pro",
|
||||||
|
"openai/gpt-5.5",
|
||||||
"openai/gpt-5.4",
|
"openai/gpt-5.4",
|
||||||
"openai/gpt-5.3-codex",
|
"openai/gpt-5.3-codex",
|
||||||
"anthropic/claude-fable-5",
|
"anthropic/claude-fable-5",
|
||||||
|
"anthropic/claude-opus-5",
|
||||||
"anthropic/claude-opus-4-8",
|
"anthropic/claude-opus-4-8",
|
||||||
"anthropic/claude-opus-4-7",
|
|
||||||
"anthropic/claude-sonnet-5",
|
"anthropic/claude-sonnet-5",
|
||||||
"anthropic/claude-sonnet-4-6",
|
"anthropic/claude-sonnet-4-6",
|
||||||
"vertex_ai/gemini-3.1-pro-preview",
|
"vertex_ai/gemini-3.1-pro-preview",
|
||||||
"gemini/gemini-3.1-pro-preview",
|
"gemini/gemini-3.1-pro-preview",
|
||||||
|
"gemini/gemini-3.6-flash",
|
||||||
"deepseek/deepseek-v4-pro",
|
"deepseek/deepseek-v4-pro",
|
||||||
"deepseek/deepseek-v4-flash",
|
"deepseek/deepseek-v4-flash",
|
||||||
|
"dashscope/qwen3.8-max",
|
||||||
"dashscope/qwen3.7-max-2026-06-08",
|
"dashscope/qwen3.7-max-2026-06-08",
|
||||||
|
"moonshot/kimi-k3",
|
||||||
"moonshot/kimi-k2.7-code",
|
"moonshot/kimi-k2.7-code",
|
||||||
"moonshot/kimi-k2.6",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
_RECOMMENDED_MODEL_NAME_SET = frozenset(name.lower() for name in RECOMMENDED_MODEL_NAMES)
|
||||||
|
|
||||||
FRONTIER_MODEL_FAMILIES = (
|
FRONTIER_MODEL_FAMILIES = (
|
||||||
(("azure", "azure_ai", "bedrock_mantle", "openai"), ("gpt-5",)),
|
(("azure", "azure_ai", "bedrock_mantle", "chatgpt", "openai"), ("gpt-5",)),
|
||||||
(
|
(
|
||||||
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
("anthropic", "azure_ai", "bedrock", "claude", "databricks", "snowflake", "vertex_ai"),
|
||||||
("claude-fable-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
("claude-fable-5", "claude-opus-5", "claude-opus-4", "claude-sonnet-5", "claude-sonnet-4"),
|
||||||
),
|
),
|
||||||
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
(("google", "gemini", "vertex_ai"), ("gemini-3",)),
|
||||||
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
(("deepseek",), ("deepseek-v4", "deepseek-r1", "deepseek-reasoner")),
|
||||||
(("alibaba", "dashscope", "qwen"), ("qwen3.7", "qwen3.5", "qwen3-max")),
|
(("alibaba", "dashscope", "qwen"), ("qwen3.8", "qwen3.7", "qwen3-max")),
|
||||||
(("moonshot", "moonshotai", "kimi"), ("kimi-k2.7", "kimi-k2.6", "kimi-k2.5")),
|
(("moonshot", "moonshotai", "kimi"), ("kimi-k3", "kimi-k2.7", "kimi-k2.6")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +229,8 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
|
|||||||
"""Apply Strix config to SDK-native defaults."""
|
"""Apply Strix config to SDK-native defaults."""
|
||||||
llm = settings.llm
|
llm = settings.llm
|
||||||
set_tracing_disabled(True)
|
set_tracing_disabled(True)
|
||||||
|
if codex.subscription_model(llm.model):
|
||||||
|
return
|
||||||
_configure_litellm_compatibility()
|
_configure_litellm_compatibility()
|
||||||
_configure_openrouter_attribution(llm.model)
|
_configure_openrouter_attribution(llm.model)
|
||||||
if llm.api_key:
|
if llm.api_key:
|
||||||
@@ -211,6 +325,8 @@ def _configure_litellm_default(name: str, value: str) -> None:
|
|||||||
|
|
||||||
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
def uses_chat_completions_tool_schema(model_name: str, settings: Settings) -> bool:
|
||||||
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
"""Return whether the resolved SDK route can only receive JSON function tools."""
|
||||||
|
if codex.subscription_model(model_name):
|
||||||
|
return False
|
||||||
model = model_name.strip().lower()
|
model = model_name.strip().lower()
|
||||||
if "/" in model and not model.startswith("openai/"):
|
if "/" in model and not model.startswith("openai/"):
|
||||||
return True
|
return True
|
||||||
@@ -313,3 +429,45 @@ def is_known_openai_bare_model(model_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
entry = litellm.model_cost.get(name)
|
entry = litellm.model_cost.get(name)
|
||||||
return bool(entry and entry.get("litellm_provider") == "openai")
|
return bool(entry and entry.get("litellm_provider") == "openai")
|
||||||
|
|
||||||
|
|
||||||
|
def is_claude_model(model_name: str) -> bool:
|
||||||
|
return "claude" in (model_name or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_bedrock_route(model_name: str) -> bool:
|
||||||
|
name = (model_name or "").strip().lower()
|
||||||
|
return name.startswith("bedrock/") or "anthropic." in name
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_cache_name_candidates(model_name: str) -> list[str]:
|
||||||
|
# LiteLLM's model map keys the same model under several names; strip the
|
||||||
|
# route prefix, then leading dotted segments (region, provider).
|
||||||
|
name = (model_name or "").strip().lower()
|
||||||
|
for prefix in ("litellm/", "bedrock/"):
|
||||||
|
if name.startswith(prefix):
|
||||||
|
name = name[len(prefix) :]
|
||||||
|
break
|
||||||
|
candidates = [name]
|
||||||
|
rest = name
|
||||||
|
while "." in rest:
|
||||||
|
rest = rest.split(".", 1)[1]
|
||||||
|
candidates.append(rest)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def bedrock_route_supports_prompt_caching(model_name: str) -> bool:
|
||||||
|
# Bedrock rejects the cache marker for models LiteLLM's map doesn't
|
||||||
|
# recognise as cache-capable, so callers withhold it unless confirmed here.
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
checker = getattr(getattr(litellm, "utils", None), "supports_prompt_caching", None)
|
||||||
|
for cand in _prompt_cache_name_candidates(model_name):
|
||||||
|
if checker is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
if checker(cand):
|
||||||
|
return True
|
||||||
|
entry = litellm.model_cost.get(cand)
|
||||||
|
if entry and entry.get("supports_prompt_caching"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|||||||
@@ -40,14 +40,50 @@ class LlmSettings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
alias="STRIX_FORCE_REQUIRED_TOOL_CHOICE",
|
||||||
)
|
)
|
||||||
|
prompt_cache: bool = Field(
|
||||||
|
default=True,
|
||||||
|
alias="STRIX_PROMPT_CACHE",
|
||||||
|
)
|
||||||
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
timeout: int = Field(default=300, alias="LLM_TIMEOUT")
|
||||||
|
|
||||||
|
|
||||||
|
class DedupeSettings(BaseSettings):
|
||||||
|
model_config = _BASE_CONFIG
|
||||||
|
|
||||||
|
model: str | None = Field(default=None, alias="STRIX_DEDUPE_MODEL")
|
||||||
|
reasoning_effort: ReasoningEffort | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="STRIX_DEDUPE_REASONING_EFFORT",
|
||||||
|
)
|
||||||
|
api_key: str | None = Field(default=None, alias="DEDUPE_LLM_API_KEY")
|
||||||
|
api_base: str | None = Field(default=None, alias="DEDUPE_LLM_API_BASE")
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSettings(BaseSettings):
|
||||||
|
"""Context-window management: per-tool-output caps and history compaction."""
|
||||||
|
|
||||||
|
model_config = _BASE_CONFIG
|
||||||
|
|
||||||
|
auto_compact: bool = Field(default=True, alias="STRIX_CONTEXT_AUTO_COMPACT")
|
||||||
|
compact_buffer_tokens: int = Field(default=20_000, gt=0, alias="STRIX_CONTEXT_BUFFER_TOKENS")
|
||||||
|
keep_tokens: int = Field(default=8_000, gt=0, alias="STRIX_CONTEXT_KEEP_TOKENS")
|
||||||
|
fallback_context_tokens: int = Field(
|
||||||
|
default=200_000, gt=0, alias="STRIX_CONTEXT_FALLBACK_TOKENS"
|
||||||
|
)
|
||||||
|
summary_max_tokens: int = Field(default=4_096, gt=0, alias="STRIX_CONTEXT_SUMMARY_TOKENS")
|
||||||
|
tool_output_max_tokens: int = Field(default=8_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_TOKENS")
|
||||||
|
tool_output_max_lines: int = Field(default=2_000, gt=0, alias="STRIX_TOOL_OUTPUT_MAX_LINES")
|
||||||
|
# Floor above the truncation-notice size so a preview always fits.
|
||||||
|
tool_output_max_bytes: int = Field(
|
||||||
|
default=50 * 1024, ge=1024, alias="STRIX_TOOL_OUTPUT_MAX_BYTES"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RuntimeSettings(BaseSettings):
|
class RuntimeSettings(BaseSettings):
|
||||||
model_config = _BASE_CONFIG
|
model_config = _BASE_CONFIG
|
||||||
|
|
||||||
image: str = Field(
|
image: str = Field(
|
||||||
default="ghcr.io/usestrix/strix-sandbox:1.0.0",
|
default="ghcr.io/usestrix/strix-sandbox:1.1.0",
|
||||||
alias="STRIX_IMAGE",
|
alias="STRIX_IMAGE",
|
||||||
)
|
)
|
||||||
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
|
||||||
@@ -85,7 +121,9 @@ class Settings(BaseSettings):
|
|||||||
model_config = _BASE_CONFIG
|
model_config = _BASE_CONFIG
|
||||||
|
|
||||||
llm: LlmSettings = Field(default_factory=LlmSettings)
|
llm: LlmSettings = Field(default_factory=LlmSettings)
|
||||||
|
dedupe: DedupeSettings = Field(default_factory=DedupeSettings)
|
||||||
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
|
||||||
|
context: ContextSettings = Field(default_factory=ContextSettings)
|
||||||
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
telemetry: TelemetrySettings = Field(default_factory=TelemetrySettings)
|
||||||
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
integrations: IntegrationSettings = Field(default_factory=IntegrationSettings)
|
||||||
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
viewer: ViewerSettings = Field(default_factory=ViewerSettings)
|
||||||
|
|||||||
+104
-8
@@ -14,13 +14,15 @@ from strix.core.sessions import session_write_lock
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from agents.items import TResponseInputItem
|
from agents.items import TResponseInputItem
|
||||||
from agents.memory import Session
|
from agents.memory import Session
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed"]
|
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -41,11 +43,15 @@ class AgentCoordinator:
|
|||||||
self.names: dict[str, str] = {}
|
self.names: dict[str, str] = {}
|
||||||
self.metadata: dict[str, dict[str, Any]] = {}
|
self.metadata: dict[str, dict[str, Any]] = {}
|
||||||
self.pending_counts: dict[str, int] = {}
|
self.pending_counts: dict[str, int] = {}
|
||||||
|
self.errors: dict[str, str] = {}
|
||||||
self.runtimes: dict[str, AgentRuntime] = {}
|
self.runtimes: dict[str, AgentRuntime] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._snapshot_path: Path | None = None
|
self._snapshot_path: Path | None = None
|
||||||
self.is_shutting_down = False
|
self.is_shutting_down = False
|
||||||
self._budget_stopped = False
|
self._budget_stopped = False
|
||||||
|
self._reserve_stopped = False
|
||||||
|
self._budget_paused = False
|
||||||
|
self._extend_budget: Callable[[], None] | None = None
|
||||||
|
|
||||||
def set_snapshot_path(self, path: Path) -> None:
|
def set_snapshot_path(self, path: Path) -> None:
|
||||||
self._snapshot_path = path
|
self._snapshot_path = path
|
||||||
@@ -64,6 +70,71 @@ class AgentCoordinator:
|
|||||||
for runtime in self.runtimes.values():
|
for runtime in self.runtimes.values():
|
||||||
runtime.wake.set()
|
runtime.wake.set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reserve_stopped(self) -> bool:
|
||||||
|
return self._reserve_stopped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def budget_paused(self) -> bool:
|
||||||
|
return self._budget_paused
|
||||||
|
|
||||||
|
def set_budget_extender(self, extend: Callable[[], None]) -> None:
|
||||||
|
self._extend_budget = extend
|
||||||
|
|
||||||
|
async def pause_for_budget(self, agent_id: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._budget_paused = True
|
||||||
|
await self.set_status(agent_id, "budget_paused")
|
||||||
|
|
||||||
|
async def resume_from_budget_pause(self, *, exclude: str | None = None) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if not self._budget_paused:
|
||||||
|
return
|
||||||
|
self._budget_paused = False
|
||||||
|
paused = [aid for aid, status in self.statuses.items() if status == "budget_paused"]
|
||||||
|
if self._extend_budget is not None:
|
||||||
|
self._extend_budget()
|
||||||
|
for aid in paused:
|
||||||
|
await self.set_status(aid, "waiting")
|
||||||
|
if aid != exclude:
|
||||||
|
await self.send(
|
||||||
|
aid,
|
||||||
|
{
|
||||||
|
"from": "system",
|
||||||
|
"type": "budget_extended",
|
||||||
|
"content": (
|
||||||
|
"[Budget] The user extended the scan budget \u2014 continue your "
|
||||||
|
"current task."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reset_budget_stops(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
budget_stopped: bool,
|
||||||
|
reserve_stopped: bool,
|
||||||
|
budget_paused: bool = False,
|
||||||
|
) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._budget_stopped = budget_stopped
|
||||||
|
self._reserve_stopped = reserve_stopped
|
||||||
|
if not budget_paused:
|
||||||
|
self._budget_paused = False
|
||||||
|
for aid, status in self.statuses.items():
|
||||||
|
if status == "budget_paused":
|
||||||
|
self.statuses[aid] = "waiting"
|
||||||
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
|
async def claim_reserve_notification(self) -> str | None:
|
||||||
|
async with self._lock:
|
||||||
|
if self._reserve_stopped:
|
||||||
|
return None
|
||||||
|
self._reserve_stopped = True
|
||||||
|
for runtime in self.runtimes.values():
|
||||||
|
runtime.wake.set()
|
||||||
|
return next((aid for aid, parent in self.parent_of.items() if parent is None), None)
|
||||||
|
|
||||||
async def register(
|
async def register(
|
||||||
self,
|
self,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -107,23 +178,34 @@ class AgentCoordinator:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
if agent_id in self.statuses:
|
if agent_id in self.statuses:
|
||||||
self.statuses[agent_id] = "running"
|
self.statuses[agent_id] = "running"
|
||||||
|
self.errors.pop(agent_id, None)
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
async def park_waiting(self, agent_id: str) -> None:
|
async def park_waiting(self, agent_id: str) -> None:
|
||||||
await self.set_status(agent_id, "waiting")
|
await self.set_status(agent_id, "waiting")
|
||||||
|
|
||||||
async def set_status(self, agent_id: str, status: Status | str) -> None:
|
async def set_status(
|
||||||
|
self, agent_id: str, status: Status | str, *, error: str | None = None
|
||||||
|
) -> None:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if agent_id not in self.statuses:
|
if agent_id not in self.statuses:
|
||||||
return
|
return
|
||||||
self.statuses[agent_id] = status # type: ignore[assignment]
|
self.statuses[agent_id] = status # type: ignore[assignment]
|
||||||
|
if error is not None:
|
||||||
|
self.errors[agent_id] = error
|
||||||
|
elif status == "running":
|
||||||
|
self.errors.pop(agent_id, None)
|
||||||
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
runtime = self.runtimes.setdefault(agent_id, AgentRuntime())
|
||||||
runtime.wake.set()
|
runtime.wake.set()
|
||||||
logger.info("agent.status %s=%s", agent_id, status)
|
logger.info("agent.status %s=%s", agent_id, status)
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
async def send(self, target_agent_id: str, message: dict[str, Any]) -> bool:
|
async def send(
|
||||||
|
self, target_agent_id: str, message: dict[str, Any], *, interrupt: bool = True
|
||||||
|
) -> bool:
|
||||||
"""Deliver a user/peer message by appending it to the target SDK session."""
|
"""Deliver a user/peer message by appending it to the target SDK session."""
|
||||||
|
if message.get("from") == "user" and self._budget_paused:
|
||||||
|
await self.resume_from_budget_pause(exclude=target_agent_id)
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if target_agent_id not in self.statuses:
|
if target_agent_id not in self.statuses:
|
||||||
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
logger.debug("agent.send dropped unknown target=%s", target_agent_id)
|
||||||
@@ -131,7 +213,7 @@ class AgentCoordinator:
|
|||||||
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
runtime = self.runtimes.setdefault(target_agent_id, AgentRuntime())
|
||||||
session = runtime.session
|
session = runtime.session
|
||||||
stream = runtime.stream
|
stream = runtime.stream
|
||||||
interrupt = runtime.interrupt_on_message
|
interrupt_on_message = runtime.interrupt_on_message
|
||||||
if session is None:
|
if session is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"agent.send dropped target=%s because its SDK session is not attached",
|
"agent.send dropped target=%s because its SDK session is not attached",
|
||||||
@@ -150,7 +232,7 @@ class AgentCoordinator:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
|
||||||
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
self.runtimes.setdefault(target_agent_id, AgentRuntime()).wake.set()
|
||||||
if stream is not None and interrupt:
|
if stream is not None and interrupt and interrupt_on_message:
|
||||||
stream.cancel(mode="immediate")
|
stream.cancel(mode="immediate")
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
return True
|
return True
|
||||||
@@ -158,7 +240,8 @@ class AgentCoordinator:
|
|||||||
async def wait_for_message(self, agent_id: str) -> None:
|
async def wait_for_message(self, agent_id: str) -> None:
|
||||||
while True:
|
while True:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if self._budget_stopped or self.pending_counts.get(agent_id, 0) > 0:
|
reserve_exit = self._reserve_stopped and self.parent_of.get(agent_id) is not None
|
||||||
|
if self._budget_stopped or reserve_exit or self.pending_counts.get(agent_id, 0) > 0:
|
||||||
return
|
return
|
||||||
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
|
wake = self.runtimes.setdefault(agent_id, AgentRuntime()).wake
|
||||||
wake.clear()
|
wake.clear()
|
||||||
@@ -246,9 +329,14 @@ class AgentCoordinator:
|
|||||||
|
|
||||||
async def graph_snapshot(
|
async def graph_snapshot(
|
||||||
self,
|
self,
|
||||||
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str]]:
|
) -> tuple[dict[str, str | None], dict[str, Status], dict[str, str], dict[str, str]]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
return dict(self.parent_of), dict(self.statuses), dict(self.names)
|
return (
|
||||||
|
dict(self.parent_of),
|
||||||
|
dict(self.statuses),
|
||||||
|
dict(self.names),
|
||||||
|
dict(self.errors),
|
||||||
|
)
|
||||||
|
|
||||||
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
def _message_to_session_item(self, message: dict[str, Any]) -> TResponseInputItem:
|
||||||
sender = str(message.get("from", "unknown"))
|
sender = str(message.get("from", "unknown"))
|
||||||
@@ -286,6 +374,10 @@ class AgentCoordinator:
|
|||||||
"names": dict(self.names),
|
"names": dict(self.names),
|
||||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
||||||
"pending_counts": dict(self.pending_counts),
|
"pending_counts": dict(self.pending_counts),
|
||||||
|
"errors": dict(self.errors),
|
||||||
|
"budget_stopped": self._budget_stopped,
|
||||||
|
"reserve_stopped": self._reserve_stopped,
|
||||||
|
"budget_paused": self._budget_paused,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def restore(self, snap: dict[str, Any]) -> None:
|
async def restore(self, snap: dict[str, Any]) -> None:
|
||||||
@@ -295,6 +387,10 @@ class AgentCoordinator:
|
|||||||
self.names = dict(snap.get("names", {}))
|
self.names = dict(snap.get("names", {}))
|
||||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
||||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
self.pending_counts = dict(snap.get("pending_counts", {}))
|
||||||
|
self.errors = dict(snap.get("errors", {}))
|
||||||
|
self._budget_stopped = bool(snap.get("budget_stopped", False))
|
||||||
|
self._reserve_stopped = bool(snap.get("reserve_stopped", False))
|
||||||
|
self._budget_paused = bool(snap.get("budget_paused", False))
|
||||||
for aid in self.statuses:
|
for aid in self.statuses:
|
||||||
self.runtimes.setdefault(aid, AgentRuntime())
|
self.runtimes.setdefault(aid, AgentRuntime())
|
||||||
|
|
||||||
|
|||||||
+255
-44
@@ -13,15 +13,27 @@ from agents import RunConfig, Runner
|
|||||||
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
from agents.exceptions import AgentsException, MaxTurnsExceeded, UserError
|
||||||
from agents.sandbox.errors import ExecTransportError
|
from agents.sandbox.errors import ExecTransportError
|
||||||
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
from docker import errors as docker_errors # type: ignore[import-untyped, unused-ignore]
|
||||||
from openai import APIError
|
from openai import (
|
||||||
|
APIConnectionError,
|
||||||
|
APIError,
|
||||||
|
APIStatusError,
|
||||||
|
APITimeoutError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
|
||||||
from strix.core.hooks import BudgetExceededError
|
from strix.config import codex
|
||||||
|
from strix.core.hooks import (
|
||||||
|
BudgetExceededError,
|
||||||
|
BudgetPausedError,
|
||||||
|
SubagentBudgetReservedError,
|
||||||
|
)
|
||||||
from strix.core.inputs import child_initial_input
|
from strix.core.inputs import child_initial_input
|
||||||
from strix.core.sessions import (
|
from strix.core.sessions import (
|
||||||
enforce_image_budget,
|
enforce_image_budget,
|
||||||
open_agent_session,
|
open_agent_session,
|
||||||
strip_all_images_from_session,
|
strip_all_images_from_session,
|
||||||
)
|
)
|
||||||
|
from strix.llm.compaction import is_context_overflow, maybe_compact
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -40,6 +52,74 @@ logger = logging.getLogger(__name__)
|
|||||||
StreamEventSink = Callable[[str, Any], None]
|
StreamEventSink = Callable[[str, Any], None]
|
||||||
|
|
||||||
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
_INPUT_REJECTION_CODES = frozenset({400, 404, 422})
|
||||||
|
_MAX_COMPACTIONS_PER_CYCLE = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _run_config_model(run_config: RunConfig) -> str | None:
|
||||||
|
return run_config.model if isinstance(run_config.model, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_instructions(agent: Any) -> str:
|
||||||
|
instructions = getattr(agent, "instructions", None)
|
||||||
|
return instructions if isinstance(instructions, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_tools_text(agent: Any) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for tool in getattr(agent, "tools", []) or []:
|
||||||
|
name = getattr(tool, "name", "")
|
||||||
|
description = getattr(tool, "description", "") or ""
|
||||||
|
schema = getattr(tool, "params_json_schema", "") or ""
|
||||||
|
parts.append(f"{name} {description} {schema}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _compact_session(
|
||||||
|
agent: Any, session: Session, run_config: RunConfig, *, force: bool
|
||||||
|
) -> bool:
|
||||||
|
model = _run_config_model(run_config)
|
||||||
|
if session is None or model is None:
|
||||||
|
return False
|
||||||
|
return await maybe_compact(
|
||||||
|
session,
|
||||||
|
model=model,
|
||||||
|
instructions=_agent_instructions(agent),
|
||||||
|
tools_text=_agent_tools_text(agent),
|
||||||
|
force=force,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_GUARDRAIL_PARK_ERROR = (
|
||||||
|
"Blocked by the model's content guardrail (flagged as a possible cybersecurity risk). "
|
||||||
|
"Set STRIX_LLM to a model that isn't blocked and resume the scan to continue."
|
||||||
|
)
|
||||||
|
|
||||||
|
_TRANSIENT_MODEL_STATUS_CODES = frozenset({408, 500, 502, 503, 504})
|
||||||
|
_MAX_TRANSIENT_MODEL_RETRIES = 4
|
||||||
|
_TRANSIENT_MODEL_RETRY_BASE_DELAY_S = 2.0
|
||||||
|
_TRANSIENT_MODEL_RETRY_MAX_DELAY_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _model_error_status_code(exc: BaseException) -> int | None:
|
||||||
|
code = getattr(exc, "status_code", None)
|
||||||
|
return code if isinstance(code, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_transient_model_error(exc: BaseException) -> bool:
|
||||||
|
if isinstance(exc, RateLimitError):
|
||||||
|
return False
|
||||||
|
if isinstance(exc, APITimeoutError | APIConnectionError):
|
||||||
|
return True
|
||||||
|
if isinstance(exc, APIStatusError):
|
||||||
|
return exc.status_code in _TRANSIENT_MODEL_STATUS_CODES
|
||||||
|
if isinstance(exc, APIError):
|
||||||
|
return _model_error_status_code(exc) is None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _transient_model_retry_delay(attempt: int) -> float:
|
||||||
|
delay = _TRANSIENT_MODEL_RETRY_BASE_DELAY_S * float(2 ** (attempt - 1))
|
||||||
|
return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S)
|
||||||
|
|
||||||
|
|
||||||
async def run_agent_loop(
|
async def run_agent_loop(
|
||||||
@@ -64,21 +144,34 @@ async def run_agent_loop(
|
|||||||
)
|
)
|
||||||
result: RunResultBase | None = None
|
result: RunResultBase | None = None
|
||||||
|
|
||||||
|
budget_stopped = coordinator.budget_stopped
|
||||||
|
reserve_stopped = coordinator.reserve_stopped
|
||||||
|
if budget_stopped:
|
||||||
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
|
raise BudgetExceededError("scan budget reached")
|
||||||
|
if reserve_stopped and context.get("parent_id") is not None:
|
||||||
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
|
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||||
|
|
||||||
|
if reserve_stopped and start_parked and interactive and context.get("parent_id") is None:
|
||||||
|
await coordinator.send(agent_id, _reserve_notice())
|
||||||
|
|
||||||
if not (start_parked and interactive):
|
if not (start_parked and interactive):
|
||||||
if interactive:
|
if interactive:
|
||||||
result = await _run_cycle(
|
with contextlib.suppress(BudgetPausedError):
|
||||||
agent,
|
result = await _run_cycle(
|
||||||
coordinator,
|
agent,
|
||||||
agent_id,
|
coordinator,
|
||||||
input_data=initial_input,
|
agent_id,
|
||||||
run_config=run_config,
|
input_data=initial_input,
|
||||||
context=context,
|
run_config=run_config,
|
||||||
max_turns=max_turns,
|
context=context,
|
||||||
session=session,
|
max_turns=max_turns,
|
||||||
interactive=interactive,
|
session=session,
|
||||||
event_sink=event_sink,
|
interactive=interactive,
|
||||||
hooks=hooks,
|
event_sink=event_sink,
|
||||||
)
|
hooks=hooks,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = await _run_noninteractive_until_lifecycle(
|
result = await _run_noninteractive_until_lifecycle(
|
||||||
agent,
|
agent,
|
||||||
@@ -106,20 +199,25 @@ async def run_agent_loop(
|
|||||||
await coordinator.set_status(agent_id, "stopped")
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
raise BudgetExceededError("scan budget reached")
|
raise BudgetExceededError("scan budget reached")
|
||||||
|
|
||||||
|
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||||
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
|
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||||
|
|
||||||
await coordinator.consume_pending(agent_id)
|
await coordinator.consume_pending(agent_id)
|
||||||
result = await _run_cycle(
|
with contextlib.suppress(BudgetPausedError):
|
||||||
agent,
|
result = await _run_cycle(
|
||||||
coordinator,
|
agent,
|
||||||
agent_id,
|
coordinator,
|
||||||
input_data=[],
|
agent_id,
|
||||||
run_config=run_config,
|
input_data=[],
|
||||||
context=context,
|
run_config=run_config,
|
||||||
max_turns=max_turns,
|
context=context,
|
||||||
session=session,
|
max_turns=max_turns,
|
||||||
interactive=interactive,
|
session=session,
|
||||||
event_sink=event_sink,
|
interactive=interactive,
|
||||||
hooks=hooks,
|
event_sink=event_sink,
|
||||||
)
|
hooks=hooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def spawn_child_agent(
|
async def spawn_child_agent(
|
||||||
@@ -212,6 +310,7 @@ async def respawn_subagents(
|
|||||||
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
if coordinator.parent_of.get(aid) is None or aid == root_id:
|
||||||
continue
|
continue
|
||||||
md["_restored_status"] = status
|
md["_restored_status"] = status
|
||||||
|
md["_restored_error"] = coordinator.errors.get(aid)
|
||||||
candidates.append(
|
candidates.append(
|
||||||
(
|
(
|
||||||
aid,
|
aid,
|
||||||
@@ -224,7 +323,8 @@ async def respawn_subagents(
|
|||||||
for child_id, name, parent_id, md in candidates:
|
for child_id, name, parent_id, md in candidates:
|
||||||
try:
|
try:
|
||||||
restored_status = str(md.get("_restored_status") or "running")
|
restored_status = str(md.get("_restored_status") or "running")
|
||||||
start_parked = interactive and restored_status != "running"
|
recoverable_park = restored_status == "waiting" and bool(md.get("_restored_error"))
|
||||||
|
start_parked = interactive and restored_status != "running" and not recoverable_park
|
||||||
|
|
||||||
if start_parked:
|
if start_parked:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -291,6 +391,10 @@ async def _run_noninteractive_until_lifecycle(
|
|||||||
await coordinator.set_status(agent_id, "stopped")
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
raise BudgetExceededError("scan budget reached")
|
raise BudgetExceededError("scan budget reached")
|
||||||
|
|
||||||
|
if coordinator.reserve_stopped and context.get("parent_id") is not None:
|
||||||
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
|
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||||
|
|
||||||
result = await _run_cycle(
|
result = await _run_cycle(
|
||||||
agent,
|
agent,
|
||||||
coordinator,
|
coordinator,
|
||||||
@@ -321,7 +425,7 @@ async def _run_noninteractive_until_lifecycle(
|
|||||||
|
|
||||||
if invalid_final_outputs >= invalid_final_output_limit:
|
if invalid_final_outputs >= invalid_final_output_limit:
|
||||||
await coordinator.set_status(agent_id, "crashed")
|
await coordinator.set_status(agent_id, "crashed")
|
||||||
await _notify_parent_on_crash(coordinator, agent_id, "crashed")
|
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
|
||||||
raise MaxTurnsExceeded(
|
raise MaxTurnsExceeded(
|
||||||
"Agent exhausted non-interactive recovery attempts without calling "
|
"Agent exhausted non-interactive recovery attempts without calling "
|
||||||
"finish_scan or agent_finish."
|
"finish_scan or agent_finish."
|
||||||
@@ -350,6 +454,8 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
hooks: RunHooks[dict[str, Any]] | None,
|
hooks: RunHooks[dict[str, Any]] | None,
|
||||||
) -> RunResultBase | None:
|
) -> RunResultBase | None:
|
||||||
image_strips = 0
|
image_strips = 0
|
||||||
|
compactions = 0
|
||||||
|
model_retries = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await coordinator.mark_running(agent_id)
|
await coordinator.mark_running(agent_id)
|
||||||
@@ -360,6 +466,10 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
await enforce_image_budget(session, max_images)
|
await enforce_image_budget(session, max_images)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("image-budget enforcement failed for %s", agent_id)
|
logger.exception("image-budget enforcement failed for %s", agent_id)
|
||||||
|
try:
|
||||||
|
await _compact_session(agent, session, run_config, force=False)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("proactive compaction failed for %s", agent_id)
|
||||||
stream = Runner.run_streamed(
|
stream = Runner.run_streamed(
|
||||||
agent,
|
agent,
|
||||||
input=input_data,
|
input=input_data,
|
||||||
@@ -380,9 +490,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
logger.exception("stream event sink failed for %s", agent_id)
|
logger.exception("stream event sink failed for %s", agent_id)
|
||||||
if stream.run_loop_exception is not None:
|
if stream.run_loop_exception is not None:
|
||||||
raise stream.run_loop_exception
|
raise stream.run_loop_exception
|
||||||
except BudgetExceededError:
|
except (BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError):
|
||||||
# A RuntimeError subclass: re-raise explicitly so it is never
|
|
||||||
# mistaken for the LiteLLM "after shutdown" race below.
|
|
||||||
raise
|
raise
|
||||||
except RuntimeError as stream_exc:
|
except RuntimeError as stream_exc:
|
||||||
if "after shutdown" not in str(stream_exc):
|
if "after shutdown" not in str(stream_exc):
|
||||||
@@ -401,6 +509,15 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await coordinator.detach_stream(agent_id, stream)
|
await coordinator.detach_stream(agent_id, stream)
|
||||||
|
except BudgetPausedError as exc:
|
||||||
|
logger.info("agent %s paused at the scan budget limit: %s", agent_id, exc)
|
||||||
|
await coordinator.pause_for_budget(agent_id)
|
||||||
|
raise
|
||||||
|
except SubagentBudgetReservedError as exc:
|
||||||
|
logger.info("sub-agent %s stopped at the budget reserve: %s", agent_id, exc)
|
||||||
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
|
await _notify_root_on_budget_reserve(coordinator)
|
||||||
|
raise
|
||||||
except BudgetExceededError as exc:
|
except BudgetExceededError as exc:
|
||||||
logger.info(
|
logger.info(
|
||||||
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
|
"agent %s reached the scan budget limit; stopping the scan: %s", agent_id, exc
|
||||||
@@ -428,6 +545,45 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
)
|
)
|
||||||
input_data = []
|
input_data = []
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
compactions < _MAX_COMPACTIONS_PER_CYCLE
|
||||||
|
and session is not None
|
||||||
|
and is_context_overflow(exc)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
compacted = await _compact_session(agent, session, run_config, force=True)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("overflow compaction recovery failed for %s", agent_id)
|
||||||
|
compacted = False
|
||||||
|
if compacted:
|
||||||
|
compactions += 1
|
||||||
|
logger.info(
|
||||||
|
"Compacted %s session after context overflow; retrying (%d)",
|
||||||
|
agent_id,
|
||||||
|
compactions,
|
||||||
|
)
|
||||||
|
input_data = []
|
||||||
|
continue
|
||||||
|
if model_retries < _MAX_TRANSIENT_MODEL_RETRIES and _is_transient_model_error(exc):
|
||||||
|
model_retries += 1
|
||||||
|
delay = _transient_model_retry_delay(model_retries)
|
||||||
|
logger.warning(
|
||||||
|
"transient model/provider error for %s; replaying turn "
|
||||||
|
"(attempt %d/%d, backoff %.1fs): %r",
|
||||||
|
agent_id,
|
||||||
|
model_retries,
|
||||||
|
_MAX_TRANSIENT_MODEL_RETRIES,
|
||||||
|
delay,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
if session is not None:
|
||||||
|
input_data = []
|
||||||
|
continue
|
||||||
|
if codex.is_content_guardrail_error(exc):
|
||||||
|
return await _handle_content_guardrail(
|
||||||
|
coordinator, agent_id, exc, interactive=interactive
|
||||||
|
)
|
||||||
if not interactive:
|
if not interactive:
|
||||||
raise
|
raise
|
||||||
if isinstance(exc, MaxTurnsExceeded):
|
if isinstance(exc, MaxTurnsExceeded):
|
||||||
@@ -437,16 +593,30 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
else:
|
else:
|
||||||
status = "crashed"
|
status = "crashed"
|
||||||
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
logger.exception("agent run failed for %s; parking as %s", agent_id, status)
|
||||||
await coordinator.set_status(agent_id, status)
|
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
|
||||||
await _notify_parent_on_crash(coordinator, agent_id, status)
|
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
||||||
if context.get("parent_id") is None and status in {"failed", "crashed"}:
|
|
||||||
raise
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
await _settle_run_result(coordinator, agent_id, interactive)
|
await _settle_run_result(coordinator, agent_id, interactive)
|
||||||
return stream
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_content_guardrail(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
exc: BaseException,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
) -> RunResultBase | None:
|
||||||
|
logger.warning("agent %s blocked by the model's content guardrail: %s", agent_id, exc)
|
||||||
|
if interactive:
|
||||||
|
await coordinator.set_status(agent_id, "waiting", error=_GUARDRAIL_PARK_ERROR)
|
||||||
|
return None
|
||||||
|
await coordinator.set_status(agent_id, "failed", error=_GUARDRAIL_PARK_ERROR)
|
||||||
|
await _notify_parent_on_terminal(coordinator, agent_id, "failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _settle_run_result(
|
async def _settle_run_result(
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -504,12 +674,31 @@ async def _append_noninteractive_tool_required_message(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _notify_parent_on_crash(
|
_TERMINAL_NOTICE = {
|
||||||
|
"crashed": (
|
||||||
|
"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
||||||
|
"Stop waiting on this child unless you want to message it again."
|
||||||
|
),
|
||||||
|
"failed": (
|
||||||
|
"[Agent failed] {name} ({agent_id}) stopped with an error and will not "
|
||||||
|
"send a completion report. Stop waiting on this child unless you want to "
|
||||||
|
"message it again."
|
||||||
|
),
|
||||||
|
"stopped": (
|
||||||
|
"[Agent capped] {name} ({agent_id}) hit its turn limit and was stopped "
|
||||||
|
"before finishing. It will not send a completion report, so stop waiting "
|
||||||
|
"on this child; account for its capped subtask and continue."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_parent_on_terminal(
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
status: str,
|
status: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if status != "crashed":
|
template = _TERMINAL_NOTICE.get(status)
|
||||||
|
if template is None:
|
||||||
return
|
return
|
||||||
async with coordinator._lock:
|
async with coordinator._lock:
|
||||||
parent = coordinator.parent_of.get(agent_id)
|
parent = coordinator.parent_of.get(agent_id)
|
||||||
@@ -520,16 +709,36 @@ async def _notify_parent_on_crash(
|
|||||||
parent,
|
parent,
|
||||||
{
|
{
|
||||||
"from": agent_id,
|
"from": agent_id,
|
||||||
"type": "crash",
|
"type": status,
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"content": (
|
"content": template.format(name=name, agent_id=agent_id),
|
||||||
f"[Agent crash] {name} ({agent_id}) terminated unexpectedly. "
|
|
||||||
"Stop waiting on this child unless you want to message it again."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
interrupt=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reserve_notice() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"from": "system",
|
||||||
|
"type": "budget_reserve_stop",
|
||||||
|
"priority": "high",
|
||||||
|
"content": (
|
||||||
|
"[Budget reserve] The scan has reached the sub-agent budget reserve: every "
|
||||||
|
"sub-agent is being force-stopped as soon as its in-flight turn completes, and "
|
||||||
|
"none will send a completion report. Their confirmed vulnerabilities are "
|
||||||
|
"already filed as they were found. Do not wait on any sub-agents and do not "
|
||||||
|
"spawn new ones — wrap up now and call finish_scan."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_root_on_budget_reserve(coordinator: AgentCoordinator) -> None:
|
||||||
|
root = await coordinator.claim_reserve_notification()
|
||||||
|
if root is None:
|
||||||
|
return
|
||||||
|
await coordinator.send(root, _reserve_notice())
|
||||||
|
|
||||||
|
|
||||||
async def _start_child_runner(
|
async def _start_child_runner(
|
||||||
*,
|
*,
|
||||||
parent_ctx: dict[str, Any],
|
parent_ctx: dict[str, Any],
|
||||||
@@ -581,6 +790,8 @@ async def _start_child_runner(
|
|||||||
)
|
)
|
||||||
except BudgetExceededError:
|
except BudgetExceededError:
|
||||||
logger.info("child %s stopped after reaching the scan budget limit", child_id)
|
logger.info("child %s stopped after reaching the scan budget limit", child_id)
|
||||||
|
except SubagentBudgetReservedError:
|
||||||
|
logger.info("child %s stopped at the sub-agent budget reserve", child_id)
|
||||||
|
|
||||||
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
task_handle = asyncio.create_task(_child_loop(), name=f"agent-{name}-{child_id}")
|
||||||
await coordinator.attach_runtime(child_id, task=task_handle)
|
await coordinator.attach_runtime(child_id, task=task_handle)
|
||||||
|
|||||||
+203
-4
@@ -14,26 +14,210 @@ from strix.report.state import get_global_report_state
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from agents import RunContextWrapper
|
from agents import RunContextWrapper
|
||||||
from agents.agent import Agent
|
from agents.agent import Agent
|
||||||
from agents.items import ModelResponse
|
from agents.items import ModelResponse, TResponseInputItem
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_STAGE_LABELS: tuple[str, ...] = ("NOTICE", "URGENT", "CRITICAL")
|
||||||
|
_TURN_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||||
|
_ROOT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.70, 0.85, 0.95)
|
||||||
|
_SUBAGENT_BUDGET_WARN_BANDS: tuple[float, ...] = (0.75, 0.80, 0.85)
|
||||||
|
_SUBAGENT_BUDGET_RESERVE = 0.90
|
||||||
|
|
||||||
|
|
||||||
class BudgetExceededError(RuntimeError):
|
class BudgetExceededError(RuntimeError):
|
||||||
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
"""Raised when the accumulated LLM cost reaches the configured budget."""
|
||||||
|
|
||||||
|
|
||||||
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
class SubagentBudgetReservedError(RuntimeError):
|
||||||
"""Persist SDK-native usage after every model response."""
|
"""Raised to stop a single sub-agent once the reserve threshold is crossed."""
|
||||||
|
|
||||||
def __init__(self, *, model: str, max_budget_usd: float | None = None) -> None:
|
|
||||||
|
class BudgetPausedError(RuntimeError):
|
||||||
|
"""Raised to park one agent when an interactive scan reaches its budget."""
|
||||||
|
|
||||||
|
|
||||||
|
def recomputed_budget_flags(
|
||||||
|
cost: float,
|
||||||
|
max_budget_usd: float | None,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
"""Return the (budget_stopped, reserve_stopped) flags a resumed scan should carry."""
|
||||||
|
if max_budget_usd is None:
|
||||||
|
return False, False
|
||||||
|
if interactive:
|
||||||
|
return False, False
|
||||||
|
budget_stopped = cost >= max_budget_usd
|
||||||
|
reserve_stopped = cost >= max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||||
|
return budget_stopped, reserve_stopped
|
||||||
|
|
||||||
|
|
||||||
|
def _crossed_stage(fraction: float, bands: tuple[float, ...]) -> int | None:
|
||||||
|
crossed: int | None = None
|
||||||
|
for index, band in enumerate(bands):
|
||||||
|
if fraction >= band:
|
||||||
|
crossed = index
|
||||||
|
return crossed
|
||||||
|
|
||||||
|
|
||||||
|
_ROOT_DIRECTIVES: tuple[str, ...] = (
|
||||||
|
(
|
||||||
|
"As the root agent, begin planning your wind-down of the whole scan: avoid "
|
||||||
|
"starting large new lines of investigation, and keep your required objectives on "
|
||||||
|
"track so you can call finish_scan comfortably before the limit."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"As the root agent, prioritize wrapping up the whole scan now: stop opening new "
|
||||||
|
"lines of investigation, close out only what is essential, and move toward calling "
|
||||||
|
"finish_scan to compile and deliver the final report."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"As the root agent, STOP all other work on the whole scan and finish immediately: "
|
||||||
|
"secure your findings and call finish_scan now — anything left unfinished when the "
|
||||||
|
"limit is hit is discarded."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_SUBAGENT_DIRECTIVES: tuple[str, ...] = (
|
||||||
|
(
|
||||||
|
"As a sub-agent, begin planning your wind-down: avoid starting large new subtasks, "
|
||||||
|
"and if you are close to a confirmed, validated vulnerability, drive it to a result "
|
||||||
|
"you can report."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"As a sub-agent, prioritize wrapping up your task now: report any confirmed, "
|
||||||
|
"validated vulnerability, finish work that is nearly done rather than starting "
|
||||||
|
"anything new, and prepare to call agent_finish."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"As a sub-agent, STOP all other work and finish immediately: report any confirmed "
|
||||||
|
"vulnerability right now and call agent_finish to hand your results back to your "
|
||||||
|
"parent before you are cut off."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrapup_directive(context: RunContextWrapper[dict[str, Any]], stage: int) -> str:
|
||||||
|
is_root = context.context.get("parent_id") is None
|
||||||
|
directives = _ROOT_DIRECTIVES if is_root else _SUBAGENT_DIRECTIVES
|
||||||
|
return directives[stage]
|
||||||
|
|
||||||
|
|
||||||
|
def _urgency(stage: int) -> str:
|
||||||
|
return _STAGE_LABELS[stage]
|
||||||
|
|
||||||
|
|
||||||
|
class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
||||||
|
"""Persist SDK-native usage and warn/stop as turn and cost budgets are consumed."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
max_budget_usd: float | None = None,
|
||||||
|
max_turns: int | None = None,
|
||||||
|
interactive: bool = False,
|
||||||
|
) -> None:
|
||||||
if max_budget_usd is not None and (
|
if max_budget_usd is not None and (
|
||||||
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
|
not math.isfinite(max_budget_usd) or max_budget_usd <= 0
|
||||||
):
|
):
|
||||||
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
raise ValueError("max_budget_usd must be a finite number greater than 0")
|
||||||
|
if max_turns is not None and max_turns <= 0:
|
||||||
|
raise ValueError("max_turns must be a positive integer")
|
||||||
self._model = model
|
self._model = model
|
||||||
self._max_budget_usd = max_budget_usd
|
self._max_budget_usd = max_budget_usd
|
||||||
|
self._budget_increment = max_budget_usd
|
||||||
|
self._max_turns = max_turns
|
||||||
|
self._interactive = interactive
|
||||||
|
|
||||||
|
def extend_budget(self) -> None:
|
||||||
|
if self._max_budget_usd is None or self._budget_increment is None:
|
||||||
|
return
|
||||||
|
self._max_budget_usd += self._budget_increment
|
||||||
|
|
||||||
|
async def on_llm_start(
|
||||||
|
self,
|
||||||
|
context: RunContextWrapper[dict[str, Any]],
|
||||||
|
agent: Agent[dict[str, Any]], # noqa: ARG002
|
||||||
|
system_prompt: str | None, # noqa: ARG002
|
||||||
|
input_items: list[TResponseInputItem],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
self._maybe_warn_turns(context, input_items)
|
||||||
|
self._maybe_warn_budget(context, input_items)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("budget/turn warning injection failed")
|
||||||
|
|
||||||
|
def _maybe_warn_turns(
|
||||||
|
self,
|
||||||
|
context: RunContextWrapper[dict[str, Any]],
|
||||||
|
input_items: list[TResponseInputItem],
|
||||||
|
) -> None:
|
||||||
|
if not self._max_turns:
|
||||||
|
return
|
||||||
|
usage = getattr(context, "usage", None)
|
||||||
|
requests = getattr(usage, "requests", None)
|
||||||
|
if not isinstance(requests, int):
|
||||||
|
return
|
||||||
|
turns_used = requests + 1
|
||||||
|
stage = _crossed_stage(turns_used / self._max_turns, _TURN_WARN_BANDS)
|
||||||
|
if stage is None:
|
||||||
|
return
|
||||||
|
remaining = max(self._max_turns - turns_used, 0)
|
||||||
|
pct = round(100 * turns_used / self._max_turns)
|
||||||
|
content = (
|
||||||
|
f"[{_urgency(stage)}] Turn budget: {turns_used}/{self._max_turns} used ({pct}%). "
|
||||||
|
f"About {remaining} turn(s) remain before this agent is force-stopped and any "
|
||||||
|
f"in-progress work is discarded. {_wrapup_directive(context, stage)}"
|
||||||
|
)
|
||||||
|
input_items.append({"role": "user", "content": content})
|
||||||
|
|
||||||
|
def _maybe_warn_budget(
|
||||||
|
self,
|
||||||
|
context: RunContextWrapper[dict[str, Any]],
|
||||||
|
input_items: list[TResponseInputItem],
|
||||||
|
) -> None:
|
||||||
|
if self._max_budget_usd is None:
|
||||||
|
return
|
||||||
|
report_state = get_global_report_state()
|
||||||
|
if report_state is None:
|
||||||
|
return
|
||||||
|
cost = report_state.get_total_llm_cost()
|
||||||
|
is_root = context.context.get("parent_id") is None
|
||||||
|
if self._interactive:
|
||||||
|
bands = _ROOT_BUDGET_WARN_BANDS
|
||||||
|
else:
|
||||||
|
bands = _ROOT_BUDGET_WARN_BANDS if is_root else _SUBAGENT_BUDGET_WARN_BANDS
|
||||||
|
stage = _crossed_stage(cost / self._max_budget_usd, bands)
|
||||||
|
if stage is None:
|
||||||
|
return
|
||||||
|
pct = round(100 * cost / self._max_budget_usd)
|
||||||
|
reserve_pct = round(_SUBAGENT_BUDGET_RESERVE * 100)
|
||||||
|
if self._interactive:
|
||||||
|
content = (
|
||||||
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||||
|
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||||
|
"is reached all agents are paused until the user chooses to continue. "
|
||||||
|
f"{_wrapup_directive(context, stage)}"
|
||||||
|
)
|
||||||
|
elif is_root:
|
||||||
|
content = (
|
||||||
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||||
|
f"spent ({pct}%). This budget is shared across every agent in the scan; when it "
|
||||||
|
"is reached the whole scan is stopped immediately, and sub-agents are stopped at "
|
||||||
|
f"{reserve_pct}% to reserve the remainder for your final report. "
|
||||||
|
f"{_wrapup_directive(context, stage)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = (
|
||||||
|
f"[{_urgency(stage)}] Scan cost budget: ${cost:.2f}/${self._max_budget_usd:.2f} "
|
||||||
|
f"spent ({pct}%). This budget is shared across every agent in the scan; "
|
||||||
|
f"sub-agents are stopped at {reserve_pct}% to leave the remainder for the root "
|
||||||
|
f"agent's final report. {_wrapup_directive(context, stage)}"
|
||||||
|
)
|
||||||
|
input_items.append({"role": "user", "content": content})
|
||||||
|
|
||||||
async def on_llm_end(
|
async def on_llm_end(
|
||||||
self,
|
self,
|
||||||
@@ -66,6 +250,21 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
|
|||||||
if self._max_budget_usd is not None:
|
if self._max_budget_usd is not None:
|
||||||
cost = report_state.get_total_llm_cost()
|
cost = report_state.get_total_llm_cost()
|
||||||
if cost >= self._max_budget_usd:
|
if cost >= self._max_budget_usd:
|
||||||
|
if self._interactive:
|
||||||
|
raise BudgetPausedError(
|
||||||
|
f"Scan budget of ${self._max_budget_usd:.2f} reached "
|
||||||
|
f"(spent ${cost:.4f}); pausing until the user continues"
|
||||||
|
)
|
||||||
raise BudgetExceededError(
|
raise BudgetExceededError(
|
||||||
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
f"Token budget of ${self._max_budget_usd:.2f} exceeded (spent ${cost:.4f})"
|
||||||
)
|
)
|
||||||
|
is_root = ctx.get("parent_id") is None
|
||||||
|
if not self._interactive and not is_root:
|
||||||
|
reserve_limit = self._max_budget_usd * _SUBAGENT_BUDGET_RESERVE
|
||||||
|
if cost >= reserve_limit:
|
||||||
|
raise SubagentBudgetReservedError(
|
||||||
|
f"Sub-agent budget reserve reached: spent ${cost:.4f} of "
|
||||||
|
f"${self._max_budget_usd:.2f} "
|
||||||
|
f"(>= {round(_SUBAGENT_BUDGET_RESERVE * 100)}% reserve); stopping this "
|
||||||
|
"sub-agent so the root agent can finish the scan."
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from openai.types.shared import Reasoning
|
|||||||
|
|
||||||
from strix.config.models import (
|
from strix.config.models import (
|
||||||
DEFAULT_MODEL_RETRY,
|
DEFAULT_MODEL_RETRY,
|
||||||
|
bedrock_route_supports_prompt_caching,
|
||||||
|
is_bedrock_route,
|
||||||
|
is_claude_model,
|
||||||
is_known_openai_bare_model,
|
is_known_openai_bare_model,
|
||||||
model_supports_reasoning,
|
model_supports_reasoning,
|
||||||
request_timeout_extra_args,
|
request_timeout_extra_args,
|
||||||
@@ -128,6 +131,7 @@ def make_model_settings(
|
|||||||
model_name: str,
|
model_name: str,
|
||||||
force_required_tool_choice: bool = False,
|
force_required_tool_choice: bool = False,
|
||||||
request_timeout: float | None = None,
|
request_timeout: float | None = None,
|
||||||
|
prompt_cache: bool = True,
|
||||||
) -> ModelSettings:
|
) -> ModelSettings:
|
||||||
model_settings = ModelSettings(
|
model_settings = ModelSettings(
|
||||||
parallel_tool_calls=False,
|
parallel_tool_calls=False,
|
||||||
@@ -145,9 +149,38 @@ def make_model_settings(
|
|||||||
)
|
)
|
||||||
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
if force_required_tool_choice and _accepts_required_tool_choice(model_name):
|
||||||
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
model_settings = model_settings.resolve(ModelSettings(tool_choice="required"))
|
||||||
|
|
||||||
|
cache_extra_args = _prompt_cache_extra_args(model_name) if prompt_cache else None
|
||||||
|
if cache_extra_args:
|
||||||
|
model_settings = model_settings.resolve(
|
||||||
|
ModelSettings(
|
||||||
|
extra_args={**(model_settings.extra_args or {}), **cache_extra_args},
|
||||||
|
),
|
||||||
|
)
|
||||||
return model_settings
|
return model_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_cache_extra_args(model_name: str) -> dict[str, Any] | None:
|
||||||
|
"""LiteLLM ``cache_control_injection_points`` for Claude prompt caching.
|
||||||
|
|
||||||
|
System prompt + rolling last-message breakpoint everywhere; ``tool_config``
|
||||||
|
only on Bedrock Converse (the only route whose LiteLLM transform consumes
|
||||||
|
it — elsewhere it leaks onto the wire and native Anthropic 400s). Unmapped
|
||||||
|
Bedrock models get no points at all: Bedrock rejects the passed-through
|
||||||
|
field outright.
|
||||||
|
"""
|
||||||
|
if not is_claude_model(model_name):
|
||||||
|
return None
|
||||||
|
if is_bedrock_route(model_name) and not bedrock_route_supports_prompt_caching(model_name):
|
||||||
|
return None
|
||||||
|
|
||||||
|
points: list[dict[str, Any]] = [{"location": "message", "role": "system"}]
|
||||||
|
if is_bedrock_route(model_name):
|
||||||
|
points.append({"location": "tool_config"})
|
||||||
|
points.append({"location": "message", "index": -1})
|
||||||
|
return {"cache_control_injection_points": points}
|
||||||
|
|
||||||
|
|
||||||
def child_initial_input(
|
def child_initial_input(
|
||||||
*,
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
+53
-5
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from agents import RunConfig
|
from agents import RunConfig
|
||||||
@@ -29,7 +31,7 @@ from strix.core.execution import (
|
|||||||
from strix.core.execution import (
|
from strix.core.execution import (
|
||||||
spawn_child_agent as start_child_agent,
|
spawn_child_agent as start_child_agent,
|
||||||
)
|
)
|
||||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
from strix.core.hooks import BudgetExceededError, ReportUsageHooks, recomputed_budget_flags
|
||||||
from strix.core.inputs import (
|
from strix.core.inputs import (
|
||||||
DEFAULT_MAX_TURNS,
|
DEFAULT_MAX_TURNS,
|
||||||
build_root_task,
|
build_root_task,
|
||||||
@@ -38,8 +40,13 @@ from strix.core.inputs import (
|
|||||||
)
|
)
|
||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||||
from strix.core.sessions import open_agent_session
|
from strix.core.sessions import open_agent_session
|
||||||
|
from strix.report.state import get_global_report_state
|
||||||
from strix.runtime import session_manager
|
from strix.runtime import session_manager
|
||||||
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
from strix.telemetry.logging import set_scan_id, setup_scan_logging
|
||||||
|
from strix.tools.output_store import (
|
||||||
|
WORKSPACE_SPILL_DIR,
|
||||||
|
configure_spill_writer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -179,6 +186,18 @@ async def run_strix_scan(
|
|||||||
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
|
f"Cannot resume scan {scan_id}: missing SDK session database at {agents_db}",
|
||||||
)
|
)
|
||||||
await coordinator.restore(snap)
|
await coordinator.restore(snap)
|
||||||
|
report_state = get_global_report_state()
|
||||||
|
if report_state is not None:
|
||||||
|
budget_stopped, reserve_stopped = recomputed_budget_flags(
|
||||||
|
report_state.get_total_llm_cost(),
|
||||||
|
max_budget_usd,
|
||||||
|
interactive=interactive,
|
||||||
|
)
|
||||||
|
await coordinator.reset_budget_stops(
|
||||||
|
budget_stopped=budget_stopped,
|
||||||
|
reserve_stopped=reserve_stopped,
|
||||||
|
budget_paused=interactive and coordinator.budget_paused,
|
||||||
|
)
|
||||||
for aid, parent in coordinator.parent_of.items():
|
for aid, parent in coordinator.parent_of.items():
|
||||||
if parent is None:
|
if parent is None:
|
||||||
root_id = aid
|
root_id = aid
|
||||||
@@ -203,6 +222,20 @@ async def run_strix_scan(
|
|||||||
)
|
)
|
||||||
logger.info("Sandbox ready for scan %s", scan_id)
|
logger.info("Sandbox ready for scan %s", scan_id)
|
||||||
|
|
||||||
|
sandbox_session = bundle["session"]
|
||||||
|
|
||||||
|
async def _spill_to_workspace(output_id: str, text: str) -> str | None:
|
||||||
|
"""Write an oversized tool result into the sandbox; return its path or None."""
|
||||||
|
path = f"{WORKSPACE_SPILL_DIR}/{output_id}.txt"
|
||||||
|
try:
|
||||||
|
await sandbox_session.write(Path(path), io.BytesIO(text.encode("utf-8")))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to spill tool output to sandbox workspace")
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
configure_spill_writer(_spill_to_workspace)
|
||||||
|
|
||||||
sessions_to_close: list[SQLiteSession] = []
|
sessions_to_close: list[SQLiteSession] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -216,6 +249,7 @@ async def run_strix_scan(
|
|||||||
model_name=resolved_model,
|
model_name=resolved_model,
|
||||||
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
force_required_tool_choice=settings.llm.force_required_tool_choice,
|
||||||
request_timeout=settings.llm.timeout,
|
request_timeout=settings.llm.timeout,
|
||||||
|
prompt_cache=settings.llm.prompt_cache,
|
||||||
)
|
)
|
||||||
run_config = RunConfig(
|
run_config = RunConfig(
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
@@ -224,7 +258,14 @@ async def run_strix_scan(
|
|||||||
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
sandbox=SandboxRunConfig(client=bundle["client"], session=bundle["session"]),
|
||||||
trace_include_sensitive_data=False,
|
trace_include_sensitive_data=False,
|
||||||
)
|
)
|
||||||
hooks = ReportUsageHooks(model=resolved_model, max_budget_usd=max_budget_usd)
|
hooks = ReportUsageHooks(
|
||||||
|
model=resolved_model,
|
||||||
|
max_budget_usd=max_budget_usd,
|
||||||
|
max_turns=max_turns,
|
||||||
|
interactive=interactive,
|
||||||
|
)
|
||||||
|
if interactive:
|
||||||
|
coordinator.set_budget_extender(hooks.extend_budget)
|
||||||
|
|
||||||
scope_context = build_scope_context(scan_config)
|
scope_context = build_scope_context(scan_config)
|
||||||
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
|
root_context = _merge_root_prompt_context(scope_context, extra_system_prompt_context)
|
||||||
@@ -238,7 +279,7 @@ async def run_strix_scan(
|
|||||||
)
|
)
|
||||||
|
|
||||||
root_agent = build_strix_agent(
|
root_agent = build_strix_agent(
|
||||||
name="strix",
|
name="Strix",
|
||||||
skills=skills,
|
skills=skills,
|
||||||
is_root=True,
|
is_root=True,
|
||||||
scan_mode=scan_mode,
|
scan_mode=scan_mode,
|
||||||
@@ -252,7 +293,7 @@ async def run_strix_scan(
|
|||||||
if not is_resume:
|
if not is_resume:
|
||||||
await coordinator.register(
|
await coordinator.register(
|
||||||
root_id,
|
root_id,
|
||||||
"strix",
|
"Strix",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
task=root_task,
|
task=root_task,
|
||||||
skills=skills,
|
skills=skills,
|
||||||
@@ -335,6 +376,12 @@ async def run_strix_scan(
|
|||||||
|
|
||||||
async with coordinator._lock:
|
async with coordinator._lock:
|
||||||
root_status = coordinator.statuses.get(root_id)
|
root_status = coordinator.statuses.get(root_id)
|
||||||
|
root_error = coordinator.errors.get(root_id)
|
||||||
|
|
||||||
|
root_recoverable_park = root_status == "waiting" and bool(root_error)
|
||||||
|
root_start_parked = bool(
|
||||||
|
interactive and is_resume and root_status != "running" and not root_recoverable_park
|
||||||
|
)
|
||||||
|
|
||||||
result = await run_agent_loop(
|
result = await run_agent_loop(
|
||||||
agent=root_agent,
|
agent=root_agent,
|
||||||
@@ -346,7 +393,7 @@ async def run_strix_scan(
|
|||||||
agent_id=root_id,
|
agent_id=root_id,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
session=root_session,
|
session=root_session,
|
||||||
start_parked=bool(interactive and is_resume and root_status != "running"),
|
start_parked=root_start_parked,
|
||||||
event_sink=event_sink,
|
event_sink=event_sink,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
)
|
)
|
||||||
@@ -399,6 +446,7 @@ async def run_strix_scan(
|
|||||||
await coordinator.set_status(root_id, "failed")
|
await coordinator.set_status(root_id, "failed")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
configure_spill_writer(None)
|
||||||
for s in sessions_to_close:
|
for s in sessions_to_close:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
s.close()
|
s.close()
|
||||||
|
|||||||
@@ -92,6 +92,39 @@ async def _rewrite_session(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_session_items(
|
||||||
|
session: Session,
|
||||||
|
new_items: list[Any],
|
||||||
|
*,
|
||||||
|
expected_len: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Overwrite the session's items, restoring the originals on failure.
|
||||||
|
|
||||||
|
When ``expected_len`` is given, the rewrite is skipped if the session no
|
||||||
|
longer has that many items (a concurrent writer changed it), so a slow
|
||||||
|
compaction summary can't clobber newer turns.
|
||||||
|
"""
|
||||||
|
async with session_write_lock(session):
|
||||||
|
original = list(await session.get_items())
|
||||||
|
if expected_len is not None and len(original) != expected_len:
|
||||||
|
logger.warning(
|
||||||
|
"skipping session rewrite: expected %d items, found %d",
|
||||||
|
expected_len,
|
||||||
|
len(original),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
rebuilt = cast("list[TResponseInputItem]", new_items)
|
||||||
|
await session.clear_session()
|
||||||
|
try:
|
||||||
|
await session.add_items(rebuilt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("session rewrite failed; restoring original items")
|
||||||
|
await session.clear_session()
|
||||||
|
await session.add_items(original)
|
||||||
|
raise
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def strip_all_images_from_session(session: Session) -> bool:
|
async def strip_all_images_from_session(session: Session) -> bool:
|
||||||
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
"""Replace every image tool output with a text placeholder (rejection recovery)."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"""`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 the
|
||||||
|
subscription.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
from strix.config import codex, load_settings
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
_USAGE = "Usage:\n strix auth login chatgpt [--manual]\n strix auth status\n strix auth logout"
|
||||||
|
|
||||||
|
|
||||||
|
def run_auth(argv: list[str]) -> int:
|
||||||
|
"""Entry point for ``strix auth …``. Returns a process exit code."""
|
||||||
|
console = Console()
|
||||||
|
# Bare `strix auth` (no subcommand) defaults to login.
|
||||||
|
subcommand = argv[0] if argv else "login"
|
||||||
|
rest = argv[1:]
|
||||||
|
|
||||||
|
if subcommand in ("-h", "--help", "help"):
|
||||||
|
console.print(_USAGE)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
handlers: dict[str, Callable[[], int]] = {
|
||||||
|
"login": lambda: _login(console, rest),
|
||||||
|
"status": lambda: _status(console),
|
||||||
|
"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)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _login(console: Console, argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
|
||||||
|
parser.add_argument(
|
||||||
|
"provider",
|
||||||
|
nargs="?",
|
||||||
|
default=LOGIN_PROVIDER,
|
||||||
|
help="Model provider to sign in with (default: chatgpt).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--manual",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip the local callback server and paste the redirect URL by hand.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
except SystemExit as exc: # argparse already printed the message
|
||||||
|
return int(exc.code or 2)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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(
|
||||||
|
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
codex.save_record(record)
|
||||||
|
_print_success(console)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_oauth_flow(
|
||||||
|
console: Console,
|
||||||
|
authorize_url: str,
|
||||||
|
verifier: str,
|
||||||
|
state: str,
|
||||||
|
*,
|
||||||
|
manual: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Drive the browser (or manual) OAuth flow and return a token record."""
|
||||||
|
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}[/]")
|
||||||
|
console.print()
|
||||||
|
if not manual:
|
||||||
|
try:
|
||||||
|
webbrowser.open(authorize_url)
|
||||||
|
except Exception: # noqa: BLE001 - opening a browser is best-effort
|
||||||
|
logger.debug("could not open browser", exc_info=True)
|
||||||
|
|
||||||
|
if server is not None:
|
||||||
|
console.print("[dim]Waiting for you to finish signing in…[/]")
|
||||||
|
result = server.wait(_CALLBACK_TIMEOUT_S)
|
||||||
|
server.shutdown()
|
||||||
|
if result is not None:
|
||||||
|
code, returned_state, error = result
|
||||||
|
if error:
|
||||||
|
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
|
||||||
|
# (the browser lands on a localhost page that won't load if no server is up;
|
||||||
|
# the address bar still holds the code+state).
|
||||||
|
console.print()
|
||||||
|
try:
|
||||||
|
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
|
||||||
|
except EOFError as exc:
|
||||||
|
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(
|
||||||
|
code: str | None,
|
||||||
|
returned_state: str | None,
|
||||||
|
verifier: str,
|
||||||
|
expected_state: str,
|
||||||
|
*,
|
||||||
|
require_state: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not code:
|
||||||
|
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 codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
|
||||||
|
if returned_state is not None and returned_state != expected_state:
|
||||||
|
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
|
||||||
|
return codex.exchange_code(code, verifier)
|
||||||
|
|
||||||
|
|
||||||
|
class _CallbackServer:
|
||||||
|
"""A one-shot local HTTP server that catches the OAuth redirect."""
|
||||||
|
|
||||||
|
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
|
||||||
|
self._httpd = httpd
|
||||||
|
self._event = event
|
||||||
|
self._holder = holder
|
||||||
|
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
|
||||||
|
if not self._event.wait(timeout):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
self._holder.get("code"),
|
||||||
|
self._holder.get("state"),
|
||||||
|
self._holder.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self._httpd.shutdown()
|
||||||
|
self._httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def _try_start_callback_server() -> _CallbackServer | None:
|
||||||
|
event = threading.Event()
|
||||||
|
holder: dict[str, Any] = {}
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args: Any) -> None: # silence default stderr logging
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path != codex.CALLBACK_PATH:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
holder["code"] = _first(query, "code")
|
||||||
|
holder["state"] = _first(query, "state")
|
||||||
|
holder["error"] = _first(query, "error_description") or _first(query, "error")
|
||||||
|
body = _render_callback_html().encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
|
||||||
|
return None
|
||||||
|
return _CallbackServer(httpd, event, holder)
|
||||||
|
|
||||||
|
|
||||||
|
def _first(query: dict[str, list[str]], key: str) -> str | None:
|
||||||
|
values = query.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def _status(console: Console) -> int:
|
||||||
|
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) -> int:
|
||||||
|
codex.logout()
|
||||||
|
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
error_text.append(f"{exc}", style="white")
|
||||||
|
console.print()
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
error_text,
|
||||||
|
title="[bold white]STRIX",
|
||||||
|
title_align="left",
|
||||||
|
border_style="red",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _print_success(console: Console) -> None:
|
||||||
|
text = Text()
|
||||||
|
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("chatgpt/", style="bold cyan")
|
||||||
|
text.append(" model (e.g. ", 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")
|
||||||
|
console.print()
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
text,
|
||||||
|
title="[bold white]STRIX",
|
||||||
|
title_align="left",
|
||||||
|
border_style="#22c55e",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
|
||||||
|
|
||||||
|
|
||||||
|
def _logo_img_tag() -> str:
|
||||||
|
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
|
||||||
|
|
||||||
|
The callback page is served offline by the local OAuth server, so the logo
|
||||||
|
is embedded rather than linked. Missing/unreadable file degrades to just the
|
||||||
|
"Strix" wordmark.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _LOGO_PATH.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
encoded = base64.b64encode(data).decode("ascii")
|
||||||
|
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_callback_html() -> str:
|
||||||
|
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
|
||||||
|
|
||||||
|
|
||||||
|
_CALLBACK_HTML = """<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Strix — signed in</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100vh; padding: 24px;
|
||||||
|
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
|
||||||
|
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
||||||
|
background: #000; color: #ededed;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
position: absolute; top: 20px; left: 22px;
|
||||||
|
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||||||
|
}
|
||||||
|
.topbar .logo { width: 40px; height: 40px; display: block; }
|
||||||
|
.topbar span {
|
||||||
|
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
|
||||||
|
transition: color .15s ease;
|
||||||
|
}
|
||||||
|
.topbar:hover span { color: #c9c9c9; }
|
||||||
|
.brand {
|
||||||
|
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
|
||||||
|
text-align: center; margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
|
||||||
|
text-align: center; margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%; max-width: 430px; text-align: center;
|
||||||
|
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
|
||||||
|
border-radius: 24px; padding: 40px 40px 34px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
|
||||||
|
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
|
||||||
|
}
|
||||||
|
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
|
||||||
|
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
|
||||||
|
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
|
||||||
|
.tagline b { color: #ededed; font-weight: 500; }
|
||||||
|
.links {
|
||||||
|
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
|
||||||
|
align-items: center; flex-wrap: wrap; font-size: .84rem;
|
||||||
|
}
|
||||||
|
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
|
||||||
|
.links a:hover { color: #fff; }
|
||||||
|
.links .dot { color: #3a3a3a; }
|
||||||
|
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
|
||||||
|
aria-label="Strix — strix.ai">
|
||||||
|
<!--LOGO-->
|
||||||
|
<span>Strix</span>
|
||||||
|
</a>
|
||||||
|
<div class="brand">Strix</div>
|
||||||
|
<h1>You're signed in</h1>
|
||||||
|
<main class="card">
|
||||||
|
<div class="badge">✓</div>
|
||||||
|
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
|
||||||
|
terminal — your security test runs there.</p>
|
||||||
|
<div class="rule"></div>
|
||||||
|
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
|
||||||
|
vulnerabilities.</p>
|
||||||
|
<nav class="links">
|
||||||
|
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
<p class="close">You can close this tab.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["run_auth"]
|
||||||
@@ -13,6 +13,7 @@ from rich.panel import Panel
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
|
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||||
from strix.core.runner import run_strix_scan
|
from strix.core.runner import run_strix_scan
|
||||||
from strix.report.state import ReportState, set_global_report_state
|
from strix.report.state import ReportState, set_global_report_state
|
||||||
from strix.runtime import session_manager
|
from strix.runtime import session_manager
|
||||||
@@ -184,6 +185,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
|
|||||||
local_sources=getattr(args, "local_sources", None) or [],
|
local_sources=getattr(args, "local_sources", None) or [],
|
||||||
interactive=bool(getattr(args, "interactive", False)),
|
interactive=bool(getattr(args, "interactive", False)),
|
||||||
max_budget_usd=getattr(args, "max_budget_usd", None),
|
max_budget_usd=getattr(args, "max_budget_usd", None),
|
||||||
|
max_turns=getattr(args, "max_turns", DEFAULT_MAX_TURNS),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
stop_updates.set()
|
stop_updates.set()
|
||||||
|
|||||||
+163
-76
@@ -5,9 +5,9 @@ Strix Agent Interface
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ from rich.text import Text
|
|||||||
|
|
||||||
from strix.config import (
|
from strix.config import (
|
||||||
apply_config_override,
|
apply_config_override,
|
||||||
|
codex,
|
||||||
load_settings,
|
load_settings,
|
||||||
persist_current,
|
persist_current,
|
||||||
)
|
)
|
||||||
@@ -30,9 +31,17 @@ from strix.config.models import (
|
|||||||
is_known_openai_bare_model,
|
is_known_openai_bare_model,
|
||||||
is_recommended_or_frontier_model,
|
is_recommended_or_frontier_model,
|
||||||
)
|
)
|
||||||
|
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||||
from strix.interface.cli import run_cli
|
from strix.interface.cli import run_cli
|
||||||
from strix.interface.tui import run_tui
|
from strix.interface.tui import run_tui
|
||||||
|
from strix.interface.update_check import (
|
||||||
|
is_binary_install,
|
||||||
|
notify_update,
|
||||||
|
prompt_update_if_available,
|
||||||
|
self_update,
|
||||||
|
start_background_check,
|
||||||
|
)
|
||||||
from strix.interface.utils import (
|
from strix.interface.utils import (
|
||||||
assign_workspace_subdirs,
|
assign_workspace_subdirs,
|
||||||
build_final_stats_text,
|
build_final_stats_text,
|
||||||
@@ -85,6 +94,16 @@ def validate_environment() -> None:
|
|||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|
||||||
|
if codex.subscription_model(settings.llm.model):
|
||||||
|
if not codex.is_authenticated():
|
||||||
|
console.print(
|
||||||
|
f"[red]STRIX_LLM={settings.llm.model} uses your ChatGPT subscription, "
|
||||||
|
"but you're not signed in.[/] Run [cyan]strix auth login chatgpt[/] first."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("Environment OK (ChatGPT subscription)")
|
||||||
|
return
|
||||||
|
|
||||||
if not settings.llm.model:
|
if not settings.llm.model:
|
||||||
missing_required_vars.append("STRIX_LLM")
|
missing_required_vars.append("STRIX_LLM")
|
||||||
|
|
||||||
@@ -192,7 +211,7 @@ def validate_environment() -> None:
|
|||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.error("Missing required env vars: %s", missing_required_vars)
|
logger.debug("Missing required env vars: %s", missing_required_vars)
|
||||||
console.print("\n")
|
console.print("\n")
|
||||||
console.print(panel)
|
console.print(panel)
|
||||||
console.print()
|
console.print()
|
||||||
@@ -205,7 +224,7 @@ def validate_environment() -> None:
|
|||||||
|
|
||||||
def check_docker_installed() -> None:
|
def check_docker_installed() -> None:
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
logger.error("Docker CLI not found in PATH")
|
logger.debug("Docker CLI not found in PATH")
|
||||||
console = Console()
|
console = Console()
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
error_text.append("DOCKER NOT INSTALLED", style="bold red")
|
||||||
@@ -267,6 +286,29 @@ def _provider_import_hint(exc: BaseException, model: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_error_hint(exc: BaseException) -> str | None:
|
||||||
|
"""Return an actionable hint for a known ChatGPT-subscription error, or None."""
|
||||||
|
if not codex.subscription_model(load_settings().llm.model):
|
||||||
|
return None
|
||||||
|
joined = " ".join(_exception_messages(exc)).lower()
|
||||||
|
if "not supported when using codex with a chatgpt account" in joined:
|
||||||
|
return (
|
||||||
|
"This model isn't available on your ChatGPT subscription. "
|
||||||
|
"Set STRIX_LLM to a model your plan includes (e.g. chatgpt/gpt-5.4)."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"error code: 401" in joined
|
||||||
|
or "http 401" in joined
|
||||||
|
or "unauthorized" in joined
|
||||||
|
or "invalid_grant" in joined
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"Your ChatGPT sign-in has expired or was revoked. Sign in again:\n"
|
||||||
|
" strix auth login chatgpt"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
async def warm_up_llm(show_model_warning: bool = True) -> None:
|
||||||
console = Console()
|
console = Console()
|
||||||
logger.info("Warming up LLM connection")
|
logger.info("Warming up LLM connection")
|
||||||
@@ -276,8 +318,8 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
|||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
configure_sdk_model_defaults(settings)
|
configure_sdk_model_defaults(settings)
|
||||||
llm = settings.llm
|
llm = settings.llm
|
||||||
|
|
||||||
raw_model = (llm.model or "").strip()
|
raw_model = (llm.model or "").strip()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
raw_model
|
raw_model
|
||||||
and "/" not in raw_model
|
and "/" not in raw_model
|
||||||
@@ -353,23 +395,63 @@ async def warm_up_llm(show_model_warning: bool = True) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
logger.info("LLM warm-up succeeded for model %s", (llm.model or "").strip())
|
||||||
|
|
||||||
|
if settings.dedupe.model:
|
||||||
|
from strix.report.dedupe import _dedupe_extra_args
|
||||||
|
|
||||||
|
dedupe_model = settings.dedupe.model.strip()
|
||||||
|
raw_model = dedupe_model
|
||||||
|
deduper = StrixProvider().get_model(dedupe_model)
|
||||||
|
# Match the runtime path: send the dedupe key/endpoint per call so a
|
||||||
|
# separate-provider dedupe model authenticates during warm-up too.
|
||||||
|
deduper_extra = _dedupe_extra_args(settings.dedupe)
|
||||||
|
deduper_settings = ModelSettings(extra_args=deduper_extra or None)
|
||||||
|
await asyncio.wait_for(
|
||||||
|
deduper.get_response(
|
||||||
|
system_instructions="You are a helpful assistant.",
|
||||||
|
input="Reply with just 'OK'.",
|
||||||
|
model_settings=deduper_settings,
|
||||||
|
tools=[],
|
||||||
|
output_schema=None,
|
||||||
|
handoffs=[],
|
||||||
|
tracing=ModelTracing.DISABLED,
|
||||||
|
previous_response_id=None,
|
||||||
|
conversation_id=None,
|
||||||
|
prompt=None,
|
||||||
|
),
|
||||||
|
timeout=llm.timeout,
|
||||||
|
)
|
||||||
|
logger.info("LLM warm-up succeeded for dedupe model %s", dedupe_model)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("LLM warm-up failed")
|
logger.debug("LLM warm-up failed", exc_info=True)
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
sub_hint = _subscription_error_hint(e)
|
||||||
error_text.append("\n\n", style="white")
|
if sub_hint is not None:
|
||||||
error_text.append("Could not establish connection to the language model.\n", style="white")
|
# The model/backend answered with a clear, actionable rejection —
|
||||||
error_text.append("Please check your configuration and try again.\n", style="white")
|
# show that instead of a generic "connection failed".
|
||||||
hint = _provider_import_hint(e, raw_model)
|
border_style = "yellow"
|
||||||
if hint is not None:
|
error_text.append("MODEL NOT AVAILABLE ON SUBSCRIPTION", style="bold yellow")
|
||||||
error_text.append(f"\n{hint}\n", style="bold yellow")
|
error_text.append("\n\n", style="white")
|
||||||
error_text.append(f"\nError: {e}", style="dim white")
|
error_text.append(f"{sub_hint}\n", style="white")
|
||||||
|
error_text.append(f"\nDetails: {e}", style="dim white")
|
||||||
|
else:
|
||||||
|
border_style = "red"
|
||||||
|
error_text.append("LLM CONNECTION FAILED", style="bold red")
|
||||||
|
error_text.append("\n\n", style="white")
|
||||||
|
error_text.append(
|
||||||
|
"Could not establish connection to the language model.\n", style="white"
|
||||||
|
)
|
||||||
|
error_text.append("Please check your configuration and try again.\n", style="white")
|
||||||
|
hint = _provider_import_hint(e, raw_model)
|
||||||
|
if hint is not None:
|
||||||
|
error_text.append(f"\n{hint}\n", style="bold yellow")
|
||||||
|
error_text.append(f"\nError: {e}", style="dim white")
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
error_text,
|
error_text,
|
||||||
title="[bold white]STRIX",
|
title="[bold white]STRIX",
|
||||||
title_align="left",
|
title_align="left",
|
||||||
border_style="red",
|
border_style=border_style,
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -400,6 +482,16 @@ def _positive_budget(value: str) -> float:
|
|||||||
return budget
|
return budget
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(value: str) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
|
||||||
|
if parsed <= 0:
|
||||||
|
raise argparse.ArgumentTypeError("must be an integer greater than 0")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments() -> argparse.Namespace:
|
def parse_arguments() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
||||||
@@ -448,6 +540,14 @@ Examples:
|
|||||||
version=f"strix {get_version()}",
|
version=f"strix {get_version()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--update",
|
||||||
|
action="store_true",
|
||||||
|
help="Update strix to the latest version and exit. Self-updates the "
|
||||||
|
"standalone binary install; for pip/pipx/uv installs, prints the "
|
||||||
|
"matching upgrade command instead.",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-t",
|
"-t",
|
||||||
"--target",
|
"--target",
|
||||||
@@ -547,10 +647,27 @@ Examples:
|
|||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--max-budget-usd",
|
"--max-budget",
|
||||||
|
dest="max_budget_usd",
|
||||||
|
metavar="USD",
|
||||||
type=_positive_budget,
|
type=_positive_budget,
|
||||||
default=None,
|
default=None,
|
||||||
help="Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached.",
|
help=(
|
||||||
|
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
|
||||||
|
"Graduated wrap-up warnings are sent to all agents as it is approached."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-turns",
|
||||||
|
dest="max_turns",
|
||||||
|
metavar="N",
|
||||||
|
type=_positive_int,
|
||||||
|
default=DEFAULT_MAX_TURNS,
|
||||||
|
help=(
|
||||||
|
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
|
||||||
|
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -566,6 +683,9 @@ Examples:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.update:
|
||||||
|
sys.exit(0 if self_update() else 1)
|
||||||
|
|
||||||
if args.instruction and args.instruction_file:
|
if args.instruction and args.instruction_file:
|
||||||
parser.error(
|
parser.error(
|
||||||
"Cannot specify both --instruction and --instruction-file. Use one or the other."
|
"Cannot specify both --instruction and --instruction-file. Use one or the other."
|
||||||
@@ -664,6 +784,7 @@ def _persist_run_record(args: argparse.Namespace) -> None:
|
|||||||
"status": "running",
|
"status": "running",
|
||||||
"start_time": datetime.now(UTC).isoformat(),
|
"start_time": datetime.now(UTC).isoformat(),
|
||||||
"end_time": None,
|
"end_time": None,
|
||||||
|
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||||
"targets_info": args.targets_info,
|
"targets_info": args.targets_info,
|
||||||
"scan_mode": args.scan_mode,
|
"scan_mode": args.scan_mode,
|
||||||
"instruction": args.instruction,
|
"instruction": args.instruction,
|
||||||
@@ -721,9 +842,7 @@ def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser
|
|||||||
args.scan_mode = persisted_scan_mode
|
args.scan_mode = persisted_scan_mode
|
||||||
|
|
||||||
|
|
||||||
def display_completion_message(
|
def display_completion_message(args: argparse.Namespace, results_path: Path) -> None:
|
||||||
args: argparse.Namespace, results_path: Path, web_url: str | None = None
|
|
||||||
) -> None:
|
|
||||||
console = Console()
|
console = Console()
|
||||||
report_state = get_global_report_state()
|
report_state = get_global_report_state()
|
||||||
|
|
||||||
@@ -762,28 +881,12 @@ def display_completion_message(
|
|||||||
results_text.append(str(results_path), style="#60a5fa")
|
results_text.append(str(results_path), style="#60a5fa")
|
||||||
panel_parts.extend(["\n", results_text])
|
panel_parts.extend(["\n", results_text])
|
||||||
|
|
||||||
if web_url:
|
view_text = Text()
|
||||||
web_text = Text()
|
view_text.append("\n")
|
||||||
web_text.append("\n")
|
view_text.append("View", style="dim")
|
||||||
web_text.append("View in web", style="dim")
|
view_text.append(" ")
|
||||||
web_text.append(" ")
|
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
||||||
# OSC-8 hyperlink: clickable in modern terminals, falls back to the URL.
|
panel_parts.extend(["\n", view_text])
|
||||||
web_text.append(web_url, style=f"#60a5fa link {web_url}")
|
|
||||||
panel_parts.extend(["\n", web_text])
|
|
||||||
|
|
||||||
reopen_text = Text()
|
|
||||||
reopen_text.append("\n")
|
|
||||||
reopen_text.append("Reopen", style="dim")
|
|
||||||
reopen_text.append(" ")
|
|
||||||
reopen_text.append(f"strix view {args.run_name}", style="#22c55e")
|
|
||||||
panel_parts.extend(["\n", reopen_text])
|
|
||||||
else:
|
|
||||||
view_text = Text()
|
|
||||||
view_text.append("\n")
|
|
||||||
view_text.append("View", style="dim")
|
|
||||||
view_text.append(" ")
|
|
||||||
view_text.append(f"strix view {args.run_name}", style="#22c55e")
|
|
||||||
panel_parts.extend(["\n", view_text])
|
|
||||||
|
|
||||||
if not scan_completed:
|
if not scan_completed:
|
||||||
resume_text = Text()
|
resume_text = Text()
|
||||||
@@ -814,6 +917,8 @@ def display_completion_message(
|
|||||||
"[#60a5fa]discord.gg/strix-ai[/]"
|
"[#60a5fa]discord.gg/strix-ai[/]"
|
||||||
)
|
)
|
||||||
console.print()
|
console.print()
|
||||||
|
if not args.non_interactive:
|
||||||
|
notify_update(console)
|
||||||
|
|
||||||
|
|
||||||
def pull_docker_image() -> None:
|
def pull_docker_image() -> None:
|
||||||
@@ -841,7 +946,7 @@ def pull_docker_image() -> None:
|
|||||||
last_update = process_pull_line(line, layers_info, status, last_update)
|
last_update = process_pull_line(line, layers_info, status, last_update)
|
||||||
|
|
||||||
except DockerException as e:
|
except DockerException as e:
|
||||||
logger.exception("Failed to pull docker image %s", image)
|
logger.debug("Failed to pull docker image %s", image, exc_info=True)
|
||||||
console.print()
|
console.print()
|
||||||
error_text = Text()
|
error_text = Text()
|
||||||
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
error_text.append("FAILED TO PULL IMAGE", style="bold red")
|
||||||
@@ -875,16 +980,29 @@ def main() -> None:
|
|||||||
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
# `strix view [<run>]` is a viewer-only subcommand, dispatched before the
|
||||||
# scan argument parser (which requires a target) and before any scan setup.
|
# scan argument parser (which requires a target) and before any scan setup.
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
if len(sys.argv) > 1 and sys.argv[1] == "view":
|
||||||
from strix.viewer.cli import run_view
|
from strix.interface.viewer.cli import run_view
|
||||||
|
|
||||||
run_view(sys.argv[2:])
|
run_view(sys.argv[2:])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# `strix auth …` manages model-subscription sign-in and exits; it needs no
|
||||||
|
# target, Docker, or scan setup.
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "auth":
|
||||||
|
from strix.interface.auth_cli import run_auth
|
||||||
|
|
||||||
|
sys.exit(run_auth(sys.argv[2:]))
|
||||||
|
|
||||||
args = parse_arguments()
|
args = parse_arguments()
|
||||||
|
|
||||||
if args.config:
|
if args.config:
|
||||||
apply_config_override(validate_config_file(args.config))
|
apply_config_override(validate_config_file(args.config))
|
||||||
|
|
||||||
|
start_background_check()
|
||||||
|
if not args.non_interactive and prompt_update_if_available(Console()):
|
||||||
|
if is_binary_install() and sys.platform != "win32":
|
||||||
|
os.execv(sys.executable, sys.argv) # noqa: S606 # nosec B606
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
check_docker_installed()
|
check_docker_installed()
|
||||||
pull_docker_image()
|
pull_docker_image()
|
||||||
|
|
||||||
@@ -941,6 +1059,7 @@ def main() -> None:
|
|||||||
|
|
||||||
_telemetry_start_kwargs = {
|
_telemetry_start_kwargs = {
|
||||||
"model": load_settings().llm.model,
|
"model": load_settings().llm.model,
|
||||||
|
"auth_mode": codex.auth_mode(load_settings().llm.model),
|
||||||
"scan_mode": args.scan_mode,
|
"scan_mode": args.scan_mode,
|
||||||
"is_whitebox": is_whitebox_scan(args.targets_info),
|
"is_whitebox": is_whitebox_scan(args.targets_info),
|
||||||
"interactive": not args.non_interactive,
|
"interactive": not args.non_interactive,
|
||||||
@@ -975,39 +1094,7 @@ def main() -> None:
|
|||||||
|
|
||||||
results_path = run_dir_for(args.run_name)
|
results_path = run_dir_for(args.run_name)
|
||||||
|
|
||||||
# For an interactive run, host the local viewer so the completion panel can
|
display_completion_message(args, results_path)
|
||||||
# show a clickable "View in web" link. Skipped in non-interactive/CI runs
|
|
||||||
# (no TTY to serve and it would block the process).
|
|
||||||
viewer_httpd = None
|
|
||||||
web_url = None
|
|
||||||
if not args.non_interactive and sys.stdout.isatty():
|
|
||||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
|
||||||
|
|
||||||
if bundle_is_built():
|
|
||||||
try:
|
|
||||||
viewer_httpd, base_url, token = serve(results_path, open_browser=False)
|
|
||||||
# The completion panel's "View in web" link must authorize the
|
|
||||||
# browser, so hand it the tokened URL rather than the bare host.
|
|
||||||
web_url = authorized_url(base_url, token)
|
|
||||||
posthog.viewer_opened(source="post_scan", live=False)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("could not start local viewer", exc_info=True)
|
|
||||||
viewer_httpd, web_url = None, None
|
|
||||||
|
|
||||||
display_completion_message(args, results_path, web_url=web_url)
|
|
||||||
|
|
||||||
if viewer_httpd is not None:
|
|
||||||
console = Console()
|
|
||||||
console.print("[dim]Hosting the local viewer. Press Ctrl-C to stop.[/]")
|
|
||||||
console.print()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
console.print("\n[dim]Viewer stopped.[/]")
|
|
||||||
finally:
|
|
||||||
viewer_httpd.shutdown()
|
|
||||||
viewer_httpd.server_close()
|
|
||||||
|
|
||||||
if args.non_interactive:
|
if args.non_interactive:
|
||||||
report_state = get_global_report_state()
|
report_state = get_global_report_state()
|
||||||
|
|||||||
+75
-24
@@ -34,6 +34,7 @@ from textual.widgets.tree import TreeNode
|
|||||||
from strix.config import load_settings
|
from strix.config import load_settings
|
||||||
from strix.config.models import is_recommended_or_frontier_model
|
from strix.config.models import is_recommended_or_frontier_model
|
||||||
from strix.core.hooks import BudgetExceededError
|
from strix.core.hooks import BudgetExceededError
|
||||||
|
from strix.core.inputs import DEFAULT_MAX_TURNS
|
||||||
from strix.core.runner import run_strix_scan
|
from strix.core.runner import run_strix_scan
|
||||||
from strix.interface.tui.live_view import TuiLiveView
|
from strix.interface.tui.live_view import TuiLiveView
|
||||||
from strix.interface.tui.messages import send_user_message_to_agent
|
from strix.interface.tui.messages import send_user_message_to_agent
|
||||||
@@ -42,6 +43,12 @@ from strix.interface.tui.renderers.agent_message_renderer import AgentMessageRen
|
|||||||
from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer
|
from strix.interface.tui.renderers.user_message_renderer import UserMessageRenderer
|
||||||
from strix.interface.utils import build_tui_stats_text
|
from strix.interface.utils import build_tui_stats_text
|
||||||
from strix.report.state import ReportState, set_global_report_state
|
from strix.report.state import ReportState, set_global_report_state
|
||||||
|
from strix.report.writer import (
|
||||||
|
guess_language_name,
|
||||||
|
parse_fenced_code,
|
||||||
|
resolve_lexer,
|
||||||
|
safe_fence,
|
||||||
|
)
|
||||||
from strix.runtime import session_manager
|
from strix.runtime import session_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -330,12 +337,11 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|||||||
return "#65a30d"
|
return "#65a30d"
|
||||||
return "#6b7280"
|
return "#6b7280"
|
||||||
|
|
||||||
def _highlight_python(self, code: str) -> Text:
|
def _highlight_python(self, code: str, language: str | None = None) -> Text:
|
||||||
try:
|
try:
|
||||||
from pygments.lexers import PythonLexer
|
|
||||||
from pygments.styles import get_style_by_name
|
from pygments.styles import get_style_by_name
|
||||||
|
|
||||||
lexer = PythonLexer()
|
lexer = resolve_lexer(language, code)
|
||||||
style = get_style_by_name("native")
|
style = get_style_by_name("native")
|
||||||
colors = {
|
colors = {
|
||||||
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
token: f"#{style_def['color']}" for token, style_def in style if style_def["color"]
|
||||||
@@ -501,10 +507,11 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|||||||
|
|
||||||
poc_script_code = vuln.get("poc_script_code", "")
|
poc_script_code = vuln.get("poc_script_code", "")
|
||||||
if poc_script_code:
|
if poc_script_code:
|
||||||
|
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
text.append("PoC Code", style=self.FIELD_STYLE)
|
text.append("PoC Code", style=self.FIELD_STYLE)
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append_text(self._highlight_python(poc_script_code))
|
text.append_text(self._highlight_python(poc_code, poc_language))
|
||||||
|
|
||||||
remediation_steps = vuln.get("remediation_steps", "")
|
remediation_steps = vuln.get("remediation_steps", "")
|
||||||
if remediation_steps:
|
if remediation_steps:
|
||||||
@@ -601,9 +608,12 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|||||||
lines.append(vuln["poc_description"])
|
lines.append(vuln["poc_description"])
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if vuln.get("poc_script_code"):
|
if vuln.get("poc_script_code"):
|
||||||
lines.append("```python")
|
poc_language, poc_code = parse_fenced_code(vuln["poc_script_code"])
|
||||||
lines.append(vuln["poc_script_code"])
|
fence_lang = poc_language or guess_language_name(poc_code)
|
||||||
lines.append("```")
|
fence = safe_fence(poc_code)
|
||||||
|
lines.append(f"{fence}{fence_lang}")
|
||||||
|
lines.append(poc_code)
|
||||||
|
lines.append(fence)
|
||||||
|
|
||||||
if vuln.get("code_locations"):
|
if vuln.get("code_locations"):
|
||||||
lines.extend(["", "## Code Analysis", ""])
|
lines.extend(["", "## Code Analysis", ""])
|
||||||
@@ -619,7 +629,9 @@ class VulnerabilityDetailScreen(ModalScreen): # type: ignore[misc]
|
|||||||
if loc.get("label"):
|
if loc.get("label"):
|
||||||
lines.append(f" {loc['label']}")
|
lines.append(f" {loc['label']}")
|
||||||
if loc.get("snippet"):
|
if loc.get("snippet"):
|
||||||
lines.append(f"```\n{loc['snippet']}\n```")
|
snippet = str(loc["snippet"])
|
||||||
|
snippet_fence = safe_fence(snippet)
|
||||||
|
lines.append(f"{snippet_fence}\n{snippet}\n{snippet_fence}")
|
||||||
if loc.get("fix_before") or loc.get("fix_after"):
|
if loc.get("fix_before") or loc.get("fix_after"):
|
||||||
lines.append("**Suggested Fix:**")
|
lines.append("**Suggested Fix:**")
|
||||||
lines.append("```diff")
|
lines.append("```diff")
|
||||||
@@ -802,6 +814,8 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
self._scan_stop_event = threading.Event()
|
self._scan_stop_event = threading.Event()
|
||||||
self._scan_completed = threading.Event()
|
self._scan_completed = threading.Event()
|
||||||
self._scan_error: BaseException | None = None
|
self._scan_error: BaseException | None = None
|
||||||
|
self._error_noted_agents: set[str] = set()
|
||||||
|
self._budget_pause_notified = False
|
||||||
|
|
||||||
self._spinner_frame_index: int = 0
|
self._spinner_frame_index: int = 0
|
||||||
self._sweep_num_squares: int = 6
|
self._sweep_num_squares: int = 6
|
||||||
@@ -1015,26 +1029,50 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
else:
|
else:
|
||||||
self._agent_graph_sync_future = None
|
self._agent_graph_sync_future = None
|
||||||
try:
|
try:
|
||||||
parent_of, statuses, names = future.result()
|
parent_of, statuses, names, errors = future.result()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("TUI agent graph sync failed")
|
logger.exception("TUI agent graph sync failed")
|
||||||
else:
|
else:
|
||||||
for agent_id, status in statuses.items():
|
for agent_id, status in statuses.items():
|
||||||
|
error = errors.get(agent_id)
|
||||||
self.live_view.upsert_agent(
|
self.live_view.upsert_agent(
|
||||||
agent_id,
|
agent_id,
|
||||||
name=names.get(agent_id, agent_id),
|
name=names.get(agent_id, agent_id),
|
||||||
parent_id=parent_of.get(agent_id),
|
parent_id=parent_of.get(agent_id),
|
||||||
status=status,
|
status=status,
|
||||||
|
error_message=error,
|
||||||
)
|
)
|
||||||
|
if status in {"failed", "crashed"} and error:
|
||||||
|
if agent_id not in self._error_noted_agents:
|
||||||
|
self._error_noted_agents.add(agent_id)
|
||||||
|
self.live_view.record_agent_error(agent_id, error)
|
||||||
|
else:
|
||||||
|
self._error_noted_agents.discard(agent_id)
|
||||||
|
self._notify_budget_pause(statuses)
|
||||||
|
|
||||||
if self._scan_loop is None or self._scan_loop.is_closed():
|
if self._scan_loop is None or self._scan_loop.is_closed():
|
||||||
return
|
return
|
||||||
|
|
||||||
async def collect() -> tuple[dict[str, str | None], dict[str, Any], dict[str, str]]:
|
async def collect() -> tuple[
|
||||||
|
dict[str, str | None], dict[str, Any], dict[str, str], dict[str, str]
|
||||||
|
]:
|
||||||
return await self.coordinator.graph_snapshot()
|
return await self.coordinator.graph_snapshot()
|
||||||
|
|
||||||
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
self._agent_graph_sync_future = asyncio.run_coroutine_threadsafe(collect(), self._scan_loop)
|
||||||
|
|
||||||
|
def _notify_budget_pause(self, statuses: dict[str, Any]) -> None:
|
||||||
|
paused = any(status == "budget_paused" for status in statuses.values())
|
||||||
|
if paused and not self._budget_pause_notified:
|
||||||
|
self._budget_pause_notified = True
|
||||||
|
self.notify(
|
||||||
|
"Budget limit reached \u2014 agents paused. Send a message to continue "
|
||||||
|
"(this extends the budget), or ctrl-q to quit.",
|
||||||
|
severity="warning",
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
elif not paused:
|
||||||
|
self._budget_pause_notified = False
|
||||||
|
|
||||||
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
def _update_agent_node(self, agent_id: str, agent_data: dict[str, Any]) -> bool:
|
||||||
if agent_id not in self.agent_nodes:
|
if agent_id not in self.agent_nodes:
|
||||||
return False
|
return False
|
||||||
@@ -1047,8 +1085,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
status_indicators = {
|
status_indicators = {
|
||||||
"running": "⚪",
|
"running": "⚪",
|
||||||
"waiting": "⏸",
|
"waiting": "⏸",
|
||||||
|
"budget_paused": "⏸",
|
||||||
"completed": "🟢",
|
"completed": "🟢",
|
||||||
"failed": "🔴",
|
"failed": "🔴",
|
||||||
|
"crashed": "🔴",
|
||||||
"stopped": "■",
|
"stopped": "■",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,20 +1274,26 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
text.append(msg)
|
text.append(msg)
|
||||||
return (text, Text(), False)
|
return (text, Text(), False)
|
||||||
|
|
||||||
if status == "failed":
|
if status in {"failed", "crashed"}:
|
||||||
error_msg = agent_data.get("error_message", "")
|
error_msg = agent_data.get("error_message", "")
|
||||||
text = Text()
|
text = Text()
|
||||||
if error_msg:
|
text.append(error_msg or "Agent failed", style="red")
|
||||||
text.append(error_msg, style="red")
|
text.append(" · ", style="dim")
|
||||||
else:
|
text.append("Send message to resume", style="dim")
|
||||||
text.append("Scan failed", style="red")
|
|
||||||
self._stop_dot_animation()
|
self._stop_dot_animation()
|
||||||
return (text, Text(), False)
|
return (text, Text(), False)
|
||||||
|
|
||||||
if status == "waiting":
|
if status in {"waiting", "budget_paused"}:
|
||||||
text = Text()
|
text = Text()
|
||||||
text.append("Send message to resume", style="dim")
|
keymap = Text()
|
||||||
return (text, Text(), False)
|
if status == "budget_paused":
|
||||||
|
text.append("Budget limit reached", style="yellow")
|
||||||
|
text.append(" \u00b7 ", style="dim")
|
||||||
|
text.append("Send a message to continue", style="dim")
|
||||||
|
keymap = keymap_styled([("ctrl-q", "quit")])
|
||||||
|
else:
|
||||||
|
text.append("Send message to resume", style="dim")
|
||||||
|
return (text, keymap, False)
|
||||||
|
|
||||||
if status == "running":
|
if status == "running":
|
||||||
if self._agent_has_real_activity(agent_id):
|
if self._agent_has_real_activity(agent_id):
|
||||||
@@ -1472,6 +1518,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
coordinator=self.coordinator,
|
coordinator=self.coordinator,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
max_budget_usd=getattr(self.args, "max_budget_usd", None),
|
max_budget_usd=getattr(self.args, "max_budget_usd", None),
|
||||||
|
max_turns=getattr(self.args, "max_turns", DEFAULT_MAX_TURNS),
|
||||||
event_sink=self._capture_sdk_event,
|
event_sink=self._capture_sdk_event,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1479,10 +1526,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
except (KeyboardInterrupt, asyncio.CancelledError):
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
||||||
logger.info("Scan interrupted by user")
|
logger.info("Scan interrupted by user")
|
||||||
except BudgetExceededError:
|
except BudgetExceededError:
|
||||||
# Defensive: the runner stops the scan cleanly on budget and
|
logger.info("Scan stopped: --max-budget limit reached")
|
||||||
# returns, so this normally never propagates. Treat it as a
|
|
||||||
# graceful stop, not a scan error, if it ever does.
|
|
||||||
logger.info("Scan stopped: --max-budget-usd limit reached")
|
|
||||||
except (ConnectionError, TimeoutError) as e:
|
except (ConnectionError, TimeoutError) as e:
|
||||||
logging.exception("Network error during scan")
|
logging.exception("Network error during scan")
|
||||||
self._scan_error = e
|
self._scan_error = e
|
||||||
@@ -1537,8 +1581,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
status_indicators = {
|
status_indicators = {
|
||||||
"running": "⚪",
|
"running": "⚪",
|
||||||
"waiting": "⏸",
|
"waiting": "⏸",
|
||||||
|
"budget_paused": "⏸",
|
||||||
"completed": "🟢",
|
"completed": "🟢",
|
||||||
"failed": "🔴",
|
"failed": "🔴",
|
||||||
|
"crashed": "🔴",
|
||||||
"stopped": "■",
|
"stopped": "■",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1582,8 +1628,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
status_indicators = {
|
status_indicators = {
|
||||||
"running": "⚪",
|
"running": "⚪",
|
||||||
"waiting": "⏸",
|
"waiting": "⏸",
|
||||||
|
"budget_paused": "⏸",
|
||||||
"completed": "🟢",
|
"completed": "🟢",
|
||||||
"failed": "🔴",
|
"failed": "🔴",
|
||||||
|
"crashed": "🔴",
|
||||||
"stopped": "■",
|
"stopped": "■",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1705,7 +1753,10 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
if not submitted:
|
if not submitted:
|
||||||
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
if self._scan_completed.is_set():
|
||||||
|
self.notify("The scan has ended; message was not sent", severity="warning")
|
||||||
|
else:
|
||||||
|
self.notify("Scan loop is not ready; message was not sent", severity="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._displayed_events.clear()
|
self._displayed_events.clear()
|
||||||
@@ -1838,7 +1889,7 @@ class StrixTUIApp(App): # type: ignore[misc]
|
|||||||
webbrowser.open(self._viewer_url)
|
webbrowser.open(self._viewer_url)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||||
|
|
||||||
if not bundle_is_built():
|
if not bundle_is_built():
|
||||||
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class TuiLiveView:
|
|||||||
self.events: list[dict[str, Any]] = []
|
self.events: list[dict[str, Any]] = []
|
||||||
self._next_event_id = 1
|
self._next_event_id = 1
|
||||||
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
self._open_assistant_event_by_agent: dict[str, dict[str, Any]] = {}
|
||||||
self._tool_event_by_call_id: dict[str, dict[str, Any]] = {}
|
self._tool_event_by_agent_and_call_id: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
|
||||||
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
def hydrate_from_run_dir(self, run_dir: Path) -> None:
|
||||||
state_dir = runtime_state_dir(run_dir)
|
state_dir = runtime_state_dir(run_dir)
|
||||||
@@ -86,6 +86,17 @@ class TuiLiveView:
|
|||||||
current["error_message"] = error_message
|
current["error_message"] = error_message
|
||||||
current["updated_at"] = now
|
current["updated_at"] = now
|
||||||
|
|
||||||
|
def record_agent_error(self, agent_id: str, error: str) -> None:
|
||||||
|
self._append_event(
|
||||||
|
agent_id,
|
||||||
|
"chat",
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": (f"An error occurred: {error}\nI'm now waiting for new instructions."),
|
||||||
|
"metadata": {"source": "agent_error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def record_user_message(self, agent_id: str, content: str) -> None:
|
def record_user_message(self, agent_id: str, content: str) -> None:
|
||||||
self._append_event(
|
self._append_event(
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -212,7 +223,8 @@ class TuiLiveView:
|
|||||||
timestamp: str | None = None,
|
timestamp: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
call_id = call["call_id"]
|
call_id = call["call_id"]
|
||||||
existing = self._tool_event_by_call_id.get(call_id)
|
event_key = (agent_id, call_id)
|
||||||
|
existing = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||||
tool_data = {
|
tool_data = {
|
||||||
"tool_name": call["tool_name"],
|
"tool_name": call["tool_name"],
|
||||||
"args": call["args"],
|
"args": call["args"],
|
||||||
@@ -222,7 +234,7 @@ class TuiLiveView:
|
|||||||
}
|
}
|
||||||
if existing is None:
|
if existing is None:
|
||||||
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
event = self._append_event(agent_id, "tool", tool_data, timestamp=timestamp)
|
||||||
self._tool_event_by_call_id[call_id] = event
|
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||||
else:
|
else:
|
||||||
existing["data"].update(tool_data)
|
existing["data"].update(tool_data)
|
||||||
self._bump_event(existing, timestamp=timestamp)
|
self._bump_event(existing, timestamp=timestamp)
|
||||||
@@ -238,7 +250,8 @@ class TuiLiveView:
|
|||||||
timestamp: str | None = None,
|
timestamp: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
call_id = output["call_id"]
|
call_id = output["call_id"]
|
||||||
event = self._tool_event_by_call_id.get(call_id)
|
event_key = (agent_id, call_id)
|
||||||
|
event = self._tool_event_by_agent_and_call_id.get(event_key)
|
||||||
if event is None:
|
if event is None:
|
||||||
event = self._append_event(
|
event = self._append_event(
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -252,7 +265,7 @@ class TuiLiveView:
|
|||||||
},
|
},
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
)
|
)
|
||||||
self._tool_event_by_call_id[call_id] = event
|
self._tool_event_by_agent_and_call_id[event_key] = event
|
||||||
|
|
||||||
result = _parse_json_value(output["output"])
|
result = _parse_json_value(output["output"])
|
||||||
event["data"]["result"] = result
|
event["data"]["result"] = result
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ from .base_renderer import BaseToolRenderer
|
|||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
def _author_label(note: dict[str, Any]) -> str:
|
||||||
|
if note.get("by_you"):
|
||||||
|
return "you"
|
||||||
|
agent_name = note.get("agent_name")
|
||||||
|
return str(agent_name).strip() if agent_name else ""
|
||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class CreateNoteRenderer(BaseToolRenderer):
|
class CreateNoteRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "create_note"
|
tool_name: ClassVar[str] = "create_note"
|
||||||
@@ -123,6 +130,9 @@ class ListNotesRenderer(BaseToolRenderer):
|
|||||||
text.append("\n - ")
|
text.append("\n - ")
|
||||||
text.append(title)
|
text.append(title)
|
||||||
text.append(f" ({category})", style="dim")
|
text.append(f" ({category})", style="dim")
|
||||||
|
author = _author_label(note)
|
||||||
|
if author:
|
||||||
|
text.append(f" by {author}", style="dim")
|
||||||
|
|
||||||
if note_content:
|
if note_content:
|
||||||
text.append("\n ")
|
text.append("\n ")
|
||||||
@@ -156,6 +166,9 @@ class GetNoteRenderer(BaseToolRenderer):
|
|||||||
text.append("\n ")
|
text.append("\n ")
|
||||||
text.append(title)
|
text.append(title)
|
||||||
text.append(f" ({category})", style="dim")
|
text.append(f" ({category})", style="dim")
|
||||||
|
author = _author_label(note)
|
||||||
|
if author:
|
||||||
|
text.append(f" by {author}", style="dim")
|
||||||
if content:
|
if content:
|
||||||
text.append("\n ")
|
text.append("\n ")
|
||||||
text.append(content, style="dim")
|
text.append(content, style="dim")
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from pygments.lexers import PythonLexer
|
|
||||||
from pygments.styles import get_style_by_name
|
from pygments.styles import get_style_by_name
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from strix.report.writer import parse_fenced_code, resolve_lexer
|
||||||
|
|
||||||
from .base_renderer import BaseToolRenderer
|
from .base_renderer import BaseToolRenderer
|
||||||
from .registry import register_tool_renderer
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
@@ -61,8 +62,8 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _highlight_python(cls, code: str) -> Text:
|
def _highlight_code(cls, code: str, language: str | None) -> Text:
|
||||||
lexer = PythonLexer()
|
lexer = resolve_lexer(language, code)
|
||||||
text = Text()
|
text = Text()
|
||||||
|
|
||||||
for token_type, token_value in lexer.get_tokens(code):
|
for token_type, token_value in lexer.get_tokens(code):
|
||||||
@@ -234,10 +235,11 @@ class CreateVulnerabilityReportRenderer(BaseToolRenderer):
|
|||||||
text.append(poc_description)
|
text.append(poc_description)
|
||||||
|
|
||||||
if poc_script_code:
|
if poc_script_code:
|
||||||
|
poc_language, poc_code = parse_fenced_code(poc_script_code)
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
text.append("PoC Code", style=FIELD_STYLE)
|
text.append("PoC Code", style=FIELD_STYLE)
|
||||||
text.append("\n")
|
text.append("\n")
|
||||||
text.append_text(cls._highlight_python(poc_script_code))
|
text.append_text(cls._highlight_code(poc_code, poc_language))
|
||||||
|
|
||||||
if remediation_steps:
|
if remediation_steps:
|
||||||
text.append("\n\n")
|
text.append("\n\n")
|
||||||
@@ -429,3 +431,117 @@ class CreateDependencyReportRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
css_classes = cls.get_css_classes("completed")
|
css_classes = cls.get_css_classes("completed")
|
||||||
return Static(padded, classes=css_classes)
|
return Static(padded, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
_LIST_SEVERITY_COLORS = {
|
||||||
|
"critical": "#dc2626",
|
||||||
|
"high": "#ea580c",
|
||||||
|
"medium": "#d97706",
|
||||||
|
"low": "#65a30d",
|
||||||
|
"info": "#0284c7",
|
||||||
|
"none": "#6b7280",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_style(severity: Any) -> str:
|
||||||
|
return _LIST_SEVERITY_COLORS.get(str(severity or "").lower(), "#d97706")
|
||||||
|
|
||||||
|
|
||||||
|
def _author_label(report: dict[str, Any]) -> str:
|
||||||
|
if report.get("by_you"):
|
||||||
|
return "you"
|
||||||
|
agent_name = report.get("agent_name")
|
||||||
|
return str(agent_name).strip() if agent_name else ""
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class ListReportsRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "list_reports"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
result = _coerce_dict(tool_data.get("result"))
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◆ ", style="#ef4444")
|
||||||
|
text.append("reports", style="dim")
|
||||||
|
|
||||||
|
if isinstance(tool_data.get("result"), str) and str(tool_data["result"]).strip():
|
||||||
|
text.append("\n ")
|
||||||
|
text.append(str(tool_data["result"]).strip(), style="dim")
|
||||||
|
elif result.get("success"):
|
||||||
|
total = result.get("total_count", 0)
|
||||||
|
reports = _coerce_list_of_dicts(result.get("reports"))
|
||||||
|
counts = _coerce_dict(result.get("severity_counts"))
|
||||||
|
|
||||||
|
text.append(f" ({total})", style="dim")
|
||||||
|
for sev, count in counts.items():
|
||||||
|
text.append(" ")
|
||||||
|
text.append(f"{sev} {count}", style=_severity_style(sev))
|
||||||
|
|
||||||
|
if not reports:
|
||||||
|
text.append("\n ")
|
||||||
|
text.append("No reports filed yet", style="dim")
|
||||||
|
else:
|
||||||
|
for report in reports:
|
||||||
|
rid = str(report.get("id", "")).strip()
|
||||||
|
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||||
|
severity = str(report.get("severity", "")).strip()
|
||||||
|
text.append("\n - ")
|
||||||
|
if severity:
|
||||||
|
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||||
|
text.append(" ")
|
||||||
|
if rid:
|
||||||
|
text.append(f"{rid} ", style="dim")
|
||||||
|
text.append(title)
|
||||||
|
author = _author_label(report)
|
||||||
|
if author:
|
||||||
|
text.append(f" ({author})", style="dim")
|
||||||
|
else:
|
||||||
|
text.append("\n ")
|
||||||
|
text.append("Loading...", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes("completed")
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class GetReportRenderer(BaseToolRenderer):
|
||||||
|
tool_name: ClassVar[str] = "get_report"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "reporting-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
result = _coerce_dict(tool_data.get("result"))
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
text.append("◆ ", style="#ef4444")
|
||||||
|
text.append("report read", style="dim")
|
||||||
|
|
||||||
|
report = _coerce_dict(result.get("report")) if result.get("success") else {}
|
||||||
|
if report:
|
||||||
|
rid = str(report.get("id", "")).strip()
|
||||||
|
title = str(report.get("title", "")).strip() or "(untitled)"
|
||||||
|
severity = str(report.get("severity", "")).strip()
|
||||||
|
text.append("\n ")
|
||||||
|
if severity:
|
||||||
|
text.append(severity.upper(), style=f"bold {_severity_style(severity)}")
|
||||||
|
text.append(" ")
|
||||||
|
if rid:
|
||||||
|
text.append(f"{rid} ", style="dim")
|
||||||
|
text.append(title)
|
||||||
|
author = _author_label(report)
|
||||||
|
if author:
|
||||||
|
text.append(f" ({author})", style="dim")
|
||||||
|
target = str(report.get("target", "")).strip()
|
||||||
|
if target:
|
||||||
|
text.append("\n ")
|
||||||
|
text.append(target, style="dim")
|
||||||
|
else:
|
||||||
|
text.append("\n ")
|
||||||
|
detail = result.get("error") if result.get("success") is False else None
|
||||||
|
text.append(str(detail) if detail else "Loading...", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes("completed")
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""Update notifications and self-update for the strix CLI.
|
||||||
|
|
||||||
|
Follows the pattern used by tools like gh, uv, and pip: a background,
|
||||||
|
rate-limited (once per 24h) check against the release source, a cached
|
||||||
|
result in ``~/.strix``, a non-intrusive notice with the upgrade command
|
||||||
|
for the detected install method, and a ``strix --update`` self-update
|
||||||
|
path for the standalone binary install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
|
||||||
|
from strix.telemetry._common import get_version
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GITHUB_REPO = "usestrix/strix"
|
||||||
|
PYPI_PACKAGE = "strix-agent"
|
||||||
|
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
||||||
|
REQUEST_TIMEOUT_SECONDS = 5
|
||||||
|
|
||||||
|
_CACHE_PATH = Path.home() / ".strix" / "update-check.json"
|
||||||
|
|
||||||
|
_background_thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_disabled() -> bool:
|
||||||
|
return bool(os.environ.get("STRIX_NO_UPDATE_CHECK")) or any(
|
||||||
|
os.environ.get(key)
|
||||||
|
for key in ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "BUILDKITE", "CIRCLECI")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary_install() -> bool:
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def get_install_method() -> str:
|
||||||
|
if is_binary_install():
|
||||||
|
return "binary"
|
||||||
|
prefix = str(Path(sys.prefix)).replace("\\", "/")
|
||||||
|
if "/pipx/" in prefix or prefix.endswith("/pipx"):
|
||||||
|
return "pipx"
|
||||||
|
if "/uv/tools/" in prefix:
|
||||||
|
return "uv"
|
||||||
|
return "pip"
|
||||||
|
|
||||||
|
|
||||||
|
def get_upgrade_command(method: str | None = None) -> str:
|
||||||
|
method = method or get_install_method()
|
||||||
|
commands = {
|
||||||
|
"binary": "strix --update",
|
||||||
|
"pipx": "pipx upgrade strix-agent",
|
||||||
|
"uv": "uv tool upgrade strix-agent",
|
||||||
|
"pip": "pip install --upgrade strix-agent",
|
||||||
|
}
|
||||||
|
return commands[method]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(value: str) -> tuple[int, ...] | None:
|
||||||
|
parts = value.strip().lstrip("v").split(".")
|
||||||
|
try:
|
||||||
|
return tuple(int(part) for part in parts)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_newer(latest: str, current: str) -> bool:
|
||||||
|
latest_parts = _parse_version(latest)
|
||||||
|
current_parts = _parse_version(current)
|
||||||
|
if latest_parts is None or current_parts is None:
|
||||||
|
return False
|
||||||
|
return latest_parts > current_parts
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_latest_version() -> str | None:
|
||||||
|
try:
|
||||||
|
if is_binary_install():
|
||||||
|
response = requests.get(
|
||||||
|
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
tag = response.json().get("tag_name", "")
|
||||||
|
return tag.lstrip("v") or None
|
||||||
|
response = requests.get(
|
||||||
|
f"https://pypi.org/pypi/{PYPI_PACKAGE}/json",
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
version = response.json().get("info", {}).get("version")
|
||||||
|
return str(version) if version else None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("update check failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_asset_digest(version: str, filename: str) -> str | None:
|
||||||
|
"""Return the expected sha256 (hex) for a release asset, if the API provides one."""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/v{version}",
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
for asset in response.json().get("assets", []):
|
||||||
|
if asset.get("name") == filename:
|
||||||
|
digest = asset.get("digest") or ""
|
||||||
|
if digest.startswith("sha256:"):
|
||||||
|
return digest.removeprefix("sha256:")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("release asset digest lookup failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache() -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
with _CACHE_PATH.open(encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return cast("dict[str, object]", data)
|
||||||
|
except Exception: # noqa: BLE001, S110
|
||||||
|
pass # nosec B110
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache(**fields: object) -> None:
|
||||||
|
try:
|
||||||
|
cache = _read_cache()
|
||||||
|
cache.update(fields)
|
||||||
|
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_CACHE_PATH.write_text(json.dumps(cache), encoding="utf-8")
|
||||||
|
except Exception: # noqa: BLE001, S110
|
||||||
|
pass # nosec B110
|
||||||
|
|
||||||
|
|
||||||
|
def skip_version(version: str) -> None:
|
||||||
|
"""Remember not to prompt again for this version (newer releases still notify)."""
|
||||||
|
_write_cache(skipped_version=version)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_cache() -> None:
|
||||||
|
latest = _fetch_latest_version()
|
||||||
|
if latest:
|
||||||
|
_write_cache(latest_version=latest, checked_at=time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_check() -> None:
|
||||||
|
"""Refresh the cached latest-version info in a daemon thread (at most once per 24h)."""
|
||||||
|
global _background_thread # noqa: PLW0603
|
||||||
|
if _is_disabled():
|
||||||
|
return
|
||||||
|
cache = _read_cache()
|
||||||
|
checked_at = cache.get("checked_at")
|
||||||
|
if isinstance(checked_at, int | float) and time.time() - checked_at < CHECK_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_background_thread = threading.Thread(target=_refresh_cache, daemon=True)
|
||||||
|
_background_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_update(*, respect_skip: bool = True) -> str | None:
|
||||||
|
"""Return the newer version from the cache, or None if up to date / unknown."""
|
||||||
|
if _is_disabled():
|
||||||
|
return None
|
||||||
|
if _background_thread is not None:
|
||||||
|
_background_thread.join(timeout=0.2)
|
||||||
|
cache = _read_cache()
|
||||||
|
latest = cache.get("latest_version")
|
||||||
|
current = get_version()
|
||||||
|
if not isinstance(latest, str) or current == "unknown" or not _is_newer(latest, current):
|
||||||
|
return None
|
||||||
|
if respect_skip and cache.get("skipped_version") == latest:
|
||||||
|
return None
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def notify_update(console: Console) -> None:
|
||||||
|
latest = get_available_update()
|
||||||
|
if not latest:
|
||||||
|
return
|
||||||
|
console.print(
|
||||||
|
f"[#eab308]A new version of strix is available:[/] "
|
||||||
|
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||||
|
f" [dim]·[/] [#60a5fa]{get_upgrade_command()}[/]"
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def run_package_upgrade(console: Console, method: str) -> bool:
|
||||||
|
"""Upgrade a package-manager install by running its upgrade command."""
|
||||||
|
command = get_upgrade_command(method).split()
|
||||||
|
console.print(f"[dim]Running[/] [#60a5fa]{' '.join(command)}[/]")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=False) # noqa: S603
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"[bold red]Update failed:[/] {e}")
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
console.print(
|
||||||
|
f"[bold red]Update failed[/] [dim](exit code {result.returncode}).[/] "
|
||||||
|
f"Run it manually: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
console.print("[#22c55e]✓ strix updated — restart the scan to use the new version[/]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_update_if_available(console: Console) -> bool:
|
||||||
|
"""Offer an interactive update before a scan starts.
|
||||||
|
|
||||||
|
Returns True if strix was updated (caller should re-exec / exit).
|
||||||
|
"""
|
||||||
|
latest = get_available_update()
|
||||||
|
if not latest or not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||||
|
return False
|
||||||
|
console.print()
|
||||||
|
console.print(
|
||||||
|
f"[#eab308]A new version of strix is available:[/] "
|
||||||
|
f"[dim]{get_version()}[/] [dim]→[/] [bold #22c55e]{latest}[/]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
"[dim] y — update now n — not now (ask again next run) s — skip this version[/]"
|
||||||
|
)
|
||||||
|
choice = Prompt.ask("Update strix?", choices=["y", "n", "s"], default="n")
|
||||||
|
console.print()
|
||||||
|
if choice == "s":
|
||||||
|
skip_version(latest)
|
||||||
|
return False
|
||||||
|
if choice != "y":
|
||||||
|
return False
|
||||||
|
method = get_install_method()
|
||||||
|
if method == "binary":
|
||||||
|
return self_update(console, version=latest)
|
||||||
|
return run_package_upgrade(console, method)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_target() -> str | None:
|
||||||
|
raw_os = platform.system().lower()
|
||||||
|
os_name = {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(raw_os)
|
||||||
|
arch = platform.machine().lower()
|
||||||
|
arch = {"aarch64": "arm64", "amd64": "x86_64"}.get(arch, arch)
|
||||||
|
if os_name is None:
|
||||||
|
return None
|
||||||
|
target = f"{os_name}-{arch}"
|
||||||
|
supported = {
|
||||||
|
"linux-x86_64",
|
||||||
|
"linux-arm64",
|
||||||
|
"macos-x86_64",
|
||||||
|
"macos-arm64",
|
||||||
|
"windows-x86_64",
|
||||||
|
}
|
||||||
|
return target if target in supported else None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_and_replace(version: str, target: str, console: Console) -> bool:
|
||||||
|
is_windows = target.startswith("windows")
|
||||||
|
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||||
|
filename = f"strix-{version}-{target}{archive_ext}"
|
||||||
|
url = f"https://github.com/{GITHUB_REPO}/releases/download/v{version}/{filename}"
|
||||||
|
binary_name = f"strix-{version}-{target}" + (".exe" if is_windows else "")
|
||||||
|
current_exe = Path(sys.executable).resolve()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
archive_path = tmp_dir / filename
|
||||||
|
console.print(f"[dim]Downloading[/] {url}")
|
||||||
|
with requests.get( # nosec B113
|
||||||
|
url,
|
||||||
|
stream=True,
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS * 12,
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
with archive_path.open("wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=1 << 20):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
expected_digest = _fetch_asset_digest(version, filename)
|
||||||
|
if expected_digest:
|
||||||
|
actual_digest = _sha256_file(archive_path)
|
||||||
|
if actual_digest != expected_digest:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"checksum mismatch for {filename}: "
|
||||||
|
f"expected sha256 {expected_digest}, got {actual_digest}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print("[dim yellow]No published checksum available; skipping verification[/]")
|
||||||
|
|
||||||
|
if is_windows:
|
||||||
|
with zipfile.ZipFile(archive_path) as zf:
|
||||||
|
zf.extract(binary_name, tmp_dir)
|
||||||
|
else:
|
||||||
|
with tarfile.open(archive_path, "r:gz") as tf:
|
||||||
|
tf.extract(binary_name, tmp_dir, filter="data")
|
||||||
|
|
||||||
|
new_binary = tmp_dir / binary_name
|
||||||
|
new_binary.chmod(new_binary.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
staged = current_exe.with_name(current_exe.name + ".new")
|
||||||
|
try:
|
||||||
|
shutil.copy2(new_binary, staged)
|
||||||
|
if is_windows:
|
||||||
|
# Windows can't replace a running executable in place; move it aside first.
|
||||||
|
old = current_exe.with_name(current_exe.name + ".old")
|
||||||
|
old.unlink(missing_ok=True)
|
||||||
|
current_exe.rename(old)
|
||||||
|
try:
|
||||||
|
staged.replace(current_exe)
|
||||||
|
except Exception:
|
||||||
|
old.rename(current_exe)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
staged.replace(current_exe)
|
||||||
|
except Exception:
|
||||||
|
staged.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def self_update(console: Console | None = None, version: str | None = None) -> bool:
|
||||||
|
"""Replace the running standalone binary with the latest release.
|
||||||
|
|
||||||
|
Returns True on success. For package-manager installs this only
|
||||||
|
prints the right upgrade command and returns False.
|
||||||
|
"""
|
||||||
|
console = console or Console()
|
||||||
|
|
||||||
|
if not is_binary_install():
|
||||||
|
method = get_install_method()
|
||||||
|
console.print(
|
||||||
|
f"[#eab308]This strix was installed via {method};[/] "
|
||||||
|
f"upgrade it with: [#60a5fa]{get_upgrade_command(method)}[/]"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
latest = version or _fetch_latest_version()
|
||||||
|
if not latest:
|
||||||
|
console.print("[bold red]Could not determine the latest strix version.[/]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
current = get_version()
|
||||||
|
if current != "unknown" and not _is_newer(latest, current):
|
||||||
|
console.print(f"[#22c55e]strix {current} is already the latest version.[/]")
|
||||||
|
return True
|
||||||
|
|
||||||
|
target = _release_target()
|
||||||
|
if not target:
|
||||||
|
console.print(
|
||||||
|
f"[bold red]No prebuilt binary for this platform "
|
||||||
|
f"({platform.system()}/{platform.machine()}).[/]"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
_download_and_replace(latest, target, console)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.debug("self-update failed", exc_info=True)
|
||||||
|
console.print(f"[bold red]Update failed:[/] {e}")
|
||||||
|
console.print(
|
||||||
|
"[dim]You can reinstall manually with:[/] "
|
||||||
|
"[#60a5fa]curl -sSL https://strix.ai/install | bash[/]"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
_write_cache(latest_version=latest, checked_at=time.time())
|
||||||
|
console.print(f"[#22c55e]✓ Updated strix to {latest}[/]")
|
||||||
|
return True
|
||||||
+44
-14
@@ -11,11 +11,10 @@ import tempfile
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
|
import requests
|
||||||
from docker.errors import DockerException, ImageNotFound
|
from docker.errors import DockerException, ImageNotFound
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@@ -253,6 +252,20 @@ def _llm_usage(report_state: Any) -> dict[str, Any]:
|
|||||||
return usage if isinstance(usage, dict) else {}
|
return usage if isinstance(usage, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subscription(report_state: Any) -> bool:
|
||||||
|
"""Whether this run uses a model subscription (no metered cost).
|
||||||
|
|
||||||
|
Prefers the run record so it's correct for hydrated/resumed runs; falls back
|
||||||
|
to current settings.
|
||||||
|
"""
|
||||||
|
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 codex
|
||||||
|
|
||||||
|
return codex.auth_mode(load_settings().llm.model) == "subscription"
|
||||||
|
|
||||||
|
|
||||||
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
def _int_stat(usage: dict[str, Any], key: str) -> int:
|
||||||
try:
|
try:
|
||||||
return max(0, int(usage.get(key) or 0))
|
return max(0, int(usage.get(key) or 0))
|
||||||
@@ -283,11 +296,16 @@ def _build_llm_usage_stats(
|
|||||||
*,
|
*,
|
||||||
live: bool = False,
|
live: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
subscription = _is_subscription(report_state)
|
||||||
usage = _llm_usage(report_state)
|
usage = _llm_usage(report_state)
|
||||||
if not usage or _int_stat(usage, "requests") <= 0:
|
if not usage or _int_stat(usage, "requests") <= 0:
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
stats_text.append("Cost ", style="dim")
|
stats_text.append("Cost ", style="dim")
|
||||||
stats_text.append("$0.0000 ", style="#fbbf24")
|
if subscription:
|
||||||
|
stats_text.append("$0.00 ", style="#22c55e")
|
||||||
|
stats_text.append("(subscription) ", style="dim")
|
||||||
|
else:
|
||||||
|
stats_text.append("$0.0000 ", style="#fbbf24")
|
||||||
stats_text.append("· ", style="dim white")
|
stats_text.append("· ", style="dim white")
|
||||||
stats_text.append("Tokens ", style="dim")
|
stats_text.append("Tokens ", style="dim")
|
||||||
stats_text.append("0", style="white")
|
stats_text.append("0", style="white")
|
||||||
@@ -312,7 +330,12 @@ def _build_llm_usage_stats(
|
|||||||
stats_text.append("Output Tokens ", style="dim")
|
stats_text.append("Output Tokens ", style="dim")
|
||||||
stats_text.append(format_token_count(output_tokens), style="white")
|
stats_text.append(format_token_count(output_tokens), style="white")
|
||||||
|
|
||||||
if live or cost > 0:
|
if subscription:
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("Cost ", style="dim")
|
||||||
|
stats_text.append("$0.00", style="#22c55e")
|
||||||
|
stats_text.append(" (subscription)", style="dim")
|
||||||
|
elif live or cost > 0:
|
||||||
stats_text.append(" · ", style="dim white")
|
stats_text.append(" · ", style="dim white")
|
||||||
stats_text.append("Cost ", style="dim")
|
stats_text.append("Cost ", style="dim")
|
||||||
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
stats_text.append(f"${cost:.4f}", style="#fbbf24")
|
||||||
@@ -337,6 +360,9 @@ def build_live_stats_text(report_state: Any) -> Text:
|
|||||||
model = load_settings().llm.model or "unknown"
|
model = load_settings().llm.model or "unknown"
|
||||||
stats_text.append("Model ", style="dim")
|
stats_text.append("Model ", style="dim")
|
||||||
stats_text.append(str(model), style="white")
|
stats_text.append(str(model), style="white")
|
||||||
|
if _is_subscription(report_state):
|
||||||
|
stats_text.append(" · ", style="dim white")
|
||||||
|
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||||
stats_text.append("\n")
|
stats_text.append("\n")
|
||||||
|
|
||||||
vuln_count = len(report_state.vulnerability_reports)
|
vuln_count = len(report_state.vulnerability_reports)
|
||||||
@@ -379,6 +405,10 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
|||||||
|
|
||||||
model = load_settings().llm.model or "unknown"
|
model = load_settings().llm.model or "unknown"
|
||||||
stats_text.append(str(model), style="white")
|
stats_text.append(str(model), style="white")
|
||||||
|
subscription = _is_subscription(report_state)
|
||||||
|
if subscription:
|
||||||
|
stats_text.append("\n")
|
||||||
|
stats_text.append("ChatGPT subscription", style="#22c55e")
|
||||||
|
|
||||||
usage = _llm_usage(report_state)
|
usage = _llm_usage(report_state)
|
||||||
if usage and _int_stat(usage, "total_tokens") > 0:
|
if usage and _int_stat(usage, "total_tokens") > 0:
|
||||||
@@ -388,7 +418,10 @@ def build_tui_stats_text(report_state: Any) -> Text:
|
|||||||
style="white",
|
style="white",
|
||||||
)
|
)
|
||||||
cost = _float_stat(usage, "cost")
|
cost = _float_stat(usage, "cost")
|
||||||
if cost > 0:
|
if subscription:
|
||||||
|
stats_text.append(" · ", style="white")
|
||||||
|
stats_text.append("$0.00", style="white")
|
||||||
|
elif cost > 0:
|
||||||
stats_text.append(" · ", style="white")
|
stats_text.append(" · ", style="white")
|
||||||
stats_text.append(f"${cost:.2f}", style="white")
|
stats_text.append(f"${cost:.2f}", style="white")
|
||||||
|
|
||||||
@@ -1054,13 +1087,12 @@ def resolve_diff_scope_context(
|
|||||||
def _is_http_git_repo(url: str) -> bool:
|
def _is_http_git_repo(url: str) -> bool:
|
||||||
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
check_url = f"{url.rstrip('/')}/info/refs?service=git-upload-pack"
|
||||||
try:
|
try:
|
||||||
req = Request(check_url, headers={"User-Agent": "git/strix"}) # noqa: S310
|
resp = requests.get(check_url, headers={"User-Agent": "git/strix"}, timeout=10)
|
||||||
with urlopen(req, timeout=10) as resp: # noqa: S310 # nosec B310
|
except (requests.RequestException, ValueError):
|
||||||
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
|
||||||
except HTTPError as e:
|
|
||||||
return e.code == 401
|
|
||||||
except (URLError, OSError, ValueError):
|
|
||||||
return False
|
return False
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
return resp.status_code == 401
|
||||||
|
return "x-git-upload-pack-advertisement" in resp.headers.get("Content-Type", "")
|
||||||
|
|
||||||
|
|
||||||
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR0911
|
||||||
@@ -1147,9 +1179,7 @@ def read_target_list_file(path_str: str) -> list[str]:
|
|||||||
if (target := line.strip()) and not target.startswith("#")
|
if (target := line.strip()) and not target.startswith("#")
|
||||||
]
|
]
|
||||||
except UnicodeDecodeError as e:
|
except UnicodeDecodeError as e:
|
||||||
raise ValueError(
|
raise ValueError(f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}") from e
|
||||||
f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}"
|
|
||||||
) from e
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
|
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ directly from the run's on-disk files. No cloud dependency, no file picker.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from strix.viewer.server import serve
|
from strix.interface.viewer.server import serve
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["serve"]
|
__all__ = ["serve"]
|
||||||
@@ -15,12 +15,12 @@ import base64
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from strix.config.loader import load_settings
|
from strix.config.loader import load_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -147,21 +147,17 @@ def _post_json(path: str, payload: dict[str, Any], *, timeout: int) -> tuple[int
|
|||||||
map, not raised.
|
map, not raised.
|
||||||
"""
|
"""
|
||||||
url = f"{_app_url()}{path}"
|
url = f"{_app_url()}{path}"
|
||||||
body = json.dumps(payload).encode("utf-8")
|
|
||||||
request = urllib.request.Request( # noqa: S310 - fixed https relay URL
|
|
||||||
url,
|
|
||||||
data=body,
|
|
||||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
response = requests.post(
|
||||||
return response.status, _parse_body(response.read())
|
url,
|
||||||
except urllib.error.HTTPError as exc:
|
json=payload,
|
||||||
return exc.code, _parse_body(exc.read())
|
headers={"Accept": "application/json"},
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
logger.warning("relay request to %s failed: %s", path, exc)
|
logger.warning("relay request to %s failed: %s", path, exc)
|
||||||
raise RelayError("unavailable") from exc
|
raise RelayError("unavailable") from exc
|
||||||
|
return response.status_code, _parse_body(response.content)
|
||||||
|
|
||||||
|
|
||||||
def _parse_body(raw: bytes) -> dict[str, Any]:
|
def _parse_body(raw: bytes) -> dict[str, Any]:
|
||||||
@@ -16,8 +16,8 @@ from strix.core.paths import (
|
|||||||
run_record_path,
|
run_record_path,
|
||||||
runs_base_dir,
|
runs_base_dir,
|
||||||
)
|
)
|
||||||
from strix.viewer.server import authorized_url, bundle_is_built, serve
|
from strix.interface.viewer.server import authorized_url, bundle_is_built, serve
|
||||||
from strix.viewer.transcript import read_run_summary
|
from strix.interface.viewer.transcript import read_run_summary
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -58,7 +58,7 @@ def run_view(argv: list[str]) -> None:
|
|||||||
if not bundle_is_built():
|
if not bundle_is_built():
|
||||||
console.print(
|
console.print(
|
||||||
"[bold red]Viewer UI is not built.[/]\n"
|
"[bold red]Viewer UI is not built.[/]\n"
|
||||||
"Build it with: [cyan]cd strix/viewer/frontend && npm ci && npm run build[/]"
|
"Build it with: [cyan]cd strix/interface/viewer/frontend && npm ci && npm run build[/]"
|
||||||
)
|
)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
Generated
-9
@@ -63,7 +63,6 @@
|
|||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -1605,7 +1604,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1616,7 +1614,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -1739,7 +1736,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.42",
|
"baseline-browser-mapping": "^2.10.42",
|
||||||
"caniuse-lite": "^1.0.30001803",
|
"caniuse-lite": "^1.0.30001803",
|
||||||
@@ -1920,7 +1916,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -3571,7 +3566,6 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3623,7 +3617,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3633,7 +3626,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -4109,7 +4101,6 @@
|
|||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
+18
-1
@@ -100,6 +100,7 @@ export function RunDetails({
|
|||||||
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
|
||||||
const totalTokens = num(usage.total_tokens);
|
const totalTokens = num(usage.total_tokens);
|
||||||
const cost = num(usage.cost);
|
const cost = num(usage.cost);
|
||||||
|
const subscription = str(raw.auth_mode) === "subscription";
|
||||||
|
|
||||||
const sub = (n: number, word: string) => (
|
const sub = (n: number, word: string) => (
|
||||||
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
|
||||||
@@ -175,6 +176,15 @@ export function RunDetails({
|
|||||||
{hasUsage ? (
|
{hasUsage ? (
|
||||||
<dl className="space-y-2.5 tabular-nums">
|
<dl className="space-y-2.5 tabular-nums">
|
||||||
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
|
||||||
|
{subscription && (
|
||||||
|
<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]">
|
||||||
|
ChatGPT subscription
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
|
||||||
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
|
||||||
{inputTokens != null && (
|
{inputTokens != null && (
|
||||||
@@ -190,7 +200,14 @@ export function RunDetails({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
|
||||||
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
|
{subscription ? (
|
||||||
|
<Field label="Cost">
|
||||||
|
<span className="text-[#22c55e]">$0.00</span>
|
||||||
|
<span className="text-[#666]"> (subscription)</span>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>
|
||||||
|
)}
|
||||||
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
+6
@@ -49,6 +49,9 @@ export default function NotesRenderer({ toolName, args, result }: ToolRendererPr
|
|||||||
<div className="mt-1.5 text-[#999] text-[13px]">
|
<div className="mt-1.5 text-[#999] text-[13px]">
|
||||||
{note.title ?? "(untitled)"}
|
{note.title ?? "(untitled)"}
|
||||||
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
<span className="text-[#555] ml-1">({note.category ?? "general"})</span>
|
||||||
|
{(note.by_you || note.agent_name) && (
|
||||||
|
<span className="text-[#666] ml-1 text-xs">by {note.by_you ? "you" : note.agent_name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
{note.content && <div className="mt-1"><Markdown text={note.content} /></div>}
|
||||||
</>
|
</>
|
||||||
@@ -74,6 +77,9 @@ export default function NotesRenderer({ toolName, args, result }: ToolRendererPr
|
|||||||
<span className="text-[#555] mr-1">-</span>
|
<span className="text-[#555] mr-1">-</span>
|
||||||
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
<span className="text-[#999]">{n.title ?? "(untitled)"}</span>
|
||||||
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
<span className="text-[#555] ml-1">({n.category ?? "general"})</span>
|
||||||
|
{(n.by_you || n.agent_name) && (
|
||||||
|
<span className="text-[#666] ml-1 text-xs">by {n.by_you ? "you" : n.agent_name}</span>
|
||||||
|
)}
|
||||||
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
{n.content && <div className="ml-3"><Markdown text={n.content} /></div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ToolRendererProps } from "@/types/events";
|
||||||
|
import { TruncatedText } from "./ToolCard";
|
||||||
|
import Markdown from "./Markdown";
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
critical: "text-red-400", high: "text-orange-400", medium: "text-yellow-400",
|
||||||
|
low: "text-blue-400", info: "text-cyan-400", none: "text-[#888]",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReportEntry {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
severity?: string;
|
||||||
|
cvss?: number;
|
||||||
|
cve?: string;
|
||||||
|
cwe?: string;
|
||||||
|
target?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
method?: string;
|
||||||
|
description_preview?: string;
|
||||||
|
description?: string;
|
||||||
|
agent_name?: string;
|
||||||
|
by_you?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorTag(r: ReportEntry) {
|
||||||
|
if (!r.agent_name && !r.by_you) return null;
|
||||||
|
const label = r.by_you ? "you" : r.agent_name;
|
||||||
|
return <span className="text-[#666] text-xs ml-1.5">({label})</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sevBadge(severity: string | undefined) {
|
||||||
|
const sev = String(severity ?? "").toLowerCase();
|
||||||
|
const color = SEVERITY_COLORS[sev] ?? "text-yellow-400";
|
||||||
|
return <span className={`font-semibold text-[13px] ${color}`}>{sev.toUpperCase() || "—"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportListRenderer({ toolName, result }: ToolRendererProps) {
|
||||||
|
const res = result as Record<string, unknown> | null;
|
||||||
|
const ok = res != null && typeof res === "object" && res.success === true;
|
||||||
|
|
||||||
|
if (toolName === "get_report") {
|
||||||
|
const report = ok ? (res.report as ReportEntry | undefined) : undefined;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-red-400/80 font-semibold text-sm">report</span>
|
||||||
|
{report ? (
|
||||||
|
<div className="mt-1.5 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{sevBadge(report.severity)}
|
||||||
|
{report.cvss != null && <span className="text-[#888] text-[13px]">CVSS {report.cvss}</span>}
|
||||||
|
{report.id && <span className="text-[#555] font-mono text-[13px]">{report.id}</span>}
|
||||||
|
{report.cve && <span className="text-[#888] font-mono text-[13px]">{report.cve}</span>}
|
||||||
|
{report.cwe && <span className="text-[#888] font-mono text-[13px]">{report.cwe}</span>}
|
||||||
|
{(report.agent_name || report.by_you) && (
|
||||||
|
<span className="text-[#666] text-[13px]">{report.by_you ? "you" : report.agent_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{report.title && <div className="text-[15px] text-white/80 font-semibold">{report.title}</div>}
|
||||||
|
{(report.target || report.endpoint) && (
|
||||||
|
<div className="text-[13px] text-[#888] font-mono">
|
||||||
|
{report.target}{report.endpoint ? ` ${report.method ?? ""} ${report.endpoint}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report.description && <TruncatedText text={report.description} maxLines={20} />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-[#555] text-xs">
|
||||||
|
{(res && typeof res === "object" && (res.error as string)) || "Report not found"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// list_reports
|
||||||
|
const rawReports = ok ? res.reports : null;
|
||||||
|
const reports: ReportEntry[] = Array.isArray(rawReports) ? (rawReports as ReportEntry[]) : [];
|
||||||
|
const total = ok && typeof res.total_count === "number" ? (res.total_count as number) : reports.length;
|
||||||
|
const counts = ok && res.severity_counts && typeof res.severity_counts === "object"
|
||||||
|
? (res.severity_counts as Record<string, number>)
|
||||||
|
: {};
|
||||||
|
const countEntries = Object.entries(counts);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-red-400/80 font-semibold text-sm">reports</span>
|
||||||
|
<span className="text-[#555] text-[13px]">({total})</span>
|
||||||
|
{countEntries.map(([sev, n]) => (
|
||||||
|
<span key={sev} className="text-[13px]">
|
||||||
|
{sevBadge(sev)}<span className="text-[#888] ml-0.5">{n}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{reports.length > 0 ? (
|
||||||
|
<div className="mt-1.5 space-y-1">
|
||||||
|
{reports.map((r, i) => (
|
||||||
|
<div key={r.id ?? i} className="text-[13px]">
|
||||||
|
<span className="text-[#555] mr-1">-</span>
|
||||||
|
{sevBadge(r.severity)}
|
||||||
|
{r.id && <span className="text-[#555] font-mono ml-1.5">{r.id}</span>}
|
||||||
|
<span className="text-[#999] ml-1.5">{r.title ?? "(untitled)"}</span>
|
||||||
|
{authorTag(r)}
|
||||||
|
{(r.target || r.endpoint) && (
|
||||||
|
<div className="ml-3 text-[#666] font-mono text-xs">
|
||||||
|
{r.target}{r.endpoint ? ` ${r.method ?? ""} ${r.endpoint}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.description_preview && (
|
||||||
|
<div className="ml-3"><Markdown text={r.description_preview} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <div className="mt-1 text-[#555] text-xs">No reports filed yet</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-2
@@ -3,6 +3,7 @@
|
|||||||
import type { ToolRendererProps } from "@/types/events";
|
import type { ToolRendererProps } from "@/types/events";
|
||||||
import { TruncatedText } from "./ToolCard";
|
import { TruncatedText } from "./ToolCard";
|
||||||
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
|
import { MdCodeBlock } from "@/components/vulnerability/MdCodeBlock";
|
||||||
|
import { parseFencedCode } from "@/lib/fenced-code";
|
||||||
import Markdown from "./Markdown";
|
import Markdown from "./Markdown";
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
@@ -19,7 +20,7 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
|
|||||||
const method = (args.method as string) ?? "";
|
const method = (args.method as string) ?? "";
|
||||||
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
const technicalAnalysis = (args.technical_analysis as string) ?? "";
|
||||||
const pocDescription = (args.poc_description as string) ?? "";
|
const pocDescription = (args.poc_description as string) ?? "";
|
||||||
const pocCode = (args.poc_script_code as string) ?? "";
|
const { language: pocLang, code: pocCode } = parseFencedCode((args.poc_script_code as string) ?? "");
|
||||||
const remediation = (args.remediation_steps as string) ?? "";
|
const remediation = (args.remediation_steps as string) ?? "";
|
||||||
const cve = (args.cve as string) ?? "";
|
const cve = (args.cve as string) ?? "";
|
||||||
const cwe = (args.cwe as string) ?? "";
|
const cwe = (args.cwe as string) ?? "";
|
||||||
@@ -59,7 +60,7 @@ export default function VulnReportRenderer({ args, result }: ToolRendererProps)
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
|
<span className="text-emerald-400/60 text-sm font-semibold">Proof of Concept</span>
|
||||||
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
|
{pocDescription && <div className="mt-1"><Markdown text={pocDescription} /></div>}
|
||||||
{pocCode && <MdCodeBlock>{pocCode}</MdCodeBlock>}
|
{pocCode && <MdCodeBlock className={pocLang ? `language-${pocLang}` : undefined}>{pocCode}</MdCodeBlock>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{remediation && (
|
{remediation && (
|
||||||
+4
-1
@@ -12,6 +12,7 @@ import FileEditRenderer from "./FileEditRenderer";
|
|||||||
import ApplyPatchRenderer from "./ApplyPatchRenderer";
|
import ApplyPatchRenderer from "./ApplyPatchRenderer";
|
||||||
import ViewImageRenderer from "./ViewImageRenderer";
|
import ViewImageRenderer from "./ViewImageRenderer";
|
||||||
import VulnReportRenderer from "./VulnReportRenderer";
|
import VulnReportRenderer from "./VulnReportRenderer";
|
||||||
|
import ReportListRenderer from "./ReportListRenderer";
|
||||||
import ProxyRenderer from "./ProxyRenderer";
|
import ProxyRenderer from "./ProxyRenderer";
|
||||||
import ThinkRenderer from "./ThinkRenderer";
|
import ThinkRenderer from "./ThinkRenderer";
|
||||||
import AgentCommsRenderer from "./AgentCommsRenderer";
|
import AgentCommsRenderer from "./AgentCommsRenderer";
|
||||||
@@ -101,7 +102,7 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
|||||||
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
filesystem: ["apply_patch", "view_image", "str_replace_editor", "list_files", "search_files"],
|
||||||
// Caido proxy tools (legacy: send_request)
|
// Caido proxy tools (legacy: send_request)
|
||||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||||
reporting: ["create_vulnerability_report"],
|
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
|
||||||
thinking: ["think"],
|
thinking: ["think"],
|
||||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
||||||
search: ["web_search"],
|
search: ["web_search"],
|
||||||
@@ -128,6 +129,8 @@ const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps
|
|||||||
finish_scan: FinishRenderer,
|
finish_scan: FinishRenderer,
|
||||||
apply_patch: ApplyPatchRenderer,
|
apply_patch: ApplyPatchRenderer,
|
||||||
view_image: ViewImageRenderer,
|
view_image: ViewImageRenderer,
|
||||||
|
list_reports: ReportListRenderer,
|
||||||
|
get_report: ReportListRenderer,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+3
-12
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import hljs from "@/lib/hljs";
|
import { highlightCode } from "@/lib/hljs";
|
||||||
import "highlight.js/styles/github-dark.css";
|
import "highlight.js/styles/github-dark.css";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||||
@@ -17,7 +17,7 @@ export function MdCodeBlock({
|
|||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const raw = String(children).replace(/\n$/, "");
|
const raw = String(children).replace(/\n$/, "");
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
const match = /language-(\S+)/.exec(className || "");
|
||||||
const isBlock = raw.includes("\n") || match;
|
const isBlock = raw.includes("\n") || match;
|
||||||
|
|
||||||
if (!isBlock) {
|
if (!isBlock) {
|
||||||
@@ -35,16 +35,7 @@ export function MdCodeBlock({
|
|||||||
: fileName
|
: fileName
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let highlighted: string;
|
const highlighted = highlightCode(raw, match?.[1]);
|
||||||
if (match) {
|
|
||||||
try {
|
|
||||||
highlighted = hljs.highlight(raw, { language: match[1], ignoreIllegals: true }).value;
|
|
||||||
} catch {
|
|
||||||
highlighted = hljs.highlightAuto(raw).value;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
highlighted = hljs.highlightAuto(raw).value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = highlighted.split("\n");
|
const lines = highlighted.split("\n");
|
||||||
|
|
||||||
+9
-5
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import hljs from "@/lib/hljs";
|
import { highlightCode } from "@/lib/hljs";
|
||||||
import "highlight.js/styles/github-dark.css";
|
import "highlight.js/styles/github-dark.css";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
import { copyToClipboard } from "@/lib/vulnerability-utils";
|
||||||
|
import { parseFencedCode } from "@/lib/fenced-code";
|
||||||
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
import { rehypeCodeMeta, mdComponents } from "./MdCodeBlock";
|
||||||
|
|
||||||
interface PocBlockProps {
|
interface PocBlockProps {
|
||||||
@@ -20,9 +21,12 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
|||||||
|
|
||||||
if (!description && !scriptCode) return null;
|
if (!description && !scriptCode) return null;
|
||||||
|
|
||||||
|
const { language, code } = parseFencedCode(scriptCode);
|
||||||
|
const highlighted = highlightCode(code, language);
|
||||||
|
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
if (!scriptCode) return;
|
if (!code) return;
|
||||||
copyToClipboard(scriptCode);
|
copyToClipboard(code);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
onCopy?.();
|
onCopy?.();
|
||||||
@@ -43,7 +47,7 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{scriptCode && (
|
{code && (
|
||||||
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
|
<div className="group/poc relative rounded-md border border-[#2a2a2a] overflow-hidden">
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
<span className="relative flex items-center text-[13px] text-[#999] font-mono px-4 py-2 border-r border-[#2a2a2a]">PoC Script<span className="absolute top-0 inset-x-0 h-0.5 bg-white/60 rounded-full" /></span>
|
||||||
@@ -64,7 +68,7 @@ export function PocBlock({ description, scriptCode, onCopy }: PocBlockProps) {
|
|||||||
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
|
<pre className="font-mono text-[12px] leading-[22px] whitespace-pre [font-variant-ligatures:none]">
|
||||||
<code
|
<code
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: hljs.highlight(scriptCode, { language: "python" }).value,
|
__html: highlighted,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ParsedFencedCode {
|
||||||
|
language?: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FENCE_RE = /^```([^\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-generated `poc_script_code` is stored wrapped in a markdown code fence
|
||||||
|
* that carries the language, e.g.
|
||||||
|
*
|
||||||
|
* ```python
|
||||||
|
* import requests
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Renderers that show the value as bare code must not display the fence lines
|
||||||
|
* literally. This extracts the inner code and the fence's language tag. Returns
|
||||||
|
* the input unchanged (no language) when it isn't fenced.
|
||||||
|
*/
|
||||||
|
export function parseFencedCode(raw: string | null | undefined): ParsedFencedCode {
|
||||||
|
if (!raw) return { code: "" };
|
||||||
|
const match = FENCE_RE.exec(raw.trim());
|
||||||
|
if (!match) return { code: raw };
|
||||||
|
const info = match[1].trim();
|
||||||
|
const language = info ? info.split(/\s+/)[0] : undefined;
|
||||||
|
return { language: language || undefined, code: match[2] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import hljs from "highlight.js/lib/common";
|
||||||
|
import http from "highlight.js/lib/languages/http";
|
||||||
|
import nginx from "highlight.js/lib/languages/nginx";
|
||||||
|
import apache from "highlight.js/lib/languages/apache";
|
||||||
|
import dockerfile from "highlight.js/lib/languages/dockerfile";
|
||||||
|
import properties from "highlight.js/lib/languages/properties";
|
||||||
|
|
||||||
|
hljs.registerLanguage("http", http);
|
||||||
|
hljs.registerLanguage("nginx", nginx);
|
||||||
|
hljs.registerLanguage("apache", apache);
|
||||||
|
hljs.registerLanguage("dockerfile", dockerfile);
|
||||||
|
hljs.registerLanguage("properties", properties);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight code, preferring an explicit language when it's recognized,
|
||||||
|
* otherwise auto-detecting. Falls back to Python when auto-detection is
|
||||||
|
* inconclusive, since legacy (unfenced) PoC scripts are Python.
|
||||||
|
*/
|
||||||
|
export function highlightCode(code: string, language?: string | null): string {
|
||||||
|
try {
|
||||||
|
if (language && hljs.getLanguage(language)) {
|
||||||
|
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
||||||
|
}
|
||||||
|
const auto = hljs.highlightAuto(code);
|
||||||
|
if (auto.language) return auto.value;
|
||||||
|
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||||
|
} catch {
|
||||||
|
return hljs.highlight(code, { language: "python", ignoreIllegals: true }).value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hljs;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user