aztrx-cli 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -342
- package/dist/cli.js +55 -8
- package/dist/core/classifier.js +1 -0
- package/dist/core/domWalker.js +45 -21
- package/dist/core/heal/index.js +14 -10
- package/dist/core/heal/llm.js +39 -0
- package/dist/core/interceptor.js +17 -9
- package/dist/core/llm.js +10 -5
- package/dist/core/modernize.js +1 -1
- package/dist/core/orchestrator.js +18 -10
- package/dist/core/prompt.js +12 -0
- package/dist/core/resolver.js +36 -16
- package/dist/core/swarm.js +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,156 +5,141 @@
|
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
Aztrx AI finds **runtime** bugs
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
plus an executable **Playwright repro** that fails `3/3` times. Then it fixes it.
|
|
13
|
-
|
|
14
|
-
This is the reliability layer — the bug that crashed a real user, not the vuln a hacker
|
|
15
|
-
could exploit.
|
|
16
|
-
|
|
17
|
-

|
|
8
|
+
Aztrx AI finds **runtime** bugs, not security holes. It drives your web app like a hostile
|
|
9
|
+
user and catches the crashes that ship to real users — *including ones a React Error Boundary
|
|
10
|
+
swallows* (the errors `window.onerror` never sees). Each crash comes back as an exact source
|
|
11
|
+
line plus an executable **Playwright repro** that fails `3/3` times. Then it fixes it.
|
|
18
12
|
|
|
19
13
|
```bash
|
|
20
|
-
npx aztrx-cli run http://localhost:3000 # find
|
|
21
|
-
npx aztrx-cli run http://localhost:3000 --fix #
|
|
14
|
+
npx aztrx-cli run http://localhost:3000 # find crashes — zero setup, no key
|
|
15
|
+
npx aztrx-cli run http://localhost:3000 --fix # fix them — free for common bugs
|
|
22
16
|
```
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
| --- | --- | --- |
|
|
26
|
-
| deterministic walk · coverage-guided fuzz · HTTP fuzz · swarm | ddmin → executable `.spec.ts` → `[deterministic 3/3]` | gated LLM patch → compile → test → replay → apply / `--pr` |
|
|
18
|
+

