aztrx-cli 0.4.1 → 0.4.3
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 +10 -4
- package/dist/cli.js +8 -6
- package/dist/core/browser.js +47 -0
- package/dist/core/orchestrator.js +7 -2
- package/dist/core/replay.js +2 -2
- package/dist/core/swarm.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -114,7 +114,7 @@ jobs:
|
|
|
114
114
|
permissions: { contents: read, pull-requests: write }
|
|
115
115
|
steps:
|
|
116
116
|
- uses: actions/checkout@v4
|
|
117
|
-
- uses: Aztrx-AI/aztrx@
|
|
117
|
+
- uses: Aztrx-AI/aztrx@v0.4.3
|
|
118
118
|
with:
|
|
119
119
|
url: http://localhost:3000
|
|
120
120
|
start-command: npm run dev # optional — boot the app in the background
|
|
@@ -137,9 +137,11 @@ niche tuning knobs).
|
|
|
137
137
|
| --- | --- | --- |
|
|
138
138
|
| `--fuzz` | Seeded chaos fuzzing instead of the deterministic walk | — |
|
|
139
139
|
| `--http-fuzz` | Server-side mutation fuzzing — hostile requests against the target origin | — |
|
|
140
|
+
| `--http-fuzz-mutations` | With `--http-fuzz`: also send POST/PUT body mutations (default: GET-only) | — |
|
|
140
141
|
| `--repro` | Minimize (ddmin) → emit Playwright spec → validate flake rate | — |
|
|
141
142
|
| `--heal` | Generate + verify a fix (implies `--repro`) | — |
|
|
142
143
|
| `--fix` | Find → explain → heal → apply — the one-command fix (free for null/undefined derefs) | — |
|
|
144
|
+
| `--magic-fix` | Hidden alias for `--fix` (deprecated) | — |
|
|
143
145
|
| `--explain` | Print a human-language summary of the findings | — |
|
|
144
146
|
| `--yes` / `-y` | Auto-apply verified fixes without prompting (with `--fix`) | — |
|
|
145
147
|
| `--pr` | Open a merge-ready PR with the verified fixes (with `--fix`) | — |
|
|
@@ -150,10 +152,10 @@ niche tuning knobs).
|
|
|
150
152
|
| `--max-actions <n>` | Max actions per pass | `100` |
|
|
151
153
|
| `--seed <n>` | PRNG seed for deterministic fuzz | `42` |
|
|
152
154
|
| `--workers <n>` | Number of parallel detection workers | `1` |
|
|
153
|
-
| `--swarm` |
|
|
155
|
+
| `--swarm` | Auto-size the swarm to CPU cores (capped at 8) | — |
|
|
154
156
|
| `--repro-runs <n>` | Flake-rate replay iterations | `3` |
|
|
155
157
|
| `--heal-model <model>` | Fallback LLM tier | `claude-sonnet-5` / `$AZTRX_MODEL` |
|
|
156
|
-
| `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5` / `$AZTRX_FAST_MODEL` |
|
|
158
|
+
| `--heal-fast-model <model>` | Fast/cheap first tier | `claude-haiku-4-5-20251001` / `$AZTRX_FAST_MODEL` |
|
|
157
159
|
| `--test-command <cmd>` | Test command run against a healed patch | `npm test` (auto-detected) |
|
|
158
160
|
| `--test-timeout <ms>` | Timeout for the heal test gate | `300000` |
|
|
159
161
|
| `--no-test` | Skip the test gate during healing | — |
|
|
@@ -166,7 +168,11 @@ niche tuning knobs).
|
|
|
166
168
|
| `--repo <path>` | Root path for sourcemap → source resolution | cwd |
|
|
167
169
|
| `--allow-host <host>` | Add a host to the network allow-list (repeatable) | — |
|
|
168
170
|
| `--storage-state <path>` | Playwright storage-state for authenticated pages | — |
|
|
171
|
+
| `--auth <path>` | Hidden alias for `--storage-state` | — |
|
|
169
172
|
| `--login` | Auto-login before the pass (needs `AZTRX_AUTH_EMAIL`/`AZTRX_AUTH_PASSWORD`) | — |
|
|
173
|
+
| `--login-email <email>` | Email for `--login` (default: `$AZTRX_AUTH_EMAIL`) | — |
|
|
174
|
+
| `--login-password <pass>` | Password for `--login` (default: `$AZTRX_AUTH_PASSWORD`) | — |
|
|
175
|
+
| `--login-url <url>` | Explicit login page URL for `--login` (default: current page) | — |
|
|
170
176
|
| `--fail-on` | Exit `1` if any crash/error finding is present | — |
|
|
171
177
|
| `--dry-run` | Log planned actions without executing them | — |
|
|
172
178
|
| `--crash-test` | Throw a deliberate error to verify capture | — |
|
|
@@ -182,7 +188,7 @@ Every run writes self-contained artifacts inside `.aztrx/` (gitignored):
|
|
|
182
188
|
.aztrx/
|
|
183
189
|
├── report.html # interactive triage report
|
|
184
190
|
├── repro/<id>.spec.ts # minimal, executable Playwright repro
|
|
185
|
-
├── heal
|
|
191
|
+
├── heal/<id>.patch # gated, compiler-checked fix (one per finding)
|
|
186
192
|
├── events.jsonl # run log (streamed by `aztrx-cli studio`)
|
|
187
193
|
├── pr-comment.md # GitHub PR markdown (with --pr-comment)
|
|
188
194
|
└── badge.svg # status badge (with --badge)
|
package/dist/cli.js
CHANGED
|
@@ -64,8 +64,15 @@ async function detectUrl(repoRoot) {
|
|
|
64
64
|
function suggestNext(findings, opts) {
|
|
65
65
|
if (opts.dryRun || opts.crashTest || findings.length === 0)
|
|
66
66
|
return;
|
|
67
|
-
const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
|
|
68
67
|
const alreadyFixing = opts.heal || opts.fix || opts.magicFix;
|
|
68
|
+
// Server-side 5xx → point at the server fuzzer. `--fix` patches frontend code,
|
|
69
|
+
// so it can't help with a 502/500 from the server.
|
|
70
|
+
const serverError = findings.some((f) => f.type === "network_5xx");
|
|
71
|
+
if (serverError && !opts.httpFuzz) {
|
|
72
|
+
console.log(pc.dim("Tip: these are server-side 5xx — run with --http-fuzz to map the server attack surface"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
|
|
69
76
|
if (crashOrError && !alreadyFixing) {
|
|
70
77
|
if (opts.repro) {
|
|
71
78
|
console.log(pc.dim("Tip: run with --fix to attempt a closed-loop fix"));
|
|
@@ -73,11 +80,6 @@ function suggestNext(findings, opts) {
|
|
|
73
80
|
else {
|
|
74
81
|
console.log(pc.dim("Tip: run with --repro to prove these with a runnable spec, or --fix to fix them end-to-end"));
|
|
75
82
|
}
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
const serverError = findings.some((f) => f.type === "network_5xx");
|
|
79
|
-
if (serverError && !opts.httpFuzz) {
|
|
80
|
-
console.log(pc.dim("Tip: run with --http-fuzz to map the server-side attack surface"));
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
program
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chromium launcher with a first-run safety net. Playwright no longer downloads
|
|
3
|
+
* its browser builds on `npm install`, so a fresh `npx aztrx-cli run` would
|
|
4
|
+
* otherwise die on a bare "Executable doesn't exist". This detects that specific
|
|
5
|
+
* failure, installs the Chromium build that matches the bundled Playwright
|
|
6
|
+
* version, and retries once — keeping the zero-setup promise.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { createRequire } from "module";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
import { chromium } from "playwright";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
/** True when the driver can't find its browser build (the "run playwright install" case). */
|
|
15
|
+
function isMissingBrowser(err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17
|
+
return /Executable doesn't exist|playwright install/i.test(msg);
|
|
18
|
+
}
|
|
19
|
+
/** Install the Chromium build that matches the bundled Playwright version. */
|
|
20
|
+
function installChromium() {
|
|
21
|
+
const pkgPath = require.resolve("playwright/package.json");
|
|
22
|
+
const cli = path.join(path.dirname(pkgPath), "cli.js");
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const child = spawn(process.execPath, [cli, "install", "chromium"], {
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
// Route progress to stderr so it never corrupts the Ink TUI on stdout.
|
|
28
|
+
child.stdout?.on("data", (d) => process.stderr.write(d));
|
|
29
|
+
child.stderr?.on("data", (d) => process.stderr.write(d));
|
|
30
|
+
child.on("error", reject);
|
|
31
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`playwright install exited ${code}`)));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** Launch Chromium headlessly, installing its browser build first when absent. */
|
|
35
|
+
export async function launchChromium(options = {}) {
|
|
36
|
+
const launch = () => chromium.launch({ headless: true, ...options });
|
|
37
|
+
try {
|
|
38
|
+
return await launch();
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (!isMissingBrowser(err))
|
|
42
|
+
throw err;
|
|
43
|
+
process.stderr.write(pc.dim("First run: downloading the Chromium browser (~150 MB)…\n"));
|
|
44
|
+
await installChromium();
|
|
45
|
+
return await launch();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -227,15 +227,15 @@ export async function run(options) {
|
|
|
227
227
|
// it in a sandboxed worktree, and verify the bug stops reproducing. Opt-in;
|
|
228
228
|
// the patch is only ever handed to a human for review, never committed.
|
|
229
229
|
if (options.heal) {
|
|
230
|
-
const
|
|
230
|
+
const reproducible = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
|
|
231
231
|
// A `network_5xx` finding heals only when its 500 body leaked a server
|
|
232
232
|
// stack (→ an own-code source location); heal then boots the patched app
|
|
233
233
|
// to verify. `network_timeout` stays excluded — it needs a time-based
|
|
234
234
|
// repro first.
|
|
235
235
|
f.type !== "network_timeout" &&
|
|
236
|
-
f.mappedLocation?.isOwnCode &&
|
|
237
236
|
f.repro &&
|
|
238
237
|
f.repro.verdict !== "unreliable");
|
|
238
|
+
const healTargets = reproducible.filter((f) => f.mappedLocation?.isOwnCode);
|
|
239
239
|
if (healTargets.length) {
|
|
240
240
|
say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
|
|
241
241
|
emitPhase("heal");
|
|
@@ -295,6 +295,11 @@ export async function run(options) {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
|
+
else if (reproducible.length > 0) {
|
|
299
|
+
// Reproducible crashes, but none mapped to source (wrong --repo?) — tell the
|
|
300
|
+
// user rather than silently doing nothing.
|
|
301
|
+
say(pc.yellow(`Found ${reproducible.length} reproducible crash(es) but couldn't map them to source files. Run from your project root (or pass --repo <dir>) so --fix can read the code.`));
|
|
302
|
+
}
|
|
298
303
|
}
|
|
299
304
|
const reportPath = writeReport(repoRoot, url, findings);
|
|
300
305
|
say(pc.dim(`Report: ${path.relative(repoRoot, reportPath)}`));
|
package/dist/core/replay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
2
1
|
import { EventBus } from "./eventBus.js";
|
|
2
|
+
import { launchChromium } from "./browser.js";
|
|
3
3
|
import { attachInterceptor } from "./interceptor.js";
|
|
4
4
|
import { fingerprintOf } from "./classifier.js";
|
|
5
5
|
/** Replays a recorded action sequence against a page. Best-effort: a selector
|
|
@@ -79,7 +79,7 @@ export class ReplayEngine {
|
|
|
79
79
|
}
|
|
80
80
|
async getBrowser() {
|
|
81
81
|
if (!this.browser)
|
|
82
|
-
this.browser = await
|
|
82
|
+
this.browser = await launchChromium();
|
|
83
83
|
return this.browser;
|
|
84
84
|
}
|
|
85
85
|
async run(url, actions, targetFingerprint, opts) {
|
package/dist/core/swarm.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "fs";
|
|
13
13
|
import * as path from "path";
|
|
14
|
-
import {
|
|
14
|
+
import { launchChromium } from "./browser.js";
|
|
15
15
|
import { EventBus } from "./eventBus.js";
|
|
16
16
|
import { attachInterceptor } from "./interceptor.js";
|
|
17
17
|
import { establishLogin } from "./auth.js";
|
|
@@ -211,7 +211,7 @@ function strategyLabel(s) {
|
|
|
211
211
|
/** Launch one browser, run the worker roster concurrently, merge findings. */
|
|
212
212
|
export async function swarmDetect(opts) {
|
|
213
213
|
const strategies = buildStrategies(opts);
|
|
214
|
-
const browser = await
|
|
214
|
+
const browser = await launchChromium();
|
|
215
215
|
try {
|
|
216
216
|
const settled = await Promise.allSettled(strategies.map((strategy, i) => detectWorker(browser, {
|
|
217
217
|
url: opts.url,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aztrx-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Aztrx AI — runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"commander": "^12.1.0",
|
|
49
49
|
"ink": "^5.2.0",
|
|
50
50
|
"picocolors": "^1.1.1",
|
|
51
|
-
"playwright": "
|
|
51
|
+
"playwright": "1.62.1",
|
|
52
52
|
"react": "^18.3.1",
|
|
53
53
|
"typescript": "^5.7.0"
|
|
54
54
|
},
|