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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SECURITY.md +39 -0
- package/SKILL.md +206 -0
- package/bin/cli.mjs +55 -0
- package/install.sh +143 -0
- package/package.json +54 -0
- package/references/adapter-candidates.md +40 -0
- package/references/browser-mcp-cheatsheet.md +132 -0
- package/references/browser-stats-cheatsheet.md +155 -0
- package/references/chrome-devtools-mcp-cheatsheet.md +232 -0
- package/references/midscene-integration.md +359 -0
- package/references/obscura-cheatsheet.md +103 -0
- package/references/playwright-cli-cheatsheet.md +64 -0
- package/references/playwright-lib-cheatsheet.md +90 -0
- package/references/recipes/add-a-tool-adapter.md +134 -0
- package/references/recipes/agent-workflows/README.md +37 -0
- package/references/recipes/agent-workflows/cache-driven-bulk-operation.md +110 -0
- package/references/recipes/agent-workflows/flow-record-and-replay.md +102 -0
- package/references/recipes/agent-workflows/incremental-pattern-discovery.md +125 -0
- package/references/recipes/agent-workflows/login-then-scrape.md +100 -0
- package/references/recipes/anti-patterns-tool-extension.md +182 -0
- package/references/recipes/body-bytes-not-body.md +139 -0
- package/references/recipes/cache-write-security.md +210 -0
- package/references/recipes/fingerprint-rescue.md +154 -0
- package/references/recipes/model-routing.md +143 -0
- package/references/recipes/path-security.md +138 -0
- package/references/recipes/privacy-canary.md +96 -0
- package/references/recipes/visual-rescue-hook.md +182 -0
- package/references/stats-prices.json +42 -0
- package/references/stats-schema.json +77 -0
- package/references/tool-versions.md +8 -0
- package/scripts/browser-add-site.sh +113 -0
- package/scripts/browser-assert.sh +106 -0
- package/scripts/browser-audit.sh +68 -0
- package/scripts/browser-baseline.sh +135 -0
- package/scripts/browser-click.sh +100 -0
- package/scripts/browser-creds-add.sh +254 -0
- package/scripts/browser-creds-list.sh +67 -0
- package/scripts/browser-creds-migrate.sh +122 -0
- package/scripts/browser-creds-remove.sh +69 -0
- package/scripts/browser-creds-rotate-totp.sh +109 -0
- package/scripts/browser-creds-show.sh +82 -0
- package/scripts/browser-creds-totp.sh +94 -0
- package/scripts/browser-do.sh +630 -0
- package/scripts/browser-doctor.sh +365 -0
- package/scripts/browser-drag.sh +90 -0
- package/scripts/browser-extract.sh +192 -0
- package/scripts/browser-fill.sh +142 -0
- package/scripts/browser-flow.sh +316 -0
- package/scripts/browser-history.sh +187 -0
- package/scripts/browser-hover.sh +92 -0
- package/scripts/browser-inspect.sh +188 -0
- package/scripts/browser-list-sessions.sh +78 -0
- package/scripts/browser-list-sites.sh +42 -0
- package/scripts/browser-login.sh +279 -0
- package/scripts/browser-mcp.sh +65 -0
- package/scripts/browser-migrate.sh +195 -0
- package/scripts/browser-open.sh +134 -0
- package/scripts/browser-press.sh +80 -0
- package/scripts/browser-remove-session.sh +72 -0
- package/scripts/browser-remove-site.sh +68 -0
- package/scripts/browser-replay.sh +206 -0
- package/scripts/browser-route.sh +174 -0
- package/scripts/browser-select.sh +122 -0
- package/scripts/browser-show-session.sh +57 -0
- package/scripts/browser-show-site.sh +37 -0
- package/scripts/browser-snapshot.sh +176 -0
- package/scripts/browser-stats.sh +522 -0
- package/scripts/browser-tab-close.sh +112 -0
- package/scripts/browser-tab-list.sh +70 -0
- package/scripts/browser-tab-switch.sh +111 -0
- package/scripts/browser-upload.sh +132 -0
- package/scripts/browser-use.sh +60 -0
- package/scripts/browser-vlm.sh +707 -0
- package/scripts/browser-wait.sh +97 -0
- package/scripts/install-git-hooks.sh +16 -0
- package/scripts/lib/capture.sh +356 -0
- package/scripts/lib/common.sh +262 -0
- package/scripts/lib/credential.sh +237 -0
- package/scripts/lib/fingerprint-rescue.js +123 -0
- package/scripts/lib/flow.sh +448 -0
- package/scripts/lib/flow_record.sh +210 -0
- package/scripts/lib/mask.sh +49 -0
- package/scripts/lib/memory.sh +427 -0
- package/scripts/lib/migrate.sh +390 -0
- package/scripts/lib/migrators/README.md +23 -0
- package/scripts/lib/migrators/memory/v1_to_v2.sh +15 -0
- package/scripts/lib/migrators/recent_urls/README.md +13 -0
- package/scripts/lib/migrators/stats/README.md +24 -0
- package/scripts/lib/node/chrome-devtools-bridge.mjs +1812 -0
- package/scripts/lib/node/mcp-server.mjs +531 -0
- package/scripts/lib/node/mcp-tools.json +68 -0
- package/scripts/lib/node/playwright-driver.mjs +1104 -0
- package/scripts/lib/node/totp-core.mjs +52 -0
- package/scripts/lib/node/totp.mjs +52 -0
- package/scripts/lib/node/url-pattern-cluster.mjs +102 -0
- package/scripts/lib/node/url-pattern-resolver.mjs +77 -0
- package/scripts/lib/output.sh +79 -0
- package/scripts/lib/router.sh +342 -0
- package/scripts/lib/sanitize.sh +107 -0
- package/scripts/lib/secret/keychain.sh +91 -0
- package/scripts/lib/secret/libsecret.sh +74 -0
- package/scripts/lib/secret/plaintext.sh +75 -0
- package/scripts/lib/secret_backend_select.sh +57 -0
- package/scripts/lib/session.sh +153 -0
- package/scripts/lib/site.sh +126 -0
- package/scripts/lib/stats.sh +419 -0
- package/scripts/lib/tool/.gitkeep +0 -0
- package/scripts/lib/tool/chrome-devtools-mcp.sh +349 -0
- package/scripts/lib/tool/obscura.sh +249 -0
- package/scripts/lib/tool/playwright-cli.sh +155 -0
- package/scripts/lib/tool/playwright-lib.sh +106 -0
- package/scripts/lib/verb_helpers.sh +222 -0
- package/scripts/lib/visual-rescue-default.sh +145 -0
- package/scripts/regenerate-docs.sh +99 -0
- 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
|
+
}
|