dsh-browser-playwright-codex 0.8.0 → 0.8.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.
@@ -32,11 +32,14 @@ export declare function challengeNote(headless: boolean): string;
32
32
  export declare const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop \u2014 tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
33
33
  /**
34
34
  * Appended after an action that left the page byte-for-byte unchanged on a page
35
- * with almost nothing else to interact with. Worded as a guess, because that is
36
- * what it is: it is the shape an unrecognised wall has, and the whole point of
37
- * this tier is to cover the vendors we have no signature for.
35
+ * with almost nothing else to interact with. It states the observation and the
36
+ * recovery — re-snapshot in case the page is still rendering, then try another
37
+ * approach — and deliberately does NOT guess "anti-bot wall" or send the user to
38
+ * look at the window: a normal fill or checkbox toggle also lands here when the
39
+ * page state it changed is not in the compared subset, and scaring the user off
40
+ * on a successful action is worse than saying less.
38
41
  */
39
- export declare const NO_PROGRESS_NOTE = "[no visible change] that action left the page identical \u2014 same URL, same title, same elements \u2014 and the page offers almost nothing else to click. This is a guess, not a diagnosis: it is what an unrecognised anti-bot wall looks like. Take one browser_snapshot to confirm; if the page still looks unchanged, stop and tell the user what you were trying to do and ask them to look at the open window, instead of retrying the same action.";
42
+ export declare const NO_PROGRESS_NOTE = "[no visible change] this action did not produce a visible change. If what you just did was a submit, a search or a navigation, the page may still be rendering \u2014 take one more browser_snapshot to confirm. If it still looks the same, try a different approach (for example another entry point on the page).";
40
43
  /** A page with at most this many actionable elements counts as "nearly empty". */
41
44
  export declare const NO_PROGRESS_MAX_REFS = 3;
42
45
  /**
package/lib/challenge.js CHANGED
@@ -40,11 +40,27 @@ function scan(nodes, depth, maxDepth, out, cap) {
40
40
  return;
41
41
  const name = node.name ?? '';
42
42
  if (name !== '')
43
- out.push({ role: (node.role ?? '').toLowerCase(), name });
43
+ out.push({ role: (node.role ?? '').toLowerCase(), name, state: stateOf(node) });
44
44
  if (depth < maxDepth)
45
45
  scan(node.children, depth + 1, maxDepth, out, cap);
46
46
  }
47
47
  }
48
+ /**
49
+ * Compact interactive-state signature of one node. A fill or a toggle changes
50
+ * this without changing the role or name, so the no-progress fingerprint has to
51
+ * include it — otherwise a normal fill or checkbox toggle looks like "no visible
52
+ * change" and trips the anti-bot guess.
53
+ */
54
+ function stateOf(node) {
55
+ const parts = [];
56
+ if (node.checked !== undefined)
57
+ parts.push(node.checked ? 'checked' : 'unchecked');
58
+ if (node.selected !== undefined)
59
+ parts.push(node.selected ? 'selected' : 'unselected');
60
+ if (node.value !== undefined && node.value !== '')
61
+ parts.push('v=' + node.value);
62
+ return parts.join(',');
63
+ }
48
64
  /** Evidence string when one header rule holds, else null. */
