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/CHANGELOG.md +74 -0
- package/README.md +17 -5
- package/SECURITY.md +5 -1
- package/lib/browser-lifecycle.d.ts +13 -1
- package/lib/browser-lifecycle.js +57 -6
- package/lib/cache-clean.d.ts +90 -0
- package/lib/cache-clean.js +386 -0
- package/lib/challenge.d.ts +8 -0
- package/lib/challenge.js +16 -0
- package/lib/client.js +165 -0
- package/lib/errors.d.ts +12 -0
- package/lib/errors.js +16 -0
- package/lib/index.d.ts +4 -2
- package/lib/index.js +3 -2
- 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 +5 -2
- package/lib/provider.d.ts +56 -0
- package/lib/provider.js +117 -5
- package/lib/runtime-state.d.ts +20 -0
- package/lib/runtime-state.js +53 -0
- package/lib/session.d.ts +33 -3
- package/lib/session.js +86 -5
- package/lib/settings-card.js +129 -0
- package/lib/tool-render.js +3 -2
- package/lib/tool.js +13 -8
- package/lib/url-policy.d.ts +13 -0
- package/lib/url-policy.js +24 -2
- package/package.json +1 -1
- package/scripts/dsh-browser-verify.mjs +34 -1
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
|
+
}
|
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,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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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.
|
package/lib/runtime-state.d.ts
CHANGED
|
@@ -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.
|
package/lib/runtime-state.js
CHANGED
|
@@ -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.
|