dsh-browser-playwright-codex 0.6.5 → 0.7.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.
@@ -0,0 +1,46 @@
1
+ /** Managed block markers: everything between them belongs to this plugin. */
2
+ export declare const BEGIN = "# >>> dsh-browser: \u6D4F\u89C8\u5668\u63D2\u4EF6\u914D\u7F6E\uFF08\u672C\u5757\u7531\u8BBE\u7F6E\u9875\u7EF4\u62A4\uFF0C\u4E5F\u53EF\u624B\u5DE5\u7F16\u8F91\uFF09>>>";
3
+ export declare const END = "# <<< dsh-browser <<<";
4
+ /**
5
+ * The pre-merge markers, written by dsh-browser-toggle 0.3.0. Accepted so an
6
+ * existing block is replaced in place instead of accumulating a second row that
7
+ * would patch the same entry twice.
8
+ */
9
+ export declare const LEGACY_BEGIN = "# >>> dsh-browser-toggle: \u5F3A\u529B\u5DE5\u5177\u95F8\u95E8\uFF08\u672C\u5757\u7531\u8BBE\u7F6E\u9875\u7EF4\u62A4\uFF0C\u4E5F\u53EF\u624B\u5DE5\u7F16\u8F91\uFF09>>>";
10
+ export declare const LEGACY_END = "# <<< dsh-browser-toggle <<<";
11
+ /** Path of the home-level patch layer (highest priority among patch files). */
12
+ export declare function homePatchPath(env?: NodeJS.ProcessEnv): string;
13
+ /**
14
+ * The managed block: one loader row for the whole plugin, with the shipped
15
+ * launch/provider/snapshot values repeated and the two gates filled in.
16
+ *
17
+ * @param allowEvaluate - register browser_evaluate
18
+ * @param allowCdp - register browser_cdp
19
+ * @returns the YAML block, markers included
20
+ */
21
+ export declare function renderBrowserBlock(allowEvaluate: boolean, allowCdp: boolean): string;
22
+ /** Merge the managed block into `original`, replacing an existing one in place. */
23
+ export declare function mergeBrowserBlock(original: string, allowEvaluate: boolean, allowCdp: boolean): string;
24
+ /** Read the current managed block, or undefined when there is none. */
25
+ export declare function parseBrowserBlock(text: string): {
26
+ allowEvaluate: boolean;
27
+ allowCdp: boolean;
28
+ } | undefined;
29
+ /** Back-compat aliases used by the absorbed settings-card host half. */
30
+ export declare const parseGatesBlock: typeof parseBrowserBlock;
31
+ /** Back-compat alias (see {@link mergeBrowserBlock}). */
32
+ export declare const mergeGatesBlock: typeof mergeBrowserBlock;
33
+ /**
34
+ * Write both gates into the home-level patch. Backup first, atomic rename, then
35
+ * read back and check; on a failed check the previous bytes are restored.
36
+ *
37
+ * @param allowEvaluate - register browser_evaluate
38
+ * @param allowCdp - register browser_cdp
39
+ * @param env - environment (DSH_HOME override for tests)
40
+ * @returns the file written and the values it now carries
41
+ */
42
+ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, env?: NodeJS.ProcessEnv): Promise<{
43
+ patch: string;
44
+ allowEvaluate: boolean;
45
+ allowCdp: boolean;
46
+ }>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Home-level patch writer for the merged single-row plugin (2026-10-19).
3
+ *
4
+ * Reads and rewrites ONE managed block inside the home-level cordis.patch.yml
5
+ * (the patch layer with the highest priority, so the user's own choice wins over
6
+ * any profile layer and over the package default). Everything outside the block
7
+ * is preserved byte for byte.
8
+ *
9
+ * Why the block carries the WHOLE row: the loader replaces an entry's `config`
10
+ * wholesale, so a block that lists only the two gates would silently reset the
11
+ * launch options, the idle timeout and the snapshot engine to their defaults.
12
+ * The template below therefore mirrors cordis.patch.yml's shipped values and
13
+ * differs only in the two gate booleans.
14
+ *
15
+ * Dependency-free on purpose: a half-written patch layer breaks every plugin
16
+ * load in the profile, so this file only uses node builtins and stays testable
17
+ * on its own.
18
+ *
19
+ * @module dsh-browser-playwright-codex/gates-patch
20
+ */
21
+ import { readFile, writeFile, rename, copyFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ /** Managed block markers: everything between them belongs to this plugin. */
25
+ export const BEGIN = '# >>> dsh-browser: 浏览器插件配置(本块由设置页维护,也可手工编辑)>>>';
26
+ export const END = '# <<< dsh-browser <<<';
27
+ /**
28
+ * The pre-merge markers, written by dsh-browser-toggle 0.3.0. Accepted so an
29
+ * existing block is replaced in place instead of accumulating a second row that
30
+ * would patch the same entry twice.
31
+ */
32
+ export const LEGACY_BEGIN = '# >>> dsh-browser-toggle: 强力工具闸门(本块由设置页维护,也可手工编辑)>>>';
33
+ export const LEGACY_END = '# <<< dsh-browser-toggle <<<';
34
+ /** Path of the home-level patch layer (highest priority among patch files). */
35
+ export function homePatchPath(env = process.env) {
36
+ return join(env.DSH_HOME || join(homedir(), '.dsh'), 'cordis.patch.yml');
37
+ }
38
+ /**
39
+ * The managed block: one loader row for the whole plugin, with the shipped
40
+ * launch/provider/snapshot values repeated and the two gates filled in.
41
+ *
42
+ * @param allowEvaluate - register browser_evaluate
43
+ * @param allowCdp - register browser_cdp
44
+ * @returns the YAML block, markers included
45
+ */
46
+ export function renderBrowserBlock(allowEvaluate, allowCdp) {
47
+ return [
48
+ BEGIN,
49
+ '# allowEvaluate: 注册 browser_evaluate(页面任意 JS,能用你的登录态操作网站)',
50
+ '# allowCdp: 注册 browser_cdp(原始 CDP,仅放行检查与输入模拟)',
51
+ '# 包内默认关闭;这里的值就是用户自己的选择。改完由热重载生效。',
52
+ '# 整行 config 会被整体替换,所以这里写全 —— 只写两个闸门会把其它设置重置成默认值。',
53
+ '- id: browser',
54
+ " name: 'dsh-browser-playwright-codex'",
55
+ ' config:',
56
+ ' service:',
57
+ ' provider: playwright',
58
+ ' playwright:',
59
+ ' launch:',
60
+ ' headless: false',
61
+ ' persistent: true',
62
+ ' viewport:',
63
+ ' width: 1280',
64
+ ' height: 800',
65
+ ' navigationTimeoutMs: 30000',
66
+ ' ignoreHTTPSErrors: false',
67
+ ' idleTimeoutMs: 0',
68
+ ' maxSessions: 8',
69
+ ' snapshot:',
70
+ ' diff: false',
71
+ ' tool:',
72
+ ' toolPrefix: "browser_"',
73
+ ' allowEvaluate: ' + (allowEvaluate ? 'true' : 'false'),
74
+ ' allowCdp: ' + (allowCdp ? 'true' : 'false'),
75
+ ' registerDisabledTools: false',
76
+ ' diagnostics: false',
77
+ ' maxWaitMs: 60000',
78
+ END,
79
+ '',
80
+ ].join('\n');
81
+ }
82
+ /** Locate the managed block, preferring the current markers over the legacy ones. */
83
+ function findBlock(original) {
84
+ const markers = [
85
+ [BEGIN, END],
86
+ [LEGACY_BEGIN, LEGACY_END],
87
+ ];
88
+ for (const [begin, end] of markers) {
89
+ const beginIdx = original.indexOf(begin);
90
+ const endIdx = original.indexOf(end);
91
+ if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
92
+ let after = endIdx + end.length;
93
+ if (original[after] === '\n')
94
+ after += 1;
95
+ return { start: beginIdx, after };
96
+ }
97
+ }
98
+ return undefined;
99
+ }
100
+ /** Merge the managed block into `original`, replacing an existing one in place. */
101
+ export function mergeBrowserBlock(original, allowEvaluate, allowCdp) {
102
+ const block = renderBrowserBlock(allowEvaluate, allowCdp);
103
+ const found = findBlock(original);
104
+ if (found !== undefined)
105
+ return original.slice(0, found.start) + block + original.slice(found.after);
106
+ return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
107
+ }
108
+ /** Read the current managed block, or undefined when there is none. */
109
+ export function parseBrowserBlock(text) {
110
+ const found = findBlock(text);
111
+ if (found === undefined)
112
+ return undefined;
113
+ const body = text.slice(found.start, found.after);
114
+ const evaluate = /allowEvaluate:\s*(true|false)/.exec(body);
115
+ const cdp = /allowCdp:\s*(true|false)/.exec(body);
116
+ if (evaluate === null || cdp === null)
117
+ return undefined;
118
+ return { allowEvaluate: evaluate[1] === 'true', allowCdp: cdp[1] === 'true' };
119
+ }
120
+ /** Back-compat aliases used by the absorbed settings-card host half. */
121
+ export const parseGatesBlock = parseBrowserBlock;
122
+ /** Back-compat alias (see {@link mergeBrowserBlock}). */
123
+ export const mergeGatesBlock = mergeBrowserBlock;
124
+ /**
125
+ * Write both gates into the home-level patch. Backup first, atomic rename, then
126
+ * read back and check; on a failed check the previous bytes are restored.
127
+ *
128
+ * @param allowEvaluate - register browser_evaluate
129
+ * @param allowCdp - register browser_cdp
130
+ * @param env - environment (DSH_HOME override for tests)
131
+ * @returns the file written and the values it now carries
132
+ */
133
+ export async function writeGates(allowEvaluate, allowCdp, env = process.env) {
134
+ const file = homePatchPath(env);
135
+ let original = '';
136
+ try {
137
+ original = await readFile(file, 'utf8');
138
+ }
139
+ catch {
140
+ original = '';
141
+ }
142
+ const next = mergeBrowserBlock(original, allowEvaluate, allowCdp);
143
+ if (original !== '')
144
+ await copyFile(file, file + '.bak-browser-toggle').catch(() => { });
145
+ const tmp = file + '.tmp-browser-toggle';
146
+ await writeFile(tmp, next, 'utf8');
147
+ await rename(tmp, file);
148
+ const check = await readFile(file, 'utf8');
149
+ const parsed = parseBrowserBlock(check);
150
+ if (parsed === undefined || parsed.allowEvaluate !== allowEvaluate || parsed.allowCdp !== allowCdp) {
151
+ if (original !== '') {
152
+ await writeFile(file, original, 'utf8');
153
+ throw new Error('patch self-check failed after write; previous content restored');
154
+ }
155
+ throw new Error('patch self-check failed after write');
156
+ }
157
+ return { patch: file, allowEvaluate, allowCdp };
158
+ }
package/lib/index.d.ts CHANGED
@@ -20,3 +20,8 @@ export { Config as PlaywrightConfigSchema } from './config.ts';
20
20
  export type { PlaywrightConfig, PageData } from './config.ts';
21
21
  export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
22
22
  export type { ToolConfig } from './tool.ts';
23
+ export { Config as BrowserPluginConfigSchema, apply, name } from './plugin.ts';
24
+ export type { BrowserPluginConfig } from './plugin.ts';
25
+ export { apply as applyPlaywright, name as playwrightEntry, Config as PlaywrightEntryConfig } from './playwright.ts';
26
+ export { apply as applyTools, name as toolEntry, Config as ToolEntryConfig } from './tool.ts';
27
+ export { Config } from './plugin.ts';
package/lib/index.js CHANGED
@@ -12,3 +12,9 @@ export { default as BrowserRuntime } from "./service.js";
12
12
  export { PlaywrightProvider } from "./provider.js";
13
13
  export { assertAllowedUrl } from "./url-policy.js";
14
14
  export { Config as PlaywrightConfigSchema } from "./config.js";
15
+ // Single loader row (2026-10-19): the package root is the plugin. The three
16
+ // sub-entries below stay importable, but the loader mounts this one.
17
+ export { Config as BrowserPluginConfigSchema, apply, name } from "./plugin.js";
18
+ export { apply as applyPlaywright, name as playwrightEntry, Config as PlaywrightEntryConfig } from "./playwright.js";
19
+ export { apply as applyTools, name as toolEntry, Config as ToolEntryConfig } from "./tool.js";
20
+ export { Config } from "./plugin.js";
@@ -50,6 +50,15 @@ export declare function resolveClickPoint(session: PlaywrightSession, locator: L
50
50
  * elements shared the label.
51
51
  */
52
52
  export declare function clickText(session: PlaywrightSession, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
53
+ /**
54
+ * The href a click on this element would navigate to, for the URL policy.
55
+ * Its own href is not enough: a button or span nested inside an off-domain
56
+ * `<a href>` has no href itself yet the click bubbles into that link and
57
+ * navigates. The raw-coordinate path already checks the closest ancestor
58
+ * link; ref and text clicks must apply the same rule (B2 in the audit).
59
+ * Returns null when no link is involved, which the policy lets through.
60
+ */
61
+ export declare function clickTargetHref(locator: Locator): Promise<string | null>;
53
62
  /**
54
63
  * Shared click core: enforce the URL policy for link targets, run the
55
64
  * Codex-style geometry ladder with the Playwright actionability fallback,
@@ -59,7 +68,34 @@ export declare function clickText(session: PlaywrightSession, text: string, sign
59
68
  export declare function clickLocator(session: PlaywrightSession, locator: Locator, signal: AbortSignal | undefined, label: string, shared: number): Promise<import("./types.ts").BrowserSnapshot>;
60
69
  export declare function clickAt(session: PlaywrightSession, x: number, y: number, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
61
70
  export declare function clickAtRef(session: PlaywrightSession, ref: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
71
+ /**
72
+ * Does this field hold a secret (password, one-time code, card number)?
73
+ *
74
+ * Mechanism-level counterpart to the old promise "it never types your
75
+ * passwords": `browser_fill` refuses these fields so the promise is true by
76
+ * construction, not by the model remembering to behave. The visible window is
77
+ * where the human types them.
78
+ *
79
+ * @param spec - tag / type / autocomplete read from the live element
80
+ * @returns true when the field must be filled by the human
81
+ */
82
+ export declare function isCredentialField(spec: {
83
+ tag: string;
84
+ type?: string;
85
+ autocomplete?: string;
86
+ }): boolean;
62
87
  export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
88
+ /**
89
+ * Normalize a field value before comparing. Two levels of forgiveness, both of them
90
+ * things a page does to a value *after* we wrote it, not differences in what we wrote:
91
+ * whitespace is collapsed, and everything that is not a letter or a digit is dropped
92
+ * (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
93
+ * currency fields — which `browser_fill`'s own description names — while still failing
94
+ * a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
95
+ * differ once normalized. The old readback accepted a length tolerance and containment,
96
+ * which is how those used to report success (audit B4).
97
+ */
98
+ export declare function normalizeFillText(value: string): string;
63
99
  /** Read the field back and decide whether the fill landed. */
64
100
  export declare function verifyFill(_session: PlaywrightSession, locator: Locator, tag: string, text: string): Promise<boolean>;
65
101
  export declare function press(session: PlaywrightSession, ref: string, key: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
@@ -3,7 +3,7 @@
3
3
  * 滚动、前进后退、标签切换,以及承载它们的可中断等待管道(withAbort)。
4
4
  * @module dsh-browser-playwright-codex/page-actions
5
5
  */
6
- import { hitTest } from "./page-probes.js";
6
+ 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";
@@ -257,6 +257,17 @@ export async function clickText(session, text, signal) {
257
257
  }
258
258
  });
259
259
  }
