argus-reviewer-e2e 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +84 -71
  2. package/action/action.yml +129 -11
  3. package/action/approval-review.mjs +13 -3
  4. package/action/bootstrap.mjs +2 -0
  5. package/action/emit-review.mjs +16 -0
  6. package/action/runtime.mjs +20 -0
  7. package/action/sticky-comment.cjs +1260 -479
  8. package/dist/cli.d.ts +103 -7
  9. package/dist/cli.js +1202 -186
  10. package/dist/config.d.ts +96 -11
  11. package/dist/config.js +102 -4
  12. package/dist/detect.d.ts +29 -2
  13. package/dist/detect.js +98 -7
  14. package/dist/driver/browser.d.ts +32 -0
  15. package/dist/driver/browser.js +56 -1
  16. package/dist/driver/target.d.ts +4 -1
  17. package/dist/driver/target.js +27 -6
  18. package/dist/engine/actions.d.ts +5 -0
  19. package/dist/engine/actions.js +8 -0
  20. package/dist/engine/explore.d.ts +78 -0
  21. package/dist/engine/explore.js +373 -0
  22. package/dist/engine/loop.d.ts +2 -2
  23. package/dist/engine/loop.js +8 -8
  24. package/dist/engine/prompts.d.ts +28 -1
  25. package/dist/engine/prompts.js +88 -0
  26. package/dist/evidence/ci.d.ts +13 -1
  27. package/dist/evidence/ci.js +38 -3
  28. package/dist/evidence/gate.d.ts +8 -0
  29. package/dist/evidence/gate.js +1 -1
  30. package/dist/evidence/link.js +1 -1
  31. package/dist/executor/a0.d.ts +114 -1
  32. package/dist/executor/a0.js +216 -4
  33. package/dist/fsutil.d.ts +3 -2
  34. package/dist/fsutil.js +7 -4
  35. package/dist/journal/schema.d.ts +1 -1
  36. package/dist/log.d.ts +2 -1
  37. package/dist/log.js +10 -2
  38. package/dist/mention.d.ts +45 -0
  39. package/dist/mention.js +107 -0
  40. package/dist/pipeline/app.d.ts +126 -0
  41. package/dist/pipeline/app.js +250 -0
  42. package/dist/pipeline/budget.d.ts +1 -0
  43. package/dist/pipeline/budget.js +1 -1
  44. package/dist/pipeline/verify.d.ts +20 -3
  45. package/dist/pipeline/verify.js +189 -35
  46. package/dist/probe/persist.d.ts +68 -0
  47. package/dist/probe/persist.js +184 -0
  48. package/dist/probe/queue.d.ts +12 -0
  49. package/dist/probe/queue.js +10 -2
  50. package/dist/report/brand-assets.generated.d.ts +9 -0
  51. package/dist/report/brand-assets.generated.js +8 -0
  52. package/dist/report/comment.d.ts +99 -6
  53. package/dist/report/comment.js +292 -103
  54. package/dist/report/html.d.ts +50 -0
  55. package/dist/report/html.js +879 -0
  56. package/dist/report/manifest.d.ts +29 -0
  57. package/dist/report/manifest.js +37 -0
  58. package/dist/report/run.d.ts +54 -1
  59. package/dist/report/run.js +34 -9
  60. package/dist/report/viewmodel.d.ts +91 -0
  61. package/dist/report/viewmodel.js +241 -0
  62. package/dist/review/adjudicate.d.ts +6 -6
  63. package/dist/review/adjudicate.js +2 -2
  64. package/dist/review/inline.d.ts +44 -0
  65. package/dist/review/inline.js +95 -0
  66. package/dist/review/packs.d.ts +21 -0
  67. package/dist/review/packs.js +47 -0
  68. package/dist/review/scope.d.ts +16 -0
  69. package/dist/review/scope.js +74 -0
  70. package/dist/review/secrets.d.ts +10 -10
  71. package/dist/review/secrets.js +7 -7
  72. package/dist/review/testfiles.d.ts +18 -0
  73. package/dist/review/testfiles.js +26 -0
  74. package/dist/review/triage.d.ts +1 -1
  75. package/dist/review/triage.js +10 -10
  76. package/dist/review/validate.d.ts +41 -0
  77. package/dist/review/validate.js +76 -0
  78. package/dist/ui/errors.d.ts +54 -0
  79. package/dist/ui/errors.js +236 -0
  80. package/dist/ui/style.d.ts +34 -0
  81. package/dist/ui/style.js +48 -0
  82. package/dist/ui/summary.d.ts +38 -0
  83. package/dist/ui/summary.js +101 -0
  84. package/dist/vision/cost.d.ts +1 -1
  85. package/dist/vision/decisions.d.ts +9 -3
  86. package/dist/vision/decisions.js +31 -21
  87. package/dist/vision/openrouter.d.ts +4 -0
  88. package/dist/vision/openrouter.js +30 -4
  89. package/package.json +11 -2
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ReviewProfile } from './review/packs.js';
1
2
  import type { Trust } from './trust.js';
