dsh-browser-playwright-codex 0.7.4 → 0.7.6

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/lib/session.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { BrowserContext, CDPSession, Locator, Page, Request } from 'playwright-core';
7
7
  import type { PlaywrightProvider } from './provider.ts';
8
- import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState } from './types.ts';
8
+ import type { BrowserNode, BrowserSnapshot, DiagnosticsEntry, DocumentResponse, LoadState, WaitCondition, WaitOutcome } from './types.ts';
9
9
  import type { RefSignature } from './page-snapshot.ts';
10
10
  import { type CookieRow } from './login-sites.ts';
11
11
  import type { ActionResult, PendingDialogRecord } from './provider.ts';
@@ -60,6 +60,32 @@ export declare class PlaywrightSession {
60
60
  /** Nonce of the baseline ref space; a change means navigation reset. */
61
61
  lastRefNonce: string | undefined;
62
62
  constructor(provider: PlaywrightProvider, owner: string, context: BrowserContext);
63
+ /**
64
+ * Turn a navigation the host guard refused into a named refusal.
65
+ *
66
+ * A page can navigate itself (location.href, meta refresh, a form submit, a
67
+ * 302) without passing any entry-point check. The guard stops the document
68
+ * request; this reports it, so "I clicked and nothing happened" becomes
69
+ * URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
70
+ * inherit an earlier refusal.
71
+ */
72
+ throwIfNavigationBlocked(): void;
73
+ /**
74
+ * Refuse to go on driving a page that is outside the allow-list.
75
+ *
76
+ * The request-level guard (navigation-guard.ts) catches navigations started
77
+ * from inside the page, but NOT a redirect the browser follows on its own:
78
+ * measured 2026-10-26, `context.route()` never sees the hop after a
79
+ * `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
80
+ * on the disallowed host with no route event at all. So every action that
81
+ * settles a page also checks where it actually ended up.
82
+ *
83
+ * The off-list page is abandoned for about:blank (hostless, always allowed)
84
+ * instead of being left there: otherwise the session would wedge on a page it
85
+ * may never drive, and "navigate somewhere allowed" could not fix it.
86
+ * @param page - the page this action ran on.
87
+ */
88
+ assertCurrentUrlAllowed(page: Page): Promise<void>;
63
89
  /**
64
90
  * The session's cookies, reduced to what the login inference needs. `value` is dropped here,
65
91
  * at the only place cookies enter the summary path, so nothing downstream can leak it.
@@ -248,6 +274,12 @@ export declare class PlaywrightSession {
248
274
  back(signal?: AbortSignal): Promise<BrowserSnapshot>;
249
275
  forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
250
276
  wait(ms: number, signal?: AbortSignal): Promise<void>;
277
+ /**
278
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
279
+ * quiet network. Validated here as well, so a programmatic caller gets the
280
+ * same refusals the tool layer gives instead of a raw Playwright timeout.
281
+ */
282
+ waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
251
283
  tabs(): Promise<{
252
284
  index: number;
253
285
  url: string;
@@ -278,9 +310,13 @@ export declare class PlaywrightSession {
278
310
  * Send one raw CDP command on the current page's session and return its
279
311
  * result. This is the "direct protocol" escape hatch behind every other
280
312
  * tool — the model can inspect geometry, dispatch trusted input events or
281
- * query the DOM the same way Chrome DevTools does. Policy (which methods
282
- * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
283
- * itself performs no filtering so internal callers keep full control.
313
+ * query the DOM the same way Chrome DevTools does.
314
+ *
315
+ * Both rules live here as well as in the tool layer, deliberately: the
316
+ * allow-list and the switch must hold for anything that reaches this method,
317
+ * not just for the registered tool. Before 2026-10-26 only `browser_cdp`
318
+ * checked them, so a caller working through the service could send
319
+ * `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
284
320
  */
285
321
  cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
286
322
  close(): Promise<void>;
package/lib/session.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { looksLikeToken, registrableDomain } from "./login-sites.js";
2
- import { BrowserError } from "./errors.js";
2
+ import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
3
+ import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
4
+ import { getGates } from "./runtime-state.js";
3
5
  import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
4
6
  import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
5
7
  import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
6
8
  import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
7
9
  import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
8
- import { assertAllowedUrl } from "./url-policy.js";
10
+ import { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
11
+ import { normalizeWaitCondition, SESSION_WAIT_CAP_MS, waitForCondition as runWait } from "./wait-conditions.js";
9
12
  /** Live session over one browser context owned by one caller. */
10
13
  export class PlaywrightSession {
11
14
  provider;
@@ -55,6 +58,66 @@ export class PlaywrightSession {
55
58
  this.owner = owner;
56
59
  this.context = context;
57
60
  }
61
+ /**
62
+ * Turn a navigation the host guard refused into a named refusal.
63
+ *
64
+ * A page can navigate itself (location.href, meta refresh, a form submit, a
65
+ * 302) without passing any entry-point check. The guard stops the document
66
+ * request; this reports it, so "I clicked and nothing happened" becomes
67
+ * URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
68
+ * inherit an earlier refusal.
69
+ */
70
+ throwIfNavigationBlocked() {
71
+ const refused = this.provider.takeBlockedNavigation();
72
+ if (refused === undefined)
73
+ return;
74
+ throw new BrowserError('URL_NOT_ALLOWED', 'the page tried to navigate to ' +
75
+ refused.url +
76
+ ' and was blocked: host ' +
77
+ refused.host +
78
+ ' is not in allowedDomains. Nothing left this site — the request was stopped before it reached the network. ' +
79
+ 'The page you were on is unchanged; tell the user the site tried to leave the allow-list.');
80
+ }
81
+ /**
82
+ * Refuse to go on driving a page that is outside the allow-list.
83
+ *
84
+ * The request-level guard (navigation-guard.ts) catches navigations started
85
+ * from inside the page, but NOT a redirect the browser follows on its own:
86
+ * measured 2026-10-26, `context.route()` never sees the hop after a
87
+ * `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
88
+ * on the disallowed host with no route event at all. So every action that
89
+ * settles a page also checks where it actually ended up.
90
+ *
91
+ * The off-list page is abandoned for about:blank (hostless, always allowed)
92
+ * instead of being left there: otherwise the session would wedge on a page it
93
+ * may never drive, and "navigate somewhere allowed" could not fix it.
94
+ * @param page - the page this action ran on.
95
+ */
96
+ async assertCurrentUrlAllowed(page) {
97
+ const raw = page.url();
98
+ if (raw === '')
99
+ return;
100
+ let target;
101
+ try {
102
+ target = new URL(raw);
103
+ }
104
+ catch {
105
+ return;
106
+ }
107
+ if (target.protocol !== 'http:' && target.protocol !== 'https:')
108
+ return;
109
+ const allowedDomains = this.provider.config.allowedDomains ?? [];
110
+ if (isHostAllowed(target.hostname, allowedDomains))
111
+ return;
112
+ await page.goto('about:blank').catch(() => { });
113
+ throw new BrowserError('URL_NOT_ALLOWED', 'the page ended up on host ' +
114
+ target.hostname +
115
+ ' (' +
116
+ raw +
117
+ '), which is not in allowedDomains — a redirect the browser followed on its own. ' +
118
+ 'The tab has been reset to about:blank, so nothing outside the allow-list stays reachable. ' +
119
+ 'Tell the user: the site tried to send this session off the allow-list.');
120
+ }
58
121
  /**
59
122
  * The session's cookies, reduced to what the login inference needs. `value` is dropped here,
60
123
  * at the only place cookies enter the summary path, so nothing downstream can leak it.
@@ -120,6 +183,9 @@ export class PlaywrightSession {
120
183
  }
121
184
  /** Run one operation under busy tracking: idle disposal defers until it settles. */
122
185
  run(operation) {
186
+ // Every action starts from a clean slate: a refusal recorded during an
187
+ // earlier action must not be reported as this one's outcome.
188
+ this.provider.blockedNavigation = undefined;
123
189
  this.provider.beginOp(this.owner);
124
190
  return Promise.resolve()
125
191
  .then(operation)
@@ -431,6 +497,14 @@ export class PlaywrightSession {
431
497
  await withAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal);
432
498
  });
433
499
  }
500
+ /**
501
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
502
+ * quiet network. Validated here as well, so a programmatic caller gets the
503
+ * same refusals the tool layer gives instead of a raw Playwright timeout.
504
+ */
505
+ async waitForCondition(condition, signal) {
506
+ return runWait(this, normalizeWaitCondition(condition, SESSION_WAIT_CAP_MS), signal);
507
+ }
434
508
  async tabs() {
435
509
  return this.run(async () => {
436
510
  this.assertLive();
@@ -491,6 +565,14 @@ export class PlaywrightSession {
491
565
  return pageData(this);
492
566
  }
493
567
  async evaluate(expression, signal) {
568
+ // The switch is enforced HERE, not only in the tool layer: browser_evaluate
569
+ // used to be guarded by tool.ts alone, so anything holding the service (a
570
+ // sibling plugin, a script) could run arbitrary page JavaScript with the
571
+ // user's logins while the setting said off (measured 2026-10-26). An
572
+ // unpublished gate set counts as OFF — a deployment that never said yes has
573
+ // not said yes.
574
+ if (!(getGates()?.allowEvaluate ?? false))
575
+ throw evaluateDisabled();
494
576
  return this.run(async () => {
495
577
  this.assertLive();
496
578
  this.provider.touch(this.owner);
@@ -511,11 +593,19 @@ export class PlaywrightSession {
511
593
  * Send one raw CDP command on the current page's session and return its
512
594
  * result. This is the "direct protocol" escape hatch behind every other
513
595
  * tool — the model can inspect geometry, dispatch trusted input events or
514
- * query the DOM the same way Chrome DevTools does. Policy (which methods
515
- * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
516
- * itself performs no filtering so internal callers keep full control.
596
+ * query the DOM the same way Chrome DevTools does.
597
+ *
598
+ * Both rules live here as well as in the tool layer, deliberately: the
599
+ * allow-list and the switch must hold for anything that reaches this method,
600
+ * not just for the registered tool. Before 2026-10-26 only `browser_cdp`
601
+ * checked them, so a caller working through the service could send
602
+ * `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
517
603
  */
518
604
  async cdp(method, params, signal) {
605
+ if (!(getGates()?.allowCdp ?? false))
606
+ throw cdpDisabled();
607
+ if (!isCdpMethodAllowed(method))
608
+ throw new BrowserError('CDP_DENIED', cdpDenialMessage(method));
519
609
  return this.run(async () => {
520
610
  this.assertLive();
521
611
  this.provider.touch(this.owner);
@@ -148,7 +148,9 @@ function registerDiskRoutes(ctx) {
148
148
  sendJson(res, 200, { ...report, browserActive: getEnabled() === true });
149
149
  }
150
150
  catch (error) {
151
- sendJson(res, 500, { error: { code: 'DISK_SCAN', message: String(error && error.message ? error.message : error) } });
151
+ sendJson(res, 500, {
152
+ error: { code: 'DISK_SCAN', message: String(error && error.message ? error.message : error) },
153
+ });
152
154
  }
153
155
  },
154
156
  }));
@@ -177,15 +179,24 @@ function registerDiskRoutes(ctx) {
177
179
  const targets = [];
178
180
  for (const value of payload.targets) {
179
181
  if (!isTargetId(value))
180
- return sendJson(res, 400, { error: { code: 'UNKNOWN_TARGET', message: 'unknown target: ' + String(value) } });
182
+ return sendJson(res, 400, {
183
+ error: { code: 'UNKNOWN_TARGET', message: 'unknown target: ' + String(value) },
184
+ });
181
185
  targets.push(value);
182
186
  }
183
187
  try {
184
- const report = await clean({ ...diskPaths(), targets, dryRun: payload.dryRun, browserActive: getEnabled() === true });
188
+ const report = await clean({
189
+ ...diskPaths(),
190
+ targets,
191
+ dryRun: payload.dryRun,
192
+ browserActive: getEnabled() === true,
193
+ });
185
194
  sendJson(res, 200, report);
186
195
  }
187
196
  catch (error) {
188
- sendJson(res, 500, { error: { code: 'DISK_CLEAN', message: String(error && error.message ? error.message : error) } });
197
+ sendJson(res, 500, {
198
+ error: { code: 'DISK_CLEAN', message: String(error && error.message ? error.message : error) },
199
+ });
189
200
  }
190
201
  },
191
202
  }));
package/lib/tool.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools';
3
3
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
4
- import { BrowserError } from "./errors.js";
4
+ import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
5
5
  import { registeredSuffixes } from "./contract.js";
6
6
  import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
7
7
  import { summarizeLoginSites } from "./login-sites.js";
@@ -9,6 +9,7 @@ import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
9
9
  import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
10
10
  import { parseJsonText, extractionPrompt } from "./extract.js";
11
11
  import { detectChallenge } from "./challenge.js";
12
+ import { describeWait, normalizeWaitCondition } from "./wait-conditions.js";
12
13
  import { renderDiagnostics, renderScreenshotValue, renderSnapshotValue, renderTabsValue, snapshotValue, } from "./tool-render.js";
13
14
  export { detectChallenge };
14
15
  /** Cordis plugin name used by loader diagnostics. */
@@ -58,12 +59,17 @@ export function apply(ctx, config) {
58
59
  // Tell the Settings page what is actually in effect. It cannot read this
59
60
  // config itself (the value may come from a bundle patch, a profile patch or a
60
61
  // home-level patch), so the tool family publishes it at registration and the
61
- // switch shows the truth instead of a guess. Driven by the same values that
62
- // decided which tools register, so the two can never disagree.
62
+ // switch shows the truth instead of a guess.
63
+ //
64
+ // These are the CAPABILITY values, not `registered.has(...)`: the session
65
+ // layer enforces the switch too, and `registerDisabledTools` deliberately
66
+ // registers a switched-off tool so the model can discover it. Reading
67
+ // registration as permission published "on" for a gate that was off — the
68
+ // settings card showed a switch that was not there.
63
69
  publishGates({
64
- allowEvaluate: registered.has('evaluate'),
65
- allowCdp: registered.has('cdp'),
66
- diagnostics: registered.has('console_messages'),
70
+ allowEvaluate: config.allowEvaluate,
71
+ allowCdp: config.allowCdp,
72
+ diagnostics: config.diagnostics,
67
73
  });
68
74
  /** Register one gated tool only when this configuration keeps it live. */
69
75
  const registerMaybe = (suffix, definition) => {
@@ -119,10 +125,9 @@ export function apply(ctx, config) {
119
125
  };
120
126
  ctx.tools.register(defineTool({
121
127
  name: p('navigate'),
122
- description: 'Navigate the persistent browser to a URL and return the new page snapshot. ' +
123
- 'The snapshot lists visible elements with stable ref= identifiers for later click/fill calls. ' +
124
- 'IRON RULE: if the page is already on the target URL, do NOT navigate to it again — re-navigating the ' +
125
- 'same URL reloads the page and can discard in-progress user input; only navigate when the URL must actually change.',
128
+ description: 'Navigate the persistent browser to a URL and return the new snapshot: visible elements with stable refs for later click/fill. ' +
129
+ 'IRON RULE: if the page is already on that URL, do NOT navigate again — a re-navigation reloads it and can ' +
130
+ 'discard input the user has typed; only navigate when the URL must actually change.',
126
131
  parameters: {
127
132
  url: { type: 'string', required: true, description: 'Absolute http(s) URL to open.' },
128
133
  waitFor: {
@@ -140,21 +145,17 @@ export function apply(ctx, config) {
140
145
  }));
141
146
  ctx.tools.register(defineTool({
142
147
  name: p('snapshot'),
143
- description: 'Return the current page snapshot: URL, title, and visible elements with stable ref= identifiers. ' +
144
- 'Use this after dynamic content changes to refresh refs before clicking. ' +
145
- 'An element shown with a name but NO [ref=...] has no ref handle of its own — ' +
148
+ description: 'Snapshot the current page: URL, title, visible elements with stable ref= ids. ' +
149
+ 'Take one after dynamic changes to refresh refs before clicking. ' +
150
+ 'A named element with NO [ref=...] has no handle of its own: ' +
146
151
  p('click') +
147
- ' and ' +
148
- p('hover') +
149
- ' still reach it by passing its exact label as text, as long as the element is visible at that moment. ' +
150
- 'An effect can be real even when the URL does not change and no tab opens (a same-page overlay or popup): compare refs and content between snapshots instead of trusting the URL. ' +
151
- 'A native dialog (alert / confirm / prompt / beforeunload) is not part of the page: it is left PENDING and blocks the page, reported at the top of the result as [dialog] type "message"; answer it with ' +
152
+ ' reaches it by exact label text (also with mode:"hover") while it is visible. ' +
153
+ 'A real effect can leave the URL and tab count unchanged (same-page overlay): compare refs and content between snapshots, not the URL. ' +
154
+ 'A native dialog (alert / confirm / prompt / beforeunload) is not page content: it stays PENDING, blocks the page, and is reported as [dialog] type "message"; answer it with ' +
152
155
  p('dialog') +
153
- '({ accept }) and accept only when the user authorized exactly that action. ' +
154
- 'IRON RULE: a snapshot is the cheapest state check after any click/type/scroll — confirm the previous ' +
155
- 'action actually took effect here (URL change, selection, toast, new content) before your next action. ' +
156
- 'Pick ONE verification channel per step: prefer a snapshot for DOM/locator ground truth or a screenshot ' +
157
- 'when visual confirmation matters, but avoid requesting both by default.',
156
+ '({ accept }) — only when the user authorized exactly that action. ' +
157
+ 'IRON RULE: after any click/type/scroll this is the cheapest check that the action took effect (URL, selection, toast, new content). ' +
158
+ 'Use one verification channel per step: snapshot for DOM truth, screenshot when visuals matter — not both by default.',
158
159
  parameters: {
159
160
  interactiveOnly: {
160
161
  type: 'boolean',
@@ -184,19 +185,19 @@ export function apply(ctx, config) {
184
185
  parameters: {
185
186
  ref: {
186
187
  type: 'string',
187
- description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
188
+ description: 'Ref from the latest snapshot (e.g. e12); preferred when the snapshot has one.',
188
189
  },
189
190
  text: {
190
191
  type: 'string',
191
- description: 'Visible label of a target shown WITHOUT a ref, copied exactly as the snapshot renders it; the first visible element whose own text matches is used and the note names it.',
192
+ description: 'Visible label of a target shown WITHOUT a ref, copied exactly; the first visible match is used and the note names it.',
192
193
  },
193
194
  x: {
194
195
  type: 'integer',
195
- description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
196
+ description: 'Viewport x in CSS px; with y, a raw click — mode "click" only, last resort.',
196
197
  },
197
198
  y: {
198
199
  type: 'integer',
199
- description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
200
+ description: 'Viewport y in CSS px; with x, a raw click — mode "click" only, last resort.',
200
201
  },
201
202
  mode: {
202
203
  type: 'string',
@@ -205,7 +206,7 @@ export function apply(ctx, config) {
205
206
  },
206
207
  precise: {
207
208
  type: 'boolean',
208
- description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
209
+ description: 'Ref clicks only: click the geometric centre through native input.',
209
210
  },
210
211
  },
211
212
  output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
@@ -237,12 +238,12 @@ export function apply(ctx, config) {
237
238
  }));
238
239
  ctx.tools.register(defineTool({
239
240
  name: p('dialog'),
240
- description: 'Answer the native dialog currently blocking the page — alert, confirm, prompt or beforeunload — and return the fresh snapshot. ' +
241
- 'A dialog is NEVER auto-answered: it stays PENDING until this call. The call that raised it and ' +
241
+ description: 'Answer the dialog blocking the page (alert / confirm / prompt / beforeunload) and return the fresh snapshot. ' +
242
+ 'Never auto-answered: it stays PENDING until this call; the raising call and ' +
242
243
  p('snapshot') +
243
- ' report it as a note; every other tool refuses to act and fails with DIALOG_PENDING. ' +
244
- 'accept: true confirms it (OK / leave page), false cancels it (Cancel / stay on page). Accept only when the user has explicitly authorized exactly this action (delete / pay / submit / overwrite); never confirm on your own initiative, and state in your reply which dialog you answered and how. ' +
245
- 'A prompt takes its value through promptText. Without a pending dialog this errors with NO_DIALOG — there is no way to accept a dialog the page never raised.',
244
+ ' report it, every other tool fails with DIALOG_PENDING. ' +
245
+ 'accept: true confirms (OK / leave page), false cancels. Only when the user explicitly authorized exactly this action (delete / pay / submit / overwrite) — never on your own initiative; say in your reply which dialog you answered and how. ' +
246
+ 'A prompt takes promptText. With no dialog pending this errors NO_DIALOG: a dialog the page never raised cannot be accepted.',
246
247
  parameters: {
247
248
  accept: {
248
249
  type: 'boolean',
@@ -266,19 +267,16 @@ export function apply(ctx, config) {
266
267
  ctx.tools.register(defineTool({
267
268
  name: p('fill'),
268
269
  description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
269
- '`value` is accepted as an alias of `text`. ' +
270
- 'For <select> elements the text selects the matching option label. ' +
270
+ '`value` is an alias of `text`; for <select> the text selects the matching option label. ' +
271
271
  'Credential fields (password, one-time code, card number) are refused with CREDENTIAL_FIELD: ' +
272
- 'a human types secrets in the visible window, so ask them and re-snapshot instead. ' +
273
- 'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
274
- 'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
275
- 'input-mask/currency fields). If the value is wrong or missing, do NOT blindly re-fill or append — ' +
276
- 'inspect the field DOM first — ' +
272
+ 'a human types secrets in the visible window, so ask them and re-snapshot. ' +
273
+ 'IRON RULE: verify in the returned snapshot that the field holds the value — frameworks can ' +
274
+ 'silently reject or transform input (controlled components, contenteditable, input-mask/currency). ' +
275
+ 'If it is wrong or missing, do NOT re-fill or append: inspect the field DOM first (' +
277
276
  p('evaluate') +
278
277
  ' reading value/outerHTML, or ' +
279
278
  p('cdp') +
280
- ' Input.insertText for contenteditable (both are gated — when one is missing from your tool list, ' +
281
- 'say so) — understand why it was ignored, then act.',
279
+ ' Input.insertText for contenteditable; both are gated, so say so when one is missing), understand why it was ignored, then act.',
282
280
  parameters: {
283
281
  ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
284
282
  text: {
@@ -356,27 +354,54 @@ export function apply(ctx, config) {
356
354
  }));
357
355
  ctx.tools.register(defineTool({
358
356
  name: p('wait'),
359
- description: 'Wait a fixed duration for slow pages or lazy content to settle.',
357
+ description: 'Wait for ONE condition: `selector` (element becomes visible), `text` (visible text appears), ' +
358
+ '`networkIdle` (no request for 500ms) or `ms` (plain sleep). Prefer a goal over `ms` — it returns ' +
359
+ 'the moment the page is ready, neither too early nor wasteful. Reports what it waited for and how long.',
360
360
  parameters: {
361
361
  ms: {
362
362
  type: 'integer',
363
- required: true,
364
- description: 'Milliseconds to wait, up to ' + String(config.maxWaitMs) + '.',
363
+ description: 'Fixed sleep in milliseconds, up to ' +
364
+ String(config.maxWaitMs) +
365
+ '. Use it only when nothing observable marks the end of the wait.',
366
+ },
367
+ selector: { type: 'string', description: 'Wait until an element matching this CSS selector is visible.' },
368
+ text: { type: 'string', description: 'Wait until visible text containing this string appears.' },
369
+ networkIdle: { type: 'boolean', description: 'Wait until the page stops issuing requests for 500ms.' },
370
+ timeoutMs: {
371
+ type: 'integer',
372
+ description: 'Upper bound for goal conditions: default 15000, max ' + String(config.maxWaitMs) + '.',
373
+ },
374
+ },
375
+ output: {
376
+ schema: { type: 'json' },
377
+ render: (_args, value) => {
378
+ const outcome = value;
379
+ return [{ type: 'text', text: 'Waited for ' + outcome.waitedFor + ' (' + String(outcome.elapsedMs) + 'ms).' }];
365
380
  },
366
381
  },
367
- output: { schema: { type: 'null' }, render: (_args, _value) => [{ type: 'text', text: 'Waited.' }] },
368
382
  async execute(args, exec) {
369
- if (!Number.isSafeInteger(args.ms) || args.ms <= 0 || args.ms > config.maxWaitMs) {
370
- throw new Error('browser_wait: ms must be a positive integer no larger than ' + String(config.maxWaitMs));
371
- }
383
+ const condition = {
384
+ ...(args.ms === undefined ? {} : { ms: args.ms }),
385
+ ...(args.selector === undefined ? {} : { selector: args.selector }),
386
+ ...(args.text === undefined ? {} : { text: args.text }),
387
+ ...(args.networkIdle === undefined ? {} : { networkIdle: args.networkIdle }),
388
+ ...(args.timeoutMs === undefined ? {} : { timeoutMs: args.timeoutMs }),
389
+ };
390
+ // Validate before acquiring: a malformed call must not launch a browser.
391
+ const normalized = normalizeWaitCondition(condition, config.maxWaitMs);
372
392
  const session = await acquire(exec);
373
- await session.wait(args.ms, exec.signal);
374
- return null;
393
+ if (normalized.kind === 'ms') {
394
+ const started = Date.now();
395
+ await session.wait(normalized.ms, exec.signal);
396
+ return { waitedFor: describeWait(normalized), elapsedMs: Date.now() - started };
397
+ }
398
+ const outcome = await session.waitForCondition(condition, exec.signal);
399
+ return { waitedFor: outcome.waitedFor, elapsedMs: outcome.elapsedMs };
375
400
  },
376
401
  }));
377
402
  ctx.tools.register(defineTool({
378
403
  name: p('tabs'),
379
- description: 'List the open tabs of this session with their indexes, URLs, and titles. The tab marked [active] is the page every other browser tool acts on.',
404
+ description: 'List open tabs: index, URL, title. [active] marks the tab every other tool acts on.',
380
405
  parameters: {},
381
406
  output: { schema: TABS_SCHEMA, render: renderTabsValue },
382
407
  async execute(_args, exec) {
@@ -547,7 +572,7 @@ export function apply(ctx, config) {
547
572
  },
548
573
  async execute(args, exec) {
549
574
  if (!config.allowEvaluate) {
550
- throw new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
575
+ throw evaluateDisabled();
551
576
  }
552
577
  const session = await acquire(exec);
553
578
  return (await session.evaluate(args.expression, exec.signal));
@@ -583,7 +608,7 @@ export function apply(ctx, config) {
583
608
  },
584
609
  async execute(args, exec) {
585
610
  if (!config.allowCdp) {
586
- throw new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
611
+ throw cdpDisabled();
587
612
  }
588
613
  if (!isCdpMethodAllowed(args.method)) {
589
614
  throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
package/lib/types.d.ts CHANGED
@@ -117,6 +117,29 @@ export interface TabInfo {
117
117
  /** True for the tab the session currently drives. */
118
118
  readonly active: boolean;
119
119
  }
120
+ /**
121
+ * What `browser_wait` should wait for. Exactly one of `ms`, `selector`, `text`
122
+ * or `networkIdle` per call; `timeoutMs` bounds the three goal conditions.
123
+ */
124
+ export interface WaitCondition {
125
+ /** Fixed sleep in milliseconds (the pre-0.7.6 behaviour, unchanged). */
126
+ readonly ms?: number;
127
+ /** Wait until an element matching this CSS selector is visible. */
128
+ readonly selector?: string;
129
+ /** Wait until visible text containing this string appears. */
130
+ readonly text?: string;
131
+ /** Wait until the page stops issuing requests for 500ms. */
132
+ readonly networkIdle?: boolean;
133
+ /** Upper bound for a goal condition; defaults to 15000 and is capped by the deployment. */
134
+ readonly timeoutMs?: number;
135
+ }
136
+ /** What a completed wait reports back. */
137
+ export interface WaitOutcome {
138
+ /** Human description of the satisfied condition. */
139
+ readonly waitedFor: string;
140
+ /** Wall-clock milliseconds the wait actually took. */
141
+ readonly elapsedMs: number;
142
+ }
120
143
  /** PNG capture of a page or element. */
121
144
  export interface ScreenshotCapture {
122
145
  readonly mime: 'image/png';
@@ -200,6 +223,11 @@ export interface BrowserSession {
200
223
  forward(signal?: AbortSignal): Promise<BrowserSnapshot>;
201
224
  /** Wait a bounded duration, letting lazy content arrive. */
202
225
  wait(ms: number, signal?: AbortSignal): Promise<void>;
226
+ /**
227
+ * Wait for a goal instead of a duration: an element, a piece of text, or a
228
+ * quiet network. `wait(ms)` stays as it was, so this is purely additive.
229
+ */
230
+ waitForCondition(condition: WaitCondition, signal?: AbortSignal): Promise<WaitOutcome>;
203
231
  /** List open tabs. */
204
232
  tabs(): Promise<readonly TabInfo[]>;
205
233
  /** Activate the tab at the given index. */
@@ -1,2 +1,15 @@
1
+ /**
2
+ * Is this host inside the allow-list? Empty list means "any host".
3
+ *
4
+ * Suffix matching is deliberately anchored one level down: `example.com`
5
+ * admits `example.com` and `a.example.com`, never `notexample.com` and never
6
+ * `example.com.evil.net`. A configured entry is compared after
7
+ * normalisation (lower case, leading dot dropped), because a user writing
8
+ * `.example.com` means the domain, not a subdomain literally named "example".
9
+ * @param host - hostname to test, e.g. "a.example.com".
10
+ * @param allowedDomains - configured suffixes; empty means everything passes.
11
+ * @returns true when the host is allowed.
12
+ */
13
+ export declare function isHostAllowed(host: string, allowedDomains: readonly string[]): boolean;
1
14
  /** Validate one absolute URL against the navigation policy. */
2
15
  export declare function assertAllowedUrl(raw: string, allowedDomains: readonly string[]): URL;
package/lib/url-policy.js CHANGED
@@ -3,6 +3,29 @@
3
3
  * @module dsh-browser-playwright-codex/url-policy
4
4
  */
5
5
  import { BrowserError } from "./errors.js";
6
+ /**
7
+ * Is this host inside the allow-list? Empty list means "any host".
8
+ *
9
+ * Suffix matching is deliberately anchored one level down: `example.com`
10
+ * admits `example.com` and `a.example.com`, never `notexample.com` and never
11
+ * `example.com.evil.net`. A configured entry is compared after
12
+ * normalisation (lower case, leading dot dropped), because a user writing
13
+ * `.example.com` means the domain, not a subdomain literally named "example".
14
+ * @param host - hostname to test, e.g. "a.example.com".
15
+ * @param allowedDomains - configured suffixes; empty means everything passes.
16
+ * @returns true when the host is allowed.
17
+ */
18
+ export function isHostAllowed(host, allowedDomains) {
19
+ if (allowedDomains.length === 0)
20
+ return true;
21
+ const target = host.toLowerCase();
22
+ return allowedDomains.some((raw) => {
23
+ const suffix = raw.trim().toLowerCase().replace(/^\.+/, '');
24
+ if (suffix === '')
25
+ return false;
26
+ return target === suffix || target.endsWith('.' + suffix);
27
+ });
28
+ }
6
29
  /** Validate one absolute URL against the navigation policy. */
7
30
  export function assertAllowedUrl(raw, allowedDomains) {
8
31
  let parsed;
@@ -17,8 +40,7 @@ export function assertAllowedUrl(raw, allowedDomains) {
17
40
  }
18
41
  if (allowedDomains.length > 0) {
19
42
  const host = parsed.hostname.toLowerCase();
20
- const allowed = allowedDomains.some((suffix) => host === suffix || host.endsWith('.' + suffix));
21
- if (!allowed) {
43
+ if (!isHostAllowed(host, allowedDomains)) {
22
44
  throw new BrowserError('URL_NOT_ALLOWED', 'host ' + parsed.hostname + ' is not in allowedDomains');
23
45
  }
24
46
  }