dsh-browser-playwright-codex 0.7.2 → 0.7.5

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/errors.js CHANGED
@@ -19,3 +19,19 @@ export function launchFailed(cause) {
19
19
  const detail = cause instanceof Error ? cause.message : String(cause);
20
20
  return new BrowserError('BROWSER_LAUNCH_FAILED', 'failed to launch the browser: ' + detail);
21
21
  }
22
+ /**
23
+ * "browser_evaluate is switched off". One wording, thrown by both layers that
24
+ * enforce it: the tool layer (before a browser is even launched) and the
25
+ * session itself (so a programmatic caller cannot go around the switch).
26
+ * @returns the denial, ready to throw.
27
+ */
28
+ export function evaluateDisabled() {
29
+ return new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
30
+ }
31
+ /**
32
+ * "browser_cdp is switched off". Same two-layer rule as {@link evaluateDisabled}.
33
+ * @returns the denial, ready to throw.
34
+ */
35
+ export function cdpDisabled() {
36
+ return new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
37
+ }
package/lib/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * session-scoped browser contexts, screenshots as attachments.
5
5
  * @module dsh-browser-playwright-codex
6
6
  */
7
- export { BrowserError, launchFailed } from './errors.ts';
7
+ export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from './errors.ts';
8
8
  export type { BrowserErrorCode } from './errors.ts';
9
9
  export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from './contract.ts';
10
10
  export type { ToolSuffix, ToolSurfaceConfig } from './contract.ts';
@@ -15,7 +15,9 @@ export type { SnapshotOptions, PageDataOptions } from './injected.ts';
15
15
  export { default as BrowserRuntime } from './service.ts';
16
16
  export type { BrowserRuntimeConfig } from './service.ts';
17
17
  export { PlaywrightProvider } from './provider.ts';
18
- export { assertAllowedUrl } from './url-policy.ts';
18
+ export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
19
+ export { installHostGuard } from './navigation-guard.ts';
20
+ export type { BlockedRequest } from './navigation-guard.ts';
19
21
  export { Config as PlaywrightConfigSchema } from './config.ts';
20
22
  export type { PlaywrightConfig, PageData } from './config.ts';
21
23
  export type { BrowserNode, BrowserProvider, BrowserSession, BrowserSnapshot, LoadState, ScreenshotCapture, TabInfo, } from './types.ts';
package/lib/index.js CHANGED
@@ -4,13 +4,14 @@
4
4
  * session-scoped browser contexts, screenshots as attachments.
5
5
  * @module dsh-browser-playwright-codex
6
6
  */
7
- export { BrowserError, launchFailed } from "./errors.js";
7
+ export { BrowserError, cdpDisabled, evaluateDisabled, launchFailed } from "./errors.js";
8
8
  export { ALWAYS_ON_SUFFIXES, ERROR_CODES, GATED_SUFFIXES, TOOL_SUFFIXES, registeredSuffixes, registeredToolNames, toolNames, } from "./contract.js";
9
9
  export { CallId, toolCallId } from "./compat.js";
10
10
  export { SNAPSHOT_SCRIPT } from "./injected.js";
11
11
  export { default as BrowserRuntime } from "./service.js";
12
12
  export { PlaywrightProvider } from "./provider.js";
13
- export { assertAllowedUrl } from "./url-policy.js";
13
+ export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
14
+ export { installHostGuard } from "./navigation-guard.js";
14
15
  export { Config as PlaywrightConfigSchema } from "./config.js";
15
16
  // Single loader row (2026-10-19): the package root is the plugin. The three
16
17
  // sub-entries below stay importable, but the loader mounts this one.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 白名单的网络层守卫:把「只能访问这些站点」变成**路上的一道闸**,而不是只在调用入口