49
65
  function headerEvidence(rule, headers) {
50
66
  if (headers === undefined)
@@ -232,11 +248,14 @@ export function challengeNote(headless) {
232
248
  export const CHALLENGE_NOTE = "[human verification] The page triggered an anti-bot challenge; it is NOT solvable by automation and must NOT be retried blindly. The browser window is open on the user's desktop — tell the user a verification appeared, wait for them to finish it (a few seconds), then call browser_snapshot again. The clearance cookie is saved to the profile automatically and keeps working for the session.";
233
249
  /**
234
250
  * Appended after an action that left the page byte-for-byte unchanged on a page
235
- * with almost nothing else to interact with. Worded as a guess, because that is
236
- * what it is: it is the shape an unrecognised wall has, and the whole point of
237
- * this tier is to cover the vendors we have no signature for.
251
+ * with almost nothing else to interact with. It states the observation and the
252
+ * recovery — re-snapshot in case the page is still rendering, then try another
253
+ * approach — and deliberately does NOT guess "anti-bot wall" or send the user to
254
+ * look at the window: a normal fill or checkbox toggle also lands here when the
255
+ * page state it changed is not in the compared subset, and scaring the user off
256
+ * on a successful action is worse than saying less.
238
257
  */
239
- export const NO_PROGRESS_NOTE = '[no visible change] that action left the page identical — same URL, same title, same elements — and the page offers almost nothing else to click. This is a guess, not a diagnosis: it is what an unrecognised anti-bot wall looks like. Take one browser_snapshot to confirm; if the page still looks unchanged, stop and tell the user what you were trying to do and ask them to look at the open window, instead of retrying the same action.';
258
+ export const NO_PROGRESS_NOTE = '[no visible change] this action did not produce a visible change. If what you just did was a submit, a search or a navigation, the page may still be rendering — take one more browser_snapshot to confirm. If it still looks the same, try a different approach (for example another entry point on the page).';
240
259
  /** A page with at most this many actionable elements counts as "nearly empty". */
241
260
  export const NO_PROGRESS_MAX_REFS = 3;
242
261
  /** Cheap, order-stable hash (djb2) so a fingerprint costs no prompt budget. */
@@ -261,7 +280,10 @@ export function snapshotFingerprint(input) {
261
280
  input.url ?? '',
262
281
  input.title ?? '',
263
282
  String(input.refs ?? 0),
264
- names.map((node) => node.role + '=' + node.name).join('\u0001'),
283
+ // Include interactive state (checked/selected/value) so a fill or a toggle
284
+ // registers as change — otherwise a normal fill looks like "no visible
285
+ // change" and trips the anti-bot guess.
286
+ names.map((node) => node.role + '=' + node.name + (node.state ? '[' + node.state + ']' : '')).join('\u0001'),
265
287
  ].join('\u0000');
266
288
  return hash(material);
267
289
  }
package/lib/client.js CHANGED
@@ -894,7 +894,8 @@ window.__ModuleLoader__.load({
894
894
  }),
895
895
  ],
896
896
  ),
897
- // 署名:原创作者 Joel Califa 的唯一条件(2026-11-08 书面授权)。放在卡片里,但不随开关隐藏——
897
+ // 署名:Joel Califa(2026-11-08 书面许可,唯一条件是署名)与 Dhvanil Patel
898
+ // (2026-11-11 邮件书面许可打包 + 修改)。放在卡片里,但不随开关隐藏——
898
899
  // 关掉特效不等于这份代码不在这台机器上。
899
900
  h(
900
901
  'div',
@@ -908,7 +909,32 @@ window.__ModuleLoader__.load({
908
909
  color: T.tertiary,
909
910
  },
910
911
  },
911
- '动效:Joel Califa(Swarm)· 代码:Dhvanil Patel(swarm-cursors)',
912
+ [
913
+ '动效:',
914
+ h(
915
+ 'a',
916
+ {
917
+ key: 'joel',
918
+ href: 'https://github.com/califa/swarm-site',
919
+ target: '_blank',
920
+ rel: 'noreferrer',
921
+ style: { color: T.tertiary, textDecoration: 'underline' },
922
+ },
923
+ 'Joel Califa(Swarm)',
924
+ ),
925
+ ' · 代码:',
926
+ h(
927
+ 'a',
928
+ {
929
+ key: 'dhvanil',
930
+ href: 'https://github.com/DhvanilPatel/swarm-cursors',
931
+ target: '_blank',
932
+ rel: 'noreferrer',
933
+ style: { color: T.tertiary, textDecoration: 'underline' },
934
+ },
935
+ 'Dhvanil Patel(swarm-cursors)',
936
+ ),
937
+ ],
912
938
  ),
913
939
  ],
914
940
  ),
