dsh-browser-playwright-codex 0.7.2 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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. Policy (which methods
282
- * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
283
- * itself performs no filtering so internal callers keep full control.
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. Policy (which methods
515
- * are allowed) lives in the tool layer via lib/cdp-policy.js; this method
516
- * itself performs no filtering so internal callers keep full control.
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);
@@ -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,
@@ -1,5 +1,6 @@
1
- import { CHALLENGE_NOTE, detectChallenge, detectSilentPage } from "./challenge.js";
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
- CHALLENGE_NOTE +
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. Driven by the same values that
62
- // decided which tools register, so the two can never disagree.
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: registered.has('evaluate'),
65
- allowCdp: registered.has('cdp'),
66
- diagnostics: registered.has('console_messages'),
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 new BrowserError('EVALUATE_DISABLED', 'browser_evaluate is disabled; set allowEvaluate: true on the browser-tool plugin to enable arbitrary page JavaScript');
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 new BrowserError('CDP_DISABLED', 'browser_cdp is disabled; set allowCdp: true on the browser-tool plugin to enable raw CDP inspection commands');
591
+ throw cdpDisabled();
587
592
  }
588
593
  if (!isCdpMethodAllowed(args.method)) {
589
594
  throw new BrowserError('CDP_DENIED', cdpDenialMessage(args.method));
@@ -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
- const allowed = allowedDomains.some((suffix) => host === suffix || host.endsWith('.' + suffix));
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.2",
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 参数',