pi-ui-extend 1.0.22 → 1.0.23

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.
@@ -7,11 +7,14 @@ import { renderConversationShellEntry } from "./conversation-shell-renderer.js";
7
7
  import { renderConversationToolEntry, renderThinkingEntry } from "./conversation-tool-renderer.js";
8
8
  export function renderConversationEntry(entry, width, options) {
9
9
  const { left: userContentLeft, contentWidth: userContentWidth } = horizontalPaddingLayout(width);
10
- const userLine = (text, entryId, syntaxHighlight, segments) => ({
10
+ const userLine = (text, entryId, syntaxHighlight, segments, links) => ({
11
11
  text: padHorizontalText(text, width),
12
12
  colorOverride: options.colors.userForeground,
13
13
  backgroundOverride: options.colors.userMessageBackground,
14
14
  ...(segments && segments.length > 0 ? { segments: segments.map((segment) => ({ ...segment, start: segment.start + userContentLeft, end: segment.end + userContentLeft })) } : {}),
15
+ ...(links && links.length > 0 ? {
16
+ links: links.map((link) => ({ ...link, start: link.start + userContentLeft, end: link.end + userContentLeft })),
17
+ } : {}),
15
18
  ...(syntaxHighlight === undefined ? {} : { syntaxHighlight }),
16
19
  ...(entryId === undefined ? {} : { target: { kind: "user-message", id: entryId } }),
17
20
  });
@@ -23,7 +26,10 @@ export function renderConversationEntry(entry, width, options) {
23
26
  });
24
27
  const userMessageLines = (userEntry) => {
25
28
  const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
26
- ...userLine(line.text, userEntry.id, line.syntaxHighlight, line.segments),
29
+ ...userLine(line.text, userEntry.id, line.syntaxHighlight, [
30
+ ...(line.segments ?? []),
31
+ ...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
32
+ ], line.links),
27
33
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
28
34
  ...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
29
35
  }));
