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.
@@ -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
- `node <runner> profiles --require-auth`. Only this explicit authenticated path
215
- may create the private empty template.
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`, and
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 readAuthConfig(cwd, required = false) {
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") await page.locator(auth.success.selector).waitFor({ timeout });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {