aztrx-cli 0.1.0 → 0.2.0
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 +234 -90
- package/dist/cli/help.js +85 -0
- package/dist/cli.js +167 -29
- package/dist/core/auth.js +76 -0
- package/dist/core/badge.js +50 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.js +52 -0
- package/dist/core/heal/boot.js +136 -0
- package/dist/core/heal/childEnv.js +60 -0
- package/dist/core/heal/index.js +47 -6
- package/dist/core/heal/redact.js +31 -1
- package/dist/core/heal/sandbox.js +53 -1
- package/dist/core/heal/verify.js +30 -1
- package/dist/core/httpFuzzer.js +258 -0
- package/dist/core/init.js +2 -2
- package/dist/core/interceptor.js +10 -1
- package/dist/core/modernize.js +144 -0
- package/dist/core/orchestrator.js +66 -77
- package/dist/core/pr.js +44 -20
- package/dist/core/prompt.js +22 -0
- package/dist/core/replay.js +35 -3
- package/dist/core/report.js +19 -6
- package/dist/core/resolver.js +158 -5
- package/dist/core/specCompiler.js +17 -2
- package/dist/core/studio.js +3 -4
- package/dist/core/summarize.js +173 -0
- package/dist/core/swarm.js +235 -0
- package/dist/core/ui.js +2 -0
- package/dist/ui/app.js +4 -2
- package/media/demo.gif +0 -0
- package/media/logo.svg +9 -0
- package/package.json +25 -6
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from "path";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
3
5
|
import pc from "picocolors";
|
|
4
6
|
import { program } from "commander";
|
|
7
|
+
import { opt, formatHelp } from "./cli/help.js";
|
|
5
8
|
import { run } from "./core/orchestrator.js";
|
|
6
9
|
import { EventBus } from "./core/eventBus.js";
|
|
7
10
|
import { renderTui } from "./ui/app.js";
|
|
8
11
|
import { initProject } from "./core/init.js";
|
|
9
12
|
import { startStudio } from "./core/studio.js";
|
|
10
13
|
import { writePrComment } from "./core/pr.js";
|
|
14
|
+
import { writeBadge } from "./core/badge.js";
|
|
11
15
|
import { flushTelemetry } from "./core/telemetry/index.js";
|
|
12
16
|
import { flushCloud } from "./core/cloud/index.js";
|
|
17
|
+
import { summarizeFindings } from "./core/summarize.js";
|
|
18
|
+
import { applyVerifiedPatches } from "./core/heal/apply.js";
|
|
19
|
+
import { promptYesNo } from "./core/prompt.js";
|
|
20
|
+
import { modernizeFile } from "./core/modernize.js";
|
|
13
21
|
function collect(value, prev) {
|
|
14
22
|
prev.push(value);
|
|
15
23
|
return prev;
|
|
16
24
|
}
|
|
25
|
+
/** Auto-size the swarm to the machine's CPU cores, capped so we never oversubscribe. */
|
|
26
|
+
function autoWorkers() {
|
|
27
|
+
const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
28
|
+
return Math.max(1, Math.min(n, 8));
|
|
29
|
+
}
|
|
30
|
+
/** Print one low-key "next flag" hint after a run, so users learn the advanced
|
|
31
|
+
* flags on demand instead of memorizing the whole surface. Fires only in the
|
|
32
|
+
* plain-log path when there's a finding worth acting on. */
|
|
33
|
+
function suggestNext(findings, opts) {
|
|
34
|
+
if (opts.dryRun || opts.crashTest || findings.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const crashOrError = findings.some((f) => f.severity === "crash" || f.severity === "error");
|
|
37
|
+
const alreadyFixing = opts.heal || opts.fix || opts.magicFix;
|
|
38
|
+
if (crashOrError && !alreadyFixing) {
|
|
39
|
+
if (opts.repro) {
|
|
40
|
+
console.log(pc.dim("Tip: run with --fix to attempt a closed-loop fix"));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(pc.dim("Tip: run with --repro to prove these with a runnable spec, or --fix to fix them end-to-end"));
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const serverError = findings.some((f) => f.type === "network_5xx");
|
|
48
|
+
if (serverError && !opts.httpFuzz) {
|
|
49
|
+
console.log(pc.dim("Tip: run with --http-fuzz to map the server-side attack surface"));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
17
52
|
program
|
|
18
|
-
.name("aztrx")
|
|
53
|
+
.name("aztrx-cli")
|
|
19
54
|
.description("Runtime stress-testing for web apps — detect bugs, prove them with a repro")
|
|
20
55
|
.option("--repo <path>", "project root to inspect/watch (default: cwd)", process.cwd());
|
|
21
56
|
program
|
|
@@ -33,7 +68,7 @@ program
|
|
|
33
68
|
if (res.gitignoreUpdated)
|
|
34
69
|
console.log(pc.green("✓") + " .aztrx/ added to .gitignore");
|
|
35
70
|
console.log("");
|
|
36
|
-
console.log(pc.dim(`Next: npx aztrx run ${res.url} --repo .`));
|
|
71
|
+
console.log(pc.dim(`Next: npx aztrx-cli run ${res.url} --repo .`));
|
|
37
72
|
});
|
|
38
73
|
program
|
|
39
74
|
.command("studio")
|
|
@@ -42,35 +77,90 @@ program
|
|
|
42
77
|
.action((opts) => {
|
|
43
78
|
startStudio({ repoRoot: path.resolve(program.opts().repo), port: parseInt(opts.port, 10) });
|
|
44
79
|
});
|
|
80
|
+
program
|
|
81
|
+
.command("modernize")
|
|
82
|
+
.description("rewrite a legacy JS/TS file into modern idiomatic syntax (LLM)")
|
|
83
|
+
.argument("<file>", "path to the file to modernize")
|
|
84
|
+
.option("-y, --yes", "apply without prompting")
|
|
85
|
+
.action(async (file, opts) => {
|
|
86
|
+
const repoRoot = path.resolve(program.opts().repo);
|
|
87
|
+
const rel = path.relative(repoRoot, path.resolve(file));
|
|
88
|
+
const res = await modernizeFile(repoRoot, file);
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
console.error(pc.red("modernize failed:") + ` ${res.error}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
for (const c of res.changes)
|
|
94
|
+
console.log(pc.green(" ✓") + ` ${c}`);
|
|
95
|
+
const doApply = await promptYesNo(`Apply modernized version to ${rel}? (y/N)`, { yes: opts.yes });
|
|
96
|
+
if (doApply) {
|
|
97
|
+
fs.writeFileSync(path.resolve(file), res.modernized, "utf-8");
|
|
98
|
+
console.log(pc.dim(` Applied → review with: git diff ${rel}`));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(pc.dim(" Not applied."));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
45
104
|
program
|
|
46
105
|
.command("run", { isDefault: true })
|
|
47
106
|
.description("inspect a running app and prove its bugs with an executable repro")
|
|
48
107
|
.argument("<url>", "dev server to inspect, e.g. http://localhost:3000")
|
|
49
|
-
.
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
.
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.
|
|
108
|
+
.configureHelp({ formatHelp })
|
|
109
|
+
.addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
|
|
110
|
+
.addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
|
|
111
|
+
.addOption(opt("--dry-run", "report what would be clicked without clicking", "detect"))
|
|
112
|
+
.addOption(opt("--crash-test", "throw a deliberate error to verify capture", "advanced"))
|
|
113
|
+
.addOption(opt("--fail-on", "exit 1 if any crash/error finding is present", "ship"))
|
|
114
|
+
.addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk (F5)", "detect"))
|
|
115
|
+
.addOption(opt("--http-fuzz", "HTTP-layer mutation fuzzing — hostile requests against the target origin (F5-http)", "detect"))
|
|
116
|
+
.addOption(opt("--http-fuzz-mutations", "with --http-fuzz: also send POST/PUT body mutations (default: GET-only)", "advanced"))
|
|
117
|
+
.addOption(opt("--seed <n>", "RNG seed for fuzz", "advanced").default("42"))
|
|
118
|
+
.addOption(opt("--workers <n>", "number of parallel detection workers (default 1)", "detect"))
|
|
119
|
+
.addOption(opt("--swarm", "auto-size the swarm to the machine's CPU cores (alias: --workers auto)").hideHelp())
|
|
120
|
+
.addOption(opt("--repro", "minimize + compile + validate each finding (F7-F9)", "prove"))
|
|
121
|
+
.addOption(opt("--repro-runs <n>", "replay iterations for the flake-rate gate", "advanced").default("3"))
|
|
122
|
+
.addOption(opt("--fix", "find → explain → heal → apply: one-command fix", "fix"))
|
|
123
|
+
.addOption(opt("--heal", "closed-loop healing for crash/error findings (implies --repro)", "fix"))
|
|
124
|
+
.addOption(opt("--magic-fix", "alias for --fix (deprecated)").hideHelp())
|
|
125
|
+
.addOption(opt("--heal-model <model>", "LLM model for healing — the fallback tier (default claude-sonnet-5)", "advanced"))
|
|
126
|
+
.addOption(opt("--heal-fast-model <model>", "fast/cheap first tier for the smart router (default claude-haiku-4-5)", "advanced"))
|
|
127
|
+
.addOption(opt("--test-command <cmd>", "test command run against a healed patch (default: npm test, auto-detected)", "advanced"))
|
|
128
|
+
.addOption(opt("--test-timeout <ms>", "timeout for the heal test gate, ms", "advanced").default("300000"))
|
|
129
|
+
.addOption(opt("--no-test", "skip the test gate during healing", "advanced"))
|
|
130
|
+
.addOption(opt("--start-command <cmd>", "command to boot the app for server healing (default: auto-detect scripts.dev/scripts.start)", "advanced"))
|
|
131
|
+
.addOption(opt("--explain", "print a human-language summary of the findings (no healing)", "fix"))
|
|
132
|
+
.addOption(opt("-y, --yes", "auto-apply verified fixes without prompting (with --fix)", "fix"))
|
|
133
|
+
.addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
|
|
134
|
+
.addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
|
|
135
|
+
.addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
|
|
136
|
+
.addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
|
|
137
|
+
.addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
|
|
138
|
+
.addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
|
|
139
|
+
.addOption(opt("--api-key <key>", "API key for --upload / --share-data (defaults to $AZTRX_API_KEY)", "advanced"))
|
|
140
|
+
.addOption(opt("--cloud-url <url>", "override the cloud ingest base URL (default https://api.aztrx.app)", "advanced"))
|
|
141
|
+
.addOption(opt("--allow-host <host>", "add a host to the network allow-list (repeatable)", "advanced").argParser(collect).default([]))
|
|
142
|
+
.addOption(opt("--storage-state <path>", "path to a Playwright storage-state JSON (cookies/localStorage) for authenticated pages", "auth"))
|
|
143
|
+
.addOption(opt("--auth <path>", "alias for --storage-state").hideHelp())
|
|
144
|
+
.addOption(opt("--login", "auto-login before the pass (needs AZTRX_AUTH_EMAIL/AZTRX_AUTH_PASSWORD env)", "auth"))
|
|
145
|
+
.addOption(opt("--login-email <email>", "email for --login (default: $AZTRX_AUTH_EMAIL)").hideHelp())
|
|
146
|
+
.addOption(opt("--login-password <pass>", "password for --login (default: $AZTRX_AUTH_PASSWORD)").hideHelp())
|
|
147
|
+
.addOption(opt("--login-url <url>", "explicit login page URL for --login (default: current page)").hideHelp())
|
|
148
|
+
.addOption(opt("--plain", "disable the live terminal UI, print plain logs (default when piped)", "advanced"))
|
|
149
|
+
.addOption(opt("--ui", "force the live terminal UI even when stdout is not a TTY", "advanced"))
|
|
72
150
|
.action(async (url, opts) => {
|
|
151
|
+
// `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
|
|
152
|
+
const magicFix = opts.magicFix || opts.fix;
|
|
73
153
|
const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
|
|
154
|
+
const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
|
|
155
|
+
const mode = (workers ?? 1) > 1 || opts.httpFuzz
|
|
156
|
+
? `swarm (${workers ?? 1} worker${(workers ?? 1) === 1 ? "" : "s"})`
|
|
157
|
+
: opts.fuzz
|
|
158
|
+
? `fuzz (seed ${opts.seed})`
|
|
159
|
+
: opts.heal
|
|
160
|
+
? "repro → heal"
|
|
161
|
+
: opts.repro
|
|
162
|
+
? "repro"
|
|
163
|
+
: "deterministic walk";
|
|
74
164
|
const runOpts = {
|
|
75
165
|
url,
|
|
76
166
|
repoRoot,
|
|
@@ -78,19 +168,30 @@ program
|
|
|
78
168
|
dryRun: opts.dryRun,
|
|
79
169
|
crashTest: opts.crashTest,
|
|
80
170
|
fuzz: opts.fuzz,
|
|
81
|
-
|
|
171
|
+
httpFuzz: opts.httpFuzz,
|
|
172
|
+
httpFuzzMutations: opts.httpFuzzMutations,
|
|
173
|
+
repro: opts.repro || opts.heal || magicFix,
|
|
82
174
|
seed: parseInt(opts.seed, 10),
|
|
175
|
+
workers,
|
|
83
176
|
allowHosts: opts.allowHost ?? [],
|
|
84
177
|
reproRuns: parseInt(opts.reproRuns, 10),
|
|
85
|
-
heal: opts.heal,
|
|
178
|
+
heal: opts.heal || magicFix,
|
|
86
179
|
healModel: opts.healModel,
|
|
87
180
|
healFastModel: opts.healFastModel,
|
|
181
|
+
testCommand: opts.testCommand,
|
|
182
|
+
testTimeoutMs: opts.testTimeoutMs ? parseInt(opts.testTimeoutMs, 10) : undefined,
|
|
183
|
+
skipTest: opts.test === false,
|
|
184
|
+
startCommand: opts.startCommand,
|
|
88
185
|
telemetry: opts.telemetry,
|
|
89
186
|
shareData: opts.shareData,
|
|
90
187
|
upload: opts.upload,
|
|
91
188
|
apiKey: opts.apiKey,
|
|
92
189
|
cloudUrl: opts.cloudUrl,
|
|
93
190
|
storageState: opts.storageState ?? opts.auth,
|
|
191
|
+
login: opts.login,
|
|
192
|
+
loginEmail: opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL,
|
|
193
|
+
loginPassword: opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD,
|
|
194
|
+
loginUrl: opts.loginUrl,
|
|
94
195
|
};
|
|
95
196
|
const failOn = Boolean(opts.failOn);
|
|
96
197
|
const useUi = !opts.plain && (process.stdout.isTTY === true || opts.ui === true);
|
|
@@ -103,13 +204,13 @@ program
|
|
|
103
204
|
done: runPromise,
|
|
104
205
|
targetUrl: url,
|
|
105
206
|
repoRoot,
|
|
106
|
-
mode
|
|
207
|
+
mode,
|
|
107
208
|
});
|
|
108
209
|
try {
|
|
109
210
|
findings = await runPromise;
|
|
110
211
|
}
|
|
111
212
|
catch (e) {
|
|
112
|
-
console.error(pc.red("Aztrx run failed:"), e.message);
|
|
213
|
+
console.error(pc.red("Aztrx AI run failed:"), e.message);
|
|
113
214
|
process.exit(1);
|
|
114
215
|
}
|
|
115
216
|
}
|
|
@@ -123,6 +224,43 @@ program
|
|
|
123
224
|
writePrComment(repoRoot, url, findings, prPath);
|
|
124
225
|
console.log(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
|
|
125
226
|
}
|
|
227
|
+
if (opts.badge) {
|
|
228
|
+
const badgePath = typeof opts.badge === "string"
|
|
229
|
+
? opts.badge
|
|
230
|
+
: path.join(repoRoot, ".aztrx", "badge.svg");
|
|
231
|
+
writeBadge(repoRoot, findings, badgePath);
|
|
232
|
+
console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
|
|
233
|
+
}
|
|
234
|
+
// F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
|
|
235
|
+
// The run already printed its structured output; this layer explains it and,
|
|
236
|
+
// under `--fix`, offers to apply the verified patches so `git diff`
|
|
237
|
+
// shows the result. Never commits.
|
|
238
|
+
if (magicFix || opts.explain) {
|
|
239
|
+
const summary = await summarizeFindings(findings, { lang: opts.lang });
|
|
240
|
+
console.log("\n" + summary);
|
|
241
|
+
}
|
|
242
|
+
if (magicFix) {
|
|
243
|
+
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
244
|
+
if (healed.length > 0) {
|
|
245
|
+
const doApply = await promptYesNo(`Apply ${healed.length} verified fix${healed.length === 1 ? "" : "es"} to the working tree? (y/N)`, { yes: opts.yes });
|
|
246
|
+
if (doApply) {
|
|
247
|
+
const result = applyVerifiedPatches(repoRoot, findings);
|
|
248
|
+
for (const a of result.applied) {
|
|
249
|
+
console.log(pc.green(" ✓ applied") + ` ${a.filePath} (${a.hunkCount} edit${a.hunkCount === 1 ? "" : "s"})`);
|
|
250
|
+
}
|
|
251
|
+
for (const c of result.conflicts) {
|
|
252
|
+
console.log(pc.yellow(" ◐ skipped") + ` ${c.filePath}: ${c.error}`);
|
|
253
|
+
}
|
|
254
|
+
console.log(pc.dim(" Review with: git diff"));
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
console.log(pc.dim(" Not applied — review the .patch files under .aztrx/heal/."));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!useUi) {
|
|
262
|
+
suggestNext(findings, opts);
|
|
263
|
+
}
|
|
126
264
|
// Drain any in-flight telemetry uploads (each bounded) before exit, so a
|
|
127
265
|
// pending `--share-data` dispatch isn't killed mid-flight. Never affects
|
|
128
266
|
// the exit code.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-auth — auto-login. Locates a login form and drives it with the user's
|
|
3
|
+
* credentials, so the rest of the run (and its repros) exercise the app
|
|
4
|
+
* *authenticated*. Best-effort by design: a login that can't be found or
|
|
5
|
+
* doesn't complete is reported, never fatal — the run continues unauth.
|
|
6
|
+
*
|
|
7
|
+
* The password input is the reliable signal for a login form; the email/username
|
|
8
|
+
* field and submit button are resolved within its nearest `<form>` (or the page
|
|
9
|
+
* body when the app doesn't use a `<form>` element).
|
|
10
|
+
*/
|
|
11
|
+
/** Locate a login form on the current page, or `null` if none is present. */
|
|
12
|
+
export async function findLoginForm(page) {
|
|
13
|
+
const password = page.locator('input[type="password"]').first();
|
|
14
|
+
if ((await password.count()) === 0)
|
|
15
|
+
return null;
|
|
16
|
+
const form = password.locator("xpath=ancestor::form[1]");
|
|
17
|
+
const scope = (await form.count()) > 0 ? form : page.locator("body");
|
|
18
|
+
const emailCandidates = [
|
|
19
|
+
scope.locator('input[type="email"]'),
|
|
20
|
+
scope.locator('input[autocomplete="username"]'),
|
|
21
|
+
scope.locator('input[name*="email" i]'),
|
|
22
|
+
scope.locator('input[name*="user" i]'),
|
|
23
|
+
scope.locator('input[name*="login" i]'),
|
|
24
|
+
scope.locator('input[type="text"]'),
|
|
25
|
+
];
|
|
26
|
+
let email = null;
|
|
27
|
+
for (const c of emailCandidates) {
|
|
28
|
+
if ((await c.count()) > 0) {
|
|
29
|
+
email = c.first();
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!email)
|
|
34
|
+
return null;
|
|
35
|
+
const submitCandidates = [
|
|
36
|
+
scope.locator('button[type="submit"]'),
|
|
37
|
+
scope.locator('input[type="submit"]'),
|
|
38
|
+
];
|
|
39
|
+
let submit = null;
|
|
40
|
+
for (const c of submitCandidates) {
|
|
41
|
+
if ((await c.count()) > 0) {
|
|
42
|
+
submit = c.first();
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { email, password, submit };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Drive the login form. Returns whether it appeared to complete: the password
|
|
50
|
+
* field vanished, or the URL changed. Never throws — every step degrades.
|
|
51
|
+
*/
|
|
52
|
+
export async function establishLogin(page, opts) {
|
|
53
|
+
if (opts.loginUrl) {
|
|
54
|
+
await page.goto(opts.loginUrl, { waitUntil: "load", timeout: 30000 }).catch(() => { });
|
|
55
|
+
await page.waitForTimeout(1000);
|
|
56
|
+
}
|
|
57
|
+
const form = await findLoginForm(page);
|
|
58
|
+
if (!form)
|
|
59
|
+
return { ok: false, reason: "no login form detected" };
|
|
60
|
+
await form.email.fill(opts.email).catch(() => { });
|
|
61
|
+
await form.password.fill(opts.password).catch(() => { });
|
|
62
|
+
const urlBefore = page.url();
|
|
63
|
+
if (form.submit) {
|
|
64
|
+
await form.submit.click({ timeout: 3000 }).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
await form.password.press("Enter").catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
await page.waitForLoadState("domcontentloaded").catch(() => { });
|
|
70
|
+
await page.waitForTimeout(1500);
|
|
71
|
+
const stillHasPassword = (await page.locator('input[type="password"]').count()) > 0;
|
|
72
|
+
const urlChanged = page.url() !== urlBefore;
|
|
73
|
+
if (!stillHasPassword || urlChanged)
|
|
74
|
+
return { ok: true };
|
|
75
|
+
return { ok: false, reason: "login did not appear to complete" };
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained SVG status badge generated from a real run's findings. Unlike a
|
|
3
|
+
* static "Protected by …" sticker, this badge is *earned*: it reflects the
|
|
4
|
+
* crash/error count of the run that produced it, so a stale badge is a
|
|
5
|
+
* regeneration problem — not a lie baked into the image.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
const CHAR_W = 7.1; // Verdana 11px approximate advance width
|
|
10
|
+
const PAD_X = 11; // horizontal padding per text block
|
|
11
|
+
const LABEL = "#27272a"; // zinc-800 — brand monochrome
|
|
12
|
+
const GREEN = "#16a34a";
|
|
13
|
+
const RED = "#dc2626";
|
|
14
|
+
function criticalCount(findings) {
|
|
15
|
+
return findings.filter((f) => f.severity === "crash" || f.severity === "error").length;
|
|
16
|
+
}
|
|
17
|
+
function blockWidth(text) {
|
|
18
|
+
return Math.round(text.length * CHAR_W + 2 * PAD_X);
|
|
19
|
+
}
|
|
20
|
+
export function renderBadge(findings, label = "aztrx") {
|
|
21
|
+
const n = criticalCount(findings);
|
|
22
|
+
const message = n === 0 ? "crash-free" : `${n} finding${n === 1 ? "" : "s"}`;
|
|
23
|
+
const color = n === 0 ? GREEN : RED;
|
|
24
|
+
const labelW = blockWidth(label);
|
|
25
|
+
const msgW = blockWidth(message);
|
|
26
|
+
const totalW = labelW + msgW;
|
|
27
|
+
const labelX = labelW / 2;
|
|
28
|
+
const msgX = labelW + msgW / 2;
|
|
29
|
+
return [
|
|
30
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${label}: ${message}">`,
|
|
31
|
+
` <linearGradient id="g" x2="0" y2="100%"><stop offset="0" stop-color="#ffffff" stop-opacity="0.14"/><stop offset="1" stop-opacity="0"/></linearGradient>`,
|
|
32
|
+
` <clipPath id="r"><rect width="${totalW}" height="20" rx="3" fill="#fff"/></clipPath>`,
|
|
33
|
+
` <g clip-path="url(#r)">`,
|
|
34
|
+
` <rect width="${labelW}" height="20" fill="${LABEL}"/>`,
|
|
35
|
+
` <rect x="${labelW}" width="${msgW}" height="20" fill="${color}"/>`,
|
|
36
|
+
` <rect width="${totalW}" height="20" fill="url(#g)"/>`,
|
|
37
|
+
` </g>`,
|
|
38
|
+
` <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" font-weight="600">`,
|
|
39
|
+
` <text x="${labelX}" y="14">${label}</text>`,
|
|
40
|
+
` <text x="${msgX}" y="14">${message}</text>`,
|
|
41
|
+
` </g>`,
|
|
42
|
+
`</svg>`,
|
|
43
|
+
].join("\n") + "\n";
|
|
44
|
+
}
|
|
45
|
+
export function writeBadge(repoRoot, findings, filePath) {
|
|
46
|
+
const file = filePath ?? path.join(repoRoot, ".aztrx", "badge.svg");
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
fs.writeFileSync(file, renderBadge(findings), "utf-8");
|
|
49
|
+
return file;
|
|
50
|
+
}
|
package/dist/core/fuzzer.js
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F13 — the only place Aztrx writes a verified patch into the user's *working
|
|
3
|
+
* tree*. Healing itself stays sandboxed (see `index.ts`); this module is the
|
|
4
|
+
* explicit, opt-in "apply" step behind `--fix`, so the recipe's promised
|
|
5
|
+
* `git diff` shows real changes.
|
|
6
|
+
*
|
|
7
|
+
* Rules that keep this safe:
|
|
8
|
+
* - Only findings whose heal result is `healed` (verified: bug gone) are applied.
|
|
9
|
+
* - The file is re-read *per finding* (several findings can touch one file),
|
|
10
|
+
* and each patch is applied with exact-match hunks. A mismatch is a conflict
|
|
11
|
+
* and is skipped — its `.patch` artifact stays for manual review.
|
|
12
|
+
* - Paths are confined to `repoRoot`; anything escaping it is refused.
|
|
13
|
+
* - Aztrx never commits. This writes working-tree files only.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { applyHunks } from "./sandbox.js";
|
|
18
|
+
export function applyVerifiedPatches(repoRoot, findings) {
|
|
19
|
+
const applied = [];
|
|
20
|
+
const conflicts = [];
|
|
21
|
+
const root = path.resolve(repoRoot);
|
|
22
|
+
for (const f of findings) {
|
|
23
|
+
if (f.heal?.status !== "healed")
|
|
24
|
+
continue;
|
|
25
|
+
const filePath = f.heal.filePath;
|
|
26
|
+
if (!filePath) {
|
|
27
|
+
conflicts.push({ filePath, error: "no source file recorded" });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const abs = path.resolve(root, filePath);
|
|
31
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
32
|
+
conflicts.push({ filePath, error: `refusing to write outside repo: ${filePath}` });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let current;
|
|
36
|
+
try {
|
|
37
|
+
current = fs.readFileSync(abs, "utf-8");
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
conflicts.push({ filePath, error: `cannot read: ${e.message}` });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const result = applyHunks(current, f.heal.hunks);
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
conflicts.push({ filePath, error: result.errors.join("; ") });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(abs, result.patched, "utf-8");
|
|
49
|
+
applied.push({ filePath, hunkCount: result.applied });
|
|
50
|
+
}
|
|
51
|
+
return { applied, conflicts };
|
|
52
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 gate #4 — boot the *patched* app for server-side verification. A server
|
|
3
|
+
* finding (e.g. `HTTP 500 /api/cart`) can't be verified by statically serving
|
|
4
|
+
* the worktree — the 500 only reappears when the route actually runs. So before
|
|
5
|
+
* the replay, this boots the patched server inside the worktree on a free port,
|
|
6
|
+
* waits for an HTTP readiness signal, and returns a `close` hook that tree-kills
|
|
7
|
+
* the process (and its children) so nothing is left holding the port.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as net from "net";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { buildChildEnv } from "./childEnv.js";
|
|
14
|
+
/** Auto-detect how the app starts, mirroring `runTests`'s `npm test` convention:
|
|
15
|
+
* prefer `scripts.dev` (no build step), then `scripts.start`. Null when neither
|
|
16
|
+
* exists — the caller then requires an explicit `--start-command`. */
|
|
17
|
+
export function detectStartCommand(repoRoot) {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf-8"));
|
|
20
|
+
const s = pkg.scripts;
|
|
21
|
+
if (s && typeof s.dev === "string")
|
|
22
|
+
return "npm run dev";
|
|
23
|
+
if (s && typeof s.start === "string")
|
|
24
|
+
return "npm run start";
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// no package.json, or unparseable — fall through to null
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** Allocate a free loopback port. Best-effort: there is a small window between
|
|
32
|
+
* closing the probe and the app binding, so a collision is surfaced as a boot
|
|
33
|
+
* timeout rather than silently mis-directed. */
|
|
34
|
+
function freePort() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const srv = net.createServer();
|
|
37
|
+
srv.once("error", reject);
|
|
38
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
39
|
+
const addr = srv.address();
|
|
40
|
+
srv.close(() => resolve(addr.port));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function bootServer(opts) {
|
|
45
|
+
const { worktreeDir, repoRoot, startCommand } = opts;
|
|
46
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
47
|
+
// A fresh worktree has no node_modules — symlink the root's so the booted
|
|
48
|
+
// server resolves its dependencies (the same junction trick sandbox.ts uses).
|
|
49
|
+
const rootNodeModules = path.join(repoRoot, "node_modules");
|
|
50
|
+
const wtNodeModules = path.join(worktreeDir, "node_modules");
|
|
51
|
+
if (!fs.existsSync(wtNodeModules) && fs.existsSync(rootNodeModules)) {
|
|
52
|
+
try {
|
|
53
|
+
fs.symlinkSync(rootNodeModules, wtNodeModules, process.platform === "win32" ? "junction" : "dir");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* resolution errors surface in the readiness timeout below */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const port = await freePort();
|
|
60
|
+
// Support scripts that hardcode a port via `-p {port}`; `PORT` is also set in
|
|
61
|
+
// the environment for the (more common) scripts that read `process.env.PORT`.
|
|
62
|
+
const command = startCommand.replace(/\{port\}/g, String(port));
|
|
63
|
+
// Ring buffer of the last ~40 log lines, so a boot timeout can tell the user
|
|
64
|
+
// *why* the server didn't come up rather than just "timeout".
|
|
65
|
+
const lines = [];
|
|
66
|
+
const push = (chunk) => {
|
|
67
|
+
for (const line of chunk.toString().split(/\r?\n/)) {
|
|
68
|
+
if (line) {
|
|
69
|
+
lines.push(line);
|
|
70
|
+
if (lines.length > 40)
|
|
71
|
+
lines.shift();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const child = spawn(command, {
|
|
76
|
+
shell: true,
|
|
77
|
+
cwd: worktreeDir,
|
|
78
|
+
// Minimal allow-list — the booted app is patched PR code; it must not see
|
|
79
|
+
// the caller's ANTHROPIC_API_KEY or other CI secrets.
|
|
80
|
+
env: buildChildEnv({ PORT: String(port), CI: "true" }),
|
|
81
|
+
// On POSIX, detach so the server + its children form their own process
|
|
82
|
+
// group — close() can then signal the whole group. Windows can't do group
|
|
83
|
+
// signaling; it relies on `taskkill /T` below instead.
|
|
84
|
+
detached: process.platform !== "win32",
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
});
|
|
87
|
+
child.stdout?.on("data", push);
|
|
88
|
+
child.stderr?.on("data", push);
|
|
89
|
+
let closed = false;
|
|
90
|
+
const close = async () => {
|
|
91
|
+
if (closed || !child.pid)
|
|
92
|
+
return;
|
|
93
|
+
closed = true;
|
|
94
|
+
if (process.platform === "win32") {
|
|
95
|
+
// `shell: true` spawns cmd.exe which spawns the real server as a child —
|
|
96
|
+
// a plain child.kill() would orphan that child and leave the port taken.
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
|
|
99
|
+
stdio: "ignore",
|
|
100
|
+
});
|
|
101
|
+
killer.on("exit", () => resolve());
|
|
102
|
+
killer.on("error", () => resolve());
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
try {
|
|
107
|
+
process.kill(-child.pid, "SIGTERM");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// already exited
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// Readiness: poll until the server answers with *any* HTTP response (2xx/4xx/
|
|
115
|
+
// 5xx all mean "the listener is up"). A still-compiling dev server (Next) may
|
|
116
|
+
// take a while on its first request — the loop keeps retrying until it's hot.
|
|
117
|
+
const url = `http://127.0.0.1:${port}`;
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
let ready = false;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
123
|
+
await res.arrayBuffer().catch(() => { });
|
|
124
|
+
ready = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!ready) {
|
|
132
|
+
await close();
|
|
133
|
+
throw new Error(`server did not become ready in ${timeoutMs}ms: ${startCommand}\n${lines.join("\n").slice(-2000)}`);
|
|
134
|
+
}
|
|
135
|
+
return { url, close, logs: () => lines.join("\n") };
|
|
136
|
+
}
|