@lincoln504/pi-research 1.3.4 → 1.3.6
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/README.md +21 -26
- package/dist/cli.mjs +108 -40
- package/dist/thread-worker.mjs +113 -25
- package/docs/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/infrastructure/browser/browser-client.ts +5 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +29 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +67 -8
- package/src/infrastructure/browser/thread-worker.mjs +113 -25
- package/src/security/osv-types.ts +1 -1
- package/src/skill-install/skill-installer.ts +43 -1
- package/src/tools/research-tool-definition.ts +3 -2
- package/src/tools/scrape.ts +6 -4
- package/src/tools/security.ts +13 -4
- package/src/utils/log-rotation.ts +44 -22
- package/src/utils/log-utils.ts +37 -2
- package/src/utils/text-utils.ts +6 -2
- package/src/youtube/potoken.ts +48 -2
- package/src/youtube/transcript-client.ts +5 -2
package/src/tools/scrape.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
MAX_SCRAPE_CONTENT_CHARS_PER_DOC,
|
|
25
25
|
} from '../constants.ts';
|
|
26
26
|
import { truncateWithMarker } from '../utils/text-utils.ts';
|
|
27
|
+
import { redactSecrets } from '../utils/log-utils.ts';
|
|
27
28
|
import { type Config, getConfig } from '../config.ts';
|
|
28
29
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
29
30
|
import { ServiceNames } from '../core/service-interfaces.ts';
|
|
@@ -261,16 +262,17 @@ export function createScrapeTool(options: {
|
|
|
261
262
|
const concurrency = p.maxConcurrency || defaultConcurrency;
|
|
262
263
|
|
|
263
264
|
try {
|
|
264
|
-
const results = await scrape(urlsToFetch, concurrency, signal,
|
|
265
|
+
const results = await scrape(urlsToFetch, concurrency, signal, config, getGlobalState().researchId, (result) => {
|
|
265
266
|
options.onUrlScrapeResult?.(result.url, result.success);
|
|
266
267
|
}, container);
|
|
267
268
|
freshResults = Array.isArray(results) ? results : [];
|
|
268
269
|
} catch (error) {
|
|
269
|
-
|
|
270
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
271
|
+
logger.error(`[scrape tool] Scrape failed: ${message}`);
|
|
270
272
|
metrics.increment('tool_scrape_calls_total', 1, { status: 'error' });
|
|
271
273
|
return {
|
|
272
|
-
content: [{ type: 'text', text: `# Scrape Failed\n\n${
|
|
273
|
-
details: { error: String(error) },
|
|
274
|
+
content: [{ type: 'text', text: `# Scrape Failed\n\n${redactSecrets(message)}` }],
|
|
275
|
+
details: { error: redactSecrets(String(error)) },
|
|
274
276
|
};
|
|
275
277
|
}
|
|
276
278
|
|
package/src/tools/security.ts
CHANGED
|
@@ -13,6 +13,15 @@ import { getMaxGatheringCalls } from '../constants.ts';
|
|
|
13
13
|
import { metrics } from '../utils/metrics.ts';
|
|
14
14
|
import type { SecuritySearchParams } from '../security/types.ts';
|
|
15
15
|
|
|
16
|
+
const MAX_DESCRIPTION_CHARS = 300;
|
|
17
|
+
|
|
18
|
+
/** Truncates an advisory description for the markdown summary line. */
|
|
19
|
+
function truncateDescription(description: string): string {
|
|
20
|
+
return description.length > MAX_DESCRIPTION_CHARS
|
|
21
|
+
? `${description.substring(0, MAX_DESCRIPTION_CHARS)}...`
|
|
22
|
+
: description;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export function createSecuritySearchTool(options: {
|
|
17
26
|
ctx: ExtensionContext;
|
|
18
27
|
tracker: ToolUsageTracker;
|
|
@@ -229,7 +238,7 @@ export function createSecuritySearchTool(options: {
|
|
|
229
238
|
}
|
|
230
239
|
}
|
|
231
240
|
const description = vuln.description;
|
|
232
|
-
markdown += `- **Description:** ${description
|
|
241
|
+
markdown += `- **Description:** ${truncateDescription(description)}\n`;
|
|
233
242
|
if (vuln.knownExploited === true) {
|
|
234
243
|
markdown += '- **[Actively Exploited]**\n';
|
|
235
244
|
}
|
|
@@ -270,7 +279,7 @@ export function createSecuritySearchTool(options: {
|
|
|
270
279
|
markdown += `- **Product:** ${vuln.product}\n`;
|
|
271
280
|
}
|
|
272
281
|
const description = vuln.description;
|
|
273
|
-
markdown += `- **Description:** ${description
|
|
282
|
+
markdown += `- **Description:** ${truncateDescription(description)}\n`;
|
|
274
283
|
if (vuln.dueDate !== undefined) {
|
|
275
284
|
markdown += `- **Due Date:** ${vuln.dueDate}\n`;
|
|
276
285
|
}
|
|
@@ -310,7 +319,7 @@ export function createSecuritySearchTool(options: {
|
|
|
310
319
|
}
|
|
311
320
|
markdown += `- **Summary:** ${adv.summary}\n`;
|
|
312
321
|
const description = adv.description ?? '';
|
|
313
|
-
markdown += `- **Description:** ${description
|
|
322
|
+
markdown += `- **Description:** ${truncateDescription(description)}\n`;
|
|
314
323
|
if (adv.published) {
|
|
315
324
|
markdown += `- **Published:** ${adv.published}\n`;
|
|
316
325
|
}
|
|
@@ -344,7 +353,7 @@ export function createSecuritySearchTool(options: {
|
|
|
344
353
|
markdown += `### ${vuln.id}\n`;
|
|
345
354
|
markdown += `- **Severity:** ${vuln.severity}\n`;
|
|
346
355
|
const description = vuln.description;
|
|
347
|
-
markdown += `- **Description:** ${description
|
|
356
|
+
markdown += `- **Description:** ${truncateDescription(description)}\n`;
|
|
348
357
|
if (vuln.affectedProducts && vuln.affectedProducts.length > 0) {
|
|
349
358
|
markdown += `- **Affected:** ${vuln.affectedProducts.join(', ')}\n`;
|
|
350
359
|
}
|
|
@@ -10,6 +10,40 @@ export interface RotationLogger {
|
|
|
10
10
|
log(...args: unknown[]): void;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Numeric equivalent of the 'a' flag string (O_WRONLY|O_APPEND|O_CREAT), with
|
|
14
|
+
// O_NOFOLLOW added when the platform defines it (not available on Windows) so
|
|
15
|
+
// the open refuses to follow a symlink an attacker with write access to the
|
|
16
|
+
// log directory could plant at this path in the window between the preceding
|
|
17
|
+
// unlink/rename and this reopen — defense-in-depth on top of an already-narrow
|
|
18
|
+
// threat model (it requires local write access to this directory to begin with).
|
|
19
|
+
const APPEND_CREATE_NOFOLLOW_FLAGS =
|
|
20
|
+
fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT |
|
|
21
|
+
(typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0);
|
|
22
|
+
|
|
23
|
+
/** Best-effort (re)create `filePath` with `mode`, refusing to follow a symlink
|
|
24
|
+
* planted at the path, and unconditionally stamp `mode` on it. Uses fchmodSync
|
|
25
|
+
* on the already-open (and O_NOFOLLOW-verified) descriptor rather than
|
|
26
|
+
* fs.chmodSync(filePath, ...) — chmodSync operates on the path and follows
|
|
27
|
+
* symlinks, which would silently re-open the exact hole O_NOFOLLOW closes if
|
|
28
|
+
* a symlink appears at this path between the open above and a path-based
|
|
29
|
+
* chmod. mode is only applied by open() when O_CREAT actually creates the
|
|
30
|
+
* file, so callers relying on the file always ending up at `mode` (even when
|
|
31
|
+
* a concurrent writer already recreated it) still need this explicit stamp.
|
|
32
|
+
* Failures are swallowed by design — see call sites.
|
|
33
|
+
* Exported for unit testing of the symlink-refusal behavior. */
|
|
34
|
+
export function touchOwnerOnly(filePath: string, mode: number): void {
|
|
35
|
+
let fd: number | undefined;
|
|
36
|
+
try {
|
|
37
|
+
fd = fs.openSync(filePath, APPEND_CREATE_NOFOLLOW_FLAGS, mode);
|
|
38
|
+
fs.fchmodSync(fd, mode);
|
|
39
|
+
} catch { /* best-effort — the next append recreates the file */ }
|
|
40
|
+
finally {
|
|
41
|
+
if (fd !== undefined) {
|
|
42
|
+
try { fs.closeSync(fd); } catch { /* best-effort */ }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
13
47
|
export class LogRotation {
|
|
14
48
|
private readonly MAX_LOG_SIZE = 10 * 1024 * 1024; // 10MB max file size
|
|
15
49
|
private readonly MAX_LOG_FILES = 10; // Keep last 10 archived logs
|
|
@@ -29,15 +63,11 @@ export class LogRotation {
|
|
|
29
63
|
fs.unlinkSync(logFile);
|
|
30
64
|
// Same rationale as the post-rotation recreate in rotateLogFile: the
|
|
31
65
|
// unlink discarded the 0o600 file, and a umask-default recreate by the
|
|
32
|
-
// next append would drop the owner-only posture.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// the unlink and the open makes the creation-time 0o600 a no-op.
|
|
38
|
-
try {
|
|
39
|
-
fs.chmodSync(logFile, 0o600);
|
|
40
|
-
} catch { /* best-effort */ }
|
|
66
|
+
// next append would drop the owner-only posture. touchOwnerOnly stamps
|
|
67
|
+
// the mode unconditionally (via fchmod on its own fd), covering the
|
|
68
|
+
// case where a concurrent append already recreated the file at umask
|
|
69
|
+
// perms between the unlink and this call.
|
|
70
|
+
touchOwnerOnly(logFile, 0o600);
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
const files = fs.readdirSync(logDir);
|
|
@@ -92,20 +122,12 @@ export class LogRotation {
|
|
|
92
122
|
// Rotation moved the 0o600 file aside; without an explicit recreate the next
|
|
93
123
|
// append materializes it at the umask default (typically 0644) in a
|
|
94
124
|
// world-traversable tmpdir, silently dropping the owner-only posture Logger
|
|
95
|
-
// establishes at construction.
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} catch { /* best-effort — the next append recreates the file */ }
|
|
100
|
-
// The mode above applies only if THIS open created the file. Several
|
|
101
|
-
// processes append to this consolidated log, so a concurrent async append
|
|
102
|
-
// can recreate it at umask perms between the rename and the open, turning
|
|
103
|
-
// the 0o600 into a no-op — stamp the mode unconditionally to close that
|
|
104
|
-
// window for good. ENOENT (nothing recreated it and the open failed) is
|
|
125
|
+
// establishes at construction. touchOwnerOnly stamps the mode
|
|
126
|
+
// unconditionally (via fchmod on its own fd) regardless of whether this
|
|
127
|
+
// open created the file or a concurrent append already recreated it
|
|
128
|
+
// between the rename and this call — ENOENT (open itself failed) is
|
|
105
129
|
// tolerable: best-effort posture, same as the recreate itself.
|
|
106
|
-
|
|
107
|
-
fs.chmodSync(logFile, 0o600);
|
|
108
|
-
} catch { /* best-effort */ }
|
|
130
|
+
touchOwnerOnly(logFile, 0o600);
|
|
109
131
|
|
|
110
132
|
|
|
111
133
|
// Clean up old archives
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -133,6 +133,25 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
|
|
|
133
133
|
* Applied to every message before it reaches the log file or the console, so
|
|
134
134
|
* neither sink receives clear-text secrets or unbounded network data.
|
|
135
135
|
*/
|
|
136
|
+
// How much raw input the regex chain below is allowed to scan before a final
|
|
137
|
+
// truncation to MAX_LOG_MESSAGE_LENGTH. A multiple of MAX_LOG_MESSAGE_LENGTH,
|
|
138
|
+
// not 1:1, because most patterns below SHRINK text (a 64-char secret becomes
|
|
139
|
+
// "[REDACTED]"), so more than `keep` raw characters can be needed to fully
|
|
140
|
+
// populate the final truncated output. Nothing past this point can ever
|
|
141
|
+
// survive into the visible output regardless, so skipping it is free — this
|
|
142
|
+
// is what bounds redactSecrets() to O(MAX_LOG_MESSAGE_LENGTH) instead of
|
|
143
|
+
// O(input length) on multi-megabyte scraped payloads.
|
|
144
|
+
const REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
|
|
145
|
+
|
|
146
|
+
// Characters that can appear inside an unbroken secret token (hex, base64url,
|
|
147
|
+
// JWT segments, API keys). Used to push the REDACT_SCAN_LENGTH cut point past
|
|
148
|
+
// the end of whatever token it would otherwise land in the middle of.
|
|
149
|
+
const TOKEN_CHAR_PATTERN = /[A-Za-z0-9_\-./+=]/;
|
|
150
|
+
// Hard cap on how far the boundary can be pushed past REDACT_SCAN_LENGTH —
|
|
151
|
+
// bounds the extra work to a fixed amount even against an adversarial run of
|
|
152
|
+
// token-like characters.
|
|
153
|
+
const REDACT_SCAN_EXTENSION_MAX = 2048;
|
|
154
|
+
|
|
136
155
|
export function redactSecrets(message: string): string {
|
|
137
156
|
// Redact BEFORE truncating. Every pattern below needs to see a secret in
|
|
138
157
|
// full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
|
|
@@ -140,7 +159,18 @@ export function redactSecrets(message: string): string {
|
|
|
140
159
|
// whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
|
|
141
160
|
// browser auth secret) too short for any pattern to recognize, so it was
|
|
142
161
|
// written out in the clear right before the "…[truncated N chars]" marker.
|
|
143
|
-
|
|
162
|
+
const rawLength = message.length;
|
|
163
|
+
// The REDACT_SCAN_LENGTH cut below is itself a truncation, subject to the
|
|
164
|
+
// exact same mid-token risk — extend it forward past any run of token
|
|
165
|
+
// characters straddling the cut point so it never slices a secret in half.
|
|
166
|
+
let scanEnd = REDACT_SCAN_LENGTH;
|
|
167
|
+
if (rawLength > scanEnd) {
|
|
168
|
+
const extensionLimit = Math.min(rawLength, scanEnd + REDACT_SCAN_EXTENSION_MAX);
|
|
169
|
+
while (scanEnd < extensionLimit && TOKEN_CHAR_PATTERN.test(message.charAt(scanEnd))) {
|
|
170
|
+
scanEnd += 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let out = rawLength > scanEnd ? message.slice(0, scanEnd) : message;
|
|
144
174
|
out = out.replace(ANSI_PATTERN, '');
|
|
145
175
|
out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
|
|
146
176
|
// Redact whole-token formats BEFORE the key/value pass: the KV value class
|
|
@@ -172,7 +202,12 @@ export function redactSecrets(message: string): string {
|
|
|
172
202
|
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
173
203
|
}
|
|
174
204
|
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
175
|
-
|
|
205
|
+
// rawLength can be SMALLER than out.length when redaction net-expands text
|
|
206
|
+
// (e.g. "key=1" → "key=[REDACTED]"), which would make `rawLength - keep`
|
|
207
|
+
// negative. out.length is always > keep in this branch, so the max() is
|
|
208
|
+
// never negative; it still prefers the larger rawLength when the
|
|
209
|
+
// REDACT_SCAN_LENGTH cut discarded raw content redaction then shrank.
|
|
210
|
+
out = `${out.slice(0, keep)}…[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
|
|
176
211
|
}
|
|
177
212
|
return out;
|
|
178
213
|
}
|
package/src/utils/text-utils.ts
CHANGED
|
@@ -188,8 +188,12 @@ export function formatDuration(ms: number): string {
|
|
|
188
188
|
if (ms < 1000) return `${ms}ms`;
|
|
189
189
|
const seconds = ms / 1000;
|
|
190
190
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
// Round the total first, then derive minutes/seconds from that integer —
|
|
192
|
+
// rounding remainingSeconds independently of minutes let e.g. 59.6s round up
|
|
193
|
+
// to a nonsensical "Xm 60s" instead of rolling over to the next minute.
|
|
194
|
+
const totalSeconds = Math.round(seconds);
|
|
195
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
196
|
+
const remainingSeconds = totalSeconds % 60;
|
|
193
197
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
194
198
|
const hours = Math.floor(minutes / 60);
|
|
195
199
|
const remainingMinutes = minutes % 60;
|
package/src/youtube/potoken.ts
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import { logger } from '../logger.ts';
|
|
45
|
+
import { safeUnref } from '../utils/safe-unref.ts';
|
|
45
46
|
import { metrics } from '../utils/metrics.ts';
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -76,6 +77,22 @@ function describeError(error: unknown): string {
|
|
|
76
77
|
// Module-level mutex: serialize all globalThis mutation across concurrent callers.
|
|
77
78
|
let mintChain: Promise<unknown> = Promise.resolve();
|
|
78
79
|
|
|
80
|
+
// Set when a hard-release fires while its doMint() is still bridging globals —
|
|
81
|
+
// globalThis is a single process-global resource, so unlike a per-context lock
|
|
82
|
+
// ANY new bridge while one is still installed is unsafe, not just one from the
|
|
83
|
+
// same caller. Blocks new mints until the stuck call's own doMint() actually
|
|
84
|
+
// settles (success or failure) and unbridges for real.
|
|
85
|
+
let wedged = false;
|
|
86
|
+
|
|
87
|
+
// How long the mutex waits for a straggling doMint() before force-releasing
|
|
88
|
+
// the chain for the next caller. doMint() awaits non-fetch operations with no
|
|
89
|
+
// abort signal (interpreter eval, BotGuard VM execution against a
|
|
90
|
+
// hostile/malformed attestation script) that could in principle hang forever;
|
|
91
|
+
// without this bound, a single wedged call would permanently break YouTube
|
|
92
|
+
// transcript fetching for the rest of the process. Mirrors the hard-release
|
|
93
|
+
// timer on the browser page-creation mutex in thread-worker-messaging.ts.
|
|
94
|
+
const MINT_HARD_RELEASE_MS = 5 * 60 * 1000;
|
|
95
|
+
|
|
79
96
|
/**
|
|
80
97
|
* Mint a session-bound token (for `identifiers[0]`, the visitorData) plus one
|
|
81
98
|
* content-bound token per remaining identifier (the video IDs), all from a
|
|
@@ -88,10 +105,39 @@ export async function mintPoTokens(
|
|
|
88
105
|
identifiers: string[],
|
|
89
106
|
opts: MintOptions = {},
|
|
90
107
|
): Promise<Map<string, string>> {
|
|
108
|
+
if (wedged) {
|
|
109
|
+
throw new Error('PoToken minter is wedged by a prior mint that never settled; refusing to start a new mint until it resolves.');
|
|
110
|
+
}
|
|
91
111
|
// Chain on the mutex so only one bridged section runs at a time.
|
|
92
112
|
const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
|
|
93
|
-
// Keep the chain alive regardless of this call's success/failure
|
|
94
|
-
|
|
113
|
+
// Keep the chain alive regardless of this call's success/failure, but bound
|
|
114
|
+
// how long the NEXT caller waits on it: if `run` never settles, force the
|
|
115
|
+
// chain open after the hard timeout rather than wedging every future
|
|
116
|
+
// mintPoTokens() call behind it forever. Marking `wedged` (rather than
|
|
117
|
+
// letting the next caller straight through to its own doMint()) prevents
|
|
118
|
+
// two calls from bridging globalThis at once — the abandoned `run` may
|
|
119
|
+
// still be mid-bridge in the background, and a second bridge/unbridge pair
|
|
120
|
+
// racing it would restore the WRONG saved descriptors, permanently
|
|
121
|
+
// corrupting globalThis.window/document/navigator for the process.
|
|
122
|
+
//
|
|
123
|
+
// Derived from `settled` (which never rejects) rather than attaching
|
|
124
|
+
// directly to `run` — `run.finally(...)` would return a new, unconsumed
|
|
125
|
+
// promise that adopts `run`'s rejection, becoming an unhandled rejection
|
|
126
|
+
// whenever doMint() throws.
|
|
127
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
128
|
+
// Once doMint() actually settles (success or failure), its own `finally`
|
|
129
|
+
// always runs unbridge() for real — safe for a new mint to bridge again.
|
|
130
|
+
settled.then(() => { wedged = false; });
|
|
131
|
+
let hardTimer: NodeJS.Timeout;
|
|
132
|
+
const hardRelease = new Promise<void>(resolve => {
|
|
133
|
+
hardTimer = setTimeout(() => {
|
|
134
|
+
logger.warn('[youtube] potoken mint mutex force-released — doMint() did not settle within the hard timeout');
|
|
135
|
+
wedged = true;
|
|
136
|
+
resolve();
|
|
137
|
+
}, MINT_HARD_RELEASE_MS);
|
|
138
|
+
safeUnref(hardTimer);
|
|
139
|
+
});
|
|
140
|
+
mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
|
|
95
141
|
return run;
|
|
96
142
|
}
|
|
97
143
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
|
|
21
21
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
|
+
import { redactSecrets } from '../utils/log-utils.ts';
|
|
23
24
|
import { metrics } from '../utils/metrics.ts';
|
|
24
25
|
import { readTextCapped } from '../utils/http-body.ts';
|
|
25
26
|
import { mintPoTokens } from './potoken.ts';
|
|
@@ -355,8 +356,10 @@ function parseJson3(body: string): string {
|
|
|
355
356
|
// Log the real cause: an unparseable body is NOT necessarily an empty
|
|
356
357
|
// bot-protection response (the caller's default diagnosis) — it can be an HTML
|
|
357
358
|
// interstitial or a response-shape change. A short snippet disambiguates without
|
|
358
|
-
// dumping a huge/again-sensitive body.
|
|
359
|
-
|
|
359
|
+
// dumping a huge/again-sensitive body. Redact BEFORE truncating to 120 chars —
|
|
360
|
+
// slicing first can cut a secret-shaped token mid-way, leaving a fragment too
|
|
361
|
+
// short for redactSecrets' whole-token patterns to recognize.
|
|
362
|
+
logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
|
|
360
363
|
return '';
|
|
361
364
|
}
|
|
362
365
|
const events = (parsed as { events?: unknown[] }).events;
|