3
+ * 打一次勾。
4
+ *
5
+ * 起因(2026-10-26 实测):入口检查只覆盖 navigate / 点击带 href 的链接 / 切标签 / 开标签。
6
+ * 页面自己 `location.href = 'http://别的站点'`、meta refresh、302 跳转、表单提交,全都
7
+ * 绕过白名单——实测把名单设成 `127.0.0.1` 后,页面脚本一跳就落在了 `localhost` 上。
8
+ * 入口检查在这里永远追不上:那些导航不是插件发起的。
9
+ *
10
+ * 做法:接管这个 context 的每一条请求(context.route),按主机名判定。
11
+ * - **文档请求**(导航、iframe):不在名单里 → 掐断,并记下来。调用方在动作收尾时
12
+ * 用它把「我点了但页面没动」变成一条明确的 URL_NOT_ALLOWED,而不是一次静默。
13
+ * - **数据请求**(xhr / fetch / eventsource):不在名单里 → 掐断。页面脚本不该拿着
14
+ * 这个浏览器的身份去白名单外的站点取数据。
15
+ * - 其余子资源(script / img / css / font / media):放行。它们几乎是页面渲染的刚需
16
+ * (CDN、头像、字体),且不带目标站点的凭据;拦掉只会让页面看起来是坏的。
17
+ *
18
+ * 只在配置了名单时才接管:空名单的语义是「任意站点」,此时接管路由纯属拖慢每一页
19
+ * (Playwright 的路由会关掉 HTTP 缓存)。
20
+ *
21
+ * @module dsh-browser-playwright-codex/navigation-guard
22
+ */
23
+ import type { BrowserContext } from 'playwright-core';
24
+ /** One request the guard refused, in the shape callers report it. */
25
+ export interface BlockedRequest {
26
+ /** Full requested URL. */
27
+ url: string;
28
+ /** Hostname that was not in the allow-list. */
29
+ host: string;
30
+ /** Playwright resourceType: document, xhr, script, … */
31
+ resourceType: string;
32
+ /** True for a navigation-level block (a document request). */
33
+ document: boolean;
34
+ /** Epoch milliseconds. */
35
+ at: number;
36
+ }
37
+ /**
38
+ * Refuse every request to a host outside the allow-list for one context.
39
+ *
40
+ * Installed once per context, before the first navigation. When the configured
41
+ * list is empty nothing is installed: "any host" must not pay the routing cost.
42
+ * @param context - the context to guard.
43
+ * @param allowedDomains - reads the live allow-list.
44
+ * @param onBlocked - called for each refused request; the caller keeps the last
45
+ * document-level block and turns it into an error for the action that caused it.
46
+ * @returns true when a guard was installed, false when the list is empty.
47
+ */
48
+ export declare function installHostGuard(context: BrowserContext, allowedDomains: () => readonly string[], onBlocked: (blocked: BlockedRequest) => void): Promise<boolean>;
@@ -0,0 +1,48 @@
1
+ import { isHostAllowed } from "./url-policy.js";
2
+ /** Resource types that carry data off the page and are refused outside the allow-list. */
3
+ const DATA_RESOURCE_TYPES = new Set(['xhr', 'fetch', 'eventsource']);
4
+ /**
5
+ * Refuse every request to a host outside the allow-list for one context.
6
+ *
7
+ * Installed once per context, before the first navigation. When the configured
8
+ * list is empty nothing is installed: "any host" must not pay the routing cost.
9
+ * @param context - the context to guard.
10
+ * @param allowedDomains - reads the live allow-list.
11
+ * @param onBlocked - called for each refused request; the caller keeps the last
12
+ * document-level block and turns it into an error for the action that caused it.
13
+ * @returns true when a guard was installed, false when the list is empty.
14
+ */
15
+ export async function installHostGuard(context, allowedDomains, onBlocked) {
16
+ const domains = allowedDomains();
17
+ if (domains.length === 0)
18
+ return false;
19
+ await context.route('**/*', async (route) => {
20
+ const request = route.request();
21
+ let host;
22
+ try {
23
+ host = new URL(request.url()).hostname;
24
+ }
25
+ catch {
26
+ // Not a URL we can judge (data:, blob:, about:, chrome-extension: …).
27
+ // Neither a host nor a site the policy talks about: let the browser decide.
28
+ await route.continue().catch(() => { });
29
+ return;
30
+ }
31
+ if (isHostAllowed(host, allowedDomains())) {
32
+ await route.continue().catch(() => { });
33
+ return;
34
+ }
35
+ const resourceType = request.resourceType();
36
+ const document = resourceType === 'document';
37
+ // Sub-resources the page needs in order to render are left alone; only the
38
+ // request classes that move the user or move data are refused.
39
+ if (!document && !DATA_RESOURCE_TYPES.has(resourceType)) {
40
+ await route.continue().catch(() => { });
41
+ return;
42
+ }
43
+ if (document)
44
+ onBlocked({ url: request.url(), host, resourceType, document, at: Date.now() });
45
+ await route.abort('blockedbyclient').catch(() => { });
46
+ });
47
+ return true;
48
+ }
@@ -121,6 +121,9 @@ export async function navigate(session, url, waitUntil, signal) {
121
121
  catch (error) {
122
122
  if (isAbortError(error) || signal?.aborted === true)
123
123
  throw error;
124
+ // A redirect chain can leave the allow-list: the guard refuses the hop and
125
+ // Playwright reports a plain navigation failure. Name the real cause.
126
+ session.throwIfNavigationBlocked();
124
127
  throw asTimeoutError('navigation', error);
125
128
  }
126
129
  return session.settleAndSnapshot(page, true);
@@ -716,6 +719,7 @@ export async function back(session, signal) {
716
719
  catch (error) {
717
720
  if (isAbortError(error))
718
721
  throw error;
722
+ session.throwIfNavigationBlocked();
719
723
  throw asTimeoutError('navigation', error);
720
724
  }
721
725
  return session.settleAndSnapshot(page, true);
@@ -730,6 +734,7 @@ export async function forward(session, signal) {
730
734
  catch (error) {
731
735
  if (isAbortError(error))
732
736
  throw error;
737
+ session.throwIfNavigationBlocked();
733
738
  throw asTimeoutError('navigation', error);
734
739
  }
735
740
  return session.settleAndSnapshot(page, true);
@@ -776,6 +781,7 @@ export async function openTab(session, url, waitUntil, signal) {
776
781
  catch (error) {
777
782
  if (isAbortError(error))
778
783
  throw error;
784
+ session.throwIfNavigationBlocked();
779
785
  throw asTimeoutError('navigation', error);
780
786
  }
781
787
  return session.settleAndSnapshot(page, true);
@@ -77,6 +77,11 @@ export async function settleAndSnapshot(session, page, navChanged) {
77
77
  });
78
78
  if (raced.blocked)
79
79
  return session.blockedSnapshot(page, raced.pending);
80
+ // A page can leave the allow-list without any entry-point check seeing it: the
81
+ // request-level guard catches self-started navigations, and this catches the
82
+ // redirect the browser followed on its own.
83
+ session.throwIfNavigationBlocked();
84
+ await session.assertCurrentUrlAllowed(page);
80
85
  return raced.value;
81
86
  }
82
87
  /**
@@ -11,6 +11,6 @@ export declare const inject: string[];
11
11
  */
12
12
  export { Config, type PageData, type PlaywrightConfig } from './config.ts';
13
13
  export { PlaywrightProvider } from './provider.ts';
14
- export { assertAllowedUrl } from './url-policy.ts';
14
+ export { assertAllowedUrl, isHostAllowed } from './url-policy.ts';
15
15
  export { resolveSnapshotEngine } from './page-snapshot.ts';
16
16
  export declare function apply(ctx: Context, config: PlaywrightConfig): void;
package/lib/playwright.js CHANGED
@@ -11,12 +11,14 @@
11
11
  * page-snapshot.ts capture, ref signatures, ref lookup
12
12
  * page-diagnostics.ts console messages, page errors, network records
13
13
  * browser-lifecycle.ts launching/reusing the persistent context, window state
14
+ * window-focus.ts is the window minimized / create a tab without raising it
15
+ * navigation-guard.ts the allow-list on the wire (refuses off-list requests)
14
16
  * login-state.ts exporting and restoring login state
15
17
  * @module dsh-browser-playwright-codex/playwright
16
18
  */
17
19
  import { PlaywrightProvider } from "./provider.js";
18
20
  import {} from "./config.js";
19
- import { subscribe as subscribeEnabled } from "./runtime-state.js";
21
+ import { publishConfiguredHeadless, subscribe as subscribeEnabled } from "./runtime-state.js";
20
22
  /** Cordis plugin name used by loader diagnostics. */
21
23
  export const name = 'browser-playwright';
22
24
  /** The browser runtime this provider registers into. */
@@ -33,10 +35,11 @@ export const inject = ['browser'];
33
35
  // subpath stopped re-exporting it). The other names keep the pre-split public surface intact.
34
36
  export { Config } from "./config.js";
35
37
  export { PlaywrightProvider } from "./provider.js";
36
- export { assertAllowedUrl } from "./url-policy.js";
38
+ export { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
37
39
  export { resolveSnapshotEngine } from "./page-snapshot.js";
38
40
  export function apply(ctx, config) {
39
41
  const provider = new PlaywrightProvider(config);
42
+ publishConfiguredHeadless(config.launch.headless === true);
40
43
  ctx.browser.registerProvider(provider);
41
44
  // Runtime on/off switch: when disabled the UI (or a future hot reload)
42
45
  // flips the flag and the provider tears down every active window so the
package/lib/provider.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Browser, type BrowserContext, type Dialog, type Page } from 'playwright-core';
2
2
  import { PlaywrightSession } from './session.ts';
3
3
  import type { PlaywrightConfig } from './config.ts';
4
+ import { type BlockedRequest } from './navigation-guard.ts';
4
5
  /**
5
6
  * Playwright-backed {@link BrowserProvider}: one shared browser, one context
6
7
  * per owner key, LRU eviction, idle disposal, ref-based snapshot interaction.
@@ -32,6 +33,8 @@ export declare class PlaywrightProvider {
32
33
  id: string;
33
34
  /** Legacy (persistent:false) shared browser instance. */
34
35
  browser: Browser | undefined;
36
+ /** Window mode the live process was launched with (undefined = nothing running). */
37
+ launchedHeadless: boolean | undefined;
35
38
  /** Persistent-mode shared profile-backed context (the personal window). */
36
39
  context: BrowserContext | undefined;
37
40
  entries: Map<string, OwnerEntry>;
@@ -48,6 +51,26 @@ export declare class PlaywrightProvider {
48
51
  /** Owner that raised the dialog currently parked on a page. */
49
52
  lastActor: string | undefined;
50
53
  lastStatePersist: number;
54
+ /**
55
+ * Launch in flight, so two sessions waking up at the same time share ONE
56
+ * launch instead of racing for the same profile directory.
57
+ *
58
+ * Measured 2026-10-26: two concurrent acquires both saw "no context yet" and
59
+ * both called launchPersistentContext. The second died on Chrome's profile
60
+ * singleton, the recovery path deleted the RUNNING browser's lock files, and
61
+ * the caller got "install a Chromium-family browser". The lock files being
62
+ * gone is what let a later launch start a second browser on the same profile
63
+ * and leave processes behind.
64
+ */
65
+ contextLaunch: Promise<BrowserContext> | undefined;
66
+ /** Same for the legacy shared-browser launch. */
67
+ browserLaunch: Promise<Browser> | undefined;
68
+ /**
69
+ * The last navigation the host guard refused. Read (and cleared) once by the
70
+ * action that caused it, so "I clicked and nothing happened" becomes a named
71
+ * URL_NOT_ALLOWED instead of a silent no-op.
72
+ */
73
+ blockedNavigation: BlockedRequest | undefined;
51
74
  constructor(config: PlaywrightConfig);
52
75
  available(): boolean;
53
76
  /** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
@@ -83,7 +106,27 @@ export declare class PlaywrightProvider {
83
106
  };
84
107
  /** One line describing a parked dialog for the tool result. */
85
108
  dialogNoteFor(pending: PendingDialogRecord): string;
109
+ /**
110
+ * Window mode to launch with: the user's switch wins over the configured
111
+ * default. Read on every launch, so a change lands on the next operation
112
+ * without touching a patch file.
113
+ */
114
+ desiredHeadless(): boolean;
115
+ /**
116
+ * Close a running process whose mode no longer matches. The next acquire
117
+ * relaunches with the new mode; login state is exported first, so only the
118
+ * tabs and unsaved page state are lost — never the login.
119
+ */
120
+ private relaunchIfModeChanged;
86
121
  acquire(owner: string): Promise<PlaywrightSession>;
122
+ /**
123
+ * Keep the number of tracked owner handles inside maxSessions in persistent
124
+ * mode. Handles share one window, so dropping the least recently used idle
125
+ * handle costs nothing the user can see; the owner gets a fresh handle on the
126
+ * same window next time. An owner with an operation in flight is never dropped.
127
+ * @param keep - the owner being acquired right now.
128
+ */
129
+ private evictIdleHandles;
87
130
  disposeOwner(owner: string): Promise<void>;
88
131
  dispose(): Promise<void>;
89
132
  /** Touch the entry's last-used clock and re-arm its idle timer. */
@@ -106,6 +149,19 @@ export declare class PlaywrightProvider {
106
149
  ensureContext(): Promise<BrowserContext>;
107
150
  /** Remove stale Chrome singleton lock files inside the profile directory. */
108
151
  clearChromeLocks(): Promise<void>;
152
+ /**
153
+ * Put the allow-list on the wire for one context: every navigation and every
154
+ * data request outside the list is refused from then on, whoever started it.
155
+ * See navigation-guard.ts for why the entry-point checks alone were not enough.
156
+ * @param context - freshly created context, before its first navigation.
157
+ */
158
+ installHostGuard(context: BrowserContext): Promise<void>;
159
+ /**
160
+ * Take the pending "a navigation was refused" record, if any. Consumed once:
161
+ * the next action must not inherit the previous action's refusal.
162
+ * @returns the refused navigation, or undefined.
163
+ */
164
+ takeBlockedNavigation(): BlockedRequest | undefined;
109
165
  /**
110
166
  * Collect the login-state fallback WITHOUT ever opening a page while the
111
167
  * window is put away.
package/lib/provider.js CHANGED
@@ -10,15 +10,18 @@ import {} from 'playwright-core';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { PlaywrightSession } from "./session.js";
13
- import { getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
13
+ import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
14
14
  import { BrowserError } from "./errors.js";
15
15
  import { SNAPSHOT_SCRIPT } from "./injected.js";
16
16
  import { isWindowMinimized } from "./window-focus.js";
17
+ import { installHostGuard } from "./navigation-guard.js";
17
18
  export class PlaywrightProvider {
18
19
  config;
19
20
  id = 'playwright';
20
21
  /** Legacy (persistent:false) shared browser instance. */
21
22
  browser;
23
+ /** Window mode the live process was launched with (undefined = nothing running). */
24
+ launchedHeadless = undefined;
22
25
  /** Persistent-mode shared profile-backed context (the personal window). */
23
26
  context;
24
27
  entries = new Map();
@@ -35,6 +38,26 @@ export class PlaywrightProvider {
35
38
  /** Owner that raised the dialog currently parked on a page. */
36
39
  lastActor;
37
40
  lastStatePersist = 0;
41
+ /**
42
+ * Launch in flight, so two sessions waking up at the same time share ONE
43
+ * launch instead of racing for the same profile directory.
44
+ *
45
+ * Measured 2026-10-26: two concurrent acquires both saw "no context yet" and
46
+ * both called launchPersistentContext. The second died on Chrome's profile
47
+ * singleton, the recovery path deleted the RUNNING browser's lock files, and
48
+ * the caller got "install a Chromium-family browser". The lock files being
49
+ * gone is what let a later launch start a second browser on the same profile
50
+ * and leave processes behind.
51
+ */
52
+ contextLaunch = undefined;
53
+ /** Same for the legacy shared-browser launch. */
54
+ browserLaunch = undefined;
55
+ /**
56
+ * The last navigation the host guard refused. Read (and cleared) once by the
57
+ * action that caused it, so "I clicked and nothing happened" becomes a named
58
+ * URL_NOT_ALLOWED instead of a silent no-op.
59
+ */
60
+ blockedNavigation = undefined;
38
61
  constructor(config) {
39
62
  this.config = config;
40
63
  // Tell the settings card which page-reading mode this deployment was
@@ -170,27 +193,61 @@ export class PlaywrightProvider {
170
193
  ' Answer it with browser_dialog({ accept }) — accept only if the user authorized exactly this action,' +
171
194
  ' otherwise dismiss; no other tool can act until it is answered.');
172
195
  }
196
+ /**
197
+ * Window mode to launch with: the user's switch wins over the configured
198
+ * default. Read on every launch, so a change lands on the next operation
199
+ * without touching a patch file.
200
+ */
201
+ desiredHeadless() {
202
+ return getEffectiveHeadless(this.config.launch.headless === true);
203
+ }
204
+ /**
205
+ * Close a running process whose mode no longer matches. The next acquire
206
+ * relaunches with the new mode; login state is exported first, so only the
207
+ * tabs and unsaved page state are lost — never the login.
208
+ */
209
+ async relaunchIfModeChanged() {
210
+ const desired = this.desiredHeadless();
211
+ if (this.launchedHeadless === undefined || this.launchedHeadless === desired)
212
+ return;
213
+ await this.dispose();
214
+ }
173
215
  async acquire(owner) {
174
216
  if (!getEnabled()) {
175
217
  throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
176
218
  }
177
219
  const existing = this.entries.get(owner);
178
220
  if (existing) {
179
- existing.lastUsed = Date.now();
180
- this.armIdle(existing);
181
- return existing.session;
221
+ // A window-mode switch made while this owner is active must not hand back
222
+ // a session in the old mode: closing and relaunching here keeps the change
223
+ // one call away instead of "next restart".
224
+ if (this.launchedHeadless !== undefined && this.launchedHeadless !== this.desiredHeadless()) {
225
+ await this.dispose();
226
+ }
227
+ else {
228
+ existing.lastUsed = Date.now();
229
+ this.armIdle(existing);
230
+ return existing.session;
231
+ }
182
232
  }
183
233
  if (this.config.launch.persistent) {
234
+ await this.relaunchIfModeChanged();
184
235
  // Personal-browser mode: all owners drive one shared profile-backed
185
236
  // window (Codex-style). Entries are lightweight session handles on
186
237
  // the shared context; the last owner to leave closes the window.
187
238
  const context = await this.ensureContext();
239
+ // maxSessions applies here too, but as a cap on HANDLES: all of them
240
+ // drive the same window, so evicting one costs nothing visible and the
241
+ // owner simply gets a fresh handle (same page set, index back to 0) next
242
+ // time. Before this, the setting was inert in the default mode.
243
+ this.evictIdleHandles(owner);
188
244
  const session = new PlaywrightSession(this, owner, context);
189
245
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
190
246
  this.entries.set(owner, entry);
191
247
  this.armIdle(entry);
192
248
  return session;
193
249
  }
250
+ await this.relaunchIfModeChanged();
194
251
  const browser = await this.ensureBrowser();
195
252
  while (this.entries.size >= this.config.maxSessions) {
196
253
  const oldest = [...this.entries.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
@@ -203,10 +260,12 @@ export class PlaywrightProvider {
203
260
  // (system DPI respected) exactly like a hand-opened Chrome, instead
204
261
  // of Playwright forcing a fixed CSS viewport that overflows the
205
262
  // window on scaled displays. Headless keeps the fixed viewport.
206
- ...(this.config.launch.headless ? { viewport: this.config.launch.viewport } : { viewport: null }),
263
+ ...(this.desiredHeadless() ? { viewport: this.config.launch.viewport } : { viewport: null }),
207
264
  ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
208
265
  });
209
266
  context.on('page', (page) => this.armDialogGuard(page));
267
+ // Allow-list on the wire, not just at the entry points (navigation-guard.ts).
268
+ await this.installHostGuard(context);
210
269
  await context.addInitScript(SNAPSHOT_SCRIPT);
211
270
  const session = new PlaywrightSession(this, owner, context);
212
271
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
@@ -221,6 +280,30 @@ export class PlaywrightProvider {
221
280
  });
222
281
  return session;
223
282
  }
283
+ /**
284
+ * Keep the number of tracked owner handles inside maxSessions in persistent
285
+ * mode. Handles share one window, so dropping the least recently used idle
286
+ * handle costs nothing the user can see; the owner gets a fresh handle on the
287
+ * same window next time. An owner with an operation in flight is never dropped.
288
+ * @param keep - the owner being acquired right now.
289
+ */
290
+ evictIdleHandles(keep) {
291
+ const limit = this.config.maxSessions;
292
+ if (!Number.isFinite(limit) || limit <= 0)
293
+ return;
294
+ while (this.entries.size >= limit) {
295
+ const candidates = [...this.entries.entries()]
296
+ .filter(([key, entry]) => key !== keep && entry.pending === 0)
297
+ .sort((left, right) => left[1].lastUsed - right[1].lastUsed);
298
+ const oldest = candidates[0];
299
+ if (oldest === undefined)
300
+ return;
301
+ const [key, entry] = oldest;
302
+ if (entry.timer !== undefined)
303
+ clearTimeout(entry.timer);
304
+ this.entries.delete(key);
305
+ }
306
+ }
224
307
  async disposeOwner(owner) {
225
308
  const entry = this.entries.get(owner);
226
309
  if (entry === undefined)
@@ -234,6 +317,7 @@ export class PlaywrightProvider {
234
317
  if (this.entries.size === 0 && this.context !== undefined) {
235
318
  const ctx = this.context;
236
319
  this.context = undefined;
320
+ this.launchedHeadless = undefined;
237
321
  await this.persistState(ctx);
238
322
  await ctx.close().catch(() => { });
239
323
  }
@@ -242,6 +326,11 @@ export class PlaywrightProvider {
242
326
  await entry.context.close().catch(() => { });
243
327
  }
244
328
  async dispose() {
329
+ this.launchedHeadless = undefined;
330
+ // A launch still in flight must not re-create a context after disposal.
331
+ this.contextLaunch = undefined;
332
+ this.browserLaunch = undefined;
333
+ this.blockedNavigation = undefined;
245
334
  const owners = [...this.entries.keys()];
246
335
  for (const owner of owners)
247
336
  await this.disposeOwner(owner);
@@ -331,6 +420,29 @@ export class PlaywrightProvider {
331
420
  async clearChromeLocks() {
332
421
  return clearChromeLocks(this);
333
422
  }
423
+ /**
424
+ * Put the allow-list on the wire for one context: every navigation and every
425
+ * data request outside the list is refused from then on, whoever started it.
426
+ * See navigation-guard.ts for why the entry-point checks alone were not enough.
427
+ * @param context - freshly created context, before its first navigation.
428
+ */
429
+ async installHostGuard(context) {
430
+ this.blockedNavigation = undefined;
431
+ await installHostGuard(context, () => this.config.allowedDomains ?? [], (blocked) => {
432
+ if (blocked.document)
433
+ this.blockedNavigation = blocked;
434
+ });
435
+ }
436
+ /**
437
+ * Take the pending "a navigation was refused" record, if any. Consumed once:
438
+ * the next action must not inherit the previous action's refusal.
439
+ * @returns the refused navigation, or undefined.
440
+ */
441
+ takeBlockedNavigation() {
442
+ const blocked = this.blockedNavigation;
443
+ this.blockedNavigation = undefined;
444
+ return blocked;
445
+ }
334
446
  /**
335
447
  * Collect the login-state fallback WITHOUT ever opening a page while the
336
448
  * window is put away.
@@ -33,6 +33,26 @@ export declare function getEnabled(): boolean;
33
33
  export declare function setEnabled(next: boolean): boolean;
34
34
  /** Subscribe to enable-state transitions. Returns an unsubscribe function. */
35
35
  export declare function subscribe(fn: (enabled: boolean) => void): () => void;
36
+ /**
37
+ * The window mode the user picked (true = headless), or undefined when they
38
+ * never picked one — the configured default then applies.
39
+ */
40
+ export declare function getHeadless(): boolean | undefined;
41
+ /** Persist the window-mode choice. Returns true when the value changed. */
42
+ export declare function setHeadless(next: boolean): boolean;
43
+ /** Publish the configured default (launch.headless) for display. */
44
+ export declare function publishConfiguredHeadless(value: boolean): void;
45
+ /** The configured default, when the provider has published it. */
46
+ export declare function getConfiguredHeadless(): boolean | undefined;
47
+ /**
48
+ * Effective window mode: the user's choice, else the configured default, else
49
+ * the shipped default (headful).
50
+ *
51
+ * @param configuredFallback - config default when the provider has not published one
52
+ */
53
+ export declare function getEffectiveHeadless(configuredFallback?: boolean): boolean;
54
+ /** Did the user pick the window mode themselves? (The card shows "你选的".) */
55
+ export declare function isHeadlessUserChosen(): boolean;
36
56
  /**
37
57
  * The page-reading mode the user picked, or undefined when they never picked
38
58
  * one — the configured default then applies.
@@ -12,6 +12,10 @@ function stateFile() {
12
12
  }
13
13
  let _enabled = true;
14
14
  let _engine = undefined;
15
+ /** User's window-mode choice: true = headless, undefined = follow the config. */
16
+ let _headless = undefined;
17
+ /** Config default pushed in by the provider (display only). */
18
+ let _configuredHeadless = undefined;
15
19
  /** Default pushed in by the provider that is actually loaded (display only). */
16
20
  let _configuredEngine = undefined;
17
21
  /** Effective tool gates, pushed in by the tool family when it is applied (display only). */
@@ -33,6 +37,9 @@ function load() {
33
37
  // unknown value means "follow the configured default" (undefined here).
34
38
  // Files written before this setting existed therefore stay valid.
35
39
  _engine = obj.engine === 'legacy' || obj.engine === 'aria' ? obj.engine : undefined;
40
+ // Same contract as engine: absent/unknown means "follow the configured
41
+ // default", so files written before this setting existed stay valid.
42
+ _headless = typeof obj.headless === 'boolean' ? obj.headless : undefined;
36
43
  }
37
44
  }
38
45
  catch {
@@ -60,6 +67,8 @@ function persist() {
60
67
  // Keep the historical shape until the user actually picks a page-reading
61
68
  // mode: an absent `engine` means "use the configured default".
62
69
  const payload = _engine === undefined ? { enabled: _enabled } : { enabled: _enabled, engine: _engine };
70
+ if (_headless !== undefined)
71
+ payload.headless = _headless;
63
72
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
64
73
  fs.renameSync(tmp, target);
65
74
  }
@@ -99,6 +108,50 @@ export function subscribe(fn) {
99
108
  _subscribers.delete(fn);
100
109
  };
101
110
  }
111
+ /**
112
+ * The window mode the user picked (true = headless), or undefined when they
113
+ * never picked one — the configured default then applies.
114
+ */
115
+ export function getHeadless() {
116
+ load();
117
+ return _headless;
118
+ }
119
+ /** Persist the window-mode choice. Returns true when the value changed. */
120
+ export function setHeadless(next) {
121
+ load();
122
+ if (typeof next !== 'boolean')
123
+ return false;
124
+ if (_headless === next)
125
+ return false;
126
+ _headless = next;
127
+ persist();
128
+ return true;
129
+ }
130
+ /** Publish the configured default (launch.headless) for display. */
131
+ export function publishConfiguredHeadless(value) {
132
+ _configuredHeadless = value === true;
133
+ }
134
+ /** The configured default, when the provider has published it. */
135
+ export function getConfiguredHeadless() {
136
+ return _configuredHeadless;
137
+ }
138
+ /**
139
+ * Effective window mode: the user's choice, else the configured default, else
140
+ * the shipped default (headful).
141
+ *
142
+ * @param configuredFallback - config default when the provider has not published one
143
+ */
144
+ export function getEffectiveHeadless(configuredFallback = false) {
145
+ load();
146
+ if (_headless !== undefined)
147
+ return _headless;
148
+ return _configuredHeadless !== undefined ? _configuredHeadless : configuredFallback === true;
149
+ }
150
+ /** Did the user pick the window mode themselves? (The card shows "你选的".) */
151
+ export function isHeadlessUserChosen() {
152
+ load();
153
+ return _headless !== undefined;
154
+ }
102
155
  /**
103
156
  * The page-reading mode the user picked, or undefined when they never picked
104
157
  * one — the configured default then applies.