donobu 5.60.7 → 5.61.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/dist/esm/lib/ai/locate/locateElement.js +11 -15
- package/dist/esm/managers/DonobuFlow.d.ts +11 -4
- package/dist/esm/managers/DonobuFlow.js +44 -10
- package/dist/esm/managers/DonobuFlowsManager.js +1 -7
- package/dist/esm/tools/AssertTool.js +14 -18
- package/dist/esm/tools/HandleBrowserDialogTool.d.ts +2 -2
- package/dist/esm/tools/HandleBrowserDialogTool.js +8 -2
- package/dist/esm/tools/ReplayableInteraction.d.ts +12 -2
- package/dist/esm/tools/ReplayableInteraction.js +164 -134
- package/dist/esm/utils/AdvancedSelectorGenerator.d.ts +106 -0
- package/dist/esm/utils/AdvancedSelectorGenerator.js +396 -0
- package/dist/esm/utils/EnvVarSensitivity.d.ts +37 -0
- package/dist/esm/utils/EnvVarSensitivity.js +64 -0
- package/dist/esm/utils/TemplateInterpolator.d.ts +50 -0
- package/dist/esm/utils/TemplateInterpolator.js +146 -5
- package/dist/esm/utils/envReferencePromptSection.d.ts +25 -0
- package/dist/esm/utils/envReferencePromptSection.js +42 -0
- package/dist/lib/ai/locate/locateElement.js +11 -15
- package/dist/managers/DonobuFlow.d.ts +11 -4
- package/dist/managers/DonobuFlow.js +44 -10
- package/dist/managers/DonobuFlowsManager.js +1 -7
- package/dist/tools/AssertTool.js +14 -18
- package/dist/tools/HandleBrowserDialogTool.d.ts +2 -2
- package/dist/tools/HandleBrowserDialogTool.js +8 -2
- package/dist/tools/ReplayableInteraction.d.ts +12 -2
- package/dist/tools/ReplayableInteraction.js +164 -134
- package/dist/utils/AdvancedSelectorGenerator.d.ts +106 -0
- package/dist/utils/AdvancedSelectorGenerator.js +396 -0
- package/dist/utils/EnvVarSensitivity.d.ts +37 -0
- package/dist/utils/EnvVarSensitivity.js +64 -0
- package/dist/utils/TemplateInterpolator.d.ts +50 -0
- package/dist/utils/TemplateInterpolator.js +146 -5
- package/dist/utils/envReferencePromptSection.d.ts +25 -0
- package/dist/utils/envReferencePromptSection.js +42 -0
- package/package.json +1 -1
|
@@ -0,0 +1,396 @@
|
|
|
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.isPurelyPositionalSelector = isPurelyPositionalSelector;
|
|
12
|
+
const v4_1 = require("zod/v4");
|
|
13
|
+
const Logger_1 = require("./Logger");
|
|
14
|
+
const MiscUtils_1 = require("./MiscUtils");
|
|
15
|
+
const PlaywrightUtils_1 = require("./PlaywrightUtils");
|
|
16
|
+
const TemplateInterpolator_1 = require("./TemplateInterpolator");
|
|
17
|
+
/**
|
|
18
|
+
* Cap on generated selector candidates kept as failovers. Mirrors the failover
|
|
19
|
+
* cap used by the deterministic locator path (`ReplayableInteraction`); kept here
|
|
20
|
+
* because this module owns the generation step.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_REPLAY_SELECTOR_CANDIDATES = 3;
|
|
23
|
+
/** Temporary attribute marking the target so the model knows which element to anchor to. */
|
|
24
|
+
const SYNTH_TARGET_MARKER = 'data-donobu-synth-target';
|
|
25
|
+
/** Cap on the neighborhood HTML sent to the model for synthesis. */
|
|
26
|
+
const MAX_NEIGHBORHOOD_HTML = 4000;
|
|
27
|
+
/**
|
|
28
|
+
* The model's decision for advancing a generated selector: the SAME candidate
|
|
29
|
+
* list, in the same order and count, each rewritten so env-derived substrings are
|
|
30
|
+
* expressed as `{{$.env.NAME}}` (optionally with a `| filter`) placeholders, or
|
|
31
|
+
* returned unchanged. The deterministic core ({@link advanceSelectorCandidates})
|
|
32
|
+
* validates and applies it.
|
|
33
|
+
*/
|
|
34
|
+
exports.AdvancedSelectorDecisionSchema = v4_1.z.object({
|
|
35
|
+
element: v4_1.z
|
|
36
|
+
.array(v4_1.z.string())
|
|
37
|
+
.describe('The selector candidates from the input, in the SAME order and count, ' +
|
|
38
|
+
'with each substring that came from an env var replaced by its ' +
|
|
39
|
+
'{{$.env.NAME}} placeholder (use the | filter form for a transformed ' +
|
|
40
|
+
'substring). Structural or coincidental matches are left untouched. No ' +
|
|
41
|
+
'other edits.'),
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* The model's synthesized selector: one that locates the target by anchoring on
|
|
45
|
+
* nearby env-derived text (e.g. a card's title), or null if it can't. Validated
|
|
46
|
+
* against the live page before being accepted.
|
|
47
|
+
*/
|
|
48
|
+
exports.SynthesizedSelectorSchema = v4_1.z.object({
|
|
49
|
+
selector: v4_1.z
|
|
50
|
+
.string()
|
|
51
|
+
.nullable()
|
|
52
|
+
.describe('A single CSS or XPath selector that UNIQUELY locates the marked target ' +
|
|
53
|
+
'element by anchoring on its env-derived nearby text (as a ' +
|
|
54
|
+
'{{$.env.NAME}} placeholder, optionally with a | filter). Do NOT use the ' +
|
|
55
|
+
'temporary marker attribute. Null if no reliable anchor exists.'),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* Turn the element the agent acted on into the best *replayable* selector:
|
|
59
|
+
* generate raw candidates from the DOM, then (only for env-parameterized actions
|
|
60
|
+
* whose value got baked in) re-parameterize and prune them so the cached selector
|
|
61
|
+
* reflects the action's semantic intent rather than incidental DOM position.
|
|
62
|
+
*
|
|
63
|
+
* The DOM read and the model call are the only impurities and live here; the
|
|
64
|
+
* decision logic is delegated to the pure helpers below. Never returns null — it
|
|
65
|
+
* falls back to the raw generated selector whenever there is nothing to advance
|
|
66
|
+
* or the model's rewrite can't be validated.
|
|
67
|
+
*/
|
|
68
|
+
async function generateAdvancedSelectors(target, context) {
|
|
69
|
+
const element = (await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(target)).slice(0, MAX_REPLAY_SELECTOR_CANDIDATES);
|
|
70
|
+
const frame = await deriveFrameSelector(target);
|
|
71
|
+
const raw = { element, frame };
|
|
72
|
+
if (element.length === 0) {
|
|
73
|
+
return raw;
|
|
74
|
+
}
|
|
75
|
+
// Env vars referenced by this action — in its own (raw, un-interpolated)
|
|
76
|
+
// parameters or in the overall objective (e.g. a click targeting
|
|
77
|
+
// "{{$.env.NAV}}", where the reference lives in the objective, not the params).
|
|
78
|
+
const referencedEnvVars = [
|
|
79
|
+
JSON.stringify(context.rawParameters ?? {}),
|
|
80
|
+
context.metadata.overallObjective ?? '',
|
|
81
|
+
].flatMap((source) => (0, TemplateInterpolator_1.extractEnvVarReferences)(source));
|
|
82
|
+
const gptClient = context.gptClient;
|
|
83
|
+
// Only data-driven actions need advancing, and either path needs the model.
|
|
84
|
+
if (referencedEnvVars.length === 0 || !gptClient) {
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
// Case A — the referenced value was baked into the target's OWN selectors (its
|
|
88
|
+
// text, an attribute, an href). Re-parameterize those and prune coincidental
|
|
89
|
+
// positional siblings.
|
|
90
|
+
const baked = computeBakedEnvForms(element, context.envData, referencedEnvVars);
|
|
91
|
+
if (baked.length > 0) {
|
|
92
|
+
let decision;
|
|
93
|
+
try {
|
|
94
|
+
const outcome = await gptClient.getStructuredOutput(buildAdvancedSelectorRequest(element, baked), exports.AdvancedSelectorDecisionSchema);
|
|
95
|
+
MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
|
|
96
|
+
decision = outcome.output;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
Logger_1.appLogger.warn('Advanced selector model call failed; keeping the generated selector.', error);
|
|
100
|
+
return raw;
|
|
101
|
+
}
|
|
102
|
+
const advancedElement = advanceSelectorCandidates(element, context.envData, decision);
|
|
103
|
+
if (JSON.stringify(advancedElement) !== JSON.stringify(element)) {
|
|
104
|
+
Logger_1.appLogger.info(`Advanced replay selector: ${JSON.stringify(element)} -> ${JSON.stringify(advancedElement)}`);
|
|
105
|
+
}
|
|
106
|
+
return { element: advancedElement, frame };
|
|
107
|
+
}
|
|
108
|
+
// Case B — the value is NOT in the target's own selectors (the target is
|
|
109
|
+
// identified only by position), but may live in a nearby element (a title or
|
|
110
|
+
// label). Try to synthesize a selector that anchors on that text instead.
|
|
111
|
+
const synthesized = await synthesizeAnchoredSelector(target, context, referencedEnvVars);
|
|
112
|
+
if (synthesized) {
|
|
113
|
+
const advancedElement = pruneCoincidentalPositional([
|
|
114
|
+
synthesized,
|
|
115
|
+
...element,
|
|
116
|
+
]);
|
|
117
|
+
Logger_1.appLogger.info(`Synthesized anchored replay selector: ${synthesized} (failover: ${JSON.stringify(advancedElement.slice(1))})`);
|
|
118
|
+
return { element: advancedElement, frame };
|
|
119
|
+
}
|
|
120
|
+
return raw;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* For each referenced env var, the distinct forms of its value (verbatim or via a
|
|
124
|
+
* transform — e.g. "contact" / "Contact" / "/contact") that actually appear in
|
|
125
|
+
* the generated candidates. PURE.
|
|
126
|
+
*/
|
|
127
|
+
function computeBakedEnvForms(candidates, envData, referencedEnvVars) {
|
|
128
|
+
const referenced = new Set(referencedEnvVars);
|
|
129
|
+
return Object.entries(envData)
|
|
130
|
+
.filter(([name]) => referenced.has(name))
|
|
131
|
+
.map(([name, value]) => ({
|
|
132
|
+
name,
|
|
133
|
+
value,
|
|
134
|
+
forms: (0, TemplateInterpolator_1.envValueForms)(value).filter((candidateForm) => candidates.some((candidate) => candidate.includes(candidateForm.form))),
|
|
135
|
+
}))
|
|
136
|
+
.filter((entry) => entry.forms.length > 0);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build the model prompt: deterministic `form -> placeholder` hints (with the
|
|
140
|
+
* `| filter` to use for each transformed form) plus the candidate list. PURE.
|
|
141
|
+
*/
|
|
142
|
+
function buildAdvancedSelectorRequest(candidates, baked) {
|
|
143
|
+
const envHints = baked
|
|
144
|
+
.map((entry) => {
|
|
145
|
+
const formLines = entry.forms
|
|
146
|
+
.map((candidateForm) => {
|
|
147
|
+
const placeholder = candidateForm.filter
|
|
148
|
+
? `{{$.env.${entry.name} | ${candidateForm.filter}}}`
|
|
149
|
+
: `{{$.env.${entry.name}}}`;
|
|
150
|
+
return ` - the substring ${JSON.stringify(candidateForm.form)} -> ${placeholder}`;
|
|
151
|
+
})
|
|
152
|
+
.join('\n');
|
|
153
|
+
return ` - ${entry.name} = ${JSON.stringify(entry.value)}\n${formLines}`;
|
|
154
|
+
})
|
|
155
|
+
.join('\n');
|
|
156
|
+
return [
|
|
157
|
+
{
|
|
158
|
+
type: 'system',
|
|
159
|
+
text: `You re-parameterize auto-generated browser element selectors so that
|
|
160
|
+
cached automation stays correct when environment variable values change.
|
|
161
|
+
|
|
162
|
+
You are given a prioritized list of selector candidates generated from a live DOM
|
|
163
|
+
element. Some may contain a substring that came from an environment variable —
|
|
164
|
+
either the value verbatim, or a deterministic transform of it (e.g. a tab text
|
|
165
|
+
"Contact" from the value "contact", or an href "/some-tab" from "Some Tab").
|
|
166
|
+
|
|
167
|
+
For each env var below, the exact substring form(s) that appear in the candidates
|
|
168
|
+
are listed, each with the placeholder to use for it. Replace each such substring
|
|
169
|
+
with its placeholder (use the \`| filter\` form when the substring is a transformed
|
|
170
|
+
form). Use judgment: only replace a substring that is genuinely the env-derived
|
|
171
|
+
data, not a coincidental structural token (e.g. a CSS tag like \`body\`, an
|
|
172
|
+
attribute name). When in doubt, LEAVE IT UNCHANGED. Never add, remove, reorder, or
|
|
173
|
+
otherwise alter a selector beyond substituting placeholders. Return the SAME
|
|
174
|
+
candidates, in the SAME order and count.
|
|
175
|
+
|
|
176
|
+
Env vars and the substring forms they take in these selectors:
|
|
177
|
+
${envHints}`,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: 'user',
|
|
181
|
+
items: [{ type: 'text', text: JSON.stringify(candidates, null, 2) }],
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Build the synthesis prompt: the target's neighborhood HTML (with the target
|
|
187
|
+
* marked) and the env values in play. PURE.
|
|
188
|
+
*/
|
|
189
|
+
function buildSynthesisRequest(neighborhoodHtml, envMapping, markerAttribute) {
|
|
190
|
+
return [
|
|
191
|
+
{
|
|
192
|
+
type: 'system',
|
|
193
|
+
text: `You write ONE robust selector that locates a specific target element by
|
|
194
|
+
anchoring on nearby text that came from an environment variable.
|
|
195
|
+
|
|
196
|
+
The target element is marked with the attribute \`${markerAttribute}\` in the HTML
|
|
197
|
+
below. Return a single CSS or XPath selector that UNIQUELY locates that element —
|
|
198
|
+
but do NOT use \`${markerAttribute}\` (it is temporary and gone at replay).
|
|
199
|
+
Instead, anchor on the env-derived text near it (e.g. a sibling/ancestor title or
|
|
200
|
+
label), written as a {{$.env.NAME}} placeholder (use the \`| filter\` form when the
|
|
201
|
+
on-page text is a transformed form of the value). Prefer an XPath that finds the
|
|
202
|
+
container by its env-derived text and then descends to the target. If you cannot
|
|
203
|
+
build a reliable, unique anchor, return null.
|
|
204
|
+
|
|
205
|
+
Env vars in play:
|
|
206
|
+
${envMapping}`,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
type: 'user',
|
|
210
|
+
items: [{ type: 'text', text: neighborhoodHtml }],
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Apply the model's decision and prune the result. PURE:
|
|
216
|
+
*
|
|
217
|
+
* 1. **Validate + templatize.** A rewrite is accepted only if interpolating it
|
|
218
|
+
* with the current env values reproduces the exact original candidate — so the
|
|
219
|
+
* model is bound to placeholder substitution; any other edit is rejected and
|
|
220
|
+
* that candidate keeps its literal. A wrong-shaped decision is ignored wholesale.
|
|
221
|
+
* 2. **Elide.** Once a candidate tracks an env value, drop any purely-positional
|
|
222
|
+
* candidate that does not: for a data-driven action, position is a coincidence
|
|
223
|
+
* of the recorded data and would mislocate when the value (or order) changes.
|
|
224
|
+
*
|
|
225
|
+
* Never returns empty — falls back to the raw candidates if pruning would remove
|
|
226
|
+
* everything (and elision never fires unless a semantic, env-bearing candidate
|
|
227
|
+
* survives to take over).
|
|
228
|
+
*/
|
|
229
|
+
function advanceSelectorCandidates(rawCandidates, envData, decision) {
|
|
230
|
+
const rewrites = decision?.element;
|
|
231
|
+
if (!Array.isArray(rewrites) || rewrites.length !== rawCandidates.length) {
|
|
232
|
+
Logger_1.appLogger.warn(`Advanced selector decision shape mismatch (${rewrites?.length ?? 'none'} vs ${rawCandidates.length}); keeping the generated selector.`);
|
|
233
|
+
return rawCandidates;
|
|
234
|
+
}
|
|
235
|
+
const dataSource = { env: envData, vars: {}, calls: [] };
|
|
236
|
+
const templatized = rawCandidates.map((raw, index) => {
|
|
237
|
+
const rewrite = rewrites[index];
|
|
238
|
+
return typeof rewrite === 'string' &&
|
|
239
|
+
(0, TemplateInterpolator_1.interpolateString)(rewrite, dataSource) === raw
|
|
240
|
+
? rewrite
|
|
241
|
+
: raw;
|
|
242
|
+
});
|
|
243
|
+
return pruneCoincidentalPositional(templatized);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Once a candidate tracks an env value, drop purely-positional candidates that
|
|
247
|
+
* don't — for a data-driven action, position is a coincidence of the recorded
|
|
248
|
+
* data and would mislocate when the value (or order) changes. Returns the input
|
|
249
|
+
* unchanged when nothing is env-bearing (no semantic anchor to take over) or when
|
|
250
|
+
* pruning would remove everything. PURE.
|
|
251
|
+
*/
|
|
252
|
+
function pruneCoincidentalPositional(candidates) {
|
|
253
|
+
if (!candidates.some(isEnvBearingSelector)) {
|
|
254
|
+
return candidates;
|
|
255
|
+
}
|
|
256
|
+
const pruned = candidates.filter((candidate) => isEnvBearingSelector(candidate) || !isPurelyPositionalSelector(candidate));
|
|
257
|
+
return pruned.length > 0 ? pruned : candidates;
|
|
258
|
+
}
|
|
259
|
+
/** A selector that carries an env reference (so it tracks the value at replay). */
|
|
260
|
+
function isEnvBearingSelector(selector) {
|
|
261
|
+
return selector.includes('{{$.env.');
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Heuristic for a selector that locates an element *purely by DOM position* — an
|
|
265
|
+
* `:nth-*` pseudo, an XPath numeric index `[N]`, or a bare combinator chain — with
|
|
266
|
+
* no meaningful anchor (id, class, attribute, or text predicate). PURE.
|
|
267
|
+
*
|
|
268
|
+
* The "meaningful anchor" guard is what keeps a text/attribute selector that also
|
|
269
|
+
* happens to carry an index (e.g. `(.//a[normalize-space(.)='X'])[2]`) from being
|
|
270
|
+
* treated as positional.
|
|
271
|
+
*/
|
|
272
|
+
function isPurelyPositionalSelector(selector) {
|
|
273
|
+
const hasMeaningfulAnchor = /#[\w-]/.test(selector) || // css id (not a bare '#')
|
|
274
|
+
/\.[a-zA-Z][\w-]*/.test(selector) || // css class (dot + identifier, not xpath './/')
|
|
275
|
+
/@[a-zA-Z_-]+/.test(selector) || // xpath attribute axis
|
|
276
|
+
/\[[a-zA-Z][\w-]*\s*[*^$~|]?=/.test(selector) || // css attribute selector
|
|
277
|
+
/normalize-space|text\(\)|contains\(/.test(selector); // text predicate
|
|
278
|
+
if (hasMeaningfulAnchor) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return (/:nth-(child|of-type|last-child|last-of-type)\b/.test(selector) ||
|
|
282
|
+
/\[\d+\]/.test(selector) ||
|
|
283
|
+
/[>+~]/.test(selector));
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Synthesize a selector that locates the target by anchoring on env-derived text
|
|
287
|
+
* in a NEARBY element (e.g. a card's title) — for the case where the target's own
|
|
288
|
+
* selectors are purely positional. Captures the target's smallest ancestor whose
|
|
289
|
+
* text contains an env value form, asks the model to write a value-anchored
|
|
290
|
+
* selector, then validates it against the live (pre-interaction) page: it is
|
|
291
|
+
* accepted only if, interpolated with the current env values, it uniquely resolves
|
|
292
|
+
* back to the target element. Returns null when there is nothing to anchor on, the
|
|
293
|
+
* model declines, or validation fails.
|
|
294
|
+
*/
|
|
295
|
+
async function synthesizeAnchoredSelector(target, context, referencedEnvVars) {
|
|
296
|
+
const gptClient = context.gptClient;
|
|
297
|
+
if (!gptClient) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
// Every form (verbatim + transforms) of each referenced value, for locating the
|
|
301
|
+
// value in the surrounding DOM text.
|
|
302
|
+
const valueForms = referencedEnvVars.flatMap((name) => {
|
|
303
|
+
const value = context.envData[name];
|
|
304
|
+
return value ? (0, TemplateInterpolator_1.envValueForms)(value).map((entry) => entry.form) : [];
|
|
305
|
+
});
|
|
306
|
+
if (valueForms.length === 0) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
// Capture the nearest ancestor whose text contains an env value form, with the
|
|
310
|
+
// target temporarily marked so the model knows which element to anchor to.
|
|
311
|
+
let neighborhoodHtml = null;
|
|
312
|
+
try {
|
|
313
|
+
await target.evaluate((el, marker) => el.setAttribute(marker, ''), SYNTH_TARGET_MARKER);
|
|
314
|
+
neighborhoodHtml = await target.evaluate((el, forms) => {
|
|
315
|
+
let node = el.parentElement;
|
|
316
|
+
while (node) {
|
|
317
|
+
const text = node.textContent ?? '';
|
|
318
|
+
if (forms.some((form) => form.length > 0 && text.includes(form))) {
|
|
319
|
+
return node.outerHTML;
|
|
320
|
+
}
|
|
321
|
+
node = node.parentElement;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}, valueForms);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
Logger_1.appLogger.warn('Failed to capture neighborhood for selector synthesis.', error);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await target
|
|
332
|
+
.evaluate((el, marker) => el.removeAttribute(marker), SYNTH_TARGET_MARKER)
|
|
333
|
+
.catch(() => undefined);
|
|
334
|
+
}
|
|
335
|
+
if (!neighborhoodHtml) {
|
|
336
|
+
return null; // the value isn't near the target — nothing to anchor on
|
|
337
|
+
}
|
|
338
|
+
const html = neighborhoodHtml.length > MAX_NEIGHBORHOOD_HTML
|
|
339
|
+
? `${neighborhoodHtml.slice(0, MAX_NEIGHBORHOOD_HTML)}…`
|
|
340
|
+
: neighborhoodHtml;
|
|
341
|
+
const envMapping = referencedEnvVars
|
|
342
|
+
.filter((name) => context.envData[name])
|
|
343
|
+
.map((name) => ` - {{$.env.${name}}} = ${JSON.stringify(context.envData[name])}`)
|
|
344
|
+
.join('\n');
|
|
345
|
+
let synthesized;
|
|
346
|
+
try {
|
|
347
|
+
const outcome = await gptClient.getStructuredOutput(buildSynthesisRequest(html, envMapping, SYNTH_TARGET_MARKER), exports.SynthesizedSelectorSchema);
|
|
348
|
+
MiscUtils_1.MiscUtils.updateTokenCounts(outcome, context.metadata);
|
|
349
|
+
synthesized = outcome.output.selector;
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
Logger_1.appLogger.warn('Selector synthesis model call failed.', error);
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
if (!synthesized || !isEnvBearingSelector(synthesized)) {
|
|
356
|
+
return null; // model declined, or produced a non-parameterized selector
|
|
357
|
+
}
|
|
358
|
+
// Validate against the live page: interpolate with current env values and
|
|
359
|
+
// confirm the result uniquely resolves to the target element.
|
|
360
|
+
const interpolated = (0, TemplateInterpolator_1.interpolateString)(synthesized, {
|
|
361
|
+
env: context.envData,
|
|
362
|
+
vars: {},
|
|
363
|
+
calls: [],
|
|
364
|
+
});
|
|
365
|
+
const resolvesToTarget = await selectorUniquelyResolvesToTarget(target, interpolated).catch(() => false);
|
|
366
|
+
if (!resolvesToTarget) {
|
|
367
|
+
Logger_1.appLogger.warn(`Synthesized selector rejected (did not uniquely resolve to the target): ${synthesized}`);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
return synthesized;
|
|
371
|
+
}
|
|
372
|
+
/** True when `selector` resolves to exactly one element and it is `target`. */
|
|
373
|
+
async function selectorUniquelyResolvesToTarget(target, selector) {
|
|
374
|
+
const frame = await target.ownerFrame();
|
|
375
|
+
if (!frame) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const locator = frame.locator(PlaywrightUtils_1.PlaywrightUtils.normalizeSelector(selector));
|
|
379
|
+
if ((await locator.count()) !== 1) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
return locator
|
|
383
|
+
.first()
|
|
384
|
+
.evaluate((element, other) => element === other, target);
|
|
385
|
+
}
|
|
386
|
+
async function deriveFrameSelector(target) {
|
|
387
|
+
const frame = await target.ownerFrame();
|
|
388
|
+
// No owning frame, or a top-level frame (no parent) -> no frame selector.
|
|
389
|
+
if (!frame?.parentFrame()) {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
const frameElement = await frame.frameElement();
|
|
393
|
+
const selectors = await PlaywrightUtils_1.PlaywrightUtils.generateSelectors(frameElement);
|
|
394
|
+
return selectors[0] ?? null;
|
|
395
|
+
}
|
|
396
|
+
//# 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
|