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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Legacy loader-row detection (2026-10-19 merge follow-up).
3
+ *
4
+ * 0.7.0 collapsed three loader rows (`browser-playwright`, `browser-tool`, and
5
+ * the `/service` entry) into one `- id: browser` row with nested
6
+ * `service` / `playwright` / `tool` config. The loader simply stops reading the
7
+ * old rows, so an upgraded deployment whose patch layer still lists them starts
8
+ * with the package defaults — and the package ships both power gates OFF.
9
+ *
10
+ * That failure is silent: nothing errors, the tools that the old row would have
11
+ * published are simply absent, and the user believes a capability is still on.
12
+ * A real 0.7.4 deployment was found in exactly this state (16 tools, no
13
+ * `evaluate` / `cdp`, `allowEvaluate: true` sitting in a row nobody reads).
14
+ *
15
+ * This module reads the patch layers (read-only, never writes) and reports the
16
+ * stale rows once per DSH home so the operator can fix the config instead of
17
+ * discovering the downgrade through a missing tool.
18
+ *
19
+ * @module dsh-browser-playwright-codex/legacy-config
20
+ */
21
+ import { readdir, readFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ /** Row ids that 0.7.0 stopped reading. */
25
+ export const LEGACY_ROW_IDS = ['browser-playwright', 'browser-tool', 'browser-playwright-service'];
26
+ /**
27
+ * Subpath entries that 0.7.0 stopped mounting. Rows naming one of these are
28
+ * stale even when the user gave them some other id.
29
+ */
30
+ export const LEGACY_ENTRY_NAMES = [
31
+ 'dsh-browser-playwright-codex/tool',
32
+ 'dsh-browser-playwright-codex/playwright',
33
+ 'dsh-browser-playwright-codex/service',
34
+ ];
35
+ /**
36
+ * Retired package names this project owns on npm. A row mounting one of them
37
+ * keeps a second, older settings card alive — and that card rewrites the patch
38
+ * layer in the pre-0.7.0 shape, so the fix keeps reverting.
39
+ *
40
+ * Matching is by package NAME, never by row id: `- id:` is a local label the
41
+ * user picks, so another plugin may legitimately call its row `browser-tool`.
42
+ * Only a row whose `name:` is one of ours is reported.
43
+ */
44
+ export const RETIRED_PACKAGES = ['dsh-browser-playwright', 'dsh-browser-toggle'];
45
+ /** A `- id:` at column 0 starts a loader row. */
46
+ const ROW_ID = /^- id:\s*['"]?([A-Za-z0-9._-]+)['"]?\s*$/;
47
+ /** `name:` at exactly two spaces belongs to the row, not to a nested config block. */
48
+ const ROW_NAME = /^ {2}name:\s*['"]?([^'"\s#]+)['"]?\s*$/;
49
+ /**
50
+ * Find loader rows in one patch file's text that 0.7.0 stopped reading.
51
+ *
52
+ * The evidence is always the row's `name:` — never its `- id:`, which is just a
53
+ * local label. A patch layer that happens to call some unrelated plugin's row
54
+ * `browser-tool` is not reported.
55
+ *
56
+ * Pure and text-only so the rule can be tested without touching a real patch
57
+ * layer. Comments are ignored naturally: a commented row fails the anchored
58
+ * `- id:` / ` name:` patterns.
59
+ *
60
+ * @param text - contents of a cordis.patch.yml layer
61
+ * @returns every stale row, in file order
62
+ */
63
+ export function findLegacyRowsIn(text) {
64
+ const rows = [];
65
+ const lines = text.split(/\r?\n/);
66
+ let current;
67
+ const finish = () => {
68
+ const name = current?.name;
69
+ if (name !== undefined && LEGACY_ENTRY_NAMES.includes(name)) {
70
+ rows.push({
71
+ id: current?.id ?? '(no id)',
72
+ name,
73
+ line: current?.line ?? 0,
74
+ target: 'entry',
75
+ });
76
+ }
77
+ else if (name !== undefined && RETIRED_PACKAGES.includes(name)) {
78
+ rows.push({
79
+ id: current?.id ?? '(no id)',
80
+ name,
81
+ line: current?.line ?? 0,
82
+ target: 'package',
83
+ });
84
+ }
85
+ current = undefined;
86
+ };
87
+ for (let index = 0; index < lines.length; index += 1) {
88
+ const line = lines[index] ?? '';
89
+ const id = ROW_ID.exec(line);
90
+ if (id !== null) {
91
+ finish();
92
+ const value = id[1];
93
+ if (value !== undefined)
94
+ current = { id: value, line: index + 1 };
95
+ continue;
96
+ }
97
+ if (current === undefined)
98
+ continue;
99
+ const name = ROW_NAME.exec(line);
100
+ const label = name?.[1];
101
+ if (label !== undefined && current.name === undefined)
102
+ current.name = label;
103
+ }
104
+ finish();
105
+ return rows;
106
+ }
107
+ /**
108
+ * The operator-facing warning for one patch file.
109
+ *
110
+ * Written as one self-contained paragraph pair on purpose: it lands in a log
111
+ * line, so it has to name the file, the rows, and the fix without the reader
112
+ * opening documentation first.
113
+ *
114
+ * @param file - absolute path of the patch layer
115
+ * @param rows - stale rows found in it
116
+ * @returns the warning text
117
+ */
118
+ export function legacyRowWarning(file, rows) {
119
+ const listed = rows
120
+ .map((row) => ` - 第 ${String(row.line)} 行: - id: ${row.id} (name: ${row.name ?? '?'})` +
121
+ (row.target === 'package' ? ' ← 退役包' : ' ← 本插件 0.7.0 之前的入口'))
122
+ .join('\n');
123
+ const retired = rows.some((row) => row.target === 'package');
124
+ return [
125
+ 'browser 插件发现 0.7.0 之前的旧加载行,当前版本不再读取它们 —— 那几行里的 config 全部不生效,',
126
+ '插件按包内默认值运行(allowEvaluate / allowCdp 默认关闭,等于你以为开着其实关着)。',
127
+ `文件: ${file}`,
128
+ listed,
129
+ ...(retired
130
+ ? [
131
+ '标了「退役包」的行挂的是本项目的旧包(dsh-browser-toggle / dsh-browser-playwright):它们自带一张旧设置卡,',
132
+ '还会按旧写法改写这个文件,所以只改下面这几行会反复被改回去 —— 先把那一行去掉或禁用。',
133
+ ]
134
+ : []),
135
+ '改法:按 name 删掉这些旧行,然后打开一次设置页的「浏览器」卡片(它会整块重写成一行),',
136
+ "或手写一行 `- id: browser` + `name: 'dsh-browser-playwright-codex'`,并把 service / playwright / tool 三节写全。",
137
+ '判断依据是每行的 name(包名),不是 id —— id 只是本地标签,别人的插件也可能叫 browser-tool。',
138
+ '如果你确认某个 name 不是本插件的包,那它跟这条提示无关,忽略即可。',
139
+ ].join('\n');
140
+ }
141
+ /**
142
+ * Patch layers that can hold a browser row: the home layer plus every profile
143
+ * layer. Missing files and unreadable directories are skipped silently — this
144
+ * check must never be the reason a plugin fails to load.
145
+ *
146
+ * @param env - environment (`DSH_HOME` override, as the rest of the package uses)
147
+ * @returns candidate file paths, home layer first
148
+ */
149
+ export async function patchLayers(env = process.env) {
150
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
151
+ const files = [join(home, 'cordis.patch.yml')];
152
+ const profiles = join(home, 'profiles');
153
+ let entries = [];
154
+ try {
155
+ entries = await readdir(profiles);
156
+ }
157
+ catch {
158
+ entries = [];
159
+ }
160
+ for (const entry of entries.sort())
161
+ files.push(join(profiles, entry, 'cordis.patch.yml'));
162
+ return files;
163
+ }
164
+ /**
165
+ * Scan every patch layer for stale rows.
166
+ *
167
+ * @param env - environment (`DSH_HOME` override)
168
+ * @returns one entry per file that still lists a retired row; empty when clean
169
+ */
170
+ export async function scanLegacyRows(env = process.env) {
171
+ const hits = [];
172
+ for (const file of await patchLayers(env)) {
173
+ let text = '';
174
+ try {
175
+ text = await readFile(file, 'utf8');
176
+ }
177
+ catch {
178
+ continue;
179
+ }
180
+ const rows = findLegacyRowsIn(text);
181
+ if (rows.length > 0)
182
+ hits.push({ file, rows });
183
+ }
184
+ return hits;
185
+ }
186
+ /** DSH homes already scanned in this process, so one load logs once. */
187
+ const scannedHomes = new Set();
188
+ /**
189
+ * Warn about stale loader rows, once per DSH home, never throwing.
190
+ *
191
+ * Call it from the plugin entry: it is advisory only, so a failure here must
192
+ * never affect mounting the capability.
193
+ *
194
+ * @param ctx - plugin context (its logger carries the message to the host log)
195
+ * @param env - environment (`DSH_HOME` override, as tests use)
196
+ * @param report - sink for the text (defaults to the context logger, then console)
197
+ */
198
+ export async function warnLegacyRows(ctx, env = process.env, report) {
199
+ if (env.DSH_BROWSER_SKIP_LEGACY_SCAN === '1')
200
+ return;
201
+ const home = env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(homedir(), '.dsh');
202
+ if (scannedHomes.has(home))
203
+ return;
204
+ scannedHomes.add(home);
205
+ let hits = [];
206
+ try {
207
+ hits = await scanLegacyRows(env);
208
+ }
209
+ catch {
210
+ return;
211
+ }
212
+ if (hits.length === 0)
213
+ return;
214
+ const warn = report ?? loggerWarn(ctx);
215
+ for (const hit of hits) {
216
+ try {
217
+ warn(legacyRowWarning(hit.file, hit.rows));
218
+ }
219
+ catch {
220
+ // A logger that is not mounted yet must not break the load.
221
+ }
222
+ }
223
+ }
224
+ /** Best-effort warn sink: the context logger when it exists, otherwise console. */
225
+ function loggerWarn(ctx) {
226
+ return (text) => {
227
+ try {
228
+ if (ctx.logger !== undefined) {
229
+ ctx.logger.warn(text);
230
+ return;
231
+ }
232
+ }
233
+ catch {
234
+ // fall through to console
235
+ }
236
+ console.warn(text);
237
+ };
238
+ }
@@ -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,6 +11,8 @@
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
  */
@@ -33,7 +35,7 @@ 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);
package/lib/plugin.js CHANGED
@@ -5,6 +5,7 @@ import * as tool from "./tool.js";
5
5
  import * as settingsCard from "./settings-card.js";
6
6
  import { Config as PlaywrightConfigSchema } from "./config.js";
7
7
  import { Config as ToolConfigSchema } from "./tool.js";
8
+ import { warnLegacyRows } from "./legacy-config.js";
8
9
  /** Schemastery validation for {@link BrowserPluginConfig}. */
9
10
  export const Config = z.object({
10
11
  service: ServiceConfig,
@@ -21,6 +22,10 @@ export const name = 'browser';
21
22
  * @param config - nested config for each concern; the loader fills defaults
22
23
  */
23
24
  export function apply(ctx, config) {
25
+ // 0.7.0 stopped reading the three legacy rows, and a stale row downgrades the
26
+ // deployment to package defaults (both power gates off) without an error.
27
+ // Advisory only: it reads the patch layers and never blocks the mount.
28
+ void warnLegacyRows(ctx).catch(() => { });
24
29
  ctx.plugin(BrowserRuntime, config.service ?? {});
25
30
  ctx.plugin(playwright, config.playwright);
26
31
  ctx.plugin(tool, config.tool);
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.
@@ -50,6 +51,26 @@ export declare class PlaywrightProvider {
50
51
  /** Owner that raised the dialog currently parked on a page. */
51
52
  lastActor: string | undefined;
52
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;
53
74
  constructor(config: PlaywrightConfig);
54
75
  available(): boolean;
55
76
  /** Pages already carrying the dialog guard (WeakSet: pages stay collectable). */
@@ -98,6 +119,14 @@ export declare class PlaywrightProvider {
98
119
  */
99
120
  private relaunchIfModeChanged;
100
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;
101
130
  disposeOwner(owner: string): Promise<void>;
102
131
  dispose(): Promise<void>;
103
132
  /** Touch the entry's last-used clock and re-arm its idle timer. */
@@ -120,6 +149,19 @@ export declare class PlaywrightProvider {
120
149
  ensureContext(): Promise<BrowserContext>;
121
150
  /** Remove stale Chrome singleton lock files inside the profile directory. */
122
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;
123
165
  /**
124
166
  * Collect the login-state fallback WITHOUT ever opening a page while the
125
167
  * window is put away.
package/lib/provider.js CHANGED
@@ -10,10 +10,11 @@ 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 { getEffectiveHeadless, 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';
@@ -37,6 +38,26 @@ export class PlaywrightProvider {
37
38
  /** Owner that raised the dialog currently parked on a page. */
38
39
  lastActor;
39
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;
40
61
  constructor(config) {
41
62
  this.config = config;
42
63
  // Tell the settings card which page-reading mode this deployment was
@@ -215,6 +236,11 @@ export class PlaywrightProvider {
215
236
  // window (Codex-style). Entries are lightweight session handles on
216
237
  // the shared context; the last owner to leave closes the window.
217
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);
218
244
  const session = new PlaywrightSession(this, owner, context);
219
245
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
220
246
  this.entries.set(owner, entry);
@@ -238,6 +264,8 @@ export class PlaywrightProvider {
238
264
  ignoreHTTPSErrors: this.config.launch.ignoreHTTPSErrors,
239
265
  });
240
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);
241
269
  await context.addInitScript(SNAPSHOT_SCRIPT);
242
270
  const session = new PlaywrightSession(this, owner, context);
243
271
  const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
@@ -252,6 +280,30 @@ export class PlaywrightProvider {
252
280
  });
253
281
  return session;
254
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
+ }
255
307
  async disposeOwner(owner) {
256
308
  const entry = this.entries.get(owner);
257
309
  if (entry === undefined)
@@ -275,6 +327,10 @@ export class PlaywrightProvider {
275
327
  }
276
328
  async dispose() {
277
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;
278
334
  const owners = [...this.entries.keys()];
279
335
  for (const owner of owners)
280
336
  await this.disposeOwner(owner);
@@ -364,6 +420,29 @@ export class PlaywrightProvider {
364
420
  async clearChromeLocks() {
365
421
  return clearChromeLocks(this);
366
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
+ }
367
446
  /**
368
447
  * Collect the login-state fallback WITHOUT ever opening a page while the
369
448
  * window is put away.