dsh-browser-playwright-codex 0.8.2 → 0.9.0

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.
@@ -323,3 +323,152 @@ export async function writeGates(allowEvaluate, allowCdp, diagnostics, env = pro
323
323
  }
324
324
  return { patch: file, allowEvaluate, allowCdp, diagnostics };
325
325
  }
326
+ /** 在块的文本里找 `launch:` 与它的 `proxy:` 子行。认不出结构就抛错(拒绝写入)。 */
327
+ function findLaunchSection(lines) {
328
+ let launchAt = -1;
329
+ let launchIndent = '';
330
+ for (let i = 0; i < lines.length; i += 1) {
331
+ const m = /^(\s*)launch\s*:\s*(#.*)?$/.exec(lines[i] ?? '');
332
+ if (m !== null) {
333
+ launchAt = i;
334
+ launchIndent = m[1] ?? '';
335
+ break;
336
+ }
337
+ }
338
+ if (launchAt === -1) {
339
+ throw new Error('the managed block has no `playwright.launch:` section, so launch.proxy cannot be edited safely; ' +
340
+ 'nothing was written. Fix the block by hand (or remove it and save a gate once to rebuild it).');
341
+ }
342
+ const baseIndent = launchIndent.length;
343
+ let childIndent = '';
344
+ let proxy;
345
+ for (let i = launchAt + 1; i < lines.length; i += 1) {
346
+ const line = lines[i] ?? '';
347
+ const trimmed = line.trim();
348
+ if (trimmed === '')
349
+ continue;
350
+ const indent = (/^\s*/.exec(line)?.[0] ?? '').length;
351
+ if (indent <= baseIndent)
352
+ break; // 出了 launch 这一节
353
+ if (trimmed.startsWith('#'))
354
+ continue;
355
+ if (childIndent === '')
356
+ childIndent = ' '.repeat(indent);
357
+ const p = /^(\s*)proxy\s*:/.exec(line);
358
+ if (p !== null)
359
+ proxy = { at: i, indent: p[1] ?? '' };
360
+ }
361
+ return {
362
+ at: launchAt,
363
+ indent: baseIndent,
364
+ childIndent: childIndent === '' ? ' '.repeat(baseIndent + 2) : childIndent,
365
+ ...(proxy === undefined ? {} : { proxy }),
366
+ };
367
+ }
368
+ /**
369
+ * 改写块里的 proxy 行:有就改、没有就插、空字符串就删。其它行逐字节保留。
370
+ *
371
+ * @param body - 托管块全文(含标记)
372
+ * @param proxy - 已经过 normalizeProxy 校验的地址;'' 表示删掉这一行
373
+ * @returns 改好的块
374
+ */
375
+ export function patchBlockProxy(body, proxy) {
376
+ const nl = body.includes('\r\n') ? '\r\n' : '\n';
377
+ const lines = body.split(/\r?\n/);
378
+ const section = findLaunchSection(lines);
379
+ const value = proxy.trim();
380
+ const rendered = section.childIndent + 'proxy: ' + JSON.stringify(value);
381
+ if (section.proxy !== undefined) {
382
+ if (value === '')
383
+ lines.splice(section.proxy.at, 1);
384
+ else
385
+ lines[section.proxy.at] = rendered;
386
+ return lines.join(nl);
387
+ }
388
+ if (value === '')
389
+ return lines.join(nl); // 本来就没有,删空是幂等的
390
+ lines.splice(section.at + 1, 0, rendered);
391
+ return lines.join(nl);
392
+ }
393
+ /** 读托管块里的 proxy 值;没有就是 ''(= 直连)。 */
394
+ export function parseBrowserBlockProxy(text) {
395
+ const found = findBlock(text);
396
+ if (found === undefined)
397
+ return '';
398
+ const body = text.slice(found.start, found.after);
399
+ const lines = body.split(/\r?\n/);
400
+ let section;
401
+ try {
402
+ section = findLaunchSection(lines);
403
+ }
404
+ catch {
405
+ return '';
406
+ }
407
+ if (section?.proxy === undefined)
408
+ return '';
409
+ const raw = (lines[section.proxy.at] ?? '').replace(/^\s*proxy\s*:\s*/, '');
410
+ const withoutComment = raw.replace(/\s+#.*$/, '').trim();
411
+ if (withoutComment === '')
412
+ return '';
413
+ if ((withoutComment.startsWith('"') && withoutComment.endsWith('"')) ||
414
+ (withoutComment.startsWith("'") && withoutComment.endsWith("'"))) {
415
+ return withoutComment.slice(1, -1);
416
+ }
417
+ return withoutComment;
418
+ }
419
+ /**
420
+ * 把 proxy 合并进文件内容:没有托管块就先按模板建一个,再改这一行。
421
+ *
422
+ * @param original - 原文件内容
423
+ * @param proxy - 已校验的地址;'' = 删掉
424
+ * @returns 新文件内容
425
+ */
426
+ export function mergeBrowserBlockProxy(original, proxy) {
427
+ const value = proxy.trim();
428
+ let base = original;
429
+ let found = findBlock(base);
430
+ if (found === undefined) {
431
+ base = (base === '' || base.endsWith('\n') ? base : base + '\n') + '\n' + renderBrowserBlock(false, false, false);
432
+ found = findBlock(base);
433
+ }
434
+ if (found === undefined)
435
+ throw new Error('could not create the managed block; nothing was written');
436
+ const edited = patchBlockProxy(base.slice(found.start, found.after), value);
437
+ if (parseBrowserBlockProxy(edited) !== value) {
438
+ throw new Error('proxy line self-check failed; nothing was written');
439
+ }
440
+ return base.slice(0, found.start) + edited + base.slice(found.after);
441
+ }
442
+ /**
443
+ * 写 proxy:备份 → 原子替换 → 读回校验;校验失败恢复原文件。
444
+ *
445
+ * @param proxy - 已校验的地址;'' 表示删掉这一行(= 直连)
446
+ * @param env - 环境(测试用 DSH_HOME 覆盖)
447
+ * @returns 写到的文件与现在的值
448
+ */
449
+ export async function writeProxy(proxy, env = process.env) {
450
+ const file = homePatchPath(env);
451
+ const value = proxy.trim();
452
+ let original = '';
453
+ try {
454
+ original = await readFile(file, 'utf8');
455
+ }
456
+ catch {
457
+ original = '';
458
+ }
459
+ const next = mergeBrowserBlockProxy(original, value);
460
+ if (original !== '')
461
+ await copyFile(file, file + '.bak-browser-toggle').catch(() => { });
462
+ const tmp = file + '.tmp-browser-toggle';
463
+ await writeFile(tmp, next, 'utf8');
464
+ await rename(tmp, file);
465
+ const check = await readFile(file, 'utf8');
466
+ if (parseBrowserBlockProxy(check) !== value) {
467
+ if (original !== '') {
468
+ await writeFile(file, original, 'utf8');
469
+ throw new Error('proxy self-check failed after write; previous content restored');
470
+ }
471
+ throw new Error('proxy self-check failed after write');
472
+ }
473
+ return { patch: file, proxy: value };
474
+ }
package/lib/provider.d.ts CHANGED
@@ -35,6 +35,12 @@ export declare class PlaywrightProvider {
35
35
  browser: Browser | undefined;
36
36
  /** Window mode the live process was launched with (undefined = nothing running). */
37
37
  launchedHeadless: boolean | undefined;
38
+ /**
39
+ * True when this host refused the renderer sandbox and the browser had to be
40
+ * launched without it. Kept as state rather than a log line so a later
41
+ * "why is Chrome showing that warning bar" question has an answer on disk.
42
+ */
43
+ sandboxFallback: boolean;
38
44
  /** Persistent-mode shared profile-backed context (the personal window). */
39
45
  context: BrowserContext | undefined;
40
46
  entries: Map<string, OwnerEntry>;
package/lib/provider.js CHANGED
@@ -17,6 +17,7 @@ import { swarmBootScript } from "./swarm-curtain.js";
17
17
  import { forgetOnBrowserClose } from "./swarm-intro.js";
18
18
  import { isWindowMinimized } from "./window-focus.js";
19
19
  import { installHostGuard } from "./navigation-guard.js";
20
+ import { normalizeProxy } from "./proxy.js";
20
21
  export class PlaywrightProvider {
21
22
  config;
22
23
  id = 'playwright';
@@ -24,6 +25,12 @@ export class PlaywrightProvider {
24
25
  browser;
25
26
  /** Window mode the live process was launched with (undefined = nothing running). */
26
27
  launchedHeadless = undefined;
28
+ /**
29
+ * True when this host refused the renderer sandbox and the browser had to be
30
+ * launched without it. Kept as state rather than a log line so a later
31
+ * "why is Chrome showing that warning bar" question has an answer on disk.
32
+ */
33
+ sandboxFallback = false;
27
34
  /** Persistent-mode shared profile-backed context (the personal window). */
28
35
  context;
29
36
  entries = new Map();
@@ -62,6 +69,9 @@ export class PlaywrightProvider {
62
69
  blockedNavigation = undefined;
63
70
  constructor(config) {
64
71
  this.config = config;
72
+ // Fail at load time, not at launch time: a bad proxy address must produce the one
73
+ // message that explains it (src/proxy.ts), not a wrapped "tried channel …" failure.
74
+ normalizeProxy(config.launch.proxy);
65
75
  // Tell the settings card which page-reading mode this deployment was
66
76
  // configured with, so it can show "当前:X(默认)" truthfully instead of
67
77
  // assuming the built-in default.
package/lib/proxy.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Proxy support for the browser launch (2026-11-12).
3
+ *
4
+ * The plugin starts its own Chromium-family browser, so a proxy has to go into the
5
+ * **launch** options (`launchOptions.proxy`) — request headers cannot route a whole
6
+ * browser. Measured here: Wikipedia through a proxy answered in 4.4 s; direct it timed
7
+ * out at 15 s, which is what every proxied site looked like before this.
8
+ *
9
+ * Three rules, each with a reason:
10
+ *
11
+ * 1. **Empty means direct.** No configured proxy keeps today's behaviour exactly.
12
+ * 2. **`bypass` always includes the local addresses.** Without it the plugin's own
13
+ * `127.0.0.1` pages (verify, the MiniWoB sweep, any local server) would be pushed
14
+ * through the proxy and fail. Playwright passes `bypass` straight to Chromium's
15
+ * proxy bypass list.
16
+ * 3. **A bad address is an error, never a silent fallback to direct.** Quietly going
17
+ * direct would look like "the site is down" and send the user debugging the wrong thing.
18
+ *
19
+ * Authentication is deliberately **not** supported in this version: a password in the
20
+ * config would be stored in plain text in the user's `cordis.patch.yml`, and Playwright
21
+ * refuses SOCKS5 authentication outright (`Browser does not support socks5 proxy
22
+ * authentication`). Rather than ship a plaintext password path, credentials are rejected
23
+ * here with a message that says why.
24
+ *
25
+ * @module dsh-browser-playwright-codex/proxy
26
+ */
27
+ /** The shape Playwright accepts in `launchOptions.proxy`. */
28
+ export interface LaunchProxy {
29
+ /** `http://host:port`, `https://host:port` or `socks5://host:port`. */
30
+ server: string;
31
+ /** Local addresses bypass the proxy, so the plugin's own local pages keep working. */
32
+ bypass: string;
33
+ }
34
+ /** Hosts that must never be proxied (the plugin's own local pages live there). */
35
+ export declare const PROXY_BYPASS = "localhost,127.0.0.1";
36
+ /**
37
+ * Turn a configured proxy address into Playwright's proxy object.
38
+ *
39
+ * Accepted shapes: `host:port` (treated as HTTP, like Playwright does), `http://host:port`,
40
+ * `https://host:port`, `socks5://host:port`. The port is required and must be 1–65535.
41
+ *
42
+ * @param raw - the configured value; empty/undefined means "connect directly"
43
+ * @returns the proxy settings (server + local bypass), or undefined for a direct connection
44
+ * @throws Error with an actionable message when the address cannot be used
45
+ */
46
+ export declare function normalizeProxy(raw: string | undefined | null): LaunchProxy | undefined;
47
+ /**
48
+ * The spread-in fragment for Playwright's launch options: `{}` when direct,
49
+ * `{ proxy: … }` when configured. Every launch path uses this so they cannot drift.
50
+ *
51
+ * @param raw - the configured proxy value
52
+ * @returns an object to spread into the launch options
53
+ */
54
+ export declare function proxyLaunchOption(raw: string | undefined | null): {
55
+ proxy?: LaunchProxy;
56
+ };
package/lib/proxy.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Proxy support for the browser launch (2026-11-12).
3
+ *
4
+ * The plugin starts its own Chromium-family browser, so a proxy has to go into the
5
+ * **launch** options (`launchOptions.proxy`) — request headers cannot route a whole
6
+ * browser. Measured here: Wikipedia through a proxy answered in 4.4 s; direct it timed
7
+ * out at 15 s, which is what every proxied site looked like before this.
8
+ *
9
+ * Three rules, each with a reason:
10
+ *
11
+ * 1. **Empty means direct.** No configured proxy keeps today's behaviour exactly.
12
+ * 2. **`bypass` always includes the local addresses.** Without it the plugin's own
13
+ * `127.0.0.1` pages (verify, the MiniWoB sweep, any local server) would be pushed
14
+ * through the proxy and fail. Playwright passes `bypass` straight to Chromium's
15
+ * proxy bypass list.
16
+ * 3. **A bad address is an error, never a silent fallback to direct.** Quietly going
17
+ * direct would look like "the site is down" and send the user debugging the wrong thing.
18
+ *
19
+ * Authentication is deliberately **not** supported in this version: a password in the
20
+ * config would be stored in plain text in the user's `cordis.patch.yml`, and Playwright
21
+ * refuses SOCKS5 authentication outright (`Browser does not support socks5 proxy
22
+ * authentication`). Rather than ship a plaintext password path, credentials are rejected
23
+ * here with a message that says why.
24
+ *
25
+ * @module dsh-browser-playwright-codex/proxy
26
+ */
27
+ const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'socks5:']);
28
+ /** Hosts that must never be proxied (the plugin's own local pages live there). */
29
+ export const PROXY_BYPASS = 'localhost,127.0.0.1';
30
+ /** One clear sentence for every rejection; the value is echoed so a typo is visible. */
31
+ function reject(value, why) {
32
+ throw new Error('launch.proxy is not a usable proxy address (' +
33
+ why +
34
+ '): "' +
35
+ value +
36
+ '". ' +
37
+ 'Write it as scheme://host:port (or just host:port) — e.g. http://proxy.example:8080, ' +
38
+ 'https://proxy.example:8443 or socks5://proxy.example:1080. ' +
39
+ 'Leave it empty to connect directly. Authenticated proxies are not supported in this version.');
40
+ }
41
+ /**
42
+ * Turn a configured proxy address into Playwright's proxy object.
43
+ *
44
+ * Accepted shapes: `host:port` (treated as HTTP, like Playwright does), `http://host:port`,
45
+ * `https://host:port`, `socks5://host:port`. The port is required and must be 1–65535.
46
+ *
47
+ * @param raw - the configured value; empty/undefined means "connect directly"
48
+ * @returns the proxy settings (server + local bypass), or undefined for a direct connection
49
+ * @throws Error with an actionable message when the address cannot be used
50
+ */
51
+ export function normalizeProxy(raw) {
52
+ const value = (raw ?? '').trim();
53
+ if (value === '')
54
+ return undefined;
55
+ // Credentials would have to live in plain text in the user's patch file, and SOCKS5
56
+ // authentication is not supported by the browser anyway — refuse before launch.
57
+ if (/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*@/i.test(value) || /^[^/?#]*@/.test(value)) {
58
+ throw new Error('launch.proxy must not carry a username or password (found a credential in "' +
59
+ value +
60
+ '"). ' +
61
+ 'This version supports proxies WITHOUT authentication only: a password in the config ' +
62
+ 'would be stored in plain text in your patch file, and SOCKS5 authentication is not ' +
63
+ 'supported by Chromium/Playwright at all. Configure an unauthenticated proxy, or leave ' +
64
+ 'this empty to connect directly.');
65
+ }
66
+ // Short form: Playwright itself reads `host:port` as HTTP, so accept it the same way.
67
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : 'http://' + value;
68
+ // Check the port by hand first: `new URL('http://host:abc')` throws a generic message,
69
+ // and "the port is not a number" is what the user needs to read.
70
+ const hostPart = withScheme.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0] ?? '';
71
+ const hostOnly = hostPart.split('?')[0] ?? '';
72
+ const portText = (() => {
73
+ if (hostOnly.startsWith('[')) {
74
+ const close = hostOnly.indexOf(']');
75
+ if (close === -1)
76
+ return '';
77
+ const rest = hostOnly.slice(close + 1);
78
+ return rest.startsWith(':') ? rest.slice(1) : '';
79
+ }
80
+ const colon = hostOnly.lastIndexOf(':');
81
+ return colon === -1 ? '' : hostOnly.slice(colon + 1);
82
+ })();
83
+ if (portText === '')
84
+ reject(value, 'the port is missing');
85
+ if (!/^\d+$/.test(portText ?? ''))
86
+ reject(value, 'the port is not a number');
87
+ // Check the range before `new URL` does: an out-of-range port makes the constructor
88
+ // throw a generic error, and the useful sentence is "the port must be 1–65535".
89
+ const portNumber = Number(portText);
90
+ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
91
+ reject(value, 'the port must be 1–65535');
92
+ }
93
+ let url;
94
+ try {
95
+ url = new URL(withScheme);
96
+ }
97
+ catch {
98
+ return reject(value, 'it does not parse as a URL');
99
+ }
100
+ if (!ALLOWED_SCHEMES.has(url.protocol.toLowerCase())) {
101
+ reject(value, 'only http, https and socks5 are supported');
102
+ }
103
+ if (url.hostname === '')
104
+ reject(value, 'the host is empty');
105
+ if (url.port === '' || !/^\d+$/.test(url.port))
106
+ reject(value, 'the port is not a number');
107
+ const port = Number(url.port);
108
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
109
+ reject(value, 'the port must be 1–65535');
110
+ // A non-special scheme (socks5) parses with an EMPTY pathname while http/https give '/',
111
+ // so both are "no path". Anything else means the user appended something.
112
+ if ((url.pathname !== '' && url.pathname !== '/') || url.search !== '' || url.hash !== '') {
113
+ reject(value, 'a proxy address has no path, query or fragment');
114
+ }
115
+ return {
116
+ server: url.protocol + '//' + url.hostname + ':' + url.port,
117
+ // Never proxy the plugin's own local pages.
118
+ bypass: PROXY_BYPASS,
119
+ };
120
+ }
121
+ /**
122
+ * The spread-in fragment for Playwright's launch options: `{}` when direct,
123
+ * `{ proxy: … }` when configured. Every launch path uses this so they cannot drift.
124
+ *
125
+ * @param raw - the configured proxy value
126
+ * @returns an object to spread into the launch options
127
+ */
128
+ export function proxyLaunchOption(raw) {
129
+ const proxy = normalizeProxy(raw);
130
+ return proxy === undefined ? {} : { proxy };
131
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Runs a saved script's steps against a live browser session.
3
+ *
4
+ * Shape of a run, and why:
5
+ * - A step list is executed in order, and the first failure ends the run. A
6
+ * script that continued past a failed click would act on a page that never
7
+ * got to where the script assumed, and every later step would be noise.
8
+ * - Each step uses the same session methods the individual tools call, so the
9
+ * URL policy, ref validation and credential refusal stay in force; a step
10
+ * cannot do anything the model could not do one call at a time.
11
+ * - A `text` target is resolved against the freshest snapshot immediately
12
+ * before the action, never against a snapshot taken at the start: that is
13
+ * the ref-staleness failure every mature runner designs around.
14
+ * - The failure result carries the index, the action and the snapshot from
15
+ * the last good step, so the model can take over from there instead of
16
+ * replaying the whole script blind.
17
+ * @module dsh-browser-playwright-codex/script-runner
18
+ */
19
+ import type { BrowserSession, BrowserSnapshot } from './types.ts';
20
+ import { type ScriptStep } from './script-steps.ts';
21
+ /** One step's result. */
22
+ export interface ScriptStepOutcome {
23
+ /** 1-based index, as the model numbered the steps. */
24
+ readonly index: number;
25
+ /** Human-readable line, e.g. `click "登录"`. */
26
+ readonly step: string;
27
+ readonly ok: boolean;
28
+ /** What the step did, on success. */
29
+ readonly note?: string;
30
+ }
31
+ /** Everything one run produced. */
32
+ export interface ScriptRunOutcome {
33
+ /** Steps attempted, including the one that failed. */
34
+ readonly ran: number;
35
+ /** Steps in the script. */
36
+ readonly total: number;
37
+ /** 1-based index of the failed step; absent when the whole script ran. */
38
+ readonly failedAt?: number;
39
+ /** Action name of the failed step. */
40
+ readonly failedAction?: string;
41
+ /** Failure message, unchanged from the underlying action. */
42
+ readonly failure?: string;
43
+ /** Machine-readable code when the failure was a BrowserError. */
44
+ readonly failureCode?: string;
45
+ /** Snapshot from the last step that succeeded (or the failed one's page). */
46
+ readonly snapshot?: BrowserSnapshot;
47
+ /** Per-step log, oldest first. */
48
+ readonly outcomes: readonly ScriptStepOutcome[];
49
+ }
50
+ /** Options for {@link runScript}. */
51
+ export interface RunScriptOptions {
52
+ /** Cancellation from the calling tool execution. */
53
+ readonly signal?: AbortSignal;
54
+ /** Called with the last working snapshot; used to publish login sites. */
55
+ readonly onSnapshot?: (snapshot: BrowserSnapshot) => void;
56
+ }
57
+ /**
58
+ * Execute every step in order, stopping at the first failure.
59
+ *
60
+ * @param session - The calling session's live browser.
61
+ * @param steps - Steps already validated by `parseScriptSteps`.
62
+ * @param options - Cancellation and the snapshot callback.
63
+ * @returns What ran, what failed, and the last working snapshot.
64
+ */
65
+ export declare function runScript(session: BrowserSession, steps: readonly ScriptStep[], options?: RunScriptOptions): Promise<ScriptRunOutcome>;