|
|
27
19
|
|
|
28
20
|
---
|
|
29
21
|
|
|
30
|
-
##
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
npx aztrx-cli run http://localhost:3000
|
|
34
|
-
```
|
|
22
|
+
## Why Aztrx AI
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
first run downloads the browser automatically).
|
|
39
|
-
|
|
40
|
-
What that one command gives you:
|
|
41
|
-
|
|
42
|
-
- **Sees swallowed errors.** Error Boundaries and `window.onerror` miss the errors your app *catches*. Aztrx AI reads the real throw-site stack off the `Error` object — a crash you've never seen in your logs becomes a finding you can't ignore.
|
|
43
|
-
- **Proves, not reports.** Every crash/error finding ships with an executable `.spec.ts` repro and a flake-rate verdict — `[deterministic 5/5]`, `[flaky 3/5]`, or `[unreliable]`.
|
|
24
|
+
- **Sees swallowed errors.** Error Boundaries and `window.onerror` miss the errors your app *catches*. Aztrx reads the real throw-site stack off the `Error` object — a crash you've never seen in your logs becomes a finding you can't ignore.
|
|
25
|
+
- **Proves, not reports.** Every crash ships with an executable `.spec.ts` repro and a flake-rate verdict — `[deterministic 3/3]`, `[flaky 3/5]`, or `[unreliable]`.
|
|
44
26
|
- **Safe by default.** A deny-by-default network guard blocks off-origin calls, a destructive-action deny-list refuses to click "delete", "pay", or "logout", and nothing leaves your machine unless you opt in.
|
|
45
27
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
npx aztrx-cli run http://localhost:3000 --fuzz # seeded chaos (replayable)
|
|
50
|
-
npx aztrx-cli run http://localhost:3000 --repro # minimize → compile → validate
|
|
51
|
-
npx aztrx-cli run http://localhost:3000 --http-fuzz # server-side 5xx hunt
|
|
52
|
-
```
|
|
28
|
+
---
|
|
53
29
|
|
|
54
|
-
|
|
30
|
+
## Quickstart
|
|
55
31
|
|
|
56
32
|
```bash
|
|
57
|
-
npm i -g aztrx-cli
|
|
58
|
-
|
|
33
|
+
npm i -g aztrx-cli # or use npx — no install needed
|
|
34
|
+
|
|
35
|
+
aztrx-cli run http://localhost:3000 # 1. find the crashes (no key, no account)
|
|
36
|
+
aztrx-cli run http://localhost:3000 --repro # 2. prove them with an executable test
|
|
37
|
+
aztrx-cli run http://localhost:3000 --fix # 3. fix them
|
|
59
38
|
```
|
|
60
39
|
|
|
40
|
+
Point it at any running dev server. It drives Chromium through Playwright (the first run
|
|
41
|
+
downloads the browser automatically).
|
|
42
|
+
|
|
61
43
|
---
|
|
62
44
|
|
|
63
|
-
## Fix it
|
|
45
|
+
## Fix it — free for common crashes
|
|
46
|
+
|
|
47
|
+
`--fix` has two engines:
|
|
64
48
|
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
**1. Free, no key.** For the most common crash — `Cannot read properties of
|
|
50
|
+
undefined/null` — a built-in rule adds `?.` (optional chaining) and applies the fix. No LLM,
|
|
51
|
+
no key, no cost:
|
|
67
52
|
|
|
68
53
|
```bash
|
|
69
|
-
|
|
70
|
-
npx aztrx-cli run http://localhost:3000 --fix # find → explain → heal → apply
|
|
71
|
-
npx aztrx-cli run http://localhost:3000 --fix --yes # non-interactive (CI)
|
|
54
|
+
aztrx-cli run http://localhost:3000 --fix # works out of the box for null/undefined derefs
|
|
72
55
|
```
|
|
73
56
|
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
**2. Your model, for complex bugs.** Logic errors, races, and anything the rule can't handle
|
|
58
|
+
— point it at any model:
|
|
76
59
|
|
|
77
60
|
```bash
|
|
78
|
-
|
|
61
|
+
# Anthropic
|
|
62
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
63
|
+
|
|
64
|
+
# or any OpenAI-compatible provider: OpenAI, Grok, DeepSeek, Gemini, Kimi, OpenRouter, Ollama
|
|
65
|
+
export AZTRX_API_BASE="https://openrouter.ai/api/v1"
|
|
79
66
|
export AZTRX_API_KEY="your-key"
|
|
80
|
-
export AZTRX_MODEL="anthropic/claude-sonnet-5"
|
|
81
|
-
npx aztrx-cli run http://localhost:3000 --fix
|
|
67
|
+
export AZTRX_MODEL="anthropic/claude-sonnet-5"
|
|
82
68
|
```
|
|
83
69
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
- **`--heal`** — the same pipeline, but stops at a verified `.patch` file (no apply).
|
|
89
|
-
- **`--explain`** — a human-language "X-ray" summary of what broke, where, and whether a fix is ready. No key required: it falls back to a deterministic offline summary.
|
|
90
|
-
|
|
91
|
-
Every patch is redacted, sandboxed in a detached git worktree, compiler-checked, and gated
|
|
92
|
-
on your own test suite before you ever see it.
|
|
70
|
+
Every fix is redacted, sandboxed in a detached git worktree, compiler-checked, and gated on
|
|
71
|
+
your test suite before you see it. Aztrx never commits. `--pr` opens a merge-ready PR;
|
|
72
|
+
`--regression-test` drops the repro into your test dir so the bug can't come back.
|
|
93
73
|
|
|
94
74
|
---
|
|
95
75
|
|
|
96
|
-
##
|
|
76
|
+
## More ways to run
|
|
97
77
|
|
|
98
|
-
|
|
78
|
+
| Flag | What it does |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `--fuzz` | coverage-guided chaos fuzz — steers toward code it hasn't reached |
|
|
81
|
+
| `--http-fuzz` | attack the server's endpoints (turns every `5xx` into a repro) |
|
|
82
|
+
| `--swarm` / `--workers N` | parallel detection workers |
|
|
83
|
+
| `--login` | auto-login to test authenticated pages |
|
|
84
|
+
| `--badge` / `--pr-comment` / `--fail-on` | CI artifacts |
|
|
85
|
+
| `modernize <file>` | rewrite legacy JS/TS into modern idiomatic syntax |
|
|
86
|
+
| `studio` | live dashboard on `localhost:7331` |
|
|
99
87
|
|
|
100
|
-
|
|
101
|
-
`--fuzz` breaks the *client* — a seeded chaos walk that also reads **V8 JS coverage** and
|
|
102
|
-
steers toward code it hasn't reached yet, so it explores rather than re-clicks. `--http-fuzz`
|
|
103
|
-
attacks the *server* — it harvests your app's real endpoints and throws hostile requests at
|
|
104
|
-
them (query overflow, JSON type-confusion, header injection), turning every `5xx` into an
|
|
105
|
-
executable repro. When a 500 body leaks a server stack, `--heal` can even fix it by booting
|
|
106
|
-
the patched app and replaying the repro.
|
|
88
|
+
Full list: `aztrx-cli run --help`, or the [CLI reference](#cli-reference).
|
|
107
89
|
|
|
108
|
-
|
|
109
|
-
`--workers 4` fans detection into parallel workers (walk + several fuzz seeds + http-fuzz),
|
|
110
|
-
merged by fingerprint. `--swarm` is a hidden alias for `--workers auto`.
|
|
90
|
+
---
|
|
111
91
|
|
|
112
|
-
|
|
113
|
-
`--login` detects the login form and signs in before the pass, so every repro runs
|
|
114
|
-
authenticated:
|
|
92
|
+
## Security
|
|
115
93
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
94
|
+
- **Local-first.** Nothing leaves your machine unless you opt in.
|
|
95
|
+
- **Never commits.** Fixes land in a detached worktree for your review.
|
|
96
|
+
- **Redacted.** Secrets are stripped from the file, error, and stack before any LLM call.
|
|
97
|
+
- **Deny-by-default network.** Off-origin calls are blocked; destructive clicks (delete/pay/logout) are refused.
|
|
98
|
+
- **`.aztrx/` is gitignored** — repros, reports, and patches stay out of history.
|
|
120
99
|
|
|
121
|
-
|
|
122
|
-
explicit login page, `--allow-host auth.example.com` for a third-party auth backend.
|
|
100
|
+
---
|
|
123
101
|
|
|
124
|
-
|
|
125
|
-
`npx aztrx-cli modernize src/legacy.js` rewrites legacy JS/TS into modern idiomatic syntax
|
|
126
|
-
(`var` → `const`, callbacks → `async`/`await`), applied only after you confirm.
|
|
102
|
+
## Continuous Integration (GitHub Action)
|
|
127
103
|
|
|
128
|
-
|
|
129
|
-
`
|
|
104
|
+
Runtime gate on every PR — boots your dev server, runs
|
|
105
|
+
`aztrx-cli run --fail-on --repro --heal`, posts a comment with the repro + patch, and fails on
|
|
106
|
+
a crash/error.
|
|
130
107
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
108
|
+
```yaml
|
|
109
|
+
# .github/workflows/ci.yml — composite action, inline
|
|
110
|
+
on: pull_request
|
|
111
|
+
jobs:
|
|
112
|
+
aztrx:
|
|
113
|
+
runs-on: ubuntu-latest
|
|
114
|
+
permissions: { contents: read, pull-requests: write }
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v4
|
|
117
|
+
- uses: Aztrx-AI/aztrx@98dbad0f7b6681c7670f7068d01dc0d54813a55f
|
|
118
|
+
with:
|
|
119
|
+
url: http://localhost:3000
|
|
120
|
+
start-command: npm run dev # optional — boot the app in the background
|
|
121
|
+
token: ${{ github.token }}
|
|
122
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional — enables --heal
|
|
123
|
+
```
|
|
136
124
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`--share-data` uploads it. `--upload` streams sanitized findings to the cloud. See
|
|
140
|
-
[Security & data flow](#security--data-flow) for the invariants.
|
|
125
|
+
A status badge (`--badge`) and PR comment (`--pr-comment`) work the same way — regenerate in
|
|
126
|
+
CI on every push.
|
|
141
127
|
|
|
142
128
|
---
|
|
143
129
|
|
|
144
130
|
## CLI reference
|
|
145
131
|
|
|
146
132
|
`aztrx-cli run --help` is grouped by intent (Detect / Prove / Fix / Report & ship / Auth);
|
|
147
|
-
the table below is the complete reference — including flags hidden from `--help` (aliases
|
|
148
|
-
|
|
133
|
+
the table below is the complete reference — including flags hidden from `--help` (aliases and
|
|
134
|
+
niche tuning knobs).
|
|
149
135
|
|
|
150
136
|
| Flag | Description | Default |
|
|
151
137
|
| --- | --- | --- |
|
|
152
138
|
| `--fuzz` | Seeded chaos fuzzing instead of the deterministic walk | — |
|
|
153
139
|
| `--http-fuzz` | Server-side mutation fuzzing — hostile requests against the target origin | — |
|
|
154
140
|
| `--repro` | Minimize (ddmin) → emit Playwright spec → validate flake rate | — |
|
|
155
|
-
| `--heal` | Generate + verify
|
|
156
|
-
| `--fix` | Find → explain → heal → apply — the one-command fix (
|
|
157
|
-
| `--magic-fix` | Deprecated alias for `--fix` (hidden) | — |
|
|
141
|
+
| `--heal` | Generate + verify a fix (implies `--repro`) | — |
|
|
142
|
+
| `--fix` | Find → explain → heal → apply — the one-command fix (free for null/undefined derefs) | — |
|
|
158
143
|
| `--explain` | Print a human-language summary of the findings | — |
|
|
159
144
|
| `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
|
|
160
145
|
| `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
|
|
@@ -172,7 +157,7 @@ and niche tuning knobs).
|
|
|
172
157
|
| `--test-command <cmd>` | Test command run against a healed patch | `npm test` (auto-detected) |
|
|
173
158
|
| `--test-timeout <ms>` | Timeout for the heal test gate | `300000` |
|
|
174
159
|
| `--no-test` | Skip the test gate during healing | — |
|
|
175
|
-
| `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start`
|
|
160
|
+
| `--start-command <cmd>` | Command to boot the app for server healing | `scripts.dev` → `scripts.start` |
|
|
176
161
|
| `--pr-comment [path]` | Write a GitHub PR markdown comment | `.aztrx/pr-comment.md` |
|
|
177
162
|
| `--badge [path]` | Write a self-contained SVG status badge | `.aztrx/badge.svg` |
|
|
178
163
|
| `--regression-test [dir]` | Copy validated repro specs into the project test dir | `e2e/` or `tests/` |
|
|
@@ -180,11 +165,8 @@ and niche tuning knobs).
|
|
|
180
165
|
| `--share-data` | Also upload the sanitized tuples (opt-in) | — |
|
|
181
166
|
| `--repo <path>` | Root path for sourcemap → source resolution | cwd |
|
|
182
167
|
| `--allow-host <host>` | Add a host to the network allow-list (repeatable) | — |
|
|
183
|
-
| `--storage-state <path>` | Playwright storage-state for authenticated pages
|
|
168
|
+
| `--storage-state <path>` | Playwright storage-state for authenticated pages | — |
|
|
184
169
|
| `--login` | Auto-login before the pass (needs `AZTRX_AUTH_EMAIL`/`AZTRX_AUTH_PASSWORD`) | — |
|
|
185
|
-
| `--login-email <email>` | Email for `--login` (hidden — prefer `$AZTRX_AUTH_EMAIL`) | `$AZTRX_AUTH_EMAIL` |
|
|
186
|
-
| `--login-password <pass>` | Password for `--login` (hidden — prefer `$AZTRX_AUTH_PASSWORD`) | `$AZTRX_AUTH_PASSWORD` |
|
|
187
|
-
| `--login-url <url>` | Explicit login page URL for `--login` (hidden) | current page |
|
|
188
170
|
| `--fail-on` | Exit `1` if any crash/error finding is present | — |
|
|
189
171
|
| `--dry-run` | Log planned actions without executing them | — |
|
|
190
172
|
| `--crash-test` | Throw a deliberate error to verify capture | — |
|
|
@@ -198,248 +180,16 @@ Every run writes self-contained artifacts inside `.aztrx/` (gitignored):
|
|
|
198
180
|
|
|
199
181
|
```
|
|
200
182
|
.aztrx/
|
|
201
|
-
├── report.html #
|
|
202
|
-
├── repro
|
|
203
|
-
|
|
204
|
-
├──
|
|
205
|
-
│ └── fix.patch # gated, compiler-checked fix
|
|
206
|
-
├── events.jsonl # run log, streamed by `aztrx-cli studio`
|
|
207
|
-
├── telemetry/dataset.jsonl # opt-in anonymized tuple dataset
|
|
183
|
+
├── report.html # interactive triage report
|
|
184
|
+
├── repro/<id>.spec.ts # minimal, executable Playwright repro
|
|
185
|
+
├── heal/fix.patch # gated, compiler-checked fix
|
|
186
|
+
├── events.jsonl # run log (streamed by `aztrx-cli studio`)
|
|
208
187
|
├── pr-comment.md # GitHub PR markdown (with --pr-comment)
|
|
209
188
|
└── badge.svg # status badge (with --badge)
|
|
210
189
|
```
|
|
211
190
|
|
|
212
191
|
---
|
|
213
192
|
|
|
214
|
-
## Continuous Integration (GitHub Action)
|
|
215
|
-
|
|
216
|
-
Runtime gate on every PR. The action boots your dev server, runs
|
|
217
|
-
`aztrx-cli run --fail-on --repro --heal`, posts a markdown comment with the
|
|
218
|
-
deterministic repro + gated patch, and fails the check on a crash/error.
|
|
219
|
-
|
|
220
|
-
```yaml
|
|
221
|
-
# .github/workflows/ci.yml — composite action, inline
|
|
222
|
-
on: pull_request
|
|
223
|
-
jobs:
|
|
224
|
-
aztrx:
|
|
225
|
-
runs-on: ubuntu-latest
|
|
226
|
-
permissions: { contents: read, pull-requests: write }
|
|
227
|
-
steps:
|
|
228
|
-
- uses: actions/checkout@v4
|
|
229
|
-
- uses: Aztrx-AI/aztrx@dc90680bca73343863da6e98c46e64be0223c6ee
|
|
230
|
-
with:
|
|
231
|
-
url: http://localhost:3000
|
|
232
|
-
start-command: npm run dev # optional — boot the app in the background
|
|
233
|
-
token: ${{ github.token }}
|
|
234
|
-
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} # optional — enables --heal
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
Or as a reusable workflow:
|
|
238
|
-
|
|
239
|
-
```yaml
|
|
240
|
-
on: pull_request
|
|
241
|
-
jobs:
|
|
242
|
-
aztrx:
|
|
243
|
-
uses: Aztrx-AI/aztrx/.github/workflows/aztrx-pr.yml@dc90680bca73343863da6e98c46e64be0223c6ee
|
|
244
|
-
with:
|
|
245
|
-
url: http://localhost:3000
|
|
246
|
-
start-command: npm run dev
|
|
247
|
-
secrets:
|
|
248
|
-
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
---
|
|
252
|
-
|
|
253
|
-
## Status badge
|
|
254
|
-
|
|
255
|
-
Hang a live badge in your README that reflects your *actual* crash/error state —
|
|
256
|
-
not a static "protected" sticker.
|
|
257
|
-
|
|
258
|
-
```bash
|
|
259
|
-
npx aztrx-cli run http://localhost:3000 --badge badge.svg
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
```markdown
|
|
263
|
-
[](https://github.com/Aztrx-AI/aztrx)
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
The badge is a self-contained SVG generated from the run's findings — green
|
|
267
|
-
`crash-free` or red `N findings`. It's honest because it's *earned*: regenerate it
|
|
268
|
-
in CI on every push to `main` and commit it back.
|
|
269
|
-
|
|
270
|
-
```yaml
|
|
271
|
-
# .github/workflows/badge.yml — keep the badge honest on every push to main
|
|
272
|
-
on:
|
|
273
|
-
push:
|
|
274
|
-
branches: [main]
|
|
275
|
-
jobs:
|
|
276
|
-
badge:
|
|
277
|
-
runs-on: ubuntu-latest
|
|
278
|
-
permissions:
|
|
279
|
-
contents: write
|
|
280
|
-
steps:
|
|
281
|
-
- uses: actions/checkout@v4
|
|
282
|
-
- uses: actions/setup-node@v4
|
|
283
|
-
with:
|
|
284
|
-
node-version: 20
|
|
285
|
-
- run: npm ci
|
|
286
|
-
- name: Boot dev server
|
|
287
|
-
run: |
|
|
288
|
-
nohup npm run dev > /tmp/dev.log 2>&1 &
|
|
289
|
-
for i in $(seq 1 60); do
|
|
290
|
-
curl -sS -o /dev/null http://localhost:3000 && break
|
|
291
|
-
sleep 2
|
|
292
|
-
done
|
|
293
|
-
- name: Generate badge
|
|
294
|
-
run: npx --yes aztrx-cli@0.4.0 run http://localhost:3000 --badge badge.svg
|
|
295
|
-
- name: Commit badge
|
|
296
|
-
run: |
|
|
297
|
-
git config user.name "github-actions[bot]"
|
|
298
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
299
|
-
git add badge.svg
|
|
300
|
-
git commit -m "chore: update aztrx badge" || echo "no change"
|
|
301
|
-
git push
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
No `--fail-on` here on purpose: the badge reflects the findings whatever they
|
|
305
|
-
are, and the run still exits 0 so the commit step always runs. Swap `push` for a
|
|
306
|
-
`schedule` cron if you'd rather regenerate daily than on every push.
|
|
307
|
-
|
|
308
|
-
---
|
|
309
|
-
|
|
310
|
-
## Smart Cloud Router
|
|
311
|
-
|
|
312
|
-
`--heal` is backed by a two-tier router: a fast/cheap model generates first, its patch
|
|
313
|
-
is gated, compiled, and replayed against the repro, and if the bug still reproduces (or
|
|
314
|
-
the patch fails a gate) aztrx falls back to the stronger model and tries again. Most
|
|
315
|
-
one-line fixes never pay for the big model. With Anthropic the defaults are
|
|
316
|
-
`claude-haiku-4-5` → `claude-sonnet-5`; with any other provider you pick both via
|
|
317
|
-
`AZTRX_FAST_MODEL` / `AZTRX_MODEL` (or `--heal-fast-model` / `--heal-model`). When only
|
|
318
|
-
one model is set, the router collapses to a single tier.
|
|
319
|
-
|
|
320
|
-
## Security & data flow
|
|
321
|
-
|
|
322
|
-
**Local-first by default.** A run never phones home unless you pass an opt-in
|
|
323
|
-
flag. By default nothing leaves your machine — no telemetry, no cloud sync, no
|
|
324
|
-
LLM call.
|
|
325
|
-
|
|
326
|
-
| What | Leaves your machine | When |
|
|
327
|
-
| --- | --- | --- |
|
|
328
|
-
| `run` (default) | nothing | — |
|
|
329
|
-
| `--heal` | redacted file + redacted error/stack, to the LLM API | only with `--heal` + `ANTHROPIC_API_KEY` |
|
|
330
|
-
| `--share-data` | a sanitized crash→repro→patch tuple | explicit opt-in |
|
|
331
|
-
| `--upload` | sanitized findings + counts | explicit opt-in |
|
|
332
|
-
|
|
333
|
-
### Invariants
|
|
334
|
-
|
|
335
|
-
- **Sourcemap containment.** A hostile sourcemap or stack URL can't read outside
|
|
336
|
-
your repo: every path is resolved against the repo root and rejected if it
|
|
337
|
-
escapes it — including through symlinks. Secret-bearing files (`.env`, `.npmrc`,
|
|
338
|
-
private keys) are never read into a report or PR comment.
|
|
339
|
-
- **Redaction before the model.** `--heal` redacts common secret patterns (keys,
|
|
340
|
-
tokens, credentials) from the file, error, and stack before they're sent; only
|
|
341
|
-
the repo-relative path and line/column are visible. Redaction is heuristic — it
|
|
342
|
-
is not a substitute for not committing secrets.
|
|
343
|
-
- **Isolated sandbox, no commits.** Patches land in a detached `git worktree` in
|
|
344
|
-
the OS temp dir — never your working tree. Aztrx AI never commits. A patch must
|
|
345
|
-
parse, add no new imports / `eval` / `child_process`, typecheck, *and* pass your
|
|
346
|
-
own test suite before it's offered as a `.patch` for you to review.
|
|
347
|
-
- **Deny-by-default network.** Only the target origin (plus explicit
|
|
348
|
-
`--allow-host`) is reachable; off-origin calls are blocked.
|
|
349
|
-
- **Destructive-action deny-list.** Never clicks delete / pay / logout.
|
|
350
|
-
- **Studio is localhost-only.** The dashboard binds `127.0.0.1` with no wildcard
|
|
351
|
-
CORS.
|
|
352
|
-
- **`.aztrx/` is gitignored** on `init` — repro specs, reports, and patches stay
|
|
353
|
-
out of history.
|
|
354
|
-
- **Pinned supply chain.** The GitHub Action pins `aztrx-cli@0.4.0` (never
|
|
355
|
-
`@latest`).
|
|
356
|
-
|
|
357
|
-
## Telemetry & privacy
|
|
358
|
-
|
|
359
|
-
Off by default and strictly opt-in. `--telemetry` collects the anonymized tuple
|
|
360
|
-
`[crash_fingerprint, min_repro_spec, verified_patch, framework_metadata,
|
|
361
|
-
model_tier_used]` locally (nothing leaves the machine); `--share-data` uploads it
|
|
362
|
-
to the telemetry endpoint. Every field passes a sanitizer that irreversibly
|
|
363
|
-
strips secrets, anonymizes URLs to `<host>`, and scrubs repo paths to `<repo>`.
|
|
364
|
-
Uploads are fire-and-forget, bounded by a 2s timeout, and never affect the exit
|
|
365
|
-
code.
|
|
366
|
-
|
|
367
|
-
---
|
|
368
|
-
|
|
369
|
-
## Open benchmark
|
|
370
|
-
|
|
371
|
-
The detector is scored against a 13-target corpus of **real Next.js App Router
|
|
372
|
-
apps** — one seeded runtime bug per app across the archetype matrix (null deref,
|
|
373
|
-
async race, JSON parse, stack overflow, Server Action, route transition, and more).
|
|
374
|
-
|
|
375
|
-
| metric | value |
|
|
376
|
-
| --- | --- |
|
|
377
|
-
| seeded bugs | 13 |
|
|
378
|
-
| detection recall | **100%** (13 / 13) |
|
|
379
|
-
| deterministic repros | **100%** (12 / 12) |
|
|
380
|
-
| repro not attempted | 1 (mount-time bug, no action history) |
|
|
381
|
-
| unseeded findings | 5 — two root causes (`/api/cart` → 500, Server Action → 500) |
|
|
382
|
-
|
|
383
|
-
```bash
|
|
384
|
-
cd bench/frameworks
|
|
385
|
-
npm install # once — installs next/react for the target apps
|
|
386
|
-
npm run bench # detection
|
|
387
|
-
npm run bench:repro # detection + repro scoring
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
Full per-case table and scoring notes live in
|
|
391
|
-
[`bench/frameworks/RESULTS.md`](bench/frameworks/RESULTS.md).
|
|
392
|
-
|
|
393
|
-
---
|
|
394
|
-
|
|
395
|
-
## Architecture
|
|
396
|
-
|
|
397
|
-
Aztrx AI is a decoupled, event-driven pipeline — modules talk only through an
|
|
398
|
-
`EventBus`; the orchestrator wires them together.
|
|
399
|
-
|
|
400
|
-
```
|
|
401
|
-
[ CDP interceptor ] ──▶ [ action ring buffer ] ──▶ [ classifier (fingerprint) ]
|
|
402
|
-
│
|
|
403
|
-
[ verified .patch ] ◀── [ LLM healer ] ◀── [ ddmin minimizer ] ◀── [ sourcemap resolver ]
|
|
404
|
-
│
|
|
405
|
-
[ Playwright spec (.spec.ts) ] ──▶ [ flake-rate validator ]
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
| Stage | Module | Role |
|
|
409
|
-
| --- | --- | --- |
|
|
410
|
-
| F1 | `interceptor.ts` | CDP interceptor — captures raw runtime errors and console/network events |
|
|
411
|
-
| F2 | `recorder.ts` | Ring buffer of the last 25 actions; selector cascade `data-testid → text → CSS path` |
|
|
412
|
-
| F3 | `classifier.ts` | Fingerprints + dedups findings, assigns severity; suppresses `.aztrx/baseline.json` (input) |
|
|
413
|
-
| F4 | `resolver.ts` | Maps minified frames to source files, lines, and snippets via sourcemaps |
|
|
414
|
-
| F5 | `fuzzer.ts` + `domWalker.ts` | Seeded chaos fuzzer; `domWalker` (F5-lite) discovers interactive elements |
|
|
415
|
-
| F6 | `networkGuard.ts` + `domWalker.ts` | Deny-by-default network policy + destructive-action deny-list |
|
|
416
|
-
| F7 | `minimizer.ts` | ddmin delta-debugging — eliminates irrelevant actions |
|
|
417
|
-
| F8 | `specCompiler.ts` | Emits standalone, clean Playwright `.spec.ts` repro |
|
|
418
|
-
| F9 | `validator.ts` | Multi-pass replays → `deterministic` / `flaky` / `unreliable` |
|
|
419
|
-
| F10 | `heal/` | Closed-loop healing — redact → generate → AST gate → sandbox → `tsc` → verify |
|
|
420
|
-
| F11 | `telemetry/` | Opt-in anonymized crash→repro→patch tuple collection (data flywheel) |
|
|
421
|
-
| F12 | `cloud/` | Opt-in cloud sync — streams sanitized findings to the ingest dashboard |
|
|
422
|
-
| F13 | `summarize.ts` + `heal/apply.ts` | Human-language "X-ray" report + opt-in apply of verified patches (`--fix`) |
|
|
423
|
-
|
|
424
|
-
---
|
|
425
|
-
|
|
426
|
-
## Roadmap
|
|
427
|
-
|
|
428
|
-
- [x] Closed-loop healing — redact → generate → gate → sandbox → verify (F10)
|
|
429
|
-
- [ ] Open-source launch — npm publish, `npx aztrx-cli run`, hero screencast
|
|
430
|
-
- [x] Hardening — `--auth`/`--storage-state`, tsc compile fast-fail, React 19/Next.js 15 triage
|
|
431
|
-
- [x] Real-project benchmark — 13 Next.js App Router targets (100% recall, 100% deterministic repro)
|
|
432
|
-
- [x] B2B ($29/mo) — GitHub Action (`action.yml` + reusable workflow), PR bot markdown comment
|
|
433
|
-
- [x] B2B ($29/mo) — Smart Cloud Router (haiku fast-tier → verify → Sonnet fallback)
|
|
434
|
-
- [x] B2B ($29/mo) — Cloud dashboard (api.aztrx.app)
|
|
435
|
-
- [x] Data flywheel — opt-in anonymized patch-tuple collection (F11)
|
|
436
|
-
- [x] Server-side healing — heal server `5xx` findings (verify a patch by booting the patched server; requires a leaked server stack + a resolvable start command)
|
|
437
|
-
- [x] Human-language "X-ray" report — `--explain` / `--lang` (LLM + offline fallback)
|
|
438
|
-
- [x] One-click heal & apply — `--fix` (verified patch → working tree, `y/N`, no commit)
|
|
439
|
-
- [x] Autonomous Swarm — parallel detection: walk + multi-seed fuzz + http-fuzz workers, merged by fingerprint
|
|
440
|
-
- [x] Auth auto-login — `--login` walks login forms (synthesize test tokens — next)
|
|
441
|
-
- [x] Code modernizer — LLM-rewrite legacy JS/TS (`modernize`; Python — next)
|
|
442
|
-
|
|
443
193
|
## Contributing
|
|
444
194
|
|
|
445
195
|
```bash
|
|
@@ -455,10 +205,10 @@ node dist/cli.js http://localhost:8901/crash.html --repo fixtures --repro
|
|
|
455
205
|
# → one ● crash mapped to crash.html:13:15, minimized to 1 step
|
|
456
206
|
```
|
|
457
207
|
|
|
458
|
-
## Support
|
|
208
|
+
## Support
|
|
459
209
|
|
|
460
|
-
If Aztrx AI saved you hours of debugging
|
|
461
|
-
|
|
210
|
+
If Aztrx AI saved you hours of debugging, you can support the author directly —
|
|
211
|
+
name a fair price on Polar.sh:
|
|
462
212
|
|
|
463
213
|
**[Donate on Polar.sh →](https://buy.polar.sh/polar_cl_f1vBaxUv3S4fJ0o28GfgzQz7gHDHXkecCQtxY0WqeFs)**
|
|
464
214
|
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { flushCloud } from "./core/cloud/index.js";
|
|
|
18
18
|
import { summarizeFindings } from "./core/summarize.js";
|
|
19
19
|
import { applyVerifiedPatches } from "./core/heal/apply.js";
|
|
20
20
|
import { openFixPr } from "./core/fixPr.js";
|
|
21
|
-
import { promptYesNo } from "./core/prompt.js";
|
|
21
|
+
import { promptYesNo, promptInput } from "./core/prompt.js";
|
|
22
22
|
import { modernizeFile } from "./core/modernize.js";
|
|
23
23
|
function collect(value, prev) {
|
|
24
24
|
prev.push(value);
|
|
@@ -29,6 +29,35 @@ function autoWorkers() {
|
|
|
29
29
|
const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
30
30
|
return Math.max(1, Math.min(n, 8));
|
|
31
31
|
}
|
|
32
|
+
/** Auto-detect the running dev server URL: `aztrx.config.ts`, the dev script's
|
|
33
|
+
* `--port`, then a probe of common ports. Returns null when nothing responds. */
|
|
34
|
+
async function detectUrl(repoRoot) {
|
|
35
|
+
const configPath = path.join(repoRoot, "aztrx.config.ts");
|
|
36
|
+
if (fs.existsSync(configPath)) {
|
|
37
|
+
const m = fs.readFileSync(configPath, "utf-8").match(/url\s*[=:]\s*["']([^"']+)["']/);
|
|
38
|
+
if (m)
|
|
39
|
+
return m[1];
|
|
40
|
+
}
|
|
41
|
+
const pkgPath = path.join(repoRoot, "package.json");
|
|
42
|
+
if (fs.existsSync(pkgPath)) {
|
|
43
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
44
|
+
const dev = pkg.scripts?.dev || pkg.scripts?.start || "";
|
|
45
|
+
const pm = dev.match(/(?:--port|-p)\s*[= ]?\s*(\d+)/);
|
|
46
|
+
if (pm)
|
|
47
|
+
return `http://localhost:${pm[1]}`;
|
|
48
|
+
}
|
|
49
|
+
for (const port of [3000, 5173, 8080, 3001, 4000, 8000]) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(300) });
|
|
52
|
+
if (res.status < 500)
|
|
53
|
+
return `http://localhost:${port}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// not listening — try the next port
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
32
61
|
/** Print one low-key "next flag" hint after a run, so users learn the advanced
|
|
33
62
|
* flags on demand instead of memorizing the whole surface. Fires only in the
|
|
34
63
|
* plain-log path when there's a finding worth acting on. */
|
|
@@ -106,7 +135,7 @@ program
|
|
|
106
135
|
program
|
|
107
136
|
.command("run", { isDefault: true })
|
|
108
137
|
.description("inspect a running app and prove its bugs with an executable repro")
|
|
109
|
-
.argument("
|
|
138
|
+
.argument("[url]", "dev server to inspect (auto-detected if omitted), e.g. http://localhost:3000")
|
|
110
139
|
.configureHelp({ formatHelp })
|
|
111
140
|
.addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
|
|
112
141
|
.addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
|
|
@@ -155,6 +184,16 @@ program
|
|
|
155
184
|
// `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
|
|
156
185
|
const magicFix = opts.magicFix || opts.fix;
|
|
157
186
|
const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
|
|
187
|
+
// Auto-detect the target when no URL is given — one less thing to type.
|
|
188
|
+
let targetUrl = url;
|
|
189
|
+
if (!targetUrl) {
|
|
190
|
+
targetUrl = await detectUrl(repoRoot);
|
|
191
|
+
if (!targetUrl) {
|
|
192
|
+
console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
console.log(pc.dim(`Auto-detected ${targetUrl}`));
|
|
196
|
+
}
|
|
158
197
|
const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
|
|
159
198
|
const mode = (workers ?? 1) > 1 || opts.httpFuzz
|
|
160
199
|
? `swarm (${workers ?? 1} worker${(workers ?? 1) === 1 ? "" : "s"})`
|
|
@@ -165,8 +204,16 @@ program
|
|
|
165
204
|
: opts.repro
|
|
166
205
|
? "repro"
|
|
167
206
|
: "deterministic walk";
|
|
207
|
+
// Interactive login: if --login was passed without credentials, ask for them
|
|
208
|
+
// so the user never has to remember the AZTRX_AUTH_* env vars.
|
|
209
|
+
let loginEmail = opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL;
|
|
210
|
+
let loginPassword = opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD;
|
|
211
|
+
if (opts.login && !loginEmail && !loginPassword) {
|
|
212
|
+
loginEmail = await promptInput("Email:");
|
|
213
|
+
loginPassword = await promptInput("Password:");
|
|
214
|
+
}
|
|
168
215
|
const runOpts = {
|
|
169
|
-
url,
|
|
216
|
+
url: targetUrl,
|
|
170
217
|
repoRoot,
|
|
171
218
|
maxActions: parseInt(opts.maxActions, 10),
|
|
172
219
|
dryRun: opts.dryRun,
|
|
@@ -193,8 +240,8 @@ program
|
|
|
193
240
|
cloudUrl: opts.cloudUrl,
|
|
194
241
|
storageState: opts.storageState ?? opts.auth,
|
|
195
242
|
login: opts.login,
|
|
196
|
-
loginEmail
|
|
197
|
-
loginPassword
|
|
243
|
+
loginEmail,
|
|
244
|
+
loginPassword,
|
|
198
245
|
loginUrl: opts.loginUrl,
|
|
199
246
|
};
|
|
200
247
|
const failOn = Boolean(opts.failOn);
|
|
@@ -206,7 +253,7 @@ program
|
|
|
206
253
|
await renderTui({
|
|
207
254
|
bus,
|
|
208
255
|
done: runPromise,
|
|
209
|
-
targetUrl
|
|
256
|
+
targetUrl,
|
|
210
257
|
repoRoot,
|
|
211
258
|
mode,
|
|
212
259
|
});
|
|
@@ -225,7 +272,7 @@ program
|
|
|
225
272
|
const prPath = typeof opts.prComment === "string"
|
|
226
273
|
? opts.prComment
|
|
227
274
|
: path.join(repoRoot, ".aztrx", "pr-comment.md");
|
|
228
|
-
writePrComment(repoRoot,
|
|
275
|
+
writePrComment(repoRoot, targetUrl, findings, prPath);
|
|
229
276
|
console.log(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
|
|
230
277
|
}
|
|
231
278
|
if (opts.badge) {
|
|
@@ -270,7 +317,7 @@ program
|
|
|
270
317
|
}
|
|
271
318
|
}
|
|
272
319
|
if (opts.pr) {
|
|
273
|
-
const prRes = await openFixPr(repoRoot, findings,
|
|
320
|
+
const prRes = await openFixPr(repoRoot, findings, targetUrl);
|
|
274
321
|
if (prRes.ok) {
|
|
275
322
|
console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
|
|
276
323
|
}
|
package/dist/core/classifier.js
CHANGED
|
@@ -37,6 +37,7 @@ const HYDRATION_NOISE = [
|
|
|
37
37
|
];
|
|
38
38
|
function normalize(message) {
|
|
39
39
|
return message
|
|
40
|
+
.replace(/^(?:TypeError|ReferenceError|RangeError|SyntaxError|URIError|EvalError|Error):\s*/g, "")
|
|
40
41
|
.replace(/\b\d+\b/g, "<N>")
|
|
41
42
|
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>")
|
|
42
43
|
.replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, "<TS>")
|
package/dist/core/domWalker.js
CHANGED
|
@@ -13,29 +13,32 @@ export const SELECTOR = 'a, button, input, select, textarea, [role="button"], [o
|
|
|
13
13
|
export async function walkDom(page, bus, opts = {}) {
|
|
14
14
|
const max = opts.maxActions ?? 100;
|
|
15
15
|
const startUrl = page.url();
|
|
16
|
+
const startOrigin = originOf(startUrl);
|
|
16
17
|
let actions = 0;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
let sawLoginForm = false;
|
|
19
|
+
const visited = new Set();
|
|
20
|
+
const queue = [startUrl];
|
|
21
|
+
// Breadth-first crawl: visit each internal page, walk its in-place controls
|
|
22
|
+
// (buttons/inputs/selects), and queue any internal links it reveals. This finds
|
|
23
|
+
// bugs on every route, not just the one you pointed at.
|
|
24
|
+
while (queue.length > 0 && actions < max) {
|
|
25
|
+
const url = queue.shift();
|
|
26
|
+
if (visited.has(url))
|
|
27
|
+
continue;
|
|
28
|
+
visited.add(url);
|
|
29
|
+
if (page.url() !== url) {
|
|
30
|
+
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
26
31
|
await page.waitForTimeout(300);
|
|
27
32
|
}
|
|
33
|
+
// Per-page "seen" set — the same button label on two pages is two targets.
|
|
34
|
+
const seen = new Set();
|
|
28
35
|
let handles;
|
|
29
36
|
try {
|
|
30
37
|
handles = await page.$$(SELECTOR);
|
|
31
38
|
}
|
|
32
39
|
catch {
|
|
33
|
-
//
|
|
34
|
-
await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
35
|
-
await page.waitForTimeout(300);
|
|
36
|
-
continue;
|
|
40
|
+
continue; // mid-navigation — the next queued URL is visited anyway
|
|
37
41
|
}
|
|
38
|
-
let acted = false;
|
|
39
42
|
for (const handle of handles) {
|
|
40
43
|
if (actions >= max)
|
|
41
44
|
break;
|
|
@@ -67,12 +70,25 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
67
70
|
if (DESTRUCTIVE.test(label))
|
|
68
71
|
continue;
|
|
69
72
|
if (tag === "a") {
|
|
73
|
+
// Don't click links directly — queue internal ones for the crawl.
|
|
70
74
|
const href = (await handle.getAttribute("href")) ?? "";
|
|
71
|
-
if (
|
|
72
|
-
|
|
75
|
+
if (href && !/^(javascript:|mailto:|tel:|#)/.test(href)) {
|
|
76
|
+
try {
|
|
77
|
+
const target = new URL(href, url).href.split("#")[0];
|
|
78
|
+
if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
|
|
79
|
+
queue.push(target);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// unparseable href — ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
73
87
|
}
|
|
74
88
|
if (tag === "input") {
|
|
75
89
|
const type = (await handle.getAttribute("type")) ?? "";
|
|
90
|
+
if (type === "password")
|
|
91
|
+
sawLoginForm = true;
|
|
76
92
|
if (!TEXT_INPUT_TYPES.has(type))
|
|
77
93
|
continue; // skip password/hidden/submit/checkbox/etc.
|
|
78
94
|
}
|
|
@@ -94,14 +110,22 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
94
110
|
await handle.click({ timeout: 1500 }).catch(() => { });
|
|
95
111
|
}
|
|
96
112
|
actions++;
|
|
97
|
-
acted = true;
|
|
98
113
|
await page.waitForTimeout(120);
|
|
99
|
-
|
|
114
|
+
// A click may navigate (e.g. a submit) — queue the new URL and stop this
|
|
115
|
+
// page's walk; the queue visits it next.
|
|
116
|
+
if (page.url() !== url) {
|
|
117
|
+
const target = page.url().split("#")[0];
|
|
118
|
+
if (target.startsWith(startOrigin) && !visited.has(target) && queue.length < 20) {
|
|
119
|
+
queue.push(target);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
100
123
|
}
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
// Give in-flight async work (fetches, timers) a moment to reject before we
|
|
125
|
+
// navigate to the next crawled page — otherwise a 300ms-later throw is lost.
|
|
126
|
+
await page.waitForTimeout(500);
|
|
103
127
|
}
|
|
104
|
-
return actions;
|
|
128
|
+
return { actions, sawLoginForm };
|
|
105
129
|
}
|
|
106
130
|
export function originOf(url) {
|
|
107
131
|
return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
|
package/dist/core/heal/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as fs from "fs";
|
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
import { redact, unredact } from "./redact.js";
|
|
21
21
|
import { auditPatch } from "./gates.js";
|
|
22
|
-
import { generatePatch, modelTiers } from "./llm.js";
|
|
22
|
+
import { generatePatch, generateRulePatch, modelTiers, RULE_TIER } from "./llm.js";
|
|
23
23
|
import { hasLlmKey } from "../llm.js";
|
|
24
24
|
import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
|
|
25
25
|
import { bootServer, detectStartCommand } from "./boot.js";
|
|
@@ -120,20 +120,24 @@ export async function heal(finding, opts) {
|
|
|
120
120
|
// 1. Redact — only the redacted copy is shown to the model.
|
|
121
121
|
const red = redact(original);
|
|
122
122
|
const ctx = { finding, filePath, fileContent: original, redactedContent: red.text };
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// A free, no-key rule-based fix (null/undefined deref) — tried before any LLM.
|
|
124
|
+
const rulePatch = !opts.patchFn ? generateRulePatch(ctx) : null;
|
|
125
|
+
// No transport configured, and no rule fix applies → nothing to try.
|
|
126
|
+
if (!opts.patchFn && !hasLlmKey() && !rulePatch) {
|
|
126
127
|
return {
|
|
127
128
|
...base,
|
|
128
129
|
status: "no-llm",
|
|
129
|
-
error: "
|
|
130
|
+
error: "this crash needs a model — the free fixer only handles null/undefined derefs. Add a key: Anthropic → ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL (no Aztrx account needed)",
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
|
-
// The Smart Cloud Router tier plan:
|
|
133
|
-
// An injected patchFn collapses to a single tier
|
|
134
|
-
const tiers =
|
|
135
|
-
? [{ model:
|
|
136
|
-
|
|
133
|
+
// The Smart Cloud Router tier plan: the free rule fix first, then fast/cheap,
|
|
134
|
+
// then Sonnet. An injected patchFn collapses to a single tier.
|
|
135
|
+
const tiers = [
|
|
136
|
+
...(rulePatch ? [{ model: RULE_TIER, label: "fast" }] : []),
|
|
137
|
+
...(opts.patchFn
|
|
138
|
+
? [{ model: opts.model ?? "default", label: "sonnet" }]
|
|
139
|
+
: modelTiers(opts.model, opts.fastModel)),
|
|
140
|
+
];
|
|
137
141
|
const wt = await createWorktree(opts.repoRoot, finding.id);
|
|
138
142
|
// The winning (or last) patch + verification, held back for the final save.
|
|
139
143
|
let savedPatch = null;
|
package/dist/core/heal/llm.js
CHANGED
|
@@ -78,9 +78,48 @@ export function parsePatch(raw) {
|
|
|
78
78
|
.map((e) => ({ search: e.search, replace: e.replace }));
|
|
79
79
|
return { explanation: typeof data.explanation === "string" ? data.explanation : "", hunks };
|
|
80
80
|
}
|
|
81
|
+
/** Sentinel model name for the free, no-key rule-based fixer. */
|
|
82
|
+
export const RULE_TIER = "__rule__";
|
|
83
|
+
// Matches "Cannot read properties of undefined|null (reading 'X')".
|
|
84
|
+
const NULL_DEREF = /Cannot read properties of (undefined|null)(?: \(reading '([^']+)'\))?/;
|
|
85
|
+
/**
|
|
86
|
+
* Rule-based fix for the most common crash — a null/undefined property access.
|
|
87
|
+
* Adds `?.` (optional chaining) at the failing access. Returns null when the
|
|
88
|
+
* error isn't a null/undefined deref or the line can't be located. Free and
|
|
89
|
+
* offline: no LLM, no key, no network — so `--fix` works out of the box for the
|
|
90
|
+
* most frequent frontend crashes.
|
|
91
|
+
*/
|
|
92
|
+
export function generateRulePatch(ctx) {
|
|
93
|
+
const m = ctx.finding.rawMessage.match(NULL_DEREF);
|
|
94
|
+
if (!m)
|
|
95
|
+
return null;
|
|
96
|
+
const kind = m[1]; // "undefined" | "null"
|
|
97
|
+
const prop = m[2]; // the property that was read
|
|
98
|
+
const line = ctx.finding.mappedLocation?.line;
|
|
99
|
+
if (!prop || line == null)
|
|
100
|
+
return null;
|
|
101
|
+
const src = ctx.fileContent.split("\n")[line - 1];
|
|
102
|
+
if (!src || !src.includes("." + prop))
|
|
103
|
+
return null;
|
|
104
|
+
// Optional-chain every `.identifier` access on the line (not just the failing
|
|
105
|
+
// one) so a chain like `d.agents.map(…)` becomes `d?.agents?.map(…)`.
|
|
106
|
+
const replace = src.replace(/\.(?=[a-zA-Z_$])/g, "?.");
|
|
107
|
+
if (replace === src)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
explanation: `Guard against a ${kind} access on \`.${prop}\` with optional chaining.`,
|
|
111
|
+
hunks: [{ search: src, replace }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
81
114
|
export async function generatePatch(ctx, opts = {}) {
|
|
82
115
|
if (opts.patchFn)
|
|
83
116
|
return opts.patchFn(ctx);
|
|
117
|
+
if (opts.model === RULE_TIER) {
|
|
118
|
+
const rulePatch = generateRulePatch(ctx);
|
|
119
|
+
if (rulePatch)
|
|
120
|
+
return rulePatch;
|
|
121
|
+
throw new Error("no rule-based fix applicable");
|
|
122
|
+
}
|
|
84
123
|
const text = await complete({
|
|
85
124
|
system: SYSTEM,
|
|
86
125
|
prompt: buildPrompt(ctx),
|
package/dist/core/interceptor.js
CHANGED
|
@@ -32,18 +32,21 @@ export function attachInterceptor(page, bus) {
|
|
|
32
32
|
if (msg.type() !== "error")
|
|
33
33
|
return;
|
|
34
34
|
const text = msg.text();
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
? "unhandled_rejection"
|
|
40
|
-
: "console_error";
|
|
35
|
+
// Pull the real Error (and its stack) out of the console args. React 18 and
|
|
36
|
+
// Next.js log a thrown error as `console.error(error)` — the Error object is
|
|
37
|
+
// an argument, not part of `msg.text()`. Match on `:line:col` (not "http")
|
|
38
|
+
// so Next.js dev stacks (`webpack-internal:///…`) are recognised too.
|
|
41
39
|
let source = text;
|
|
40
|
+
let hasErrorArg = false;
|
|
42
41
|
for (const arg of msg.args()) {
|
|
43
42
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const info = await arg.evaluate((a) => a instanceof Error
|
|
44
|
+
? { isError: true, value: a.stack || String(a) }
|
|
45
|
+
: { isError: false, value: String(a) });
|
|
46
|
+
if (info.isError)
|
|
47
|
+
hasErrorArg = true;
|
|
48
|
+
if (/:\d+:\d+/.test(info.value)) {
|
|
49
|
+
source = info.value;
|
|
47
50
|
break;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
@@ -51,6 +54,11 @@ export function attachInterceptor(page, bus) {
|
|
|
51
54
|
// non-serializable arg — keep msg.text()
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
// A rejection the init script forwarded, or a thrown Error logged by React —
|
|
58
|
+
// both are real errors, not benign console warnings.
|
|
59
|
+
const type = text.startsWith("Unhandled Promise Rejection:") || hasErrorArg
|
|
60
|
+
? "unhandled_rejection"
|
|
61
|
+
: "console_error";
|
|
54
62
|
const loc = msg.location();
|
|
55
63
|
const frame = extractFrame(source) ??
|
|
56
64
|
({ url: loc.url, line: loc.lineNumber, column: loc.columnNumber, message: text.split("\n")[0].slice(0, 200) });
|
package/dist/core/llm.js
CHANGED
|
@@ -82,13 +82,18 @@ export async function complete(opts) {
|
|
|
82
82
|
: openaiComplete(s, model, opts);
|
|
83
83
|
}
|
|
84
84
|
async function anthropicComplete(s, model, opts) {
|
|
85
|
+
const headers = {
|
|
86
|
+
"content-type": "application/json",
|
|
87
|
+
"x-api-key": s.apiKey,
|
|
88
|
+
"anthropic-version": "2023-06-01",
|
|
89
|
+
};
|
|
90
|
+
// Identity-linked API keys must name the workspace they act in.
|
|
91
|
+
const workspaceId = process.env.ANTHROPIC_WORKSPACE_ID;
|
|
92
|
+
if (workspaceId)
|
|
93
|
+
headers["anthropic-workspace-id"] = workspaceId;
|
|
85
94
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
86
95
|
method: "POST",
|
|
87
|
-
headers
|
|
88
|
-
"content-type": "application/json",
|
|
89
|
-
"x-api-key": s.apiKey,
|
|
90
|
-
"anthropic-version": "2023-06-01",
|
|
91
|
-
},
|
|
96
|
+
headers,
|
|
92
97
|
body: JSON.stringify({
|
|
93
98
|
model,
|
|
94
99
|
max_tokens: opts.maxTokens ?? 2048,
|
package/dist/core/modernize.js
CHANGED
|
@@ -86,7 +86,7 @@ export async function modernizeFile(repoRoot, filePath) {
|
|
|
86
86
|
return { ok: false, original: "", changes: [], error: `cannot read ${filePath}: ${e.message}` };
|
|
87
87
|
}
|
|
88
88
|
if (!hasLlmKey()) {
|
|
89
|
-
return { ok: false, original, changes: [], lang, error: "
|
|
89
|
+
return { ok: false, original, changes: [], lang, error: "modernize needs a model. Add a key: Anthropic → ANTHROPIC_API_KEY, or any provider → AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL" };
|
|
90
90
|
}
|
|
91
91
|
let reply;
|
|
92
92
|
try {
|
|
@@ -97,7 +97,7 @@ export async function run(options) {
|
|
|
97
97
|
else {
|
|
98
98
|
emitPhase("walk");
|
|
99
99
|
}
|
|
100
|
-
const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, } = await swarmDetect({
|
|
100
|
+
const { findings, replayStorageState: swarmAuthState, totalActions, totalCoverage, workerCount, roles, sawLoginForm, } = await swarmDetect({
|
|
101
101
|
url,
|
|
102
102
|
repoRoot,
|
|
103
103
|
maxActions,
|
|
@@ -128,7 +128,7 @@ export async function run(options) {
|
|
|
128
128
|
printFinding(f, say);
|
|
129
129
|
}
|
|
130
130
|
if (workerCount > 1) {
|
|
131
|
-
say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s).\n`));
|
|
131
|
+
say(pc.dim(`\nSwarm: ${totalActions} action(s) across ${workerCount} worker(s) — ${roles.join(", ")}.\n`));
|
|
132
132
|
}
|
|
133
133
|
else if (options.fuzz) {
|
|
134
134
|
say(pc.dim(`\nFuzzed ${totalActions} action(s) — covered ${totalCoverage} new code range(s).\n`));
|
|
@@ -136,6 +136,9 @@ export async function run(options) {
|
|
|
136
136
|
else {
|
|
137
137
|
say(pc.dim(`\nWalked ${totalActions} action(s).\n`));
|
|
138
138
|
}
|
|
139
|
+
if (sawLoginForm && !options.login) {
|
|
140
|
+
say(pc.yellow("Hint: this app has a login form — re-run with --login to test the authenticated app."));
|
|
141
|
+
}
|
|
139
142
|
// F7 → F8 → F9: minimize each finding, compile an executable spec, validate
|
|
140
143
|
// the flake rate. Only crash/error findings with a recorded action history.
|
|
141
144
|
if (options.repro) {
|
|
@@ -313,14 +316,19 @@ export async function run(options) {
|
|
|
313
316
|
// F12 — opt-in cloud sync. Streams the sanitized run results to the ingest
|
|
314
317
|
// API for the team dashboard; dedup happens server-side by fingerprint.
|
|
315
318
|
if (options.upload) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
319
|
+
if (options.apiKey || process.env.AZTRX_API_KEY) {
|
|
320
|
+
submitRun(findings, {
|
|
321
|
+
repoRoot,
|
|
322
|
+
url,
|
|
323
|
+
apiKey: options.apiKey,
|
|
324
|
+
endpoint: options.cloudUrl,
|
|
325
|
+
mode: runMode(options),
|
|
326
|
+
counts,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
say(pc.yellow("Upload skipped: no API key. Set --api-key or AZTRX_API_KEY to stream findings to the dashboard."));
|
|
331
|
+
}
|
|
324
332
|
}
|
|
325
333
|
runLog.append({ type: "run_end", counts, ts: Date.now() });
|
|
326
334
|
say(pc.dim("────────────────────────────────────────────"));
|
package/dist/core/prompt.js
CHANGED
|
@@ -20,3 +20,15 @@ export function promptYesNo(question, opts = {}) {
|
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
|
+
/** A single-line text prompt. Returns "" when stdout isn't a TTY (unattended run). */
|
|
24
|
+
export function promptInput(question) {
|
|
25
|
+
if (process.stdout.isTTY !== true)
|
|
26
|
+
return Promise.resolve("");
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
rl.question(question + " ", (answer) => {
|
|
30
|
+
rl.close();
|
|
31
|
+
resolve(answer.trim());
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
package/dist/core/resolver.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { TraceMap, originalPositionFor, } from "@jridgewell/trace-mapping";
|
|
4
|
+
/** Framework-internal frames to skip when hunting the throw site. */
|
|
5
|
+
const FRAMEWORK_FRAME = /node_modules|webpack-runtime|\.next[\\/]|next[\\/]dist[\\/]/;
|
|
4
6
|
/**
|
|
5
|
-
* Pulls the first
|
|
6
|
-
*
|
|
7
|
+
* Pulls the first *user-code* frame out of a stack string. Iterates every line,
|
|
8
|
+
* skips framework internals (webpack runtime, node_modules, next/dist), and
|
|
9
|
+
* returns the first frame in the user's own code — so a Next.js dev stack like
|
|
10
|
+
* `webpack-internal:///(app-pages-browser)/./app/page.tsx:29:21` resolves to the
|
|
11
|
+
* user's file, not `intercept-console-error.js`.
|
|
7
12
|
*/
|
|
8
13
|
export function extractFrame(text) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const message = text.split("\n")[0].trim().slice(0, 200);
|
|
15
|
+
for (const raw of text.split("\n")) {
|
|
16
|
+
// V8 frame: "at fn (url:line:col)" or "at url:line:col".
|
|
17
|
+
const m = raw.trim().match(/^(?:at\s+)?(?:\S+\s+\()?(.+?):(\d+):(\d+)\)?$/);
|
|
18
|
+
if (!m)
|
|
19
|
+
continue;
|
|
20
|
+
const url = m[1];
|
|
21
|
+
if (!url || FRAMEWORK_FRAME.test(url))
|
|
22
|
+
continue;
|
|
23
|
+
return { url, line: parseInt(m[2], 10), column: parseInt(m[3], 10), message };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
18
26
|
}
|
|
19
27
|
/** True if `p` looks like an absolute source path — a `file://` URL, a POSIX
|
|
20
28
|
* absolute path, or a Windows drive path. Rejects bare relative tokens like
|
|
@@ -48,6 +56,20 @@ export function extractServerFrame(stack) {
|
|
|
48
56
|
function stripQuery(url) {
|
|
49
57
|
return url.split("?")[0];
|
|
50
58
|
}
|
|
59
|
+
/** Normalize a stack-frame URL to a repo-relative source path. Handles the
|
|
60
|
+
* dev-server schemes (`webpack-internal:///(ns)/./src/…`, `webpack://ns/src/…`),
|
|
61
|
+
* `file://`, and plain `https://host/path` bundle URLs. */
|
|
62
|
+
function normalizeFrameUrl(url) {
|
|
63
|
+
return stripQuery(url)
|
|
64
|
+
.replace(/^webpack-internal:\/\/\/[^/]+\/\.\//, "")
|
|
65
|
+
.replace(/^webpack:\/\/[^/]+\//, "")
|
|
66
|
+
.replace(/^webpack:\/\//, "")
|
|
67
|
+
.replace(/^\/@fs\//, "")
|
|
68
|
+
.replace(/^file:\/\/\/([A-Za-z]:)/, "$1") // file:///C:/x → C:/x
|
|
69
|
+
.replace(/^file:\/\//, "")
|
|
70
|
+
.replace(/^https?:\/\/[^/]+\//, "")
|
|
71
|
+
.replace(/^\//, "");
|
|
72
|
+
}
|
|
51
73
|
/** True only for a real, readable regular file — directories and unreadable
|
|
52
74
|
* paths return false so readers never hit `EISDIR` / permission errors. */
|
|
53
75
|
function isFile(p) {
|
|
@@ -131,11 +153,9 @@ export async function resolveFrame(frame, repoRoot) {
|
|
|
131
153
|
const viaMap = await trySourceMap(frame, repoRoot);
|
|
132
154
|
if (viaMap)
|
|
133
155
|
return viaMap;
|
|
134
|
-
// Fallback: Vite
|
|
135
|
-
// bundle URL is already the source path — no sourcemap needed.
|
|
136
|
-
const relative =
|
|
137
|
-
.replace(/^https?:\/\/[^/]+\//, "")
|
|
138
|
-
.replace(/^\//, "");
|
|
156
|
+
// Fallback: dev servers (Vite, Next) serve real source files at their URL
|
|
157
|
+
// path, so the bundle URL is already the source path — no sourcemap needed.
|
|
158
|
+
const relative = normalizeFrameUrl(frame.url);
|
|
139
159
|
const directPath = resolveWithin(repoRoot, relative);
|
|
140
160
|
if (!directPath) {
|
|
141
161
|
return {
|
package/dist/core/swarm.js
CHANGED
|
@@ -131,9 +131,12 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
|
131
131
|
}
|
|
132
132
|
let actions = 0;
|
|
133
133
|
let newCoverage = 0;
|
|
134
|
+
let sawLoginForm = false;
|
|
134
135
|
if (loaded) {
|
|
135
136
|
if (strategy.kind === "walk") {
|
|
136
|
-
|
|
137
|
+
const wr = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
138
|
+
actions = wr.actions;
|
|
139
|
+
sawLoginForm = wr.sawLoginForm;
|
|
137
140
|
}
|
|
138
141
|
else if (strategy.kind === "fuzz") {
|
|
139
142
|
const fr = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
@@ -151,7 +154,7 @@ export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
|
151
154
|
}
|
|
152
155
|
await page.waitForTimeout(500);
|
|
153
156
|
await context.close();
|
|
154
|
-
return { findings: classifier.findings(), actions, newCoverage, replayStorageState };
|
|
157
|
+
return { findings: classifier.findings(), actions, newCoverage, sawLoginForm, replayStorageState };
|
|
155
158
|
}
|
|
156
159
|
/** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
|
|
157
160
|
export function mergeFindings(arrays) {
|
|
@@ -194,6 +197,17 @@ function buildStrategies(opts) {
|
|
|
194
197
|
}
|
|
195
198
|
return strategies;
|
|
196
199
|
}
|
|
200
|
+
/** Human-readable label for a worker's role in the swarm. */
|
|
201
|
+
function strategyLabel(s) {
|
|
202
|
+
switch (s.kind) {
|
|
203
|
+
case "walk":
|
|
204
|
+
return "walk";
|
|
205
|
+
case "http-fuzz":
|
|
206
|
+
return "http-fuzz";
|
|
207
|
+
case "fuzz":
|
|
208
|
+
return `fuzz seed ${s.seed}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
197
211
|
/** Launch one browser, run the worker roster concurrently, merge findings. */
|
|
198
212
|
export async function swarmDetect(opts) {
|
|
199
213
|
const strategies = buildStrategies(opts);
|
|
@@ -231,7 +245,15 @@ export async function swarmDetect(opts) {
|
|
|
231
245
|
const findings = mergeFindings(results.map((r) => r.findings));
|
|
232
246
|
const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
|
|
233
247
|
const totalCoverage = results.reduce((sum, r) => sum + r.newCoverage, 0);
|
|
234
|
-
return {
|
|
248
|
+
return {
|
|
249
|
+
findings,
|
|
250
|
+
replayStorageState,
|
|
251
|
+
totalActions,
|
|
252
|
+
totalCoverage,
|
|
253
|
+
workerCount: strategies.length,
|
|
254
|
+
roles: strategies.map(strategyLabel),
|
|
255
|
+
sawLoginForm: results.some((r) => r.sawLoginForm),
|
|
256
|
+
};
|
|
235
257
|
}
|
|
236
258
|
finally {
|
|
237
259
|
await browser.close();
|