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.
Files changed (102) hide show
  1. package/README.md +326 -29
  2. package/dist/cli/help.d.ts +22 -0
  3. package/dist/cli/repo.d.ts +20 -0
  4. package/dist/cli/repo.js +50 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +284 -86
  7. package/dist/core/auth.d.ts +33 -0
  8. package/dist/core/badge.d.ts +9 -0
  9. package/dist/core/browser.d.ts +3 -0
  10. package/dist/core/classifier.d.ts +41 -0
  11. package/dist/core/cloud/index.d.ts +62 -0
  12. package/dist/core/cloud/index.js +24 -4
  13. package/dist/core/devServer.d.ts +90 -0
  14. package/dist/core/devServer.js +253 -0
  15. package/dist/core/diagnose.d.ts +18 -0
  16. package/dist/core/diff.d.ts +31 -0
  17. package/dist/core/domWalker.d.ts +24 -0
  18. package/dist/core/domWalker.js +18 -2
  19. package/dist/core/eventBus.d.ts +59 -0
  20. package/dist/core/events.d.ts +42 -0
  21. package/dist/core/fixPr.d.ts +16 -0
  22. package/dist/core/fixPr.js +11 -1
  23. package/dist/core/fuzzer.d.ts +24 -0
  24. package/dist/core/fuzzer.js +1 -1
  25. package/dist/core/heal/apply.d.ts +28 -0
  26. package/dist/core/heal/boot.d.ts +74 -0
  27. package/dist/core/heal/boot.js +126 -23
  28. package/dist/core/heal/childEnv.d.ts +15 -0
  29. package/dist/core/heal/gates.d.ts +11 -0
  30. package/dist/core/heal/index.d.ts +19 -0
  31. package/dist/core/heal/index.js +53 -23
  32. package/dist/core/heal/llm.d.ts +43 -0
  33. package/dist/core/heal/llm.js +22 -2
  34. package/dist/core/heal/redact.d.ts +22 -0
  35. package/dist/core/heal/sandbox.d.ts +54 -0
  36. package/dist/core/heal/sandbox.js +63 -3
  37. package/dist/core/heal/types.d.ts +102 -0
  38. package/dist/core/heal/verify.d.ts +37 -0
  39. package/dist/core/heal/verify.js +28 -14
  40. package/dist/core/httpFuzzer.d.ts +33 -0
  41. package/dist/core/init.d.ts +21 -0
  42. package/dist/core/init.js +5 -6
  43. package/dist/core/interceptor.d.ts +21 -0
  44. package/dist/core/llm.d.ts +36 -0
  45. package/dist/core/llm.js +40 -13
  46. package/dist/core/minimizer.d.ts +13 -0
  47. package/dist/core/modernize.d.ts +28 -0
  48. package/dist/core/modernize.js +2 -2
  49. package/dist/core/networkGuard.d.ts +13 -0
  50. package/dist/core/orchestrator.d.ts +86 -0
  51. package/dist/core/orchestrator.js +5 -2
  52. package/dist/core/patrol/loop.d.ts +46 -0
  53. package/dist/core/patrol/pr.d.ts +42 -0
  54. package/dist/core/patrol/pr.js +9 -6
  55. package/dist/core/patrol/record.d.ts +51 -0
  56. package/dist/core/patrol/state.d.ts +39 -0
  57. package/dist/core/pr.d.ts +3 -0
  58. package/dist/core/pr.js +15 -11
  59. package/dist/core/prompt.d.ts +14 -0
  60. package/dist/core/recorder.d.ts +23 -0
  61. package/dist/core/recorder.js +1 -1
  62. package/dist/core/renderMarkdown.d.ts +7 -0
  63. package/dist/core/replay.d.ts +33 -0
  64. package/dist/core/replay.js +52 -7
  65. package/dist/core/report.d.ts +8 -0
  66. package/dist/core/resolver.d.ts +64 -0
  67. package/dist/core/resolver.js +135 -17
  68. package/dist/core/rng.d.ts +2 -0
  69. package/dist/core/specCompiler.d.ts +17 -0
  70. package/dist/core/studio.d.ts +6 -0
  71. package/dist/core/studio.js +0 -3
  72. package/dist/core/summarize.d.ts +26 -0
  73. package/dist/core/summarize.js +17 -6
  74. package/dist/core/swarm.d.ts +97 -0
  75. package/dist/core/swarm.js +25 -2
  76. package/dist/core/telemetry/index.d.ts +33 -0
  77. package/dist/core/telemetry/index.js +22 -4
  78. package/dist/core/telemetry/sanitize.d.ts +19 -0
  79. package/dist/core/telemetry/types.d.ts +26 -0
  80. package/dist/core/types.d.ts +88 -0
  81. package/dist/core/ui.d.ts +23 -0
  82. package/dist/core/ui.js +0 -5
  83. package/dist/core/validator.d.ts +14 -0
  84. package/dist/core/version.d.ts +1 -0
  85. package/dist/hooks/index.d.ts +106 -0
  86. package/dist/hooks/index.js +349 -0
  87. package/dist/mcp/index.d.ts +58 -0
  88. package/dist/mcp/index.js +295 -0
  89. package/dist/mcp/install.d.ts +52 -0
  90. package/dist/mcp/install.js +204 -0
  91. package/dist/mcp/protocol.d.ts +106 -0
  92. package/dist/mcp/protocol.js +156 -0
  93. package/dist/mcp/tools.d.ts +107 -0
  94. package/dist/mcp/tools.js +621 -0
  95. package/dist/next/index.d.ts +70 -0
  96. package/dist/next/index.js +103 -0
  97. package/dist/plugins/scan.d.ts +85 -0
  98. package/dist/plugins/scan.js +188 -0
  99. package/dist/ui/app.d.ts +11 -0
  100. package/dist/vite/index.d.ts +60 -0
  101. package/dist/vite/index.js +56 -0
  102. 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>;
@@ -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", "-A"]);
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>;
@@ -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(page, handle);
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>;
@@ -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
- env: buildChildEnv({ PORT: String(port), CI: "true" }),
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
- 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
- }
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>;
@@ -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 inject
41
- * their own dev-server `serve` fn. */
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
- // Server findings (network_5xx) verify by *booting* the patched app, not static
101
- // serving. That needs a start command resolve it before paying the LLM so a
102
- // missing one skips cleanly rather than after an expensive generation.
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
- : modelTiers(opts.model, opts.fastModel)),
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
- last = e instanceof BudgetExhaustedError
163
- ? { ...base, status: "budget-exhausted", error: e.message, model: tier.model }
164
- : { ...base, status: "no-llm", error: e.message, model: tier.model };
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. Server findings boot the
257
- // patched app in the worktree (static serving can't run a server); client
258
- // findings keep the static server.
259
- const serve = isNetwork
260
- ? (dir) => bootServer({ worktreeDir: dir, repoRoot: opts.repoRoot, startCommand: startCommand })
261
- : opts.serve ?? ((dir, fp) => staticServe(dir, fp));
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,