dsh-browser-playwright-codex 0.8.0 → 0.8.2
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/CHANGELOG.md +64 -0
- package/NOTICE.md +10 -10
- package/README.md +25 -12
- package/SECURITY.md +12 -4
- package/assets/swarm-rebuild.js +3864 -0
- package/lib/challenge.d.ts +7 -4
- package/lib/challenge.js +28 -6
- package/lib/client.js +28 -2
- package/lib/config.d.ts +1 -1
- package/lib/config.js +1 -2
- package/lib/errors.d.ts +17 -0
- package/lib/errors.js +28 -0
- package/lib/gates-patch.d.ts +41 -1
- package/lib/gates-patch.js +163 -5
- package/lib/injected.js +45 -0
- package/lib/page-actions.d.ts +24 -0
- package/lib/page-actions.js +41 -10
- package/lib/session.js +55 -9
- package/lib/snapshot-aria.d.ts +0 -20
- package/lib/snapshot-aria.js +68 -3
- package/lib/snapshot-render.js +52 -7
- package/lib/swarm-contract.d.ts +11 -5
- package/lib/swarm-contract.js +11 -5
- package/lib/swarm-curtain.js +2 -2
- package/lib/swarm-intro.js +11 -9
- package/lib/tool-types.d.ts +10 -0
- package/lib/tool.js +6 -2
- package/lib/types.d.ts +6 -0
- package/package.json +18 -13
package/lib/challenge.d.ts
CHANGED
|
@@ -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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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]
|
|
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.
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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]
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/lib/gates-patch.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {};
|
package/lib/gates-patch.js
CHANGED
|
@@ -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,95 @@ function findBlock(original) {
|
|
|
98
174
|
}
|
|
99
175
|
return undefined;
|
|
100
176
|
}
|
|
101
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* A **top-level** loader row for `id: browser` that is not inside a managed block.
|
|
179
|
+
*
|
|
180
|
+
* Why this exists (2026-11-12, found while writing the settings-card tests): when the
|
|
181
|
+
* user's patch layer already carried an unmarked `- id: browser` row (hand-edited, or
|
|
182
|
+
* written by a version before the markers existed), `mergeBrowserBlock` used to append
|
|
183
|
+
* the full template — producing **two loader rows for the same id**. The loader then
|
|
184
|
+
* sees the package twice, which is the same class of failure as issue #4 (a save writes
|
|
185
|
+
* a patch that breaks the next start). Adopting the row instead keeps the user's keys
|
|
186
|
+
* and leaves exactly one row.
|
|
187
|
+
*
|
|
188
|
+
* @param original - the whole patch file
|
|
189
|
+
* @returns the row's bounds, or undefined when there is none
|
|
190
|
+
*/
|
|
191
|
+
function findUnmarkedRow(original) {
|
|
192
|
+
const head = /^([ \t]*)-[ \t]+id:[ \t]*browser[ \t]*(?:#.*)?$/m.exec(original);
|
|
193
|
+
if (head === null)
|
|
194
|
+
return undefined;
|
|
195
|
+
const start = head.index;
|
|
196
|
+
const indent = (head[1] ?? '').length;
|
|
197
|
+
const bodyStart = start + head[0].length;
|
|
198
|
+
// The row ends at the next sequence item at the same (or a shallower) indent.
|
|
199
|
+
let end = original.length;
|
|
200
|
+
const item = /^([ \t]*)-[ \t]+/gm;
|
|
201
|
+
item.lastIndex = bodyStart;
|
|
202
|
+
for (let found = item.exec(original); found !== null; found = item.exec(original)) {
|
|
203
|
+
if ((found[1] ?? '').length <= indent) {
|
|
204
|
+
end = found.index;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const body = original.slice(start, end);
|
|
209
|
+
const namesUs = /['"]dsh-browser-playwright-codex['"]/.test(body);
|
|
210
|
+
return { start, end, namesUs, indent };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Merge the gate values into `original`.
|
|
214
|
+
*
|
|
215
|
+
* - No managed block yet → append the full template (nothing to preserve).
|
|
216
|
+
* - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
|
|
217
|
+
* - Block present but its structure defeats line editing (a flow-style
|
|
218
|
+
* `tool: { ... }`, a truncated block) → fall back to the whole template, which
|
|
219
|
+
* is the old behaviour: at least the switch the user just flipped takes
|
|
220
|
+
* effect. Guarded by a read-back check so a half-edited block can never be
|
|
221
|
+
* written out.
|
|
222
|
+
*/
|
|
102
223
|
export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics) {
|
|
103
|
-
const
|
|
224
|
+
const gates = { allowEvaluate, allowCdp, diagnostics };
|
|
104
225
|
const found = findBlock(original);
|
|
105
|
-
if (found
|
|
106
|
-
|
|
107
|
-
|
|
226
|
+
if (found === undefined) {
|
|
227
|
+
const row = findUnmarkedRow(original);
|
|
228
|
+
if (row !== undefined) {
|
|
229
|
+
// An unmarked row already mounts this package. Wrapping it (instead of appending a
|
|
230
|
+
// second one) is what keeps "one loader row per package" true — the same invariant
|
|
231
|
+
// the 0.7.0 merge had to satisfy.
|
|
232
|
+
if (!row.namesUs) {
|
|
233
|
+
// It is somebody else's `- id: browser` row. Refuse rather than write a file that
|
|
234
|
+
// mounts two different packages under one id: a clear error now beats a fatal start.
|
|
235
|
+
throw new Error('the home patch already has an `- id: browser` row that is not this package; ' +
|
|
236
|
+
'refusing to add a second row for the same id — rename one of them by hand, then retry');
|
|
237
|
+
}
|
|
238
|
+
const nl = original.includes('\r\n') ? '\r\n' : '\n';
|
|
239
|
+
const body = original.slice(row.start, row.end).replace(/[ \t]+$/, '');
|
|
240
|
+
const wrapped = BEGIN + nl + body + nl + END + nl;
|
|
241
|
+
const edited = patchBlockGates(wrapped, gates);
|
|
242
|
+
const check = parseBrowserBlock(edited);
|
|
243
|
+
if (check !== undefined &&
|
|
244
|
+
check.allowEvaluate === allowEvaluate &&
|
|
245
|
+
check.allowCdp === allowCdp &&
|
|
246
|
+
check.diagnostics === diagnostics) {
|
|
247
|
+
return original.slice(0, row.start) + edited + original.slice(row.end);
|
|
248
|
+
}
|
|
249
|
+
const rebuilt = BEGIN + nl + renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) + END + nl;
|
|
250
|
+
return original.slice(0, row.start) + rebuilt + original.slice(row.end);
|
|
251
|
+
}
|
|
252
|
+
const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
253
|
+
return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
|
|
254
|
+
}
|
|
255
|
+
const current = original.slice(found.start, found.after);
|
|
256
|
+
const edited = patchBlockGates(current, gates);
|
|
257
|
+
const check = parseBrowserBlock(edited);
|
|
258
|
+
if (check !== undefined &&
|
|
259
|
+
check.allowEvaluate === allowEvaluate &&
|
|
260
|
+
check.allowCdp === allowCdp &&
|
|
261
|
+
check.diagnostics === diagnostics) {
|
|
262
|
+
return original.slice(0, found.start) + edited + original.slice(found.after);
|
|
263
|
+
}
|
|
264
|
+
const rebuilt = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
265
|
+
return original.slice(0, found.start) + rebuilt + original.slice(found.after);
|
|
108
266
|
}
|
|
109
267
|
/** Read the current managed block, or undefined when there is none. */
|
|
110
268
|
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
|
package/lib/page-actions.d.ts
CHANGED
|
@@ -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
|
package/lib/page-actions.js
CHANGED
|
@@ -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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
}
|