specpi 0.12.0 → 0.15.0

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/NPM_RELEASE.md +2 -0
  3. package/README.md +20 -12
  4. package/SECURITY_MODEL.md +36 -0
  5. package/THIRD_PARTY.md +10 -2
  6. package/docs/browser-testing.md +76 -0
  7. package/docs/delegation/README.md +40 -4
  8. package/docs/delegation/protocol.md +13 -0
  9. package/extensions/browser/core.d.mts +64 -0
  10. package/extensions/browser/diagnostics.ts +275 -0
  11. package/extensions/browser/index.ts +349 -57
  12. package/extensions/browser/interactions.ts +118 -0
  13. package/extensions/browser/lifecycle.ts +28 -0
  14. package/extensions/command-guard/index.ts +4 -1
  15. package/extensions/delegation/core.mjs +13 -2
  16. package/extensions/delegation/extension.mjs +64 -7
  17. package/extensions/delegation/managed-files.mjs +1 -0
  18. package/extensions/delegation/native.mjs +8 -2
  19. package/extensions/delegation/presentation.mjs +2 -1
  20. package/extensions/delegation/protocol.mjs +13 -2
  21. package/extensions/delegation/provider.mjs +3 -2
  22. package/extensions/delegation/settings.mjs +140 -0
  23. package/extensions/spec.ts +9 -0
  24. package/extensions/tool-wishlist/index.ts +7 -1
  25. package/extensions/tool-wishlist/verification.mjs +2 -0
  26. package/extensions/workflow-controls/index.ts +2 -2
  27. package/package.json +15 -4
  28. package/scripts/check-package.mjs +47 -23
  29. package/scripts/check-pi-package.mjs +1 -0
  30. package/scripts/check-syntax.mjs +5 -0
  31. package/scripts/run-browser-tests.mjs +61 -0
  32. package/scripts/setup-browser-tests.mjs +38 -0
  33. package/scripts/site-browser.mjs +272 -0
  34. package/scripts/specpi.mjs +18 -0
  35. package/templates/AGENTS.md +1 -0
