donobu 5.60.8 → 5.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/browser-side-scripts/smart-selector-generator.js +27 -1
  2. package/dist/esm/browser-side-scripts/smart-selector-generator.js +27 -1
  3. package/dist/esm/lib/ai/locate/locateElement.js +11 -15
  4. package/dist/esm/managers/DonobuFlow.d.ts +11 -4
  5. package/dist/esm/managers/DonobuFlow.js +44 -10
  6. package/dist/esm/managers/DonobuFlowsManager.js +1 -7
  7. package/dist/esm/tools/AssertTool.js +14 -18
  8. package/dist/esm/tools/ReplayableInteraction.d.ts +12 -2
  9. package/dist/esm/tools/ReplayableInteraction.js +184 -139
  10. package/dist/esm/utils/AdvancedSelectorGenerator.d.ts +130 -0
  11. package/dist/esm/utils/AdvancedSelectorGenerator.js +485 -0
  12. package/dist/esm/utils/EnvVarSensitivity.d.ts +37 -0
  13. package/dist/esm/utils/EnvVarSensitivity.js +64 -0
  14. package/dist/esm/utils/TemplateInterpolator.d.ts +50 -0
  15. package/dist/esm/utils/TemplateInterpolator.js +146 -5
  16. package/dist/esm/utils/envReferencePromptSection.d.ts +25 -0
  17. package/dist/esm/utils/envReferencePromptSection.js +42 -0
  18. package/dist/lib/ai/locate/locateElement.js +11 -15
  19. package/dist/managers/DonobuFlow.d.ts +11 -4
  20. package/dist/managers/DonobuFlow.js +44 -10
  21. package/dist/managers/DonobuFlowsManager.js +1 -7
  22. package/dist/tools/AssertTool.js +14 -18
  23. package/dist/tools/ReplayableInteraction.d.ts +12 -2
  24. package/dist/tools/ReplayableInteraction.js +184 -139
  25. package/dist/utils/AdvancedSelectorGenerator.d.ts +130 -0
  26. package/dist/utils/AdvancedSelectorGenerator.js +485 -0
  27. package/dist/utils/EnvVarSensitivity.d.ts +37 -0
  28. package/dist/utils/EnvVarSensitivity.js +64 -0
  29. package/dist/utils/TemplateInterpolator.d.ts +50 -0
  30. package/dist/utils/TemplateInterpolator.js +146 -5
  31. package/dist/utils/envReferencePromptSection.d.ts +25 -0
  32. package/dist/utils/envReferencePromptSection.js +42 -0
  33. package/package.json +1 -1