package/lib/config.d.ts CHANGED
@@ -49,7 +49,7 @@ export interface PlaywrightConfig {
49
49
  * 测试配置、以及旧部署的 patch 层都能直接通过类型检查。
50
50
  */
51
51
  swarm?: {
52
- /** 出厂就开着:这是装完就能看见的那一下「哇」。设置卡上的开关可以随时关掉。 */
52
+ /** 出厂默认关:动效资产随包分发(0.8.1 起已获作者授权),但默认不播;想要的人在设置卡里打开。 */
53
53
  enabled: boolean;
54
54
  /** 只在某个网站第一次打开时播;false = 每次导航都播(不建议)。 */
55
55
  oncePerSite: boolean;
package/lib/config.js CHANGED
@@ -47,8 +47,7 @@ export const Config = z.object({
47
47
  .default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
48
48
  swarm: z
49
49
  .object({
50
- // 出厂关:动效资产不再随 npm 包分发(0.8.0 起),默认开会让每个新用户
51
- // 在"文件根本不存在"的状态下开箱报错。想要的人把资产放回 assets/ 再打开。
50
+ // 出厂关:资产在包里(0.8.1 起已获作者授权),但默认不播,避免每次开新站先把页面盖住。
52
51
  enabled: z.boolean().default(false),
53
52
  // 只在每个网站第一次打开时播,避免每次导航都把页面盖住。
54
53
  oncePerSite: z.boolean().default(true),
package/lib/errors.d.ts CHANGED
@@ -35,3 +35,20 @@ export declare function evaluateDisabled(): BrowserError;
35
35
  * @returns the denial, ready to throw.
36
36
  */
37
37
  export declare function cdpDisabled(): BrowserError;
38
+ /**
39
+ * Normalize a `browser_evaluate` result so it survives the harness's
40
+ * lossless-JSON output boundary.
41
+ *
42
+ * The harness (dsh-tools) rejects a tool result that is not losslessly
43
+ * JSON-serializable, and it is strictest about *top-level* shape: a bare array
44
+ * or `undefined` at the root fails its check with an opaque
45
+ * "value is not lossless JSON" that does not tell the model how to fix it. This
46
+ * wraps only those shapes — arrays, `undefined` and functions — into a JSON
47
+ * string plus a marker note, and leaves every other value (objects, strings,
48
+ * numbers, booleans, null) untouched so callers get back what their expression
49
+ * actually returned.
50
+ *
51
+ * @param value - the raw value the page expression evaluated to.
52
+ * @returns a lossless-JSON-safe value, with a note when it had to be stringified.
53
+ */
54
+ export declare function normalizeEvaluateResult(value: unknown): unknown;
package/lib/errors.js CHANGED
@@ -35,3 +35,31 @@ export function evaluateDisabled() {
35
35
  export function cdpDisabled() {
36
36
  return new BrowserError('CDP_DISABLED', "browser_cdp is disabled; set tool.allowCdp: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable raw CDP inspection commands");
37
37
  }
38
+ /**
39
+ * Normalize a `browser_evaluate` result so it survives the harness's
40
+ * lossless-JSON output boundary.
41
+ *
42
+ * The harness (dsh-tools) rejects a tool result that is not losslessly
43
+ * JSON-serializable, and it is strictest about *top-level* shape: a bare array
44
+ * or `undefined` at the root fails its check with an opaque
45
+ * "value is not lossless JSON" that does not tell the model how to fix it. This
46
+ * wraps only those shapes — arrays, `undefined` and functions — into a JSON
47
+ * string plus a marker note, and leaves every other value (objects, strings,
48
+ * numbers, booleans, null) untouched so callers get back what their expression
49
+ * actually returned.
50
+ *
51
+ * @param value - the raw value the page expression evaluated to.
52
+ * @returns a lossless-JSON-safe value, with a note when it had to be stringified.
53
+ */
54
+ export function normalizeEvaluateResult(value) {
55
+ if (Array.isArray(value)) {
56
+ return { note: 'array converted to JSON string', value: JSON.stringify(value) };
57
+ }
58
+ if (value === undefined) {
59
+ return { note: 'expression returned undefined' };
60
+ }
61
+ if (typeof value === 'function') {
62
+ return { note: 'function converted to string', value: String(value) };
63
+ }
64
+ return value;
65
+ }
@@ -20,7 +20,46 @@ export declare function homePatchPath(env?: NodeJS.ProcessEnv): string;
20
20
  * @returns the YAML block, markers included
21
21
  */
22
22
  export declare function renderBrowserBlock(allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
23
- /** Merge the managed block into `original`, replacing an existing one in place. */
23
+ /** The three gate keys, in the order a freshly created block writes them. */
24
+ declare const GATE_KEYS: readonly ["allowEvaluate", "allowCdp", "diagnostics"];
25
+ /** One of the three gate keys this writer owns. */
26
+ type GateKey = (typeof GATE_KEYS)[number];
27
+ /** The gate values as a record, for line-level rewriting. */
28
+ type GateValues = Record<GateKey, boolean>;
29
+ /**
30
+ * Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
31
+ * other line byte for byte.
32
+ *
33
+ * Why not re-render the whole block: the loader replaces an entry's `config`
34
+ * wholesale, so the block has to carry every key the user cares about — and it
35
+ * cannot know all of them. Re-rendering the template silently reset whatever
36
+ * the template did not list: `profileDir` (which wiped the saved login), the
37
+ * launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
38
+ * `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
39
+ * 2026-11-10: toggling one switch in the settings card logged the user out of
40
+ * every site, because their block said profileDir: 'D:/dsh-profile' and the
41
+ * template does not.
42
+ *
43
+ * Documented consequence, deliberately kept: keys the user never wrote stay
44
+ * unwritten (the package default applies), which is exactly what the user chose
45
+ * by not writing them.
46
+ *
47
+ * @param body - the full block text, markers included
48
+ * @param gates - the three gate values to write
49
+ * @returns the block with only those lines changed or added
50
+ */
51
+ export declare function patchBlockGates(body: string, gates: GateValues): string;
52
+ /**
53
+ * Merge the gate values into `original`.
54
+ *
55
+ * - No managed block yet → append the full template (nothing to preserve).
56
+ * - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
57
+ * - Block present but its structure defeats line editing (a flow-style
58
+ * `tool: { ... }`, a truncated block) → fall back to the whole template, which
59
+ * is the old behaviour: at least the switch the user just flipped takes
60
+ * effect. Guarded by a read-back check so a half-edited block can never be
61
+ * written out.
62
+ */
24
63
  export declare function mergeBrowserBlock(original: string, allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
25
64
  /** Read the current managed block, or undefined when there is none. */
26
65
  export declare function parseBrowserBlock(text: string): {
@@ -47,3 +86,4 @@ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, di
47
86
  allowCdp: boolean;
48
87
  diagnostics: boolean;
49
88
  }>;
89
+ export {};
@@ -80,6 +80,82 @@ export function renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) {
80
80
  '',
81
81
  ].join('\n');
82
82
  }
83
+ /** The three gate keys, in the order a freshly created block writes them. */
84
+ const GATE_KEYS = ['allowEvaluate', 'allowCdp', 'diagnostics'];
85
+ /** One `key: value # note` line of the managed block. */
86
+ const GATE_LINE = /^(\s*)(allowEvaluate|allowCdp|diagnostics)(\s*:\s*)(?:true|false)?[ \t]*(#.*)?$/;
87
+ /**
88
+ * Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
89
+ * other line byte for byte.
90
+ *
91
+ * Why not re-render the whole block: the loader replaces an entry's `config`
92
+ * wholesale, so the block has to carry every key the user cares about — and it
93
+ * cannot know all of them. Re-rendering the template silently reset whatever
94
+ * the template did not list: `profileDir` (which wiped the saved login), the
95
+ * launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
96
+ * `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
97
+ * 2026-11-10: toggling one switch in the settings card logged the user out of
98
+ * every site, because their block said profileDir: 'D:/dsh-profile' and the
99
+ * template does not.
100
+ *
101
+ * Documented consequence, deliberately kept: keys the user never wrote stay
102
+ * unwritten (the package default applies), which is exactly what the user chose
103
+ * by not writing them.
104
+ *
105
+ * @param body - the full block text, markers included
106
+ * @param gates - the three gate values to write
107
+ * @returns the block with only those lines changed or added
108
+ */
109
+ export function patchBlockGates(body, gates) {
110
+ const nl = body.includes('\r\n') ? '\r\n' : '\n';
111
+ const lines = body.split(/\r?\n/);
112
+ const seen = new Set();
113
+ let lastGateAt = -1;
114
+ let gateIndent = '';
115
+ let toolAt = -1;
116
+ let configIndent;
117
+ for (let index = 0; index < lines.length; index += 1) {
118
+ const line = lines[index] ?? '';
119
+ const gate = GATE_LINE.exec(line);
120
+ if (gate !== null) {
121
+ const key = gate[2];
122
+ seen.add(key);
123
+ const indent = gate[1] ?? '';
124
+ const comment = gate[4] === undefined ? '' : ' ' + gate[4];
125
+ lines[index] = indent + key + ': ' + (gates[key] ? 'true' : 'false') + comment;
126
+ lastGateAt = index;
127
+ gateIndent = indent;
128
+ continue;
129
+ }
130
+ if (toolAt === -1 && /^\s*tool\s*:\s*(#.*)?$/.test(line))
131
+ toolAt = index;
132
+ const config = /^(\s*)config\s*:/.exec(line);
133
+ if (configIndent === undefined && config !== null)
134
+ configIndent = config[1] ?? '';
135
+ }
136
+ const missing = GATE_KEYS.filter((key) => !seen.has(key));
137
+ if (missing.length === 0)
138
+ return lines.join(nl);
139
+ const rendered = missing.map((key) => key + ': ' + (gates[key] ? 'true' : 'false'));
140
+ if (lastGateAt >= 0) {
141
+ // Beside the gates already present: same indentation, same section.
142
+ lines.splice(lastGateAt + 1, 0, ...rendered.map((line) => gateIndent + line));
143
+ return lines.join(nl);
144
+ }
145
+ if (toolAt >= 0) {
146
+ const indent = (/^\s*/.exec(lines[toolAt] ?? '')?.[0] ?? '') + ' ';
147
+ lines.splice(toolAt + 1, 0, ...rendered.map((line) => indent + line));
148
+ return lines.join(nl);
149
+ }
150
+ // No tool section at all: add one just above the closing marker, inside config.
151
+ const endAt = lines.findIndex((line) => line.trim() === END || line.trim() === LEGACY_END);
152
+ const configPad = (configIndent ?? '') + ' ';
153
+ const insert = [configPad + 'tool:', ...rendered.map((line) => configPad + ' ' + line)];
154
+ if (endAt === -1)
155
+ return lines.concat(insert).join(nl);
156
+ lines.splice(endAt, 0, ...insert);
157
+ return lines.join(nl);
158
+ }
83
159
  /** Locate the managed block, preferring the current markers over the legacy ones. */
84
160
  function findBlock(original) {
85
161
  const markers = [
@@ -98,13 +174,35 @@ function findBlock(original) {
98
174
  }
99
175
  return undefined;
100
176
  }
101
- /** Merge the managed block into `original`, replacing an existing one in place. */
177
+ /**
178
+ * Merge the gate values into `original`.
179
+ *
180
+ * - No managed block yet → append the full template (nothing to preserve).
181
+ * - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
182
+ * - Block present but its structure defeats line editing (a flow-style
183
+ * `tool: { ... }`, a truncated block) → fall back to the whole template, which
184
+ * is the old behaviour: at least the switch the user just flipped takes
185
+ * effect. Guarded by a read-back check so a half-edited block can never be
186
+ * written out.
187
+ */
102
188
  export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics) {
103
- const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
189
+ const gates = { allowEvaluate, allowCdp, diagnostics };
104
190
  const found = findBlock(original);
105
- if (found !== undefined)
106
- return original.slice(0, found.start) + block + original.slice(found.after);
107
- return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
191
+ if (found === undefined) {
192
+ const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
193
+ return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
194
+ }
195
+ const current = original.slice(found.start, found.after);
196
+ const edited = patchBlockGates(current, gates);
197
+ const check = parseBrowserBlock(edited);
198
+ if (check !== undefined &&
199
+ check.allowEvaluate === allowEvaluate &&
200
+ check.allowCdp === allowCdp &&
201
+ check.diagnostics === diagnostics) {
202
+ return original.slice(0, found.start) + edited + original.slice(found.after);
203
+ }
204
+ const rebuilt = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
205
+ return original.slice(0, found.start) + rebuilt + original.slice(found.after);
108
206
  }
109
207
  /** Read the current managed block, or undefined when there is none. */
110
208
  export function parseBrowserBlock(text) {
package/lib/injected.js CHANGED
@@ -66,6 +66,22 @@ export const SNAPSHOT_SCRIPT = String.raw `
66
66
  return text.length > max ? text.slice(0, max) + '…' : text
67
67
  }
68
68
 
69
+ // Autocomplete tokens that mark a credential field. Mirrors CREDENTIAL_AUTOCOMPLETE
70
+ // in page-actions.ts so the snapshot never reads back what the user typed into one.
71
+ const CREDENTIAL_AUTOCOMPLETE = ['current-password', 'new-password', 'one-time-code', 'cc-number', 'cc-csc', 'cc-exp', 'cc-exp-month', 'cc-exp-year']
72
+
73
+ // Whether an element holds a secret (password, one-time code, card number).
74
+ // Same judgement as isCredentialField in page-actions.ts: type=password, or an
75
+ // autocomplete token list containing a credential token (not just the last one).
76
+ function isCredentialEl(el) {
77
+ const tag = el.tagName.toLowerCase()
78
+ if (tag !== 'input' && tag !== 'textarea') return false
79
+ const type = tag === 'input' ? (el.getAttribute('type') || '').toLowerCase() : ''
80
+ if (type === 'password') return true
81
+ const tokens = (el.getAttribute('autocomplete') || '').toLowerCase().split(/\s+/)
82
+ return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.indexOf(token) !== -1)
83
+ }
84
+
69
85
  function ownText(el, maxText) {
70
86
  let out = ''
71
87
  for (const node of el.childNodes) {
@@ -77,6 +93,13 @@ export const SNAPSHOT_SCRIPT = String.raw `
77
93
  }
78
94
 
79
95
  function nameOf(el, maxName, maxText, actionable) {
96
+ // An <option> names itself with its own text — never the enclosing <label> or
97
+ // <select>, whose textContent contains every option. Without this every option
98
+ // in one <select> shares the same long name and the model cannot tell them
99
+ // apart (measured 2026-11-11 on selenium.dev's web-form).
100
+ if (el.tagName.toLowerCase() === 'option') {
101
+ return clip((el.textContent || '').replace(/\s+/g, ' ').trim(), maxName)
102
+ }
80
103
  const aria = el.getAttribute('aria-label')
81
104
  if (aria && aria.trim()) return clip(aria.trim(), maxName)
82
105
  if (el.id) {
@@ -256,6 +279,28 @@ export const SNAPSHOT_SCRIPT = String.raw `
256
279
  if (el.tagName.toLowerCase() === 'input' && (role === 'checkbox' || role === 'radio')) node.checked = !!el.checked
257
280
  if (role === 'option') node.selected = !!el.selected
258
281
  if (el.disabled) node.disabled = true
282
+ // Expose the current value of a text entry field so the model can see what a
283
+ // fill actually left behind, and so a value change registers as page change.
284
+ // Credential fields (password, one-time code, card number) are excluded: the
285
+ // user typed those in the visible window, and reading them back into the
286
+ // snapshot would send the secret to the model API. Mirrors isCredentialField
287
+ // in page-actions.ts.
288
+ //
289
+ // A real <select> is left out on purpose: its NAME already shows the chosen
290
+ // option (the branch above), so a value flag would print the same fact twice.
291
+ // Excluding by TAG, not by role, matters: autocomplete search boxes routinely
292
+ // carry an explicit role="combobox" and their value must still be visible.
293
+ // An <option> carries its value attribute only when it differs from its text.
294
+ const isSelectEl = el.tagName.toLowerCase() === 'select'
295
+ const textEntry =
296
+ role === 'textbox' || role === 'textarea' || role === 'searchbox' || role === 'combobox'
297
+ if (textEntry && !isSelectEl && !isCredentialEl(el)) {
298
+ const v = (el.value !== undefined ? String(el.value) : '').replace(/\s+/g, ' ').trim()
299
+ if (v !== '') node.value = clip(v, opts.maxTextLength)
300
+ } else if (role === 'option') {
301
+ const ov = (el.getAttribute('value') || '').trim()
302
+ if (ov !== '' && ov !== nodeName) node.value = clip(ov, opts.maxNameLength)
303
+ }
259
304
  if (role === 'link') {
260
305
  const href = el.getAttribute('href')
261
306
  if (href && href.trim() && href.indexOf('javascript:') !== 0) node.href = href
@@ -84,6 +84,30 @@ export declare function isCredentialField(spec: {
84
84
  type?: string;
85
85
  autocomplete?: string;
86
86
  }): boolean;
87
+ /** What the live element says about itself, as far as the credential check needs. */
88
+ export interface FieldSpec {
89
+ tag: string;
90
+ type: string;
91
+ autocomplete: string;
92
+ }
93
+ /**
94
+ * Read tag/type/autocomplete from the live element — and refuse to guess.
95
+ *
96
+ * `browser_fill` promises it never types a secret. That promise used to depend
97
+ * on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
98
+ * autocomplete:''}))`): an unreadable field produced a spec that looks like a
99
+ * plain text box, the credential check said "not a credential", and the fill
100
+ * went ahead. Fail-open on a page whose attributes we cannot read is exactly
101
+ * the case where the answer is least certain, so it now fails closed: no spec,
102
+ * nothing typed, CREDENTIAL_FIELD with a clear next step.
103
+ *
104
+ * Cancel and deadline are NOT credential failures: they pass through untouched
105
+ * so the caller reports what actually happened.
106
+ *
107
+ * @param locator - the resolved target
108
+ * @returns the field spec read from the page
109
+ */
110
+ export declare function readFieldSpec(locator: Locator): Promise<FieldSpec>;
87
111
  export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
88
112
  /**
89
113
  * Normalize a field value before comparing. Two levels of forgiveness, both of them
@@ -7,6 +7,7 @@ import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
7
7
  import { BrowserError } from "./errors.js";
8
8
  import { assertAllowedUrl } from "./url-policy.js";
9
9
  import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
10
+ import { isReadTimeout } from "./read-deadline.js";
10
11
  import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
11
12
  import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
12
13
  import { maybePlaySwarm, stopSwarm, willPlaySwarm } from "./swarm-intro.js";
@@ -542,9 +543,45 @@ export function isCredentialField(spec) {
542
543
  return false;
543
544
  if ((spec.type ?? '').toLowerCase() === 'password')
544
545
  return true;
545
- const tokens = (spec.autocomplete ?? '').toLowerCase().trim().split(/\s+/);
546
- const token = tokens[tokens.length - 1] ?? '';
547
- return CREDENTIAL_AUTOCOMPLETE.has(token);
546
+ // Every token, not just the last one. `autocomplete` is a space-separated
547
+ // token list ("section-a shipping new-password webauthn") and only its LAST
548
+ // token used to be checked, so any credential token that was not written last
549
+ // read as an ordinary field (measured 2026-11-10).
550
+ const tokens = (spec.autocomplete ?? '').toLowerCase().split(/\s+/);
551
+ return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.has(token));
552
+ }
553
+ /**
554
+ * Read tag/type/autocomplete from the live element — and refuse to guess.
555
+ *
556
+ * `browser_fill` promises it never types a secret. That promise used to depend
557
+ * on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
558
+ * autocomplete:''}))`): an unreadable field produced a spec that looks like a
559
+ * plain text box, the credential check said "not a credential", and the fill
560
+ * went ahead. Fail-open on a page whose attributes we cannot read is exactly
561
+ * the case where the answer is least certain, so it now fails closed: no spec,
562
+ * nothing typed, CREDENTIAL_FIELD with a clear next step.
563
+ *
564
+ * Cancel and deadline are NOT credential failures: they pass through untouched
565
+ * so the caller reports what actually happened.
566
+ *
567
+ * @param locator - the resolved target
568
+ * @returns the field spec read from the page
569
+ */
570
+ export async function readFieldSpec(locator) {
571
+ try {
572
+ return await locator.evaluate((el) => {
573
+ const tag = el.tagName.toLowerCase();
574
+ const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
575
+ return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
576
+ });
577
+ }
578
+ catch (error) {
579
+ if (isAbortError(error) || isReadTimeout(error))
580
+ throw error;
581
+ const detail = error instanceof Error ? error.message : String(error);
582
+ throw new BrowserError('CREDENTIAL_FIELD', "browser_fill could not read this field's attributes, so it cannot rule out a password / one-time code / card field and typed nothing. Take a fresh snapshot (the element may have been replaced) and retry, or ask the user to fill it in the visible window. Underlying failure: " +
583
+ detail.slice(0, 200));
584
+ }
548
585
  }
549
586
  export async function fill(session, ref, text, signal) {
550
587
  return session.run(async () => {
@@ -552,13 +589,7 @@ export async function fill(session, ref, text, signal) {
552
589
  const page = session.currentPageSync();
553
590
  await session.waitForElementStable(locator);
554
591
  await session.assertActionable(locator, { editable: true });
555
- const spec = await locator
556
- .evaluate((el) => {
557
- const tag = el.tagName.toLowerCase();
558
- const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
559
- return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
560
- })
561
- .catch(() => ({ tag: 'input', type: '', autocomplete: '' }));
592
+ const spec = await readFieldSpec(locator);
562
593
  if (isCredentialField(spec)) {
563
594
  throw new BrowserError('CREDENTIAL_FIELD', 'browser_fill refuses credential fields (password, one-time code, card number): a human types those in the visible window. Ask the user to type it there, then take a fresh snapshot and continue with the rest.');
564
595
  }