@@ -0,0 +1,275 @@
1
+ import crypto from "node:crypto";
2
+ import type { ConsoleMessage, Page, Request, Response } from "playwright";
3
+
4
+ export const DIAGNOSTIC_CATEGORIES = ["pageerror", "console", "requestfailed", "http"] as const;
5
+ export type DiagnosticCategory = (typeof DIAGNOSTIC_CATEGORIES)[number];
6
+ export const MAX_DIAGNOSTIC_RECORDS = 200;
7
+ export const MAX_DIAGNOSTIC_BYTES = 256 * 1024;
8
+ export const MAX_RECORD_BYTES = 2048;
9
+
10
+ export function boundedInteger(value: number | undefined, fallback: number, maximum: number, minimum = 1): number {
11
+ const result = value ?? fallback;
12
+ if (!Number.isInteger(result) || result < minimum || result > maximum) {
13
+ throw new Error(`Expected an integer from ${minimum} to ${maximum}.`);
14
+ }
15
+
16
+ return result;
17
+ }
18
+
19
+ function prefix(value: string, limit: number): string {
20
+ return value.slice(0, limit).toWellFormed();
21
+ }
22
+
23
+ function diagnosticUrl(value: string) {
24
+ try {
25
+ const url = new URL(value.slice(0, 16384));
26
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
27
+ return { text: "[non-http location]", truncated: false };
28
+ }
29
+
30
+ url.username = "";
31
+ url.password = "";
32
+ url.search = "";
33
+ url.hash = "";
34
+
35
+ return { text: prefix(url.href, 300), truncated: value.length > 16384 || url.href.length > 300 };
36
+ } catch {
37
+ return { text: "[location omitted]", truncated: value.length > 16384 };
38
+ }
39
+ }
40
+
41
+ export function sanitizeUrl(value: string): string {
42
+ return diagnosticUrl(value).text;
43
+ }
44
+
45
+ /** Best effort only. Application messages and URL paths may contain arbitrary secrets. */
46
+ function diagnosticText(value: string, limit = 700) {
47
+ // Bound processing as well as retention. Redact before taking the final display prefix.
48
+ let locationTruncated = false;
49
+ const sanitized = value
50
+ .slice(0, 16384)
51
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\|$)/gu, " ")
52
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/gu, " ")
53
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
54
+ .replace(/https?:\/\/[^\s<>"']+/giu, (url) => {
55
+ const location = diagnosticUrl(url);
56
+ locationTruncated ||= location.truncated;
57
+
58
+ return location.text;
59
+ })
60
+ .replace(/\b(?:bearer|basic)\s+[^\s,;]+/giu, "[authorization redacted]")
61
+ .replace(
62
+ /(["']?(?:password|passwd|secret|token|api[_-]?key|authorization|cookie|credential|access[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\s,;]+)/giu,
63
+ "$1[redacted]",
64
+ );
65
+
66
+ return {
67
+ text: prefix(sanitized, limit),
68
+ truncated: value.length > 16384 || sanitized.length > limit || locationTruncated,
69
+ };
70
+ }
71
+
72
+ export function sanitizeDiagnostic(value: string, limit = 700): string {
73
+ return diagnosticText(value, limit).text;
74
+ }
75
+
76
+ type DiagnosticRecord = {
77
+ sequence: number;
78
+ navigation: number;
79
+ category: DiagnosticCategory;
80
+ message: string;
81
+ url: string;
82
+ method?: string;
83
+ status?: number;
84
+ resourceType?: string;
85
+ truncated: boolean;
86
+ };
87
+ export type DiagnosticQuery = {
88
+ maxEntries?: number;
89
+ maxChars?: number;
90
+ category?: DiagnosticCategory;
91
+ cursor?: string;
92
+ clear?: boolean;
93
+ };
94
+
95
+ export class BrowserDiagnostics {
96
+ private context = crypto.randomUUID();
97
+ private sequence = 0;
98
+ private navigation = 0;
99
+ private floor = 0;
100
+ private dropped = 0;
101
+ private bytes = 0;
102
+ private records: DiagnosticRecord[] = [];
103
+
104
+ reset(): void {
105
+ this.context = crypto.randomUUID();
106
+ this.sequence = 0;
107
+ this.navigation = 0;
108
+ this.floor = 0;
109
+ this.dropped = 0;
110
+ this.bytes = 0;
111
+ this.records = [];
112
+ }
113
+
114
+ navigated(): void {
115
+ this.navigation += 1;
116
+ }
117
+
118
+ record(
119
+ category: DiagnosticCategory,
120
+ message: string,
121
+ url = "",
122
+ extra: { method?: string; status?: number; resourceType?: string } = {},
123
+ ): void {
124
+ const clean = diagnosticText(message);
125
+ const location = url ? diagnosticUrl(url) : undefined;
126
+ const method = extra.method ? diagnosticText(extra.method, 20) : undefined;
127
+ const resourceType = extra.resourceType ? diagnosticText(extra.resourceType, 30) : undefined;
128
+ const entry: DiagnosticRecord = {
129
+ sequence: ++this.sequence,
130
+ navigation: this.navigation,
131
+ category,
132
+ message: clean.text,
133
+ url: location?.text ?? "",
134
+ method: method?.text,
135
+ status: extra.status,
136
+ resourceType: resourceType?.text,
137
+ truncated:
138
+ clean.truncated ||
139
+ location?.truncated === true ||
140
+ method?.truncated === true ||
141
+ resourceType?.truncated === true,
142
+ };
143
+ while (Buffer.byteLength(JSON.stringify(entry)) > MAX_RECORD_BYTES) {
144
+ entry.truncated = true;
145
+ if (entry.message.length) {
146
+ entry.message = prefix(entry.message, Math.floor(entry.message.length / 2));
147
+ } else {
148
+ entry.url = prefix(entry.url, Math.floor(entry.url.length / 2));
149
+ }
150
+ }
151
+
152
+ this.records.push(entry);
153
+ this.bytes += Buffer.byteLength(JSON.stringify(entry));
154
+ while (this.records.length > MAX_DIAGNOSTIC_RECORDS || this.bytes > MAX_DIAGNOSTIC_BYTES) {
155
+ const removed = this.records.shift()!;
156
+ this.bytes -= Buffer.byteLength(JSON.stringify(removed));
157
+ this.floor = removed.sequence;
158
+ this.dropped += 1;
159
+ }
160
+ }
161
+
162
+ read(query: DiagnosticQuery = {}) {
163
+ const maxEntries = boundedInteger(query.maxEntries, 50, 100);
164
+ const maxChars = boundedInteger(query.maxChars, 12000, 30000, 1000);
165
+ if (query.category !== undefined && !DIAGNOSTIC_CATEGORIES.includes(query.category)) {
166
+ throw new Error("Unknown diagnostic category.");
167
+ }
168
+
169
+ if (query.clear !== undefined && typeof query.clear !== "boolean") {
170
+ throw new Error("clear must be boolean.");
171
+ }
172
+
173
+ let after = 0;
174
+ let contextChanged = false;
175
+ if (query.cursor !== undefined) {
176
+ if (typeof query.cursor !== "string" || !/^[\da-f-]{36}:\d{1,16}$/u.test(query.cursor)) {
177
+ throw new Error("Invalid diagnostics cursor.");
178
+ }
179
+
180
+ const [context, sequence] = query.cursor.split(":");
181
+ contextChanged = context !== this.context;
182
+ after = contextChanged ? 0 : Number(sequence);
183
+ if (!Number.isSafeInteger(after) || after > this.sequence) {
184
+ throw new Error("Invalid diagnostics cursor sequence.");
185
+ }
186
+ }
187
+
188
+ const candidates = this.records.filter(
189
+ (entry) => entry.sequence > after && (!query.category || entry.category === query.category),
190
+ );
191
+ const result = {
192
+ notice: "Untrusted application output; redaction is best-effort. Empty results do not prove application health. Active page only.",
193
+ context: this.context,
194
+ contextChanged,
195
+ cursorGap: contextChanged || after < this.floor,
196
+ droppedRecords: this.dropped,
197
+ retainedRecords: this.records.length,
198
+ retainedBytes: this.bytes,
199
+ clearedRecords: query.clear ? this.records.length : 0,
200
+ hasMore: false,
201
+ nextCursor: `${this.context}:${this.sequence}`,
202
+ records: [] as DiagnosticRecord[],
203
+ };
204
+ for (const entry of candidates.slice(0, maxEntries)) {
205
+ result.records.push({ ...entry });
206
+ if (JSON.stringify(result).length > maxChars) {
207
+ if (result.records.length > 1) {
208
+ result.records.pop();
209
+ break;
210
+ }
211
+
212
+ const first = result.records[0];
213
+ while (JSON.stringify(result).length > maxChars) {
214
+ first.truncated = true;
215
+ if (first.message.length) {
216
+ first.message = prefix(first.message, Math.floor(first.message.length / 2));
217
+ } else {
218
+ first.url = prefix(first.url, Math.floor(first.url.length / 2));
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ result.hasMore = result.records.length < candidates.length;
225
+ if (result.hasMore) {
226
+ result.nextCursor = `${this.context}:${result.records.at(-1)?.sequence ?? after}`;
227
+ }
228
+
229
+ if (query.clear) {
230
+ // Atomic synchronous read-and-clear of the entire buffer, including filtered/unreturned records.
231
+ this.records = [];
232
+ this.bytes = 0;
233
+ this.floor = this.sequence;
234
+ }
235
+
236
+ return result;
237
+ }
238
+
239
+ attach(page: Page): () => void {
240
+ const onError = (error: Error) => this.record("pageerror", error.message);
241
+ const onConsole = (message: ConsoleMessage) => {
242
+ if (message.type() === "error") {
243
+ this.record("console", message.text(), message.location().url);
244
+ }
245
+ };
246
+
247
+ const onRequest = (request: Request) =>
248
+ this.record("requestfailed", request.failure()?.errorText ?? "Request failed", request.url(), {
249
+ method: request.method(),
250
+ resourceType: request.resourceType(),
251
+ });
252
+ const onResponse = (response: Response) => {
253
+ if (response.status() >= 400) {
254
+ const request = response.request();
255
+ this.record("http", `HTTP ${response.status()}`, response.url(), {
256
+ status: response.status(),
257
+ method: request.method(),
258
+ resourceType: request.resourceType(),
259
+ });
260
+ }
261
+ };
262
+
263
+ page.on("pageerror", onError);
264
+ page.on("console", onConsole);
265
+ page.on("requestfailed", onRequest);
266
+ page.on("response", onResponse);
267
+
268
+ return () => {
269
+ page.off("pageerror", onError);
270
+ page.off("console", onConsole);
271
+ page.off("requestfailed", onRequest);
272
+ page.off("response", onResponse);
273
+ };
274
+ }
275
+ }