aztrx-cli 0.4.5 → 0.5.2
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 +326 -29
- package/dist/cli/help.d.ts +22 -0
- package/dist/cli/repo.d.ts +20 -0
- package/dist/cli/repo.js +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -86
- package/dist/core/auth.d.ts +33 -0
- package/dist/core/badge.d.ts +9 -0
- package/dist/core/browser.d.ts +3 -0
- package/dist/core/classifier.d.ts +41 -0
- package/dist/core/cloud/index.d.ts +62 -0
- package/dist/core/cloud/index.js +24 -4
- package/dist/core/devServer.d.ts +90 -0
- package/dist/core/devServer.js +253 -0
- package/dist/core/diagnose.d.ts +18 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/domWalker.d.ts +24 -0
- package/dist/core/domWalker.js +18 -2
- package/dist/core/eventBus.d.ts +59 -0
- package/dist/core/events.d.ts +42 -0
- package/dist/core/fixPr.d.ts +16 -0
- package/dist/core/fixPr.js +11 -1
- package/dist/core/fuzzer.d.ts +24 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.d.ts +28 -0
- package/dist/core/heal/boot.d.ts +74 -0
- package/dist/core/heal/boot.js +126 -23
- package/dist/core/heal/childEnv.d.ts +15 -0
- package/dist/core/heal/gates.d.ts +11 -0
- package/dist/core/heal/index.d.ts +19 -0
- package/dist/core/heal/index.js +53 -23
- package/dist/core/heal/llm.d.ts +43 -0
- package/dist/core/heal/llm.js +22 -2
- package/dist/core/heal/redact.d.ts +22 -0
- package/dist/core/heal/sandbox.d.ts +54 -0
- package/dist/core/heal/sandbox.js +63 -3
- package/dist/core/heal/types.d.ts +102 -0
- package/dist/core/heal/verify.d.ts +37 -0
- package/dist/core/heal/verify.js +28 -14
- package/dist/core/httpFuzzer.d.ts +33 -0
- package/dist/core/init.d.ts +21 -0
- package/dist/core/init.js +5 -6
- package/dist/core/interceptor.d.ts +21 -0
- package/dist/core/llm.d.ts +36 -0
- package/dist/core/llm.js +40 -13
- package/dist/core/minimizer.d.ts +13 -0
- package/dist/core/modernize.d.ts +28 -0
- package/dist/core/modernize.js +2 -2
- package/dist/core/networkGuard.d.ts +13 -0
- package/dist/core/orchestrator.d.ts +86 -0
- package/dist/core/orchestrator.js +5 -2
- package/dist/core/patrol/loop.d.ts +46 -0
- package/dist/core/patrol/pr.d.ts +42 -0
- package/dist/core/patrol/pr.js +9 -6
- package/dist/core/patrol/record.d.ts +51 -0
- package/dist/core/patrol/state.d.ts +39 -0
- package/dist/core/pr.d.ts +3 -0
- package/dist/core/pr.js +15 -11
- package/dist/core/prompt.d.ts +14 -0
- package/dist/core/recorder.d.ts +23 -0
- package/dist/core/recorder.js +1 -1
- package/dist/core/renderMarkdown.d.ts +7 -0
- package/dist/core/replay.d.ts +33 -0
- package/dist/core/replay.js +52 -7
- package/dist/core/report.d.ts +8 -0
- package/dist/core/resolver.d.ts +64 -0
- package/dist/core/resolver.js +135 -17
- package/dist/core/rng.d.ts +2 -0
- package/dist/core/specCompiler.d.ts +17 -0
- package/dist/core/studio.d.ts +6 -0
- package/dist/core/studio.js +0 -3
- package/dist/core/summarize.d.ts +26 -0
- package/dist/core/summarize.js +17 -6
- package/dist/core/swarm.d.ts +97 -0
- package/dist/core/swarm.js +25 -2
- package/dist/core/telemetry/index.d.ts +33 -0
- package/dist/core/telemetry/index.js +22 -4
- package/dist/core/telemetry/sanitize.d.ts +19 -0
- package/dist/core/telemetry/types.d.ts +26 -0
- package/dist/core/types.d.ts +88 -0
- package/dist/core/ui.d.ts +23 -0
- package/dist/core/ui.js +0 -5
- package/dist/core/validator.d.ts +14 -0
- package/dist/core/version.d.ts +1 -0
- package/dist/hooks/index.d.ts +106 -0
- package/dist/hooks/index.js +349 -0
- package/dist/mcp/index.d.ts +58 -0
- package/dist/mcp/index.js +295 -0
- package/dist/mcp/install.d.ts +52 -0
- package/dist/mcp/install.js +204 -0
- package/dist/mcp/protocol.d.ts +106 -0
- package/dist/mcp/protocol.js +156 -0
- package/dist/mcp/tools.d.ts +107 -0
- package/dist/mcp/tools.js +621 -0
- package/dist/next/index.d.ts +70 -0
- package/dist/next/index.js +103 -0
- package/dist/plugins/scan.d.ts +85 -0
- package/dist/plugins/scan.js +188 -0
- package/dist/ui/app.d.ts +11 -0
- package/dist/vite/index.d.ts +60 -0
- package/dist/vite/index.js +56 -0
- package/package.json +44 -6
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Finding, RecordedAction, ReproVerdict, TelemetryErrorPayload } from "./types.js";
|
|
2
|
+
import type { HealStatus } from "./heal/types.js";
|
|
3
|
+
export type RunPhase = "launch" | "swarm" | "walk" | "fuzz" | "http-fuzz" | "repro" | "heal" | "done";
|
|
4
|
+
export interface PhaseEvent {
|
|
5
|
+
phase: RunPhase;
|
|
6
|
+
detail?: string;
|
|
7
|
+
ts: number;
|
|
8
|
+
}
|
|
9
|
+
export interface RouteEvent {
|
|
10
|
+
url: string;
|
|
11
|
+
ts: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ReproEvent {
|
|
14
|
+
finding: Finding;
|
|
15
|
+
verdict: ReproVerdict;
|
|
16
|
+
runs: number;
|
|
17
|
+
reproductions: number;
|
|
18
|
+
steps: number;
|
|
19
|
+
totalSteps: number;
|
|
20
|
+
/** Repo-relative path to the compiled Playwright spec. */
|
|
21
|
+
specPath: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HealEvent {
|
|
24
|
+
finding: Finding;
|
|
25
|
+
status: HealStatus;
|
|
26
|
+
patchPath?: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
export type NoticeLevel = "hint" | "warning" | "danger";
|
|
30
|
+
export interface NoticeEvent {
|
|
31
|
+
message: string;
|
|
32
|
+
level: NoticeLevel;
|
|
33
|
+
ts: number;
|
|
34
|
+
}
|
|
35
|
+
export interface AztrxEvents {
|
|
36
|
+
telemetry: TelemetryErrorPayload;
|
|
37
|
+
action: RecordedAction;
|
|
38
|
+
finding: Finding;
|
|
39
|
+
repro: ReproEvent;
|
|
40
|
+
heal: HealEvent;
|
|
41
|
+
phase: PhaseEvent;
|
|
42
|
+
route: RouteEvent;
|
|
43
|
+
noise: {
|
|
44
|
+
ts: number;
|
|
45
|
+
};
|
|
46
|
+
notice: NoticeEvent;
|
|
47
|
+
}
|
|
48
|
+
type Handler<T> = (payload: T) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Minimal typed event bus — the single backbone the modules communicate over,
|
|
51
|
+
* so the Orchestrator can be swapped for a cloud sink later without touching
|
|
52
|
+
* any module (hexagonal, per PRD §2.2).
|
|
53
|
+
*/
|
|
54
|
+
export declare class EventBus {
|
|
55
|
+
private handlers;
|
|
56
|
+
on<K extends keyof AztrxEvents>(event: K, handler: Handler<AztrxEvents[K]>): () => void;
|
|
57
|
+
emit<K extends keyof AztrxEvents>(event: K, payload: AztrxEvents[K]): void;
|
|
58
|
+
}
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Finding, ReproVerdict } from "./types.js";
|
|
2
|
+
import type { HealStatus } from "./heal/types.js";
|
|
3
|
+
export type RunEvent = {
|
|
4
|
+
type: "run_start";
|
|
5
|
+
url: string;
|
|
6
|
+
ts: number;
|
|
7
|
+
} | {
|
|
8
|
+
type: "finding";
|
|
9
|
+
finding: Finding;
|
|
10
|
+
} | {
|
|
11
|
+
type: "repro";
|
|
12
|
+
fingerprint: string;
|
|
13
|
+
verdict: ReproVerdict;
|
|
14
|
+
runs: number;
|
|
15
|
+
reproductions: number;
|
|
16
|
+
steps: number;
|
|
17
|
+
totalSteps: number;
|
|
18
|
+
specPath: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: "heal";
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
status: HealStatus;
|
|
23
|
+
patchPath?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: "run_end";
|
|
27
|
+
counts: Record<string, number>;
|
|
28
|
+
ts: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Append-only JSON-lines run log. Every run writes its lifecycle + findings
|
|
32
|
+
* here so a live surface (Local Studio, later the cloud sink) can tail it
|
|
33
|
+
* without coupling to the orchestrator's console output.
|
|
34
|
+
*/
|
|
35
|
+
export declare class RunLog {
|
|
36
|
+
private readonly file;
|
|
37
|
+
constructor(repoRoot: string);
|
|
38
|
+
append(event: RunEvent): void;
|
|
39
|
+
/** Truncate so the log represents the latest run only (live-tail semantics). */
|
|
40
|
+
reset(): void;
|
|
41
|
+
get path(): string;
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Finding } from "./types.js";
|
|
2
|
+
export interface FixPrResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
url?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Turn applied, verified fixes into a merge-ready PR: create a branch, commit,
|
|
9
|
+
* and open a PR via the `gh` CLI with the repro evidence in the body. Mirrors
|
|
10
|
+
* the "merge-ready PR" flow but for runtime crashes, not security vulns.
|
|
11
|
+
*
|
|
12
|
+
* Only the files the patches touched are staged. `git add -A` would commit the
|
|
13
|
+
* user's unrelated uncommitted work into an auto-generated commit and push it —
|
|
14
|
+
* the one thing an automated fixer must never do to someone's repo.
|
|
15
|
+
*/
|
|
16
|
+
export declare function openFixPr(repoRoot: string, findings: Finding[], url: string): Promise<FixPrResult>;
|
package/dist/core/fixPr.js
CHANGED
|
@@ -5,6 +5,10 @@ const exec = promisify(execFile);
|
|
|
5
5
|
* Turn applied, verified fixes into a merge-ready PR: create a branch, commit,
|
|
6
6
|
* and open a PR via the `gh` CLI with the repro evidence in the body. Mirrors
|
|
7
7
|
* the "merge-ready PR" flow but for runtime crashes, not security vulns.
|
|
8
|
+
*
|
|
9
|
+
* Only the files the patches touched are staged. `git add -A` would commit the
|
|
10
|
+
* user's unrelated uncommitted work into an auto-generated commit and push it —
|
|
11
|
+
* the one thing an automated fixer must never do to someone's repo.
|
|
8
12
|
*/
|
|
9
13
|
export async function openFixPr(repoRoot, findings, url) {
|
|
10
14
|
const healed = findings.filter((f) => f.heal?.status === "healed");
|
|
@@ -30,9 +34,15 @@ export async function openFixPr(repoRoot, findings, url) {
|
|
|
30
34
|
"",
|
|
31
35
|
"Each fix was gated (AST safety), compiled, run against the test suite, and replayed against the repro in an isolated worktree before this PR.",
|
|
32
36
|
].join("\n");
|
|
37
|
+
// The authoritative path of what was written is `heal.filePath` — the same
|
|
38
|
+
// field `applyVerifiedPatches` resolves and writes through.
|
|
39
|
+
const files = [...new Set(healed.map((f) => f.heal?.filePath).filter((p) => !!p))];
|
|
40
|
+
if (files.length === 0) {
|
|
41
|
+
return { ok: false, error: "no staged files recorded for the verified fixes" };
|
|
42
|
+
}
|
|
33
43
|
try {
|
|
34
44
|
await exec("git", ["-C", repoRoot, "checkout", "-b", branch]);
|
|
35
|
-
await exec("git", ["-C", repoRoot, "add", "
|
|
45
|
+
await exec("git", ["-C", repoRoot, "add", "--", ...files]);
|
|
36
46
|
await exec("git", ["-C", repoRoot, "commit", "-m", title]);
|
|
37
47
|
}
|
|
38
48
|
catch (e) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { EventBus } from "./eventBus.js";
|
|
3
|
+
export interface FuzzOptions {
|
|
4
|
+
seed?: number;
|
|
5
|
+
maxActions?: number;
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
/** Opt-in: include controls the deny-list skips (delete/pay/logout/checkout…).
|
|
8
|
+
* Off by default — these can mutate real state. */
|
|
9
|
+
allowDestructive?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface FuzzResult {
|
|
12
|
+
actions: number;
|
|
13
|
+
/** JS code ranges executed for the first time during this run — a coverage
|
|
14
|
+
* signal that this pass explored new code, not just re-clicked known paths. */
|
|
15
|
+
newCoverage: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* F5 — chaos fuzzer with coverage guidance. A seeded random walk (click/hover/
|
|
19
|
+
* keypress/select/garbage input) that also reads V8 JS coverage and biases its
|
|
20
|
+
* picks toward elements whose action previously uncovered new code — the same
|
|
21
|
+
* "prefer the input that reaches new code" idea behind libFuzzer/AFL, applied to
|
|
22
|
+
* the DOM. Returns how many brand-new JS ranges this pass executed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fuzz(page: Page, bus: EventBus, opts?: FuzzOptions): Promise<FuzzResult>;
|
package/dist/core/fuzzer.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function fuzz(page, bus, opts = {}) {
|
|
|
145
145
|
const known = actionable.filter((a) => a.label && interesting.has(a.label));
|
|
146
146
|
const chosen = known.length > 0 && rnd() < 0.5 ? pick(rnd, known) : pick(rnd, actionable);
|
|
147
147
|
const { handle, tag, label } = chosen;
|
|
148
|
-
const selectors = await selectorCascade(
|
|
148
|
+
const selectors = await selectorCascade(handle);
|
|
149
149
|
const roll = rnd();
|
|
150
150
|
let didAct = true;
|
|
151
151
|
if (tag === "input" || tag === "textarea") {
|
|
@@ -0,0 +1,28 @@
|
|
|
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 type { Finding } from "../types.js";
|
|
16
|
+
export interface AppliedPatch {
|
|
17
|
+
filePath: string;
|
|
18
|
+
hunkCount: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ApplyConflict {
|
|
21
|
+
filePath: string;
|
|
22
|
+
error: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ApplySummary {
|
|
25
|
+
applied: AppliedPatch[];
|
|
26
|
+
conflicts: ApplyConflict[];
|
|
27
|
+
}
|
|
28
|
+
export declare function applyVerifiedPatches(repoRoot: string, findings: Finding[]): ApplySummary;
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
* The same machinery serves zero-config runs (`aztrx` with no URL — see
|
|
10
|
+
* `src/core/devServer.ts`), which boot the user's own app instead of patched
|
|
11
|
+
* worktree code. The two callers differ only in environment isolation and port
|
|
12
|
+
* choice, which is what the `env` and `port` options below select.
|
|
13
|
+
*/
|
|
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 declare function detectStartCommand(repoRoot: string): string | null;
|
|
18
|
+
/** Is anything listening on this loopback port?
|
|
19
|
+
*
|
|
20
|
+
* Connect-based rather than bind-based on purpose: on Windows `SO_REUSEADDR`
|
|
21
|
+
* lets a socket bind a port another process already holds, so a successful
|
|
22
|
+
* `listen()` proves nothing there. A refused connection is the only reliable
|
|
23
|
+
* "nobody home" signal across platforms. */
|
|
24
|
+
export declare function isPortFree(port: number): Promise<boolean>;
|
|
25
|
+
/** Signal a process and everything below it to stop.
|
|
26
|
+
*
|
|
27
|
+
* The process we spawned is rarely the only one that matters: `shell: true`
|
|
28
|
+
* puts a cmd.exe/sh in between, and a dev server or a browser launch leaves
|
|
29
|
+
* grandchildren of its own. Killing only the direct child orphans them, which
|
|
30
|
+
* shows up later as a taken port or a stray Chromium.
|
|
31
|
+
*
|
|
32
|
+
* Callers must spawn with `detached: process.platform !== "win32"` so that
|
|
33
|
+
* POSIX can signal the whole process group; Windows has no group signalling and
|
|
34
|
+
* uses `taskkill /T` instead. */
|
|
35
|
+
export declare function killTreeSync(pid: number): void;
|
|
36
|
+
/** The awaiting form, for callers with an async shutdown path. The plugins use
|
|
37
|
+
* `killTreeSync` directly because their cleanup also runs from
|
|
38
|
+
* `process.on("exit")`, where promises never settle.
|
|
39
|
+
*
|
|
40
|
+
* This one does not return until the group is actually gone. `killTreeSync` only
|
|
41
|
+
* *sends* the signal, and the two platforms differ in what that guarantees:
|
|
42
|
+
* `taskkill /F` blocks until the process is dead, but POSIX SIGTERM is delivered
|
|
43
|
+
* asynchronously, so a synchronous kill leaves the server holding its listening
|
|
44
|
+
* socket for a few more milliseconds. Callers treat `close()` as "the port is
|
|
45
|
+
* free now" — the plugins and `patrol` re-bind the same port on restart — so
|
|
46
|
+
* returning early hands them a port that is still taken.
|
|
47
|
+
*
|
|
48
|
+
* Liveness is checked on the group (`-pid`) rather than the pid, because the
|
|
49
|
+
* process we spawned is a shell: `shell: true` means the socket is usually held
|
|
50
|
+
* by a grandchild, and only the group covers it. */
|
|
51
|
+
export declare function killTree(pid: number): Promise<void>;
|
|
52
|
+
export interface BootedServer {
|
|
53
|
+
url: string;
|
|
54
|
+
close: () => Promise<void>;
|
|
55
|
+
/** Tail of the server's stdout/stderr, for timeout/error reporting. */
|
|
56
|
+
logs: () => string;
|
|
57
|
+
}
|
|
58
|
+
export declare function bootServer(opts: {
|
|
59
|
+
worktreeDir: string;
|
|
60
|
+
repoRoot: string;
|
|
61
|
+
startCommand: string;
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
/** Preferred port. Honoured only when it is genuinely free — otherwise a free
|
|
64
|
+
* one is allocated. A dev server on its framework's usual port is more
|
|
65
|
+
* faithful (absolute URLs, redirects, CORS origins), so the zero-config path
|
|
66
|
+
* asks for it; heal wants isolation and leaves it unset. */
|
|
67
|
+
port?: number;
|
|
68
|
+
/** `isolated` (default) strips the environment to a minimal allow-list so the
|
|
69
|
+
* booted process — untrusted PR code in the heal sandbox — never sees the
|
|
70
|
+
* caller's secrets. `inherit` passes this process's environment through,
|
|
71
|
+
* which is what booting the user's *own* app on their *own* machine needs:
|
|
72
|
+
* a stripped env breaks anything reading `DATABASE_URL` and friends. */
|
|
73
|
+
env?: "isolated" | "inherit";
|
|
74
|
+
}): Promise<BootedServer>;
|
package/dist/core/heal/boot.js
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
* the replay, this boots the patched server inside the worktree on a free port,
|
|
6
6
|
* waits for an HTTP readiness signal, and returns a `close` hook that tree-kills
|
|
7
7
|
* the process (and its children) so nothing is left holding the port.
|
|
8
|
+
*
|
|
9
|
+
* The same machinery serves zero-config runs (`aztrx` with no URL — see
|
|
10
|
+
* `src/core/devServer.ts`), which boot the user's own app instead of patched
|
|
11
|
+
* worktree code. The two callers differ only in environment isolation and port
|
|
12
|
+
* choice, which is what the `env` and `port` options below select.
|
|
8
13
|
*/
|
|
9
|
-
import { spawn } from "child_process";
|
|
14
|
+
import { spawn, spawnSync } from "child_process";
|
|
10
15
|
import * as fs from "fs";
|
|
11
16
|
import * as net from "net";
|
|
12
17
|
import * as path from "path";
|
|
@@ -41,9 +46,121 @@ function freePort() {
|
|
|
41
46
|
});
|
|
42
47
|
});
|
|
43
48
|
}
|
|
49
|
+
/** Is anything listening on this loopback port?
|
|
50
|
+
*
|
|
51
|
+
* Connect-based rather than bind-based on purpose: on Windows `SO_REUSEADDR`
|
|
52
|
+
* lets a socket bind a port another process already holds, so a successful
|
|
53
|
+
* `listen()` proves nothing there. A refused connection is the only reliable
|
|
54
|
+
* "nobody home" signal across platforms. */
|
|
55
|
+
export function isPortFree(port) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const sock = net.connect({ port, host: "127.0.0.1" });
|
|
58
|
+
const finish = (free) => {
|
|
59
|
+
sock.destroy();
|
|
60
|
+
resolve(free);
|
|
61
|
+
};
|
|
62
|
+
sock.setTimeout(1000);
|
|
63
|
+
sock.once("connect", () => finish(false));
|
|
64
|
+
// A listener that accepts but never speaks is still a listener — treat a
|
|
65
|
+
// timeout as taken, so the caller allocates a different port rather than
|
|
66
|
+
// colliding with whatever is there.
|
|
67
|
+
sock.once("timeout", () => finish(false));
|
|
68
|
+
sock.once("error", () => finish(true));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Signal a process and everything below it to stop.
|
|
72
|
+
*
|
|
73
|
+
* The process we spawned is rarely the only one that matters: `shell: true`
|
|
74
|
+
* puts a cmd.exe/sh in between, and a dev server or a browser launch leaves
|
|
75
|
+
* grandchildren of its own. Killing only the direct child orphans them, which
|
|
76
|
+
* shows up later as a taken port or a stray Chromium.
|
|
77
|
+
*
|
|
78
|
+
* Callers must spawn with `detached: process.platform !== "win32"` so that
|
|
79
|
+
* POSIX can signal the whole process group; Windows has no group signalling and
|
|
80
|
+
* uses `taskkill /T` instead. */
|
|
81
|
+
export function killTreeSync(pid) {
|
|
82
|
+
if (process.platform === "win32") {
|
|
83
|
+
try {
|
|
84
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// already exited, or no such process
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(-pid, "SIGTERM");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// already exited
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** How long to let a signalled process group exit before escalating, and how
|
|
100
|
+
* long to wait after SIGKILL — which cannot be caught, so this is a backstop. */
|
|
101
|
+
const TERM_GRACE_MS = 3000;
|
|
102
|
+
const KILL_GRACE_MS = 2000;
|
|
103
|
+
const POLL_MS = 25;
|
|
104
|
+
/** Is this pid — or, given a negative pid, this whole process group — alive?
|
|
105
|
+
* Signal 0 delivers nothing; it only asks the kernel whether the target exists. */
|
|
106
|
+
function isAlive(target) {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(target, 0);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
// Only ESRCH means "no such process". EPERM means it exists but is not ours
|
|
113
|
+
// to signal — still alive, and treating it as gone would cut the wait short.
|
|
114
|
+
return e.code !== "ESRCH";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function waitUntilGone(target, timeoutMs) {
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
while (isAlive(target)) {
|
|
120
|
+
if (Date.now() >= deadline)
|
|
121
|
+
return false;
|
|
122
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** The awaiting form, for callers with an async shutdown path. The plugins use
|
|
127
|
+
* `killTreeSync` directly because their cleanup also runs from
|
|
128
|
+
* `process.on("exit")`, where promises never settle.
|
|
129
|
+
*
|
|
130
|
+
* This one does not return until the group is actually gone. `killTreeSync` only
|
|
131
|
+
* *sends* the signal, and the two platforms differ in what that guarantees:
|
|
132
|
+
* `taskkill /F` blocks until the process is dead, but POSIX SIGTERM is delivered
|
|
133
|
+
* asynchronously, so a synchronous kill leaves the server holding its listening
|
|
134
|
+
* socket for a few more milliseconds. Callers treat `close()` as "the port is
|
|
135
|
+
* free now" — the plugins and `patrol` re-bind the same port on restart — so
|
|
136
|
+
* returning early hands them a port that is still taken.
|
|
137
|
+
*
|
|
138
|
+
* Liveness is checked on the group (`-pid`) rather than the pid, because the
|
|
139
|
+
* process we spawned is a shell: `shell: true` means the socket is usually held
|
|
140
|
+
* by a grandchild, and only the group covers it. */
|
|
141
|
+
export async function killTree(pid) {
|
|
142
|
+
killTreeSync(pid);
|
|
143
|
+
// Windows has no group signalling and `killTreeSync` already blocked on the
|
|
144
|
+
// forced kill, so there is nothing left to wait for.
|
|
145
|
+
if (process.platform === "win32")
|
|
146
|
+
return;
|
|
147
|
+
const group = -pid;
|
|
148
|
+
if (await waitUntilGone(group, TERM_GRACE_MS))
|
|
149
|
+
return;
|
|
150
|
+
// Still alive after the grace period: a child that ignores SIGTERM. SIGKILL
|
|
151
|
+
// cannot be caught or blocked, so this is the end of the line.
|
|
152
|
+
try {
|
|
153
|
+
process.kill(group, "SIGKILL");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// exited between the check and the signal
|
|
157
|
+
}
|
|
158
|
+
await waitUntilGone(group, KILL_GRACE_MS);
|
|
159
|
+
}
|
|
44
160
|
export async function bootServer(opts) {
|
|
45
161
|
const { worktreeDir, repoRoot, startCommand } = opts;
|
|
46
162
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
163
|
+
const envMode = opts.env ?? "isolated";
|
|
47
164
|
// A fresh worktree has no node_modules — symlink the root's so the booted
|
|
48
165
|
// server resolves its dependencies (the same junction trick sandbox.ts uses).
|
|
49
166
|
const rootNodeModules = path.join(repoRoot, "node_modules");
|
|
@@ -56,7 +173,7 @@ export async function bootServer(opts) {
|
|
|
56
173
|
/* resolution errors surface in the readiness timeout below */
|
|
57
174
|
}
|
|
58
175
|
}
|
|
59
|
-
const port = await freePort();
|
|
176
|
+
const port = opts.port !== undefined && (await isPortFree(opts.port)) ? opts.port : await freePort();
|
|
60
177
|
// Support scripts that hardcode a port via `-p {port}`; `PORT` is also set in
|
|
61
178
|
// the environment for the (more common) scripts that read `process.env.PORT`.
|
|
62
179
|
const command = startCommand.replace(/\{port\}/g, String(port));
|
|
@@ -76,8 +193,12 @@ export async function bootServer(opts) {
|
|
|
76
193
|
shell: true,
|
|
77
194
|
cwd: worktreeDir,
|
|
78
195
|
// 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
|
-
|
|
196
|
+
// the caller's ANTHROPIC_API_KEY or other CI secrets. `inherit` (the
|
|
197
|
+
// zero-config path) boots the user's own app, so it gets the real env —
|
|
198
|
+
// minus the forced `CI`, which changes how dev servers behave.
|
|
199
|
+
env: envMode === "isolated"
|
|
200
|
+
? buildChildEnv({ PORT: String(port), CI: "true" })
|
|
201
|
+
: { ...process.env, PORT: String(port) },
|
|
81
202
|
// On POSIX, detach so the server + its children form their own process
|
|
82
203
|
// group — close() can then signal the whole group. Windows can't do group
|
|
83
204
|
// signaling; it relies on `taskkill /T` below instead.
|
|
@@ -91,25 +212,7 @@ export async function bootServer(opts) {
|
|
|
91
212
|
if (closed || !child.pid)
|
|
92
213
|
return;
|
|
93
214
|
closed = true;
|
|
94
|
-
|
|
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
|
-
}
|
|
215
|
+
await killTree(child.pid);
|
|
113
216
|
};
|
|
114
217
|
// Readiness: poll until the server answers with *any* HTTP response (2xx/4xx/
|
|
115
218
|
// 5xx all mean "the listener is up"). A still-compiling dev server (Next) may
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process environment isolation. The heal sandbox runs the project's own
|
|
3
|
+
* test suite, `tsc`, and dev server — which means it executes untrusted PR code.
|
|
4
|
+
* Handing that code the full `process.env` would leak the caller's secrets
|
|
5
|
+
* (ANTHROPIC_API_KEY, GH_TOKEN, NPM_TOKEN, AWS_*, …) into a process the PR author
|
|
6
|
+
* controls. Children therefore get only a minimal allow-list of harmless,
|
|
7
|
+
* platform-essential variables.
|
|
8
|
+
*
|
|
9
|
+
* Escape hatch: a caller whose dev server legitimately needs app config (e.g.
|
|
10
|
+
* `DATABASE_URL`) can pass it through by naming it in `AZTRX_ENV_ALLOW`
|
|
11
|
+
* (comma-separated) — without re-opening the leak for every secret in the env.
|
|
12
|
+
*/
|
|
13
|
+
/** Build a minimal child environment: harmless platform vars, anything named in
|
|
14
|
+
* `AZTRX_ENV_ALLOW`, and any caller-supplied `extra` (e.g. `{ PORT: "4317" }`). */
|
|
15
|
+
export declare function buildChildEnv(extra?: Record<string, string>): Record<string, string>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 gate #2 — AST safety gates. A model-generated patch is never applied
|
|
3
|
+
* blindly: the patched file is re-parsed and rejected if it smuggles in a new
|
|
4
|
+
* import, dynamic code execution, child-process access, or an empty catch that
|
|
5
|
+
* would swallow the very error we're trying to surface. JavaScript/TypeScript
|
|
6
|
+
* use the TypeScript compiler's AST; HTML/Vue/Svelte are audited by extracting
|
|
7
|
+
* inline <script> blocks and running the same checks on each.
|
|
8
|
+
*/
|
|
9
|
+
import type { GateResult } from "./types.js";
|
|
10
|
+
/** Audit a patch by parsing the resulting file. `filePath` selects the parser. */
|
|
11
|
+
export declare function auditPatch(original: string, patched: string, filePath: string): GateResult;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F10 — closed-loop healing. Orchestrates the full loop, in order:
|
|
3
|
+
*
|
|
4
|
+
* 1. redact — strip secrets from anything that will leave the machine
|
|
5
|
+
* 2. generate — ask an LLM for a Search & Replace diff (against the redacted
|
|
6
|
+
* file, unredacted before it touches the raw bytes)
|
|
7
|
+
* 3. gate — re-parse the patched file; reject new imports / eval /
|
|
8
|
+
* child_process / empty catch
|
|
9
|
+
* 4. sandbox — apply the patch in a detached git worktree, never the tree
|
|
10
|
+
* 5. test — run the repo's own test suite; reject the patch if it goes red
|
|
11
|
+
* 6. verify — replay the repro against the patched app; the bug must be gone
|
|
12
|
+
* 7. hand off — write a unified-diff `.patch` for a human to review and commit
|
|
13
|
+
*
|
|
14
|
+
* Aztrx never commits. A patch that fails any gate, does not apply exactly, or
|
|
15
|
+
* still reproduces the bug is rejected and reported, not silently kept.
|
|
16
|
+
*/
|
|
17
|
+
import type { Finding } from "../types.js";
|
|
18
|
+
import type { HealOptions, HealResult } from "./types.js";
|
|
19
|
+
export declare function heal(finding: Finding, opts: HealOptions): Promise<HealResult>;
|
package/dist/core/heal/index.js
CHANGED
|
@@ -36,9 +36,17 @@ const MIME = {
|
|
|
36
36
|
".jpg": "image/jpeg",
|
|
37
37
|
".ico": "image/x-icon",
|
|
38
38
|
};
|
|
39
|
+
/** Can this file be verification-served as-is? Only a self-contained HTML
|
|
40
|
+
* document. Static serving hands back raw bytes with no build step, so a `.tsx`
|
|
41
|
+
* arrives as text the browser will not execute — the code under test never runs
|
|
42
|
+
* and the crash cannot reproduce. Fixtures qualify; app sources never do. */
|
|
43
|
+
function isStaticEntry(filePath) {
|
|
44
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
45
|
+
return ext === ".html" || ext === ".htm";
|
|
46
|
+
}
|
|
39
47
|
/** Default verification server: serve the worktree's repo root statically and
|
|
40
|
-
* address the mapped file directly. Works for static fixtures; real apps
|
|
41
|
-
*
|
|
48
|
+
* address the mapped file directly. Works for static fixtures; real apps boot
|
|
49
|
+
* the patched worktree instead (see the serve selection in `heal`). */
|
|
42
50
|
function staticServe(worktreeDir, filePath) {
|
|
43
51
|
const entry = filePath.split(path.sep).join("/");
|
|
44
52
|
return new Promise((resolve, reject) => {
|
|
@@ -97,18 +105,29 @@ export async function heal(finding, opts) {
|
|
|
97
105
|
if (!finding.repro || finding.repro.verdict === "unreliable") {
|
|
98
106
|
return { ...base, error: "no deterministic repro to verify against" };
|
|
99
107
|
}
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
108
|
+
// Verification has to run the *patched* code, and there are only two ways to
|
|
109
|
+
// do that. Booting the worktree runs the real app, but needs a start command.
|
|
110
|
+
// Static serving runs anything, but with no build step and no bundler — so a
|
|
111
|
+
// `.tsx` comes back as text the browser will not execute, the patched code
|
|
112
|
+
// never runs, and the crash "not reproducing" means nothing. Only a
|
|
113
|
+
// self-contained HTML document is honest to static-serve (the fixture path).
|
|
114
|
+
//
|
|
115
|
+
// So: an injected serve hook wins, then booting, then static — and when none
|
|
116
|
+
// applies we refuse. Both checks run before paying the LLM, so an unverifiable
|
|
117
|
+
// finding skips cleanly instead of after an expensive generation.
|
|
103
118
|
const isNetwork = finding.type === "network_5xx";
|
|
104
119
|
const startCommand = opts.startCommand ?? detectStartCommand(opts.repoRoot);
|
|
105
|
-
if (isNetwork && !startCommand) {
|
|
106
|
-
return {
|
|
107
|
-
...base,
|
|
108
|
-
error: "no start command for server heal (set --start-command, or add scripts.dev / scripts.start)",
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
120
|
const filePath = loc.filePath;
|
|
121
|
+
if (!opts.serve && !startCommand) {
|
|
122
|
+
if (isNetwork || !isStaticEntry(filePath)) {
|
|
123
|
+
return {
|
|
124
|
+
...base,
|
|
125
|
+
error: isNetwork
|
|
126
|
+
? "no start command for server heal (set --start-command, or add scripts.dev / scripts.start)"
|
|
127
|
+
: `cannot verify a patch to ${filePath} without running it — a statically served source file never executes, so "fixed" would be unprovable. Set --start-command, or add scripts.dev / scripts.start to package.json`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
112
131
|
const absPath = path.resolve(opts.repoRoot, filePath);
|
|
113
132
|
let original;
|
|
114
133
|
try {
|
|
@@ -131,12 +150,18 @@ export async function heal(finding, opts) {
|
|
|
131
150
|
};
|
|
132
151
|
}
|
|
133
152
|
// 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.
|
|
153
|
+
// then Sonnet. An injected patchFn collapses to a single tier. Paid tiers are
|
|
154
|
+
// enqueued only when a key is actually configured — otherwise the last tier
|
|
155
|
+
// throws "…_API_KEY is not set" and overwrites the specific failure the free
|
|
156
|
+
// rule tier already reported (test-failed, compile-failed, …), telling the
|
|
157
|
+
// user to add a key when the real problem was something else entirely.
|
|
135
158
|
const tiers = [
|
|
136
159
|
...(rulePatch ? [{ model: RULE_TIER, label: "fast" }] : []),
|
|
137
160
|
...(opts.patchFn
|
|
138
161
|
? [{ model: opts.model ?? "default", label: "sonnet" }]
|
|
139
|
-
:
|
|
162
|
+
: hasLlmKey()
|
|
163
|
+
? modelTiers(opts.model, opts.fastModel)
|
|
164
|
+
: []),
|
|
140
165
|
];
|
|
141
166
|
const wt = await createWorktree(opts.repoRoot, finding.id);
|
|
142
167
|
// The winning (or last) patch + verification, held back for the final save.
|
|
@@ -158,10 +183,14 @@ export async function heal(finding, opts) {
|
|
|
158
183
|
}
|
|
159
184
|
catch (e) {
|
|
160
185
|
// A spent session budget stops everything paid; a transport/config failure
|
|
161
|
-
// won't be fixed by a pricier tier either, so both break out here.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
// won't be fixed by a pricier tier either, so both break out here. Keep an
|
|
187
|
+
// earlier tier's gate failure (test-failed, unfixed, …) — it names a real
|
|
188
|
+
// problem the user can act on, where "no key" would mask it.
|
|
189
|
+
if (e instanceof BudgetExhaustedError || last.status === base.status) {
|
|
190
|
+
last = e instanceof BudgetExhaustedError
|
|
191
|
+
? { ...base, status: "budget-exhausted", error: e.message, model: tier.model }
|
|
192
|
+
: { ...base, status: "no-llm", error: e.message, model: tier.model };
|
|
193
|
+
}
|
|
165
194
|
break;
|
|
166
195
|
}
|
|
167
196
|
if (patch.hunks.length === 0) {
|
|
@@ -253,12 +282,13 @@ export async function heal(finding, opts) {
|
|
|
253
282
|
};
|
|
254
283
|
continue;
|
|
255
284
|
}
|
|
256
|
-
// 4. Verify — the bug must stop reproducing
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
const serve =
|
|
260
|
-
|
|
261
|
-
|
|
285
|
+
// 4. Verify — the bug must stop reproducing, against the *patched* code.
|
|
286
|
+
// Mirror the selection made before generation: an injected hook wins, then
|
|
287
|
+
// booting the worktree, then static serving for a real HTML entry.
|
|
288
|
+
const serve = opts.serve ??
|
|
289
|
+
(startCommand
|
|
290
|
+
? (dir) => bootServer({ worktreeDir: dir, repoRoot: opts.repoRoot, startCommand })
|
|
291
|
+
: (dir, fp) => staticServe(dir, fp));
|
|
262
292
|
const v = await verifyFix({
|
|
263
293
|
url: opts.url,
|
|
264
294
|
actions: opts.actions,
|