browser-automation-skill 0.71.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.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/SECURITY.md +39 -0
  4. package/SKILL.md +206 -0
  5. package/bin/cli.mjs +55 -0
  6. package/install.sh +143 -0
  7. package/package.json +54 -0
  8. package/references/adapter-candidates.md +40 -0
  9. package/references/browser-mcp-cheatsheet.md +132 -0
  10. package/references/browser-stats-cheatsheet.md +155 -0
  11. package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
  12. package/references/midscene-integration.md +359 -0
  13. package/references/obscura-cheatsheet.md +103 -0
  14. package/references/playwright-cli-cheatsheet.md +64 -0
  15. package/references/playwright-lib-cheatsheet.md +90 -0
  16. package/references/recipes/add-a-tool-adapter.md +134 -0
  17. package/references/recipes/agent-workflows/README.md +37 -0
  18. package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
  19. package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
  20. package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
  21. package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
  22. package/references/recipes/anti-patterns-tool-extension.md +182 -0
  23. package/references/recipes/body-bytes-not-body.md +139 -0
  24. package/references/recipes/cache-write-security.md +210 -0
  25. package/references/recipes/fingerprint-rescue.md +154 -0
  26. package/references/recipes/model-routing.md +143 -0
  27. package/references/recipes/path-security.md +138 -0
  28. package/references/recipes/privacy-canary.md +96 -0
  29. package/references/recipes/visual-rescue-hook.md +182 -0
  30. package/references/stats-prices.json +42 -0
  31. package/references/stats-schema.json +77 -0
  32. package/references/tool-versions.md +8 -0
  33. package/scripts/browser-add-site.sh +113 -0
  34. package/scripts/browser-assert.sh +106 -0
  35. package/scripts/browser-audit.sh +68 -0
  36. package/scripts/browser-baseline.sh +135 -0
  37. package/scripts/browser-click.sh +100 -0
  38. package/scripts/browser-creds-add.sh +254 -0
  39. package/scripts/browser-creds-list.sh +67 -0
  40. package/scripts/browser-creds-migrate.sh +122 -0
  41. package/scripts/browser-creds-remove.sh +69 -0
  42. package/scripts/browser-creds-rotate-totp.sh +109 -0
  43. package/scripts/browser-creds-show.sh +82 -0
  44. package/scripts/browser-creds-totp.sh +94 -0
  45. package/scripts/browser-do.sh +630 -0
  46. package/scripts/browser-doctor.sh +365 -0
  47. package/scripts/browser-drag.sh +90 -0
  48. package/scripts/browser-extract.sh +192 -0
  49. package/scripts/browser-fill.sh +142 -0
  50. package/scripts/browser-flow.sh +316 -0
  51. package/scripts/browser-history.sh +187 -0
  52. package/scripts/browser-hover.sh +92 -0
  53. package/scripts/browser-inspect.sh +188 -0
  54. package/scripts/browser-list-sessions.sh +78 -0
  55. package/scripts/browser-list-sites.sh +42 -0
  56. package/scripts/browser-login.sh +279 -0
  57. package/scripts/browser-mcp.sh +65 -0
  58. package/scripts/browser-migrate.sh +195 -0
  59. package/scripts/browser-open.sh +134 -0
  60. package/scripts/browser-press.sh +80 -0
  61. package/scripts/browser-remove-session.sh +72 -0
  62. package/scripts/browser-remove-site.sh +68 -0
  63. package/scripts/browser-replay.sh +206 -0
  64. package/scripts/browser-route.sh +174 -0
  65. package/scripts/browser-select.sh +122 -0
  66. package/scripts/browser-show-session.sh +57 -0
  67. package/scripts/browser-show-site.sh +37 -0
  68. package/scripts/browser-snapshot.sh +176 -0
  69. package/scripts/browser-stats.sh +522 -0
  70. package/scripts/browser-tab-close.sh +112 -0
  71. package/scripts/browser-tab-list.sh +70 -0
  72. package/scripts/browser-tab-switch.sh +111 -0
  73. package/scripts/browser-upload.sh +132 -0
  74. package/scripts/browser-use.sh +60 -0
  75. package/scripts/browser-vlm.sh +707 -0
  76. package/scripts/browser-wait.sh +97 -0
  77. package/scripts/install-git-hooks.sh +16 -0
  78. package/scripts/lib/capture.sh +356 -0
  79. package/scripts/lib/common.sh +262 -0
  80. package/scripts/lib/credential.sh +237 -0
  81. package/scripts/lib/fingerprint-rescue.js +123 -0
  82. package/scripts/lib/flow.sh +448 -0
  83. package/scripts/lib/flow_record.sh +210 -0
  84. package/scripts/lib/mask.sh +49 -0
  85. package/scripts/lib/memory.sh +427 -0
  86. package/scripts/lib/migrate.sh +390 -0
  87. package/scripts/lib/migrators/README.md +23 -0
  88. package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
  89. package/scripts/lib/migrators/recent_urls/README.md +13 -0
  90. package/scripts/lib/migrators/stats/README.md +24 -0
  91. package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
  92. package/scripts/lib/node/mcp-server.mjs +531 -0
  93. package/scripts/lib/node/mcp-tools.json +68 -0
  94. package/scripts/lib/node/playwright-driver.mjs +1104 -0
  95. package/scripts/lib/node/totp-core.mjs +52 -0
  96. package/scripts/lib/node/totp.mjs +52 -0
  97. package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
  98. package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
  99. package/scripts/lib/output.sh +79 -0
  100. package/scripts/lib/router.sh +342 -0
  101. package/scripts/lib/sanitize.sh +107 -0
  102. package/scripts/lib/secret/keychain.sh +91 -0
  103. package/scripts/lib/secret/libsecret.sh +74 -0
  104. package/scripts/lib/secret/plaintext.sh +75 -0
  105. package/scripts/lib/secret_backend_select.sh +57 -0
  106. package/scripts/lib/session.sh +153 -0
  107. package/scripts/lib/site.sh +126 -0
  108. package/scripts/lib/stats.sh +419 -0
  109. package/scripts/lib/tool/.gitkeep +0 -0
  110. package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
  111. package/scripts/lib/tool/obscura.sh +249 -0
  112. package/scripts/lib/tool/playwright-cli.sh +155 -0
  113. package/scripts/lib/tool/playwright-lib.sh +106 -0
  114. package/scripts/lib/verb_helpers.sh +222 -0
  115. package/scripts/lib/visual-rescue-default.sh +145 -0
  116. package/scripts/regenerate-docs.sh +99 -0
  117. package/uninstall.sh +51 -0