260
+ /**
261
+ * The href a click on this element would navigate to, for the URL policy.
262
+ * Its own href is not enough: a button or span nested inside an off-domain
263
+ * `<a href>` has no href itself yet the click bubbles into that link and
264
+ * navigates. The raw-coordinate path already checks the closest ancestor
265
+ * link; ref and text clicks must apply the same rule (B2 in the audit).
266
+ * Returns null when no link is involved, which the policy lets through.
267
+ */
268
+ export async function clickTargetHref(locator) {
269
+ return (await locator.evaluate(clickTargetHrefProbe)) ?? null;
270
+ }
260
271
  /**
261
272
  * Shared click core: enforce the URL policy for link targets, run the
262
273
  * Codex-style geometry ladder with the Playwright actionability fallback,
@@ -264,7 +275,7 @@ export async function clickText(session, text, signal) {
264
275
  * in the landing note; shared is how many elements matched it (0 for refs).
265
276
  */
266
277
  export async function clickLocator(session, locator, signal, label, shared) {
267
- session.assertClickTargetAllowed(await locator.getAttribute('href'));
278
+ session.assertClickTargetAllowed(await clickTargetHref(locator));
268
279
  const page = session.currentPageSync();
269
280
  const urlBefore = page.url();
270
281
  let point;
@@ -396,8 +407,9 @@ export async function clickAtRef(session, ref, signal) {
396
407
  const page = session.currentPageSync();
397
408
  const urlBefore = page.url();
398
409
  // Ref mode resolves the element before clicking, so the link policy can
399
- // be enforced up front — the same rule browser_click applies.
400
- session.assertClickTargetAllowed(await locator.getAttribute('href'));
410
+ // be enforced up front — the same rule browser_click applies, including the
411
+ // ancestor link a nested control would navigate through.
412
+ session.assertClickTargetAllowed(await clickTargetHref(locator));
401
413
  try {
402
414
  // Codex-style geometry click with obstruction awareness:
403
415
  // scroll (centre → end → start), wait for the bounding rect to
@@ -468,13 +480,55 @@ export async function clickAtRef(session, ref, signal) {
468
480
  }
469
481
  });
470
482
  }