2
3
  export interface ProviderRules {
3
4
  only?: string[];
@@ -46,6 +47,51 @@ export interface Sandbox {
46
47
  */
47
48
  allowForks: boolean;
48
49
  }
50
+ /**
51
+ * Exploratory lane (roadmap E2.U4): free runtime capture today
52
+ * (console/pageerror/failed-request taps render as `observed` findings),
53
+ * bounded act policy later. Opt-in — `enabled` defaults to false. On
54
+ * untrusted checkouts the whole block is stripped by the config allowlist.
55
+ */
56
+ export interface Explore {
57
+ /** Master switch for capture + act. Default false. */
58
+ enabled: boolean;
59
+ /** Step cap for the exploratory act policy (U4b). Default 20. */
60
+ maxSteps: number;
61
+ /** Model budget for the act policy (U4b). Unset = bounded by run budget. */
62
+ budgetUsd: number | undefined;
63
+ }
64
+ /**
65
+ * Verified expected state for `verify --app`: at least one of these must
66
+ * hold for the lane to pass — a page that merely loads is never a pass.
67
+ * All configured conditions are ANDed.
68
+ */
69
+ export interface AppExpectation {
70
+ /** Substring that must appear in the a11y tree (case-insensitive). */
71
+ text?: string;
72
+ /** Regex source the final page URL must match. */
73
+ url?: string;
74
+ /** Playwright/CSS selector that must resolve at least one node. */
75
+ selector?: string;
76
+ }
77
+ /**
78
+ * `verify --app` lane (follow-through U3): a bounded natural-language task
79
+ * run on the ExploreLoop substrate. Opt-in — the lane is selected by the
80
+ * `--app` flag and this block supplies the task contract. Without `task`
81
+ * and at least one `expected` marker the lane records `blocked`.
82
+ */
83
+ export interface AppLane {
84
+ /** Natural-language task the lane must accomplish. */
85
+ task: string | undefined;
86
+ /** Expected-state marker(s) the lane verifies deterministically. */
87
+ expected: AppExpectation | undefined;
88
+ /** Step cap for the task loop. Unset → explore.maxSteps. */
89
+ maxSteps: number | undefined;
90
+ /** Lane USD budget. Unset = bounded by run budget. */
91
+ budgetUsd: number | undefined;
92
+ /** Wall-clock cap in ms. Unset → lane default (120s). */
93
+ timeoutMs: number | undefined;
94
+ }
49
95
  export interface Config {
50
96
  model: string;
51
97
  escalation_model: string;
@@ -61,7 +107,7 @@ export interface Config {
61
107
  */
62
108
  code_model: string | undefined;
63
109
  /**
64
- * OpenRouter Decisions API model for typed adjudication (Jev). Defaults
110
+ * OpenRouter Decisions API model for typed adjudication (the confidence model). Defaults
65
111
  * to the pinned `typesafe/jev-1.13-20260917` — alias slugs like
66
112
  * `~typesafe/jev-latest` drift silently and thresholds are calibrated
67
113
  * to a version. Set to `''` to disable adjudication (regex-only mode).
@@ -80,6 +126,12 @@ export interface Config {
80
126
  testsDir: string | undefined;
81
127
  /** Directory for JUnit XML + JSON run report output. */
82
128
  reportDir: string | undefined;
129
+ /**
130
+ * How many `verify` run manifests the local history keeps
131
+ * (`<reportDir>/manifests/*.json`) — the dashboard/TUI run list reads it.
132
+ * `0` disables archival; unset defaults to 20 at write time.
133
+ */
134
+ reportRetention: number | undefined;
83
135
  /**
84
136
  * Named secrets for `td.type(name, { secret: true })`. The value is typed
85
137
  * locally and never sent to the model — the model only resolves the field.
@@ -140,13 +192,19 @@ export interface Config {
140
192
  recordStepCap: number | undefined;
141
193
  /**
142
194
  * Agent Zero instance for delegated tasks (`argus-reviewer delegate`,
143
- * `heal: 'a0'`). `url` is the instance base URL — leave unset to let the
144
- * `a0` CLI resolve it (saved host, AGENT_ZERO_HOST, Docker discovery).
145
- * Least-privilege scoping (browser vs full desktop) is configured on the
146
- * instance's gateway, not here.
195
+ * `heal: 'a0'`, `verify --a0`). `url` is the instance base URL — leave
196
+ * unset to let the `a0` CLI resolve it (saved host, AGENT_ZERO_HOST,
197
+ * Docker discovery). `maxTasks` caps delegations per `verify`/`run`
198
+ * invocation (verify lane default 1, heal default 5); `timeoutMs` is the
199
+ * per-task wall-clock bound for the verify lane. The lane is
200
+ * an explicit opt-in escalation and caps completed delegations at
201
+ * `inconclusive` — the agent's answer is self-reported evidence, never a
202
+ * `passed` verdict (live round-trip proven in #53).
147
203
  */
148
204
  a0: {
149
205
  url: string | undefined;
206
+ maxTasks: number | undefined;
207
+ timeoutMs: number | undefined;
150
208
  } | undefined;
151
209
  /**
152
210
  * Failure escalation for `run`. 'local' (default) heals via the vision
@@ -160,9 +218,20 @@ export interface Config {
160
218
  * `resolveConfig` — `enabled: false` by default so the lane is opt-in.
161
219
  */
162
220
  sandbox: Sandbox;
221
+ /**
222
+ * Exploratory lane. Always populated after `resolveConfig` —
223
+ * `enabled: false` by default so capture is opt-in.
224
+ */
225
+ explore: Explore;
226
+ /**
227
+ * `verify --app` task lane. Always populated after `resolveConfig` —
228
+ * every field unset by default so the lane blocks on missing contract
229
+ * rather than inventing one.
230
+ */
231
+ app: AppLane;
163
232
  /**
164
233
  * Code-review policy knobs. Always populated after `resolveConfig`.
165
- * `secretsThreshold`: Jev `noul` probability at/above which a
234
+ * `secretsThreshold`: confidence-model `noul` probability at/above which a
166
235
  * secret-shaped diff literal is reported as a finding (below →
167
236
  * suppressed but audit-recorded). Default 0.3 — tune after dogfooding.
168
237
  * `maxComments`: cap on inline review comments posted per run
@@ -170,19 +239,22 @@ export interface Config {
170
239
  * `severityGate`: consumer-facing alias over `severity` — 'bug'
171
240
  * fails on bugs only, 'risk' fails on bug|risk. Unset → `severity`
172
241
  * list is authoritative.
173
- * `triage`: Jev pre-review lane — 'off' no call, 'annotate' (default)
242
+ * `triage`: confidence-model pre-review lane — 'off' no call, 'annotate' (default)
174
243
  * records risk/deep-review/area into the report + sticky, 'route'
175
244
  * additionally swaps the code model to `lowRiskModel` on low-risk
176
- * diffs. Jev routes/annotates, never gates — coverage is constant.
245
+ * diffs. The confidence model routes/annotates, never gates — coverage is constant.
177
246
  * `lowRiskModel`: the cheap code-model slug 'route' falls to; unset →
178
247
  * route keeps `code_model` (annotate-equivalent).
179
248
  * `findingThreshold`: P(false-positive) required to suppress a nit/q
180
- * finding after Jev adjudication — 1.0 (default) is annotate-only,
249
+ * finding after confidence-model adjudication — 1.0 (default) is annotate-only,
181
250
  * lowering it suppresses progressively more low-confidence nits.
182
251
  * bug/risk are never suppressed.
183
252
  * `requestChanges`: allow the review event to escalate to
184
- * REQUEST_CHANGES for proven blockers (probe-reproduced or Jev
253
+ * REQUEST_CHANGES for proven blockers (probe-reproduced or confidence-model
185
254
  * high-confidence). Default true — set false for advisory-only posting.
255
+ * `profiles`: named review lenses appended to the review prompt
256
+ * ('security'|'perf'|'debloat' — see src/review/packs.ts). Unknown names
257
+ * are dropped at config load. Default [] — no extra rubric.
186
258
  */
187
259
  review: {
188
260
  secretsThreshold: number;
@@ -192,16 +264,29 @@ export interface Config {
192
264
  lowRiskModel: string | undefined;
193
265
  findingThreshold: number;
194
266
  requestChanges: boolean;
267
+ profiles: ReviewProfile[];
268
+ /**
269
+ * Glob list of changed paths kept out of the review input. A configured
270
+ * list replaces the defaults (generated, fixture, golden, vendored
271
+ * paths); `[]` excludes nothing.
272
+ */
273
+ exclude: string[];
195
274
  };
196
275
  }
197
- export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review'>> & {
276
+ export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review' | 'explore' | 'app'>> & {
198
277
  provider?: Partial<ProviderRules>;
199
278
  sandbox?: Partial<Sandbox>;
200
279
  review?: Partial<Config['review']>;
280
+ explore?: Partial<Explore>;
281
+ app?: Partial<AppLane>;
201
282
  };
202
283
  export declare const DEFAULT_RECORD_STEP_CAP = 40;
284
+ export declare const DEFAULT_EXPLORE: Explore;
285
+ export declare const DEFAULT_APP: AppLane;
203
286
  export declare const DEFAULT_SANDBOX: Sandbox;
204
287
  export declare function defineConfig(input: ConfigInput): ConfigInput;
288
+ /** Keep only non-blank string expected-state markers; all-dropped means unconfigured. */
289
+ export declare function sanitizeExpectation(input: unknown): AppExpectation | undefined;
205
290
  /**
206
291
  * Which severities fail the review status. `review.severityGate` is the
207
292
  * consumer-facing alias over `severity` — 'risk' fails on bug|risk,
package/dist/config.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { DEFAULT_REVIEW_EXCLUDE } from './review/scope.js';
3
+ import { isReviewProfile } from './review/packs.js';
2
4
  import { JEV_DEFAULT_MODEL } from './vision/decisions.js';
3
5
  export const DEFAULT_RECORD_STEP_CAP = 40;
6
+ export const DEFAULT_EXPLORE = {
7
+ enabled: false,
8
+ maxSteps: 20,
9
+ budgetUsd: undefined,
10
+ };
11
+ export const DEFAULT_APP = {
12
+ task: undefined,
13
+ expected: undefined,
14
+ maxSteps: undefined,
15
+ budgetUsd: undefined,
16
+ timeoutMs: undefined,
17
+ };
4
18
  export const DEFAULT_SANDBOX = {
5
19
  enabled: false,
6
20
  image: undefined,
@@ -26,6 +40,7 @@ const defaults = {
26
40
  cacheDir: undefined,
27
41
  testsDir: undefined,
28
42
  reportDir: undefined,
43
+ reportRetention: undefined,
29
44
  secrets: undefined,
30
45
  pageSetup: undefined,
31
46
  openrouter: undefined,
@@ -40,6 +55,8 @@ const defaults = {
40
55
  a0: undefined,
41
56
  heal: 'local',
42
57
  sandbox: { ...DEFAULT_SANDBOX },
58
+ explore: { ...DEFAULT_EXPLORE },
59
+ app: { ...DEFAULT_APP },
43
60
  review: {
44
61
  secretsThreshold: 0.3,
45
62
  maxComments: 20,
@@ -48,6 +65,8 @@ const defaults = {
48
65
  lowRiskModel: undefined,
49
66
  findingThreshold: 1.0,
50
67
  requestChanges: true,
68
+ profiles: [],
69
+ exclude: [...DEFAULT_REVIEW_EXCLUDE],
51
70
  },
52
71
  };
53
72
  export function defineConfig(input) {
@@ -61,6 +80,32 @@ function posInt(v, dflt) {
61
80
  function prob01(v, dflt) {
62
81
  return v !== undefined && Number.isFinite(v) && v >= 0 && v <= 1 ? v : dflt;
63
82
  }
83
+ /** Optional positive-integer config values stay undefined when absent or wrong-typed. */
84
+ function optPosInt(v) {
85
+ return v !== undefined && Number.isInteger(v) && v >= 1 ? v : undefined;
86
+ }
87
+ /** Keep only non-blank string expected-state markers; all-dropped means unconfigured. */
88
+ export function sanitizeExpectation(input) {
89
+ if (typeof input !== 'object' || input === null)
90
+ return undefined;
91
+ const raw = input;
92
+ // Whitespace-only markers are vacuous — a `' '` needle matches every
93
+ // accessibility tree. Trim at the boundary so they drop like ''.
94
+ const marker = (v) => typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
95
+ const expected = {};
96
+ const text = marker(raw.text);
97
+ if (text !== undefined)
98
+ expected.text = text;
99
+ const url = marker(raw.url);
100
+ if (url !== undefined)
101
+ expected.url = url;
102
+ const selector = marker(raw.selector);
103
+ if (selector !== undefined)
104
+ expected.selector = selector;
105
+ return expected.text === undefined && expected.url === undefined && expected.selector === undefined
106
+ ? undefined
107
+ : expected;
108
+ }
64
109
  /**
65
110
  * Which severities fail the review status. `review.severityGate` is the
66
111
  * consumer-facing alias over `severity` — 'risk' fails on bug|risk,
@@ -103,10 +148,34 @@ export function resolveConfig(input = {}) {
103
148
  sandbox.maxProbes = posInt(sandbox.maxProbes, DEFAULT_SANDBOX.maxProbes);
104
149
  sandbox.timeoutMs = posInt(sandbox.timeoutMs, DEFAULT_SANDBOX.timeoutMs);
105
150
  sandbox.pidsLimit = posInt(sandbox.pidsLimit, DEFAULT_SANDBOX.pidsLimit);
151
+ // Same wrong-typed degrade as sandbox — a mis-typed flag must never
152
+ // self-enable the lane.
153
+ const rawExplore = typeof input.explore === 'object' && input.explore !== null ? input.explore : {};
154
+ const explore = { ...defaults.explore, ...rawExplore };
155
+ explore.enabled = rawExplore.enabled === true;
156
+ explore.maxSteps = posInt(explore.maxSteps, DEFAULT_EXPLORE.maxSteps);
157
+ explore.budgetUsd =
158
+ typeof explore.budgetUsd === 'number' &&
159
+ Number.isFinite(explore.budgetUsd) &&
160
+ explore.budgetUsd > 0
161
+ ? explore.budgetUsd
162
+ : undefined;
163
+ // Same wrong-typed degrade for the app lane contract — a mis-typed
164
+ // marker must never self-author a passing condition.
165
+ const rawApp = typeof input.app === 'object' && input.app !== null ? input.app : {};
166
+ const app = { ...defaults.app, ...rawApp };
167
+ app.task = typeof app.task === 'string' && app.task.trim() !== '' ? app.task : undefined;
168
+ app.expected = sanitizeExpectation(app.expected);
169
+ app.maxSteps = optPosInt(rawApp.maxSteps);
170
+ app.timeoutMs = optPosInt(rawApp.timeoutMs);
171
+ app.budgetUsd =
172
+ typeof app.budgetUsd === 'number' && Number.isFinite(app.budgetUsd) && app.budgetUsd > 0
173
+ ? app.budgetUsd
174
+ : undefined;
106
175
  const rawReview = typeof input.review === 'object' && input.review !== null ? input.review : {};
107
176
  const review = { ...defaults.review, ...rawReview };
108
177
  // Thresholds must be probabilities — anything else (NaN, >1,
109
- // negative) would silently suppress or flood the Jev lanes.
178
+ // negative) would silently suppress or flood the confidence-model lanes.
110
179
  review.secretsThreshold = prob01(review.secretsThreshold, defaults.review.secretsThreshold);
111
180
  review.maxComments =
112
181
  typeof review.maxComments === 'number' &&
@@ -127,10 +196,39 @@ export function resolveConfig(input = {}) {
127
196
  // Advisory-only escape hatch — only literal `false` opts out; anything
128
197
  // else (mis-typed values included) keeps the default-true posture.
129
198
  review.requestChanges = review.requestChanges !== false;
130
- const resolved = { ...defaults, ...input, provider, sandbox, review };
199
+ // Unknown profile names are rejected at config load — a typo silently
200
+ // disabling a lens is worse than dropping it. Non-array input means the
201
+ // field was mis-typed entirely and also drops to the empty default.
202
+ review.profiles = Array.isArray(rawReview.profiles)
203
+ ? [...new Set(rawReview.profiles.filter(isReviewProfile))]
204
+ : [];
205
+ review.exclude =
206
+ Array.isArray(rawReview.exclude) &&
207
+ rawReview.exclude.every((g) => typeof g === 'string' && g !== '')
208
+ ? [...rawReview.exclude]
209
+ : [...DEFAULT_REVIEW_EXCLUDE];
210
+ const resolved = { ...defaults, ...input, provider, sandbox, explore, app, review };
131
211
  resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
212
+ // Retention is a non-negative integer (0 = keep none) — a mis-typed or
213
+ // negative bound degrades to unset, never to "keep everything".
214
+ resolved.reportRetention =
215
+ typeof resolved.reportRetention === 'number' &&
216
+ Number.isInteger(resolved.reportRetention) &&
217
+ resolved.reportRetention >= 0
218
+ ? resolved.reportRetention
219
+ : undefined;
132
220
  if (resolved.heal !== 'a0')
133
221
  resolved.heal = 'local';
222
+ if (resolved.a0 !== undefined) {
223
+ // A0 bounds degrade like every other numeric knob — a hostile or
224
+ // mis-typed cap must not become unlimited tasks or no timeout.
225
+ const a0 = resolved.a0;
226
+ resolved.a0 = {
227
+ url: typeof a0.url === 'string' && a0.url !== '' ? a0.url : undefined,
228
+ maxTasks: optPosInt(a0.maxTasks),
229
+ timeoutMs: optPosInt(a0.timeoutMs),
230
+ };
231
+ }
134
232
  // '' is the documented opt-out — an empty slug would send a broken
135
233
  // model id to the decisions endpoint on every adjudication call.
136
234
  if (resolved.decisionModel === '')
@@ -188,7 +286,7 @@ export async function loadConfig(cwd, opts) {
188
286
  // legit consumer debugging "why is my config ignored") is invisible.
189
287
  try {
190
288
  if ((await fs.stat(path.join(cwd, `${name}.ts`))).isFile()) {
191
- opts.note?.(`config: ${name}.ts ignored — untrusted checkouts load JSON config only`);
289
+ opts.note?.(`config: ${name}.ts ignored – untrusted checkouts load JSON config only`);
192
290
  }
193
291
  }
194
292
  catch {
@@ -208,7 +306,7 @@ export async function loadConfig(cwd, opts) {
208
306
  const raw = await fs.readFile(file, 'utf8');
209
307
  const parsed = JSON.parse(raw);
210
308
  if (untrusted) {
211
- opts.note?.(`config: ${name}.json loaded untrusted — honoring ${[...UNTRUSTED_CONFIG_KEYS].join(', ')} only`);
309
+ opts.note?.(`config: ${name}.json loaded untrusted – honoring ${[...UNTRUSTED_CONFIG_KEYS].join(', ')} only`);
212
310
  return finish(filterUntrustedConfig(parsed));
213
311
  }
214
312
  return finish(parsed);
package/dist/detect.d.ts CHANGED
@@ -15,16 +15,43 @@ export interface ExecResult {
15
15
  timedOut?: boolean;
16
16
  /** Signal the process was terminated by, when killed (e.g. 'SIGTERM'). */
17
17
  signal?: string | undefined;
18
+ /**
19
+ * The process never started (ENOENT — binary missing) — set by the real
20
+ * executor so callers don't sniff stderr text for the distinction.
21
+ */
22
+ spawnError?: boolean | undefined;
18
23
  }
19
24
  export type ExecFn = (cmd: string, args: string[], timeoutMs: number,
20
25
  /** Extra env merged over process.env — keeps secrets out of `ps`/`/proc` argv. */
21
- env?: Record<string, string>) => Promise<ExecResult>;
26
+ env?: Record<string, string>,
27
+ /**
28
+ * `baseEnv` replaces the inherited process environment wholesale — the
29
+ * caller's allowlist, not ambient env. Without it the child inherits
30
+ * process.env as before.
31
+ */
32
+ opts?: {
33
+ baseEnv?: Record<string, string>;
34
+ }) => Promise<ExecResult>;
22
35
  export declare const defaultExec: ExecFn;
