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