@alwaysmeticulous/debug-workspace 2.300.0 → 2.303.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.
@@ -1,11 +1,11 @@
1
1
  "use strict";
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="9b6ac7e0-327f-556e-868b-d5322291660f")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b0e8824a-2c75-553c-abd1-f66bad1029e6")}catch(e){}}();
3
3
 
4
4
  var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
6
6
  };
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.detectSnapshotAssets = exports.generateDebugWorkspace = void 0;
8
+ exports.redactLogCredentials = exports.generateFilteredLogs = exports.redactCookiesAndHeaders = exports.redactCookiesAndHeadersInJson = exports.detectSnapshotAssets = exports.generateDebugWorkspace = void 0;
9
9
  const child_process_1 = require("child_process");
10
10
  const crypto_1 = require("crypto");
11
11
  const fs_1 = require("fs");
@@ -23,7 +23,8 @@ const generateDebugWorkspace = async (options) => {
23
23
  const { client, debugContext, workspaceDir } = options;
24
24
  const claudeDir = (0, path_1.join)(workspaceDir, ".claude");
25
25
  (0, fs_1.mkdirSync)(claudeDir, { recursive: true });
26
- generateFilteredLogs(workspaceDir);
26
+ (0, exports.redactCookiesAndHeaders)(debugContext, workspaceDir);
27
+ (0, exports.generateFilteredLogs)(workspaceDir);
27
28
  generateLogDiffs(debugContext, workspaceDir);
28
29
  generateDiffSummaries(workspaceDir);
29
30
  generatePrDiff(debugContext, workspaceDir);
@@ -236,29 +237,399 @@ const detectSnapshotAssets = (workspaceDir) => {
236
237
  };
237
238
  exports.detectSnapshotAssets = detectSnapshotAssets;
238
239
  // ---------------------------------------------------------------------------
240
+ // Cookie, header & storage redaction
241
+ // ---------------------------------------------------------------------------
242
+ const REDACTED_VALUE_MARKER = "<redacted>";
243
+ // Array-valued keys whose entries are `{ name, value }` or `{ key, value }`
244
+ // pairs (or, in the case of `cookies.json`, richer cookie objects) where
245
+ // only `value` holds sensitive data, regardless of what object contains
246
+ // them.
247
+ const NAME_VALUE_ARRAY_KEYS = new Set([
248
+ "headers",
249
+ "cookies",
250
+ // Flat shape used by `applicationStorageDataOverride` inside
251
+ // `launchBrowserAndReplayParams.json` (see `ApplicationStorage`).
252
+ "localStorageEntries",
253
+ "sessionStorageEntries",
254
+ // Fixed/override authentication values baked into
255
+ // `replayExecutionOptions` inside `launchBrowserAndReplayParams.json` --
256
+ // these carry the project's configured authentication credentials (e.g.
257
+ // fixed auth cookies/storage entries) in plaintext, same shape as
258
+ // `cookies` / `*StorageEntries` above (see `ReplayExecutionOptions`).
259
+ "extraCookies",
260
+ "extraLocalStorageEntries",
261
+ "extraSessionStorageEntries",
262
+ // Flat HAR-style header arrays used by `NetworkRequestSnapshotData` (custom
263
+ // check network snapshots) instead of nesting under a `request`/`response`
264
+ // object with a `headers` key.
265
+ "requestHeaders",
266
+ "responseHeaders",
267
+ ]);
268
+ // Scalar (non-array) fields that hold a secret token directly, rather than a
269
+ // name/value pair. `session.recordingToken` (camelCase, on the `session:
270
+ // RecordedSession` embedded in `launchBrowserAndReplayParams.json`) and
271
+ // `recording_token` (snake_case, on session `data.json`) both hold the
272
+ // project's recording token in plaintext.
273
+ const SENSITIVE_SCALAR_KEYS = new Set([
274
+ "recordingToken",
275
+ "recording_token",
276
+ ]);
277
+ // `randomEvents.localStorage.state` / `randomEvents.sessionStorage.state` on
278
+ // session `data.json` nest their `{ key, value }` entries one level deeper
279
+ // than `localStorageEntries` / `sessionStorageEntries` above, so a `state`
280
+ // array is only redacted when its containing object was reached via one of
281
+ // these keys.
282
+ const STORAGE_STATE_CONTAINER_KEYS = new Set([
283
+ "localStorage",
284
+ "sessionStorage",
285
+ ]);
286
+ // Backend span attributes (see `HttpSpanAttributes`) store raw header values
287
+ // as `http.request.header.<name>` / `http.response.header.<name>` arrays of
288
+ // strings, rather than `{ name, value }` pairs.
289
+ const HTTP_HEADER_ATTRIBUTE_KEY_RE = /^http\.(?:request|response)\.header\./;
290
+ // `replayExecutionOptions.customRequestHeaders` (see `InjectableRequestHeader`)
291
+ // carries the project's configured custom request headers, which can embed a
292
+ // plaintext secret value one level deeper than the other name/value shapes:
293
+ // `{ name, value: { type: "static", value: "<secret>" }, requestTargets }`.
294
+ const NESTED_STATIC_VALUE_ARRAY_KEYS = new Set([
295
+ "customRequestHeaders",
296
+ ]);
297
+ const isRedactableArrayKey = (key, containerKey) => {
298
+ if (!key) {
299
+ return false;
300
+ }
301
+ if (NAME_VALUE_ARRAY_KEYS.has(key)) {
302
+ return true;
303
+ }
304
+ return (key === "state" &&
305
+ containerKey != null &&
306
+ STORAGE_STATE_CONTAINER_KEYS.has(containerKey));
307
+ };
308
+ // Recursively redacts cookie, header, storage, and secret-token *values*
309
+ // anywhere in a parsed JSON value, while preserving their names/keys (and
310
+ // array lengths) so an agent can still see which cookies/headers/storage
311
+ // entries were present and how many values a given header had. `key` is the
312
+ // property name `value` was reached through (if any) and `containerKey` is
313
+ // the key of the object that held that property one level up -- both are
314
+ // only used to decide whether an array should be treated as redactable.
315
+ // Handles:
316
+ // - HAR-style `headers: [{ name, value }, ...]` arrays, wherever they occur
317
+ // (e.g. inside `pollyHAR` entries' `request`/`response` objects).
318
+ // - `cookies: [{ name, value, domain, ... }, ...]` arrays, e.g. on session
319
+ // `data.json`, and `replayExecutionOptions.extraCookies`.
320
+ // - `randomEvents.localStorage.state` / `randomEvents.sessionStorage.state`,
321
+ // the flat `localStorageEntries` / `sessionStorageEntries` arrays, and
322
+ // `replayExecutionOptions.extraLocalStorageEntries` /
323
+ // `extraSessionStorageEntries` -- all arrays of `{ key, value }` entries.
324
+ // - `replayExecutionOptions.customRequestHeaders`, whose entries nest their
325
+ // secret value one level deeper (`{ name, value: { type, value } }`).
326
+ // - Backend span `http.request.header.*` / `http.response.header.*`
327
+ // attributes.
328
+ // - Scalar secret tokens such as `session.recordingToken` / the session
329
+ // `data.json` `recording_token` field.
330
+ // Exported for testing.
331
+ const redactCookiesAndHeadersInJson = (value, key, containerKey) => {
332
+ if (Array.isArray(value)) {
333
+ if (key && NESTED_STATIC_VALUE_ARRAY_KEYS.has(key)) {
334
+ return value.map(redactNestedStaticValueEntry);
335
+ }
336
+ if (isRedactableArrayKey(key, containerKey)) {
337
+ return value.map(redactNameValueEntryValue);
338
+ }
339
+ return value.map((item) => (0, exports.redactCookiesAndHeadersInJson)(item));
340
+ }
341
+ if (value !== null && typeof value === "object") {
342
+ const result = {};
343
+ for (const [childKey, childValue] of Object.entries(value)) {
344
+ if (SENSITIVE_SCALAR_KEYS.has(childKey) &&
345
+ typeof childValue === "string") {
346
+ result[childKey] = REDACTED_VALUE_MARKER;
347
+ }
348
+ else if (HTTP_HEADER_ATTRIBUTE_KEY_RE.test(childKey) &&
349
+ Array.isArray(childValue)) {
350
+ result[childKey] = childValue.map(() => REDACTED_VALUE_MARKER);
351
+ }
352
+ else {
353
+ result[childKey] = (0, exports.redactCookiesAndHeadersInJson)(childValue, childKey, key);
354
+ }
355
+ }
356
+ return result;
357
+ }
358
+ return value;
359
+ };
360
+ exports.redactCookiesAndHeadersInJson = redactCookiesAndHeadersInJson;
361
+ const redactNameValueEntryValue = (item) => {
362
+ if (item !== null && typeof item === "object" && "value" in item) {
363
+ return {
364
+ ...item,
365
+ value: REDACTED_VALUE_MARKER,
366
+ };
367
+ }
368
+ return (0, exports.redactCookiesAndHeadersInJson)(item);
369
+ };
370
+ const redactNestedStaticValueEntry = (item) => {
371
+ if (item !== null &&
372
+ typeof item === "object" &&
373
+ "value" in item &&
374
+ item.value !== null &&
375
+ typeof item.value === "object" &&
376
+ "value" in item.value) {
377
+ const itemRecord = item;
378
+ return {
379
+ ...itemRecord,
380
+ value: { ...itemRecord.value, value: REDACTED_VALUE_MARKER },
381
+ };
382
+ }
383
+ return (0, exports.redactCookiesAndHeadersInJson)(item);
384
+ };
385
+ // Redacts a JSON file in place. `topLevelKey` lets callers tell the redactor
386
+ // that the *root* of the file (not just a nested property) should be treated
387
+ // as a redactable array -- needed for `cookies.json`, which is uploaded as a
388
+ // bare `Cookie[]` rather than `{ cookies: [...] }`.
389
+ const redactCookiesAndHeadersInFile = (filePath, topLevelKey) => {
390
+ if (!(0, fs_1.existsSync)(filePath)) {
391
+ return false;
392
+ }
393
+ try {
394
+ const parsed = JSON.parse((0, fs_1.readFileSync)(filePath, "utf8"));
395
+ const redacted = (0, exports.redactCookiesAndHeadersInJson)(parsed, topLevelKey);
396
+ (0, fs_1.writeFileSync)(filePath, JSON.stringify(redacted, null, 2));
397
+ return true;
398
+ }
399
+ catch (error) {
400
+ const message = error instanceof Error ? error.message : String(error);
401
+ console.warn(` Warning: Could not redact cookies/headers/storage in ${filePath}: ${message}`);
402
+ return false;
403
+ }
404
+ };
405
+ // Redacts an NDJSON file (one JSON value per line) in place, running
406
+ // `redactCookiesAndHeadersInJson` over each line independently. Used for
407
+ // `timeline.ndjson`, which -- unlike `timeline.json` -- is sometimes already
408
+ // present in the downloaded replay archive rather than generated fresh from
409
+ // the (by-then-redacted) `timeline.json`.
410
+ const redactCookiesAndHeadersInNdjsonFile = (filePath) => {
411
+ if (!(0, fs_1.existsSync)(filePath)) {
412
+ return false;
413
+ }
414
+ try {
415
+ const raw = (0, fs_1.readFileSync)(filePath, "utf8");
416
+ const redactedLines = raw.split("\n").map((line) => {
417
+ if (line.trim() === "") {
418
+ return line;
419
+ }
420
+ return JSON.stringify((0, exports.redactCookiesAndHeadersInJson)(JSON.parse(line)));
421
+ });
422
+ (0, fs_1.writeFileSync)(filePath, redactedLines.join("\n"));
423
+ return true;
424
+ }
425
+ catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ console.warn(` Warning: Could not redact cookies/headers/storage in ${filePath}: ${message}`);
428
+ return false;
429
+ }
430
+ };
431
+ // Redacts cookie, header, and local/session storage values from the raw
432
+ // JSON files placed into the workspace, before any other step reads or
433
+ // diffs them. This runs first so that downstream steps (e.g.
434
+ // `generateParamsDiffs`, `generateSessionSummaries`, `generateDebugDerivedFiles`)
435
+ // never see or propagate the real values.
436
+ // Exported for testing.
437
+ const redactCookiesAndHeaders = (debugContext, workspaceDir) => {
438
+ let count = 0;
439
+ for (const subDir of ["head", "base", "other"]) {
440
+ const subDirPath = (0, path_1.join)(workspaceDir, debug_constants_1.DEBUG_DATA_DIRECTORY, "replays", subDir);
441
+ if (!(0, fs_1.existsSync)(subDirPath)) {
442
+ continue;
443
+ }
444
+ for (const replayId of (0, fs_1.readdirSync)(subDirPath)) {
445
+ const replayDir = (0, path_1.join)(subDirPath, replayId);
446
+ if (redactCookiesAndHeadersInFile((0, path_1.join)(replayDir, "cookies.json"), "cookies")) {
447
+ count++;
448
+ }
449
+ if (redactCookiesAndHeadersInFile((0, path_1.join)(replayDir, "launchBrowserAndReplayParams.json"))) {
450
+ count++;
451
+ }
452
+ // `timeline.json`'s network-request entries (`pollyReplay`,
453
+ // `passedThroughNetworkRequest`, etc.) embed full HAR `headers` arrays,
454
+ // including `Cookie`/`Authorization` values, for every request/response
455
+ // in the replay. Redact it before `generateDebugDerivedFiles` derives
456
+ // `timeline.ndjson` / `events-index` / `network-log` from it.
457
+ if (redactCookiesAndHeadersInFile((0, path_1.join)(replayDir, "timeline.json"))) {
458
+ count++;
459
+ }
460
+ // Usually generated fresh from (already-redacted) `timeline.json`, but
461
+ // redact in place too in case it was already present in the downloaded
462
+ // archive.
463
+ if (redactCookiesAndHeadersInNdjsonFile((0, path_1.join)(replayDir, "timeline.ndjson"))) {
464
+ count++;
465
+ }
466
+ // Feature-flagged custom-check network snapshot data, carrying the same
467
+ // HAR-style `requestHeaders`/`responseHeaders` in plaintext.
468
+ if (redactCookiesAndHeadersInFile((0, path_1.join)(replayDir, "custom-checks-snapshots", "network-requests.json"))) {
469
+ count++;
470
+ }
471
+ }
472
+ }
473
+ for (const sessionId of debugContext.sessionIds) {
474
+ const dataPath = (0, path_1.join)(workspaceDir, debug_constants_1.DEBUG_DATA_DIRECTORY, "sessions", sessionId, "data.json");
475
+ if (redactCookiesAndHeadersInFile(dataPath)) {
476
+ count++;
477
+ }
478
+ }
479
+ if (count > 0) {
480
+ console.log(chalk_1.default.green(` Redacted cookie, header, and storage values in ${count} file(s)`));
481
+ }
482
+ };
483
+ exports.redactCookiesAndHeaders = redactCookiesAndHeaders;
484
+ // ---------------------------------------------------------------------------
239
485
  // Log filtering and diffs
