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/CHANGELOG.md +60 -0
- package/README.md +10 -10
- package/SECURITY.md +1 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +49 -5
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/gates-patch.js +0 -1
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -2
- package/lib/legacy-config.d.ts +93 -0
- package/lib/legacy-config.js +238 -0
- package/lib/navigation-guard.d.ts +48 -0
- package/lib/navigation-guard.js +48 -0
- package/lib/page-actions.js +6 -0
- package/lib/page-snapshot.js +5 -0
- package/lib/playwright.d.ts +1 -1
- package/lib/playwright.js +3 -1
- package/lib/plugin.js +5 -0
- package/lib/provider.d.ts +42 -0
- package/lib/provider.js +80 -1
- package/lib/session.d.ts +40 -4
- package/lib/session.js +95 -5
- package/lib/settings-card.js +15 -4
- package/lib/tool.js +79 -54
- package/lib/types.d.ts +28 -0
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/lib/wait-conditions.d.ts +55 -0
- package/lib/wait-conditions.js +141 -0
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
|
@@ -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
|
+
}
|
package/lib/page-actions.js
CHANGED
|
@@ -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);
|
package/lib/page-snapshot.js
CHANGED
|
@@ -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
|
/**
|
package/lib/playwright.d.ts
CHANGED
|
@@ -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
|
|
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.
|