23
36
  export type ProbeFn = (url: string, timeoutMs: number) => Promise<boolean>;
37
+ /**
38
+ * The only environment keys an Agent Zero child process may inherit.
39
+ * Provider keys, GitHub tokens, `ARGUS_*`, and npm auth variables never
40
+ * propagate (R12) — the child is a remote agent harness, not an extension
41
+ * of this process's trust. Lives here (not in executor/a0.ts) because every
42
+ * `a0` spawn — delegation, the lane's `--version` preflight, and init's
43
+ * environment probe — must use it or the contract leaks.
44
+ */
45
+ export declare const A0_CHILD_ENV_KEYS: readonly ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TERM", "TMPDIR", "XDG_RUNTIME_DIR", "DOCKER_HOST", "AGENT_ZERO_HOST", "A0_USERNAME", "A0_PASSWORD"];
46
+ /** Build the sanitized child env: allowlisted keys that exist in `env`. */
47
+ export declare function buildA0ChildEnv(env: NodeJS.ProcessEnv | Record<string, string | undefined>): Record<string, string>;
24
48
  /**
25
49
  * Any HTTP response — including a login redirect — means *something* is up,
26
50
  * but port 5080 could be an unrelated service. Require an Agent Zero marker
27
- * in the served HTML before trusting the probe result.
51
+ * in the served HTML before trusting the probe result. Redirects are
52
+ * followed: a login-gated instance 302s `/` to `/login`, and the marker
53
+ * check must apply to the page the host actually serves, not the redirect
54
+ * stub — a hop to a non-Zero page still fails the marker check.
28
55
  */