@@ -0,0 +1,485 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SynthesizedSelectorSchema = exports.AdvancedSelectorDecisionSchema = void 0;
4
+ exports.generateAdvancedSelectors = generateAdvancedSelectors;
5
+ exports.computeBakedEnvForms = computeBakedEnvForms;
6
+ exports.buildAdvancedSelectorRequest = buildAdvancedSelectorRequest;
7
+ exports.buildSynthesisRequest = buildSynthesisRequest;
8
+ exports.advanceSelectorCandidates = advanceSelectorCandidates;
9
+ exports.pruneCoincidentalPositional = pruneCoincidentalPositional;
10
+ exports.isEnvBearingSelector = isEnvBearingSelector;
11
+ exports.isEphemeralIdSelector = isEphemeralIdSelector;
12
+ exports.pruneEphemeralIdSelectors = pruneEphemeralIdSelectors;
13
+ exports.isDynamicIdSelector = isDynamicIdSelector;
14
+ exports.isPurelyPositionalSelector = isPurelyPositionalSelector;
15
+ const v4_1 = require("zod/v4");
16
+ const Logger_1 = require("./Logger");
17
+ const MiscUtils_1 = require("./MiscUtils");
18
+ const PlaywrightUtils_1 = require("./PlaywrightUtils");
19
+ const TemplateInterpolator_1 = require("./TemplateInterpolator");
20
+ /**
21
+ * Cap on generated selector candidates kept as failovers. Mirrors the failover
22
+ * cap used by the deterministic locator path (`ReplayableInteraction`); kept here
23
+ * because this module owns the generation step.
24
+ */
25
+ const MAX_REPLAY_SELECTOR_CANDIDATES = 3;
26
+ /** Temporary attribute marking the target so the model knows which element to anchor to. */
27
+ const SYNTH_TARGET_MARKER = 'data-donobu-synth-target';
28
+ /** Cap on the neighborhood HTML sent to the model for synthesis. */
29
+ const MAX_NEIGHBORHOOD_HTML = 4000;
30
+ /**
31
+ * The model's decision for advancing a generated selector: the SAME candidate
32
+ * list, in the same order and count, each rewritten so env-derived substrings are
33
+ * expressed as `{{$.env.NAME}}` (optionally with a `| filter`) placeholders, or
34
+ * returned unchanged. The deterministic core ({@link advanceSelectorCandidates})
35
+ * validates and applies it.
36
+ */
37
+ exports.AdvancedSelectorDecisionSchema = v4_1.z.object({
38
+ element: v4_1.z
39
+ .array(v4_1.z.string())
40
+ .describe('The selector candidates from the input, in the SAME order and count, ' +
41
+ 'with each substring that came from an env var replaced by its ' +
42
+ '{{$.env.NAME}} placeholder (use the | filter form for a transformed ' +
43
+ 'substring). Structural or coincidental matches are left untouched. No ' +
44
+ 'other edits.'),
45
+ });
46
+ /**
47
+ * The model's synthesized selector: one that locates the target by anchoring on
48
+ * nearby env-derived text (e.g. a card's title), or null if it can't. Validated
49
+ * against the live page before being accepted.
50
+ */
51
+ exports.SynthesizedSelectorSchema = v4_1.z.object({
52
+ selector: v4_1.z
53
+ .string()
54
+ .nullable()
55
+ .describe('A single CSS or XPath selector that UNIQUELY locates the marked target ' +
56
+ 'element by anchoring on its env-derived nearby text (as a ' +
57
+ '{{$.env.NAME}} placeholder, optionally with a | filter). Do NOT use the ' +
58
+ 'temporary marker attribute. Null if no reliable anchor exists.'),
59
+ });
60
+ /**
61
+ * Turn the element the agent acted on into the best *replayable* selector:
62
+ * generate raw candidates from the DOM, then (only for env-parameterized actions
63
+ * whose value got baked in) re-parameterize and prune them so the cached selector
64
+ * reflects the action's semantic intent rather than incidental DOM position.
65
+ *
66
+ * The DOM read and the model call are the only impurities and live here; the
67
+ * decision logic is delegated to the pure helpers below. Never returns null — it
68
+ * falls back to the raw generated selector whenever there is nothing to advance
69
+ * or the model's rewrite can't be validated.
70
+ */
71
+ async function generateAdvancedSelectors(target, context) {
72
+ // Prune ephemeral-id candidates (e.g. React useId() values) before capping, so
73
+ // the cap keeps stable failovers rather than spending slots on selectors that
74
+ // are guaranteed stale at replay.
75
+ const element = pruneEphemeralIdSelectors(await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
76
+ const frame = await deriveFrameSelector(target);
77
+ const raw = { element, frame };
78
+ if (element.length === 0) {
79
+ return raw;
80
+ }
81
+ // Env vars referenced by this action — in its own (raw, un-interpolated)
82
+ // parameters or in the overall objective (e.g. a click targeting
83
+ // "{{$.env.NAV}}", where the reference lives in the objective, not the params).
84
+ const referencedEnvVars = [
85
+ JSON.stringify(context.rawParameters ?? {}),
86
+ context.metadata.overallObjective ?? '',
87
+ ].flatMap((source) => (0, TemplateInterpolator_1.extractEnvVarReferences)(source));
88
+ const gptClient = context.gptClient;
89
+ // Only data-driven actions need advancing, and either path needs the model.
90
+ if (referencedEnvVars.length === 0 || !gptClient) {
91
+ return raw;
92
+ }
93
+ // Case A — the referenced value was baked into the target's OWN selectors (its
94
+ // text, an attribute, an href). Re-parameterize those and prune coincidental
95
+ // positional siblings.
96
+ const baked = computeBakedEnvForms(element, context.envData, referencedEnvVars);
97
+ if (baked.length > 0) {
98
+ let decision;
99
+ try {
100
+ const outcome = await gptClient.getStructuredOutput(buildAdvancedSelectorRequest(element, baked), exports.AdvancedSelectorDecisionSchema);
101
+ MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
102
+ decision = outcome.output;
103
+ }
104
+ catch (error) {
105
+ Logger_1.appLogger.warn('Advanced selector model call failed; keeping the generated selector.', error);
106
+ return raw;
107
+ }
108
+ const advancedElement = advanceSelectorCandidates(element, context.envData, decision);
109
+ if (JSON.stringify(advancedElement) !== JSON.stringify(element)) {
110
+ Logger_1.appLogger.info(`Advanced replay selector: ${JSON.stringify(element)} -> ${JSON.stringify(advancedElement)}`);
111
+ }
112
+ return { element: advancedElement, frame };
113
+ }
114
+ // Case B — the value is NOT in the target's own selectors (the target is
115
+ // identified only by position), but may live in a nearby element (a title or
116
+ // label). Try to synthesize a selector that anchors on that text instead.
117
+ const synthesized = await synthesizeAnchoredSelector(target, context, referencedEnvVars);
118
+ if (synthesized) {
119
+ const advancedElement = pruneCoincidentalPositional([
120
+ synthesized,
121
+ ...element,
122
+ ]);
123
+ Logger_1.appLogger.info(`Synthesized anchored replay selector: ${synthesized} (failover: ${JSON.stringify(advancedElement.slice(1))})`);
124
+ return { element: advancedElement, frame };
125
+ }
126
+ return raw;
127
+ }
128
+ /**
129
+ * For each referenced env var, the distinct forms of its value (verbatim or via a
130
+ * transform — e.g. "contact" / "Contact" / "/contact") that actually appear in
131
+ * the generated candidates. PURE.
132
+ */
133
+ function computeBakedEnvForms(candidates, envData, referencedEnvVars) {
134
+ const referenced = new Set(referencedEnvVars);
135
+ return Object.entries(envData)
136
+ .filter(([name]) => referenced.has(name))
137
+ .map(([name, value]) => ({
138
+ name,
139
+ value,
140
+ forms: (0, TemplateInterpolator_1.envValueForms)(value).filter((candidateForm) => candidates.some((candidate) => candidate.includes(candidateForm.form))),
141
+ }))
142
+ .filter((entry) => entry.forms.length > 0);
143
+ }
144
+ /**
145
+ * Build the model prompt: deterministic `form -> placeholder` hints (with the
146
+ * `| filter` to use for each transformed form) plus the candidate list. PURE.
147
+ */
148
+ function buildAdvancedSelectorRequest(candidates, baked) {
149
+ const envHints = baked
150
+ .map((entry) => {
151
+ const formLines = entry.forms
152
+ .map((candidateForm) => {
153
+ const placeholder = candidateForm.filter
154
+ ? `{{$.env.${entry.name} | ${candidateForm.filter}}}`
155
+ : `{{$.env.${entry.name}}}`;
156
+ return ` - the substring ${JSON.stringify(candidateForm.form)} -> ${placeholder}`;
157
+ })
158
+ .join('\n');
159
+ return ` - ${entry.name} = ${JSON.stringify(entry.value)}\n${formLines}`;
160
+ })
161
+ .join('\n');
162
+ return [
163
+ {
164
+ type: 'system',
165
+ text: `You re-parameterize auto-generated browser element selectors so that
166
+ cached automation stays correct when environment variable values change.
167
+
168
+ You are given a prioritized list of selector candidates generated from a live DOM
169
+ element. Some may contain a substring that came from an environment variable —
170
+ either the value verbatim, or a deterministic transform of it (e.g. a tab text
171
+ "Contact" from the value "contact", or an href "/some-tab" from "Some Tab").
172
+
173
+ For each env var below, the exact substring form(s) that appear in the candidates
174
+ are listed, each with the placeholder to use for it. Replace each such substring
175
+ with its placeholder (use the \`| filter\` form when the substring is a transformed
176
+ form). Use judgment: only replace a substring that is genuinely the env-derived
177
+ data, not a coincidental structural token (e.g. a CSS tag like \`body\`, an
178
+ attribute name). When in doubt, LEAVE IT UNCHANGED. Never add, remove, reorder, or
179
+ otherwise alter a selector beyond substituting placeholders. Return the SAME
180
+ candidates, in the SAME order and count.
181
+
182
+ Env vars and the substring forms they take in these selectors:
183
+ ${envHints}`,
184
+ },
185
+ {
186
+ type: 'user',
187
+ items: [{ type: 'text', text: JSON.stringify(candidates, null, 2) }],
188
+ },
189
+ ];
190
+ }
191
+ /**
192
+ * Build the synthesis prompt: the target's neighborhood HTML (with the target
193
+ * marked) and the env values in play. PURE.
194
+ */
195
+ function buildSynthesisRequest(neighborhoodHtml, envMapping, markerAttribute) {
196
+ return [
197
+ {
198
+ type: 'system',
199
+ text: `You write ONE robust selector that locates a specific target element by
200
+ anchoring on nearby text that came from an environment variable.
201
+
202
+ The target element is marked with the attribute \`${markerAttribute}\` in the HTML
203
+ below. Return a single CSS or XPath selector that UNIQUELY locates that element —
204
+ but do NOT use \`${markerAttribute}\` (it is temporary and gone at replay).
205
+ Instead, anchor on the env-derived text near it (e.g. a sibling/ancestor title or
206
+ label), written as a {{$.env.NAME}} placeholder. When the on-page text is a
207
+ transformed form of the value, append EXACTLY ONE filter with the pipe form
208
+ \`{{$.env.NAME | filter}}\`, using only these filter names (they take NO
209
+ arguments): ${Object.keys(TemplateInterpolator_1.ENV_VALUE_FILTERS).join(', ')}. Do NOT invent other
210
+ filter names (e.g. \`upcase\`, \`slice\`) or pass arguments — any other filter is
211
+ rejected and the selector is discarded. Prefer an XPath that finds the container
212
+ by its env-derived text and then descends to the target. If you cannot build a
213
+ reliable, unique anchor, return null.
214
+
215
+ Env vars in play:
216
+ ${envMapping}`,
217
+ },
218
+ {
219
+ type: 'user',
220
+ items: [{ type: 'text', text: neighborhoodHtml }],
221
+ },
222
+ ];
223
+ }
224
+ /**
225
+ * Apply the model's decision and prune the result. PURE:
226
+ *
227
+ * 1. **Validate + templatize.** A rewrite is accepted only if interpolating it
228
+ * with the current env values reproduces the exact original candidate — so the
229
+ * model is bound to placeholder substitution; any other edit is rejected and
230
+ * that candidate keeps its literal. A wrong-shaped decision is ignored wholesale.
231
+ * 2. **Elide.** Once a candidate tracks an env value, drop any purely-positional
232
+ * candidate that does not: for a data-driven action, position is a coincidence
233
+ * of the recorded data and would mislocate when the value (or order) changes.
234
+ *
235
+ * Never returns empty — falls back to the raw candidates if pruning would remove
236
+ * everything (and elision never fires unless a semantic, env-bearing candidate
237
+ * survives to take over).
238
+ */
239
+ function advanceSelectorCandidates(rawCandidates, envData, decision) {
240
+ const rewrites = decision?.element;
241
+ if (!Array.isArray(rewrites) || rewrites.length !== rawCandidates.length) {
242
+ Logger_1.appLogger.warn(`Advanced selector decision shape mismatch (${rewrites?.length ?? 'none'} vs ${rawCandidates.length}); keeping the generated selector.`);
243
+ return rawCandidates;
244
+ }
245
+ const dataSource = { env: envData, vars: {}, calls: [] };
246
+ const templatized = rawCandidates.map((raw, index) => {
247
+ const rewrite = rewrites[index];
248
+ return typeof rewrite === 'string' &&
249
+ (0, TemplateInterpolator_1.interpolateString)(rewrite, dataSource) === raw
250
+ ? rewrite
251
+ : raw;
252
+ });
253
+ return pruneCoincidentalPositional(templatized);
254
+ }
255
+ /**
256
+ * Once a candidate tracks an env value, drop purely-positional candidates that
257
+ * don't — for a data-driven action, position is a coincidence of the recorded
258
+ * data and would mislocate when the value (or order) changes. Returns the input
259
+ * unchanged when nothing is env-bearing (no semantic anchor to take over) or when
260
+ * pruning would remove everything. PURE.
261
+ */
262
+ function pruneCoincidentalPositional(candidates) {
263
+ if (!candidates.some(isEnvBearingSelector)) {
264
+ return candidates;
265
+ }
266
+ const pruned = candidates.filter((candidate) => isEnvBearingSelector(candidate) || !isPurelyPositionalSelector(candidate));
267
+ return pruned.length > 0 ? pruned : candidates;
268
+ }
269
+ /** A selector that carries an env reference (so it tracks the value at replay). */
270
+ function isEnvBearingSelector(selector) {
271
+ return selector.includes('{{$.env.');
272
+ }
273
+ /**
274
+ * A selector anchored on an *ephemeral* id — one regenerated on every render, so
275
+ * pinning it guarantees the selector goes stale at replay. The canonical case is
276
+ * a React `useId()` value: a colon-wrapped id (`#:r18:`, often CSS-escaped as
277
+ * `#\:r18\:`), possibly scoped (`#\:rd\: > li:nth-of-type(7)`). Mirrors the
278
+ * `isEphemeralId` guard in the browser-side generator; kept here as a Node-side
279
+ * funnel guard for candidates from any source. PURE.
280
+ */
281
+ function isEphemeralIdSelector(selector) {
282
+ // Drop CSS escaping (`#\:r18\:` -> `#:r18:`), then look for a `#`-id token
283
+ // whose value is colon-wrapped.
284
+ const unescaped = selector.replace(/\\/g, '');
285
+ return /#:[^\s>+~#.[\]:]+:/.test(unescaped);
286
+ }
287
+ /**
288
+ * Drop candidates anchored on an ephemeral id, but never empty the list: if every
289
+ * candidate is ephemeral, keep them (a stale selector still beats no selector).
290
+ * PURE.
291
+ */
292
+ function pruneEphemeralIdSelectors(candidates) {
293
+ const stable = candidates.filter((candidate) => !isEphemeralIdSelector(candidate));
294
+ return stable.length > 0 ? stable : candidates;
295
+ }
296
+ /**
297
+ * Mirrors the browser-side `isHashLike` heuristic (smart-selector-generator):
298
+ * detects machine-generated id *values* (webpack hashes, UUIDs, multi-segment
299
+ * hex) that change between builds/renders. Kept in sync by hand — the browser
300
+ * script is a separate, injected runtime that cannot import this module. PURE.
301
+ */
302
+ function isHashLikeIdValue(id) {
303
+ return (/^[a-f0-9]{6,}$/i.test(id) ||
304
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) ||
305
+ id.split(/[-_]/).filter((segment) => /^[a-f0-9]{6,}$/i.test(segment))
306
+ .length >= 2);
307
+ }
308
+ /**
309
+ * Decode the CSS identifier escapes that {@link CSS.escape} emits, enough to
310
+ * recover the underlying id value: hex escapes (`\39 ` -> `9`) and literal
311
+ * backslash escapes (`\:` -> `:`). PURE.
312
+ */
313
+ function cssUnescapeIdentifier(value) {
314
+ return value
315
+ .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (match, hex) => {
316
+ const codePoint = parseInt(hex, 16);
317
+ return codePoint > 0 && codePoint <= 0x10ffff
318
+ ? String.fromCodePoint(codePoint)
319
+ : match;
320
+ })
321
+ .replace(/\\(.)/g, '$1');
322
+ }
323
+ /**
324
+ * The leading `#id` value of a CSS selector (unescaped), or null when the
325
+ * selector does not start with an id. Reads only the id token, stopping at the
326
+ * first structural boundary (combinator, attribute, class, or pseudo). PURE.
327
+ */
328
+ function leadingCssIdValue(selector) {
329
+ const trimmed = selector.trim();
330
+ if (!trimmed.startsWith('#')) {
331
+ return null;
332
+ }
333
+ const unescaped = cssUnescapeIdentifier(trimmed.slice(1));
334
+ const match = /^[^\s>+~.[:]+/.exec(unescaped);
335
+ return match ? match[0] : null;
336
+ }
337
+ /**
338
+ * A selector anchored on a *dynamic* id — one the generator deliberately
339
+ * deprioritizes because it is machine-generated and unstable: an ephemeral
340
+ * (per-render) id like React's `useId()`, a hash/UUID-style id, or an over-long
341
+ * id. Such an id may legitimately survive as a low-priority failover, but must
342
+ * never be *promoted* to primary (doing so re-pins a value that is stale at
343
+ * replay). Mirrors the browser-side ranking heuristics. PURE.
344
+ */
345
+ function isDynamicIdSelector(selector) {
346
+ if (isEphemeralIdSelector(selector)) {
347
+ return true;
348
+ }
349
+ const id = leadingCssIdValue(selector);
350
+ return id !== null && (isHashLikeIdValue(id) || id.length > 24);
351
+ }
352
+ /**
353
+ * Heuristic for a selector that locates an element *purely by DOM position* — an
354
+ * `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
355
+ * no meaningful anchor (id, class, attribute, or text predicate). PURE.
356
+ *
357
+ * The "meaningful anchor" guard is what keeps a text/attribute selector that also
358
+ * happens to carry an index (e.g. `(.//a[normalize-space(.)='X'])[2]`) from being
359
+ * treated as positional.
360
+ */
361
+ function isPurelyPositionalSelector(selector) {
362
+ const hasMeaningfulAnchor = /#[\w-]/.test(selector) || // css id (not a bare '#')
363
+ /\.[a-zA-Z][\w-]*/.test(selector) || // css class (dot + identifier, not xpath './/')
364
+ /@[a-zA-Z_-]+/.test(selector) || // xpath attribute axis
365
+ /\[[a-zA-Z][\w-]*\s*[*^$~|]?=/.test(selector) || // css attribute selector
366
+ /normalize-space|text\(\)|contains\(/.test(selector); // text predicate
367
+ if (hasMeaningfulAnchor) {
368
+ return false;
369
+ }
370
+ return (/:nth-(child|of-type|last-child|last-of-type)\b/.test(selector) ||
371
+ /\[\d+\]/.test(selector) ||
372
+ /[>+~]/.test(selector));
373
+ }
374
+ /**
375
+ * Synthesize a selector that locates the target by anchoring on env-derived text
376
+ * in a NEARBY element (e.g. a card's title) — for the case where the target's own
377
+ * selectors are purely positional. Captures the target's smallest ancestor whose
378
+ * text contains an env value form, asks the model to write a value-anchored
379
+ * selector, then validates it against the live (pre-interaction) page: it is
380
+ * accepted only if, interpolated with the current env values, it uniquely resolves
381
+ * back to the target element. Returns null when there is nothing to anchor on, the
382
+ * model declines, or validation fails.
383
+ */
384
+ async function synthesizeAnchoredSelector(target, context, referencedEnvVars) {
385
+ const gptClient = context.gptClient;
386
+ if (!gptClient) {
387
+ return null;
388
+ }
389
+ // Every form (verbatim + transforms) of each referenced value, for locating the
390
+ // value in the surrounding DOM text.
391
+ const valueForms = referencedEnvVars.flatMap((name) => {
392
+ const value = context.envData[name];
393
+ return value ? (0, TemplateInterpolator_1.envValueForms)(value).map((entry) => entry.form) : [];
394
+ });
395
+ if (valueForms.length === 0) {
396
+ return null;
397
+ }
398
+ // Capture the nearest ancestor whose text contains an env value form, with the
399
+ // target temporarily marked so the model knows which element to anchor to.
400
+ let neighborhoodHtml = null;
401
+ try {
402
+ await target.evaluate((el, marker) => el.setAttribute(marker, ''), SYNTH_TARGET_MARKER);
403
+ neighborhoodHtml = await target.evaluate((el, forms) => {
404
+ let node = el.parentElement;
405
+ while (node) {
406
+ const text = node.textContent ?? '';
407
+ if (forms.some((form) => form.length > 0 && text.includes(form))) {
408
+ return node.outerHTML;
409
+ }
410
+ node = node.parentElement;
411
+ }
412
+ return null;
413
+ }, valueForms);
414
+ }
415
+ catch (error) {
416
+ Logger_1.appLogger.warn('Failed to capture neighborhood for selector synthesis.', error);
417
+ return null;
418
+ }
419
+ finally {
420
+ await target
421
+ .evaluate((el, marker) => el.removeAttribute(marker), SYNTH_TARGET_MARKER)
422
+ .catch(() => undefined);
423
+ }
424
+ if (!neighborhoodHtml) {
425
+ return null; // the value isn't near the target — nothing to anchor on
426
+ }
427
+ const html = neighborhoodHtml.length > MAX_NEIGHBORHOOD_HTML
428
+ ? `${neighborhoodHtml.slice(0, MAX_NEIGHBORHOOD_HTML)}…`
429
+ : neighborhoodHtml;
430
+ const envMapping = referencedEnvVars
431
+ .filter((name) => context.envData[name])
432
+ .map((name) => ` - {{$.env.${name}}} = ${JSON.stringify(context.envData[name])}`)
433
+ .join('\n');
434
+ let synthesized;
435
+ try {
436
+ const outcome = await gptClient.getStructuredOutput(buildSynthesisRequest(html, envMapping, SYNTH_TARGET_MARKER), exports.SynthesizedSelectorSchema);
437
+ MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
438
+ synthesized = outcome.output.selector;
439
+ }
440
+ catch (error) {
441
+ Logger_1.appLogger.warn('Selector synthesis model call failed.', error);
442
+ return null;
443
+ }
444
+ if (!synthesized || !isEnvBearingSelector(synthesized)) {
445
+ return null; // model declined, or produced a non-parameterized selector
446
+ }
447
+ // Validate against the live page: interpolate with current env values and
448
+ // confirm the result uniquely resolves to the target element.
449
+ const interpolated = (0, TemplateInterpolator_1.interpolateString)(synthesized, {
450
+ env: context.envData,
451
+ vars: {},
452
+ calls: [],
453
+ });
454
+ const resolvesToTarget = await selectorUniquelyResolvesToTarget(target, interpolated).catch(() => false);
455
+ if (!resolvesToTarget) {
456
+ Logger_1.appLogger.warn(`Synthesized selector rejected (did not uniquely resolve to the target): ${synthesized}`);
457
+ return null;
458
+ }
459
+ return synthesized;
460
+ }
461
+ /** True when `selector` resolves to exactly one element and it is `target`. */
462
+ async function selectorUniquelyResolvesToTarget(target, selector) {
463
+ const frame = await target.ownerFrame();
464
+ if (!frame) {
465
+ return false;
466
+ }
467
+ const locator = frame.locator(PlaywrightUtils_1.PlaywrightUtils.normalizeSelector(selector));
468
+ if ((await locator.count()) !== 1) {
469
+ return false;
470
+ }
471
+ return locator
472
+ .first()
473
+ .evaluate((element, other) => element === other, target);
474
+ }
475
+ async function deriveFrameSelector(target) {
476
+ const frame = await target.ownerFrame();
477
+ // No owning frame, or a top-level frame (no parent) -> no frame selector.
478
+ if (!frame?.parentFrame()) {
479
+ return null;
480
+ }
481
+ const frameElement = await frame.frameElement();
482
+ const selectors = await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(frameElement);
483
+ return selectors[0] ?? null;
484
+ }
485
+ //# sourceMappingURL=AdvancedSelectorGenerator.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Heuristic for whether an environment variable should be treated as a secret.
3
+ *
4
+ * ⚠️ MUST stay in sync with the frontend copy at
5
+ * `frontend/src/app/settings/environment/page.tsx` (`SENSITIVE_NAME_PATTERNS`,
6
+ * `SENSITIVE_VALUE_PREFIXES`, `isSensitive`). The two cannot share runtime code:
7
+ * `donobu` is only a *devDependency* of the frontend, and the settings page is a
8
+ * browser (`'use client'`) component, so there is no shared runtime module — this
9
+ * is a deliberate duplicate.
10
+ *
11
+ * Why keeping them identical matters: the frontend uses this to mask values in
12
+ * the settings UI; the backend uses it to decide which values may be shown to the
13
+ * AI and which must be redacted from AI-visible output. If the two drift, a value
14
+ * shown masked in the UI could still leak to the AI (or, conversely, a value the
15
+ * user sees in plaintext could be needlessly withheld). If you change the
16
+ * patterns here, change them there too.
17
+ */
18
+ export declare const SENSITIVE_ENV_NAME_PATTERNS: RegExp;
19
+ export declare const SENSITIVE_ENV_VALUE_PREFIXES: string[];
20
+ /**
21
+ * Returns true when an env var looks sensitive — either its name matches a known
22
+ * secret-ish pattern or its value carries a known secret token prefix. See the
23
+ * sync warning above.
24
+ */
25
+ export declare function isSensitiveEnvVar(name: string, value: string): boolean;
26
+ /**
27
+ * Redacts the resolved values of *sensitive* env vars from a string the AI will
28
+ * see (e.g. a tool result that interpolated `{{$.env.X}}` and echoes it back).
29
+ * Each occurrence of a sensitive value is replaced with a `[redacted:NAME]`
30
+ * marker, so the agent learns the reference exists but never the secret itself.
31
+ *
32
+ * Non-sensitive values are left intact — consistent with their being shown in the
33
+ * settings UI and usable by the agent. Empty values are skipped to avoid
34
+ * pathological whole-string matches.
35
+ */
36
+ export declare function redactSensitiveEnvValues(text: string, envData: Record<string, string>): string;
37
+ //# sourceMappingURL=EnvVarSensitivity.d.ts.map
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SENSITIVE_ENV_VALUE_PREFIXES = exports.SENSITIVE_ENV_NAME_PATTERNS = void 0;
4
+ exports.isSensitiveEnvVar = isSensitiveEnvVar;
5
+ exports.redactSensitiveEnvValues = redactSensitiveEnvValues;
6
+ /**
7
+ * Heuristic for whether an environment variable should be treated as a secret.
8
+ *
9
+ * ⚠️ MUST stay in sync with the frontend copy at
10
+ * `frontend/src/app/settings/environment/page.tsx` (`SENSITIVE_NAME_PATTERNS`,
11
+ * `SENSITIVE_VALUE_PREFIXES`, `isSensitive`). The two cannot share runtime code:
12
+ * `donobu` is only a *devDependency* of the frontend, and the settings page is a
13
+ * browser (`'use client'`) component, so there is no shared runtime module — this
14
+ * is a deliberate duplicate.
15
+ *
16
+ * Why keeping them identical matters: the frontend uses this to mask values in
17
+ * the settings UI; the backend uses it to decide which values may be shown to the
18
+ * AI and which must be redacted from AI-visible output. If the two drift, a value
19
+ * shown masked in the UI could still leak to the AI (or, conversely, a value the
20
+ * user sees in plaintext could be needlessly withheld). If you change the
21
+ * patterns here, change them there too.
22
+ */
23
+ exports.SENSITIVE_ENV_NAME_PATTERNS = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i;
24
+ exports.SENSITIVE_ENV_VALUE_PREFIXES = [
25
+ 'sk-',
26
+ 'ghp_',
27
+ 'gho_',
28
+ 'glpat-',
29
+ 'xoxb-',
30
+ 'xoxp-',
31
+ ];
32
+ /**
33
+ * Returns true when an env var looks sensitive — either its name matches a known
34
+ * secret-ish pattern or its value carries a known secret token prefix. See the
35
+ * sync warning above.
36
+ */
37
+ function isSensitiveEnvVar(name, value) {
38
+ return (exports.SENSITIVE_ENV_NAME_PATTERNS.test(name) ||
39
+ exports.SENSITIVE_ENV_VALUE_PREFIXES.some((prefix) => value.startsWith(prefix)));
40
+ }
41
+ /**
42
+ * Redacts the resolved values of *sensitive* env vars from a string the AI will
43
+ * see (e.g. a tool result that interpolated `{{$.env.X}}` and echoes it back).
44
+ * Each occurrence of a sensitive value is replaced with a `[redacted:NAME]`
45
+ * marker, so the agent learns the reference exists but never the secret itself.
46
+ *
47
+ * Non-sensitive values are left intact — consistent with their being shown in the
48
+ * settings UI and usable by the agent. Empty values are skipped to avoid
49
+ * pathological whole-string matches.
50
+ */
51
+ function redactSensitiveEnvValues(text, envData) {
52
+ let redacted = text;
53
+ for (const [name, value] of Object.entries(envData)) {
54
+ // Skip empty values, and values too long to possibly be a substring — a cheap
55
+ // guard since env values in this system can be very large (up to ~1MB).
56
+ if (value &&
57
+ value.length <= redacted.length &&
58
+ isSensitiveEnvVar(name, value)) {
59
+ redacted = redacted.split(value).join(`[redacted:${name}]`);
60
+ }
61
+ }
62
+ return redacted;
63
+ }
64
+ //# sourceMappingURL=EnvVarSensitivity.js.map
@@ -2,6 +2,56 @@
2
2
  * Regular expression to match template JSON-path expressions inside of {{...}}
3
3
  */
4
4
  export declare const TEMPLATE_DATA_PATTERN: RegExp;
5
+ /**
6
+ * Allow-listed, pure string transforms usable as `{{ <path> | <filter> }}` in a
7
+ * template (e.g. `{{$.env.NAV | title}}`). They let a single env value render in
8
+ * the various forms it takes across a page (a tab text "Contact" vs an href
9
+ * "/contact" vs a slug "some-tab") while a recorded selector keeps the
10
+ * placeholder instead of a frozen literal.
11
+ *
12
+ * Keep this set SMALL and deterministic: it is the single source of truth used
13
+ * both at replay (to render a value) and at record time (to recognize that an
14
+ * on-page literal is a transformed form of an env value). Each is a total
15
+ * `string -> string` function — no arguments, no side effects.
16
+ */
17
+ export declare const ENV_VALUE_FILTERS: Record<string, (input: string) => string>;
18
+ /**
19
+ * Split a template expression into its JSONPath part and any trailing
20
+ * `| filter | filter` chain. Splits only on top-level `|` — pipes inside
21
+ * brackets/parens or quotes (e.g. a JSONPath filter `[?(@.x == "a|b")]`) are left
22
+ * intact. The custom {@link queryJsonPath} dialect never uses `|`, so a top-level
23
+ * `|` is unambiguously a filter delimiter.
24
+ */
25
+ export declare function parseFilteredExpression(content: string): {
26
+ path: string;
27
+ filters: string[];
28
+ };
29
+ /**
30
+ * Apply a chain of {@link ENV_VALUE_FILTERS} to a string, left to right. Throws
31
+ * on an unknown filter name so the caller (interpolation) leaves the original
32
+ * `{{...}}` placeholder in place rather than silently emitting a wrong value.
33
+ */
34
+ export declare function applyEnvValueFilters(value: string, filters: string[]): string;
35
+ /**
36
+ * The distinct forms an env value can take via the allow-listed filters — the
37
+ * identity form plus each filter applied. Used at record time to recognize that
38
+ * an on-page literal (e.g. "Contact") is a transformed form of an env value
39
+ * (e.g. "contact") so the recorded selector can keep `{{$.env.X | title}}`.
40
+ *
41
+ * Identity comes first; duplicate forms (a filter that doesn't change the value)
42
+ * are dropped, preferring the simplest (earliest) producer.
43
+ */
44
+ export declare function envValueForms(value: string): Array<{
45
+ filter: string | null;
46
+ form: string;
47
+ }>;
48
+ /**
49
+ * Extract the env var NAMES referenced (via `{{$.env.NAME}}`, with or without a
50
+ * `| filter` suffix) anywhere in a template string. Centralizes the filter-aware
51
+ * parsing so callers don't re-derive names by naive substring math (which breaks
52
+ * once an expression carries a filter, e.g. `$.env.NAV | title`).
53
+ */
54
+ export declare function extractEnvVarReferences(template: string): string[];
5
55
  /**
6
56
  * Resolve a JSONPath expression against the context
7
57
  * @param expression - The JSONPath expression to evaluate