@tandryukha/aidemo 0.15.0 → 0.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tandryukha/aidemo",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "license": "MIT",
5
5
  "description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "@modelcontextprotocol/sdk": "1.30.0",
37
37
  "commander": "^12.1.0",
38
38
  "openai": "^4.67.0",
39
- "playwright": "^1.48.0",
39
+ "playwright": "^1.55.1",
40
40
  "tsx": "^4.23.1",
41
41
  "zod": "^3.23.8",
42
42
  "zod-to-json-schema": "3.25.2"
package/src/player.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Page, Locator, Frame, Request, Response, Dialog } from "playwright";
2
2
  import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
3
3
  import { compileUserRegex } from "./safe-regex.js";
4
+ import { redactUrl, redactSecrets } from "./redact.js";
4
5
  import { promises as fs } from "node:fs";
5
6
  import { basename, isAbsolute, join, resolve } from "node:path";
6
7
  import { existsSync } from "node:fs";
@@ -242,39 +243,51 @@ class NetworkWatch {
242
243
  private readonly page: Page,
243
244
  private readonly t0: number
244
245
  ) {
246
+ // A listener that throws surfaces as an unhandled rejection on the page
247
+ // and can abort a take, so each one is wrapped: diagnostics are strictly
248
+ // best-effort and must never take the recording down with them.
245
249
  this.onResponse = (res: Response) => {
246
- if (res.status() < 400) return;
247
- const req = res.request();
248
- const entry: FailedRequest = {
249
- tMs: Date.now() - t0,
250
- method: req.method(),
251
- url: truncateUrl(res.url(), 200),
252
- status: res.status(),
253
- resourceType: req.resourceType(),
254
- };
255
- this.push(entry);
256
- if (/^(xhr|fetch)$/.test(entry.resourceType)) {
257
- res
258
- .text()
259
- .then((b) => {
260
- const snippet = b.replace(/\s+/g, " ").trim().slice(0, NET_BODY_MAX);
261
- if (snippet) entry.body = snippet;
262
- })
263
- .catch(() => {});
250
+ try {
251
+ if (res.status() < 400) return;
252
+ const req = res.request();
253
+ const entry: FailedRequest = {
254
+ tMs: Date.now() - t0,
255
+ method: req.method(),
256
+ url: redactUrl(res.url(), 200),
257
+ status: res.status(),
258
+ resourceType: req.resourceType(),
259
+ };
260
+ this.push(entry);
261
+ if (/^(xhr|fetch)$/.test(entry.resourceType)) {
262
+ res
263
+ .text()
264
+ .then((b) => {
265
+ const snippet = redactSecrets(b.replace(/\s+/g, " ").trim()).slice(0, NET_BODY_MAX);
266
+ if (snippet) entry.body = snippet;
267
+ })
268
+ // The body is gone once the page navigates; that's expected.
269
+ .catch(() => {});
270
+ }
271
+ } catch {
272
+ /* diagnostics only — a broken response record is not a take failure */
264
273
  }
265
274
  };
266
275
  this.onFailed = (req: Request) => {
267
- const err = req.failure()?.errorText ?? "failed";
268
- // Navigations cancel in-flight requests; that's not an app failure.
269
- if (/ERR_ABORTED/.test(err)) return;
270
- this.push({
271
- tMs: Date.now() - t0,
272
- method: req.method(),
273
- url: truncateUrl(req.url(), 200),
274
- status: null,
275
- error: err,
276
- resourceType: req.resourceType(),
277
- });
276
+ try {
277
+ const err = req.failure()?.errorText ?? "failed";
278
+ // Navigations cancel in-flight requests; that's not an app failure.
279
+ if (/ERR_ABORTED/.test(err)) return;
280
+ this.push({
281
+ tMs: Date.now() - t0,
282
+ method: req.method(),
283
+ url: redactUrl(req.url(), 200),
284
+ status: null,
285
+ error: err,
286
+ resourceType: req.resourceType(),
287
+ });
288
+ } catch {
289
+ /* diagnostics only */
290
+ }
278
291
  };
279
292
  }
280
293
 
@@ -1900,7 +1913,9 @@ async function waitForChange(
1900
1913
  // ---------------------------------------------------------------------------
1901
1914
 
1902
1915
  function truncateUrl(url: string, n = 90): string {
1903
- return url.length > n ? url.slice(0, n) + "…" : url;
1916
+ // Same masking as the network watch: every URL that reaches a log or a
1917
+ // fail-*.json goes through redaction, not just the ones from failed requests.
1918
+ return redactUrl(url, n);
1904
1919
  }
1905
1920
 
1906
1921
  /** Never returns — enriches the error with scene/action context + diagnostics. */
package/src/redact.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Redaction for diagnostics.
3
+ *
4
+ * A failed take writes `logs/fail-*.json` (and log lines) containing live URLs
5
+ * and, for XHR/fetch failures, a snippet of the response body. Those come from
6
+ * whatever app was being recorded — a magic-link callback, an OAuth redirect or
7
+ * an error payload can easily carry a session token. The logs are meant to be
8
+ * pasted into an issue, so anything that looks like a credential is masked
9
+ * before it is written. This is a best-effort net, not a guarantee: it never
10
+ * throws, and it errs toward masking.
11
+ */
12
+
13
+ const MASK = "REDACTED";
14
+
15
+ /** Query/form parameter names whose *value* is never printed. */
16
+ const SECRET_PARAM =
17
+ /^(?:.*[-_])?(?:token|access[-_]?token|id[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|api[-_]?key|apikey|key|auth|authorization|session|sig|signature|code|credential|assertion)$/i;
18
+
19
+ /** Free-text shapes that are credentials wherever they appear. */
20
+ const SECRET_SHAPES: Array<[RegExp, string]> = [
21
+ [/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${MASK}`],
22
+ // Provider-prefixed keys (sk-…, ghp_…, xoxb-…, AKIA…).
23
+ [/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, MASK],
24
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, MASK],
25
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, MASK],
26
+ [/\bAKIA[0-9A-Z]{16}\b/g, MASK],
27
+ // JWTs.
28
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, MASK],
29
+ // JSON/pair values under a secret-looking name.
30
+ [
31
+ /("?)([A-Za-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|apikey|authorization|credential)[A-Za-z0-9_-]*)\1(\s*[:=]\s*)("?)[^"\s,}&]+\4/gi,
32
+ `$1$2$1$3$4${MASK}$4`,
33
+ ],
34
+ ];
35
+
36
+ /** Mask credential-shaped substrings in free text (response bodies, errors). */
37
+ export function redactSecrets(text: string): string {
38
+ if (!text) return text;
39
+ let out = text;
40
+ for (const [re, to] of SECRET_SHAPES) out = out.replace(re, to);
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Mask secret-bearing query/fragment parameters, then truncate to `n` chars.
46
+ * Anything that doesn't parse as a URL still goes through {@link redactSecrets}.
47
+ */
48
+ export function redactUrl(url: string, n = 90): string {
49
+ let safe: string;
50
+ try {
51
+ const u = new URL(url);
52
+ for (const params of [u.searchParams, new URLSearchParams(u.hash.replace(/^#/, ""))]) {
53
+ let touched = false;
54
+ for (const name of [...params.keys()]) {
55
+ if (SECRET_PARAM.test(name)) {
56
+ params.set(name, MASK);
57
+ touched = true;
58
+ }
59
+ }
60
+ if (touched && params !== u.searchParams) u.hash = params.toString();
61
+ }
62
+ if (u.username || u.password) {
63
+ u.username = "";
64
+ u.password = "";
65
+ }
66
+ safe = redactSecrets(u.toString());
67
+ } catch {
68
+ // Not an absolute URL (about:blank, a relative href, a data: blob).
69
+ safe = redactSecrets(url);
70
+ }
71
+ return safe.length > n ? safe.slice(0, n) + "…" : safe;
72
+ }
package/src/safe-regex.ts CHANGED
@@ -30,7 +30,15 @@ export function isUnsafeRegexSource(pattern: string): string | null {
30
30
  * field so the author can see which matcher to fix.
31
31
  */
32
32
  export function compileUserRegex(pattern: string, flags: string, where: string): RegExp {
33
+ if (typeof pattern !== "string" || pattern === "")
34
+ throw new Error(`${where}: regex must be a non-empty string`);
33
35
  const unsafe = isUnsafeRegexSource(pattern);
34
36
  if (unsafe) throw new Error(`${where}: unsafe regex — ${unsafe}`);
35
- return new RegExp(pattern, flags);
37
+ try {
38
+ return new RegExp(pattern, flags);
39
+ } catch (e) {
40
+ // A raw SyntaxError names neither the field nor the storyboard, which is
41
+ // the only thing the author can act on.
42
+ throw new Error(`${where}: invalid regex ${JSON.stringify(pattern)} — ${(e as Error).message}`);
43
+ }
36
44
  }