483
+ /** Autocomplete tokens whose value is a secret the human should type in the window. */
484
+ const CREDENTIAL_AUTOCOMPLETE = new Set([
485
+ 'current-password',
486
+ 'new-password',
487
+ 'one-time-code',
488
+ 'cc-number',
489
+ 'cc-csc',
490
+ 'cc-exp',
491
+ 'cc-exp-month',
492
+ 'cc-exp-year',
493
+ ]);
494
+ /**
495
+ * Does this field hold a secret (password, one-time code, card number)?
496
+ *
497
+ * Mechanism-level counterpart to the old promise "it never types your
498
+ * passwords": `browser_fill` refuses these fields so the promise is true by
499
+ * construction, not by the model remembering to behave. The visible window is
500
+ * where the human types them.
501
+ *
502
+ * @param spec - tag / type / autocomplete read from the live element
503
+ * @returns true when the field must be filled by the human
504
+ */
505
+ export function isCredentialField(spec) {
506
+ const tag = spec.tag.toLowerCase();
507
+ if (tag !== 'input' && tag !== 'textarea')
508
+ return false;
509
+ if ((spec.type ?? '').toLowerCase() === 'password')
510
+ return true;
511
+ const tokens = (spec.autocomplete ?? '').toLowerCase().trim().split(/\s+/);
512
+ const token = tokens[tokens.length - 1] ?? '';
513
+ return CREDENTIAL_AUTOCOMPLETE.has(token);
514
+ }
471
515
  export async function fill(session, ref, text, signal) {
472
516
  return session.run(async () => {
473
517
  const locator = await session.refLocator(ref);
474
518
  const page = session.currentPageSync();
475
519
  await session.waitForElementStable(locator);
476
520
  await session.assertActionable(locator, { editable: true });
477
- const tag = await locator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'input');
521
+ const spec = await locator
522
+ .evaluate((el) => {
523
+ const tag = el.tagName.toLowerCase();
524
+ const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
525
+ return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
526
+ })
527
+ .catch(() => ({ tag: 'input', type: '', autocomplete: '' }));
528
+ if (isCredentialField(spec)) {
529
+ 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.');
530
+ }
531
+ const tag = spec.tag;
478
532
  let lastError;
479
533
  let written = false;
480
534
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -492,9 +546,9 @@ export async function fill(session, ref, text, signal) {
492
546
  lastError = error;
493
547
  continue;
494
548
  }
495
- // Post-action verification: read the value back. Lenient match
496
- // so formatted/masked inputs do not false-positive, but a value
497
- // that never stuck gets one retry then a clear diagnostic.
549
+ // Post-action verification: read the value back and require it to match
550
+ // what was typed (whitespace-normalized only), so a value the page ignored
551
+ // or rewrote gets one retry then a clear diagnostic.
498
552
  written = await session.verifyFill(locator, tag, text);
499
553
  if (written)
500
554
  break;
@@ -507,24 +561,44 @@ export async function fill(session, ref, text, signal) {
507
561
  return session.settleAndSnapshot(page, false);
508
562
  });
509
563
  }
