pi-ui-extend 1.0.28 → 1.0.30
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/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +42 -6
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +78 -0
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +308 -3
- package/package.json +1 -1
|
@@ -35,7 +35,9 @@ source reading, server polling, capability probing, or retries.
|
|
|
35
35
|
list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
|
|
36
36
|
and use public mode. Choose an auth profile only when the task names its id,
|
|
37
37
|
safe profile traits make the choice unambiguous, or a public run proves that
|
|
38
|
-
the requested page requires login.
|
|
38
|
+
the requested page requires login. If form authentication is required but
|
|
39
|
+
there is no usable profile, follow **Form-auth scaffolding** below instead of
|
|
40
|
+
asking the user to discover selectors.
|
|
39
41
|
5. Inspect the target code and write a declarative JSONC flow under
|
|
40
42
|
`$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or raw
|
|
41
43
|
executable JavaScript in it. The `evaluate` action exposes only the safe
|
|
@@ -208,11 +210,43 @@ after login succeeds so credentials are not captured in the trace.
|
|
|
208
210
|
|
|
209
211
|
## Credentials and blocked runs
|
|
210
212
|
|
|
213
|
+
### Form-auth scaffolding
|
|
214
|
+
|
|
215
|
+
When a public HTTPS login page (or loopback HTTP page for local development) is
|
|
216
|
+
known and there is no usable profile, invoke the trusted runner once:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
node <runner> auth scaffold \
|
|
220
|
+
--profile <safe-id> \
|
|
221
|
+
--login-url <public-login-url>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The runner discovers the login form and privately writes a target-specific
|
|
225
|
+
`.pi/qa_auth.jsonc` containing `__PI_QA_SECRET_n__` values. It may create the
|
|
226
|
+
file or replace only the runner's own empty generated template; it refuses to
|
|
227
|
+
overwrite invalid or non-empty auth configuration. Never inspect the result.
|
|
228
|
+
Relay `QA_AUTH_SCAFFOLD_CREATED`, the reported file, profile, placeholder count,
|
|
229
|
+
and action, then ask the user to replace only those placeholder values. Browser
|
|
230
|
+
QA must resume in a new run after the user confirms that edit.
|
|
231
|
+
|
|
232
|
+
By default, login succeeds when the discovered form becomes hidden. A form-less
|
|
233
|
+
page therefore requires an explicit deterministic signal using either
|
|
234
|
+
`--success-url <glob>` or `--success-selector <selector>` with optional
|
|
235
|
+
`--success-state attached|detached|visible|hidden`. Use `--base-url <url>` only
|
|
236
|
+
to override the profile's same-origin application root. These are public
|
|
237
|
+
metadata; never put credential values in command arguments or environment
|
|
238
|
+
variables.
|
|
239
|
+
|
|
240
|
+
Do not scaffold cookie, bearer, storage, storage-state, MFA, CAPTCHA, or
|
|
241
|
+
federated login. For those modes, use the generic empty-template flow below and
|
|
242
|
+
ask the user to configure the profile themselves.
|
|
243
|
+
|
|
211
244
|
Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
|
|
212
245
|
task explicitly requires authenticated behavior, or a public run reaches the
|
|
213
246
|
flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
|
|
214
|
-
|
|
215
|
-
|
|
247
|
+
form-auth scaffolding when supported. Otherwise run
|
|
248
|
+
`node <runner> profiles --require-auth`. Only these explicit authenticated paths
|
|
249
|
+
may create the private auth file.
|
|
216
250
|
|
|
217
251
|
If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
|
|
218
252
|
and explicitly report that authenticated browser QA requires credentials or an
|
|
@@ -220,7 +254,9 @@ auth-config update. Ask the user to fill the reported file and rerun QA. If
|
|
|
220
254
|
`templateCreated` is true, say that a private empty template was created at that
|
|
221
255
|
path. Relay only the runner's profile, file, reason, action, and template-created
|
|
222
256
|
state; never read the generated file or attempt to recover by exposing or
|
|
223
|
-
replaying credentials.
|
|
257
|
+
replaying credentials. If the action is `fill_credentials`, also relay the
|
|
258
|
+
placeholder count and tell the user to replace only the generated placeholder
|
|
259
|
+
values.
|
|
224
260
|
|
|
225
261
|
For any other blocked run, report the runner status and redacted reason. Do not
|
|
226
262
|
claim that browser QA passed based on source inspection, unit tests, or a build.
|
|
@@ -230,5 +266,5 @@ all non-empty `artifacts.screenshots`, `artifacts.videos`, and
|
|
|
230
266
|
`artifacts.traces`, and `artifacts.downloads` groups in the final response.
|
|
231
267
|
These links are mandatory so the user can open the evidence directly.
|
|
232
268
|
|
|
233
|
-
See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
|
|
234
|
-
`references/qa-design.md`.
|
|
269
|
+
See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
|
|
270
|
+
`references/qa-design.md`, and `references/auth-scaffold-spec.md`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Spec: Browser QA form-auth scaffold
|
|
2
|
+
|
|
3
|
+
## Type
|
|
4
|
+
|
|
5
|
+
Change
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Let the browser-QA sub-agent prepare a target-specific form-auth configuration
|
|
10
|
+
without reading, writing, receiving, or printing credential values. The user
|
|
11
|
+
should only need to replace clearly marked secret placeholders.
|
|
12
|
+
|
|
13
|
+
## Scope
|
|
14
|
+
|
|
15
|
+
- Add a trusted runner command that inspects a public login form.
|
|
16
|
+
- Generate `.pi/qa_auth.jsonc` with discovered field and submit selectors.
|
|
17
|
+
- Keep generated values as non-secret placeholders until the user edits them.
|
|
18
|
+
- Preserve the rule that agents never read or edit `.pi/qa_auth.jsonc`.
|
|
19
|
+
|
|
20
|
+
## Non-goals
|
|
21
|
+
|
|
22
|
+
- Collecting credentials interactively through the agent.
|
|
23
|
+
- Guessing cookie, token, storage-state, MFA, or federated-login credentials.
|
|
24
|
+
- Modifying an existing non-empty auth configuration.
|
|
25
|
+
|
|
26
|
+
## Behavior
|
|
27
|
+
|
|
28
|
+
- `auth scaffold` requires a project-local browser-QA sub-agent workspace, an
|
|
29
|
+
HTTPS login URL (or loopback HTTP for local development), and a safe profile
|
|
30
|
+
id.
|
|
31
|
+
- The trusted runner opens the unauthenticated login page with fail-closed
|
|
32
|
+
origin routing and discovers the most likely login form, fillable fields,
|
|
33
|
+
submit control, and form container.
|
|
34
|
+
- It writes a private `0600` `.pi/qa_auth.jsonc` containing only generated
|
|
35
|
+
selectors and `__PI_QA_SECRET_n__` placeholders. It may replace the runner's
|
|
36
|
+
empty generated template, but never a non-empty profiles object.
|
|
37
|
+
- By default, successful login means that the discovered form becomes hidden.
|
|
38
|
+
A form-less page requires an explicit success URL pattern or selector/state.
|
|
39
|
+
- A run cannot use a profile while generated placeholders remain. The runner
|
|
40
|
+
returns `QA_AUTH_UPDATE_REQUIRED` without exposing selectors or values.
|
|
41
|
+
|
|
42
|
+
## Invariants
|
|
43
|
+
|
|
44
|
+
- No credential value is accepted through command arguments or environment
|
|
45
|
+
variables.
|
|
46
|
+
- Scaffold status output contains only path, profile id, counts, and action.
|
|
47
|
+
- The command never prints inspected DOM text, input values, URLs, or selectors.
|
|
48
|
+
- Agents continue to treat `.pi/qa_auth.jsonc` as opaque and user-owned.
|
|
49
|
+
|
|
50
|
+
## Edge cases
|
|
51
|
+
|
|
52
|
+
- Reject pages without a discoverable fillable field or submit control, and
|
|
53
|
+
form-less pages without an explicit success condition.
|
|
54
|
+
- Reject remote plain-HTTP login pages.
|
|
55
|
+
- Reject cross-origin base URLs and malformed success options.
|
|
56
|
+
- Reject symlinked or permissive auth paths.
|
|
57
|
+
- Refuse to overwrite invalid or non-empty auth configuration.
|
|
58
|
+
|
|
59
|
+
## Related files
|
|
60
|
+
|
|
61
|
+
- `scripts/browser-qa-runner.mjs`
|
|
62
|
+
- `../SKILL.md`
|
|
63
|
+
- `../../../../../test/async-subagents/browser-qa-runner.test.ts`
|
|
64
|
+
|
|
65
|
+
## Verification
|
|
66
|
+
|
|
67
|
+
- Unit tests for discovery output, placeholder blocking, private permissions,
|
|
68
|
+
empty-template replacement, and non-empty-config refusal.
|
|
69
|
+
- Existing browser-QA runner tests and repository checks continue to pass.
|
|
70
|
+
|
|
71
|
+
## Evidence
|
|
72
|
+
|
|
73
|
+
- Confirmed by code: current runner owns Playwright, origin isolation, private
|
|
74
|
+
auth-file creation, validation, and redacted status output.
|
|
75
|
+
- Confirmed by tests: current runner rejects permissive/symlinked auth files and
|
|
76
|
+
creates its empty template with private permissions.
|
|
77
|
+
- Confirmed by docs: the private skill forbids agents from reading or editing
|
|
78
|
+
`.pi/qa_auth.jsonc`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import path from "node:path";
|
|
@@ -10,6 +11,7 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
|
10
11
|
import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
|
|
11
12
|
|
|
12
13
|
const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
|
|
14
|
+
const AUTH_SECRET_PLACEHOLDER = /^__PI_QA_SECRET_[1-9][0-9]*__$/;
|
|
13
15
|
const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
|
|
14
16
|
const QA_WORKSPACE_RELATIVE = "browser-qa";
|
|
15
17
|
const EVIDENCE_RELATIVE = "evidence";
|
|
@@ -109,6 +111,12 @@ if (!isMainThread && workerData?.operation === "sanitize_trace") {
|
|
|
109
111
|
async function main() {
|
|
110
112
|
const [command = "profiles", ...rawArgs] = process.argv.slice(2);
|
|
111
113
|
const cwd = fs.realpathSync(process.cwd());
|
|
114
|
+
if (command === "auth") {
|
|
115
|
+
const [authCommand, ...authArgs] = rawArgs;
|
|
116
|
+
if (authCommand !== "scaffold") throw new QaStatusError("QA_RUN_FAILED", `unknown auth command: ${authCommand ?? "(missing)"}`, 1);
|
|
117
|
+
await runAuthScaffold(cwd, parseAuthScaffoldArgs(authArgs));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
112
120
|
if (command === "profiles") {
|
|
113
121
|
const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
|
|
114
122
|
if (rawArgs.length > 0 && !requireAuth) {
|
|
@@ -122,7 +130,7 @@ async function main() {
|
|
|
122
130
|
|
|
123
131
|
const args = parseArgs(rawArgs);
|
|
124
132
|
const selected = args.profile
|
|
125
|
-
? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
|
|
133
|
+
? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
|
|
126
134
|
: createPublicProfile(args.baseUrl);
|
|
127
135
|
const profileId = selected.id;
|
|
128
136
|
const profile = selected.profile;
|
|
@@ -290,7 +298,189 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
|
|
|
290
298
|
writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
|
|
291
299
|
}
|
|
292
300
|
|
|
293
|
-
function
|
|
301
|
+
async function runAuthScaffold(cwd, args) {
|
|
302
|
+
const profileId = args.profile;
|
|
303
|
+
if (!isSafeName(profileId) || profileId.length > 128) throw new QaStatusError("QA_RUN_FAILED", "--profile must be a safe profile id no longer than 128 characters", 1);
|
|
304
|
+
const loginUrl = normalizeScaffoldUrl(args.loginUrl, "--login-url");
|
|
305
|
+
const login = new URL(loginUrl);
|
|
306
|
+
const baseUrl = normalizeScaffoldUrl(args.baseUrl ?? `${login.origin}/`, "--base-url", login.origin);
|
|
307
|
+
const success = scaffoldSuccess(args);
|
|
308
|
+
const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
|
|
309
|
+
const progress = createRunnerProgress(path.join(agentDir, QA_WORKSPACE_RELATIVE));
|
|
310
|
+
assertAuthScaffoldWritable(cwd);
|
|
311
|
+
const timeout = Math.min(parseRunnerTimeout(args.runnerTimeoutMs, profileId), 60_000);
|
|
312
|
+
const deadline = Date.now() + timeout;
|
|
313
|
+
let browser;
|
|
314
|
+
let context;
|
|
315
|
+
try {
|
|
316
|
+
progress("auth_scaffold_started", { timeoutMs: timeout });
|
|
317
|
+
const playwright = loadPlaywright(cwd);
|
|
318
|
+
browser = await runStage(progress, "auth_scaffold_browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
|
|
319
|
+
context = await runStage(progress, "auth_scaffold_context_create", deadline, () => browser.newContext({
|
|
320
|
+
...DEFAULT_ENVIRONMENT,
|
|
321
|
+
serviceWorkers: "block",
|
|
322
|
+
viewport: DEFAULT_VIEWPORT,
|
|
323
|
+
}), profileId);
|
|
324
|
+
await runStage(progress, "auth_scaffold_origin_guard", deadline, () => installOriginGuard(context, [login.origin], { type: "none" }), profileId);
|
|
325
|
+
const page = await runStage(progress, "auth_scaffold_page_create", deadline, () => context.newPage(), profileId);
|
|
326
|
+
await runStage(progress, "auth_scaffold_login_load", deadline, () => page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout }), profileId);
|
|
327
|
+
const discovered = await runStage(progress, "auth_scaffold_form_discovery", deadline, () => discoverLoginForm(page), profileId);
|
|
328
|
+
if (!success && !discovered.formSelector) {
|
|
329
|
+
throw new QaStatusError("QA_RUN_FAILED", "no login form was found; provide an explicit success condition for a form-less login page", 1, profileId);
|
|
330
|
+
}
|
|
331
|
+
const profile = {
|
|
332
|
+
description: args.description ?? `Browser QA form login (${profileId})`,
|
|
333
|
+
traits: ["auth:form"],
|
|
334
|
+
baseUrl,
|
|
335
|
+
allowedOrigins: [login.origin],
|
|
336
|
+
auth: {
|
|
337
|
+
type: "form",
|
|
338
|
+
loginUrl,
|
|
339
|
+
fields: discovered.fields.map((selector, index) => ({ selector, value: `__PI_QA_SECRET_${index + 1}__` })),
|
|
340
|
+
submitSelector: discovered.submitSelector,
|
|
341
|
+
success: success ?? { selector: discovered.formSelector, state: "hidden" },
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
const replacedEmptyTemplate = writeAuthScaffold(cwd, profileId, profile);
|
|
345
|
+
writeStatus({
|
|
346
|
+
status: "QA_AUTH_SCAFFOLD_CREATED",
|
|
347
|
+
file: CONFIG_RELATIVE,
|
|
348
|
+
profile: profileId,
|
|
349
|
+
action: "fill_credentials",
|
|
350
|
+
placeholderCount: discovered.fields.length,
|
|
351
|
+
replacedEmptyTemplate,
|
|
352
|
+
});
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error instanceof QaStatusError) {
|
|
355
|
+
if (error.details?.timedOut) terminateRunnerDescendants();
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
if (error instanceof RunnerStageTimeoutError) {
|
|
359
|
+
terminateRunnerDescendants();
|
|
360
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth scaffold timed out; verify the public login page and retry", EXIT_RUNNER_TIMEOUT, profileId, { timedOut: true });
|
|
361
|
+
}
|
|
362
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth scaffold failed; verify the public login page and retry", 1, profileId);
|
|
363
|
+
} finally {
|
|
364
|
+
if (context) await runCleanupStage(progress, "auth_scaffold_context_close", () => context.close()).catch(terminateOnCleanupTimeout);
|
|
365
|
+
if (browser) await runCleanupStage(progress, "auth_scaffold_browser_close", () => browser.close()).catch(terminateOnCleanupTimeout);
|
|
366
|
+
progress("auth_scaffold_finished");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function terminateOnCleanupTimeout(error) {
|
|
371
|
+
if (error instanceof RunnerStageTimeoutError) terminateRunnerDescendants();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function normalizeScaffoldUrl(raw, flag, expectedOrigin) {
|
|
375
|
+
if (typeof raw !== "string") throw new QaStatusError("QA_RUN_FAILED", `${flag} is required`, 1);
|
|
376
|
+
try {
|
|
377
|
+
const url = new URL(raw);
|
|
378
|
+
if (!isSecureScaffoldUrl(url) || url.username || url.password || (expectedOrigin && url.origin !== expectedOrigin)) throw new Error();
|
|
379
|
+
return url.href;
|
|
380
|
+
} catch {
|
|
381
|
+
throw new QaStatusError("QA_RUN_FAILED", `${flag} must use HTTPS or loopback HTTP without credentials${expectedOrigin ? " on the login origin" : ""}`, 1);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isSecureScaffoldUrl(url) {
|
|
386
|
+
if (url.protocol === "https:") return true;
|
|
387
|
+
if (url.protocol !== "http:") return false;
|
|
388
|
+
const hostname = url.hostname.toLowerCase();
|
|
389
|
+
return hostname === "localhost"
|
|
390
|
+
|| hostname.endsWith(".localhost")
|
|
391
|
+
|| /^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
|
|
392
|
+
|| hostname === "[::1]"
|
|
393
|
+
|| hostname === "::1";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function scaffoldSuccess(args) {
|
|
397
|
+
if (args.successUrl && args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "use only one of --success-url or --success-selector", 1);
|
|
398
|
+
if (args.successState && !args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "--success-state requires --success-selector", 1);
|
|
399
|
+
if (args.successUrl) return { url: boundedScaffoldString(args.successUrl, "--success-url") };
|
|
400
|
+
if (!args.successSelector) return undefined;
|
|
401
|
+
const state = args.successState ?? "visible";
|
|
402
|
+
if (validLocatorState(state) !== state) throw new QaStatusError("QA_RUN_FAILED", "--success-state is invalid", 1);
|
|
403
|
+
return { selector: boundedScaffoldString(args.successSelector, "--success-selector"), state };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function boundedScaffoldString(value, flag) {
|
|
407
|
+
if (typeof value !== "string" || !value.trim() || value.length > 1024 || /[\r\n\0]/.test(value)) {
|
|
408
|
+
throw new QaStatusError("QA_RUN_FAILED", `${flag} must be a non-empty single-line string no longer than 1024 characters`, 1);
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function discoverLoginForm(page) {
|
|
414
|
+
const discovered = await page.evaluate(() => {
|
|
415
|
+
const quoteAttribute = (value) => value.replace(/[\0-\x1f\x7f"\\]/g, (character) => {
|
|
416
|
+
if (character === "\0") return "�";
|
|
417
|
+
if (character === '"') return '\\"';
|
|
418
|
+
if (character === "\\") return "\\\\";
|
|
419
|
+
return `\\${character.codePointAt(0).toString(16)} `;
|
|
420
|
+
});
|
|
421
|
+
const unique = (selector) => {
|
|
422
|
+
try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
|
|
423
|
+
};
|
|
424
|
+
const selectorFor = (element) => {
|
|
425
|
+
if (element.id) {
|
|
426
|
+
const selector = `[id="${quoteAttribute(element.id)}"]`;
|
|
427
|
+
if (unique(selector)) return selector;
|
|
428
|
+
}
|
|
429
|
+
for (const attribute of ["data-testid", "data-test", "name", "aria-label"]) {
|
|
430
|
+
const value = element.getAttribute(attribute);
|
|
431
|
+
if (!value) continue;
|
|
432
|
+
const selector = `${element.tagName.toLowerCase()}[${attribute}="${quoteAttribute(value)}"]`;
|
|
433
|
+
if (unique(selector)) return selector;
|
|
434
|
+
}
|
|
435
|
+
const parts = [];
|
|
436
|
+
let current = element;
|
|
437
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
|
|
438
|
+
const tag = current.tagName.toLowerCase();
|
|
439
|
+
const siblings = [...current.parentElement.children].filter((item) => item.tagName === current.tagName);
|
|
440
|
+
parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
|
|
441
|
+
const selector = parts.join(" > ");
|
|
442
|
+
if (unique(selector)) return selector;
|
|
443
|
+
current = current.parentElement;
|
|
444
|
+
}
|
|
445
|
+
return parts.join(" > ");
|
|
446
|
+
};
|
|
447
|
+
const fillable = (element) => {
|
|
448
|
+
if (element.disabled || element.getAttribute("aria-hidden") === "true") return false;
|
|
449
|
+
if (element instanceof HTMLTextAreaElement) return true;
|
|
450
|
+
if (!(element instanceof HTMLInputElement)) return false;
|
|
451
|
+
return ["email", "password", "text", "tel", "url", "search", "number"].includes((element.type || "text").toLowerCase());
|
|
452
|
+
};
|
|
453
|
+
const visible = (element) => Boolean(element.getClientRects().length) && getComputedStyle(element).visibility !== "hidden";
|
|
454
|
+
const forms = [...document.querySelectorAll("form")];
|
|
455
|
+
const ranked = forms.map((form) => {
|
|
456
|
+
const fields = [...form.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
|
|
457
|
+
const passwordCount = fields.filter((element) => element instanceof HTMLInputElement && element.type === "password").length;
|
|
458
|
+
return { form, fields, score: passwordCount * 100 + fields.length };
|
|
459
|
+
}).filter((entry) => entry.fields.length > 0).sort((left, right) => right.score - left.score);
|
|
460
|
+
const selected = ranked[0];
|
|
461
|
+
const root = selected?.form ?? document;
|
|
462
|
+
const fields = selected?.fields ?? [...document.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
|
|
463
|
+
const submit = [...root.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')].find(visible);
|
|
464
|
+
return {
|
|
465
|
+
fields: fields.map(selectorFor),
|
|
466
|
+
submitSelector: submit ? selectorFor(submit) : undefined,
|
|
467
|
+
formSelector: selected ? selectorFor(selected.form) : undefined,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
if (!isObject(discovered) || !Array.isArray(discovered.fields) || discovered.fields.length === 0
|
|
471
|
+
|| discovered.fields.length > 10 || !discovered.fields.every(isBoundedSelector)
|
|
472
|
+
|| !isBoundedSelector(discovered.submitSelector)
|
|
473
|
+
|| (discovered.formSelector !== undefined && !isBoundedSelector(discovered.formSelector))) {
|
|
474
|
+
throw new Error("no supported login form with fillable fields and a submit control was found");
|
|
475
|
+
}
|
|
476
|
+
return discovered;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isBoundedSelector(value) {
|
|
480
|
+
return typeof value === "string" && value.length > 0 && value.length <= 1024 && !/[\0-\x1f\x7f]/.test(value);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function readAuthConfig(cwd, required = false, allowPlaceholders = false) {
|
|
294
484
|
const candidate = path.join(cwd, CONFIG_RELATIVE);
|
|
295
485
|
if (!fs.existsSync(candidate)) {
|
|
296
486
|
if (!required) return { present: false, profiles: {} };
|
|
@@ -333,6 +523,18 @@ function readAuthConfig(cwd, required = false) {
|
|
|
333
523
|
{ action: "provide_credentials", templateCreated: false },
|
|
334
524
|
);
|
|
335
525
|
}
|
|
526
|
+
const profileValues = Object.values(value.profiles);
|
|
527
|
+
if (required && !allowPlaceholders && profileValues.length > 0
|
|
528
|
+
&& profileValues.every((profile) => authPlaceholderCount(profile) > 0)) {
|
|
529
|
+
const placeholderCount = profileValues.reduce((count, profile) => count + authPlaceholderCount(profile), 0);
|
|
530
|
+
throw new QaStatusError(
|
|
531
|
+
"QA_AUTH_UPDATE_REQUIRED",
|
|
532
|
+
`credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
|
|
533
|
+
EXIT_AUTH_UPDATE_REQUIRED,
|
|
534
|
+
undefined,
|
|
535
|
+
{ action: "fill_credentials", templateCreated: false, placeholderCount },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
336
538
|
if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
|
|
337
539
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
|
|
338
540
|
}
|
|
@@ -357,9 +559,28 @@ function selectProfile(profiles, requestedId) {
|
|
|
357
559
|
if (!isObject(profile.auth) || typeof profile.auth.type !== "string") {
|
|
358
560
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile auth configuration is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
|
|
359
561
|
}
|
|
562
|
+
const placeholderCount = authPlaceholderCount(profile);
|
|
563
|
+
if (placeholderCount > 0) {
|
|
564
|
+
throw new QaStatusError(
|
|
565
|
+
"QA_AUTH_UPDATE_REQUIRED",
|
|
566
|
+
`credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
|
|
567
|
+
EXIT_AUTH_UPDATE_REQUIRED,
|
|
568
|
+
requestedId,
|
|
569
|
+
{ action: "fill_credentials", templateCreated: false, placeholderCount },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
360
572
|
return { id: requestedId, profile };
|
|
361
573
|
}
|
|
362
574
|
|
|
575
|
+
function authPlaceholderCount(profile) {
|
|
576
|
+
if (!isObject(profile) || !isObject(profile.auth) || profile.auth.type !== "form" || !Array.isArray(profile.auth.fields)) return 0;
|
|
577
|
+
return profile.auth.fields.filter((field) => (
|
|
578
|
+
isObject(field)
|
|
579
|
+
&& typeof field.value === "string"
|
|
580
|
+
&& AUTH_SECRET_PLACEHOLDER.test(field.value.trim())
|
|
581
|
+
)).length;
|
|
582
|
+
}
|
|
583
|
+
|
|
363
584
|
function createPublicProfile(rawBaseUrl) {
|
|
364
585
|
if (typeof rawBaseUrl !== "string") {
|
|
365
586
|
throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
|
|
@@ -436,6 +657,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
|
|
|
436
657
|
if (!Array.isArray(auth.fields) || auth.fields.length === 0 || auth.fields.some((field) => !isObject(field) || typeof field.selector !== "string" || !field.selector || typeof field.value !== "string" || !field.value)) throw authError(profileId, "form fields require non-empty selector/value strings");
|
|
437
658
|
if (typeof auth.submitSelector !== "string" || !auth.submitSelector) throw authError(profileId, "form submitSelector is missing");
|
|
438
659
|
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) throw authError(profileId, "form success.url or success.selector is required");
|
|
660
|
+
if (auth.success.state !== undefined && (typeof auth.success.selector !== "string" || validLocatorState(auth.success.state) !== auth.success.state)) throw authError(profileId, "form success.state requires a selector and valid locator state");
|
|
439
661
|
break;
|
|
440
662
|
case "storageState":
|
|
441
663
|
if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
|
|
@@ -1211,7 +1433,9 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
|
|
|
1211
1433
|
}
|
|
1212
1434
|
await page.locator(auth.submitSelector).click();
|
|
1213
1435
|
if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
|
|
1214
|
-
if (isObject(auth.success) && typeof auth.success.selector === "string")
|
|
1436
|
+
if (isObject(auth.success) && typeof auth.success.selector === "string") {
|
|
1437
|
+
await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
|
|
1438
|
+
}
|
|
1215
1439
|
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
|
|
1216
1440
|
throw authError(profileId, "form success.url or success.selector is required");
|
|
1217
1441
|
}
|
|
@@ -1354,6 +1578,26 @@ function parseArgs(values) {
|
|
|
1354
1578
|
return result;
|
|
1355
1579
|
}
|
|
1356
1580
|
|
|
1581
|
+
function parseAuthScaffoldArgs(values) {
|
|
1582
|
+
const result = {};
|
|
1583
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
1584
|
+
const flag = values[index];
|
|
1585
|
+
const value = values[index + 1];
|
|
1586
|
+
if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
|
|
1587
|
+
const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1588
|
+
if (!["profile", "loginUrl", "baseUrl", "description", "successUrl", "successSelector", "successState", "runnerTimeoutMs"].includes(key)) {
|
|
1589
|
+
throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
|
|
1590
|
+
}
|
|
1591
|
+
if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
|
|
1592
|
+
result[key] = value;
|
|
1593
|
+
}
|
|
1594
|
+
if (result.description !== undefined) {
|
|
1595
|
+
result.description = boundedScaffoldString(result.description, "--description");
|
|
1596
|
+
if (result.description.length > 200) throw new QaStatusError("QA_RUN_FAILED", "--description must be no longer than 200 characters", 1);
|
|
1597
|
+
}
|
|
1598
|
+
return result;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1357
1601
|
function parseRunnerTimeout(value, profileId) {
|
|
1358
1602
|
if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
|
|
1359
1603
|
const parsed = Number(value);
|
|
@@ -1764,6 +2008,67 @@ function createAuthTemplate(cwd) {
|
|
|
1764
2008
|
return true;
|
|
1765
2009
|
}
|
|
1766
2010
|
|
|
2011
|
+
function writeAuthScaffold(cwd, profileId, profile) {
|
|
2012
|
+
const initialTarget = assertAuthScaffoldWritable(cwd);
|
|
2013
|
+
const content = `// Generated by the trusted browser QA runner from the public login form.\n// Replace only __PI_QA_SECRET_n__ values yourself; agents must not read or edit this file.\n${JSON.stringify({ profiles: { [profileId]: profile } }, null, 2)}\n`;
|
|
2014
|
+
const temporaryFile = `${initialTarget.file}.scaffold-${randomUUID()}.tmp`;
|
|
2015
|
+
try {
|
|
2016
|
+
fs.writeFileSync(temporaryFile, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2017
|
+
const descriptor = fs.openSync(temporaryFile, "r+");
|
|
2018
|
+
try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
|
2019
|
+
|
|
2020
|
+
const currentTarget = assertAuthScaffoldWritable(cwd);
|
|
2021
|
+
if (currentTarget.replacedEmptyTemplate !== initialTarget.replacedEmptyTemplate) {
|
|
2022
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
|
|
2023
|
+
}
|
|
2024
|
+
if (currentTarget.replacedEmptyTemplate) {
|
|
2025
|
+
fs.renameSync(temporaryFile, currentTarget.file);
|
|
2026
|
+
} else {
|
|
2027
|
+
try {
|
|
2028
|
+
fs.linkSync(temporaryFile, currentTarget.file);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
if (isAlreadyExistsError(error)) throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
|
|
2031
|
+
throw error;
|
|
2032
|
+
}
|
|
2033
|
+
fs.unlinkSync(temporaryFile);
|
|
2034
|
+
}
|
|
2035
|
+
return currentTarget.replacedEmptyTemplate;
|
|
2036
|
+
} finally {
|
|
2037
|
+
if (fs.existsSync(temporaryFile)) fs.unlinkSync(temporaryFile);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function assertAuthScaffoldWritable(cwd) {
|
|
2042
|
+
const root = fs.realpathSync(cwd);
|
|
2043
|
+
const directory = path.join(root, path.dirname(CONFIG_RELATIVE));
|
|
2044
|
+
const file = path.join(root, CONFIG_RELATIVE);
|
|
2045
|
+
assertInside(root, directory, "auth config directory");
|
|
2046
|
+
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
|
2047
|
+
const directoryStat = fs.lstatSync(directory);
|
|
2048
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
2049
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth config directory must be a real project-local directory", 1);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
let replacedEmptyTemplate = false;
|
|
2053
|
+
if (fs.existsSync(file)) {
|
|
2054
|
+
const existing = resolveExistingPrivateFile(root, CONFIG_RELATIVE, "auth config");
|
|
2055
|
+
const existingContent = fs.readFileSync(existing, "utf8");
|
|
2056
|
+
const errors = [];
|
|
2057
|
+
const value = parseJsonc(existingContent, errors, { allowTrailingComma: true });
|
|
2058
|
+
if (errors.length > 0 || !isObject(value) || !isObject(value.profiles)) {
|
|
2059
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing auth config is invalid; scaffold creation will not overwrite it", 1);
|
|
2060
|
+
}
|
|
2061
|
+
if (Object.keys(value.profiles).length > 0) {
|
|
2062
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing auth config has profiles; scaffold creation will not overwrite it", 1);
|
|
2063
|
+
}
|
|
2064
|
+
if (existingContent !== AUTH_TEMPLATE) {
|
|
2065
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing empty auth config was not generated by the trusted runner; scaffold creation will not overwrite it", 1);
|
|
2066
|
+
}
|
|
2067
|
+
replacedEmptyTemplate = true;
|
|
2068
|
+
}
|
|
2069
|
+
return { file, replacedEmptyTemplate };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1767
2072
|
function isAlreadyExistsError(error) {
|
|
1768
2073
|
return error !== null && typeof error === "object" && error.code === "EEXIST";
|
|
1769
2074
|
}
|
package/package.json
CHANGED