@@ -0,0 +1,1104 @@
1
+ // scripts/lib/node/playwright-driver.mjs
2
+ //
3
+ // Node ESM bridge between the playwright-lib bash adapter and the real
4
+ // `playwright` package. Speaks skill-flag surface (--url, --ref, --selector,
5
+ // --text, --secret-stdin, --depth, --headed, --storage-state) so adapters
6
+ // don't have to translate to a binary's positional CLI.
7
+ //
8
+ // Stub mode (BROWSER_SKILL_LIB_STUB=1):
9
+ // Mirror tests/stubs/playwright-cli — hash argv, look up fixture, print, exit.
10
+ // Lets the bats suite verify the adapter contract without a real browser.
11
+ //
12
+ // Real mode (default):
13
+ // Lazy-import playwright; launch chromium; optionally apply storageState;
14
+ // dispatch the verb; emit JSON events + final result; close cleanly.
15
+ // Implementation deferred — this file currently throws when stub mode is off
16
+ // so the contract is established but real-mode work lands in a follow-up PR.
17
+ //
18
+ // Spec: docs/superpowers/specs/2026-04-30-tool-adapter-extension-model-design.md §2
19
+ // docs/superpowers/specs/2026-05-01-token-efficient-adapter-output-design.md §3
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync, chmodSync, mkdirSync, openSync } from 'node:fs';
23
+ import { createServer, createConnection } from 'node:net';
24
+ import { join, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { createRequire } from 'node:module';
27
+ import { execSync, spawn } from 'node:child_process';
28
+ import { homedir } from 'node:os';
29
+
30
+ const argv = process.argv.slice(2);
31
+
32
+ if (process.env.BROWSER_SKILL_LIB_STUB === '1') {
33
+ stubDispatch(argv);
34
+ } else {
35
+ realDispatch(argv).catch((err) => {
36
+ process.stderr.write(
37
+ `playwright-driver.mjs: unhandled error: ${err && err.stack ? err.stack : String(err)}\n`
38
+ );
39
+ process.exit(1);
40
+ });
41
+ }
42
+
43
+ function stubDispatch(args) {
44
+ const logFile = process.env.STUB_LOG_FILE;
45
+ if (logFile) {
46
+ const ts = new Date().toISOString();
47
+ appendFileSync(logFile, `--- ${ts} ---\n${args.join('\n')}\n`);
48
+ }
49
+
50
+ const hash = sha256NulJoined(args);
51
+ const fixturesDir =
52
+ process.env.PLAYWRIGHT_LIB_FIXTURES_DIR ||
53
+ join(repoRoot(), 'tests/fixtures/playwright-lib');
54
+ const fixturePath = join(fixturesDir, `${hash}.json`);
55
+
56
+ if (existsSync(fixturePath)) {
57
+ process.stdout.write(readFileSync(fixturePath, 'utf-8'));
58
+ process.exit(0);
59
+ }
60
+
61
+ const argvJson = JSON.stringify(args);
62
+ process.stdout.write(
63
+ `{"status":"error","reason":"no fixture for argv-hash ${hash}","argv":${argvJson}}\n`
64
+ );
65
+ process.exit(41);
66
+ }
67
+
68
+ async function realDispatch(args) {
69
+ const verb = args[0];
70
+ const flags = parseFlags(args.slice(1));
71
+
72
+ switch (verb) {
73
+ case 'open':
74
+ return await runOpen(flags);
75
+ case 'snapshot':
76
+ return await runSnapshot(flags);
77
+ case 'click':
78
+ return await runClick(flags);
79
+ case 'fill':
80
+ return await runFill(flags);
81
+ case 'daemon-start':
82
+ return await runDaemonStart(flags);
83
+ case 'daemon-stop':
84
+ return runDaemonStop();
85
+ case 'daemon-status':
86
+ return runDaemonStatus();
87
+ case 'login':
88
+ return await runLogin(flags);
89
+ case 'auto-relogin':
90
+ return await runAutoRelogin(flags);
91
+ default:
92
+ process.stderr.write(`playwright-driver.mjs: unknown verb '${verb}'\n`);
93
+ process.exit(2);
94
+ }
95
+ }
96
+
97
+ // --- Stateful verbs (route through IPC daemon) ---
98
+ // chromium.connect()-based clients can't share state across processes.
99
+ // The daemon (started via daemon-start) holds browser+context+page+refMap
100
+ // internally and exposes verb operations over a Unix socket. Verb processes
101
+ // here are thin clients: send one JSON line, read one JSON line, exit.
102
+
103
+ async function runSnapshot(flags) {
104
+ const reply = await ipcCall({ verb: 'snapshot' });
105
+ emitDaemonReply(reply);
106
+ process.exit(reply.event === 'error' ? 30 : 0);
107
+ }
108
+
109
+ async function runClick(flags) {
110
+ if (flags.ref && flags.selector) {
111
+ process.stderr.write('playwright-driver.mjs::click: --ref and --selector are mutually exclusive\n');
112
+ process.exit(2);
113
+ }
114
+ if (!flags.ref && !flags.selector) {
115
+ process.stderr.write('playwright-driver.mjs::click: --ref eN or --selector CSS is required\n');
116
+ process.exit(2);
117
+ }
118
+ const ipcMsg = { verb: 'click' };
119
+ if (flags.ref) ipcMsg.ref = flags.ref;
120
+ else ipcMsg.selector = flags.selector;
121
+ const reply = await ipcCall(ipcMsg);
122
+ emitDaemonReply(reply);
123
+ process.exit(reply.event === 'error' ? 30 : 0);
124
+ }
125
+
126
+ async function runFill(flags) {
127
+ if (flags.ref && flags.selector) {
128
+ process.stderr.write('playwright-driver.mjs::fill: --ref and --selector are mutually exclusive\n');
129
+ process.exit(2);
130
+ }
131
+ if (!flags.ref && !flags.selector) {
132
+ process.stderr.write('playwright-driver.mjs::fill: --ref eN or --selector CSS is required\n');
133
+ process.exit(2);
134
+ }
135
+ let text = flags.text;
136
+ if (flags['secret-stdin']) {
137
+ if (typeof flags.text === 'string') {
138
+ process.stderr.write('playwright-driver.mjs::fill: --text and --secret-stdin are mutually exclusive\n');
139
+ process.exit(2);
140
+ }
141
+ text = await readAllStdin();
142
+ }
143
+ if (typeof text !== 'string' || text.length === 0) {
144
+ process.stderr.write('playwright-driver.mjs::fill: --text VALUE or --secret-stdin required\n');
145
+ process.exit(2);
146
+ }
147
+ const ipcMsg = { verb: 'fill', text };
148
+ if (flags.ref) ipcMsg.ref = flags.ref;
149
+ else ipcMsg.selector = flags.selector;
150
+ const reply = await ipcCall(ipcMsg);
151
+ // Replace the text field in the reply (defensive; daemon should not echo it).
152
+ delete reply.text;
153
+ emitDaemonReply(reply);
154
+ process.exit(reply.event === 'error' ? 30 : 0);
155
+ }
156
+
157
+ function emitDaemonReply(reply) {
158
+ if (reply.event === 'snapshot' && Array.isArray(reply.refs)) {
159
+ // Compact eN-indexed listing the agent can read directly.
160
+ const summary = { ...reply, ref_count: reply.refs.length };
161
+ delete summary.refs;
162
+ process.stdout.write(JSON.stringify(summary) + '\n');
163
+ for (const r of reply.refs) {
164
+ const tail = r.name ? ` "${r.name}"` : '';
165
+ process.stdout.write(`${r.id} ${r.role}${tail}\n`);
166
+ }
167
+ return;
168
+ }
169
+ process.stdout.write(JSON.stringify(reply) + '\n');
170
+ }
171
+
172
+ async function ipcCall(msg) {
173
+ const state = readDaemonState();
174
+ if (!state || !isPidAlive(state.pid) || !state.ipc_port) {
175
+ process.stderr.write(
176
+ `playwright-driver.mjs: stateful verb '${msg.verb}' requires running daemon ` +
177
+ `(run: node playwright-driver.mjs daemon-start)\n`
178
+ );
179
+ process.exit(41);
180
+ }
181
+ return await new Promise((resolve, reject) => {
182
+ const conn = createConnection({ host: state.ipc_host || '127.0.0.1', port: state.ipc_port });
183
+ let buf = '';
184
+ let settled = false;
185
+ const t = setTimeout(() => {
186
+ if (settled) return;
187
+ settled = true;
188
+ try { conn.destroy(); } catch (_) {}
189
+ reject(new Error(`ipcCall: timeout waiting for daemon reply (verb=${msg.verb})`));
190
+ }, parseInt(process.env.BROWSER_SKILL_LIB_TIMEOUT_MS || '30000', 10));
191
+
192
+ conn.on('connect', () => {
193
+ conn.write(JSON.stringify(msg) + '\n');
194
+ });
195
+ conn.on('data', (chunk) => {
196
+ buf += chunk.toString('utf-8');
197
+ const nl = buf.indexOf('\n');
198
+ if (nl < 0 || settled) return;
199
+ settled = true;
200
+ clearTimeout(t);
201
+ try {
202
+ resolve(JSON.parse(buf.slice(0, nl)));
203
+ } catch (e) {
204
+ reject(e);
205
+ } finally {
206
+ try { conn.end(); } catch (_) {}
207
+ }
208
+ });
209
+ conn.on('error', (e) => {
210
+ if (settled) return;
211
+ settled = true;
212
+ clearTimeout(t);
213
+ reject(e);
214
+ });
215
+ });
216
+ }
217
+
218
+ function readAllStdin() {
219
+ return new Promise((resolve, reject) => {
220
+ let data = '';
221
+ process.stdin.setEncoding('utf-8');
222
+ process.stdin.on('data', (chunk) => { data += chunk; });
223
+ process.stdin.on('end', () => resolve(data));
224
+ process.stdin.on('error', reject);
225
+ });
226
+ }
227
+
228
+ // runLogin — headed Chromium one-shot for interactive credential capture.
229
+ // User logs in to the site in the browser window, presses Enter on stdin to
230
+ // signal "done", driver captures context.storageState() and writes it to
231
+ // --output-path (caller validates origins + writes meta sidecar afterwards).
232
+ //
233
+ // Single-shot (not daemon-routed): login is its own ephemeral flow. Daemon
234
+ // would interfere — we want a fresh, isolated context for each login.
235
+ async function runLogin(flags) {
236
+ const url = flags.url;
237
+ const outputPath = flags['output-path'];
238
+ if (!url) {
239
+ process.stderr.write('playwright-driver.mjs::login: --url is required\n');
240
+ process.exit(2);
241
+ }
242
+ if (!outputPath) {
243
+ process.stderr.write('playwright-driver.mjs::login: --output-path is required\n');
244
+ process.exit(2);
245
+ }
246
+
247
+ const { chromium } = loadPlaywright();
248
+ // Always headed — login is an interactive verb. --headless is meaningless.
249
+ const browser = await chromium.launch({ headless: false });
250
+ try {
251
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
252
+ const page = await ctx.newPage();
253
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
254
+
255
+ process.stderr.write(
256
+ `\n Browser opened at ${url}\n` +
257
+ ` Log in interactively, then press Enter here to capture the session.\n` +
258
+ ` (Press Ctrl-C to abort without saving.)\n\n`
259
+ );
260
+
261
+ await waitForEnterOnStdin();
262
+
263
+ // Capture state BEFORE closing the browser/context.
264
+ const state = await ctx.storageState();
265
+ mkdirSync(dirname(outputPath), { recursive: true, mode: 0o700 });
266
+ writeFileSync(outputPath, JSON.stringify(state, null, 2));
267
+ chmodSync(outputPath, 0o600);
268
+
269
+ process.stdout.write(
270
+ JSON.stringify({
271
+ event: 'login-saved',
272
+ output_path: outputPath,
273
+ cookie_count: state.cookies.length,
274
+ origin_count: state.origins.length,
275
+ }) + '\n'
276
+ );
277
+
278
+ await browser.close();
279
+ process.exit(0);
280
+ } catch (err) {
281
+ try { await browser.close(); } catch (_) {}
282
+ process.stderr.write(
283
+ `playwright-driver.mjs::login: ${err && err.message ? err.message : err}\n`
284
+ );
285
+ process.exit(30);
286
+ }
287
+ }
288
+
289
+ // runAutoRelogin — programmatic headless login using stored credentials
290
+ // (phase-5 part 3). Reads NUL-separated `username\0password` from stdin,
291
+ // navigates the site URL, fills best-effort form selectors, clicks submit,
292
+ // captures storageState, writes to --output-path. AP-7: secret never on argv.
293
+ //
294
+ // Selectors are best-effort — common email + password + submit patterns.
295
+ // Sites with non-standard login forms will fail; auth-flow detection at
296
+ // creds-add time (phase-5 part 3-iii) is the long-term fix.
297
+ async function runAutoRelogin(flags) {
298
+ const url = flags.url;
299
+ const outputPath = flags['output-path'];
300
+ if (!url) {
301
+ process.stderr.write('playwright-driver.mjs::auto-relogin: --url is required\n');
302
+ process.exit(2);
303
+ }
304
+ if (!outputPath) {
305
+ process.stderr.write('playwright-driver.mjs::auto-relogin: --output-path is required\n');
306
+ process.exit(2);
307
+ }
308
+
309
+ // Phase-5 part 3-iv test hook: bats sets BROWSER_SKILL_DRIVER_TEST_2FA=1
310
+ // to short-circuit the browser launch and exit 25 (EXIT_AUTH_INTERACTIVE_
311
+ // REQUIRED). Lets bats verify the bash-side propagation without a real
312
+ // Chrome + 2FA challenge page. Production callers never set this.
313
+ if (process.env.BROWSER_SKILL_DRIVER_TEST_2FA === '1') {
314
+ process.stderr.write('playwright-driver.mjs::auto-relogin: 2FA challenge detected (test-mode)\n');
315
+ process.stdout.write(JSON.stringify({
316
+ event: 'auto-relogin-2fa-required',
317
+ reason: 'site requires interactive 2FA / one-time-code',
318
+ }) + '\n');
319
+ process.exit(25);
320
+ }
321
+
322
+ // Phase-5 part 4-iii test hook: BROWSER_SKILL_DRIVER_TEST_TOTP_REPLAY=1
323
+ // short-circuits the browser launch with an artificial "TOTP auto-replay
324
+ // succeeded" path — generates the code via totp-core (so the import path
325
+ // is wired correctly), writes an empty storageState, exits 0. Lets bats
326
+ // verify the bash side passes the 3rd stdin chunk.
327
+ if (process.env.BROWSER_SKILL_DRIVER_TEST_TOTP_REPLAY === '1') {
328
+ const credsBlobTest = await readAllStdin();
329
+ const chunks = credsBlobTest.split('\0');
330
+ if (chunks.length < 3 || !chunks[2]) {
331
+ process.stderr.write(
332
+ 'playwright-driver.mjs::auto-relogin (test-totp): 3rd stdin chunk (totp_secret) missing\n'
333
+ );
334
+ process.exit(2);
335
+ }
336
+ const { totpAt } = await import('./totp-core.mjs');
337
+ const tTest = process.env.TOTP_TIME_T
338
+ ? parseInt(process.env.TOTP_TIME_T, 10)
339
+ : Math.floor(Date.now() / 1000);
340
+ const code = totpAt(chunks[2], tTest);
341
+ mkdirSync(dirname(outputPath), { recursive: true, mode: 0o700 });
342
+ writeFileSync(outputPath, JSON.stringify({ cookies: [], origins: [] }));
343
+ chmodSync(outputPath, 0o600);
344
+ process.stdout.write(JSON.stringify({
345
+ event: 'auto-relogin-totp-replayed',
346
+ output_path: outputPath,
347
+ totp_code_length: code.length,
348
+ }) + '\n');
349
+ process.exit(0);
350
+ }
351
+
352
+ const credsBlob = await readAllStdin();
353
+ const sep = credsBlob.indexOf('\0');
354
+ if (sep === -1) {
355
+ process.stderr.write(
356
+ "playwright-driver.mjs::auto-relogin: stdin must be 'username\\0password' (or 'username\\0password\\0totp_secret' for totp-enabled creds)\n"
357
+ );
358
+ process.exit(2);
359
+ }
360
+ const username = credsBlob.slice(0, sep);
361
+ // After password — find optional 3rd chunk (TOTP shared secret) for
362
+ // phase-5 part 4-iii auto-replay. When present, after detect2FA fires the
363
+ // driver fills the OTP field with the generated code instead of exiting 25.
364
+ const afterUser = credsBlob.slice(sep + 1);
365
+ const sep2 = afterUser.indexOf('\0');
366
+ const password = sep2 === -1 ? afterUser : afterUser.slice(0, sep2);
367
+ const totpSecret = sep2 === -1 ? null : afterUser.slice(sep2 + 1);
368
+
369
+ const { chromium } = loadPlaywright();
370
+ const browser = await chromium.launch({ headless: true });
371
+ try {
372
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
373
+ const page = await ctx.newPage();
374
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
375
+
376
+ const usernameSelectors = [
377
+ 'input[type=email]',
378
+ 'input[name=email]',
379
+ 'input[name=username]',
380
+ 'input[autocomplete=username]',
381
+ 'input#email',
382
+ 'input#username',
383
+ ];
384
+ const passwordSelectors = [
385
+ 'input[type=password]',
386
+ 'input[name=password]',
387
+ 'input[autocomplete=current-password]',
388
+ 'input#password',
389
+ ];
390
+ const submitSelectors = [
391
+ 'button[type=submit]',
392
+ 'input[type=submit]',
393
+ 'button:has-text("Sign in")',
394
+ 'button:has-text("Log in")',
395
+ 'button:has-text("Login")',
396
+ ];
397
+
398
+ await fillFirstMatch(page, usernameSelectors, username, 'username');
399
+ await fillFirstMatch(page, passwordSelectors, password, 'password');
400
+ await clickFirstMatch(page, submitSelectors, 'submit');
401
+
402
+ // Wait for navigation OR network idle. 15s budget covers most flows.
403
+ await Promise.race([
404
+ page.waitForLoadState('networkidle', { timeout: 15000 }),
405
+ page.waitForURL((u) => u.toString() !== url, { timeout: 15000 }),
406
+ ]).catch(() => { /* both timed out — capture whatever state we have */ });
407
+
408
+ // Phase-5 part 3-iv: detect 2FA challenge pages and exit 25 instead of
409
+ // capturing a useless storageState. Heuristic: post-submit landing has a
410
+ // one-time-code input or 2FA-related text. Best-effort — non-standard
411
+ // 2FA flows won't be caught and will fall through to the normal capture
412
+ // path (which will likely return an unauthenticated session).
413
+ // Phase-5 part 4-iii: if a TOTP shared secret was provided in stdin
414
+ // (3rd NUL chunk), generate the current code, fill the OTP field, submit,
415
+ // and continue to capture storageState. Otherwise exit 25 as before.
416
+ if (await detect2FA(page)) {
417
+ if (totpSecret) {
418
+ try {
419
+ const { totpAt } = await import('./totp-core.mjs');
420
+ const t = process.env.TOTP_TIME_T
421
+ ? parseInt(process.env.TOTP_TIME_T, 10)
422
+ : Math.floor(Date.now() / 1000);
423
+ const code = totpAt(totpSecret, t);
424
+ const otpSelectors = [
425
+ 'input[autocomplete="one-time-code"]',
426
+ 'input[name*="otp" i]',
427
+ 'input[name*="code" i]',
428
+ 'input[name*="verification" i]',
429
+ 'input#otp', 'input#code',
430
+ ];
431
+ await fillFirstMatch(page, otpSelectors, code, 'OTP');
432
+ await clickFirstMatch(page, [
433
+ 'button[type=submit]',
434
+ 'input[type=submit]',
435
+ 'button:has-text("Verify")',
436
+ 'button:has-text("Continue")',
437
+ 'button:has-text("Submit")',
438
+ ], 'OTP-submit');
439
+ await Promise.race([
440
+ page.waitForLoadState('networkidle', { timeout: 15000 }),
441
+ page.waitForURL(() => true, { timeout: 15000 }),
442
+ ]).catch(() => { /* both timed out */ });
443
+ // Fall through to the normal storageState capture below.
444
+ } catch (err) {
445
+ try { await browser.close(); } catch (_) { /* ignore */ }
446
+ process.stderr.write(
447
+ `playwright-driver.mjs::auto-relogin: TOTP replay failed: ${err && err.message ? err.message : err}\n`
448
+ );
449
+ process.exit(30);
450
+ }
451
+ } else {
452
+ try { await browser.close(); } catch (_) { /* ignore */ }
453
+ process.stderr.write(
454
+ 'playwright-driver.mjs::auto-relogin: 2FA challenge detected — interactive login required (or store a TOTP secret with creds-add --enable-totp)\n'
455
+ );
456
+ process.stdout.write(JSON.stringify({
457
+ event: 'auto-relogin-2fa-required',
458
+ reason: 'site requires interactive 2FA / one-time-code',
459
+ url: page.url(),
460
+ }) + '\n');
461
+ process.exit(25);
462
+ }
463
+ }
464
+
465
+ const state = await ctx.storageState();
466
+ mkdirSync(dirname(outputPath), { recursive: true, mode: 0o700 });
467
+ writeFileSync(outputPath, JSON.stringify(state, null, 2));
468
+ chmodSync(outputPath, 0o600);
469
+
470
+ process.stdout.write(JSON.stringify({
471
+ event: 'auto-relogin-saved',
472
+ output_path: outputPath,
473
+ cookie_count: state.cookies.length,
474
+ origin_count: state.origins.length,
475
+ }) + '\n');
476
+
477
+ await browser.close();
478
+ process.exit(0);
479
+ } catch (err) {
480
+ try { await browser.close(); } catch (_) { /* ignore */ }
481
+ process.stderr.write(
482
+ `playwright-driver.mjs::auto-relogin: ${err && err.message ? err.message : err}\n`
483
+ );
484
+ process.exit(30);
485
+ }
486
+ }
487
+
488
+ // detect2FA — best-effort heuristic for whether the current page is a 2FA
489
+ // challenge. Checks (in order): one-time-code autocomplete attribute, common
490
+ // OTP/code field names, page text matching 2FA keywords. Returns true on
491
+ // any match. Does NOT cover SMS-prompt fallbacks or push-notification flows
492
+ // (those typically show a "waiting" UI rather than an input field).
493
+ async function detect2FA(page) {
494
+ // 1. Standard autocomplete attribute (RFC).
495
+ if ((await page.locator('input[autocomplete="one-time-code"]').count()) > 0) {
496
+ return true;
497
+ }
498
+ // 2. Common OTP/code field names.
499
+ const otpSelectors = [
500
+ 'input[name*="otp" i]',
501
+ 'input[name*="code" i]',
502
+ 'input[name*="verification" i]',
503
+ 'input[name*="two_factor" i]',
504
+ 'input[name*="2fa" i]',
505
+ 'input#otp',
506
+ 'input#code',
507
+ ];
508
+ for (const sel of otpSelectors) {
509
+ if ((await page.locator(sel).count()) > 0) return true;
510
+ }
511
+ // 3. Page text heuristics.
512
+ const bodyText = await page.locator('body').textContent({ timeout: 2000 }).catch(() => '');
513
+ if (!bodyText) return false;
514
+ const lower = bodyText.toLowerCase();
515
+ const phrases = [
516
+ 'two-factor', 'two factor', '2fa',
517
+ 'verification code', 'one-time code', 'one-time password',
518
+ 'authenticator app', 'authenticator code',
519
+ 'enter the code', 'enter code',
520
+ ];
521
+ for (const p of phrases) {
522
+ if (lower.includes(p)) return true;
523
+ }
524
+ return false;
525
+ }
526
+
527
+ async function fillFirstMatch(page, selectors, value, label) {
528
+ for (const sel of selectors) {
529
+ const el = page.locator(sel).first();
530
+ if ((await el.count()) > 0) {
531
+ await el.fill(value);
532
+ return;
533
+ }
534
+ }
535
+ throw new Error(
536
+ `auto-relogin: no matching ${label} input among [${selectors.join(', ')}]`
537
+ );
538
+ }
539
+
540
+ async function clickFirstMatch(page, selectors, label) {
541
+ for (const sel of selectors) {
542
+ const el = page.locator(sel).first();
543
+ if ((await el.count()) > 0) {
544
+ await el.click();
545
+ return;
546
+ }
547
+ }
548
+ throw new Error(
549
+ `auto-relogin: no matching ${label} button among [${selectors.join(', ')}]`
550
+ );
551
+ }
552
+
553
+ function waitForEnterOnStdin() {
554
+ return new Promise((resolve) => {
555
+ process.stdin.setEncoding('utf-8');
556
+ const onData = (chunk) => {
557
+ if (chunk.includes('\n')) {
558
+ process.stdin.removeListener('data', onData);
559
+ process.stdin.pause();
560
+ resolve();
561
+ }
562
+ };
563
+ process.stdin.on('data', onData);
564
+ process.stdin.resume();
565
+ });
566
+ }
567
+
568
+ // --- Daemon lifecycle ---
569
+ // daemon-start spawns a detached node child that calls launchServer (chromium)
570
+ // and writes ${BROWSER_SKILL_HOME}/playwright-lib-daemon.json with PID +
571
+ // wsEndpoint. The parent process polls the state file (up to 10s), prints
572
+ // the state, and exits. Subsequent verb invocations connect via the
573
+ // wsEndpoint. daemon-stop SIGTERMs the PID and removes the state file.
574
+ //
575
+ // State file mode 0600; directory mode 0700 (matches BROWSER_SKILL_HOME).
576
+
577
+ async function runDaemonStart(flags) {
578
+ if (flags['internal-server'] === true) {
579
+ return await daemonChildMain(flags);
580
+ }
581
+
582
+ const existing = readDaemonState();
583
+ if (existing && isPidAlive(existing.pid)) {
584
+ process.stdout.write(
585
+ JSON.stringify({ event: 'daemon-already-running', ...existing }) + '\n'
586
+ );
587
+ process.exit(0);
588
+ }
589
+
590
+ // Stale state file (PID dead) — clear it before spawning.
591
+ if (existing) {
592
+ try { unlinkSync(daemonStatePath()); } catch (_) {}
593
+ }
594
+
595
+ const childArgv = [
596
+ fileURLToPath(import.meta.url),
597
+ 'daemon-start',
598
+ '--internal-server',
599
+ ];
600
+ if (flags.headed) childArgv.push('--headed');
601
+
602
+ // Capture daemon child stderr to a log under BROWSER_SKILL_HOME instead of
603
+ // /dev/null so launch failures aren't silent. The log is gitignored
604
+ // (.browser-skill/captures pattern); mode 0600 inherits from parent dir.
605
+ mkdirSync(browserSkillHome(), { recursive: true, mode: 0o700 });
606
+ const logPath = join(browserSkillHome(), 'playwright-lib-daemon.log');
607
+ const stderrFd = openSync(logPath, 'a', 0o600);
608
+
609
+ const child = spawn(process.execPath, childArgv, {
610
+ detached: true,
611
+ stdio: ['ignore', 'ignore', stderrFd],
612
+ env: process.env,
613
+ });
614
+ child.unref();
615
+
616
+ const stateFile = daemonStatePath();
617
+ const deadline = Date.now() + 10000;
618
+ while (Date.now() < deadline) {
619
+ if (existsSync(stateFile)) {
620
+ const state = readDaemonState();
621
+ if (state && isPidAlive(state.pid)) {
622
+ process.stdout.write(
623
+ JSON.stringify({ event: 'daemon-started', ...state }) + '\n'
624
+ );
625
+ process.exit(0);
626
+ }
627
+ }
628
+ await sleep(100);
629
+ }
630
+
631
+ process.stderr.write(
632
+ 'playwright-driver.mjs::daemon-start: timed out waiting for daemon to come up\n'
633
+ );
634
+ process.exit(30);
635
+ }
636
+
637
+ async function daemonChildMain(flags) {
638
+ const { chromium } = loadPlaywright();
639
+ const headless = !flags.headed;
640
+ const server = await chromium.launchServer({ headless });
641
+ const wsEndpoint = server.wsEndpoint();
642
+
643
+ // The daemon HOLDS the browser handle + current context + current page.
644
+ // Verb clients send commands; the daemon mutates this state and replies.
645
+ // This sidesteps the chromium.connect cross-process state-sharing limit.
646
+ const browser = await chromium.connect(wsEndpoint);
647
+ let context = null;
648
+ let page = null;
649
+ let refMap = null;
650
+
651
+ // IPC over TCP loopback (not Unix socket) — Unix-socket sun_path is capped
652
+ // at 104 chars on macOS; bats temp paths exceed it. Loopback + random port
653
+ // sidesteps the limit cleanly and matches Playwright's own launchServer
654
+ // which uses ws://localhost:PORT.
655
+ const ipcServer = createServer((conn) => {
656
+ let buf = '';
657
+ conn.setEncoding('utf-8');
658
+ conn.on('data', async (chunk) => {
659
+ buf += chunk;
660
+ let nl;
661
+ while ((nl = buf.indexOf('\n')) >= 0) {
662
+ const line = buf.slice(0, nl);
663
+ buf = buf.slice(nl + 1);
664
+ if (!line) continue;
665
+ let reply;
666
+ try {
667
+ const msg = JSON.parse(line);
668
+ reply = await dispatch(msg);
669
+ } catch (err) {
670
+ reply = { event: 'error', message: err && err.message ? err.message : String(err) };
671
+ }
672
+ try { conn.write(JSON.stringify(reply) + '\n'); } catch (_) {}
673
+ }
674
+ });
675
+ conn.on('error', () => { /* client closed mid-write; ignore */ });
676
+ });
677
+
678
+ async function dispatch(msg) {
679
+ switch (msg.verb) {
680
+ case 'open': {
681
+ if (context) { try { await context.close(); } catch (_) {} }
682
+ const opts = { viewport: { width: 1280, height: 800 } };
683
+ if (msg.viewport) opts.viewport = msg.viewport;
684
+ if (msg.storage_state) opts.storageState = msg.storage_state;
685
+ if (msg.user_agent) opts.userAgent = msg.user_agent;
686
+ context = await browser.newContext(opts);
687
+ page = await context.newPage();
688
+ const resp = await page.goto(msg.url, { waitUntil: 'domcontentloaded' });
689
+ return {
690
+ event: 'navigated',
691
+ url: page.url(),
692
+ title: await page.title(),
693
+ status: resp ? resp.status() : null,
694
+ attached_to_daemon: true,
695
+ };
696
+ }
697
+ case 'snapshot': {
698
+ if (!page) return { event: 'error', message: 'no open page (run open --url first)' };
699
+ // Playwright 1.59 dropped page.accessibility. Use ariaSnapshot which
700
+ // returns the agent-readable YAML format, then parse out interactive
701
+ // (role, name) pairs to assign eN refs the agent can click/fill by.
702
+ const yaml = await page.ariaSnapshot();
703
+ const refs = parseAriaSnapshot(yaml);
704
+ refMap = refs;
705
+ try {
706
+ const refsFile = join(browserSkillHome(), 'playwright-lib-refs.json');
707
+ mkdirSync(dirname(refsFile), { recursive: true, mode: 0o700 });
708
+ writeFileSync(refsFile, JSON.stringify({
709
+ page_url: page.url(),
710
+ captured_at: new Date().toISOString(),
711
+ aria_yaml: yaml,
712
+ refs,
713
+ }, null, 2));
714
+ chmodSync(refsFile, 0o600);
715
+ } catch (_) { /* non-fatal */ }
716
+ return { event: 'snapshot', page_url: page.url(), aria_yaml: yaml, refs };
717
+ }
718
+ case 'click': {
719
+ if (!page) return { event: 'error', message: 'no open page' };
720
+ // Selector path (PL3): use page.locator(selector).first().click().
721
+ // Skips refMap precondition — locators don't require snapshot.
722
+ if (msg.selector) {
723
+ try {
724
+ await page.locator(msg.selector).first().click();
725
+ } catch (err) {
726
+ return { event: 'error', message: `click failed: ${err && err.message ? err.message : String(err)}` };
727
+ }
728
+ return { event: 'click', selector: msg.selector, status: 'ok' };
729
+ }
730
+ // Existing ref path (unchanged):
731
+ if (!refMap) return { event: 'error', message: 'no refs (run snapshot first)' };
732
+ const entry = refMap.find((r) => r.id === msg.ref);
733
+ if (!entry) {
734
+ return {
735
+ event: 'error',
736
+ message: `ref '${msg.ref}' not found in last snapshot (${refMap.length} refs available)`,
737
+ };
738
+ }
739
+ await locatorFor(page, entry).click();
740
+ return { event: 'click', ref: entry.id, role: entry.role, name: entry.name || null, status: 'ok' };
741
+ }
742
+ case 'fill': {
743
+ if (!page) return { event: 'error', message: 'no open page' };
744
+ const text = typeof msg.text === 'string' ? msg.text : '';
745
+ // Selector path (PL3): use page.locator(selector).first().fill().
746
+ // Skips refMap precondition. Same secret-scrub semantics as ref path.
747
+ //
748
+ // Tier 3: short-timeout default. Playwright's default locator timeout
749
+ // is 30s — too long when --selector matches nothing (blocks the daemon).
750
+ // Default 5s; env override BROWSER_SKILL_FILL_TIMEOUT_MS for tests
751
+ // that legitimately need longer.
752
+ const fillTimeoutMs = Number.parseInt(
753
+ process.env.BROWSER_SKILL_FILL_TIMEOUT_MS || '5000',
754
+ 10,
755
+ );
756
+ if (msg.selector) {
757
+ try {
758
+ await page.locator(msg.selector).first().fill(text, { timeout: fillTimeoutMs });
759
+ } catch (err) {
760
+ let safeMessage = err && err.message ? err.message : String(err);
761
+ if (text && safeMessage.includes(text)) {
762
+ safeMessage = safeMessage.split(text).join('<redacted>');
763
+ }
764
+ return { event: 'error', message: `fill failed: ${safeMessage}` };
765
+ }
766
+ return {
767
+ event: 'fill',
768
+ selector: msg.selector,
769
+ text_length: text.length,
770
+ status: 'ok',
771
+ };
772
+ }
773
+ // Existing ref path (unchanged):
774
+ if (!refMap) return { event: 'error', message: 'no refs (run snapshot first)' };
775
+ const entry = refMap.find((r) => r.id === msg.ref);
776
+ if (!entry) {
777
+ return { event: 'error', message: `ref '${msg.ref}' not found in last snapshot` };
778
+ }
779
+ // Playwright echoes the fill arg in error logs (e.g. "fill(\"<text>\")"
780
+ // — would leak the secret). Wrap + scrub before returning so the
781
+ // client never sees the secret in any path.
782
+ try {
783
+ await locatorFor(page, entry).fill(text);
784
+ } catch (err) {
785
+ let safeMessage = err && err.message ? err.message : String(err);
786
+ if (text && safeMessage.includes(text)) {
787
+ safeMessage = safeMessage.split(text).join('<redacted>');
788
+ }
789
+ return { event: 'error', message: `fill failed: ${safeMessage}` };
790
+ }
791
+ return {
792
+ event: 'fill',
793
+ ref: entry.id,
794
+ role: entry.role,
795
+ name: entry.name || null,
796
+ text_length: text.length,
797
+ status: 'ok',
798
+ };
799
+ }
800
+ default:
801
+ return { event: 'error', message: `unknown verb '${msg.verb}'` };
802
+ }
803
+ }
804
+
805
+ await new Promise((resolve, reject) => {
806
+ ipcServer.listen(0, '127.0.0.1', () => resolve());
807
+ ipcServer.once('error', reject);
808
+ });
809
+ const ipcPort = ipcServer.address().port;
810
+
811
+ const state = {
812
+ pid: process.pid,
813
+ ws_endpoint: wsEndpoint,
814
+ ipc_host: '127.0.0.1',
815
+ ipc_port: ipcPort,
816
+ started_at: new Date().toISOString(),
817
+ browser: 'chromium',
818
+ headless,
819
+ };
820
+
821
+ const stateFile = daemonStatePath();
822
+ mkdirSync(dirname(stateFile), { recursive: true, mode: 0o700 });
823
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
824
+ chmodSync(stateFile, 0o600);
825
+
826
+ const cleanup = async () => {
827
+ try { await ipcServer.close(); } catch (_) {}
828
+ try { if (context) await context.close(); } catch (_) {}
829
+ try { await browser.close(); } catch (_) {}
830
+ try { await server.close(); } catch (_) {}
831
+ try { unlinkSync(stateFile); } catch (_) {}
832
+ process.exit(0);
833
+ };
834
+ process.on('SIGTERM', cleanup);
835
+ process.on('SIGINT', cleanup);
836
+
837
+ // Block forever (until signal).
838
+ await new Promise(() => {});
839
+ }
840
+
841
+ // Roles considered "interactive" for the purposes of assigning eN refs.
842
+ // Plus 'heading' (when named) so agents can disambiguate sections.
843
+ const INTERACTIVE_ROLES = new Set([
844
+ 'button', 'link', 'textbox', 'searchbox', 'combobox',
845
+ 'checkbox', 'radio', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
846
+ 'option', 'tab', 'switch', 'slider', 'spinbutton',
847
+ ]);
848
+
849
+ // Parse Playwright's ariaSnapshot YAML output and emit eN-tagged interactive
850
+ // refs. Each line of the form ` - role "name":` or ` - role:` produces a
851
+ // (role, name) tuple — we keep only roles agents typically click/fill, plus
852
+ // named headings for landmarking.
853
+ //
854
+ // Example input:
855
+ // - heading "Example Domain" [level=1]
856
+ // - link "Learn more"
857
+ // - paragraph: This domain is for use in documentation examples …
858
+ //
859
+ // Output: [{id:"e1", role:"heading", name:"Example Domain"},
860
+ // {id:"e2", role:"link", name:"Learn more"}]
861
+ function parseAriaSnapshot(yaml) {
862
+ const refs = [];
863
+ let n = 0;
864
+ const re = /^\s*-\s+([a-z][a-z]+)(?:\s+"([^"]*)")?[\s:[]/gm;
865
+ let m;
866
+ while ((m = re.exec(yaml)) !== null) {
867
+ const role = m[1];
868
+ const name = m[2] || '';
869
+ if (INTERACTIVE_ROLES.has(role) || (role === 'heading' && name)) {
870
+ n += 1;
871
+ refs.push({ id: `e${n}`, role, name });
872
+ }
873
+ }
874
+ return refs;
875
+ }
876
+
877
+ function locatorFor(page, entry) {
878
+ // Resolve a Locator from the (role, name) stored in the ref-map. Uses
879
+ // Playwright's getByRole — most stable cross-call locator. Limitation:
880
+ // pages with weak ARIA may have ambiguous (role, name) pairs; .first()
881
+ // picks the first match.
882
+ const opts = {};
883
+ if (entry.name) opts.name = entry.name;
884
+ return page.getByRole(entry.role, opts).first();
885
+ }
886
+
887
+
888
+ function runDaemonStop() {
889
+ const state = readDaemonState();
890
+ if (!state) {
891
+ process.stdout.write('{"event":"daemon-not-running"}\n');
892
+ process.exit(0);
893
+ }
894
+ if (isPidAlive(state.pid)) {
895
+ try { process.kill(state.pid, 'SIGTERM'); } catch (_) {}
896
+ }
897
+ // Brief wait for the daemon to clean up its state file.
898
+ const deadline = Date.now() + 5000;
899
+ while (Date.now() < deadline && existsSync(daemonStatePath())) {
900
+ // Busy-wait — sleep helper is async; sync wait is fine for ≤5s shutdown.
901
+ const now = Date.now();
902
+ while (Date.now() - now < 50) { /* ~50ms tick */ }
903
+ }
904
+ try { unlinkSync(daemonStatePath()); } catch (_) {}
905
+ process.stdout.write(
906
+ JSON.stringify({ event: 'daemon-stopped', pid: state.pid }) + '\n'
907
+ );
908
+ process.exit(0);
909
+ }
910
+
911
+ function runDaemonStatus() {
912
+ const state = readDaemonState();
913
+ if (state && isPidAlive(state.pid)) {
914
+ process.stdout.write(
915
+ JSON.stringify({ event: 'daemon-running', ...state }) + '\n'
916
+ );
917
+ process.exit(0);
918
+ }
919
+ process.stdout.write('{"event":"daemon-not-running"}\n');
920
+ process.exit(0);
921
+ }
922
+
923
+ function browserSkillHome() {
924
+ return process.env.BROWSER_SKILL_HOME || join(homedir(), '.browser-skill');
925
+ }
926
+
927
+ function daemonStatePath() {
928
+ return join(browserSkillHome(), 'playwright-lib-daemon.json');
929
+ }
930
+
931
+ function readDaemonState() {
932
+ const p = daemonStatePath();
933
+ if (!existsSync(p)) return null;
934
+ try {
935
+ return JSON.parse(readFileSync(p, 'utf-8'));
936
+ } catch (_) {
937
+ return null;
938
+ }
939
+ }
940
+
941
+ function isPidAlive(pid) {
942
+ try {
943
+ process.kill(pid, 0);
944
+ return true;
945
+ } catch (_) {
946
+ return false;
947
+ }
948
+ }
949
+
950
+ function sleep(ms) {
951
+ return new Promise((r) => setTimeout(r, ms));
952
+ }
953
+
954
+ function parseFlags(args) {
955
+ const out = { _positional: [] };
956
+ for (let i = 0; i < args.length; i++) {
957
+ const a = args[i];
958
+ if (a.startsWith('--')) {
959
+ const key = a.slice(2);
960
+ const next = args[i + 1];
961
+ if (next !== undefined && !next.startsWith('--')) {
962
+ out[key] = next;
963
+ i += 1;
964
+ } else {
965
+ out[key] = true;
966
+ }
967
+ } else {
968
+ out._positional.push(a);
969
+ }
970
+ }
971
+ return out;
972
+ }
973
+
974
+ async function runOpen(flags) {
975
+ const url = flags.url;
976
+ if (!url) {
977
+ process.stderr.write('playwright-driver.mjs::open: --url is required\n');
978
+ process.exit(2);
979
+ }
980
+ const headed = flags.headed === true;
981
+ const viewport = flags.viewport
982
+ ? parseViewport(flags.viewport)
983
+ : { width: 1280, height: 800 };
984
+ const storageStatePath = flags['storage-state'];
985
+ const userAgent = flags['user-agent'];
986
+
987
+ const { chromium } = loadPlaywright();
988
+
989
+ // If a daemon with an IPC socket is running, route through it so the
990
+ // context+page persists for subsequent stateful verbs (snapshot/click/fill).
991
+ // Otherwise: one-shot launch + close — useful as a smoke test, no state.
992
+ const daemon = readDaemonState();
993
+ if (daemon && isPidAlive(daemon.pid) && daemon.ipc_port) {
994
+ const reply = await ipcCall({
995
+ verb: 'open',
996
+ url,
997
+ viewport,
998
+ storage_state: storageStatePath || undefined,
999
+ user_agent: userAgent || undefined,
1000
+ });
1001
+ process.stdout.write(JSON.stringify(reply) + '\n');
1002
+ process.exit(reply.event === 'error' ? 30 : 0);
1003
+ }
1004
+
1005
+ const browser = await chromium.launch({ headless: !headed });
1006
+ const attached = false;
1007
+ try {
1008
+ const contextOptions = { viewport };
1009
+ if (storageStatePath) contextOptions.storageState = storageStatePath;
1010
+ if (userAgent) contextOptions.userAgent = userAgent;
1011
+
1012
+ const context = await browser.newContext(contextOptions);
1013
+ const page = await context.newPage();
1014
+
1015
+ const response = await page.goto(url, { waitUntil: 'domcontentloaded' });
1016
+ const title = await page.title();
1017
+ const finalUrl = page.url();
1018
+
1019
+ process.stdout.write(
1020
+ JSON.stringify({
1021
+ event: 'navigated',
1022
+ url: finalUrl,
1023
+ title,
1024
+ status: response ? response.status() : null,
1025
+ attached_to_daemon: attached,
1026
+ }) + '\n'
1027
+ );
1028
+
1029
+ if (attached) {
1030
+ // Disconnect — context + page stay alive in the daemon.
1031
+ await browser.close();
1032
+ } else {
1033
+ await context.close();
1034
+ await browser.close();
1035
+ }
1036
+ process.exit(0);
1037
+ } catch (err) {
1038
+ try { await browser.close(); } catch (_) {}
1039
+ process.stderr.write(
1040
+ `playwright-driver.mjs::open: ${err && err.message ? err.message : String(err)}\n`
1041
+ );
1042
+ process.exit(30);
1043
+ }
1044
+ }
1045
+
1046
+ // loadPlaywright resolves the `playwright` package by walking up from the
1047
+ // driver's location (project node_modules), then falling back to the npm
1048
+ // global root (BROWSER_SKILL_NPM_GLOBAL or `npm root -g`). Necessary because
1049
+ // users typically install playwright globally, but ESM `import('playwright')`
1050
+ // only walks up from the script's directory — not into ~/global node_modules.
1051
+ function loadPlaywright() {
1052
+ const req = createRequire(import.meta.url);
1053
+
1054
+ // First try local resolution (works if a project node_modules exists).
1055
+ try {
1056
+ return req('playwright');
1057
+ } catch (_) {
1058
+ // Fall through to global lookup.
1059
+ }
1060
+
1061
+ let npmRoot = process.env.BROWSER_SKILL_NPM_GLOBAL;
1062
+ if (!npmRoot) {
1063
+ try {
1064
+ npmRoot = execSync('npm root -g', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1065
+ } catch (_) {
1066
+ process.stderr.write(
1067
+ 'playwright-driver.mjs: cannot locate `playwright` — install it (`npm i -g playwright && playwright install chromium`)\n'
1068
+ );
1069
+ process.exit(21); // EXIT_TOOL_MISSING
1070
+ }
1071
+ }
1072
+
1073
+ try {
1074
+ return req(join(npmRoot, 'playwright'));
1075
+ } catch (err) {
1076
+ process.stderr.write(
1077
+ `playwright-driver.mjs: cannot load playwright from ${npmRoot}: ${err && err.message ? err.message : err}\n`
1078
+ );
1079
+ process.exit(21);
1080
+ }
1081
+ }
1082
+
1083
+ function parseViewport(spec) {
1084
+ const m = /^(\d+)x(\d+)$/.exec(spec);
1085
+ if (!m) {
1086
+ process.stderr.write(`--viewport must be WxH (got: ${spec})\n`);
1087
+ process.exit(2);
1088
+ }
1089
+ return { width: parseInt(m[1], 10), height: parseInt(m[2], 10) };
1090
+ }
1091
+
1092
+ function sha256NulJoined(args) {
1093
+ const hash = createHash('sha256');
1094
+ for (const a of args) {
1095
+ hash.update(a, 'utf-8');
1096
+ hash.update(Buffer.from([0]));
1097
+ }
1098
+ return hash.digest('hex');
1099
+ }
1100
+
1101
+ function repoRoot() {
1102
+ const here = dirname(fileURLToPath(import.meta.url));
1103
+ return join(here, '..', '..', '..');
1104
+ }