564
+ /**
565
+ * Normalize a field value before comparing. Two levels of forgiveness, both of them
566
+ * things a page does to a value *after* we wrote it, not differences in what we wrote:
567
+ * whitespace is collapsed, and everything that is not a letter or a digit is dropped
568
+ * (`1,000` ≡ `1000`) with case folded (`abc` ≡ `ABC`). That covers input masks and
569
+ * currency fields — which `browser_fill`'s own description names — while still failing
570
+ * a genuinely different value: `ABCD`→`ZZZZ`, `John`→`Jane` and `1234`→`12345` all
571
+ * differ once normalized. The old readback accepted a length tolerance and containment,
572
+ * which is how those used to report success (audit B4).
573
+ */
574
+ export function normalizeFillText(value) {
575
+ return String(value)
576
+ .normalize('NFKC')
577
+ .toLowerCase()
578
+ .replace(/[^\p{L}\p{N}]+/gu, '');
579
+ }
510
580
  /** Read the field back and decide whether the fill landed. */
511
581
  export async function verifyFill(_session, locator, tag, text) {
582
+ const expected = normalizeFillText(text);
512
583
  try {
513
584
  if (tag === 'select') {
514
585
  const label = await locator.evaluate((el) => {
515
586
  const o = el.selectedOptions[0];
516
587
  return o === undefined ? '' : o.label;
517
588
  });
518
- return label === text;
519
- }
520
- const value = await locator.inputValue();
521
- if (value === text)
522
- return true;
523
- // Lenient: formatted inputs (masks, prefixes) rewrite the display
524
- // value; accept containment or a near-equal length as "written".
525
- return (text.length > 0 &&
526
- value.length > 0 &&
527
- (value.includes(text) || text.includes(value) || Math.abs(value.length - text.length) <= 2));
589
+ return normalizeFillText(label) === expected;
590
+ }
591
+ if (tag === 'input' || tag === 'textarea') {
592
+ return normalizeFillText(await locator.inputValue()) === expected;
593
+ }
594
+ // contenteditable / role=textbox elements have no inputValue(); calling it
595
+ // throws, which used to turn a fill that HAD landed into VALUE_NOT_SET.
596
+ // Judge the text the page would show instead.
597
+ const contents = await locator.evaluate((el) => {
598
+ const node = el;
599
+ return [el.textContent ?? '', typeof node.innerText === 'string' ? node.innerText : ''];
600
+ });
601
+ return contents.some((candidate) => normalizeFillText(candidate) === expected);
528
602
  }
