pi-ui-extend 1.0.21 → 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.
- package/dist/app/rendering/conversation-entry-renderer.js +12 -3
- package/dist/app/rendering/render-controller.js +7 -0
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +2 -1
- package/dist/markdown-format.d.ts +7 -0
- package/dist/markdown-format.js +59 -2
- package/external/pi-tools-suite/README.md +25 -15
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +15 -10
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -5
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +2 -2
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +27 -13
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +9 -6
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +55 -52
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +7 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
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
|
};
|
package/dist/markdown-format.js
CHANGED
|
@@ -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)
|
|
@@ -247,23 +247,28 @@ For an oh-my-openagent-style workflow, run `/ultrawork` or `/ulw` to ask the par
|
|
|
247
247
|
|
|
248
248
|
### Private browser QA and project auth
|
|
249
249
|
|
|
250
|
-
The built-in `browser-qa` role runs on `
|
|
251
|
-
`
|
|
252
|
-
private skill under `src/async-subagents/private-skills/`,
|
|
253
|
-
discovery. The role's first-class `isolatedSkills` setting launches the child with
|
|
250
|
+
The built-in `browser-qa` role runs on `openai-codex/gpt-5.4-mini`, with
|
|
251
|
+
`antigravity/gemini-3-flash-preview` and then `zai/glm-5.3` as fallbacks. Its browser
|
|
252
|
+
workflow is an explicit private skill under `src/async-subagents/private-skills/`,
|
|
253
|
+
outside normal Pi skill discovery. The role's first-class `isolatedSkills` setting launches the child with
|
|
254
254
|
`--no-skills` plus one self-contained private workflow. It bundles the relevant
|
|
255
255
|
scenario-design, locator, waiting, assertion, evidence, and cleanup guidance next
|
|
256
256
|
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
|
-
|
|
261
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
also form the fail-closed HTTP(S)/WebSocket allowlist and service
|
|
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
|
{
|
|
@@ -288,10 +293,13 @@ blocked. Example:
|
|
|
288
293
|
}
|
|
289
294
|
```
|
|
290
295
|
|
|
291
|
-
Do not place credential values in prompts, QA flows, shell arguments, reports
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
296
|
+
Do not place credential values in prompts, QA flows, shell arguments, or reports.
|
|
297
|
+
The helper reads JSONC internally and emits only redacted statuses. For form auth,
|
|
298
|
+
video recording begins on the login page and captures the field-filling and submit
|
|
299
|
+
sequence; password inputs remain browser-masked, but the private video may show
|
|
300
|
+
other visible login identifiers and must be treated as sensitive evidence. Tracing
|
|
301
|
+
starts only after login succeeds and is sanitized before retention. The launcher
|
|
302
|
+
provides each browser QA process with its own
|
|
295
303
|
`.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Declarative flows,
|
|
296
304
|
screenshots, video, sanitized traces, and result manifests stay there, so normal
|
|
297
305
|
session shutdown or `subagents cleanup` removes them with the run directory.
|
|
@@ -299,7 +307,9 @@ The runner validates the owning agent metadata and refuses flows outside that
|
|
|
299
307
|
workspace; reusing an agent id clears stale browser QA files first. Trace archives
|
|
300
308
|
have network records and non-image resources removed, then known
|
|
301
309
|
configured/runtime credential values are redacted and verified before retention.
|
|
302
|
-
|
|
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
|
|
303
313
|
`QA_AUTH_UPDATE_REQUIRED`, naming only the profile/file/reason needed for the
|
|
304
314
|
parent to ask the user for an update and rerun. See
|
|
305
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 `
|
|
8
|
-
`
|
|
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
|
-
-
|
|
33
|
-
|
|
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,
|
|
50
|
-
update-required
|
|
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
|
|
87
|
-
origins, path/mode hardening, private
|
|
88
|
-
|
|
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
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"research": { "model": "zai/glm-5-turbo", "thinking": "low" },
|
|
117
117
|
"docs": { "model": "zai/glm-4.5-air", "thinking": "low" },
|
|
118
118
|
"frontend": { "model": "antigravity/gemini-3-flash-preview", "fallbackModels": ["zai/glm-5.3"], "thinking": "medium" },
|
|
119
|
-
"browser-qa": { "model": "
|
|
119
|
+
"browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
|
|
120
120
|
"tests": { "model": "zai/glm-5-turbo", "thinking": "medium" },
|
|
121
121
|
"review": { "model": "zai/glm-5.3", "thinking": "high" },
|
|
122
122
|
"implement": { "model": "zai/glm-5.3", "thinking": "high" },
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"research": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "low" },
|
|
133
133
|
"docs": { "model": "openai-codex/gpt-5.6-luna", "fallbackModels": ["zai/glm-4.5-air"], "thinking": "low" },
|
|
134
134
|
"frontend": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
|
|
135
|
-
"browser-qa": { "model": "
|
|
135
|
+
"browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
|
|
136
136
|
"tests": { "model": "openai-codex/gpt-5.6-terra", "fallbackModels": ["zai/glm-5-turbo"], "thinking": "medium" },
|
|
137
137
|
"review": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
|
|
138
138
|
"implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
"research": { "model": "antigravity/gemini-3.1-pro-preview", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "medium" },
|
|
149
149
|
"docs": { "model": "antigravity/gemini-2.5-flash", "fallbackModels": ["openai-codex/gpt-5.3-codex-spark", "zai/glm-4.5-air"], "thinking": "medium" },
|
|
150
150
|
"frontend": { "model": "antigravity/gemini-3.1-pro-preview-customtools", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5.3"], "thinking": "low" },
|
|
151
|
-
"browser-qa": { "model": "
|
|
151
|
+
"browser-qa": { "model": "openai-codex/gpt-5.4-mini", "fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"], "thinking": "medium" },
|
|
152
152
|
"tests": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.4-mini", "zai/glm-5-turbo"], "thinking": "high" },
|
|
153
153
|
"review": { "model": "antigravity/antigravity-claude-sonnet-4-6", "fallbackModels": ["openai-codex/gpt-5.6-sol", "zai/glm-5.3"], "thinking": "high" },
|
|
154
154
|
"implement": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"], "thinking": "high" },
|
|
@@ -199,8 +199,8 @@
|
|
|
199
199
|
|
|
200
200
|
"browser-qa": {
|
|
201
201
|
"description": "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
|
|
202
|
-
"model": "
|
|
203
|
-
"fallbackModels": ["
|
|
202
|
+
"model": "openai-codex/gpt-5.4-mini",
|
|
203
|
+
"fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
|
|
204
204
|
"thinking": "medium",
|
|
205
205
|
"tools": ["read", "grep", "bash"]
|
|
206
206
|
},
|
|
@@ -207,8 +207,8 @@ const BUILTIN_CONFIG: SubagentConfig = {
|
|
|
207
207
|
},
|
|
208
208
|
"browser-qa": {
|
|
209
209
|
description: "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
|
|
210
|
-
model: "
|
|
211
|
-
fallbackModels: ["
|
|
210
|
+
model: "openai-codex/gpt-5.4-mini",
|
|
211
|
+
fallbackModels: ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
|
|
212
212
|
thinking: "medium",
|
|
213
213
|
tools: ["read", "grep", "bash"],
|
|
214
214
|
isolatedSkills: [getBrowserQaSkillPath()],
|
|
@@ -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.
|
|
27
|
-
|
|
28
|
-
|
|
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 --
|
|
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.
|
|
@@ -101,17 +106,26 @@ isolated browser context and exclusive evidence directory; the runner closes
|
|
|
101
106
|
all owned browser resources on success and failure. Flows, screenshots, video,
|
|
102
107
|
sanitized traces, and runner result manifests remain under
|
|
103
108
|
`$PI_SUBAGENT_AGENT_DIR/browser-qa/` so normal sub-agent shutdown or cleanup
|
|
104
|
-
deletes them with the run directory.
|
|
105
|
-
|
|
109
|
+
deletes them with the run directory. For form auth, recording starts on the login
|
|
110
|
+
page and includes field filling and submission; password inputs remain masked,
|
|
111
|
+
but the private video may show visible login identifiers. Tracing starts only
|
|
112
|
+
after login succeeds so credentials are not captured in the trace.
|
|
106
113
|
|
|
107
114
|
## Credentials and blocked runs
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
115
129
|
|
|
116
130
|
For any other blocked run, report the runner status and redacted reason. Do not
|
|
117
131
|
claim that browser QA passed based on source inspection, unit tests, or a build.
|
|
@@ -55,13 +55,16 @@ stale credentials into an explicit update request instead of misreporting a
|
|
|
55
55
|
product regression.
|
|
56
56
|
|
|
57
57
|
Do not encode credentials, tokens, storage values, or login form secrets in the
|
|
58
|
-
flow. The trusted runner applies the selected profile internally
|
|
59
|
-
|
|
58
|
+
flow. The trusted runner applies the selected profile internally. For form auth,
|
|
59
|
+
video starts on the login page and includes field filling and submission; password
|
|
60
|
+
inputs remain masked, but visible identifiers can appear, so treat the video as
|
|
61
|
+
sensitive private evidence. Tracing starts only after login succeeds and is
|
|
62
|
+
sanitized before retention.
|
|
60
63
|
|
|
61
64
|
## Evidence strategy
|
|
62
65
|
|
|
63
|
-
The runner always attempts a final or failure screenshot, records video
|
|
64
|
-
creates a sanitized trace
|
|
66
|
+
The runner always attempts a final or failure screenshot, records video from the
|
|
67
|
+
first page, and creates a sanitized post-auth trace. Add named `screenshot`
|
|
65
68
|
steps only at states that materially help explain the result—for example before
|
|
66
69
|
and after a destructive interaction, or when a transient success message is
|
|
67
70
|
the oracle.
|
|
@@ -102,5 +105,5 @@ result manifest inside `$PI_SUBAGENT_AGENT_DIR/browser-qa/`. The launcher owns
|
|
|
102
105
|
that path and the runner validates it before opening a browser. Do not override
|
|
103
106
|
the environment path or copy evidence into shared `.pi/qa-runs`/`.pi/qa-flows`
|
|
104
107
|
directories: the agent-local workspace is intentionally removed by the normal
|
|
105
|
-
sub-agent shutdown and cleanup lifecycle. Authentication config
|
|
106
|
-
|
|
108
|
+
sub-agent shutdown and cleanup lifecycle. Authentication config remains a
|
|
109
|
+
separate persistent input under project `.pi/`.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
3
|
import fs from "node:fs";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { createRequire } from "node:module";
|
|
@@ -9,12 +8,11 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
|
9
8
|
import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
|
|
10
9
|
|
|
11
10
|
const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
|
|
12
|
-
const STATE_RELATIVE = path.join(".pi", "qa-auth-state");
|
|
13
11
|
const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
|
|
14
12
|
const QA_WORKSPACE_RELATIVE = "browser-qa";
|
|
15
13
|
const EVIDENCE_RELATIVE = "evidence";
|
|
14
|
+
const FORM_VIDEO_STEP_DELAY_MS = 250;
|
|
16
15
|
const EXIT_AUTH_UPDATE_REQUIRED = 42;
|
|
17
|
-
const EXIT_PROFILE_REQUIRED = 43;
|
|
18
16
|
const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
|
|
19
17
|
const AUTH_TEMPLATE = `{
|
|
20
18
|
// Authenticated browser QA requires at least one project-local profile.
|
|
@@ -59,7 +57,7 @@ main().catch((error) => {
|
|
|
59
57
|
writeStatus({
|
|
60
58
|
status: statusError.status,
|
|
61
59
|
profile: statusError.profileId,
|
|
62
|
-
file: CONFIG_RELATIVE,
|
|
60
|
+
...(statusError.status === "QA_AUTH_UPDATE_REQUIRED" ? { file: CONFIG_RELATIVE } : {}),
|
|
63
61
|
reason: statusError.reason,
|
|
64
62
|
...statusError.details,
|
|
65
63
|
});
|
|
@@ -69,15 +67,21 @@ main().catch((error) => {
|
|
|
69
67
|
async function main() {
|
|
70
68
|
const [command = "profiles", ...rawArgs] = process.argv.slice(2);
|
|
71
69
|
const cwd = fs.realpathSync(process.cwd());
|
|
72
|
-
const config = readAuthConfig(cwd);
|
|
73
70
|
if (command === "profiles") {
|
|
74
|
-
|
|
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) });
|
|
75
77
|
return;
|
|
76
78
|
}
|
|
77
79
|
if (command !== "run") throw new QaStatusError("QA_RUN_FAILED", `unknown command: ${command}`, 1);
|
|
78
80
|
|
|
79
81
|
const args = parseArgs(rawArgs);
|
|
80
|
-
const selected =
|
|
82
|
+
const selected = args.profile
|
|
83
|
+
? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
|
|
84
|
+
: createPublicProfile(args.baseUrl);
|
|
81
85
|
const profileId = selected.id;
|
|
82
86
|
const profile = selected.profile;
|
|
83
87
|
const secrets = collectSecrets(profile.auth);
|
|
@@ -112,7 +116,7 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
|
|
|
112
116
|
const runtimeSecrets = collectSecrets(profile.auth);
|
|
113
117
|
const tracePath = path.join(evidenceDir, "trace.zip");
|
|
114
118
|
try {
|
|
115
|
-
const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins
|
|
119
|
+
const contextOptions = await contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins });
|
|
116
120
|
context = await browser.newContext({
|
|
117
121
|
...contextOptions,
|
|
118
122
|
baseURL,
|
|
@@ -122,10 +126,11 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
|
|
|
122
126
|
});
|
|
123
127
|
await installOriginGuard(context, allowedOrigins, profile.auth);
|
|
124
128
|
await applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId);
|
|
125
|
-
runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
|
|
126
|
-
await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
|
|
127
129
|
page = await context.newPage();
|
|
128
130
|
video = page.video();
|
|
131
|
+
await applyFormAuth(page, profile.auth, allowedOrigins, profileId);
|
|
132
|
+
runtimeSecrets.push(...collectStorageStateSecrets(await context.storageState()));
|
|
133
|
+
await context.tracing.start({ screenshots: true, snapshots: true, sources: false });
|
|
129
134
|
await executeFlow({ page, context, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets });
|
|
130
135
|
await assertPageDoesNotExposeSecrets(page, runtimeSecrets, profileId);
|
|
131
136
|
await page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true });
|
|
@@ -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)
|
|
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) {
|
|
@@ -465,24 +487,16 @@ function isStringArray(value) {
|
|
|
465
487
|
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === "string");
|
|
466
488
|
}
|
|
467
489
|
|
|
468
|
-
async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins
|
|
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");
|
|
473
496
|
return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
|
|
474
497
|
}
|
|
475
498
|
if (auth.type === "form") {
|
|
476
|
-
|
|
477
|
-
const statePath = path.join(stateDirectory, `${profileId}-${authCacheKey(auth, allowedOrigins)}.json`);
|
|
478
|
-
if (!fs.existsSync(statePath)) {
|
|
479
|
-
ensurePrivateDirectory(cwd, stateDirectory);
|
|
480
|
-
for (const name of fs.readdirSync(stateDirectory)) {
|
|
481
|
-
if (name.startsWith(`${profileId}-`) && name.endsWith(".json")) fs.rmSync(path.join(stateDirectory, name), { force: true });
|
|
482
|
-
}
|
|
483
|
-
await createFormState({ statePath, auth, allowedOrigins, browser, profileId });
|
|
484
|
-
}
|
|
485
|
-
return { storageState: filteredStorageState(statePath, allowedOrigins, profileId) };
|
|
499
|
+
return {};
|
|
486
500
|
}
|
|
487
501
|
if (!["cookie", "localStorage", "sessionStorage", "bearer"].includes(auth.type)) {
|
|
488
502
|
throw authError(profileId, `unsupported auth type: ${auth.type}`);
|
|
@@ -490,39 +504,36 @@ async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins,
|
|
|
490
504
|
return {};
|
|
491
505
|
}
|
|
492
506
|
|
|
493
|
-
async function
|
|
507
|
+
async function applyFormAuth(page, auth, allowedOrigins, profileId) {
|
|
508
|
+
if (auth.type !== "form") return;
|
|
494
509
|
if (typeof auth.loginUrl !== "string" || !isAllowedUrl(auth.loginUrl, allowedOrigins)) {
|
|
495
510
|
throw authError(profileId, "form loginUrl is missing or outside allowedOrigins");
|
|
496
511
|
}
|
|
497
512
|
if (!Array.isArray(auth.fields) || auth.fields.length === 0 || typeof auth.submitSelector !== "string") {
|
|
498
513
|
throw authError(profileId, "form fields or submitSelector are missing");
|
|
499
514
|
}
|
|
500
|
-
const context = await browser.newContext();
|
|
501
515
|
try {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
516
|
+
const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
|
|
517
|
+
page.setDefaultTimeout(timeout);
|
|
518
|
+
page.setDefaultNavigationTimeout(timeout);
|
|
519
|
+
await page.goto(auth.loginUrl, { timeout });
|
|
520
|
+
await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
|
|
505
521
|
for (const field of auth.fields) {
|
|
506
522
|
if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
|
|
507
523
|
throw authError(profileId, "form fields must contain selector/value strings");
|
|
508
524
|
}
|
|
509
525
|
await page.locator(field.selector).fill(field.value);
|
|
526
|
+
await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
|
|
510
527
|
}
|
|
511
528
|
await page.locator(auth.submitSelector).click();
|
|
512
|
-
const timeout = finitePositive(auth.timeoutMs) ?? 15_000;
|
|
513
529
|
if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
|
|
514
530
|
if (isObject(auth.success) && typeof auth.success.selector === "string") await page.locator(auth.success.selector).waitFor({ timeout });
|
|
515
531
|
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
|
|
516
532
|
throw authError(profileId, "form success.url or success.selector is required");
|
|
517
533
|
}
|
|
518
|
-
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
519
|
-
await context.storageState({ path: statePath });
|
|
520
|
-
fs.chmodSync(statePath, 0o600);
|
|
521
534
|
} catch (error) {
|
|
522
535
|
if (error instanceof QaStatusError) throw error;
|
|
523
536
|
throw authError(profileId, "form login was rejected or did not reach the configured success condition");
|
|
524
|
-
} finally {
|
|
525
|
-
await context.close().catch(() => {});
|
|
526
537
|
}
|
|
527
538
|
}
|
|
528
539
|
|
|
@@ -550,10 +561,6 @@ async function applyContextAuth(context, auth, allowedOrigins, baseURL, profileI
|
|
|
550
561
|
}
|
|
551
562
|
}
|
|
552
563
|
|
|
553
|
-
function authCacheKey(auth, allowedOrigins) {
|
|
554
|
-
return createHash("sha256").update(JSON.stringify({ auth, allowedOrigins })).digest("hex").slice(0, 16);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
564
|
async function installOriginGuard(context, allowedOrigins, auth) {
|
|
558
565
|
await context.route("**/*", async (route) => {
|
|
559
566
|
const request = route.request();
|
|
@@ -890,10 +897,6 @@ function createExclusivePrivateDirectory(cwd, target) {
|
|
|
890
897
|
createPrivateDirectory(cwd, target, true);
|
|
891
898
|
}
|
|
892
899
|
|
|
893
|
-
function ensurePrivateDirectory(cwd, target) {
|
|
894
|
-
createPrivateDirectory(cwd, target, false);
|
|
895
|
-
}
|
|
896
|
-
|
|
897
900
|
function createPrivateDirectory(cwd, target, exclusive) {
|
|
898
901
|
const root = fs.realpathSync(cwd);
|
|
899
902
|
const resolved = path.resolve(target);
|
|
@@ -300,6 +300,13 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
|
|
|
300
300
|
"When no mockup exists, choose a clear aesthetic direction and explain it briefly. Verify with targeted build/lint/tests or screenshot-relevant checks when possible."
|
|
301
301
|
]
|
|
302
302
|
},
|
|
303
|
+
"browser-qa": {
|
|
304
|
+
"description": "Use for browser-based visual QA: reproduce UI bugs and verify fixes with deterministic assertions, screenshots, video, and traces.",
|
|
305
|
+
"model": "openai-codex/gpt-5.4-mini",
|
|
306
|
+
"fallbackModels": ["antigravity/gemini-3-flash-preview", "zai/glm-5.3"],
|
|
307
|
+
"thinking": "medium",
|
|
308
|
+
"tools": ["read", "grep", "bash"]
|
|
309
|
+
},
|
|
303
310
|
"tests": {
|
|
304
311
|
"description": "Use for tests: locate coverage, find gaps, run/check targeted test commands, diagnose failing tests.",
|
|
305
312
|
"model": "zai/glm-5-turbo",
|
package/package.json
CHANGED