@@ -86,7 +92,9 @@ function renderAssistantLines(text, width, options) {
86
92
  ? { start: contentLeft, end: contentLeft + line.text.length, foreground: options.colors.assistantForeground, bold: true }
87
93
  : undefined;
88
94
  const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
89
- const allSegments = headingSegment ? [headingSegment, ...existingSegments] : existingSegments;
95
+ const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
96
+ const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
97
+ const allSegments = headingSegment ? [headingSegment, ...existingSegments, ...linkSegments] : [...existingSegments, ...linkSegments];
90
98
  lines.push({
91
99
  text: padHorizontalText(line.text, width),
92
100
  ...(line.copyText === undefined ? {} : { copyText: line.copyText }),
@@ -94,6 +102,7 @@ function renderAssistantLines(text, width, options) {
94
102
  colorOverride: options.colors.assistantForeground,
95
103
  backgroundOverride: options.colors.assistantMessageBackground,
96
104
  ...(allSegments.length > 0 ? { segments: allSegments } : {}),
105
+ ...(links.length > 0 ? { links } : {}),
97
106
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
98
107
  });
99
108
  }
@@ -57,6 +57,7 @@ export class AppRenderController {
57
57
  this.deps.mouseController.syncConversationSelectionForRender(scrollMetrics.start, bodyHeight, topReservedRows, conversationColumns);
58
58
  this.deps.mouseController.renderedTargets.clear();
59
59
  this.deps.mouseController.renderedRowTexts.clear();
60
+ this.deps.mouseController.renderedLinks.clear();
60
61
  this.deps.mouseController.renderedRowBackgrounds.clear();
61
62
  this.deps.mouseController.renderedImageTargets.clear();
62
63
  this.deps.mouseController.statusModelTarget = undefined;
@@ -109,6 +110,8 @@ export class AppRenderController {
109
110
  this.deps.mouseController.renderedTargets.set(row, rendered.target);
110
111
  if (rendered?.imageTargets?.length)
111
112
  this.deps.mouseController.renderedImageTargets.set(row, rendered.imageTargets);
113
+ if (rendered?.links?.length)
114
+ this.deps.mouseController.renderedLinks.set(row, rendered.links);
112
115
  this.deps.mouseController.renderedRowTexts.set(row, rendered?.text ?? "");
113
116
  setRenderedBackground(row, rendered?.backgroundOverride);
114
117
  appendFrameOutput(row, this.renderFrameRow(row, this.deps.screenStyler.styleBaseLine(row, rendered, conversationColumns)));
@@ -128,6 +131,8 @@ export class AppRenderController {
128
131
  this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
129
132
  if (rendered.line?.imageTargets?.length)
130
133
  this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
134
+ if (rendered.line?.links?.length)
135
+ this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
131
136
  this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
132
137
  setRenderedBackground(row, rendered.line?.backgroundOverride);
133
138
  appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
@@ -182,6 +187,8 @@ export class AppRenderController {
182
187
  this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
183
188
  if (rendered.line?.imageTargets?.length)
184
189
  this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
190
+ if (rendered.line?.links?.length)
191
+ this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
185
192
  this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
186
193
  setRenderedBackground(row, rendered.line?.backgroundOverride);
187
194
  appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
@@ -97,6 +97,7 @@ export declare class AppMouseController {
97
97
  id: string;
98
98
  } | import("../types.js").ToastLineTarget | undefined>;
99
99
  readonly renderedRowTexts: Map<number, string>;
100
+ readonly renderedLinks: Map<number, readonly RenderedLink[]>;
100
101
  readonly renderedRowBackgrounds: Map<number, string>;
101
102
  readonly renderedImageTargets: Map<number, readonly ImageClickTarget[]>;
102
103
  statusModelTarget: StatusModelTarget | undefined;
@@ -18,6 +18,7 @@ export class AppMouseController {
18
18
  commandController;
19
19
  renderedTargets = new Map();
20
20
  renderedRowTexts = new Map();
21
+ renderedLinks = new Map();
21
22
  renderedRowBackgrounds = new Map();
22
23
  renderedImageTargets = new Map();
23
24
  statusModelTarget;
@@ -308,7 +309,7 @@ export class AppMouseController {
308
309
  const text = this.renderedRowTexts.get(event.y);
309
310
  if (!text)
310
311
  return undefined;
311
- for (const link of detectFileLinks(text, this.host.cwd())) {
312
+ for (const link of [...(this.renderedLinks.get(event.y) ?? []), ...detectFileLinks(text, this.host.cwd())]) {
312
313
  const startColumn = stringDisplayWidth(text.slice(0, link.start)) + 1;
313
314
  const endColumn = startColumn + stringDisplayWidth(text.slice(link.start, link.end));
314
315
  if (event.x >= startColumn && event.x < endColumn)
@@ -8,10 +8,16 @@ export type RenderedMarkdownLine = {
8
8
  end: number;
9
9
  bold: true;
10
10
  }[];
11
+ links?: readonly RenderedMarkdownLink[];
11
12
  heading?: boolean;
12
13
  sourceStart?: number;
13
14
  sourceEnd?: number;
14
15
  };
16
+ export type RenderedMarkdownLink = {
17
+ start: number;
18
+ end: number;
19
+ url: string;
20
+ };
15
21
  export type RenderedMarkdownTextLine = {
16
22
  text: string;
17
23
  copyText?: string;
@@ -21,6 +27,7 @@ export type RenderedMarkdownTextLine = {
21
27
  end: number;
22
28
  bold: true;
23
29
  }[] | undefined;
30
+ links?: readonly RenderedMarkdownLink[] | undefined;
24
31
  syntaxHighlight?: SyntaxLineHighlight | undefined;
25
32
  heading?: boolean;
26
33
  };
@@ -44,6 +44,7 @@ export function formatMarkdownTables(text, maxWidth) {
44
44
  export function renderMarkdownLine(text, start = 0) {
45
45
  const safeStart = Math.max(0, Math.min(text.length, start));
46
46
  const segments = [];
47
+ const links = [];
47
48
  let rendered = text.slice(0, safeStart);
48
49
  let index = safeStart;
49
50
  let inCode = false;
@@ -66,10 +67,20 @@ export function renderMarkdownLine(text, start = 0) {
66
67
  continue;
67
68
  }
68
69
  }
70
+ if (!inCode && char === "[" && text[index - 1] !== "!" && !isEscaped(text, index)) {
71
+ const link = markdownLinkAt(text, index);
72
+ if (link) {
73
+ const linkStart = rendered.length;
74
+ rendered += link.label;
75
+ links.push({ start: linkStart, end: rendered.length, url: link.url });
76
+ index = link.end;
77
+ continue;
78
+ }
79
+ }
69
80
  rendered += char;
70
81
  index += 1;
71
82
  }
72
- return { text: rendered, segments, ...(isHeading ? { heading: true } : {}) };
83
+ return { text: rendered, segments, ...(links.length > 0 ? { links } : {}), ...(isHeading ? { heading: true } : {}) };
73
84
  }
74
85
  export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
75
86
  const lines = [];
@@ -84,7 +95,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
84
95
  const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
85
96
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
86
97
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
87
- const logicalLine = markdownLine ?? { text: rawLine, segments: [] };
98
+ const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
88
99
  for (const wrapped of wrapRenderedMarkdownLine(logicalLine, width, options)) {
89
100
  const wrappedSyntaxHighlight = syntaxHighlight && wrapped.sourceStart !== undefined && wrapped.sourceEnd !== undefined
90
101
  ? {
@@ -103,6 +114,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
103
114
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
104
115
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
105
116
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
117
+ ...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
106
118
  ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
107
119
  ...(isHeadingLine ? { heading: true } : {}),
108
120
  });
@@ -141,11 +153,15 @@ function wrapRenderedMarkdownLine(line, width, options) {
141
153
  if (stringDisplayWidth(line.text) <= safeWidth)
142
154
  return [line];
143
155
  const ranges = wrapDisplayLineByWordsWithRanges(line.text, safeWidth, options);
156
+ const links = line.links ?? [];
144
157
  return ranges.map((range, index) => ({
145
158
  text: range.text,
146
159
  copyText: line.text.slice(range.start, ranges[index + 1]?.start ?? range.end),
147
160
  ...(index < ranges.length - 1 ? { continuesOnNextLine: true } : {}),
148
161
  segments: line.segments.flatMap((segment) => shiftSegmentToRange(segment, range.start, range.end)),
162
+ ...(links.length > 0
163
+ ? { links: links.flatMap((link) => shiftLinkToRange(link, range.start, range.end)) }
164
+ : {}),
149
165
  sourceStart: range.start,
150
166
  sourceEnd: range.end,
151
167
  }));
@@ -320,6 +336,47 @@ function shiftSegmentToRange(segment, rangeStart, rangeEnd) {
320
336
  return [];
321
337
  return [{ ...segment, start: start - rangeStart, end: end - rangeStart }];
322
338
  }
339
+ function shiftLinkToRange(link, rangeStart, rangeEnd) {
340
+ const start = Math.max(link.start, rangeStart);
341
+ const end = Math.min(link.end, rangeEnd);
342
+ if (end <= start)
343
+ return [];
344
+ return [{ ...link, start: start - rangeStart, end: end - rangeStart }];
345
+ }
346
+ function markdownLinkAt(text, start) {
347
+ const labelEnd = findUnescapedCharacter(text, "]", start + 1);
348
+ if (labelEnd <= start + 1 || text[labelEnd + 1] !== "(")
349
+ return undefined;
350
+ const destinationStart = labelEnd + 2;
351
+ let nestedParentheses = 0;
352
+ for (let index = destinationStart; index < text.length; index += 1) {
353
+ if (isEscaped(text, index))
354
+ continue;
355
+ const char = text[index] ?? "";
356
+ if (char === "(") {
357
+ nestedParentheses += 1;
358
+ continue;
359
+ }
360
+ if (char !== ")")
361
+ continue;
362
+ if (nestedParentheses > 0) {
363
+ nestedParentheses -= 1;
364
+ continue;
365
+ }
366
+ const url = text.slice(destinationStart, index);
367
+ if (!/^(?:file|https?):\/\/\S+$/u.test(url))
368
+ return undefined;
369
+ return { label: text.slice(start + 1, labelEnd), url, end: index + 1 };
370
+ }
371
+ return undefined;
372
+ }
373
+ function findUnescapedCharacter(text, character, start) {
374
+ for (let index = start; index < text.length; index += 1) {
375
+ if (text[index] === character && !isEscaped(text, index))
376
+ return index;
377
+ }
378
+ return -1;
379
+ }
323
380
  function parseMarkdownTableBlock(lines, start) {
324
381
  const header = parseMarkdownTableRow(lines[start] ?? "");
325
382
  if (!header)
@@ -257,13 +257,18 @@ to its trusted runner, so browser QA does not depend on a separately installed
257
257
  skill or CLI. The private workflow remains mandatory when configuration appends
258
258
  other isolated skills; parent and ordinary sub-agent sessions do not discover it.
259
259
 
260
- Keep named dev/staging auth profiles in project `.pi/qa_auth.jsonc` (there is no
261
- `/qa-auth` command). The private runner supports `form`, `cookie`, `localStorage`,
260
+ Public browser QA does not require an auth profile or `.pi/qa_auth.jsonc`: run it
261
+ with an explicit base URL, whose exact origin becomes the fail-closed allowlist.
262
+ The runner neither creates nor requests a credential file for that path.
263
+
264
+ For targets that actually require login, keep named dev/staging auth profiles in
265
+ project `.pi/qa_auth.jsonc` (there is no `/qa-auth` command). The private runner
266
+ supports `form`, `cookie`, `localStorage`,
262
267
  `sessionStorage`, `bearer`, and existing Playwright `storageState` auth. Every
263
- profile must declare exact `allowedOrigins`; select the profile id explicitly when
264
- spawning QA. On POSIX, keep the config at mode `0600`. During a run those origins
265
- also form the fail-closed HTTP(S)/WebSocket allowlist and service workers are
266
- blocked. Example:
268
+ profile must declare exact `allowedOrigins`; select the profile id explicitly only
269
+ for authenticated QA. On POSIX, keep the config at mode `0600`. During a run
270
+ those origins also form the fail-closed HTTP(S)/WebSocket allowlist and service
271
+ workers are blocked. Example:
267
272
 
268
273
  ```jsonc
269
274
  {
@@ -302,7 +307,9 @@ The runner validates the owning agent metadata and refuses flows outside that
302
307
  workspace; reusing an agent id clears stale browser QA files first. Trace archives
303
308
  have network records and non-image resources removed, then known
304
309
  configured/runtime credential values are redacted and verified before retention.
305
- Missing, ambiguous, rejected, or expired auth returns `QA_PROFILE_REQUIRED` or
310
+ Listing profiles when the auth file is absent returns an empty list without
311
+ creating a template. Only an explicit authenticated request may create the
312
+ private template. Missing, rejected, or expired selected auth returns
306
313
  `QA_AUTH_UPDATE_REQUIRED`, naming only the profile/file/reason needed for the
307
314
  parent to ask the user for an update and rerun. See
308
315
  `src/async-subagents/private-skills/browser-qa/references/qa-auth.example.jsonc`
@@ -4,8 +4,8 @@
4
4
 
5
5
  Provide a cheap, fast `browser-qa` async-subagent that reproduces browser bugs
6
6
  and proves fixes with deterministic assertions plus screenshot, video, and trace
7
- evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
8
- `openai-codex/gpt-5.4-mini`.
7
+ evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
8
+ `antigravity/gemini-3-flash-preview` and then `zai/glm-5.3`.
9
9
 
10
10
  ## Private skill isolation
11
11
 
@@ -26,11 +26,16 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
26
26
 
27
27
  ## Authentication contract
28
28
 
29
+ - Public browser QA requires no auth profile and does not create or require
30
+ `.pi/qa_auth.jsonc`. Its explicit base URL supplies the one exact allowed
31
+ origin, and the runner still blocks every other HTTP(S)/WebSocket origin.
29
32
  - Auth profiles live in project-local `.pi/qa_auth.jsonc` and are selected by
30
33
  explicit id. The file must be a real project-local file with mode `0600` on
31
34
  POSIX. Profile listings expose only `id`, description, and traits.
32
- - When authenticated QA first needs credentials and that file is absent, the
33
- runner creates a private empty template and returns `provide_credentials`.
35
+ - Listing profiles when the file is absent returns an empty list without side
36
+ effects. When authenticated QA explicitly requests credentials and that file
37
+ is absent, the runner creates a private empty template and returns
38
+ `provide_credentials`.
34
39
  The sub-agent must explicitly ask the user to fill the reported file and
35
40
  rerun QA; it must not read or edit the credential values itself.
36
41
  - Every profile requires one or more exact `allowedOrigins`. Secret-bearing auth
@@ -46,8 +51,8 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
46
51
  `.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Multiple profiles use
47
52
  separate browser contexts/evidence directories, and normal sub-agent shutdown
48
53
  or cleanup removes the whole workspace with its run.
49
- - Missing, ambiguous, rejected, or expired auth returns a machine-readable
50
- update-required/profile-required status naming only the profile id, config
54
+ - Missing, rejected, or expired explicitly selected auth returns a
55
+ machine-readable update-required status naming only the profile id, config
51
56
  file, and redacted reason. The parent asks the user to update the file and
52
57
  reruns; there is no `/qa-auth` command.
53
58
 
@@ -83,10 +88,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
83
88
  profile; ordinary profiles retain existing skill discovery behavior.
84
89
  3. Auth profile listing and all error output are redacted; model-authored input
85
90
  cannot execute code in the credential-bearing process.
86
- 4. Runner tests cover explicit profile selection, all auth modes, fail-closed
87
- origins, path/mode hardening, private empty-template creation, explicit
88
- credential requests, non-executable flows, and successful redacted evidence
89
- creation.
91
+ 4. Runner tests cover public execution without an auth file, explicit profile
92
+ selection, all auth modes, fail-closed origins, path/mode hardening, private
93
+ empty-template creation only on an explicit auth request, non-executable
94
+ flows, and successful redacted evidence creation.
90
95
  5. Browser QA flows/evidence live only inside the owning sub-agent directory;
91
96
  deleting the run removes them while persistent auth config/state remains.
92
97
  6. Completed test runs report clickable screenshot, video, and trace links
@@ -23,14 +23,19 @@ Never read, print, grep, copy, or edit credential values from
23
23
  3. Discover the requested target, expected behavior, and the smallest scenario
24
24
  that can prove it. If the target cannot be reached or started, report the
25
25
  concrete blocker instead of substituting static checks for browser QA.
26
- 4. Run `node <runner> profiles`. Choose a profile only when the task names its
27
- id or safe profile traits make the choice unambiguous. Otherwise stop with
28
- `QA_PROFILE_REQUIRED` and list only ids, descriptions, and traits.
26
+ 4. If the requested behavior requires authentication, run
27
+ `node <runner> profiles`. A missing auth config is valid and returns an empty
28
+ list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
29
+ and use public mode. Choose an auth profile only when the task names its id,
30
+ safe profile traits make the choice unambiguous, or a public run proves that
31
+ the requested page requires login.
29
32
  5. Inspect the target code and write a declarative JSONC flow under
30
33
  `$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or
31
34
  executable JavaScript in it.
32
- 6. Run:
33
- `node <runner> run --profile <id> --base-url <url> --flow <flow.jsonc>`.
35
+ 6. Run public QA with
36
+ `node <runner> run --base-url <url> --flow <flow.jsonc>`. The URL's exact
37
+ origin becomes the fail-closed allowlist. Only for authenticated QA, add
38
+ `--profile <id>`; the selected profile then owns the URL and allowlist.
34
39
  Profile id, URL, and flow path are non-secret; never pass credentials as
35
40
  arguments or environment variables.
36
41
  7. Report deterministic assertions and every artifact returned by the runner.
@@ -108,12 +113,19 @@ after login succeeds so credentials are not captured in the trace.
108
113
 
109
114
  ## Credentials and blocked runs
110
115
 
111
- If the runner returns `QA_AUTH_UPDATE_REQUIRED`, stop and explicitly report that
112
- browser QA requires credentials or an auth-config update. Ask the user to fill
113
- the reported file and rerun QA. If `templateCreated` is true, say that a private
114
- empty template was created at that path. Relay only the runner's profile, file,
115
- reason, action, and template-created state; never read the generated file or
116
- attempt to recover by exposing or replaying credentials.
116
+ Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
117
+ task explicitly requires authenticated behavior, or a public run reaches the
118
+ flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
119
+ `node <runner> profiles --require-auth`. Only this explicit authenticated path
120
+ may create the private empty template.
121
+
122
+ If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
123
+ and explicitly report that authenticated browser QA requires credentials or an
124
+ auth-config update. Ask the user to fill the reported file and rerun QA. If
125
+ `templateCreated` is true, say that a private empty template was created at that
126
+ path. Relay only the runner's profile, file, reason, action, and template-created
127
+ state; never read the generated file or attempt to recover by exposing or
128
+ replaying credentials.
117
129
 
118
130
  For any other blocked run, report the runner status and redacted reason. Do not
119
131
  claim that browser QA passed based on source inspection, unit tests, or a build.
@@ -13,7 +13,6 @@ const QA_WORKSPACE_RELATIVE = "browser-qa";
13
13
  const EVIDENCE_RELATIVE = "evidence";
14
14
  const FORM_VIDEO_STEP_DELAY_MS = 250;
15
15
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
16
- const EXIT_PROFILE_REQUIRED = 43;
17
16
  const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
18
17
  const AUTH_TEMPLATE = `{
19
18
  // Authenticated browser QA requires at least one project-local profile.
@@ -58,7 +57,7 @@ main().catch((error) => {
58
57
  writeStatus({
59
58
  status: statusError.status,
60
59
  profile: statusError.profileId,
61
- file: CONFIG_RELATIVE,
60
+ ...(statusError.status === "QA_AUTH_UPDATE_REQUIRED" ? { file: CONFIG_RELATIVE } : {}),
62
61
  reason: statusError.reason,
63
62
  ...statusError.details,
64
63
  });
@@ -68,15 +67,21 @@ main().catch((error) => {
68
67
  async function main() {
69
68
  const [command = "profiles", ...rawArgs] = process.argv.slice(2);
70
69
  const cwd = fs.realpathSync(process.cwd());
71
- const config = readAuthConfig(cwd);
72
70
  if (command === "profiles") {
73
- writeStatus({ status: "QA_PROFILES", profiles: safeProfiles(config.profiles) });
71
+ const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
72
+ if (rawArgs.length > 0 && !requireAuth) {
73
+ throw new QaStatusError("QA_RUN_FAILED", `invalid profiles argument: ${rawArgs[0]}`, 1);
74
+ }
75
+ const config = readAuthConfig(cwd, requireAuth);
76
+ writeStatus({ status: "QA_PROFILES", authConfigPresent: config.present, profiles: safeProfiles(config.profiles) });
74
77
  return;
75
78
  }
76
79
  if (command !== "run") throw new QaStatusError("QA_RUN_FAILED", `unknown command: ${command}`, 1);
77
80
 
78
81
  const args = parseArgs(rawArgs);
79
- const selected = selectProfile(config.profiles, args.profile);
82
+ const selected = args.profile
83
+ ? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
84
+ : createPublicProfile(args.baseUrl);
80
85
  const profileId = selected.id;
81
86
  const profile = selected.profile;
82
87
  const secrets = collectSecrets(profile.auth);
@@ -192,9 +197,10 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
192
197
  writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
193
198
  }
194
199
 
195
- function readAuthConfig(cwd) {
200
+ function readAuthConfig(cwd, required = false) {
196
201
  const candidate = path.join(cwd, CONFIG_RELATIVE);
197
202
  if (!fs.existsSync(candidate)) {
203
+ if (!required) return { present: false, profiles: {} };
198
204
  let templateCreated;
199
205
  try {
200
206
  templateCreated = createAuthTemplate(cwd);
@@ -222,7 +228,10 @@ function readAuthConfig(cwd) {
222
228
  if (errors.length > 0) {
223
229
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", `auth config is invalid JSONC (${printParseErrorCode(errors[0].error)})`, EXIT_AUTH_UPDATE_REQUIRED);
224
230
  }
225
- if (!isObject(value) || !isObject(value.profiles) || Object.keys(value.profiles).length === 0) {
231
+ if (!isObject(value) || !isObject(value.profiles)) {
232
+ throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth config must define a profiles object", EXIT_AUTH_UPDATE_REQUIRED);
233
+ }
234
+ if (required && Object.keys(value.profiles).length === 0) {
226
235
  throw new QaStatusError(
227
236
  "QA_AUTH_UPDATE_REQUIRED",
228
237
  "credentials are required; auth config must define a non-empty profiles object",
@@ -234,7 +243,7 @@ function readAuthConfig(cwd) {
234
243
  if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
235
244
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
236
245
  }
237
- return value;
246
+ return { ...value, present: true };
238
247
  }
239
248
 
240
249
  function safeProfiles(profiles) {
@@ -248,15 +257,6 @@ function safeProfiles(profiles) {
248
257
  }
249
258
 
250
259
  function selectProfile(profiles, requestedId) {
251
- if (!requestedId) {
252
- throw new QaStatusError(
253
- "QA_PROFILE_REQUIRED",
254
- "select one auth profile by id",
255
- EXIT_PROFILE_REQUIRED,
256
- undefined,
257
- { profiles: safeProfiles(profiles) },
258
- );
259
- }
260
260
  if (!isSafeName(requestedId) || !isObject(profiles[requestedId])) {
261
261
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "requested auth profile is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
262
262
  }
@@ -267,6 +267,26 @@ function selectProfile(profiles, requestedId) {
267
267
  return { id: requestedId, profile };
268
268
  }
269
269
 
270
+ function createPublicProfile(rawBaseUrl) {
271
+ if (typeof rawBaseUrl !== "string") {
272
+ throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
273
+ }
274
+ try {
275
+ const url = new URL(rawBaseUrl);
276
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
277
+ return {
278
+ id: "public",
279
+ profile: {
280
+ baseUrl: url.href,
281
+ allowedOrigins: [url.origin],
282
+ auth: { type: "none" },
283
+ },
284
+ };
285
+ } catch {
286
+ throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be an http(s) URL without embedded credentials", 1, "public");
287
+ }
288
+ }
289
+
270
290
  function normalizeAllowedOrigins(value, profileId) {
271
291
  if (!Array.isArray(value) || value.length === 0) {
272
292
  throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile must define allowedOrigins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
@@ -296,6 +316,8 @@ function normalizeBaseUrl(raw, allowedOrigins, profileId) {
296
316
 
297
317
  function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
298
318
  switch (auth.type) {
319
+ case "none":
320
+ break;
299
321
  case "cookie":
300
322
  if (!Array.isArray(auth.cookies) || auth.cookies.length === 0) throw authError(profileId, "cookie auth requires cookies");
301
323
  for (const cookie of auth.cookies) {
@@ -467,6 +489,7 @@ function isStringArray(value) {
467
489
 
468
490
  async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins }) {
469
491
  const auth = profile.auth;
492
+ if (auth.type === "none") return {};
470
493
  if (auth.type === "storageState") {
471
494
  if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
472
495
  const statePath = resolveExistingPrivateFile(cwd, auth.path, "storageState");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
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": {