529
603
  catch {
530
604
  return false;
@@ -62,4 +62,9 @@ export declare function networkRequests(session: PlaywrightSession, args: {
62
62
  failure: string | null | undefined;
63
63
  }[];
64
64
  };
65
+ /**
66
+ * Bounded page data for browser_extract. Credential values (password fields and
67
+ * password-autocomplete fields) are blanked inside the page before the payload
68
+ * leaves the browser: the extract prompt is sent to an auxiliary model.
69
+ */
65
70
  export declare function pageData(session: PlaywrightSession): Promise<unknown>;
@@ -173,6 +173,11 @@ export function networkRequests(session, args) {
173
173
  })),
174
174
  };
175
175
  }
176
+ /**
177
+ * Bounded page data for browser_extract. Credential values (password fields and
178
+ * password-autocomplete fields) are blanked inside the page before the payload
179
+ * leaves the browser: the extract prompt is sent to an auxiliary model.
180
+ */
176
181
  export async function pageData(session) {
177
182
  return session.run(async () => {
178
183
  session.assertLive();
@@ -182,7 +187,38 @@ export async function pageData(session) {
182
187
  const fn = window.__dshPageData;
183
188
  if (typeof fn !== 'function')
184
189
  return { url: location.href, title: document.title, text: '', truncated: false, links: [], inputs: [] };
185
- return fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
190
+ const data = fn({ maxTextChars: 30000, maxLinks: 300, maxInputs: 200 });
191
+ // Credentials never leave the page. __dshPageData reads every field's current
192
+ // value, and browser_extract pastes that object into an auxiliary model's
193
+ // prompt, so a login or change-password page put the plaintext password into
194
+ // that prompt (audit B3). The field itself stays — "there is a password box
195
+ // here" is useful page structure — but its value is blanked. The collector and
196
+ // this pass use the same selector over the same document, so the i-th entry is
197
+ // the i-th field.
198
+ if (data !== null && Array.isArray(data.inputs)) {
199
+ const fields = document.querySelectorAll('input, select, textarea');
200
+ for (let index = 0; index < data.inputs.length; index += 1) {
201
+ const entry = data.inputs[index];
202
+ const field = fields[index];
203
+ if (entry === undefined || field === undefined)
204
+ continue;
205
+ const type = (field.getAttribute('type') ?? '').toLowerCase();
206
+ const autocomplete = (field.getAttribute('autocomplete') ?? '').toLowerCase();
207
+ if (type === 'password' ||
208
+ autocomplete.includes('current-password') ||
209
+ autocomplete.includes('new-password') ||
210
+ // One-time codes and card numbers are just as sensitive as passwords: a
211
+ // 2FA code in the prompt can be replayed, and a card number is payment
212
+ // data (independent review, P2-7). The field stays, its value does not.
213
+ autocomplete.includes('one-time-code') ||
214
+ autocomplete.includes('cc-number') ||
215
+ autocomplete.includes('cc-csc') ||
216
+ autocomplete.includes('cc-exp')) {
217
+ entry['value'] = '';
218
+ }
219
+ }
220
+ }
221
+ return data;
186
222
  };
187
223
  let data;
188
224
  try {
@@ -55,6 +55,18 @@ export declare const pageStabilityProbe: (arg: {
55
55
  frames: number;
56
56
  timeoutMs: number;
57
57
  }) => Promise<boolean>;
58
+ /**
59
+ * The href a click on this element would follow: the element's own href, or the
60
+ * nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
61
+ * hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
62
+ * text clicks used to look only at the element itself, so a button/span nested in an
63
+ * off-domain `<a>` reached the outside host while every check passed. This probe is
64
+ * that missing layer: locator.evaluate(clickTargetHrefProbe).
65
+ *
66
+ * A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
67
+ * caller's session policy keeps its existing exemption for them.
68
+ */
69
+ export declare const clickTargetHrefProbe: (el: Element) => string | null;
58
70
  /**
59
71
  * The click-point hit test, moved out of the session so it lives with the other page-side
60
72
  * functions: it is serialized into the page by locator.evaluate(), so the same
@@ -201,6 +201,39 @@ export const pageStabilityProbe = (arg) => new Promise((resolve) => {
201
201
  ];
202
202
  requestAnimationFrame(tick);
203
203
  });
204
+ /**
205
+ * The href a click on this element would follow: the element's own href, or the
206
+ * nearest ancestor `<a href>` — crossing shadow-root boundaries, the same way the
207
+ * hit-test walks. Raw-coordinate clicks already check `closest('a[href]')`; ref and
208
+ * text clicks used to look only at the element itself, so a button/span nested in an
209
+ * off-domain `<a>` reached the outside host while every check passed. This probe is
210
+ * that missing layer: locator.evaluate(clickTargetHrefProbe).
211
+ *
212
+ * A policy check, not a navigation: `javascript:` hrefs are returned as-is and the
213
+ * caller's session policy keeps its existing exemption for them.
214
+ */
215
+ export const clickTargetHrefProbe = (el) => {
216
+ const own = el.getAttribute('href');
217
+ if (own !== null && own !== '')
218
+ return own;
219
+ let node = el;
220
+ // Document order upward, hopping from a shadow root to its host when the
221
+ // element lives inside one. The seen guard makes a malformed cycle terminate.
222
+ const seen = [];
223
+ while (node !== null) {
224
+ if (seen.indexOf(node) !== -1)
225
+ break;
226
+ seen.push(node);
227
+ const anchor = node.closest('a[href]');
228
+ if (anchor !== null)
229
+ return anchor.getAttribute('href');
230
+ const root = node.getRootNode
231
+ ? node.getRootNode()
232
+ : null;
233
+ node = root !== null && root !== undefined && root.host !== undefined ? root.host : null;
234
+ }
235
+ return null;
236
+ };
204
237
  export const hitTest = (el) => {
205
238
  const r = el.getBoundingClientRect();
206
239
  const w = r.width;
@@ -110,9 +110,9 @@ export async function captureAria(session, page, opts) {
110
110
  const legacy = await session.captureLegacy(page, opts);
111
111
  return {
112
112
  ...legacy,
113
- engineNote: 'smart mode could not read session page, so it was rendered with the compatible engine. ' +
113
+ engineNote: 'smart mode could not read this page, so it was rendered with the compatible engine. ' +
114
114
  'The refs below belong to the compatible engine. Nothing is wrong with the page; ' +
115
- 'switch the page-reading mode in Settings if you want to stop seeing session.',
115
+ 'switch the page-reading mode in Settings if you would rather not see this fallback notice.',
116
116
  };
117
117
  }
118
118
  return {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The single loader entry: one package = one plugin (2026-10-19).
3
+ *
4
+ * Why this file exists: `dsh-client-modules` rejects distinct active Loader
5
+ * sources that resolve to the same package name, and a Settings card needs a
6
+ * client module. The browser capability used to be mounted as three rows
7
+ * (`/service`, `/playwright`, `/tool`) because it has three configurable
8
+ * concerns, which forced the card into a second package (dsh-browser-toggle).
9
+ * One row per package makes the card legal inside this package, so the whole
10
+ * product is now one plugin: this entry mounts the three concerns as child
11
+ * plugins and keeps their own config schemas untouched.
12
+ *
13
+ * Config shape (see cordis.patch.yml):
14
+ *
15
+ * service: { provider } browser seam
16
+ * playwright: { launch, idleTimeoutMs, ... } provider / window / snapshot
17
+ * tool: { toolPrefix, allowEvaluate, ... } the model-facing tools
18
+ *
19
+ * Each child still exports its original schema and apply(), so the subpath
20
+ * entries (`./service`, `./playwright`, `./tool`) keep working for tests, the
21
+ * verify script and anyone importing the package directly.
22
+ *
23
+ * @module dsh-browser-playwright-codex/plugin
24
+ */
25
+ import type { Context } from '@deepseek-ai/cordis';
26
+ import z from '@deepseek-ai/schemastery';
27
+ import { type BrowserRuntimeConfig } from './service.ts';
28
+ import { type PlaywrightConfig } from './config.ts';
29
+ import { type ToolConfig } from './tool.ts';
30
+ /** Config accepted by the single loader row. */
31
+ export interface BrowserPluginConfig {
32
+ /** Browser seam: which registered provider to use. */
33
+ service?: BrowserRuntimeConfig;
34
+ /** Provider: window, launch and snapshot settings. */
35
+ playwright?: PlaywrightConfig;
36
+ /** Model-facing tools: prefix, capability gates, wait caps. */
37
+ tool?: ToolConfig;
38
+ }
39
+ /** Schemastery validation for {@link BrowserPluginConfig}. */
40
+ export declare const Config: z<BrowserPluginConfig>;
41
+ /** Cordis plugin name used by loader diagnostics (this is the row id in patches). */
42
+ export declare const name = "browser";
43
+ /**
44
+ * Mount the whole capability: the seam, the Playwright provider, the tool family,
45
+ * and — when the deployment has a web server — the Settings card.
46
+ *
47
+ * @param ctx - plugin context
48
+ * @param config - nested config for each concern; the loader fills defaults
49
+ */
50
+ export declare function apply(ctx: Context, config: BrowserPluginConfig): void;
51
+ declare const _default: {
52
+ name: string;
53
+ Config: z<BrowserPluginConfig>;
54
+ apply: typeof apply;
55
+ };
56
+ export default _default;