29
56
  export declare const defaultProbe: ProbeFn;
30
57
  export interface A0Info {
package/dist/detect.js CHANGED
@@ -2,15 +2,19 @@ import { execFile } from 'node:child_process';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { readdir, readFile } from 'node:fs/promises';
5
- export const defaultExec = (cmd, args, timeoutMs, env) => new Promise((resolve) => {
5
+ /** Grace between a timeout's SIGTERM and the escalation SIGKILL. */
6
+ const EXEC_KILL_GRACE_MS = 2_000;
7
+ export const defaultExec = (cmd, args, timeoutMs, env, opts) => new Promise((resolve) => {
8
+ const baseEnv = opts?.baseEnv ?? process.env;
6
9
  // 4 MiB headroom — the sandbox caps output itself after capture, and a
7
10
  // chatty probe hitting execFile's 1 MiB default would error instead of
8
11
  // reaching the harness classifier.
9
- execFile(cmd, args, {
12
+ const child = execFile(cmd, args, {
10
13
  timeout: timeoutMs,
11
14
  maxBuffer: 4 * 1024 * 1024,
12
- ...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
15
+ env: { ...baseEnv, ...env },
13
16
  }, (err, stdout, stderr) => {
17
+ settled = true;
14
18
  if (err) {
15
19
  // stderr is '' (not undefined) on spawn ENOENT — fall back to the
16
20
  // error message so callers can distinguish "missing" from "failed".
@@ -23,27 +27,112 @@ export const defaultExec = (cmd, args, timeoutMs, env) => new Promise((resolve)
23
27
  timedOut: err.killed === true &&
24
28
  err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
25
29
  signal: typeof err.signal === 'string' ? err.signal : undefined,
30
+ spawnError: err.code === 'ENOENT',
26
31
  });
27
32
  }
28
33
  else {
29
34
  resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) });
30
35
  }
31
36
  });
37
+ let settled = false;
38
+ // execFile's timeout only delivers SIGTERM — a child that traps it (or a
39
+ // descendant holding the stdio pipes open) would keep this promise
40
+ // pending forever. Escalate to SIGKILL after a short grace, and resolve
41
+ // anyway if even that can't make the callback fire.
42
+ const escalate = setTimeout(() => {
43
+ if (settled)
44
+ return;
45
+ try {
46
+ child.kill('SIGKILL');
47
+ }
48
+ catch {
49
+ // already gone
50
+ }
51
+ setTimeout(() => {
52
+ if (!settled) {
53
+ settled = true;
54
+ resolve({ code: 1, stdout: '', stderr: 'process killed', timedOut: true });
55
+ }
56
+ }, EXEC_KILL_GRACE_MS).unref();
57
+ }, timeoutMs + EXEC_KILL_GRACE_MS);
58
+ escalate.unref();
59
+ child.on('close', () => clearTimeout(escalate));
32
60
  });
61
+ /**
62
+ * The only environment keys an Agent Zero child process may inherit.
63
+ * Provider keys, GitHub tokens, `ARGUS_*`, and npm auth variables never
64
+ * propagate (R12) — the child is a remote agent harness, not an extension
65
+ * of this process's trust. Lives here (not in executor/a0.ts) because every
66
+ * `a0` spawn — delegation, the lane's `--version` preflight, and init's
67
+ * environment probe — must use it or the contract leaks.
68
+ */
69
+ export const A0_CHILD_ENV_KEYS = [
70
+ 'PATH',
71
+ 'HOME',
72
+ 'USER',
73
+ 'LOGNAME',
74
+ 'SHELL',
75
+ 'LANG',
76
+ 'LC_ALL',
77
+ 'TERM',
78
+ 'TMPDIR',
79
+ 'XDG_RUNTIME_DIR',
80
+ 'DOCKER_HOST',
81
+ // The resolved host pointer — a URL, not a credential.
82
+ 'AGENT_ZERO_HOST',
83
+ // Headless auth for login-gated instances: the a0 CLI itself consumes
84
+ // these (headless has no other auth path — session cookies only persist
85
+ // via the interactive TUI's remember-host flow). Operator-set only; they
86
+ // scope to the a0 host, not to any provider.
87
+ 'A0_USERNAME',
88
+ 'A0_PASSWORD',
89
+ ];
90
+ /** Build the sanitized child env: allowlisted keys that exist in `env`. */
91
+ export function buildA0ChildEnv(env) {
92
+ const out = {};
93
+ for (const key of A0_CHILD_ENV_KEYS) {
94
+ const value = env[key];
95
+ if (value !== undefined && value !== '')
96
+ out[key] = value;
97
+ }
98
+ return out;
99
+ }
33
100
  /**
34
101
  * Any HTTP response — including a login redirect — means *something* is up,
35
102
  * but port 5080 could be an unrelated service. Require an Agent Zero marker
36
- * in the served HTML before trusting the probe result.
103
+ * in the served HTML before trusting the probe result. Redirects are
104
+ * followed: a login-gated instance 302s `/` to `/login`, and the marker
105
+ * check must apply to the page the host actually serves, not the redirect
106
+ * stub — a hop to a non-Zero page still fails the marker check.
37
107
  */
38
108
  export const defaultProbe = async (url, timeoutMs) => {
39
109
  try {
40
110
  const res = await fetch(url, {
41
111
  signal: AbortSignal.timeout(timeoutMs),
42
- redirect: 'manual',
112
+ redirect: 'follow',
43
113
  });
44
114
  if (res.status >= 500)
45
115
  return false;
46
- const body = (await res.text()).slice(0, 65_536);
116
+ // Bound the bytes actually received — a misbehaving host streaming an
117
+ // unbounded body inside the probe window would otherwise be fully
118
+ // buffered by res.text() before the slice.
119
+ const reader = res.body?.getReader();
120
+ if (reader === undefined)
121
+ return false;
122
+ const chunks = [];
123
+ let received = 0;
124
+ for (;;) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ received += value.byteLength;
129
+ chunks.push(value);
130
+ if (received >= 65_536) {
131
+ await reader.cancel();
132
+ break;
133
+ }
134
+ }
135
+ const body = new TextDecoder().decode(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks));
47
136
  return /agent.?zero/i.test(body);
48
137
  }