240
486
  // ---------------------------------------------------------------------------
487
+ // Exported for testing.
241
488
  const generateFilteredLogs = (workspaceDir) => {
242
489
  const replaySubDirs = ["head", "base", "other"];
243
- let count = 0;
490
+ let filteredCount = 0;
491
+ let redactedCount = 0;
244
492
  for (const subDir of replaySubDirs) {
245
493
  const subDirPath = (0, path_1.join)(workspaceDir, debug_constants_1.DEBUG_DATA_DIRECTORY, "replays", subDir);
246
494
  if (!(0, fs_1.existsSync)(subDirPath)) {
247
495
  continue;
248
496
  }
249
497
  for (const replayId of (0, fs_1.readdirSync)(subDirPath)) {
250
- const logPath = (0, path_1.join)(subDirPath, replayId, "logs.deterministic.txt");
498
+ const replayDir = (0, path_1.join)(subDirPath, replayId);
499
+ // `logs.concise.txt` has no filtered counterpart, so redact credential
500
+ // values directly on it (in place) rather than leaving them in plaintext.
501
+ if (redactLogCredentialsInFile((0, path_1.join)(replayDir, "logs.concise.txt"))) {
502
+ redactedCount++;
503
+ }
504
+ // `logs.ndjson` is the raw source `logs.concise.txt` / `logs.deterministic.txt`
505
+ // are rendered from, and is also read directly by `generateDebugDerivedFiles`
506
+ // (for `logs-index` / `vt-progression`) -- redact it too so none of those
507
+ // downstream artifacts leak credentials that only this raw file still held.
508
+ if (redactLogCredentialsInNdjsonFile((0, path_1.join)(replayDir, "logs.ndjson"))) {
509
+ redactedCount++;
510
+ }
511
+ const logPath = (0, path_1.join)(replayDir, "logs.deterministic.txt");
251
512
  if (!(0, fs_1.existsSync)(logPath)) {
252
513
  continue;
253
514
  }
254
515
  const raw = (0, fs_1.readFileSync)(logPath, "utf8");
255
- const filtered = filterLogLines(raw);
256
- (0, fs_1.writeFileSync)((0, path_1.join)(subDirPath, replayId, "logs.deterministic.filtered.txt"), filtered);
257
- count++;
516
+ const credentialsRedacted = (0, exports.redactLogCredentials)(raw);
517
+ if (credentialsRedacted !== raw) {
518
+ (0, fs_1.writeFileSync)(logPath, credentialsRedacted);
519
+ redactedCount++;
520
+ }
521
+ const filtered = filterLogLines(credentialsRedacted);
522
+ (0, fs_1.writeFileSync)((0, path_1.join)(replayDir, "logs.deterministic.filtered.txt"), filtered);
523
+ filteredCount++;
258
524
  }
259
525
  }
260
- if (count > 0) {
261
- console.log(chalk_1.default.green(` Generated ${count} filtered log file(s)`));
526
+ if (filteredCount > 0) {
527
+ console.log(chalk_1.default.green(` Generated ${filteredCount} filtered log file(s)`));
528
+ }
529
+ if (redactedCount > 0) {
530
+ console.log(chalk_1.default.green(` Redacted credential values in ${redactedCount} log file(s)`));
531
+ }
532
+ };
533
+ exports.generateFilteredLogs = generateFilteredLogs;
534
+ // Header/cookie names whose values are treated as credentials in replay logs:
535
+ // unlike `LOG_NOISE_PATTERNS` below (general noise, only stripped from the
536
+ // `.filtered.txt` copy), matches here are redacted on the raw log file too,
537
+ // since a leaked session cookie or bearer token is a real credential rather
538
+ // than just diff noise.
539
+ const CREDENTIAL_HEADER_NAMES = "Authorization|Proxy-Authorization|Cookie|Set-Cookie|X-Api-Key|X-Auth-Token|X-Csrf-Token";
540
+ // Best-effort patterns for credential-bearing header/cookie values dumped
541
+ // verbatim into replay logs (e.g. from network-stubbing or header-injection
542
+ // debug output). Run against the *entire* log file content (not line-by-line)
543
+ // so JSON/object dumps pretty-printed across multiple lines are still caught.
544
+ // Since logs are free-text (not structured JSON like the files handled by
545
+ // `redactCookiesAndHeadersInJson`), this cannot be exhaustive.
546
+ const LOG_CREDENTIAL_REPLACEMENTS = [
547
+ {
548
+ // Plain `Header-Name: <value>` dumps -- redact the rest of the line
549
+ // after the colon, preserving the header name.
550
+ pattern: new RegExp(`\\b(${CREDENTIAL_HEADER_NAMES})(\\s*:\\s*)\\S.*`, "gi"),
551
+ replacement: `$1$2${REDACTED_VALUE_MARKER}`,
552
+ },
553
+ {
554
+ // `{ name: 'Cookie', value: 'secret' }` / `{"name":"Cookie","value":"secret"}`
555
+ // (HAR-style header/cookie entries), whether logged via `util.inspect`
556
+ // (unquoted keys, single-quoted strings) or `JSON.stringify`.
557
+ pattern: new RegExp(`(['"]?name['"]?\\s*:\\s*['"]?(?:${CREDENTIAL_HEADER_NAMES})['"]?[\\s\\S]{0,80}?['"]?value['"]?\\s*:\\s*(['"]))[^'"\n]*\\2`, "gi"),
558
+ replacement: `$1${REDACTED_VALUE_MARKER}$2`,
559
+ },
560
+ {
561
+ // Raw bearer/basic auth tokens, wherever they appear (e.g. in error
562
+ // messages or stack traces, not just header dumps).
563
+ pattern: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*/g,
564
+ replacement: `Bearer ${REDACTED_VALUE_MARKER}`,
565
+ },
566
+ {
567
+ pattern: /\bBasic\s+[A-Za-z0-9+/]+=*/g,
568
+ replacement: `Basic ${REDACTED_VALUE_MARKER}`,
569
+ },
570
+ ];
571
+ // Exported for testing.
572
+ const redactLogCredentials = (raw) => {
573
+ let result = raw;
574
+ for (const { pattern, replacement } of LOG_CREDENTIAL_REPLACEMENTS) {
575
+ result = result.replace(pattern, replacement);
576
+ }
577
+ return result;
578
+ };
579
+ exports.redactLogCredentials = redactLogCredentials;
580
+ const redactLogCredentialsInFile = (filePath) => {
581
+ if (!(0, fs_1.existsSync)(filePath)) {
582
+ return false;
583
+ }
584
+ const raw = (0, fs_1.readFileSync)(filePath, "utf8");
585
+ const redacted = (0, exports.redactLogCredentials)(raw);
586
+ if (redacted === raw) {
587
+ return false;
588
+ }
589
+ (0, fs_1.writeFileSync)(filePath, redacted);
590
+ return true;
591
+ };
592
+ // Redacts credential values from the `message` field of each entry in a
593
+ // `logs.ndjson`-style file (one JSON object per line), leaving unaffected
594
+ // lines byte-for-byte identical.
595
+ const redactLogCredentialsInNdjsonFile = (filePath) => {
596
+ if (!(0, fs_1.existsSync)(filePath)) {
597
+ return false;
598
+ }
599
+ try {
600
+ const raw = (0, fs_1.readFileSync)(filePath, "utf8");
601
+ let changed = false;
602
+ const redactedLines = raw.split("\n").map((line) => {
603
+ if (line.trim() === "") {
604
+ return line;
605
+ }
606
+ let entry;
607
+ try {
608
+ entry = JSON.parse(line);
609
+ }
610
+ catch {
611
+ return line;
612
+ }
613
+ if (typeof entry.message !== "string") {
614
+ return line;
615
+ }
616
+ const redactedMessage = (0, exports.redactLogCredentials)(entry.message);
617
+ if (redactedMessage === entry.message) {
618
+ return line;
619
+ }
620
+ changed = true;
621
+ return JSON.stringify({ ...entry, message: redactedMessage });
622
+ });
623
+ if (!changed) {
624
+ return false;
625
+ }
626
+ (0, fs_1.writeFileSync)(filePath, redactedLines.join("\n"));
627
+ return true;
628
+ }
629
+ catch (error) {
630
+ const message = error instanceof Error ? error.message : String(error);
631
+ console.warn(` Warning: Could not redact credentials in ${filePath}: ${message}`);
632
+ return false;
262
633
  }
263
634
  };
264
635
  const LOG_NOISE_PATTERNS = [
@@ -1404,4 +1775,4 @@ const defaultWriteContextJson = (debugContext, workspaceDir, fileMetadata, proje
1404
1775
  (0, fs_1.writeFileSync)((0, path_1.join)(workspaceDir, debug_constants_1.DEBUG_DATA_DIRECTORY, "context.json"), JSON.stringify(context, null, 2));
1405
1776
  };
1406
1777
  //# sourceMappingURL=generate-debug-workspace.js.map
1407
- //# debugId=9b6ac7e0-327f-556e-868b-d5322291660f
1778
+ //# debugId=b0e8824a-2c75-553c-abd1-f66bad1029e6