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/session.d.ts
CHANGED
|
@@ -60,6 +60,32 @@ export declare class PlaywrightSession {
|
|
|
60
60
|
/** Nonce of the baseline ref space; a change means navigation reset. */
|
|
61
61
|
lastRefNonce: string | undefined;
|
|
62
62
|
constructor(provider: PlaywrightProvider, owner: string, context: BrowserContext);
|
|
63
|
+
/**
|
|
64
|
+
* Turn a navigation the host guard refused into a named refusal.
|
|
65
|
+
*
|
|
66
|
+
* A page can navigate itself (location.href, meta refresh, a form submit, a
|
|
67
|
+
* 302) without passing any entry-point check. The guard stops the document
|
|
68
|
+
* request; this reports it, so "I clicked and nothing happened" becomes
|
|
69
|
+
* URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
|
|
70
|
+
* inherit an earlier refusal.
|
|
71
|
+
*/
|
|
72
|
+
throwIfNavigationBlocked(): void;
|
|
73
|
+
/**
|
|
74
|
+
* Refuse to go on driving a page that is outside the allow-list.
|
|
75
|
+
*
|
|
76
|
+
* The request-level guard (navigation-guard.ts) catches navigations started
|
|
77
|
+
* from inside the page, but NOT a redirect the browser follows on its own:
|
|
78
|
+
* measured 2026-10-26, `context.route()` never sees the hop after a
|
|
79
|
+
* `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
|
|
80
|
+
* on the disallowed host with no route event at all. So every action that
|
|
81
|
+
* settles a page also checks where it actually ended up.
|
|
82
|
+
*
|
|
83
|
+
* The off-list page is abandoned for about:blank (hostless, always allowed)
|
|
84
|
+
* instead of being left there: otherwise the session would wedge on a page it
|
|
85
|
+
* may never drive, and "navigate somewhere allowed" could not fix it.
|
|
86
|
+
* @param page - the page this action ran on.
|
|
87
|
+
*/
|
|
88
|
+
assertCurrentUrlAllowed(page: Page): Promise<void>;
|
|
63
89
|
/**
|
|
64
90
|
* The session's cookies, reduced to what the login inference needs. `value` is dropped here,
|
|
65
91
|
* at the only place cookies enter the summary path, so nothing downstream can leak it.
|
|
@@ -278,9 +304,13 @@ export declare class PlaywrightSession {
|
|
|
278
304
|
* Send one raw CDP command on the current page's session and return its
|
|
279
305
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
280
306
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
281
|
-
* query the DOM the same way Chrome DevTools does.
|
|
282
|
-
*
|
|
283
|
-
*
|
|
307
|
+
* query the DOM the same way Chrome DevTools does.
|
|
308
|
+
*
|
|
309
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
310
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
311
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
312
|
+
* checked them, so a caller working through the service could send
|
|
313
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
284
314
|
*/
|
|
285
315
|
cdp(method: string, params: Record<string, unknown>, signal?: AbortSignal): Promise<unknown>;
|
|
286
316
|
close(): Promise<void>;
|
package/lib/session.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { looksLikeToken, registrableDomain } from "./login-sites.js";
|
|
2
|
-
import { BrowserError } from "./errors.js";
|
|
2
|
+
import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
|
|
3
|
+
import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
4
|
+
import { getGates } from "./runtime-state.js";
|
|
3
5
|
import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
|
|
4
6
|
import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
|
|
5
7
|
import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
|
|
6
8
|
import { assertActionable, back, clickAt, clickAtRef, clickLocator, clickText, closeTab, fill, forward, hoverLocator, hoverText, navigate, openTab, press, resolveClickPoint, runAction, scroll, switchTab, verifyFill, withAbort, } from "./page-actions.js";
|
|
7
9
|
import { assertLive, attachDiff, attachDocResponse, attachNoProgress, buildRefSignatures, captureAria, captureLegacy, refLocator, resolveSnapshotEngine, settleAndSnapshot, snapshot, } from "./page-snapshot.js";
|
|
8
|
-
import { assertAllowedUrl } from "./url-policy.js";
|
|
10
|
+
import { assertAllowedUrl, isHostAllowed } from "./url-policy.js";
|
|
9
11
|
/** Live session over one browser context owned by one caller. */
|
|
10
12
|
export class PlaywrightSession {
|
|
11
13
|
provider;
|
|
@@ -55,6 +57,66 @@ export class PlaywrightSession {
|
|
|
55
57
|
this.owner = owner;
|
|
56
58
|
this.context = context;
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Turn a navigation the host guard refused into a named refusal.
|
|
62
|
+
*
|
|
63
|
+
* A page can navigate itself (location.href, meta refresh, a form submit, a
|
|
64
|
+
* 302) without passing any entry-point check. The guard stops the document
|
|
65
|
+
* request; this reports it, so "I clicked and nothing happened" becomes
|
|
66
|
+
* URL_NOT_ALLOWED naming the host. Consumed once: a later action must not
|
|
67
|
+
* inherit an earlier refusal.
|
|
68
|
+
*/
|
|
69
|
+
throwIfNavigationBlocked() {
|
|
70
|
+
const refused = this.provider.takeBlockedNavigation();
|
|
71
|
+
if (refused === undefined)
|
|
72
|
+
return;
|
|
73
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page tried to navigate to ' +
|
|
74
|
+
refused.url +
|
|
75
|
+
' and was blocked: host ' +
|
|
76
|
+
refused.host +
|
|
77
|
+
' is not in allowedDomains. Nothing left this site — the request was stopped before it reached the network. ' +
|
|
78
|
+
'The page you were on is unchanged; tell the user the site tried to leave the allow-list.');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Refuse to go on driving a page that is outside the allow-list.
|
|
82
|
+
*
|
|
83
|
+
* The request-level guard (navigation-guard.ts) catches navigations started
|
|
84
|
+
* from inside the page, but NOT a redirect the browser follows on its own:
|
|
85
|
+
* measured 2026-10-26, `context.route()` never sees the hop after a
|
|
86
|
+
* `continue()`d request, so `127.0.0.1/x` answering `302 → localhost/y` lands
|
|
87
|
+
* on the disallowed host with no route event at all. So every action that
|
|
88
|
+
* settles a page also checks where it actually ended up.
|
|
89
|
+
*
|
|
90
|
+
* The off-list page is abandoned for about:blank (hostless, always allowed)
|
|
91
|
+
* instead of being left there: otherwise the session would wedge on a page it
|
|
92
|
+
* may never drive, and "navigate somewhere allowed" could not fix it.
|
|
93
|
+
* @param page - the page this action ran on.
|
|
94
|
+
*/
|
|
95
|
+
async assertCurrentUrlAllowed(page) {
|
|
96
|
+
const raw = page.url();
|
|
97
|
+
if (raw === '')
|
|
98
|
+
return;
|
|
99
|
+
let target;
|
|
100
|
+
try {
|
|
101
|
+
target = new URL(raw);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:')
|
|
107
|
+
return;
|
|
108
|
+
const allowedDomains = this.provider.config.allowedDomains ?? [];
|
|
109
|
+
if (isHostAllowed(target.hostname, allowedDomains))
|
|
110
|
+
return;
|
|
111
|
+
await page.goto('about:blank').catch(() => { });
|
|
112
|
+
throw new BrowserError('URL_NOT_ALLOWED', 'the page ended up on host ' +
|
|
113
|
+
target.hostname +
|
|
114
|
+
' (' +
|
|
115
|
+
raw +
|
|
116
|
+
'), which is not in allowedDomains — a redirect the browser followed on its own. ' +
|
|
117
|
+
'The tab has been reset to about:blank, so nothing outside the allow-list stays reachable. ' +
|
|
118
|
+
'Tell the user: the site tried to send this session off the allow-list.');
|
|
119
|
+
}
|
|
58
120
|
/**
|
|
59
121
|
* The session's cookies, reduced to what the login inference needs. `value` is dropped here,
|
|
60
122
|
* at the only place cookies enter the summary path, so nothing downstream can leak it.
|
|
@@ -120,6 +182,9 @@ export class PlaywrightSession {
|
|
|
120
182
|
}
|
|
121
183
|
/** Run one operation under busy tracking: idle disposal defers until it settles. */
|
|
122
184
|
run(operation) {
|
|
185
|
+
// Every action starts from a clean slate: a refusal recorded during an
|
|
186
|
+
// earlier action must not be reported as this one's outcome.
|
|
187
|
+
this.provider.blockedNavigation = undefined;
|
|
123
188
|
this.provider.beginOp(this.owner);
|
|
124
189
|
return Promise.resolve()
|
|
125
190
|
.then(operation)
|
|
@@ -491,6 +556,14 @@ export class PlaywrightSession {
|
|
|
491
556
|
return pageData(this);
|
|
492
557
|
}
|
|
493
558
|
async evaluate(expression, signal) {
|
|
559
|
+
// The switch is enforced HERE, not only in the tool layer: browser_evaluate
|
|
560
|
+
// used to be guarded by tool.ts alone, so anything holding the service (a
|
|
561
|
+
// sibling plugin, a script) could run arbitrary page JavaScript with the
|
|
562
|
+
// user's logins while the setting said off (measured 2026-10-26). An
|
|
563
|
+
// unpublished gate set counts as OFF — a deployment that never said yes has
|
|
564
|
+
// not said yes.
|
|
565
|
+
if (!(getGates()?.allowEvaluate ?? false))
|
|
566
|
+
throw evaluateDisabled();
|
|
494
567
|
return this.run(async () => {
|
|
495
568
|
this.assertLive();
|
|
496
569
|
this.provider.touch(this.owner);
|
|
@@ -511,11 +584,19 @@ export class PlaywrightSession {
|
|
|
511
584
|
* Send one raw CDP command on the current page's session and return its
|
|
512
585
|
* result. This is the "direct protocol" escape hatch behind every other
|
|
513
586
|
* tool — the model can inspect geometry, dispatch trusted input events or
|
|
514
|
-
* query the DOM the same way Chrome DevTools does.
|
|
515
|
-
*
|
|
516
|
-
*
|
|
587
|
+
* query the DOM the same way Chrome DevTools does.
|
|
588
|
+
*
|
|
589
|
+
* Both rules live here as well as in the tool layer, deliberately: the
|
|
590
|
+
* allow-list and the switch must hold for anything that reaches this method,
|
|
591
|
+
* not just for the registered tool. Before 2026-10-26 only `browser_cdp`
|
|
592
|
+
* checked them, so a caller working through the service could send
|
|
593
|
+
* `Runtime.evaluate` — arbitrary JavaScript — with the switch off (measured).
|
|
517
594
|
*/
|
|
518
595
|
async cdp(method, params, signal) {
|
|
596
|
+
if (!(getGates()?.allowCdp ?? false))
|
|
597
|
+
throw cdpDisabled();
|
|
598
|
+
if (!isCdpMethodAllowed(method))
|
|
599
|
+
throw new BrowserError('CDP_DENIED', cdpDenialMessage(method));
|
|
519
600
|
return this.run(async () => {
|
|
520
601
|
this.assertLive();
|
|
521
602
|
this.provider.touch(this.owner);
|
package/lib/settings-card.js
CHANGED
|
@@ -64,6 +64,7 @@ import * as runtimeState from "./runtime-state.js";
|
|
|
64
64
|
// without a DSH host (a half-written patch layer would break every plugin load).
|
|
65
65
|
import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
|
|
66
66
|
import { readFileSync } from 'node:fs';
|
|
67
|
+
import { clean, isTargetId, resolvePaths, scan } from "./cache-clean.js";
|
|
67
68
|
const { getEnabled, setEnabled, getEffectiveEngine, setSnapshotEngine, isEngineUserChosen } = runtimeState;
|
|
68
69
|
export const name = 'browser-toggle';
|
|
69
70
|
export const inject = ['webServer'];
|
|
@@ -114,7 +115,132 @@ async function readBody(req) {
|
|
|
114
115
|
}
|
|
115
116
|
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
|
|
116
117
|
}
|
|
118
|
+
/** The plugin's own version, used to refuse other versions' installers. */
|
|
119
|
+
function installedVersion() {
|
|
120
|
+
try {
|
|
121
|
+
const raw = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
122
|
+
return typeof raw.version === 'string' ? raw.version : '0.0.0';
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return '0.0.0';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Resolved disk locations; never taken from the request body. */
|
|
129
|
+
function diskPaths() {
|
|
130
|
+
return resolvePaths(process.env, installedVersion());
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* GET /disk/stat — sizes per bucket; POST /disk/clean — preview or execute.
|
|
134
|
+
* Both require the CSRF header, both answer with per-target detail so partial
|
|
135
|
+
* failures are visible instead of being folded into one number.
|
|
136
|
+
*/
|
|
137
|
+
function registerDiskRoutes(ctx) {
|
|
138
|
+
ctx.effect(() => ctx.webServer.register({
|
|
139
|
+
kind: 'exact',
|
|
140
|
+
path: PREFIX + '/disk/stat',
|
|
141
|
+
handler: async (req, res) => {
|
|
142
|
+
if (req.method !== 'GET')
|
|
143
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'GET only' } });
|
|
144
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
145
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
146
|
+
try {
|
|
147
|
+
const report = await scan(diskPaths());
|
|
148
|
+
sendJson(res, 200, { ...report, browserActive: getEnabled() === true });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
sendJson(res, 500, {
|
|
152
|
+
error: { code: 'DISK_SCAN', message: String(error && error.message ? error.message : error) },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}));
|
|
157
|
+
ctx.effect(() => ctx.webServer.register({
|
|
158
|
+
kind: 'exact',
|
|
159
|
+
path: PREFIX + '/disk/clean',
|
|
160
|
+
handler: async (req, res) => {
|
|
161
|
+
if (req.method !== 'POST')
|
|
162
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
163
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
164
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
165
|
+
const body = await readBody(req);
|
|
166
|
+
if (!body.ok)
|
|
167
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
168
|
+
let payload;
|
|
169
|
+
try {
|
|
170
|
+
payload = JSON.parse(body.text || '{}');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
174
|
+
}
|
|
175
|
+
if (typeof payload.dryRun !== 'boolean')
|
|
176
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`dryRun` must be a boolean' } });
|
|
177
|
+
if (!Array.isArray(payload.targets) || payload.targets.length === 0)
|
|
178
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`targets` must be a non-empty array' } });
|
|
179
|
+
const targets = [];
|
|
180
|
+
for (const value of payload.targets) {
|
|
181
|
+
if (!isTargetId(value))
|
|
182
|
+
return sendJson(res, 400, {
|
|
183
|
+
error: { code: 'UNKNOWN_TARGET', message: 'unknown target: ' + String(value) },
|
|
184
|
+
});
|
|
185
|
+
targets.push(value);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const report = await clean({
|
|
189
|
+
...diskPaths(),
|
|
190
|
+
targets,
|
|
191
|
+
dryRun: payload.dryRun,
|
|
192
|
+
browserActive: getEnabled() === true,
|
|
193
|
+
});
|
|
194
|
+
sendJson(res, 200, report);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
sendJson(res, 500, {
|
|
198
|
+
error: { code: 'DISK_CLEAN', message: String(error && error.message ? error.message : error) },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* POST /window { headless } — the user's window-mode switch.
|
|
206
|
+
*
|
|
207
|
+
* The change is persisted immediately and applied by the provider on its next
|
|
208
|
+
* acquire (it compares the mode it launched with against the desired one and
|
|
209
|
+
* relaunches, exporting login state first). Nothing is killed mid-operation.
|
|
210
|
+
*/
|
|
211
|
+
function registerWindowRoute(ctx) {
|
|
212
|
+
ctx.effect(() => ctx.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: PREFIX + '/window',
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
if (req.method !== 'POST')
|
|
217
|
+
return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
|
|
218
|
+
if (req.headers[HEADER] !== 'browser-toggle')
|
|
219
|
+
return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
|
|
220
|
+
const body = await readBody(req);
|
|
221
|
+
if (!body.ok)
|
|
222
|
+
return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
|
|
223
|
+
let payload;
|
|
224
|
+
try {
|
|
225
|
+
payload = JSON.parse(body.text || '{}');
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
|
|
229
|
+
}
|
|
230
|
+
if (typeof payload.headless !== 'boolean')
|
|
231
|
+
return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`headless` must be a boolean' } });
|
|
232
|
+
const changed = runtimeState.setHeadless(payload.headless);
|
|
233
|
+
sendJson(res, 200, {
|
|
234
|
+
headless: runtimeState.getEffectiveHeadless(),
|
|
235
|
+
changed,
|
|
236
|
+
appliedOn: 'next-operation',
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
117
241
|
export function apply(ctx) {
|
|
242
|
+
registerDiskRoutes(ctx);
|
|
243
|
+
registerWindowRoute(ctx);
|
|
118
244
|
ctx.effect(() => ctx.webServer.register({
|
|
119
245
|
kind: 'exact',
|
|
120
246
|
path: `${PREFIX}/state`,
|
|
@@ -136,6 +262,9 @@ export function apply(ctx) {
|
|
|
136
262
|
allowEvaluate: gates ? gates.allowEvaluate : null,
|
|
137
263
|
allowCdp: gates ? gates.allowCdp : null,
|
|
138
264
|
diagnostics: gates ? gates.diagnostics : null,
|
|
265
|
+
headless: runtimeState.getEffectiveHeadless(),
|
|
266
|
+
headlessUserChosen: runtimeState.isHeadlessUserChosen(),
|
|
267
|
+
headlessConfigured: runtimeState.getConfiguredHeadless(),
|
|
139
268
|
// What the user's own layer says right now. Differs from the above
|
|
140
269
|
// between a write and the live reload that applies it.
|
|
141
270
|
patchGates: managedBlockGates() ?? null,
|
package/lib/tool-render.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { challengeNote, detectChallenge, detectSilentPage } from "./challenge.js";
|
|
2
2
|
import { renderSnapshot, renderSnapshotDiff } from "./snapshot-render.js";
|
|
3
|
+
import * as runtimeState from "./runtime-state.js";
|
|
3
4
|
/**
|
|
4
5
|
* Render one diagnostics payload as one line per entry. Diagnostics are read when
|
|
5
6
|
* something already looks wrong, so the compact form matters more than the rich
|
|
@@ -134,7 +135,7 @@ export function snapshotValue(snapshot) {
|
|
|
134
135
|
']: ' +
|
|
135
136
|
challenge.hint +
|
|
136
137
|
'.\n' +
|
|
137
|
-
|
|
138
|
+
challengeNote(runtimeState.getEffectiveHeadless(true)) +
|
|
138
139
|
'\n\n' +
|
|
139
140
|
tree) +
|
|
140
141
|
diffBlock,
|
package/lib/tool.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
2
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
3
|
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
-
import { BrowserError } from "./errors.js";
|
|
4
|
+
import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
|
|
5
5
|
import { registeredSuffixes } from "./contract.js";
|
|
6
6
|
import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
|
|
7
7
|
import { summarizeLoginSites } from "./login-sites.js";
|
|
@@ -58,12 +58,17 @@ export function apply(ctx, config) {
|
|
|
58
58
|
// Tell the Settings page what is actually in effect. It cannot read this
|
|
59
59
|
// config itself (the value may come from a bundle patch, a profile patch or a
|
|
60
60
|
// home-level patch), so the tool family publishes it at registration and the
|
|
61
|
-
// switch shows the truth instead of a guess.
|
|
62
|
-
//
|
|
61
|
+
// switch shows the truth instead of a guess.
|
|
62
|
+
//
|
|
63
|
+
// These are the CAPABILITY values, not `registered.has(...)`: the session
|
|
64
|
+
// layer enforces the switch too, and `registerDisabledTools` deliberately
|
|
65
|
+
// registers a switched-off tool so the model can discover it. Reading
|
|
66
|
+
// registration as permission published "on" for a gate that was off — the
|
|
67
|
+
// settings card showed a switch that was not there.
|
|
63
68
|
publishGates({
|
|
64
|
-
allowEvaluate:
|
|
65
|
-
allowCdp:
|
|
66
|
-
diagnostics:
|
|
69
|
+
allowEvaluate: config.allowEvaluate,
|
|
70
|
+
allowCdp: config.allowCdp,
|
|
71
|
+
diagnostics: config.diagnostics,
|
|
67
72
|
});
|
|
68
73
|
/** Register one gated tool only when this configuration keeps it live. */
|
|
69
74
|
const registerMaybe = (suffix, definition) => {
|
|
@@ -547,7 +552,7 @@ export function apply(ctx, config) {
|
|
|
547
552
|
},
|
|
548
553
|
async execute(args, exec) {
|
|
549
554
|
if (!config.allowEvaluate) {
|
|
550
|
-
throw
|
|
555
|
+
throw evaluateDisabled();
|
|
551
556
|
}
|
|
552
557
|
const session = await acquire(exec);
|
|
553
558
|
return (await session.evaluate(args.expression, exec.signal));
|
|
@@ -583,7 +588,7 @@ export function apply(ctx, config) {
|
|
|
583
588
|
},
|
|
584
589
|
async execute(args, exec) {
|
|
585
590
|
if (!config.allowCdp) {
|
|
586
|
-
throw
|
|
591
|
+
throw cdpDisabled();
|
|
587
592
|
}
|
|
588
593
|
if (!isCdpMethodAllowed(args.method)) {
|
|
589
594
|
throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
|
package/lib/url-policy.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this host inside the allow-list? Empty list means "any host".
|
|
3
|
+
*
|
|
4
|
+
* Suffix matching is deliberately anchored one level down: `example.com`
|
|
5
|
+
* admits `example.com` and `a.example.com`, never `notexample.com` and never
|
|
6
|
+
* `example.com.evil.net`. A configured entry is compared after
|
|
7
|
+
* normalisation (lower case, leading dot dropped), because a user writing
|
|
8
|
+
* `.example.com` means the domain, not a subdomain literally named "example".
|
|
9
|
+
* @param host - hostname to test, e.g. "a.example.com".
|
|
10
|
+
* @param allowedDomains - configured suffixes; empty means everything passes.
|
|
11
|
+
* @returns true when the host is allowed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isHostAllowed(host: string, allowedDomains: readonly string[]): boolean;
|
|
1
14
|
/** Validate one absolute URL against the navigation policy. */
|
|
2
15
|
export declare function assertAllowedUrl(raw: string, allowedDomains: readonly string[]): URL;
|
package/lib/url-policy.js
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
* @module dsh-browser-playwright-codex/url-policy
|
|
4
4
|
*/
|
|
5
5
|
import { BrowserError } from "./errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Is this host inside the allow-list? Empty list means "any host".
|
|
8
|
+
*
|
|
9
|
+
* Suffix matching is deliberately anchored one level down: `example.com`
|
|
10
|
+
* admits `example.com` and `a.example.com`, never `notexample.com` and never
|
|
11
|
+
* `example.com.evil.net`. A configured entry is compared after
|
|
12
|
+
* normalisation (lower case, leading dot dropped), because a user writing
|
|
13
|
+
* `.example.com` means the domain, not a subdomain literally named "example".
|
|
14
|
+
* @param host - hostname to test, e.g. "a.example.com".
|
|
15
|
+
* @param allowedDomains - configured suffixes; empty means everything passes.
|
|
16
|
+
* @returns true when the host is allowed.
|
|
17
|
+
*/
|
|
18
|
+
export function isHostAllowed(host, allowedDomains) {
|
|
19
|
+
if (allowedDomains.length === 0)
|
|
20
|
+
return true;
|
|
21
|
+
const target = host.toLowerCase();
|
|
22
|
+
return allowedDomains.some((raw) => {
|
|
23
|
+
const suffix = raw.trim().toLowerCase().replace(/^\.+/, '');
|
|
24
|
+
if (suffix === '')
|
|
25
|
+
return false;
|
|
26
|
+
return target === suffix || target.endsWith('.' + suffix);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
6
29
|
/** Validate one absolute URL against the navigation policy. */
|
|
7
30
|
export function assertAllowedUrl(raw, allowedDomains) {
|
|
8
31
|
let parsed;
|
|
@@ -17,8 +40,7 @@ export function assertAllowedUrl(raw, allowedDomains) {
|
|
|
17
40
|
}
|
|
18
41
|
if (allowedDomains.length > 0) {
|
|
19
42
|
const host = parsed.hostname.toLowerCase();
|
|
20
|
-
|
|
21
|
-
if (!allowed) {
|
|
43
|
+
if (!isHostAllowed(host, allowedDomains)) {
|
|
22
44
|
throw new BrowserError('URL_NOT_ALLOWED', 'host ' + parsed.hostname + ' is not in allowedDomains');
|
|
23
45
|
}
|
|
24
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@ import path from 'node:path'
|
|
|
28
28
|
import { PlaywrightProvider } from '../lib/provider.js'
|
|
29
29
|
import { Config as ConfigSchema } from '../lib/config.js'
|
|
30
30
|
import { renderSnapshot } from '../lib/snapshot-render.js'
|
|
31
|
-
import { getEffectiveEngine, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
31
|
+
import { getEffectiveEngine, publishGates, setSnapshotEngine } from '../lib/runtime-state.js'
|
|
32
32
|
|
|
33
33
|
// 验收必须只看代码,不看这台机器上用户的选择。
|
|
34
34
|
// 设置页的「页面识别方式」存在 ~/.dsh/dsh-browser-playwright.state.json,运行时状态层
|
|
@@ -128,6 +128,36 @@ const provider = new PlaywrightProvider(
|
|
|
128
128
|
)
|
|
129
129
|
const session = await provider.acquire('verify')
|
|
130
130
|
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// [0] 门禁在代码层也成立(2026-10-26 之前的漏洞:开关只在工具层挡)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// 本脚本不经过工具层,直接调 session —— 正是当初绕过门禁的那条路。默认(没发布门禁)
|
|
135
|
+
// 必须拒绝;确认之后再把门禁打开,好让后面的用例能读页面状态。
|
|
136
|
+
console.log('[0] 门禁:没开门禁时代码层也拒绝')
|
|
137
|
+
let evaluateGate = ''
|
|
138
|
+
try {
|
|
139
|
+
await session.evaluate('1 + 1')
|
|
140
|
+
} catch (e) {
|
|
141
|
+
evaluateGate = e.code
|
|
142
|
+
}
|
|
143
|
+
check('evaluate 未开门禁时报 EVALUATE_DISABLED', evaluateGate === 'EVALUATE_DISABLED', evaluateGate)
|
|
144
|
+
let cdpGate = ''
|
|
145
|
+
try {
|
|
146
|
+
await session.cdp('DOM.getDocument', { depth: 1 })
|
|
147
|
+
} catch (e) {
|
|
148
|
+
cdpGate = e.code
|
|
149
|
+
}
|
|
150
|
+
check('cdp 未开门禁时报 CDP_DISABLED', cdpGate === 'CDP_DISABLED', cdpGate)
|
|
151
|
+
publishGates({ allowEvaluate: true, allowCdp: true, diagnostics: true })
|
|
152
|
+
let cdpDenied = ''
|
|
153
|
+
try {
|
|
154
|
+
// 门禁打开也仍然要过白名单:Runtime.evaluate 是任意页面 JS,永远不在名单里。
|
|
155
|
+
await session.cdp('Runtime.evaluate', { expression: 'document.cookie', returnByValue: true })
|
|
156
|
+
} catch (e) {
|
|
157
|
+
cdpDenied = e.code
|
|
158
|
+
}
|
|
159
|
+
check('开门禁后 Runtime.evaluate 仍报 CDP_DENIED', cdpDenied === 'CDP_DENIED', cdpDenied)
|
|
160
|
+
|
|
131
161
|
console.log('[1] 快照取名与折叠')
|
|
132
162
|
const s0 = await session.navigate(URL_, 'load')
|
|
133
163
|
const tree = renderSnapshot(s0)
|
|
@@ -366,6 +396,9 @@ const expectedTools = contractMod.registeredToolNames(toolConfig.toolPrefix, {
|
|
|
366
396
|
registerDisabledTools: toolConfig.registerDisabledTools,
|
|
367
397
|
})
|
|
368
398
|
const byName = (n) => reg.find((t) => t.name === n)
|
|
399
|
+
// apply() 会把有效门禁刷成这份配置里的值(这里出厂默认是关),而下面的用例直接调
|
|
400
|
+
// session 而不是走工具层,所以再打开一次。
|
|
401
|
+
publishGates({ allowEvaluate: true, allowCdp: true, diagnostics: true })
|
|
369
402
|
check('存在 browser_dialog', byName('browser_dialog') !== undefined)
|
|
370
403
|
check(
|
|
371
404
|
'browser_dialog 有 accept 参数',
|