pi-ui-extend 1.0.28 → 1.0.31
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/app.d.ts +1 -0
- package/dist/app/app.js +12 -0
- package/dist/app/extensions/extension-ui-controller.d.ts +6 -1
- package/dist/app/extensions/extension-ui-controller.js +16 -7
- package/dist/app/types.d.ts +7 -0
- package/dist/bundled-extensions/question/index.js +2 -1
- package/dist/bundled-extensions/question/result.d.ts +2 -2
- package/dist/bundled-extensions/question/result.js +21 -9
- package/dist/bundled-extensions/question/tui.js +50 -12
- package/dist/bundled-extensions/question/types.d.ts +14 -1
- package/dist/sdk.d.ts +1 -1
- package/external/pi-tools-suite/README.md +1 -1
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +42 -6
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +78 -0
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +308 -3
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +1 -1
- package/package.json +1 -1
package/dist/app/app.d.ts
CHANGED
|
@@ -86,6 +86,7 @@ export declare class PiUiExtendApp {
|
|
|
86
86
|
private setInput;
|
|
87
87
|
private resetInputAfterProgrammaticEdit;
|
|
88
88
|
private restoreTabInputState;
|
|
89
|
+
private restoreEditorSnapshot;
|
|
89
90
|
private clearPersistedInputDraft;
|
|
90
91
|
private insertVoiceTranscript;
|
|
91
92
|
private pasteInternalClipboard;
|
package/dist/app/app.js
CHANGED
|
@@ -345,6 +345,13 @@ export class PiUiExtendApp {
|
|
|
345
345
|
restoreSessionStatus: () => this.restoreSessionStatus(),
|
|
346
346
|
setInput: (value) => this.setInput(value),
|
|
347
347
|
getInput: () => this.input,
|
|
348
|
+
setInputState: (state) => this.restoreTabInputState(state),
|
|
349
|
+
getInputState: () => this.inputEditor.draftState,
|
|
350
|
+
setInputSnapshot: (snapshot) => this.restoreEditorSnapshot(snapshot),
|
|
351
|
+
getInputSnapshot: () => ({
|
|
352
|
+
text: this.inputEditor.expandedText,
|
|
353
|
+
images: this.inputEditor.images.map((image) => ({ ...image })),
|
|
354
|
+
}),
|
|
348
355
|
get entries() { return app.entries; },
|
|
349
356
|
deleteConversationEntry: (entryId) => this.conversationViewport.deleteEntry(entryId),
|
|
350
357
|
});
|
|
@@ -982,6 +989,11 @@ export class PiUiExtendApp {
|
|
|
982
989
|
this.inputEditor.restoreDraftState(state);
|
|
983
990
|
this.autocompleteController.dispose();
|
|
984
991
|
}
|
|
992
|
+
restoreEditorSnapshot(snapshot) {
|
|
993
|
+
this.restoreTabInputState({ text: snapshot.text, cursor: snapshot.text.length });
|
|
994
|
+
for (const image of snapshot.images)
|
|
995
|
+
this.inputEditor.attachImage(image.data, image.mimeType);
|
|
996
|
+
}
|
|
985
997
|
async clearPersistedInputDraft() {
|
|
986
998
|
await this.tabsController.setInputStateForTab(this.tabsController.activeInputTabId(), { text: "", cursor: 0 });
|
|
987
999
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Theme } from "../../theme.js";
|
|
2
2
|
import { type ToastKind, type ToastNotifier } from "../../ui.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { InputEditorDraftState } from "../../input-editor.js";
|
|
4
|
+
import type { Entry, ExtensionInputMouseEvent, ExtensionWidgetRegistration, ExtensionWidgetTheme, PixExtensionUIContext, PixEditorSnapshot, PixMenuController, WidgetPlacement, WidgetTuiHandle } from "../types.js";
|
|
4
5
|
export type ExtensionTerminalInputResult = {
|
|
5
6
|
consume: boolean;
|
|
6
7
|
data?: string;
|
|
@@ -21,6 +22,10 @@ export type ExtensionUiControllerHost = {
|
|
|
21
22
|
restoreSessionStatus(): void;
|
|
22
23
|
setInput(value: string): void;
|
|
23
24
|
getInput(): string;
|
|
25
|
+
setInputState(state: InputEditorDraftState): void;
|
|
26
|
+
getInputState(): InputEditorDraftState;
|
|
27
|
+
setInputSnapshot(snapshot: PixEditorSnapshot): void;
|
|
28
|
+
getInputSnapshot(): PixEditorSnapshot;
|
|
24
29
|
readonly entries: readonly Entry[];
|
|
25
30
|
deleteConversationEntry(entryId: string): void;
|
|
26
31
|
};
|
|
@@ -242,16 +242,25 @@ export class ExtensionUiController {
|
|
|
242
242
|
pasteToEditor: (text) => {
|
|
243
243
|
if (!this.isScopeActive(contextScopeKey))
|
|
244
244
|
return;
|
|
245
|
-
this.host.
|
|
245
|
+
this.host.setInputState({ text, cursor: text.length });
|
|
246
246
|
renderIfRunning();
|
|
247
247
|
},
|
|
248
248
|
setEditorText: (text) => {
|
|
249
249
|
if (!this.isScopeActive(contextScopeKey))
|
|
250
250
|
return;
|
|
251
|
-
this.host.
|
|
251
|
+
this.host.setInputState({ text, cursor: text.length });
|
|
252
252
|
renderIfRunning();
|
|
253
253
|
},
|
|
254
254
|
getEditorText: () => this.isScopeActive(contextScopeKey) ? this.host.getInput() : "",
|
|
255
|
+
getEditorSnapshot: () => this.isScopeActive(contextScopeKey)
|
|
256
|
+
? this.host.getInputSnapshot()
|
|
257
|
+
: { text: "", images: [] },
|
|
258
|
+
setEditorSnapshot: (snapshot) => {
|
|
259
|
+
if (!this.isScopeActive(contextScopeKey))
|
|
260
|
+
return;
|
|
261
|
+
this.host.setInputSnapshot(snapshot);
|
|
262
|
+
renderIfRunning();
|
|
263
|
+
},
|
|
255
264
|
editor: async (title, prefill) => await this.editorDialog(title, prefill, contextScopeKey),
|
|
256
265
|
addAutocompleteProvider: () => undefined,
|
|
257
266
|
setEditorComponent: () => undefined,
|
|
@@ -396,7 +405,7 @@ export class ExtensionUiController {
|
|
|
396
405
|
const scopeKey = this.normalizeScopeKey(options.scopeKey);
|
|
397
406
|
if (this.activeCustomUis.has(scopeKey))
|
|
398
407
|
throw new Error("Another extension custom UI is already active.");
|
|
399
|
-
const savedInput = options.savedInput ?? (this.isScopeActive(scopeKey) ? this.host.
|
|
408
|
+
const savedInput = options.savedInput ?? (this.isScopeActive(scopeKey) ? this.host.getInputState() : undefined);
|
|
400
409
|
return await new Promise((resolve, reject) => {
|
|
401
410
|
const active = {
|
|
402
411
|
key: CUSTOM_UI_WIDGET_KEY,
|
|
@@ -408,7 +417,7 @@ export class ExtensionUiController {
|
|
|
408
417
|
};
|
|
409
418
|
this.activeCustomUis.set(scopeKey, active);
|
|
410
419
|
if (options.editorInput !== undefined)
|
|
411
|
-
this.host.
|
|
420
|
+
this.host.setInputState({ text: options.editorInput, cursor: options.editorInput.length });
|
|
412
421
|
const done = (value) => {
|
|
413
422
|
if (active.settled || this.activeCustomUis.get(scopeKey) !== active)
|
|
414
423
|
return;
|
|
@@ -449,8 +458,8 @@ export class ExtensionUiController {
|
|
|
449
458
|
return;
|
|
450
459
|
active.settled = true;
|
|
451
460
|
this.activeCustomUis.delete(active.scopeKey);
|
|
452
|
-
if (this.isScopeActive(active.scopeKey) && active.savedInput !== undefined
|
|
453
|
-
this.host.
|
|
461
|
+
if (this.isScopeActive(active.scopeKey) && active.savedInput !== undefined) {
|
|
462
|
+
this.host.setInputState(active.savedInput);
|
|
454
463
|
}
|
|
455
464
|
try {
|
|
456
465
|
active.component?.dispose?.();
|
|
@@ -477,7 +486,7 @@ export class ExtensionUiController {
|
|
|
477
486
|
return undefined;
|
|
478
487
|
const active = this.activeCustomUis.get(scopeKey);
|
|
479
488
|
if (active && active.savedInput === undefined)
|
|
480
|
-
active.savedInput = this.host.
|
|
489
|
+
active.savedInput = this.host.getInputState();
|
|
481
490
|
return active;
|
|
482
491
|
}
|
|
483
492
|
isScopeActive(scopeKey) {
|
package/dist/app/types.d.ts
CHANGED
|
@@ -437,6 +437,11 @@ export type AboveInputRenderer = {
|
|
|
437
437
|
set(key: string, content: ExtensionWidgetContent): void;
|
|
438
438
|
clear(key: string): void;
|
|
439
439
|
};
|
|
440
|
+
/** Attachment-aware editor content with virtual paste/image tags resolved. */
|
|
441
|
+
export type PixEditorSnapshot = {
|
|
442
|
+
text: string;
|
|
443
|
+
images: readonly ImageContent[];
|
|
444
|
+
};
|
|
440
445
|
export type PixExtensionUIContext = Omit<ExtensionUIContext, "notify"> & {
|
|
441
446
|
notify(message: string, type?: ToastKind): void;
|
|
442
447
|
toast: ToastNotifier;
|
|
@@ -444,6 +449,8 @@ export type PixExtensionUIContext = Omit<ExtensionUIContext, "notify"> & {
|
|
|
444
449
|
renderAboveInput(key: string, content: ExtensionWidgetContent): void;
|
|
445
450
|
showMenu<T>(items: readonly PixMenuItem<T>[], options: PixMenuOptions): Promise<T | undefined>;
|
|
446
451
|
menu: PixMenuController;
|
|
452
|
+
getEditorSnapshot(): PixEditorSnapshot;
|
|
453
|
+
setEditorSnapshot(snapshot: PixEditorSnapshot): void;
|
|
447
454
|
};
|
|
448
455
|
export type WidgetTuiHandle = {
|
|
449
456
|
requestRender(force?: boolean): void;
|
|
@@ -20,7 +20,8 @@ export default function questionExtension(pi) {
|
|
|
20
20
|
const selections = await runQuestionnaire(questions, ctx);
|
|
21
21
|
if (selections == null)
|
|
22
22
|
return createQuestionToolResult(createCanceledQuestionResult("user_canceled"), questions);
|
|
23
|
-
|
|
23
|
+
const images = selections.flatMap((selection) => "customText" in selection ? selection.images ?? [] : []);
|
|
24
|
+
return createQuestionToolResult(createSuccessfulQuestionResult(questions, selections), questions, images);
|
|
24
25
|
},
|
|
25
26
|
});
|
|
26
27
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { CanceledQuestionResult, NormalizedQuestion, QuestionResultDetails, QuestionSelection, QuestionToolResult, SuccessfulQuestionResult } from "./types.js";
|
|
1
|
+
import type { CanceledQuestionResult, NormalizedQuestion, QuestionImageContent, QuestionResultDetails, QuestionSelection, QuestionToolResult, SuccessfulQuestionResult } from "./types.js";
|
|
2
2
|
export declare function createSuccessfulQuestionResult(questions: NormalizedQuestion[], selections: QuestionSelection[]): SuccessfulQuestionResult;
|
|
3
3
|
export declare function createCanceledQuestionResult(reason: CanceledQuestionResult["reason"], questions?: NormalizedQuestion[]): CanceledQuestionResult;
|
|
4
4
|
export declare function createFallbackPrompt(questions: NormalizedQuestion[]): string;
|
|
5
5
|
export declare function summarizeQuestionResult(result: QuestionResultDetails, questions?: NormalizedQuestion[]): string;
|
|
6
|
-
export declare function createQuestionToolResult(details: QuestionResultDetails, questions?: NormalizedQuestion[]): QuestionToolResult;
|
|
6
|
+
export declare function createQuestionToolResult(details: QuestionResultDetails, questions?: NormalizedQuestion[], images?: QuestionImageContent[]): QuestionToolResult;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { throwInvalid } from "./contract.js";
|
|
2
|
+
function formatAttachedImages(count) {
|
|
3
|
+
return `${count} image${count === 1 ? "" : "s"} attached`;
|
|
4
|
+
}
|
|
2
5
|
export function createSuccessfulQuestionResult(questions, selections) {
|
|
3
6
|
const selectionsById = new Map(selections.map((selection) => [selection.id, selection]));
|
|
4
7
|
return {
|
|
@@ -8,13 +11,16 @@ export function createSuccessfulQuestionResult(questions, selections) {
|
|
|
8
11
|
throwInvalid(`Missing answer for question "${question.id}".`, "Retry after collecting one answer for each normalized question.");
|
|
9
12
|
if ("customText" in selection) {
|
|
10
13
|
const customText = selection.customText.trim();
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
const imageCount = selection.images?.length ?? 0;
|
|
15
|
+
if (!customText && imageCount === 0)
|
|
16
|
+
throwInvalid(`Custom Answer for question "${question.id}" is empty after trimming.`, "Retry with non-empty Custom Answer text, an image, or choose a predefined Choice.");
|
|
17
|
+
const label = customText || formatAttachedImages(imageCount);
|
|
13
18
|
return {
|
|
14
19
|
id: question.id,
|
|
15
|
-
value: customText,
|
|
16
|
-
label
|
|
20
|
+
value: customText || label,
|
|
21
|
+
label,
|
|
17
22
|
wasCustom: true,
|
|
23
|
+
...(imageCount > 0 ? { imageCount } : {}),
|
|
18
24
|
};
|
|
19
25
|
}
|
|
20
26
|
const choiceIndex = question.choices.findIndex((choice) => choice.value === selection.choiceValue);
|
|
@@ -68,17 +74,23 @@ export function summarizeQuestionResult(result, questions = []) {
|
|
|
68
74
|
const questionLabels = new Map(questions.map((question) => [question.id, question.label]));
|
|
69
75
|
return `question answers: ${result.answers.map((answer) => {
|
|
70
76
|
const questionLabel = questionLabels.get(answer.id) ?? answer.id;
|
|
71
|
-
if (answer.wasCustom)
|
|
72
|
-
|
|
77
|
+
if (answer.wasCustom) {
|
|
78
|
+
const imageLabel = answer.imageCount ? formatAttachedImages(answer.imageCount) : undefined;
|
|
79
|
+
const imageSuffix = imageLabel && answer.label !== imageLabel ? `; ${imageLabel}` : "";
|
|
80
|
+
return `${questionLabel}: ${answer.label} (custom answer${imageSuffix})`;
|
|
81
|
+
}
|
|
73
82
|
return `${questionLabel}: ${answer.label} (choice ${answer.index})`;
|
|
74
83
|
}).join("; ")}.`;
|
|
75
84
|
}
|
|
76
|
-
export function createQuestionToolResult(details, questions = []) {
|
|
85
|
+
export function createQuestionToolResult(details, questions = [], images = []) {
|
|
77
86
|
return {
|
|
78
|
-
content: [
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
79
89
|
type: "text",
|
|
80
90
|
text: summarizeQuestionResult(details, questions),
|
|
81
|
-
}
|
|
91
|
+
},
|
|
92
|
+
...images.map((image) => ({ ...image })),
|
|
93
|
+
],
|
|
82
94
|
details,
|
|
83
95
|
};
|
|
84
96
|
}
|
|
@@ -97,6 +97,9 @@ function formatHeader(title, width) {
|
|
|
97
97
|
function clampIndex(index, length) {
|
|
98
98
|
return Math.max(0, Math.min(Math.max(0, length - 1), index));
|
|
99
99
|
}
|
|
100
|
+
function formatAttachedImages(count) {
|
|
101
|
+
return `${count} image${count === 1 ? "" : "s"} attached`;
|
|
102
|
+
}
|
|
100
103
|
export async function runQuestionnaire(questions, ctx) {
|
|
101
104
|
return ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
102
105
|
const selections = new Map();
|
|
@@ -104,6 +107,7 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
104
107
|
const reviewSubmitIndex = questions.length;
|
|
105
108
|
const pixCapabilities = tui.pix;
|
|
106
109
|
const usesSharedEditor = Boolean(pixCapabilities?.delegatedEditorInput && ctx.ui.setEditorText && ctx.ui.getEditorText);
|
|
110
|
+
const usesEditorSnapshots = Boolean(usesSharedEditor && ctx.ui.setEditorSnapshot && ctx.ui.getEditorSnapshot);
|
|
107
111
|
let questionIndex = 0;
|
|
108
112
|
let selectedChoiceIndex = 0;
|
|
109
113
|
let selectedReviewIndex = reviewSubmitIndex;
|
|
@@ -129,21 +133,45 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
129
133
|
function sharedEditorText() {
|
|
130
134
|
if (usesSharedEditor)
|
|
131
135
|
return ctx.ui.getEditorText?.() ?? "";
|
|
132
|
-
return customDrafts.get(currentQuestion().id) ?? "";
|
|
136
|
+
return customDrafts.get(currentQuestion().id)?.text ?? "";
|
|
137
|
+
}
|
|
138
|
+
function sharedEditorSnapshot() {
|
|
139
|
+
if (usesEditorSnapshots) {
|
|
140
|
+
const snapshot = ctx.ui.getEditorSnapshot?.();
|
|
141
|
+
if (snapshot)
|
|
142
|
+
return cloneEditorSnapshot(snapshot);
|
|
143
|
+
}
|
|
144
|
+
return { text: sharedEditorText(), images: [] };
|
|
145
|
+
}
|
|
146
|
+
function cloneEditorSnapshot(snapshot) {
|
|
147
|
+
return {
|
|
148
|
+
text: snapshot.text,
|
|
149
|
+
images: snapshot.images.map((image) => ({ ...image })),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function setSharedEditorSnapshot(snapshot) {
|
|
153
|
+
const cloned = cloneEditorSnapshot(snapshot);
|
|
154
|
+
customDrafts.set(currentQuestion().id, cloned);
|
|
155
|
+
if (usesEditorSnapshots)
|
|
156
|
+
ctx.ui.setEditorSnapshot?.(cloned);
|
|
157
|
+
else if (usesSharedEditor)
|
|
158
|
+
ctx.ui.setEditorText?.(cloned.text);
|
|
133
159
|
}
|
|
134
160
|
function setSharedEditorText(text) {
|
|
135
|
-
customDrafts.set(currentQuestion().id, text);
|
|
161
|
+
customDrafts.set(currentQuestion().id, { text, images: [] });
|
|
136
162
|
if (usesSharedEditor)
|
|
137
163
|
ctx.ui.setEditorText?.(text);
|
|
138
164
|
}
|
|
139
165
|
function clearSharedEditorText() {
|
|
140
|
-
if (
|
|
166
|
+
if (usesEditorSnapshots)
|
|
167
|
+
ctx.ui.setEditorSnapshot?.({ text: "", images: [] });
|
|
168
|
+
else if (usesSharedEditor)
|
|
141
169
|
ctx.ui.setEditorText?.("");
|
|
142
170
|
}
|
|
143
171
|
function captureCustomDraft() {
|
|
144
172
|
if (mode !== "custom")
|
|
145
173
|
return;
|
|
146
|
-
customDrafts.set(currentQuestion().id,
|
|
174
|
+
customDrafts.set(currentQuestion().id, sharedEditorSnapshot());
|
|
147
175
|
}
|
|
148
176
|
function getCompleteSelections() {
|
|
149
177
|
const orderedSelections = [];
|
|
@@ -172,8 +200,12 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
172
200
|
submitCompleteSelections();
|
|
173
201
|
}
|
|
174
202
|
function formatReviewAnswerLabel(question, selection) {
|
|
175
|
-
if ("customText" in selection)
|
|
176
|
-
|
|
203
|
+
if ("customText" in selection) {
|
|
204
|
+
const imageCount = selection.images?.length ?? 0;
|
|
205
|
+
const text = selection.customText || formatAttachedImages(imageCount);
|
|
206
|
+
const imageSuffix = selection.customText && imageCount > 0 ? ` (+${formatAttachedImages(imageCount)})` : "";
|
|
207
|
+
return `${CUSTOM_ANSWER_LABEL}: ${text}${imageSuffix}`;
|
|
208
|
+
}
|
|
177
209
|
return question.choices.find((choice) => choice.value === selection.choiceValue)?.label ?? "Unknown";
|
|
178
210
|
}
|
|
179
211
|
function selectionIndexForQuestion(question) {
|
|
@@ -250,11 +282,13 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
250
282
|
function enterCustomMode() {
|
|
251
283
|
const question = currentQuestion();
|
|
252
284
|
const existing = selections.get(question.id);
|
|
253
|
-
const prefill = existing && "customText" in existing
|
|
285
|
+
const prefill = existing && "customText" in existing
|
|
286
|
+
? { text: existing.customText, images: existing.images ?? [] }
|
|
287
|
+
: customDrafts.get(question.id) ?? { text: "", images: [] };
|
|
254
288
|
mode = "custom";
|
|
255
289
|
selectedChoiceIndex = customAnswerIndex(question);
|
|
256
290
|
customError = undefined;
|
|
257
|
-
|
|
291
|
+
setSharedEditorSnapshot(prefill);
|
|
258
292
|
refresh();
|
|
259
293
|
}
|
|
260
294
|
function selectChoice(index) {
|
|
@@ -271,15 +305,19 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
271
305
|
enterCustomMode();
|
|
272
306
|
}
|
|
273
307
|
function submitCustomAnswer() {
|
|
274
|
-
const
|
|
275
|
-
const trimmed = text.trim();
|
|
276
|
-
if (!trimmed) {
|
|
308
|
+
const snapshot = sharedEditorSnapshot();
|
|
309
|
+
const trimmed = snapshot.text.trim();
|
|
310
|
+
if (!trimmed && snapshot.images.length === 0) {
|
|
277
311
|
customError = "Custom Answer cannot be empty.";
|
|
278
312
|
refresh();
|
|
279
313
|
return;
|
|
280
314
|
}
|
|
281
315
|
const question = currentQuestion();
|
|
282
|
-
selections.set(question.id, {
|
|
316
|
+
selections.set(question.id, {
|
|
317
|
+
id: question.id,
|
|
318
|
+
customText: trimmed,
|
|
319
|
+
...(snapshot.images.length > 0 ? { images: snapshot.images } : {}),
|
|
320
|
+
});
|
|
283
321
|
customDrafts.delete(question.id);
|
|
284
322
|
clearSharedEditorText();
|
|
285
323
|
advanceAfterAnswer();
|
|
@@ -30,6 +30,7 @@ export interface PredefinedQuestionSelection {
|
|
|
30
30
|
export interface CustomQuestionSelection {
|
|
31
31
|
id: string;
|
|
32
32
|
customText: string;
|
|
33
|
+
images?: QuestionImageContent[];
|
|
33
34
|
}
|
|
34
35
|
export type QuestionSelection = PredefinedQuestionSelection | CustomQuestionSelection;
|
|
35
36
|
export interface QuestionAnswer {
|
|
@@ -38,6 +39,7 @@ export interface QuestionAnswer {
|
|
|
38
39
|
label: string;
|
|
39
40
|
wasCustom: boolean;
|
|
40
41
|
index?: number;
|
|
42
|
+
imageCount?: number;
|
|
41
43
|
}
|
|
42
44
|
export interface SuccessfulQuestionResult {
|
|
43
45
|
answers: QuestionAnswer[];
|
|
@@ -54,8 +56,17 @@ export interface TextContent {
|
|
|
54
56
|
type: "text";
|
|
55
57
|
text: string;
|
|
56
58
|
}
|
|
59
|
+
export interface QuestionImageContent {
|
|
60
|
+
type: "image";
|
|
61
|
+
data: string;
|
|
62
|
+
mimeType: string;
|
|
63
|
+
}
|
|
64
|
+
export interface QuestionEditorSnapshot {
|
|
65
|
+
text: string;
|
|
66
|
+
images: QuestionImageContent[];
|
|
67
|
+
}
|
|
57
68
|
export interface QuestionToolResult {
|
|
58
|
-
content: TextContent
|
|
69
|
+
content: Array<TextContent | QuestionImageContent>;
|
|
59
70
|
details: QuestionResultDetails;
|
|
60
71
|
}
|
|
61
72
|
export interface QuestionUiContext {
|
|
@@ -64,6 +75,8 @@ export interface QuestionUiContext {
|
|
|
64
75
|
custom<T>(factory: (tui: QuestionTui, theme: QuestionTheme, keybindings: unknown, done: (value: T) => void) => QuestionComponent): Promise<T | undefined>;
|
|
65
76
|
setEditorText?(text: string): void;
|
|
66
77
|
getEditorText?(): string;
|
|
78
|
+
setEditorSnapshot?(snapshot: QuestionEditorSnapshot): void;
|
|
79
|
+
getEditorSnapshot?(): QuestionEditorSnapshot;
|
|
67
80
|
notify?(message: string, level: "info" | "warning" | "error"): void;
|
|
68
81
|
};
|
|
69
82
|
}
|
package/dist/sdk.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { isToastKind, Toast, TOAST_KINDS } from "./ui.js";
|
|
2
2
|
export type { ToastEntry, ToastKind, ToastNotifier, ToastState } from "./ui.js";
|
|
3
|
-
export type { AboveInputRenderer, ExtensionWidgetComponent, ExtensionWidgetContent, ExtensionWidgetFactory, PixMenuController, PixMenuItem, PixMenuOptions, PixMenuSelectOptions, PixMenuVariant, PixExtensionUIContext, WidgetPlacement, WidgetTuiHandle, } from "./app/types.js";
|
|
3
|
+
export type { AboveInputRenderer, ExtensionWidgetComponent, ExtensionWidgetContent, ExtensionWidgetFactory, PixMenuController, PixMenuItem, PixMenuOptions, PixMenuSelectOptions, PixMenuVariant, PixExtensionUIContext, PixEditorSnapshot, WidgetPlacement, WidgetTuiHandle, } from "./app/types.js";
|
|
@@ -370,7 +370,7 @@ The canonical use case is an **`oracle`** role that consults a flagship model fr
|
|
|
370
370
|
"description": "Cross-provider second opinion: consult a flagship from a different provider than the parent to pressure-test a hard decision. Read-only; advise, do not edit.",
|
|
371
371
|
"model": "openai-codex/gpt-5.6-sol",
|
|
372
372
|
"fallbackModels": ["zai/glm-5.3"],
|
|
373
|
-
"thinking": "
|
|
373
|
+
"thinking": "max",
|
|
374
374
|
"modelByParent": {
|
|
375
375
|
"zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
|
|
376
376
|
"openai-codex/*": "zai/glm-5.3",
|
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
"description": "Oracle: cross-provider flagship second opinion for hard or high-stakes uncertainty. Use sparingly to pressure-test architecture, plans, root-cause hypotheses, risk/security calls, or final recommendations when independent disagreement is valuable. Read-only; advise, do not edit.",
|
|
245
245
|
"model": "openai-codex/gpt-5.6-sol",
|
|
246
246
|
"fallbackModels": ["zai/glm-5.3"],
|
|
247
|
-
"thinking": "
|
|
247
|
+
"thinking": "max",
|
|
248
248
|
"tools": ["read", "grep", "bash"],
|
|
249
249
|
"modelByParent": {
|
|
250
250
|
"zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
|
|
@@ -250,7 +250,7 @@ const BUILTIN_CONFIG: SubagentConfig = {
|
|
|
250
250
|
"antigravity/*": { model: "zai/glm-5.3", fallbackModels: ["openai-codex/gpt-5.6-sol"] },
|
|
251
251
|
"anthropic/*": { model: "openai-codex/gpt-5.6-sol", fallbackModels: ["zai/glm-5.3"] },
|
|
252
252
|
},
|
|
253
|
-
thinking: "
|
|
253
|
+
thinking: "max",
|
|
254
254
|
tools: ["read", "grep", "bash"],
|
|
255
255
|
promptAppend: "You are an oracle: a flagship model from a different provider giving a second opinion to the parent agent. Give a concise, decisive recommendation with key tradeoffs and risks. Disagree when warranted; do not rubber-stamp. Do not edit unless explicitly asked.",
|
|
256
256
|
},
|
|
@@ -35,7 +35,9 @@ source reading, server polling, capability probing, or retries.
|
|
|
35
35
|
list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
|
|
36
36
|
and use public mode. Choose an auth profile only when the task names its id,
|
|
37
37
|
safe profile traits make the choice unambiguous, or a public run proves that
|
|
38
|
-
the requested page requires login.
|
|
38
|
+
the requested page requires login. If form authentication is required but
|
|
39
|
+
there is no usable profile, follow **Form-auth scaffolding** below instead of
|
|
40
|
+
asking the user to discover selectors.
|
|
39
41
|
5. Inspect the target code and write a declarative JSONC flow under
|
|
40
42
|
`$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or raw
|
|
41
43
|
executable JavaScript in it. The `evaluate` action exposes only the safe
|
|
@@ -208,11 +210,43 @@ after login succeeds so credentials are not captured in the trace.
|
|
|
208
210
|
|
|
209
211
|
## Credentials and blocked runs
|
|
210
212
|
|
|
213
|
+
### Form-auth scaffolding
|
|
214
|
+
|
|
215
|
+
When a public HTTPS login page (or loopback HTTP page for local development) is
|
|
216
|
+
known and there is no usable profile, invoke the trusted runner once:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
node <runner> auth scaffold \
|
|
220
|
+
--profile <safe-id> \
|
|
221
|
+
--login-url <public-login-url>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The runner discovers the login form and privately writes a target-specific
|
|
225
|
+
`.pi/qa_auth.jsonc` containing `__PI_QA_SECRET_n__` values. It may create the
|
|
226
|
+
file or replace only the runner's own empty generated template; it refuses to
|
|
227
|
+
overwrite invalid or non-empty auth configuration. Never inspect the result.
|
|
228
|
+
Relay `QA_AUTH_SCAFFOLD_CREATED`, the reported file, profile, placeholder count,
|
|
229
|
+
and action, then ask the user to replace only those placeholder values. Browser
|
|
230
|
+
QA must resume in a new run after the user confirms that edit.
|
|
231
|
+
|
|
232
|
+
By default, login succeeds when the discovered form becomes hidden. A form-less
|
|
233
|
+
page therefore requires an explicit deterministic signal using either
|
|
234
|
+
`--success-url <glob>` or `--success-selector <selector>` with optional
|
|
235
|
+
`--success-state attached|detached|visible|hidden`. Use `--base-url <url>` only
|
|
236
|
+
to override the profile's same-origin application root. These are public
|
|
237
|
+
metadata; never put credential values in command arguments or environment
|
|
238
|
+
variables.
|
|
239
|
+
|
|
240
|
+
Do not scaffold cookie, bearer, storage, storage-state, MFA, CAPTCHA, or
|
|
241
|
+
federated login. For those modes, use the generic empty-template flow below and
|
|
242
|
+
ask the user to configure the profile themselves.
|
|
243
|
+
|
|
211
244
|
Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
|
|
212
245
|
task explicitly requires authenticated behavior, or a public run reaches the
|
|
213
246
|
flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
|
|
214
|
-
|
|
215
|
-
|
|
247
|
+
form-auth scaffolding when supported. Otherwise run
|
|
248
|
+
`node <runner> profiles --require-auth`. Only these explicit authenticated paths
|
|
249
|
+
may create the private auth file.
|
|
216
250
|
|
|
217
251
|
If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
|
|
218
252
|
and explicitly report that authenticated browser QA requires credentials or an
|
|
@@ -220,7 +254,9 @@ auth-config update. Ask the user to fill the reported file and rerun QA. If
|
|
|
220
254
|
`templateCreated` is true, say that a private empty template was created at that
|
|
221
255
|
path. Relay only the runner's profile, file, reason, action, and template-created
|
|
222
256
|
state; never read the generated file or attempt to recover by exposing or
|
|
223
|
-
replaying credentials.
|
|
257
|
+
replaying credentials. If the action is `fill_credentials`, also relay the
|
|
258
|
+
placeholder count and tell the user to replace only the generated placeholder
|
|
259
|
+
values.
|
|
224
260
|
|
|
225
261
|
For any other blocked run, report the runner status and redacted reason. Do not
|
|
226
262
|
claim that browser QA passed based on source inspection, unit tests, or a build.
|
|
@@ -230,5 +266,5 @@ all non-empty `artifacts.screenshots`, `artifacts.videos`, and
|
|
|
230
266
|
`artifacts.traces`, and `artifacts.downloads` groups in the final response.
|
|
231
267
|
These links are mandatory so the user can open the evidence directly.
|
|
232
268
|
|
|
233
|
-
See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
|
|
234
|
-
`references/qa-design.md`.
|
|
269
|
+
See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
|
|
270
|
+
`references/qa-design.md`, and `references/auth-scaffold-spec.md`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Spec: Browser QA form-auth scaffold
|
|
2
|
+
|
|
3
|
+
## Type
|
|
4
|
+
|
|
5
|
+
Change
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Let the browser-QA sub-agent prepare a target-specific form-auth configuration
|
|
10
|
+
without reading, writing, receiving, or printing credential values. The user
|
|
11
|
+
should only need to replace clearly marked secret placeholders.
|
|
12
|
+
|
|
13
|
+
## Scope
|
|
14
|
+
|
|
15
|
+
- Add a trusted runner command that inspects a public login form.
|
|
16
|
+
- Generate `.pi/qa_auth.jsonc` with discovered field and submit selectors.
|
|
17
|
+
- Keep generated values as non-secret placeholders until the user edits them.
|
|
18
|
+
- Preserve the rule that agents never read or edit `.pi/qa_auth.jsonc`.
|
|
19
|
+
|
|
20
|
+
## Non-goals
|
|
21
|
+
|
|
22
|
+
- Collecting credentials interactively through the agent.
|
|
23
|
+
- Guessing cookie, token, storage-state, MFA, or federated-login credentials.
|
|
24
|
+
- Modifying an existing non-empty auth configuration.
|
|
25
|
+
|
|
26
|
+
## Behavior
|
|
27
|
+
|
|
28
|
+
- `auth scaffold` requires a project-local browser-QA sub-agent workspace, an
|
|
29
|
+
HTTPS login URL (or loopback HTTP for local development), and a safe profile
|
|
30
|
+
id.
|
|
31
|
+
- The trusted runner opens the unauthenticated login page with fail-closed
|
|
32
|
+
origin routing and discovers the most likely login form, fillable fields,
|
|
33
|
+
submit control, and form container.
|
|
34
|
+
- It writes a private `0600` `.pi/qa_auth.jsonc` containing only generated
|
|
35
|
+
selectors and `__PI_QA_SECRET_n__` placeholders. It may replace the runner's
|
|
36
|
+
empty generated template, but never a non-empty profiles object.
|
|
37
|
+
- By default, successful login means that the discovered form becomes hidden.
|
|
38
|
+
A form-less page requires an explicit success URL pattern or selector/state.
|
|
39
|
+
- A run cannot use a profile while generated placeholders remain. The runner
|
|
40
|
+
returns `QA_AUTH_UPDATE_REQUIRED` without exposing selectors or values.
|
|
41
|
+
|
|
42
|
+
## Invariants
|
|
43
|
+
|
|
44
|
+
- No credential value is accepted through command arguments or environment
|
|
45
|
+
variables.
|
|
46
|
+
- Scaffold status output contains only path, profile id, counts, and action.
|
|
47
|
+
- The command never prints inspected DOM text, input values, URLs, or selectors.
|
|
48
|
+
- Agents continue to treat `.pi/qa_auth.jsonc` as opaque and user-owned.
|
|
49
|
+
|
|
50
|
+
## Edge cases
|
|
51
|
+
|
|
52
|
+
- Reject pages without a discoverable fillable field or submit control, and
|
|
53
|
+
form-less pages without an explicit success condition.
|
|
54
|
+
- Reject remote plain-HTTP login pages.
|
|
55
|
+
- Reject cross-origin base URLs and malformed success options.
|
|
56
|
+
- Reject symlinked or permissive auth paths.
|
|
57
|
+
- Refuse to overwrite invalid or non-empty auth configuration.
|
|
58
|
+
|
|
59
|
+
## Related files
|
|
60
|
+
|
|
61
|
+
- `scripts/browser-qa-runner.mjs`
|
|
62
|
+
- `../SKILL.md`
|
|
63
|
+
- `../../../../../test/async-subagents/browser-qa-runner.test.ts`
|
|
64
|
+
|
|
65
|
+
## Verification
|
|
66
|
+
|
|
67
|
+
- Unit tests for discovery output, placeholder blocking, private permissions,
|
|
68
|
+
empty-template replacement, and non-empty-config refusal.
|
|
69
|
+
- Existing browser-QA runner tests and repository checks continue to pass.
|
|
70
|
+
|
|
71
|
+
## Evidence
|
|
72
|
+
|
|
73
|
+
- Confirmed by code: current runner owns Playwright, origin isolation, private
|
|
74
|
+
auth-file creation, validation, and redacted status output.
|
|
75
|
+
- Confirmed by tests: current runner rejects permissive/symlinked auth files and
|
|
76
|
+
creates its empty template with private permissions.
|
|
77
|
+
- Confirmed by docs: the private skill forbids agents from reading or editing
|
|
78
|
+
`.pi/qa_auth.jsonc`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import path from "node:path";
|
|
@@ -10,6 +11,7 @@ import { parse as parseJsonc, printParseErrorCode } from "jsonc-parser";
|
|
|
10
11
|
import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
|
|
11
12
|
|
|
12
13
|
const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
|
|
14
|
+
const AUTH_SECRET_PLACEHOLDER = /^__PI_QA_SECRET_[1-9][0-9]*__$/;
|
|
13
15
|
const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
|
|
14
16
|
const QA_WORKSPACE_RELATIVE = "browser-qa";
|
|
15
17
|
const EVIDENCE_RELATIVE = "evidence";
|
|
@@ -109,6 +111,12 @@ if (!isMainThread && workerData?.operation === "sanitize_trace") {
|
|
|
109
111
|
async function main() {
|
|
110
112
|
const [command = "profiles", ...rawArgs] = process.argv.slice(2);
|
|
111
113
|
const cwd = fs.realpathSync(process.cwd());
|
|
114
|
+
if (command === "auth") {
|
|
115
|
+
const [authCommand, ...authArgs] = rawArgs;
|
|
116
|
+
if (authCommand !== "scaffold") throw new QaStatusError("QA_RUN_FAILED", `unknown auth command: ${authCommand ?? "(missing)"}`, 1);
|
|
117
|
+
await runAuthScaffold(cwd, parseAuthScaffoldArgs(authArgs));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
112
120
|
if (command === "profiles") {
|
|
113
121
|
const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
|
|
114
122
|
if (rawArgs.length > 0 && !requireAuth) {
|
|
@@ -122,7 +130,7 @@ async function main() {
|
|
|
122
130
|
|
|
123
131
|
const args = parseArgs(rawArgs);
|
|
124
132
|
const selected = args.profile
|
|
125
|
-
? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
|
|
133
|
+
? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
|
|
126
134
|
: createPublicProfile(args.baseUrl);
|
|
127
135
|
const profileId = selected.id;
|
|
128
136
|
const profile = selected.profile;
|
|
@@ -290,7 +298,189 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
|
|
|
290
298
|
writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
|
|
291
299
|
}
|
|
292
300
|
|
|
293
|
-
function
|
|
301
|
+
async function runAuthScaffold(cwd, args) {
|
|
302
|
+
const profileId = args.profile;
|
|
303
|
+
if (!isSafeName(profileId) || profileId.length > 128) throw new QaStatusError("QA_RUN_FAILED", "--profile must be a safe profile id no longer than 128 characters", 1);
|
|
304
|
+
const loginUrl = normalizeScaffoldUrl(args.loginUrl, "--login-url");
|
|
305
|
+
const login = new URL(loginUrl);
|
|
306
|
+
const baseUrl = normalizeScaffoldUrl(args.baseUrl ?? `${login.origin}/`, "--base-url", login.origin);
|
|
307
|
+
const success = scaffoldSuccess(args);
|
|
308
|
+
const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
|
|
309
|
+
const progress = createRunnerProgress(path.join(agentDir, QA_WORKSPACE_RELATIVE));
|
|
310
|
+
assertAuthScaffoldWritable(cwd);
|
|
311
|
+
const timeout = Math.min(parseRunnerTimeout(args.runnerTimeoutMs, profileId), 60_000);
|
|
312
|
+
const deadline = Date.now() + timeout;
|
|
313
|
+
let browser;
|
|
314
|
+
let context;
|
|
315
|
+
try {
|
|
316
|
+
progress("auth_scaffold_started", { timeoutMs: timeout });
|
|
317
|
+
const playwright = loadPlaywright(cwd);
|
|
318
|
+
browser = await runStage(progress, "auth_scaffold_browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
|
|
319
|
+
context = await runStage(progress, "auth_scaffold_context_create", deadline, () => browser.newContext({
|
|
320
|
+
...DEFAULT_ENVIRONMENT,
|
|
321
|
+
serviceWorkers: "block",
|
|
322
|
+
viewport: DEFAULT_VIEWPORT,
|
|
323
|
+
}), profileId);
|
|
324
|
+
await runStage(progress, "auth_scaffold_origin_guard", deadline, () => installOriginGuard(context, [login.origin], { type: "none" }), profileId);
|
|
325
|
+
const page = await runStage(progress, "auth_scaffold_page_create", deadline, () => context.newPage(), profileId);
|
|
326
|
+
await runStage(progress, "auth_scaffold_login_load", deadline, () => page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout }), profileId);
|
|
327
|
+
const discovered = await runStage(progress, "auth_scaffold_form_discovery", deadline, () => discoverLoginForm(page), profileId);
|
|
328
|
+
if (!success && !discovered.formSelector) {
|
|
329
|
+
throw new QaStatusError("QA_RUN_FAILED", "no login form was found; provide an explicit success condition for a form-less login page", 1, profileId);
|
|
330
|
+
}
|
|
331
|
+
const profile = {
|
|
332
|
+
description: args.description ?? `Browser QA form login (${profileId})`,
|
|
333
|
+
traits: ["auth:form"],
|
|
334
|
+
baseUrl,
|
|
335
|
+
allowedOrigins: [login.origin],
|
|
336
|
+
auth: {
|
|
337
|
+
type: "form",
|
|
338
|
+
loginUrl,
|
|
339
|
+
fields: discovered.fields.map((selector, index) => ({ selector, value: `__PI_QA_SECRET_${index + 1}__` })),
|
|
340
|
+
submitSelector: discovered.submitSelector,
|
|
341
|
+
success: success ?? { selector: discovered.formSelector, state: "hidden" },
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
const replacedEmptyTemplate = writeAuthScaffold(cwd, profileId, profile);
|
|
345
|
+
writeStatus({
|
|
346
|
+
status: "QA_AUTH_SCAFFOLD_CREATED",
|
|
347
|
+
file: CONFIG_RELATIVE,
|
|
348
|
+
profile: profileId,
|
|
349
|
+
action: "fill_credentials",
|
|
350
|
+
placeholderCount: discovered.fields.length,
|
|
351
|
+
replacedEmptyTemplate,
|
|
352
|
+
});
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error instanceof QaStatusError) {
|
|
355
|
+
if (error.details?.timedOut) terminateRunnerDescendants();
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
if (error instanceof RunnerStageTimeoutError) {
|
|
359
|
+
terminateRunnerDescendants();
|
|
360
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth scaffold timed out; verify the public login page and retry", EXIT_RUNNER_TIMEOUT, profileId, { timedOut: true });
|
|
361
|
+
}
|
|
362
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth scaffold failed; verify the public login page and retry", 1, profileId);
|
|
363
|
+
} finally {
|
|
364
|
+
if (context) await runCleanupStage(progress, "auth_scaffold_context_close", () => context.close()).catch(terminateOnCleanupTimeout);
|
|
365
|
+
if (browser) await runCleanupStage(progress, "auth_scaffold_browser_close", () => browser.close()).catch(terminateOnCleanupTimeout);
|
|
366
|
+
progress("auth_scaffold_finished");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function terminateOnCleanupTimeout(error) {
|
|
371
|
+
if (error instanceof RunnerStageTimeoutError) terminateRunnerDescendants();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function normalizeScaffoldUrl(raw, flag, expectedOrigin) {
|
|
375
|
+
if (typeof raw !== "string") throw new QaStatusError("QA_RUN_FAILED", `${flag} is required`, 1);
|
|
376
|
+
try {
|
|
377
|
+
const url = new URL(raw);
|
|
378
|
+
if (!isSecureScaffoldUrl(url) || url.username || url.password || (expectedOrigin && url.origin !== expectedOrigin)) throw new Error();
|
|
379
|
+
return url.href;
|
|
380
|
+
} catch {
|
|
381
|
+
throw new QaStatusError("QA_RUN_FAILED", `${flag} must use HTTPS or loopback HTTP without credentials${expectedOrigin ? " on the login origin" : ""}`, 1);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isSecureScaffoldUrl(url) {
|
|
386
|
+
if (url.protocol === "https:") return true;
|
|
387
|
+
if (url.protocol !== "http:") return false;
|
|
388
|
+
const hostname = url.hostname.toLowerCase();
|
|
389
|
+
return hostname === "localhost"
|
|
390
|
+
|| hostname.endsWith(".localhost")
|
|
391
|
+
|| /^127(?:\.[0-9]{1,3}){3}$/.test(hostname)
|
|
392
|
+
|| hostname === "[::1]"
|
|
393
|
+
|| hostname === "::1";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function scaffoldSuccess(args) {
|
|
397
|
+
if (args.successUrl && args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "use only one of --success-url or --success-selector", 1);
|
|
398
|
+
if (args.successState && !args.successSelector) throw new QaStatusError("QA_RUN_FAILED", "--success-state requires --success-selector", 1);
|
|
399
|
+
if (args.successUrl) return { url: boundedScaffoldString(args.successUrl, "--success-url") };
|
|
400
|
+
if (!args.successSelector) return undefined;
|
|
401
|
+
const state = args.successState ?? "visible";
|
|
402
|
+
if (validLocatorState(state) !== state) throw new QaStatusError("QA_RUN_FAILED", "--success-state is invalid", 1);
|
|
403
|
+
return { selector: boundedScaffoldString(args.successSelector, "--success-selector"), state };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function boundedScaffoldString(value, flag) {
|
|
407
|
+
if (typeof value !== "string" || !value.trim() || value.length > 1024 || /[\r\n\0]/.test(value)) {
|
|
408
|
+
throw new QaStatusError("QA_RUN_FAILED", `${flag} must be a non-empty single-line string no longer than 1024 characters`, 1);
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function discoverLoginForm(page) {
|
|
414
|
+
const discovered = await page.evaluate(() => {
|
|
415
|
+
const quoteAttribute = (value) => value.replace(/[\0-\x1f\x7f"\\]/g, (character) => {
|
|
416
|
+
if (character === "\0") return "�";
|
|
417
|
+
if (character === '"') return '\\"';
|
|
418
|
+
if (character === "\\") return "\\\\";
|
|
419
|
+
return `\\${character.codePointAt(0).toString(16)} `;
|
|
420
|
+
});
|
|
421
|
+
const unique = (selector) => {
|
|
422
|
+
try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
|
|
423
|
+
};
|
|
424
|
+
const selectorFor = (element) => {
|
|
425
|
+
if (element.id) {
|
|
426
|
+
const selector = `[id="${quoteAttribute(element.id)}"]`;
|
|
427
|
+
if (unique(selector)) return selector;
|
|
428
|
+
}
|
|
429
|
+
for (const attribute of ["data-testid", "data-test", "name", "aria-label"]) {
|
|
430
|
+
const value = element.getAttribute(attribute);
|
|
431
|
+
if (!value) continue;
|
|
432
|
+
const selector = `${element.tagName.toLowerCase()}[${attribute}="${quoteAttribute(value)}"]`;
|
|
433
|
+
if (unique(selector)) return selector;
|
|
434
|
+
}
|
|
435
|
+
const parts = [];
|
|
436
|
+
let current = element;
|
|
437
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
|
|
438
|
+
const tag = current.tagName.toLowerCase();
|
|
439
|
+
const siblings = [...current.parentElement.children].filter((item) => item.tagName === current.tagName);
|
|
440
|
+
parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
|
|
441
|
+
const selector = parts.join(" > ");
|
|
442
|
+
if (unique(selector)) return selector;
|
|
443
|
+
current = current.parentElement;
|
|
444
|
+
}
|
|
445
|
+
return parts.join(" > ");
|
|
446
|
+
};
|
|
447
|
+
const fillable = (element) => {
|
|
448
|
+
if (element.disabled || element.getAttribute("aria-hidden") === "true") return false;
|
|
449
|
+
if (element instanceof HTMLTextAreaElement) return true;
|
|
450
|
+
if (!(element instanceof HTMLInputElement)) return false;
|
|
451
|
+
return ["email", "password", "text", "tel", "url", "search", "number"].includes((element.type || "text").toLowerCase());
|
|
452
|
+
};
|
|
453
|
+
const visible = (element) => Boolean(element.getClientRects().length) && getComputedStyle(element).visibility !== "hidden";
|
|
454
|
+
const forms = [...document.querySelectorAll("form")];
|
|
455
|
+
const ranked = forms.map((form) => {
|
|
456
|
+
const fields = [...form.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
|
|
457
|
+
const passwordCount = fields.filter((element) => element instanceof HTMLInputElement && element.type === "password").length;
|
|
458
|
+
return { form, fields, score: passwordCount * 100 + fields.length };
|
|
459
|
+
}).filter((entry) => entry.fields.length > 0).sort((left, right) => right.score - left.score);
|
|
460
|
+
const selected = ranked[0];
|
|
461
|
+
const root = selected?.form ?? document;
|
|
462
|
+
const fields = selected?.fields ?? [...document.querySelectorAll("input, textarea")].filter((element) => fillable(element) && visible(element));
|
|
463
|
+
const submit = [...root.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')].find(visible);
|
|
464
|
+
return {
|
|
465
|
+
fields: fields.map(selectorFor),
|
|
466
|
+
submitSelector: submit ? selectorFor(submit) : undefined,
|
|
467
|
+
formSelector: selected ? selectorFor(selected.form) : undefined,
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
if (!isObject(discovered) || !Array.isArray(discovered.fields) || discovered.fields.length === 0
|
|
471
|
+
|| discovered.fields.length > 10 || !discovered.fields.every(isBoundedSelector)
|
|
472
|
+
|| !isBoundedSelector(discovered.submitSelector)
|
|
473
|
+
|| (discovered.formSelector !== undefined && !isBoundedSelector(discovered.formSelector))) {
|
|
474
|
+
throw new Error("no supported login form with fillable fields and a submit control was found");
|
|
475
|
+
}
|
|
476
|
+
return discovered;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isBoundedSelector(value) {
|
|
480
|
+
return typeof value === "string" && value.length > 0 && value.length <= 1024 && !/[\0-\x1f\x7f]/.test(value);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function readAuthConfig(cwd, required = false, allowPlaceholders = false) {
|
|
294
484
|
const candidate = path.join(cwd, CONFIG_RELATIVE);
|
|
295
485
|
if (!fs.existsSync(candidate)) {
|
|
296
486
|
if (!required) return { present: false, profiles: {} };
|
|
@@ -333,6 +523,18 @@ function readAuthConfig(cwd, required = false) {
|
|
|
333
523
|
{ action: "provide_credentials", templateCreated: false },
|
|
334
524
|
);
|
|
335
525
|
}
|
|
526
|
+
const profileValues = Object.values(value.profiles);
|
|
527
|
+
if (required && !allowPlaceholders && profileValues.length > 0
|
|
528
|
+
&& profileValues.every((profile) => authPlaceholderCount(profile) > 0)) {
|
|
529
|
+
const placeholderCount = profileValues.reduce((count, profile) => count + authPlaceholderCount(profile), 0);
|
|
530
|
+
throw new QaStatusError(
|
|
531
|
+
"QA_AUTH_UPDATE_REQUIRED",
|
|
532
|
+
`credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
|
|
533
|
+
EXIT_AUTH_UPDATE_REQUIRED,
|
|
534
|
+
undefined,
|
|
535
|
+
{ action: "fill_credentials", templateCreated: false, placeholderCount },
|
|
536
|
+
);
|
|
537
|
+
}
|
|
336
538
|
if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
|
|
337
539
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
|
|
338
540
|
}
|
|
@@ -357,9 +559,28 @@ function selectProfile(profiles, requestedId) {
|
|
|
357
559
|
if (!isObject(profile.auth) || typeof profile.auth.type !== "string") {
|
|
358
560
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile auth configuration is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
|
|
359
561
|
}
|
|
562
|
+
const placeholderCount = authPlaceholderCount(profile);
|
|
563
|
+
if (placeholderCount > 0) {
|
|
564
|
+
throw new QaStatusError(
|
|
565
|
+
"QA_AUTH_UPDATE_REQUIRED",
|
|
566
|
+
`credentials are required; replace ${placeholderCount} generated credential placeholder${placeholderCount === 1 ? "" : "s"} in the auth config`,
|
|
567
|
+
EXIT_AUTH_UPDATE_REQUIRED,
|
|
568
|
+
requestedId,
|
|
569
|
+
{ action: "fill_credentials", templateCreated: false, placeholderCount },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
360
572
|
return { id: requestedId, profile };
|
|
361
573
|
}
|
|
362
574
|
|
|
575
|
+
function authPlaceholderCount(profile) {
|
|
576
|
+
if (!isObject(profile) || !isObject(profile.auth) || profile.auth.type !== "form" || !Array.isArray(profile.auth.fields)) return 0;
|
|
577
|
+
return profile.auth.fields.filter((field) => (
|
|
578
|
+
isObject(field)
|
|
579
|
+
&& typeof field.value === "string"
|
|
580
|
+
&& AUTH_SECRET_PLACEHOLDER.test(field.value.trim())
|
|
581
|
+
)).length;
|
|
582
|
+
}
|
|
583
|
+
|
|
363
584
|
function createPublicProfile(rawBaseUrl) {
|
|
364
585
|
if (typeof rawBaseUrl !== "string") {
|
|
365
586
|
throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
|
|
@@ -436,6 +657,7 @@ function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
|
|
|
436
657
|
if (!Array.isArray(auth.fields) || auth.fields.length === 0 || auth.fields.some((field) => !isObject(field) || typeof field.selector !== "string" || !field.selector || typeof field.value !== "string" || !field.value)) throw authError(profileId, "form fields require non-empty selector/value strings");
|
|
437
658
|
if (typeof auth.submitSelector !== "string" || !auth.submitSelector) throw authError(profileId, "form submitSelector is missing");
|
|
438
659
|
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) throw authError(profileId, "form success.url or success.selector is required");
|
|
660
|
+
if (auth.success.state !== undefined && (typeof auth.success.selector !== "string" || validLocatorState(auth.success.state) !== auth.success.state)) throw authError(profileId, "form success.state requires a selector and valid locator state");
|
|
439
661
|
break;
|
|
440
662
|
case "storageState":
|
|
441
663
|
if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
|
|
@@ -1211,7 +1433,9 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
|
|
|
1211
1433
|
}
|
|
1212
1434
|
await page.locator(auth.submitSelector).click();
|
|
1213
1435
|
if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
|
|
1214
|
-
if (isObject(auth.success) && typeof auth.success.selector === "string")
|
|
1436
|
+
if (isObject(auth.success) && typeof auth.success.selector === "string") {
|
|
1437
|
+
await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
|
|
1438
|
+
}
|
|
1215
1439
|
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
|
|
1216
1440
|
throw authError(profileId, "form success.url or success.selector is required");
|
|
1217
1441
|
}
|
|
@@ -1354,6 +1578,26 @@ function parseArgs(values) {
|
|
|
1354
1578
|
return result;
|
|
1355
1579
|
}
|
|
1356
1580
|
|
|
1581
|
+
function parseAuthScaffoldArgs(values) {
|
|
1582
|
+
const result = {};
|
|
1583
|
+
for (let index = 0; index < values.length; index += 2) {
|
|
1584
|
+
const flag = values[index];
|
|
1585
|
+
const value = values[index + 1];
|
|
1586
|
+
if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
|
|
1587
|
+
const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1588
|
+
if (!["profile", "loginUrl", "baseUrl", "description", "successUrl", "successSelector", "successState", "runnerTimeoutMs"].includes(key)) {
|
|
1589
|
+
throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
|
|
1590
|
+
}
|
|
1591
|
+
if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
|
|
1592
|
+
result[key] = value;
|
|
1593
|
+
}
|
|
1594
|
+
if (result.description !== undefined) {
|
|
1595
|
+
result.description = boundedScaffoldString(result.description, "--description");
|
|
1596
|
+
if (result.description.length > 200) throw new QaStatusError("QA_RUN_FAILED", "--description must be no longer than 200 characters", 1);
|
|
1597
|
+
}
|
|
1598
|
+
return result;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1357
1601
|
function parseRunnerTimeout(value, profileId) {
|
|
1358
1602
|
if (value === undefined) return DEFAULT_RUNNER_TIMEOUT_MS;
|
|
1359
1603
|
const parsed = Number(value);
|
|
@@ -1764,6 +2008,67 @@ function createAuthTemplate(cwd) {
|
|
|
1764
2008
|
return true;
|
|
1765
2009
|
}
|
|
1766
2010
|
|
|
2011
|
+
function writeAuthScaffold(cwd, profileId, profile) {
|
|
2012
|
+
const initialTarget = assertAuthScaffoldWritable(cwd);
|
|
2013
|
+
const content = `// Generated by the trusted browser QA runner from the public login form.\n// Replace only __PI_QA_SECRET_n__ values yourself; agents must not read or edit this file.\n${JSON.stringify({ profiles: { [profileId]: profile } }, null, 2)}\n`;
|
|
2014
|
+
const temporaryFile = `${initialTarget.file}.scaffold-${randomUUID()}.tmp`;
|
|
2015
|
+
try {
|
|
2016
|
+
fs.writeFileSync(temporaryFile, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2017
|
+
const descriptor = fs.openSync(temporaryFile, "r+");
|
|
2018
|
+
try { fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
|
2019
|
+
|
|
2020
|
+
const currentTarget = assertAuthScaffoldWritable(cwd);
|
|
2021
|
+
if (currentTarget.replacedEmptyTemplate !== initialTarget.replacedEmptyTemplate) {
|
|
2022
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
|
|
2023
|
+
}
|
|
2024
|
+
if (currentTarget.replacedEmptyTemplate) {
|
|
2025
|
+
fs.renameSync(temporaryFile, currentTarget.file);
|
|
2026
|
+
} else {
|
|
2027
|
+
try {
|
|
2028
|
+
fs.linkSync(temporaryFile, currentTarget.file);
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
if (isAlreadyExistsError(error)) throw new QaStatusError("QA_RUN_FAILED", "auth config changed during scaffold creation; no file was overwritten", 1);
|
|
2031
|
+
throw error;
|
|
2032
|
+
}
|
|
2033
|
+
fs.unlinkSync(temporaryFile);
|
|
2034
|
+
}
|
|
2035
|
+
return currentTarget.replacedEmptyTemplate;
|
|
2036
|
+
} finally {
|
|
2037
|
+
if (fs.existsSync(temporaryFile)) fs.unlinkSync(temporaryFile);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function assertAuthScaffoldWritable(cwd) {
|
|
2042
|
+
const root = fs.realpathSync(cwd);
|
|
2043
|
+
const directory = path.join(root, path.dirname(CONFIG_RELATIVE));
|
|
2044
|
+
const file = path.join(root, CONFIG_RELATIVE);
|
|
2045
|
+
assertInside(root, directory, "auth config directory");
|
|
2046
|
+
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { mode: 0o700 });
|
|
2047
|
+
const directoryStat = fs.lstatSync(directory);
|
|
2048
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
2049
|
+
throw new QaStatusError("QA_RUN_FAILED", "auth config directory must be a real project-local directory", 1);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
let replacedEmptyTemplate = false;
|
|
2053
|
+
if (fs.existsSync(file)) {
|
|
2054
|
+
const existing = resolveExistingPrivateFile(root, CONFIG_RELATIVE, "auth config");
|
|
2055
|
+
const existingContent = fs.readFileSync(existing, "utf8");
|
|
2056
|
+
const errors = [];
|
|
2057
|
+
const value = parseJsonc(existingContent, errors, { allowTrailingComma: true });
|
|
2058
|
+
if (errors.length > 0 || !isObject(value) || !isObject(value.profiles)) {
|
|
2059
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing auth config is invalid; scaffold creation will not overwrite it", 1);
|
|
2060
|
+
}
|
|
2061
|
+
if (Object.keys(value.profiles).length > 0) {
|
|
2062
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing auth config has profiles; scaffold creation will not overwrite it", 1);
|
|
2063
|
+
}
|
|
2064
|
+
if (existingContent !== AUTH_TEMPLATE) {
|
|
2065
|
+
throw new QaStatusError("QA_RUN_FAILED", "existing empty auth config was not generated by the trusted runner; scaffold creation will not overwrite it", 1);
|
|
2066
|
+
}
|
|
2067
|
+
replacedEmptyTemplate = true;
|
|
2068
|
+
}
|
|
2069
|
+
return { file, replacedEmptyTemplate };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1767
2072
|
function isAlreadyExistsError(error) {
|
|
1768
2073
|
return error !== null && typeof error === "object" && error.code === "EEXIST";
|
|
1769
2074
|
}
|
|
@@ -341,7 +341,7 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
|
|
|
341
341
|
"description": "Oracle: cross-provider flagship second opinion for hard or high-stakes uncertainty. Use sparingly to pressure-test architecture, plans, root-cause hypotheses, risk/security calls, or final recommendations when independent disagreement is valuable. Read-only; advise, do not edit.",
|
|
342
342
|
"model": "openai-codex/gpt-5.6-sol",
|
|
343
343
|
"fallbackModels": ["zai/glm-5.3"],
|
|
344
|
-
"thinking": "
|
|
344
|
+
"thinking": "max",
|
|
345
345
|
"tools": ["read", "grep", "bash"],
|
|
346
346
|
"modelByParent": {
|
|
347
347
|
"zai/*": { "model": "openai-codex/gpt-5.6-sol", "fallbackModels": ["zai/glm-5.3"] },
|
package/package.json
CHANGED