49
138
  catch {
@@ -105,7 +194,9 @@ export async function detectEnvironment(env, opts = {}) {
105
194
  const exec = opts.exec ?? defaultExec;
106
195
  const home = opts.home ?? homedir();
107
196
  const [a0Version, gh, browsers, a0Host] = await Promise.all([
108
- exec('a0', ['--version'], 5_000),
197
+ // Even the presence probe gets the allowlisted env — an `a0` binary is
198
+ // third-party code and never sees provider/git secrets.
199
+ exec('a0', ['--version'], 5_000, undefined, { baseEnv: buildA0ChildEnv(env) }),
109
200
  exec('gh', ['auth', 'status'], 5_000),
110
201
  playwrightBrowsers(home),
111
202
  resolveA0Host(env, opts),
@@ -16,7 +16,28 @@ export interface BrowserDriverOptions {
16
16
  browser?: 'chromium' | 'firefox' | 'webkit' | undefined;
17
17
  /** Hard limit in ms for Playwright cleanup. */
18
18
  browserTimeoutMs?: number | undefined;
19
+ /**
20
+ * Exploratory capture (U4a): record page errors, console errors, and
21
+ * failed same-origin requests during the run. Free — no model calls.
22
+ */
23
+ captureErrors?: boolean;
19
24
  }
25
+ /**
26
+ * One runtime anomaly observed while the browser was driving the app —
27
+ * becomes an `observed` finding on the run report. Captures never change a
28
+ * verdict; they are evidence, not adjudication.
29
+ */
30
+ export interface PageCapture {
31
+ kind: 'console-error' | 'pageerror' | 'request-failed';
32
+ /** Normalized, truncated message or failure signature. */
33
+ text: string;
34
+ /** Failing request URL (request-failed only, same-origin only). */
35
+ url?: string;
36
+ /** Collapsed repeat count for this signature. */
37
+ count: number;
38
+ }
39
+ /** Distinct capture signatures kept per browser session. */
40
+ export declare const MAX_CAPTURE_SIGNATURES = 50;
20
41
  export interface Observation {
21
42
  screenshotJpeg: Buffer;
22
43
  a11yYaml: string;
@@ -38,8 +59,19 @@ export declare class BrowserDriver {
38
59
  private readonly browserTimeoutMs;
39
60
  private video;
40
61
  private closed;
62
+ private readonly captures;
41
63
  private constructor();
42
64
  static launch(options?: BrowserDriverOptions): Promise<BrowserDriver>;
65
+ /**
66
+ * Exploratory capture taps (U4a). Noise controls are applied at collection:
67
+ * identical signatures collapse into one capture with a repeat count,
68
+ * request-failed events drop third-party origins (analytics/tag beacons
69
+ * failing is noise, not signal), and distinct signatures are capped.
70
+ */
71
+ private _attachCaptureTaps;
72
+ private _addCapture;
73
+ /** Captured page anomalies for this session — empty unless captureErrors. */
74
+ pageCaptures(): PageCapture[];
43
75
  get rawPage(): Page;
44
76
  get recordingDir(): string;
45
77
  goto(url: string): Promise<void>;