ccqa 1.51.1 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1307 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_fs = require("node:fs");
3
+ let node_path = require("node:path");
4
+ let node_child_process = require("node:child_process");
5
+ let node_module = require("node:module");
6
+ let node_fs_promises = require("node:fs/promises");
7
+ let _anthropic_ai_claude_agent_sdk = require("@anthropic-ai/claude-agent-sdk");
8
+ let node_async_hooks = require("node:async_hooks");
9
+ require("yaml");
10
+ let zod = require("zod");
11
+ let node_os = require("node:os");
12
+ //#region src/cli/logger.ts
13
+ /**
14
+ * When a `withBuffer` scope is active, every log line (stdout and stderr) is
15
+ * appended to its buffer instead of being written immediately. Parallel spec
16
+ * runs use this so each spec's narration — including logs emitted deep inside
17
+ * the live executor — flushes as one contiguous block, not interleaved.
18
+ */
19
+ const bufferStore = new node_async_hooks.AsyncLocalStorage();
20
+ const sinkStore = new node_async_hooks.AsyncLocalStorage();
21
+ function emit(text, sink = process.stdout) {
22
+ const store = bufferStore.getStore();
23
+ if (store) {
24
+ store.out.push(text);
25
+ return;
26
+ }
27
+ const activeSink = sinkStore.getStore();
28
+ if (activeSink) {
29
+ activeSink.write(text);
30
+ return;
31
+ }
32
+ sink.write(text);
33
+ }
34
+ function write(scope, message, sink = process.stdout) {
35
+ emit(`[${scope}] ${message}\n`, sink);
36
+ }
37
+ function bash(command) {
38
+ emit(` $ ${command.slice(0, 120)}\n`);
39
+ }
40
+ function warn(message) {
41
+ write("warn", message, process.stderr);
42
+ }
43
+ //#endregion
44
+ //#region src/ir/from-agent-browser.ts
45
+ /**
46
+ * Normalization from the agent-browser side of the recorder into the IR.
47
+ * The trace protocol emits one pipe-delimited `AB_ACTION|...` line per
48
+ * browser action (see `src/prompts/trace.ts` and
49
+ * `claude/invoke.ts:extractAbActionFromBashCommand`); `parseAbActionLine`
50
+ * turns each line into a `RecordedAction`.
51
+ *
52
+ * The mapping is a deterministic re-encoding: `to-agent-browser.ts` is its
53
+ * inverse, and the round-trip identity (ab argv → wire → IR → ab argv) is
54
+ * pinned by `roundtrip.test.ts`.
55
+ */
56
+ /**
57
+ * Semantic locator strategies exposed by `agent-browser find`. Used by the
58
+ * `find_*` wire commands when a target cannot be uniquely picked out by the
59
+ * ALLOWED CSS forms (e.g. repeated `aria-label='1 reply'` rows where only
60
+ * "the last one" is meaningful).
61
+ *
62
+ * `first` / `last` / `nth` are positional helpers whose value carries an
63
+ * inner CSS selector (`nth` additionally needs an index); they normalize to
64
+ * a `css` Locator plus `index`. The remaining strategies read the value as
65
+ * the human-visible text/id and normalize to the matching `Locator.by`.
66
+ */
67
+ const FIND_LOCATORS = [
68
+ "role",
69
+ "text",
70
+ "label",
71
+ "placeholder",
72
+ "alt",
73
+ "title",
74
+ "testid",
75
+ "first",
76
+ "last",
77
+ "nth"
78
+ ];
79
+ /**
80
+ * Actions reachable via `agent-browser find <locator> ... <action>`. Kept
81
+ * here next to the locator list so all `find` wire knowledge lives in one
82
+ * place — `claude/invoke.ts` imports these instead of redefining its own sets.
83
+ */
84
+ const FIND_ACTIONS = [
85
+ "click",
86
+ "dblclick",
87
+ "fill",
88
+ "type",
89
+ "hover",
90
+ "focus",
91
+ "check",
92
+ "uncheck"
93
+ ];
94
+ new Set(Object.keys({
95
+ element_enabled: "enabled",
96
+ element_disabled: "enabled",
97
+ element_checked: "checked",
98
+ element_unchecked: "checked"
99
+ }));
100
+ //#endregion
101
+ //#region src/spec/yaml-schema.ts
102
+ /**
103
+ * An action step: one user-facing browser interaction. `instruction` and
104
+ * `expected` are the natural-language description handed to Claude during
105
+ * `ccqa trace`. URLs live inside `instruction`, either verbatim or via
106
+ * `${ENV_VAR}` references (resolved at runtime).
107
+ */
108
+ const ActionStepSchema = zod.z.object({
109
+ instruction: zod.z.string().min(1),
110
+ expected: zod.z.string().min(1)
111
+ }).strict();
112
+ /**
113
+ * An include step: invokes a reusable block (`.ccqa/blocks/<name>/spec.yaml`).
114
+ * `params` values are plain strings; env refs (`${VAR}`) inside them are
115
+ * resolved at expand time the same way step instructions are.
116
+ */
117
+ const IncludeStepSchema = zod.z.object({
118
+ include: zod.z.string().min(1),
119
+ params: zod.z.record(zod.z.string(), zod.z.string()).optional()
120
+ }).strict();
121
+ /**
122
+ * A claim about the page decided by a model rather than by a selector match,
123
+ * for output a run cannot predict. `from` narrows what it reads to one
124
+ * element; omitted, the page's visible text. See docs/spec.md.
125
+ */
126
+ const JudgeByLlmStepSchema = zod.z.object({
127
+ judgeByLlm: zod.z.string().min(1),
128
+ from: zod.z.string().min(1).optional()
129
+ }).strict();
130
+ /**
131
+ * A spec step is an action, an include, or a judge-by-LLM — discriminated by the
132
+ * presence of the `include` / `judgeByLlm` key (see the predicates below).
133
+ */
134
+ const StepSchema = zod.z.union([
135
+ ActionStepSchema,
136
+ IncludeStepSchema,
137
+ JudgeByLlmStepSchema
138
+ ]);
139
+ /**
140
+ * Execution mode for `ccqa run`:
141
+ * - `deterministic` (default): vitest replays the recorded `test.spec.ts`.
142
+ * - `live`: Claude drives agent-browser per step (for fragile UIs where
143
+ * codegen is impractical). Cost ~$0.5 per spec.
144
+ */
145
+ const SpecModeSchema = zod.z.enum(["deterministic", "live"]);
146
+ /**
147
+ * A name a spec chooses that ccqa resolves to a path or looks up in a
148
+ * registry. Restricted to a slug so it cannot escape a directory.
149
+ */
150
+ function slug(what) {
151
+ return zod.z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
152
+ }
153
+ /**
154
+ * A saved browser session (cookies + localStorage) to restore before the spec
155
+ * runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
156
+ */
157
+ const SessionNameSchema = slug("session name");
158
+ /**
159
+ * Sessions to restore before a `mode: live` spec runs: one name or a list,
160
+ * always read back as a list. Multiple names are merged (their cookies +
161
+ * localStorage are unioned) and restored together, so a spec can start
162
+ * signed-in to several providers at once.
163
+ */
164
+ const SessionFieldSchema = zod.z.union([SessionNameSchema, zod.z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
165
+ /**
166
+ * A generation-target id: which plugin turns this spec into runnable tests
167
+ * (e.g. "agent-browser", "playwright", "runn"). Whether the id names a
168
+ * registered target is the registry's responsibility, so new targets don't
169
+ * require a schema change.
170
+ */
171
+ const TargetIdSchema = slug("target");
172
+ zod.z.object({
173
+ title: zod.z.string().min(1),
174
+ disabled: zod.z.boolean().optional(),
175
+ target: TargetIdSchema.optional(),
176
+ mode: SpecModeSchema.optional(),
177
+ session: SessionFieldSchema.optional(),
178
+ steps: zod.z.array(StepSchema).min(1)
179
+ }).strict().superRefine((spec, ctx) => {
180
+ if (spec.target === void 0 || spec.target === "agent-browser") return;
181
+ for (const key of ["mode", "session"]) if (spec[key] !== void 0) ctx.addIssue({
182
+ code: "custom",
183
+ path: [key],
184
+ message: `\`${key}\` only applies to the agent-browser target — remove it or drop \`target: ${spec.target}\``
185
+ });
186
+ });
187
+ /**
188
+ * A block param declaration. `required` defaults to true; only explicit
189
+ * `required: false` makes it optional. `secret: true` flags the value as
190
+ * sensitive — codegen renders such values as `process.env.<NAME> ?? ""`
191
+ * template literals so the secret never ends up baked into test.spec.ts.
192
+ */
193
+ const BlockParamSchema = zod.z.object({
194
+ name: zod.z.string().min(1),
195
+ required: zod.z.boolean().optional(),
196
+ secret: zod.z.boolean().optional()
197
+ }).strict();
198
+ zod.z.object({
199
+ title: zod.z.string().min(1),
200
+ params: zod.z.array(BlockParamSchema).optional(),
201
+ steps: zod.z.array(zod.z.union([ActionStepSchema, JudgeByLlmStepSchema])).min(1)
202
+ }).strict();
203
+ //#endregion
204
+ //#region src/runtime/literal-scrub.ts
205
+ /**
206
+ * Opaque machine-generated id shapes. Only the unambiguous forms are listed:
207
+ * long digit or hex runs also appear in addresses a step legitimately names
208
+ * (a date path, a numeric tenant id, a content hash), and a false hit here
209
+ * dead-ends that step — the guard in `claude/invoke.ts` blocks the `open`
210
+ * and the navigate check below drops the action. A missed id still fails
211
+ * verification at record time, so the trade is deliberate.
212
+ *
213
+ * Boundaries are alphanumeric lookarounds, not `\b`, so `item_01H8…`
214
+ * (word-char `_`) is still caught; `i` accepts lowercase ULIDs (a 26-letter
215
+ * lowercase run inside a URL is rare enough to risk). Ids that come from
216
+ * `${ENV_VAR}` values are symbolised before either check runs, never match.
217
+ */
218
+ const OPAQUE_ID_PATTERNS = [{
219
+ id: "ulid",
220
+ pattern: /(?<![0-9A-Za-z])[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-Za-z])/i,
221
+ label: "ULID"
222
+ }, {
223
+ id: "uuid",
224
+ pattern: /(?<![0-9A-Za-z])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z])/i,
225
+ label: "UUID"
226
+ }];
227
+ /** First opaque-id hit in `text`, or null. Also used by the `open` guard in `claude/invoke.ts`. */
228
+ function findOpaqueIdSegment(text) {
229
+ for (const p of OPAQUE_ID_PATTERNS) {
230
+ const m = text.match(p.pattern);
231
+ if (m) return {
232
+ patternId: p.id,
233
+ match: m[0]
234
+ };
235
+ }
236
+ return null;
237
+ }
238
+ //#endregion
239
+ //#region src/runtime/env-scrub.ts
240
+ /**
241
+ * Replace every occurrence of an env value with its `${VAR}` placeholder in
242
+ * `text`. **Caller invariant**: the map must be sorted longest-value-first
243
+ * so a shorter value doesn't shadow a longer one that contains it as a
244
+ * substring. `buildSpecEnvScrub` upholds this; hand-built maps should too.
245
+ */
246
+ function scrubEnvValues(text, scrubMap) {
247
+ if (scrubMap.length === 0) return text;
248
+ let out = text;
249
+ for (const [value, placeholder] of scrubMap) if (out.includes(value)) out = out.replaceAll(value, placeholder);
250
+ return out;
251
+ }
252
+ //#endregion
253
+ //#region src/claude/native-binary.ts
254
+ const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
255
+ /**
256
+ * The agent SDK launches Claude through a native `claude` binary that ships in
257
+ * a per-platform package (`@anthropic-ai/claude-agent-sdk-<platform>-<cpu>`),
258
+ * declared as an *optional* dependency of the SDK. Optional means a consumer's
259
+ * lockfile can omit it without any install-time error — and then every Claude
260
+ * call fails at runtime with a message that never reaches our logs. Resolving
261
+ * the package up front lets us say so once, in a line that names the fix.
262
+ *
263
+ * ccqa's own package.json repeats these packages in `optionalDependencies` for
264
+ * the same reason: a second declaration gives the resolver another chance to
265
+ * record them. Keep that list's version range in step with the SDK's.
266
+ */
267
+ function nativeBinaryPackage(platform = process.platform, arch = process.arch, musl = isMusl(platform)) {
268
+ return `@anthropic-ai/claude-agent-sdk-${platform}-${arch === "arm64" ? "arm64" : "x64"}${platform === "linux" && musl ? "-musl" : ""}`;
269
+ }
270
+ /**
271
+ * musl builds (Alpine and friends) need their own binary. Node doesn't expose
272
+ * the libc flavour directly; the absence of `glibcVersionRuntime` in the
273
+ * process report is the usual proxy.
274
+ */
275
+ function isMusl(platform) {
276
+ if (platform !== "linux") return false;
277
+ return !(process.report?.getReport?.())?.header?.glibcVersionRuntime;
278
+ }
279
+ /**
280
+ * Name of the platform package this host needs, or `null` when it resolves.
281
+ * The per-platform packages have no `exports`, so the manifest is reachable.
282
+ */
283
+ function missingNativeBinaryPackage(resolve = require$1.resolve) {
284
+ const pkg = nativeBinaryPackage();
285
+ try {
286
+ resolve(`${pkg}/package.json`);
287
+ return null;
288
+ } catch {
289
+ return pkg;
290
+ }
291
+ }
292
+ /** Advice shown when the binary is absent — the package name plus how to fix it. */
293
+ function missingNativeBinaryMessage(pkg) {
294
+ return `${pkg} is not installed. The Claude Agent SDK needs it to start Claude on this platform, so every Claude-backed command (run in live mode, drift, diagnose) will fail. It ships as an optional dependency of the SDK, which a lockfile can drop silently: reinstall without omitting optional dependencies, or add it to your project as a direct dependency pinned to the same version as @anthropic-ai/claude-agent-sdk.`;
295
+ }
296
+ //#endregion
297
+ //#region src/claude/cost-tally.ts
298
+ /**
299
+ * Sum every Claude invocation made inside a scope.
300
+ *
301
+ * A command like `record` calls Claude several times — the browser trace, the
302
+ * codegen cleanup, one diagnosis per auto-fix retry — and the caller wants one
303
+ * number for the whole command. Threading a cost out of each of those return
304
+ * types would touch every layer in between, so the tally is scoped instead:
305
+ * `invokeClaudeStreaming` adds to whichever scope is active, and nothing
306
+ * between the two has to know.
307
+ *
308
+ * Scoped rather than module-global because commands run specs concurrently
309
+ * (`drift` uses a pool). Two scopes must not fold into each other.
310
+ */
311
+ const tallyStore = new node_async_hooks.AsyncLocalStorage();
312
+ /** Record one invocation against the active scope. No-op outside one. */
313
+ function tallyInvocation(cost) {
314
+ tallyStore.getStore()?.push(cost);
315
+ }
316
+ //#endregion
317
+ //#region src/claude/env-keys.ts
318
+ /**
319
+ * Variables that carry a credential the Claude Code process can use on its
320
+ * own, with no login on the host: an API key, a gateway bearer token, or a
321
+ * subscription token from `claude setup-token`.
322
+ */
323
+ const CREDENTIAL_ENV_KEYS = [
324
+ "ANTHROPIC_API_KEY",
325
+ "ANTHROPIC_AUTH_TOKEN",
326
+ "CLAUDE_CODE_OAUTH_TOKEN"
327
+ ];
328
+ /**
329
+ * Standard Claude Code environment variables that select the API endpoint and
330
+ * credentials. ccqa forwards whichever of these are set to the underlying
331
+ * Claude Code process; it does not read or interpret their values.
332
+ *
333
+ * - `ANTHROPIC_BASE_URL` — the API endpoint to send requests to.
334
+ * - `ANTHROPIC_AUTH_TOKEN` — sent as `Authorization: Bearer <token>`.
335
+ * - `ANTHROPIC_API_KEY` — API key, when used instead of a token.
336
+ * - `ANTHROPIC_CUSTOM_HEADERS` — extra request headers.
337
+ * - `CLAUDE_CODE_OAUTH_TOKEN` — long-lived subscription token from
338
+ * `claude setup-token`, the headless-CI counterpart of a login.
339
+ */
340
+ const ENDPOINT_ENV_KEYS = [
341
+ "ANTHROPIC_BASE_URL",
342
+ "ANTHROPIC_CUSTOM_HEADERS",
343
+ ...CREDENTIAL_ENV_KEYS
344
+ ];
345
+ //#endregion
346
+ //#region src/claude/invoke.ts
347
+ /** The built-in tools an allow-list names: `Bash(*)` is `Bash`; `mcp__*` are not built-ins. */
348
+ function builtinToolNames(allowedTools) {
349
+ const names = allowedTools.map((entry) => entry.replace(/\(.*\)$/, "")).filter((name) => !name.startsWith("mcp__"));
350
+ return [...new Set(names)];
351
+ }
352
+ function resolveModel(explicit) {
353
+ if (explicit) return explicit;
354
+ const envModel = process.env["CCQA_MODEL"];
355
+ return envModel && envModel.length > 0 ? envModel : void 0;
356
+ }
357
+ /**
358
+ * When both credentials are present the OAuth token wins and the API key is
359
+ * dropped. Left to the CLI the API key would win, which makes "switch a CI
360
+ * job to the subscription token" require unwiring the key everywhere; with
361
+ * this rule, adding the one variable is the whole switch, and removing it is
362
+ * the whole rollback. The one place the rule lives — both the resolved view
363
+ * and the env the SDK receives apply it through here.
364
+ */
365
+ function preferOauthToken(env) {
366
+ if (env["CLAUDE_CODE_OAUTH_TOKEN"]) delete env["ANTHROPIC_API_KEY"];
367
+ }
368
+ /**
369
+ * Drop endpoint variables that are present but empty, so an empty value never
370
+ * reaches the Claude Code process as an override. "Set to nothing" is how a
371
+ * caller that cannot omit the key says "use the default" — a CI job wiring
372
+ * `ANTHROPIC_BASE_URL` from an unset repository variable, most of all.
373
+ */
374
+ function withoutEmptyEndpointVars(env) {
375
+ const out = { ...env };
376
+ for (const key of ENDPOINT_ENV_KEYS) if (out[key] === "") delete out[key];
377
+ return out;
378
+ }
379
+ /**
380
+ * The environment actually handed to the Claude Code process: the full process
381
+ * environment with the caller's overrides on top, empty endpoint variables
382
+ * dropped, and — when both credentials survive the merge — the API key removed
383
+ * so the OAuth token wins.
384
+ *
385
+ * That removal MUST happen on the env the SDK receives, not only on the
386
+ * resolved view: left to the CLI the API key would win, silently moving every
387
+ * call from the subscription to metered billing when a CI job wires both
388
+ * (which is exactly what happened before this function existed).
389
+ */
390
+ function buildInvocationEnv(env) {
391
+ const merged = withoutEmptyEndpointVars({
392
+ ...process.env,
393
+ ...env
394
+ });
395
+ preferOauthToken(merged);
396
+ merged["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
397
+ return merged;
398
+ }
399
+ let nativeBinaryWarned = false;
400
+ /**
401
+ * Warn once per process when the SDK's per-platform native binary is missing:
402
+ * every Claude call is about to fail, and the opaque per-step errors alone are
403
+ * expensive to trace back to a lockfile that dropped an optional dependency.
404
+ */
405
+ function warnOnceIfNativeBinaryMissing() {
406
+ if (nativeBinaryWarned) return;
407
+ nativeBinaryWarned = true;
408
+ const missing = missingNativeBinaryPackage();
409
+ if (missing) warn(missingNativeBinaryMessage(missing));
410
+ }
411
+ /** Whole minutes read best, but the ceiling is set in ms and may be seconds. */
412
+ function formatDuration(ms) {
413
+ if (ms < 6e4 || ms % 6e4 !== 0) return `${Math.round(ms / 1e3)}s`;
414
+ const minutes = ms / 6e4;
415
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
416
+ }
417
+ async function invokeClaudeStreaming(options, onEvent) {
418
+ const { prompt, systemPrompt, allowedTools, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false, additionalDirectories } = options;
419
+ const resolvedModel = resolveModel(model);
420
+ const mergedEnv = buildInvocationEnv(env);
421
+ const abortController = new AbortController();
422
+ let lastAbToolUseId = null;
423
+ let lastAbAnswerHolds = null;
424
+ const claimAbToolUse = (toolUseId) => {
425
+ if (toolUseId !== lastAbToolUseId) return false;
426
+ lastAbToolUseId = null;
427
+ lastAbAnswerHolds = null;
428
+ return true;
429
+ };
430
+ const sdkOptions = {
431
+ systemPrompt,
432
+ maxTurns,
433
+ allowedTools,
434
+ tools: builtinToolNames(allowedTools),
435
+ strictMcpConfig: true,
436
+ settingSources: [],
437
+ permissionMode: "bypassPermissions",
438
+ allowDangerouslySkipPermissions: true,
439
+ abortController,
440
+ ...resolvedModel ? { model: resolvedModel } : {},
441
+ ...cwd ? { cwd } : {},
442
+ ...additionalDirectories?.length ? { additionalDirectories } : {},
443
+ env: mergedEnv,
444
+ ...mcpServers ? { mcpServers } : {},
445
+ ...disableThinking ? { thinking: { type: "disabled" } } : {},
446
+ hooks: onAbAction || onAbActionFailed ? {
447
+ PreToolUse: [{ hooks: [async (input) => {
448
+ if (input.hook_event_name !== "PreToolUse") return {};
449
+ if (input.tool_name !== "Bash") return {};
450
+ const cmd = input.tool_input?.["command"];
451
+ if (typeof cmd !== "string") return {};
452
+ if (!relaxAbConstraints) {
453
+ if (isBlockedAbSubcommand(cmd)) return {
454
+ decision: "block",
455
+ reason: "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector."
456
+ };
457
+ if (hasRefSelector(cmd)) return {
458
+ decision: "block",
459
+ reason: "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either."
460
+ };
461
+ const bareTag = findPositionalBareTag(cmd);
462
+ if (bareTag !== null) return {
463
+ decision: "block",
464
+ reason: `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`
465
+ };
466
+ if (hasMultipleAbInvocations(cmd)) return {
467
+ decision: "block",
468
+ reason: "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command."
469
+ };
470
+ if (hasErrorSuppression(cmd)) return {
471
+ decision: "block",
472
+ reason: "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code."
473
+ };
474
+ const runProducedUrl = findRunProducedOpenUrl(cmd, envScrubMap);
475
+ if (runProducedUrl !== null) return {
476
+ decision: "block",
477
+ reason: `Do not open ${runProducedUrl} — that address was produced by this run (the id in it belongs to a record this run created), so the generated test would open a record later runs do not have. Reach the page the way a person does: click through from where the run already is. If the spec's instruction really names this exact address, put the id in a profile variable so it survives replay.`
478
+ };
479
+ }
480
+ const assertMarker = relaxAbConstraints ? null : extractCcqaAssertFromBashCommand(cmd);
481
+ const ab = relaxAbConstraints ? null : extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
482
+ if ((ab !== null || assertMarker !== null) && onAbAction) {
483
+ lastAbToolUseId = input.tool_use_id;
484
+ lastAbAnswerHolds = assertMarker === null ? null : markerHolds(assertMarker, ab);
485
+ const stepId = extractCcqaStepFromBashCommand(cmd);
486
+ onAbAction({
487
+ ...ab !== null ? { abAction: ab } : {},
488
+ ...stepId ? { stepId } : {},
489
+ ...assertMarker !== null ? { assertMarker } : {},
490
+ ...hasCcqaSecretPrefix(cmd) ? { secret: true } : {}
491
+ });
492
+ } else lastAbToolUseId = null;
493
+ return {};
494
+ }] }],
495
+ PostToolUse: [{ hooks: [async (input) => {
496
+ if (input.hook_event_name !== "PostToolUse") return {};
497
+ if (input.tool_name !== "Bash") return {};
498
+ const holds = lastAbAnswerHolds;
499
+ const output = bashToolOutput(input.tool_response);
500
+ const contradicted = holds !== null && output !== null && !holds(output);
501
+ if (!isBashToolResponseError(input.tool_response) && !contradicted) return {};
502
+ if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
503
+ return {};
504
+ }] }],
505
+ PostToolUseFailure: [{ hooks: [async (input) => {
506
+ if (input.hook_event_name !== "PostToolUseFailure") return {};
507
+ if (input.tool_name !== "Bash") return {};
508
+ if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
509
+ return {};
510
+ }] }]
511
+ } : void 0
512
+ };
513
+ warnOnceIfNativeBinaryMissing();
514
+ const capTimer = timeoutMs === void 0 ? null : setTimeout(() => abortController.abort(), timeoutMs);
515
+ capTimer?.unref?.();
516
+ let result = "";
517
+ let answered = false;
518
+ let isError = false;
519
+ let errorDetail = null;
520
+ let cost = {
521
+ totalCostUsd: null,
522
+ durationMs: null,
523
+ durationApiMs: null,
524
+ numTurns: null,
525
+ inputTokens: null,
526
+ cacheCreationInputTokens: null,
527
+ cacheReadInputTokens: null,
528
+ outputTokens: null,
529
+ models: []
530
+ };
531
+ const q = await buildMessageStream(prompt, sdkOptions);
532
+ try {
533
+ for await (const msg of q) {
534
+ onEvent(msg);
535
+ if (msg.type === "assistant" && !silenceBashLog) {
536
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use" && block.name === "Bash") {
537
+ const cmd = block.input?.["command"];
538
+ if (typeof cmd === "string") bash(scrubEnvValues(cmd, envScrubMap));
539
+ }
540
+ }
541
+ if (msg.type === "result") {
542
+ answered = true;
543
+ isError = msg.is_error ?? false;
544
+ if (msg.subtype === "success") result = msg.result;
545
+ else {
546
+ result = "";
547
+ errorDetail = `SDK reported ${msg.subtype}`;
548
+ }
549
+ cost = extractInvocationCost(msg);
550
+ }
551
+ }
552
+ } catch (err) {
553
+ isError = true;
554
+ errorDetail = err instanceof Error ? err.message : String(err);
555
+ if (!result) result = errorDetail;
556
+ } finally {
557
+ if (capTimer) clearTimeout(capTimer);
558
+ }
559
+ if (abortController.signal.aborted && timeoutMs !== void 0 && !answered) {
560
+ isError = true;
561
+ errorDetail = `stopped after ${formatDuration(timeoutMs)} (host time limit)`;
562
+ result = errorDetail;
563
+ }
564
+ tallyInvocation(cost);
565
+ return {
566
+ result,
567
+ isError,
568
+ errorDetail,
569
+ cost
570
+ };
571
+ }
572
+ /**
573
+ * Pull the cost / usage / turn / duration fields off the SDK `result` message.
574
+ * The SDK's success and error result shapes share these fields, so we read
575
+ * them defensively as `unknown` and coerce — newer SDK versions may rename a
576
+ * field without breaking our extraction.
577
+ */
578
+ function extractInvocationCost(msg) {
579
+ const m = msg;
580
+ const usage = m["usage"];
581
+ const modelUsage = m["modelUsage"];
582
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
583
+ const models = modelUsage && typeof modelUsage === "object" ? Object.keys(modelUsage) : [];
584
+ return {
585
+ totalCostUsd: pricedForClaude(models) ? num(m["total_cost_usd"]) : null,
586
+ durationMs: num(m["duration_ms"]),
587
+ durationApiMs: num(m["duration_api_ms"]),
588
+ numTurns: num(m["num_turns"]),
589
+ inputTokens: num(usage?.["input_tokens"]),
590
+ cacheCreationInputTokens: num(usage?.["cache_creation_input_tokens"]),
591
+ cacheReadInputTokens: num(usage?.["cache_read_input_tokens"]),
592
+ outputTokens: num(usage?.["output_tokens"]),
593
+ models
594
+ };
595
+ }
596
+ /**
597
+ * The SDK prices an unknown model id at a default Claude rate rather than
598
+ * returning null, so a self-hosted model would report dollars nobody is billed.
599
+ */
600
+ function pricedForClaude(models) {
601
+ return models.every((id) => /claude/i.test(id));
602
+ }
603
+ const BLOCKED_AB_SUBCOMMANDS = new Set([
604
+ "eval",
605
+ "js",
606
+ "label",
607
+ "textbox"
608
+ ]);
609
+ /**
610
+ * Shell-aware tokenizer: splits a command string into tokens respecting single/double quotes.
611
+ * e.g. `click "[role='dialog'] button:last-child"` → ["click", "[role='dialog'] button:last-child"]
612
+ */
613
+ function shellTokenize(s) {
614
+ const tokens = [];
615
+ let cur = "";
616
+ let quote = null;
617
+ for (let i = 0; i < s.length; i++) {
618
+ const ch = s[i];
619
+ if (quote) if (ch === quote) quote = null;
620
+ else cur += ch;
621
+ else if (ch === "\"" || ch === "'") quote = ch;
622
+ else if (ch === " " || ch === " ") {
623
+ if (cur) {
624
+ tokens.push(cur);
625
+ cur = "";
626
+ }
627
+ } else cur += ch;
628
+ }
629
+ if (cur) tokens.push(cur);
630
+ return tokens;
631
+ }
632
+ /**
633
+ * Positional tokens of an `agent-browser [flags] <subcommand> [args...]`
634
+ * command string, value-taking flags dropped. Empty when `cmd` is not an
635
+ * agent-browser call.
636
+ */
637
+ function abPositionalTokens(cmd) {
638
+ const abIdx = cmd.indexOf("agent-browser");
639
+ if (abIdx === -1) return [];
640
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
641
+ let i = 0;
642
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
643
+ return parts.slice(i);
644
+ }
645
+ /** Extracts the subcommand from an `agent-browser [flags] <subcommand> [args...]` command string. */
646
+ function extractAbSubcommand(cmd) {
647
+ return abPositionalTokens(cmd)[0] ?? null;
648
+ }
649
+ /** Returns true if the agent-browser subcommand is blocked (eval/js/find/etc). */
650
+ function isBlockedAbSubcommand(cmd) {
651
+ const sub = extractAbSubcommand(cmd);
652
+ return sub !== null && BLOCKED_AB_SUBCOMMANDS.has(sub);
653
+ }
654
+ /**
655
+ * Detects "the Bash tool returned an error" from a SDK PostToolUse hook's
656
+ * `tool_response`. The SDK can shape this two ways depending on how Claude
657
+ * Code reports Bash failures:
658
+ *
659
+ * - `{ is_error: true, ... }` — the canonical Bash failure shape
660
+ * - `{ output, exitCode, killed?, ... }` — the BashOutput shape; treat
661
+ * non-zero exit / kill as error
662
+ *
663
+ * We accept either. Anything else (including missing fields) is treated as a
664
+ * successful response so we never roll back over an unrelated tool call.
665
+ */
666
+ function isBashToolResponseError(tool_response) {
667
+ if (tool_response === null || typeof tool_response !== "object") return false;
668
+ const r = tool_response;
669
+ if (r["is_error"] === true) return true;
670
+ if (typeof r["exitCode"] === "number" && r["exitCode"] !== 0) return true;
671
+ if (r["killed"] === true) return true;
672
+ return false;
673
+ }
674
+ /** The Bash tool's own output, when the response carries one. */
675
+ function bashToolOutput(tool_response) {
676
+ if (tool_response === null || typeof tool_response !== "object") return null;
677
+ const output = tool_response["output"];
678
+ return typeof output === "string" ? output : null;
679
+ }
680
+ /**
681
+ * Whether a marked probe's printed answer bears its marker out.
682
+ *
683
+ * `get count`, `get url` and `is` all exit 0 whatever they answer, so the
684
+ * exit code says only that the probe ran. The marker says what the step
685
+ * expected; this reads the answer and says whether it agreed. An unknown
686
+ * marker, or a probe with nothing to compare, answers null — nothing to
687
+ * check is not the same as a check that failed.
688
+ */
689
+ function markerHolds(marker, abAction) {
690
+ if (marker.startsWith("url_contains:")) {
691
+ const substring = marker.slice(13);
692
+ return substring ? (out) => out.includes(substring) : null;
693
+ }
694
+ const parts = abAction === null ? [] : abAction.split("|");
695
+ if (parts[1] === "get_count") {
696
+ const present = (out) => Number.parseInt(out.trim(), 10);
697
+ if (marker === "element_visible") return (out) => present(out) > 0;
698
+ if (marker === "element_not_visible") return (out) => present(out) === 0;
699
+ return null;
700
+ }
701
+ if (parts[1] === "is") {
702
+ const yes = marker === "element_enabled" || marker === "element_checked";
703
+ if (!yes && !(marker === "element_disabled" || marker === "element_unchecked")) return null;
704
+ return (out) => out.trim() === String(yes);
705
+ }
706
+ return null;
707
+ }
708
+ /**
709
+ * Detect `agent-browser ... find first|last|nth <bare-tag> <action>`. A bare
710
+ * tag inside a *positional* finder matches every element of that tag on the
711
+ * page, so "the last button" picks a different element whenever the page
712
+ * shape shifts — recorded tests built on top are flaky by construction. The
713
+ * check is narrow on purpose: `find role button --name X` is fine because
714
+ * role + accessible name stays stable.
715
+ */
716
+ function findPositionalBareTag(cmd) {
717
+ if (extractAbSubcommand(cmd) !== "find") return null;
718
+ const abIdx = cmd.indexOf("agent-browser");
719
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
720
+ let i = 0;
721
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
722
+ const locator = parts[i + 1];
723
+ if (locator !== "first" && locator !== "last" && locator !== "nth") return null;
724
+ const innerIdx = locator === "nth" ? i + 3 : i + 2;
725
+ const inner = parts[innerIdx];
726
+ const action = parts[innerIdx + 1] ?? "";
727
+ if (!inner) return null;
728
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(inner)) return null;
729
+ return {
730
+ locator,
731
+ selector: inner,
732
+ action
733
+ };
734
+ }
735
+ /** Returns true if any argument to an agent-browser command uses a @ref selector (e.g. @e14). */
736
+ function hasRefSelector(cmd) {
737
+ const abIdx = cmd.indexOf("agent-browser");
738
+ if (abIdx === -1) return false;
739
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
740
+ let i = 0;
741
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
742
+ i++;
743
+ for (; i < parts.length; i++) if (/^@/.test(parts[i])) return true;
744
+ return false;
745
+ }
746
+ /**
747
+ * Detect `agent-browser open <url>` whose address carries an opaque
748
+ * machine-generated id (ULID / UUID) that no `${ENV_VAR}` value accounts
749
+ * for. Such an address was produced by the run itself — this is the
750
+ * mechanical form of the trace prompt's "Open only a URL the step names"
751
+ * rule, which alone does not stop every model. Returns the env-scrubbed URL
752
+ * for the block message, or null when the command is fine.
753
+ */
754
+ function findRunProducedOpenUrl(cmd, envScrubMap) {
755
+ const [sub, url] = abPositionalTokens(cmd);
756
+ if (sub !== "open" || !url) return null;
757
+ const scrubbed = scrubEnvValues(url, envScrubMap);
758
+ return findOpaqueIdSegment(scrubbed) !== null ? scrubbed : null;
759
+ }
760
+ /**
761
+ * Split `cmd` into shell statements at unquoted separators (`;`, `|`, `&`,
762
+ * newline; consecutive separator chars like `&&` count once). String
763
+ * literals are honoured so `fill "a;b"` stays a single statement. This is a
764
+ * heuristic split (no subshell grammar), shared by the compound-invocation
765
+ * guard and the CCQA_STEP prefix extraction so both agree on what "one
766
+ * command" means.
767
+ */
768
+ function splitShellStatements(cmd) {
769
+ const statements = [];
770
+ let start = 0;
771
+ let quote = null;
772
+ for (let i = 0; i < cmd.length; i++) {
773
+ const ch = cmd[i];
774
+ if (quote) {
775
+ if (ch === quote) quote = null;
776
+ continue;
777
+ }
778
+ if (ch === "\"" || ch === "'" || ch === "`") {
779
+ quote = ch;
780
+ continue;
781
+ }
782
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
783
+ statements.push(cmd.slice(start, i));
784
+ while (i + 1 < cmd.length && (cmd[i + 1] === "|" || cmd[i + 1] === "&" || cmd[i + 1] === ";" || cmd[i + 1] === "\n")) i++;
785
+ start = i + 1;
786
+ }
787
+ }
788
+ statements.push(cmd.slice(start));
789
+ return statements;
790
+ }
791
+ /** One leading `KEY=value` env assignment; value may be single/double-quoted. */
792
+ const ENV_ASSIGN_HEAD_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|(\S*))(?:\s+|$)/;
793
+ /**
794
+ * Split a statement into its leading `KEY=value` env assignments and the
795
+ * command they prefix. An assignment whose value is a command substitution
796
+ * (`$(...)` / backticks) is NOT treated as a prefix — `result=$(agent-browser
797
+ * ... snapshot)` is an assignment statement, not an agent-browser invocation,
798
+ * and must stay invisible to the guards below.
799
+ */
800
+ function splitLeadingEnvAssignments(statement) {
801
+ const env = /* @__PURE__ */ new Map();
802
+ let command = statement.trimStart();
803
+ for (;;) {
804
+ const m = ENV_ASSIGN_HEAD_RE.exec(command);
805
+ if (!m) break;
806
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
807
+ if (value.startsWith("$(") || value.startsWith("`")) return {
808
+ env: /* @__PURE__ */ new Map(),
809
+ command: statement.trimStart()
810
+ };
811
+ env.set(m[1], value);
812
+ command = command.slice(m[0].length);
813
+ }
814
+ return {
815
+ env,
816
+ command
817
+ };
818
+ }
819
+ /** True when `command` starts with `agent-browser` as the command word. */
820
+ function isAgentBrowserHead(command) {
821
+ if (!command.startsWith("agent-browser")) return false;
822
+ const after = command[13];
823
+ return after === void 0 || !/[A-Za-z0-9_\-]/.test(after);
824
+ }
825
+ /** Step ids passed via `CCQA_STEP=<step-id>` must be a plain slug. */
826
+ const STEP_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
827
+ /**
828
+ * Extract the step id from the `CCQA_STEP=<step-id>` env prefix on the
829
+ * agent-browser invocation in `cmd` (e.g. `CCQA_STEP=step-03 agent-browser
830
+ * --session s click "text=Submit"`). The prefix may sit anywhere in the
831
+ * leading env-assignment run (`FOO=x CCQA_STEP=step-02 agent-browser ...`),
832
+ * and the invocation may be a later statement of a compound command
833
+ * (`cd app && CCQA_STEP=step-01 agent-browser ...`). Returns null when the
834
+ * prefix is absent or its value is not a valid slug — callers then fall back
835
+ * to the STEP_START text protocol.
836
+ */
837
+ function extractCcqaStepFromBashCommand(cmd) {
838
+ const value = ccqaEnvPrefix(cmd, "CCQA_STEP");
839
+ return value !== null && STEP_SLUG_RE.test(value) ? value : null;
840
+ }
841
+ /**
842
+ * The value of a `CCQA_*` env prefix on the agent-browser invocation in `cmd`.
843
+ *
844
+ * The three markers the trace protocol carries this way — the step, the assert,
845
+ * the secret — differ only in what they do with the value, so how a prefix is
846
+ * found is written once: changing what counts as an invocation (a compound
847
+ * command, another leading assignment) must not reach two of them and miss one.
848
+ */
849
+ function ccqaEnvPrefix(cmd, name) {
850
+ for (const statement of splitShellStatements(cmd)) {
851
+ const { env, command } = splitLeadingEnvAssignments(statement);
852
+ if (!isAgentBrowserHead(command)) continue;
853
+ return env.get(name) ?? null;
854
+ }
855
+ return null;
856
+ }
857
+ /**
858
+ * Extract the assert marker from the `CCQA_ASSERT=<marker>` env prefix on
859
+ * the agent-browser invocation in `cmd`, e.g. `CCQA_STEP=step-03
860
+ * CCQA_ASSERT=1 agent-browser --session s wait --text "Submitted" --timeout
861
+ * 3000`. The marker declares that the command verifies a step signal;
862
+ * `promoteMarkedAssert` maps it onto recorded assert action(s). Returns the
863
+ * raw value — semantic validation (which markers combine with which
864
+ * commands) happens at promotion time so mismatches surface as warnings
865
+ * instead of being silently dropped here. Returns null when the prefix is
866
+ * absent or empty.
867
+ */
868
+ function extractCcqaAssertFromBashCommand(cmd) {
869
+ const value = ccqaEnvPrefix(cmd, "CCQA_ASSERT");
870
+ return value !== null && value.length > 0 ? value : null;
871
+ }
872
+ /**
873
+ * Whether the agent-browser invocation in `cmd` carries `CCQA_SECRET=1`,
874
+ * which the trace prompt asks for on a command that types into a password
875
+ * field. The same channel as `CCQA_STEP` and `CCQA_ASSERT`, for the same
876
+ * reason: the command line is the one place the fact is observable.
877
+ */
878
+ function hasCcqaSecretPrefix(cmd) {
879
+ const value = ccqaEnvPrefix(cmd, "CCQA_SECRET");
880
+ return value !== null && value !== "" && value !== "0";
881
+ }
882
+ /**
883
+ * Returns true when `cmd` contains more than one `agent-browser` invocation
884
+ * chained together via shell operators (`&&`, `||`, `;`, `|`, newline). The
885
+ * PreToolUse hook only records ONE AB_ACTION per Bash call, so chained
886
+ * invocations would silently drop every intermediate failure — turning
887
+ * "I tried four selectors before one worked" into a clean-looking trace
888
+ * with five orphaned actions that later fail at replay.
889
+ *
890
+ * Counts statements whose command word is `agent-browser`, skipping any
891
+ * leading env assignments (the trace protocol prefixes every invocation
892
+ * with `CCQA_STEP=<step-id>`). String literals are honoured so
893
+ * `agent-browser fill 'agent-browser'` doesn't false-fire.
894
+ */
895
+ function hasMultipleAbInvocations(cmd) {
896
+ let count = 0;
897
+ for (const statement of splitShellStatements(cmd)) {
898
+ if (!isAgentBrowserHead(splitLeadingEnvAssignments(statement).command)) continue;
899
+ count++;
900
+ if (count > 1) return true;
901
+ }
902
+ return false;
903
+ }
904
+ /**
905
+ * Returns true when an `agent-browser` command in `cmd` has its exit
906
+ * status hidden by a shell decorator that would prevent ccqa from rolling
907
+ * back a failed attempt:
908
+ *
909
+ * - trailing `|| true` / `|| :` / `; true` (force exit 0)
910
+ * - `2>/dev/null` and friends (drop stderr, sometimes paired with `|| true`)
911
+ *
912
+ * The agent-browser command itself returns exit 1 on selector miss, so
913
+ * once one of these is present the PostToolUse hook sees `is_error=false`
914
+ * and the bad attempt sneaks into ir.json.
915
+ */
916
+ function hasErrorSuppression(cmd) {
917
+ if (cmd.indexOf("agent-browser") === -1) return false;
918
+ if (/\|\|\s*(true|:|\s*$|#)/.test(cmd)) return true;
919
+ if (/;\s*(true|:)\b/.test(cmd)) return true;
920
+ if (/2\s*>\s*\/dev\/null/.test(cmd)) return true;
921
+ if (/&\s*>\s*\/dev\/null/.test(cmd)) return true;
922
+ return false;
923
+ }
924
+ /**
925
+ * Parse an `agent-browser --session <name> <cmd> [args...]` bash command
926
+ * and return the corresponding AB_ACTION line, or null if not an agent-browser call.
927
+ */
928
+ function extractAbActionFromBashCommand(cmd) {
929
+ const subCmd = extractAbSubcommand(cmd);
930
+ if (!subCmd) return null;
931
+ const abIdx = cmd.indexOf("agent-browser");
932
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
933
+ let i = 0;
934
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
935
+ const args = parts.slice(i + 1);
936
+ switch (subCmd) {
937
+ case "cookies":
938
+ if (args[0] === "clear") return "AB_ACTION|cookies_clear";
939
+ return null;
940
+ case "open": return `AB_ACTION|open|${args[0] ?? ""}`;
941
+ case "press": return `AB_ACTION|press|${args[0] ?? ""}`;
942
+ case "scroll": return `AB_ACTION|scroll|${args.join("|")}`;
943
+ case "click":
944
+ case "dblclick":
945
+ case "check":
946
+ case "uncheck":
947
+ case "hover":
948
+ case "wait": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}`;
949
+ case "fill":
950
+ case "type":
951
+ case "select": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
952
+ case "drag": return `AB_ACTION|drag|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
953
+ case "upload": {
954
+ const sel = args[0] ?? "";
955
+ const files = args.slice(1);
956
+ if (!sel || files.length === 0) return null;
957
+ return `AB_ACTION|upload|${sel}|${files.join("|")}`;
958
+ }
959
+ case "snapshot": return null;
960
+ case "find": return extractFindAbAction(args);
961
+ default: return null;
962
+ }
963
+ }
964
+ /**
965
+ * Wire lines for the observation-only probes `get count <sel>`, `get url` and
966
+ * `is <state> <sel>`.
967
+ * These commands read state without mutating it, so they have no place in
968
+ * the replay sequence and `extractAbActionFromBashCommand` ignores them.
969
+ * They matter only when a `CCQA_ASSERT=<marker>` env prefix declares the
970
+ * probe verifies a step signal — the hook layer then surfaces them via this
971
+ * function so `promoteMarkedAssert` can turn them into recorded asserts.
972
+ * Only consulted when a marker is present; unmarked `get` commands stay
973
+ * unobserved as before.
974
+ */
975
+ function extractObservationAbAction(cmd) {
976
+ const sub = extractAbSubcommand(cmd);
977
+ if (sub !== "get" && sub !== "is") return null;
978
+ const abIdx = cmd.indexOf("agent-browser");
979
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
980
+ let i = 0;
981
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
982
+ const args = parts.slice(i + 1);
983
+ if (sub === "is") return args[0] && args[1] ? `AB_ACTION|is|${args[0]}|${args[1]}` : null;
984
+ if (args[0] === "count" && args[1]) return `AB_ACTION|get_count|${args[1]}`;
985
+ if (args[0] === "url") return "AB_ACTION|get_url";
986
+ return null;
987
+ }
988
+ const FIND_ACTION_SET = new Set(FIND_ACTIONS);
989
+ const FIND_LOCATOR_SET = new Set(FIND_LOCATORS);
990
+ /**
991
+ * Parse the positional tokens of `agent-browser find <locator> <value> [...]
992
+ * <action> [fillValue]` and produce a canonical
993
+ * `AB_ACTION|find_<action>|<locator>|<value>|<extra>|<exact>|...|<label>`
994
+ * line. The wire format keeps a fixed positional layout across locators so
995
+ * downstream `parseAbActionLine` in `ir/from-agent-browser.ts` can split on
996
+ * `|` alone:
997
+ *
998
+ * <extra> is `--name` value for role, integer index for nth, "" otherwise.
999
+ * <exact> is the literal "exact" if --exact was passed, "" otherwise.
1000
+ *
1001
+ * Returns null for malformed invocations — the caller treats null as "not a
1002
+ * structured action" and the Bash command still runs unobserved.
1003
+ */
1004
+ function extractFindAbAction(args) {
1005
+ const locator = args[0];
1006
+ if (!locator || !FIND_LOCATOR_SET.has(locator)) return null;
1007
+ let i = 1;
1008
+ let value = args[i] ?? "";
1009
+ i++;
1010
+ let extra = "";
1011
+ if (locator === "nth") {
1012
+ extra = value;
1013
+ value = args[i] ?? "";
1014
+ i++;
1015
+ }
1016
+ let action = "";
1017
+ let name = "";
1018
+ let exact = "";
1019
+ let fillValue = "";
1020
+ for (; i < args.length; i++) {
1021
+ const tok = args[i];
1022
+ if (tok === "--name") {
1023
+ name = args[i + 1] ?? "";
1024
+ i++;
1025
+ } else if (tok === "--exact") exact = "exact";
1026
+ else if (FIND_ACTION_SET.has(tok)) action = tok;
1027
+ else if (tok === "text" && action === "" && locator === "role") action = tok;
1028
+ else if (action) fillValue = tok;
1029
+ }
1030
+ if (!action) return null;
1031
+ if (locator === "role") extra = name;
1032
+ const command = `find_${action}`;
1033
+ if (action === "fill" || action === "type") return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|${fillValue}|`;
1034
+ return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|`;
1035
+ }
1036
+ async function buildMessageStream(prompt, options) {
1037
+ const mockFile = process.env["CCQA_CLAUDE_MOCK_FILE"];
1038
+ if (mockFile) return replayMockMessages(mockFile, options);
1039
+ return (0, _anthropic_ai_claude_agent_sdk.query)({
1040
+ prompt,
1041
+ options
1042
+ });
1043
+ }
1044
+ async function* replayMockMessages(path, options) {
1045
+ const raw = await (0, node_fs_promises.readFile)(path, "utf8");
1046
+ for (const line of raw.split("\n")) {
1047
+ const trimmed = line.trim();
1048
+ if (!trimmed) continue;
1049
+ const msg = JSON.parse(trimmed);
1050
+ await fireMockPreToolUseHooks(msg, options);
1051
+ yield msg;
1052
+ }
1053
+ }
1054
+ /**
1055
+ * The real SDK fires PreToolUse hooks as it executes tool calls; the JSONL
1056
+ * replay approximates that by invoking the configured PreToolUse hooks for
1057
+ * every Bash tool_use block before yielding its message, so e2e stubs
1058
+ * exercise the AB_ACTION recording path (including CCQA_STEP step
1059
+ * attribution). Hook decisions are ignored and post-tool hooks are not
1060
+ * simulated — the replay runs no tools, so there is nothing to block or fail.
1061
+ */
1062
+ async function fireMockPreToolUseHooks(msg, options) {
1063
+ const matchers = options.hooks?.PreToolUse;
1064
+ if (!matchers || msg.type !== "assistant") return;
1065
+ for (const block of msg.message.content ?? []) {
1066
+ if (block.type !== "tool_use" || block.name !== "Bash") continue;
1067
+ const input = {
1068
+ hook_event_name: "PreToolUse",
1069
+ tool_name: "Bash",
1070
+ tool_input: block.input,
1071
+ tool_use_id: block.id,
1072
+ session_id: "mock",
1073
+ transcript_path: "",
1074
+ cwd: process.cwd()
1075
+ };
1076
+ for (const matcher of matchers) for (const hook of matcher.hooks) await hook(input, block.id, { signal: new AbortController().signal });
1077
+ }
1078
+ }
1079
+ //#endregion
1080
+ //#region src/drift/auth.ts
1081
+ /**
1082
+ * Claude Code can also run against AWS Bedrock / Google Vertex AI, selected by
1083
+ * these env toggles. Credentials then come from the cloud SDK's own chain
1084
+ * (instance/task roles, gcloud auth, …), so none of the Anthropic-side probes
1085
+ * below apply — a set toggle counts as auth being available. ccqa forwards the
1086
+ * toggle verbatim; only "0"/"false" (any case) is treated as explicitly off.
1087
+ */
1088
+ const CLOUD_PROVIDER_ENV_KEYS = ["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX"];
1089
+ function cloudProviderEnabled() {
1090
+ return CLOUD_PROVIDER_ENV_KEYS.some((key) => {
1091
+ const value = process.env[key]?.trim().toLowerCase();
1092
+ return value !== void 0 && value !== "" && value !== "0" && value !== "false";
1093
+ });
1094
+ }
1095
+ /**
1096
+ * Probe whether the host has any credential the Anthropic SDK can pick up:
1097
+ * - one of CREDENTIAL_ENV_KEYS (API key, gateway bearer token, or the
1098
+ * subscription token from `claude setup-token`)
1099
+ * - CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX (cloud-provider
1100
+ * endpoints authenticated by the cloud SDK's credential chain)
1101
+ * - ~/.claude/.credentials.json (Claude Code login, file-based platforms)
1102
+ * - macOS Keychain item "Claude Code-credentials" (Claude Code login on
1103
+ * darwin stores the OAuth credentials in the Keychain, not on disk)
1104
+ *
1105
+ * Claude-driven hooks are opt-in, so the caller only consults this after the
1106
+ * user has asked for analysis. We never throw — auth absence is a normal flow
1107
+ * that surfaces as "analysis skipped".
1108
+ */
1109
+ function driftAuthAvailable() {
1110
+ for (const key of CREDENTIAL_ENV_KEYS) {
1111
+ const value = process.env[key];
1112
+ if (typeof value === "string" && value.length > 0) return { ok: true };
1113
+ }
1114
+ if (cloudProviderEnabled()) return { ok: true };
1115
+ if ((0, node_fs.existsSync)((0, node_path.join)((0, node_os.homedir)(), ".claude", ".credentials.json"))) return { ok: true };
1116
+ if (process.platform === "darwin" && keychainHasClaudeCredentials()) return { ok: true };
1117
+ return {
1118
+ ok: false,
1119
+ reason: `no ${CREDENTIAL_ENV_KEYS.join(" / ")} / Bedrock or Vertex env / claude login`
1120
+ };
1121
+ }
1122
+ /**
1123
+ * `security find-generic-password` without `-w` only checks the item's
1124
+ * existence (exit 0) — it never reads the secret, so no Keychain unlock
1125
+ * prompt is triggered. Resolved via PATH so tests can stub the binary.
1126
+ */
1127
+ function keychainHasClaudeCredentials() {
1128
+ try {
1129
+ return (0, node_child_process.spawnSync)("security", [
1130
+ "find-generic-password",
1131
+ "-s",
1132
+ "Claude Code-credentials"
1133
+ ], {
1134
+ stdio: "ignore",
1135
+ timeout: 3e3
1136
+ }).status === 0;
1137
+ } catch {
1138
+ return false;
1139
+ }
1140
+ }
1141
+ //#endregion
1142
+ //#region src/diagnose/diagnose.ts
1143
+ /**
1144
+ * Pull every plausible JSON object out of `raw`. We try, in order:
1145
+ * 1. The whole string with code fences stripped (the prompt asks for
1146
+ * JSON-only, so this is the happy path).
1147
+ * 2. Each balanced `{...}` block found by scanning the text. The model
1148
+ * sometimes prefixes the JSON with a "Confirmed: ..." sentence or
1149
+ * mentions partial JSON in its tool-using reasoning; we want to
1150
+ * try the *last* well-formed object first because it's most likely
1151
+ * the final answer, then earlier ones as a fallback.
1152
+ *
1153
+ * The caller `JSON.parse`s each candidate and stops at the first match
1154
+ * that normalises to a known DiagnosisResult.
1155
+ */
1156
+ function extractJsonCandidates(raw) {
1157
+ const out = [];
1158
+ const stripped = stripFence(raw);
1159
+ if (stripped) out.push(stripped);
1160
+ const blocks = [];
1161
+ let depth = 0;
1162
+ let start = -1;
1163
+ let inString = false;
1164
+ let escaped = false;
1165
+ for (let i = 0; i < raw.length; i++) {
1166
+ const ch = raw[i];
1167
+ if (inString) {
1168
+ if (escaped) escaped = false;
1169
+ else if (ch === "\\") escaped = true;
1170
+ else if (ch === "\"") inString = false;
1171
+ continue;
1172
+ }
1173
+ if (ch === "\"") {
1174
+ inString = true;
1175
+ continue;
1176
+ }
1177
+ if (ch === "{") {
1178
+ if (depth === 0) start = i;
1179
+ depth++;
1180
+ } else if (ch === "}") {
1181
+ depth--;
1182
+ if (depth === 0 && start >= 0) {
1183
+ blocks.push(raw.slice(start, i + 1));
1184
+ start = -1;
1185
+ }
1186
+ }
1187
+ }
1188
+ for (let i = blocks.length - 1; i >= 0; i--) {
1189
+ const block = blocks[i];
1190
+ if (!out.includes(block)) out.push(block);
1191
+ }
1192
+ return out;
1193
+ }
1194
+ function truncate(s, max) {
1195
+ return s.length <= max ? s : `${s.slice(0, max)}... [truncated, ${s.length - max} more chars]`;
1196
+ }
1197
+ function stripFence(raw) {
1198
+ return raw.trim().replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
1199
+ }
1200
+ //#endregion
1201
+ //#region src/runtime/judge.ts
1202
+ const SYSTEM_PROMPT = [
1203
+ "You decide whether a claim holds for a piece of text taken from a web page under test.",
1204
+ "",
1205
+ "Judge only what the claim says. Do not reward text that is merely well-formed,",
1206
+ "and do not fail text for wording, length, or formatting the claim does not mention.",
1207
+ "An empty or error-like text fails any claim about content.",
1208
+ "",
1209
+ "Answer with one line of JSON and nothing else: {\"ok\": true|false, \"reason\": \"<one sentence>\"}"
1210
+ ].join("\n");
1211
+ /** Beyond this the tail is dropped, so one runaway page cannot fill the model's context. */
1212
+ const MAX_TEXT_CHARS = 2e4;
1213
+ /** A judge runs inside a test, so a turn that will not finish has to fail rather than hold the run. */
1214
+ const JUDGE_TIMEOUT_MS = 6e4;
1215
+ /** Name the verdict is attached under, so a report can find it by name. */
1216
+ const ATTACHMENT_NAME = "ccqa-judge";
1217
+ /**
1218
+ * Fails the test unless a model agrees the claim holds for the text read from
1219
+ * `from` (a selector; omitted, the page's body). The reason the model gave
1220
+ * rides in the failure, so a red test says what was wrong.
1221
+ *
1222
+ * A selector matching several elements judges the first, as Playwright's
1223
+ * page-level `innerText` does — narrow it if that is not what you mean.
1224
+ *
1225
+ * A string third argument is shorthand for `{ from: <string> }` — the form
1226
+ * generated tests already carry. Pass `testInfo` (Playwright's per-test
1227
+ * handle) to attach the verdict to the test's report, pass or fail.
1228
+ */
1229
+ async function judgeByLlm(page, claim, options) {
1230
+ const opts = typeof options === "string" ? { from: options } : options ?? {};
1231
+ const from = opts.from ?? "body";
1232
+ const model = opts.model ?? process.env["CCQA_JUDGE_MODEL"];
1233
+ const verdict = await decideClaim({
1234
+ claim,
1235
+ text: await page.innerText(from),
1236
+ ...model ? { model } : {}
1237
+ });
1238
+ if (opts.testInfo) {
1239
+ const attachment = {
1240
+ body: JSON.stringify({
1241
+ claim,
1242
+ from,
1243
+ ok: verdict.ok,
1244
+ reason: verdict.reason
1245
+ }, null, 2),
1246
+ contentType: "application/json"
1247
+ };
1248
+ try {
1249
+ await opts.testInfo.attach(ATTACHMENT_NAME, attachment);
1250
+ } catch (err) {
1251
+ const reason = err instanceof Error ? err.message : String(err);
1252
+ process.stderr.write(`judgeByLlm: could not attach "${ATTACHMENT_NAME}" (${reason})\n`);
1253
+ }
1254
+ }
1255
+ if (!verdict.ok) throw new Error(`judgeByLlm: the claim did not hold (${verdict.reason || "no reason given"})\n claim: ${claim}\n read from: ${from}`);
1256
+ }
1257
+ /**
1258
+ * Decides a claim about text a run cannot predict — a generated answer, a
1259
+ * summary. A model that will not answer, or answers something this cannot
1260
+ * read, is an unmade decision rather than a passing one: it throws, so a
1261
+ * claim never goes silently unjudged.
1262
+ */
1263
+ async function decideClaim(input) {
1264
+ if (!driftAuthAvailable().ok) throw new Error("judgeByLlm needs Claude credentials: set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN (a Claude subscription token) in the environment that runs the test — a Bedrock or Vertex environment, or a local `claude login`, also count. Optionally set CCQA_JUDGE_MODEL to pick the model for judgements (otherwise CCQA_MODEL, then the Claude Code default).");
1265
+ const { result, isError, errorDetail } = await invokeClaudeStreaming({
1266
+ prompt: [
1267
+ "## Claim",
1268
+ input.claim.trim(),
1269
+ "",
1270
+ "## Text",
1271
+ truncate(input.text.trim(), MAX_TEXT_CHARS) || "(the page yielded no text)"
1272
+ ].join("\n"),
1273
+ systemPrompt: SYSTEM_PROMPT,
1274
+ allowedTools: [],
1275
+ maxTurns: 1,
1276
+ disableThinking: true,
1277
+ timeoutMs: JUDGE_TIMEOUT_MS,
1278
+ ...input.model ? { model: input.model } : {},
1279
+ ...input.cwd ? { cwd: input.cwd } : {}
1280
+ }, () => {});
1281
+ if (isError) throw new Error(`judgeByLlm: the model did not answer (${errorDetail || result})`);
1282
+ return parseVerdict(result);
1283
+ }
1284
+ /**
1285
+ * The model is asked for bare JSON but sometimes wraps it in prose or a fence.
1286
+ * An answer with no readable verdict is a failure to decide, not a false one.
1287
+ */
1288
+ function parseVerdict(answer) {
1289
+ for (const candidate of extractJsonCandidates(answer)) {
1290
+ let parsed;
1291
+ try {
1292
+ parsed = JSON.parse(candidate);
1293
+ } catch {
1294
+ continue;
1295
+ }
1296
+ const verdict = parsed;
1297
+ if (typeof verdict?.ok === "boolean") return {
1298
+ ok: verdict.ok,
1299
+ reason: typeof verdict.reason === "string" ? verdict.reason : ""
1300
+ };
1301
+ }
1302
+ throw new Error(`judgeByLlm: no verdict in the model's answer (${truncate(answer, 200)})`);
1303
+ }
1304
+ //#endregion
1305
+ exports.decideClaim = decideClaim;
1306
+ exports.judgeByLlm = judgeByLlm;
1307
+ exports.parseVerdict = parseVerdict;