@huaqiu/dsh-plugin-log 0.3.11

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/src/index.ts ADDED
@@ -0,0 +1,445 @@
1
+ /**
2
+ * `@huaqiu/dsh-plugin-log` — one server-side log for every Huaqiu DSH plugin.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Plugin diagnostics used to live only in the browser console: each plugin's
7
+ * browser half logged to `console.*`, and the node half — the half that
8
+ * actually owns credentials, HTTP routes and agent tools — logged nothing at
9
+ * all outside the DSH process's stdout, which HQ Edge only captures as an
10
+ * opaque text blob. When a `deepseek-harness` upgrade broke credential
11
+ * propagation, the only way to see what the node half resolved was to add
12
+ * temporary prints and re-run.
13
+ *
14
+ * This package gives every plugin one shared, file-backed, cross-platform log
15
+ * under the DSH home:
16
+ *
17
+ * <DSH_HOME>/logs/dsh-plugins.log (current)
18
+ * <DSH_HOME>/logs/dsh-plugins.1.log (previous, after rotation)
19
+ *
20
+ * ## DSH home resolution
21
+ *
22
+ * The directory comes from `@deepseek-ai/dsh-home-paths`, the same single-root
23
+ * helper the rest of DSH uses, so every override HQ Edge (or any other host)
24
+ * performs is honoured for free:
25
+ *
26
+ * explicit `configure({ dir })` > $DSH_PLUGIN_LOG_DIR > $DSH_HOME > ~/.dsh
27
+ *
28
+ * HQ Edge spawns DSH with `DSH_HOME` pointing at its own versioned, per-user
29
+ * data directory (`…/HQ/hq-edge/<ver>/dsh-home`), so plugin logs land next to
30
+ * the rest of that installation's state and never in the user's `~/.dsh`.
31
+ *
32
+ * ## Cross-platform notes
33
+ *
34
+ * - Pure `node:fs` / `node:os` / `node:path` — no native modules, no shelling
35
+ * out, nothing that differs between macOS, Linux and Windows but the path
36
+ * separators (handled by `node:path`).
37
+ * - Rotation uses unlink-then-rename, because Windows cannot rename over an
38
+ * existing file.
39
+ * - File names never embed `:` or other Windows-illegal characters.
40
+ * - If the log directory cannot be created (read-only install, locked-down
41
+ * profile) we fall back to the OS temp dir, and if that also fails we keep
42
+ * logging to the console. A logging failure must never take a plugin down.
43
+ *
44
+ * ## Safety
45
+ *
46
+ * - Every field passes through `redact()`: credential-shaped keys and values
47
+ * are replaced with `[redacted]`, so the log is safe to share.
48
+ * - `getLogger()` never throws. Neither does any log call.
49
+ */
50
+
51
+ import {
52
+ mkdirSync,
53
+ renameSync,
54
+ rmSync,
55
+ statSync,
56
+ } from 'node:fs'
57
+ import { appendFile } from 'node:fs/promises'
58
+ import { tmpdir } from 'node:os'
59
+ import { join } from 'node:path'
60
+ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
61
+
62
+ import { isEnabled, parseLevel, type LogLevel } from './levels.js'
63
+ import { redact } from './redact.js'
64
+
65
+ export type { LogLevel } from './levels.js'
66
+ export { LOG_LEVELS, levelRank, parseLevel } from './levels.js'
67
+ export { redact, REDACTED } from './redact.js'
68
+
69
+ /** Arbitrary structured context attached to a log record. */
70
+ export type LogFields = Record<string, unknown>
71
+
72
+ /** The logger surface every plugin codes against. */
73
+ export interface PluginLogger {
74
+ readonly component: string
75
+ debug(message: string, fields?: LogFields): void
76
+ info(message: string, fields?: LogFields): void
77
+ warn(message: string, fields?: LogFields): void
78
+ error(message: string, fields?: LogFields): void
79
+ /** Derive a logger that always carries `fields`. */
80
+ child(fields: LogFields): PluginLogger
81
+ }
82
+
83
+ export interface LoggingOptions {
84
+ /** Override the log directory (highest precedence, beats every env var). */
85
+ dir?: string
86
+ /** Base file name inside the directory. Default `dsh-plugins.log`. */
87
+ fileName?: string
88
+ /** Minimum file level. Default from `$DSH_PLUGIN_LOG_LEVEL`, else `info`. */
89
+ level?: LogLevel
90
+ /**
91
+ * Minimum level mirrored to the console. HQ Edge captures DSH's stdout, so
92
+ * this is how a plugin surfaces a problem without anyone opening a file.
93
+ * Default from `$DSH_PLUGIN_LOG_CONSOLE`, else `warn`.
94
+ */
95
+ consoleLevel?: LogLevel | 'off'
96
+ /** Rotate once the current file exceeds this many bytes. Default 5 MiB. */
97
+ maxBytes?: number
98
+ /** Number of files kept (current + rotated). Default 4. */
99
+ maxFiles?: number
100
+ }
101
+
102
+ const DEFAULT_FILE_NAME = 'dsh-plugins.log'
103
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
104
+ const DEFAULT_MAX_FILES = 4
105
+ const DEFAULT_LEVEL: LogLevel = 'info'
106
+ const DEFAULT_CONSOLE_LEVEL: LogLevel = 'warn'
107
+
108
+ // ── configuration ───────────────────────────────────────────────────────────
109
+
110
+ interface ResolvedConfig {
111
+ dir: string
112
+ fileName: string
113
+ level: LogLevel
114
+ consoleLevel: LogLevel | 'off'
115
+ maxBytes: number
116
+ maxFiles: number
117
+ }
118
+
119
+ /**
120
+ * Process-wide logger state, held on `globalThis`.
121
+ *
122
+ * Plugins are built independently, so each one gets its OWN bundled copy of
123
+ * this module. Without a shared home, "one unified log" would degrade into one
124
+ * sink (and one rotation counter) per plugin. Keying the state off a
125
+ * `Symbol.for` on `globalThis` makes every copy — bundled, external, or
126
+ * duplicated across installs — cooperate inside the same DSH process.
127
+ */
128
+ const STATE_KEY = Symbol.for('@huaqiu/dsh-plugin-log/state')
129
+
130
+ interface LogState {
131
+ override: Partial<LoggingOptions> | null
132
+ resolved: ResolvedConfig | null
133
+ sink: FileSink | null
134
+ bootstrapped: boolean
135
+ }
136
+
137
+ function state(): LogState {
138
+ const g = globalThis as Record<symbol, unknown>
139
+ const existing = g[STATE_KEY] as LogState | undefined
140
+ if (existing) return existing
141
+ const fresh: LogState = { override: null, resolved: null, sink: null, bootstrapped: false }
142
+ g[STATE_KEY] = fresh
143
+ return fresh
144
+ }
145
+
146
+ const getOverride = (): Partial<LoggingOptions> | null => state().override
147
+ const setOverride = (value: Partial<LoggingOptions> | null): void => { state().override = value }
148
+ const getSink = (): FileSink | null => state().sink
149
+ const setSink = (value: FileSink | null): void => { state().sink = value }
150
+
151
+ /**
152
+ * Programmatically configure logging. Call before the first `getLogger()`;
153
+ * later calls take effect on the next `resetLogging()` (tests, host re-init).
154
+ */
155
+ export function configureLogging(options: LoggingOptions): void {
156
+ setOverride({ ...(getOverride() ?? {}), ...options })
157
+ state().resolved = null
158
+ }
159
+
160
+ /** Forget all configuration and cached loggers. Test/teardown helper. */
161
+ export function resetLogging(): void {
162
+ const s = state()
163
+ s.override = null
164
+ s.resolved = null
165
+ s.sink = null
166
+ s.bootstrapped = false
167
+ }
168
+
169
+ function env(name: string): string | undefined {
170
+ const value = process.env[name]
171
+ return value !== undefined && value.trim().length > 0 ? value.trim() : undefined
172
+ }
173
+
174
+ function readInt(value: string | undefined, fallback: number): number {
175
+ if (value === undefined) return fallback
176
+ const parsed = Number.parseInt(value, 10)
177
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
178
+ }
179
+
180
+ /**
181
+ * Pick the log directory, honouring every override in precedence order.
182
+ * Falls back to the OS temp directory when the DSH home is not writable — a
183
+ * plugin that cannot log to its preferred location still logs somewhere.
184
+ */
185
+ function resolveLogDir(explicit?: string): { dir: string; fallback: boolean } {
186
+ const candidates: string[] = []
187
+ if (explicit !== undefined && explicit.length > 0) candidates.push(explicit)
188
+ const fromEnv = env('DSH_PLUGIN_LOG_DIR')
189
+ if (fromEnv) candidates.push(fromEnv)
190
+ // The DSH home is always a candidate, so a host that overrides DSH_HOME (HQ
191
+ // Edge does) gets its logs in its own tree without setting anything else.
192
+ candidates.push(dshHomePath('logs'))
193
+ candidates.push(join(tmpdir(), 'hq-dsh-plugins', 'logs'))
194
+
195
+ for (let i = 0; i < candidates.length; i += 1) {
196
+ const dir = candidates[i]
197
+ if (!dir) continue
198
+ try {
199
+ mkdirSync(dir, { recursive: true })
200
+ return { dir, fallback: i > 0 }
201
+ } catch {
202
+ /* read-only or locked-down — try the next candidate */
203
+ }
204
+ }
205
+
206
+ // Nothing is writable. Report the last candidate; the sink degrades to
207
+ // console output rather than failing.
208
+ const last = candidates[candidates.length - 1]
209
+ return { dir: last ?? '.', fallback: true }
210
+ }
211
+
212
+ function currentConfig(): ResolvedConfig {
213
+ const s = state()
214
+ if (s.resolved) return s.resolved
215
+ const o = s.override
216
+ const { dir } = resolveLogDir(o?.dir)
217
+ const consoleRaw = o?.consoleLevel ?? env('DSH_PLUGIN_LOG_CONSOLE')
218
+ s.resolved = {
219
+ dir,
220
+ fileName: o?.fileName ?? env('DSH_PLUGIN_LOG_FILE') ?? DEFAULT_FILE_NAME,
221
+ level: o?.level ?? parseLevel(env('DSH_PLUGIN_LOG_LEVEL'), DEFAULT_LEVEL),
222
+ consoleLevel:
223
+ o?.consoleLevel === 'off'
224
+ ? 'off'
225
+ : consoleRaw === 'off' || consoleRaw === 'none' || consoleRaw === '0'
226
+ ? 'off'
227
+ : consoleRaw === 'all' || consoleRaw === '1' || consoleRaw === 'true'
228
+ ? 'debug'
229
+ : parseLevel(consoleRaw, DEFAULT_CONSOLE_LEVEL),
230
+ maxBytes: o?.maxBytes ?? readInt(env('DSH_PLUGIN_LOG_MAX_BYTES'), DEFAULT_MAX_BYTES),
231
+ maxFiles: o?.maxFiles ?? readInt(env('DSH_PLUGIN_LOG_MAX_FILES'), DEFAULT_MAX_FILES),
232
+ }
233
+ return s.resolved
234
+ }
235
+
236
+ /** Absolute path of the current log file, or `null` before first use. */
237
+ export function logFilePath(): string | null {
238
+ const s = getSink()
239
+ return s ? s.path : null
240
+ }
241
+
242
+ /** Absolute directory plugin logs are written to. */
243
+ export function logDir(): string {
244
+ return currentConfig().dir
245
+ }
246
+
247
+ // ── file sink ───────────────────────────────────────────────────────────────
248
+
249
+ /**
250
+ * Serialized appender with size-based rotation.
251
+ *
252
+ * Writes are chained on a single promise so concurrent log calls from different
253
+ * plugins in the same process can never interleave or race the rotation.
254
+ */
255
+ class FileSink {
256
+ readonly path: string
257
+ private queue: Promise<void> = Promise.resolve()
258
+ private bytes: number
259
+
260
+ constructor(
261
+ private readonly dir: string,
262
+ private readonly fileName: string,
263
+ private readonly maxBytes: number,
264
+ private readonly maxFiles: number,
265
+ ) {
266
+ this.path = join(dir, fileName)
267
+ this.bytes = this.currentSize()
268
+ }
269
+
270
+ private currentSize(): number {
271
+ try {
272
+ return statSync(this.path).size
273
+ } catch {
274
+ return 0
275
+ }
276
+ }
277
+
278
+ /** Rotate when appending `next` bytes would exceed the cap. */
279
+ private rotateIfNeeded(next: number): void {
280
+ if (this.maxBytes <= 0) return
281
+ if (this.bytes + next <= this.maxBytes) return
282
+ try {
283
+ // Shift: .2 -> .3, .1 -> .2, current -> .1 (oldest is dropped).
284
+ for (let i = this.maxFiles - 1; i >= 1; i -= 1) {
285
+ const from = i === 1 ? join(this.dir, this.fileName) : this.rotated(i - 1)
286
+ const to = this.rotated(i)
287
+ if (!exists(from)) continue
288
+ // Windows cannot rename over an existing file — remove the target first.
289
+ if (exists(to)) rmSync(to, { force: true })
290
+ renameSync(from, to)
291
+ }
292
+ this.bytes = this.currentSize()
293
+ } catch {
294
+ // Rotation is best-effort: never lose the write because of it.
295
+ }
296
+ }
297
+
298
+ private rotated(index: number): string {
299
+ const dot = this.fileName.lastIndexOf('.')
300
+ const stem = dot > 0 ? this.fileName.slice(0, dot) : this.fileName
301
+ const ext = dot > 0 ? this.fileName.slice(dot) : ''
302
+ return join(this.dir, `${stem}.${index}${ext}`)
303
+ }
304
+
305
+ /** Enqueue one already-serialized line. Never rejects. */
306
+ write(line: string): void {
307
+ const size = Buffer.byteLength(line, 'utf8')
308
+ this.bytes += size
309
+ this.queue = this.queue
310
+ .then(async () => {
311
+ this.rotateIfNeeded(size)
312
+ await appendFile(this.path, line, 'utf8')
313
+ })
314
+ .catch(() => {
315
+ /* a full or locked disk must never surface as a plugin failure */
316
+ })
317
+ }
318
+
319
+ /** Wait for everything enqueued so far to reach the file. */
320
+ flush(): Promise<void> {
321
+ return this.queue
322
+ }
323
+ }
324
+
325
+ function exists(path: string): boolean {
326
+ try {
327
+ statSync(path)
328
+ return true
329
+ } catch {
330
+ return false
331
+ }
332
+ }
333
+
334
+ function currentSink(): FileSink {
335
+ const existing = getSink()
336
+ if (existing) return existing
337
+ const cfg = currentConfig()
338
+ const created = new FileSink(cfg.dir, cfg.fileName, cfg.maxBytes, cfg.maxFiles)
339
+ setSink(created)
340
+ return created
341
+ }
342
+
343
+ /** Wait for all pending writes to land (shutdown hooks, tests). */
344
+ export async function flushLogs(): Promise<void> {
345
+ await getSink()?.flush()
346
+ }
347
+
348
+ // ── logger ──────────────────────────────────────────────────────────────────
349
+
350
+ function consoleMethod(level: LogLevel): 'log' | 'info' | 'warn' | 'error' {
351
+ if (level === 'error') return 'error'
352
+ if (level === 'warn') return 'warn'
353
+ if (level === 'info') return 'info'
354
+ return 'log'
355
+ }
356
+
357
+ function createLogger(component: string, defaults: LogFields): PluginLogger {
358
+ const emit = (level: LogLevel, message: string, fields?: LogFields): void => {
359
+ try {
360
+ const cfg = currentConfig()
361
+ if (!isEnabled(level, cfg.level) && !(cfg.consoleLevel !== 'off' && isEnabled(level, cfg.consoleLevel))) {
362
+ return
363
+ }
364
+
365
+ const record: LogFields = {
366
+ ts: new Date().toISOString(),
367
+ level,
368
+ component,
369
+ pid: process.pid,
370
+ msg: message,
371
+ }
372
+ if (Object.keys(defaults).length > 0) Object.assign(record, defaults)
373
+ if (fields && Object.keys(fields).length > 0) Object.assign(record, fields)
374
+
375
+ const safe = redact(record) as LogFields
376
+
377
+ if (isEnabled(level, cfg.level)) {
378
+ currentSink().write(`${JSON.stringify(safe)}\n`)
379
+ }
380
+ if (cfg.consoleLevel !== 'off' && isEnabled(level, cfg.consoleLevel)) {
381
+ const { msg, ...rest } = safe
382
+ // eslint-disable-next-line no-console
383
+ console[consoleMethod(level)](`[${component}] ${String(msg)}`, rest)
384
+ }
385
+ } catch {
386
+ /* logging must never throw */
387
+ }
388
+ }
389
+
390
+ return {
391
+ component,
392
+ debug: (message, fields) => emit('debug', message, fields),
393
+ info: (message, fields) => emit('info', message, fields),
394
+ warn: (message, fields) => emit('warn', message, fields),
395
+ error: (message, fields) => emit('error', message, fields),
396
+ child: (fields) => createLogger(component, { ...defaults, ...fields }),
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Get (or create) the logger for `component`.
402
+ *
403
+ * `component` should be the short plugin name used everywhere else in its
404
+ * output — `dsh-auth`, `dsh-artifacts`, `dsh-schematic-gen` — so the unified
405
+ * file can be filtered with a single grep.
406
+ */
407
+ export function getLogger(component: string, defaults: LogFields = {}): PluginLogger {
408
+ bootstrap()
409
+ return createLogger(component, defaults)
410
+ }
411
+
412
+ // ── boot banner ─────────────────────────────────────────────────────────────
413
+
414
+ /**
415
+ * Write one record describing where this process is logging.
416
+ *
417
+ * This is the line that makes the next "we upgraded deepseek-harness and
418
+ * something stopped syncing" investigation cheap: it pins the DSH home, the
419
+ * resolved log file, the platform and the DSH/plugin versions in use at the
420
+ * moment the first plugin touched the log.
421
+ */
422
+ function bootstrap(): void {
423
+ const s = state()
424
+ if (s.bootstrapped) return
425
+ s.bootstrapped = true
426
+ const cfg = currentConfig()
427
+ const logger = createLogger('dsh-plugin-log', {})
428
+ let dshHome: string | null = null
429
+ try {
430
+ dshHome = resolveDshHome()
431
+ } catch {
432
+ dshHome = null
433
+ }
434
+ logger.info('plugin log ready', {
435
+ logFile: join(cfg.dir, cfg.fileName),
436
+ logDir: cfg.dir,
437
+ dshHome,
438
+ level: cfg.level,
439
+ consoleLevel: cfg.consoleLevel,
440
+ pid: process.pid,
441
+ node: process.version,
442
+ platform: process.platform,
443
+ arch: process.arch,
444
+ })
445
+ }
package/src/levels.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Log levels for `@huaqiu/dsh-plugin-log`.
3
+ *
4
+ * Four levels only — enough to separate "noise while debugging" from "someone
5
+ * must look at this", and few enough that a reader of the log file can filter
6
+ * with a single grep.
7
+ */
8
+
9
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
10
+
11
+ /** Levels ordered from most to least verbose. */
12
+ export const LOG_LEVELS: readonly LogLevel[] = ['debug', 'info', 'warn', 'error']
13
+
14
+ const RANK: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
15
+
16
+ /** Numeric rank of a level — higher means more severe. */
17
+ export function levelRank(level: LogLevel): number {
18
+ return RANK[level]
19
+ }
20
+
21
+ /**
22
+ * Parse a level from an arbitrary (env-supplied) value.
23
+ *
24
+ * Unparseable or empty input falls back instead of throwing: a bad
25
+ * `DSH_PLUGIN_LOG_LEVEL` in a customer environment must degrade to "log
26
+ * normally", never to "crash the plugin host".
27
+ */
28
+ export function parseLevel(value: unknown, fallback: LogLevel): LogLevel {
29
+ if (typeof value !== 'string') return fallback
30
+ const normalized = value.trim().toLowerCase()
31
+ return (LOG_LEVELS as readonly string[]).includes(normalized)
32
+ ? (normalized as LogLevel)
33
+ : fallback
34
+ }
35
+
36
+ /** True when `level` is at least as severe as `threshold`. */
37
+ export function isEnabled(level: LogLevel, threshold: LogLevel): boolean {
38
+ return levelRank(level) >= levelRank(threshold)
39
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Credential redaction for `@huaqiu/dsh-plugin-log`.
3
+ *
4
+ * The whole point of a shared plugin log is that it is safe to hand to someone
5
+ * else when debugging — which means it must never become a second, unmanaged
6
+ * copy of the user's credential. Everything written through this logger goes
7
+ * through `redact()` first, so a caller cannot leak a token by accident.
8
+ *
9
+ * Two rules:
10
+ *
11
+ * 1. **Key-based** — any field whose name looks credential-ish
12
+ * (`token`, `authorization`, `password`, `cookie`, `apiKey`, …) has its
13
+ * value replaced, at any nesting depth.
14
+ * 2. **Shape-based** — string values that look like a bearer header or a
15
+ * long opaque secret are replaced even when the key is innocent
16
+ * (`headers: ['Authorization: Bearer ey…']`).
17
+ *
18
+ * Redaction is deliberately key-name based rather than "redact every long
19
+ * string": log readability matters, and most long strings (project paths,
20
+ * URLs, artifact ids) carry no secret.
21
+ */
22
+
23
+ /** Field names whose values are always replaced. Matches at any depth. */
24
+ const SENSITIVE_KEY = /(token|secret|password|passwd|pwd|authorization|cookie|api[-_]?key|access[-_]?key|credential)/i
25
+
26
+ /** `Bearer <opaque>` / `Basic <opaque>` inside a free-form string. */
27
+ const BEARER_IN_STRING = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i
28
+
29
+ /** Strings at least this long that look like a single opaque credential blob. */
30
+ const OPAQUE_SECRET = /^[A-Za-z0-9_-]{32,}$/
31
+
32
+ export const REDACTED = '[redacted]'
33
+
34
+ /** Maximum object depth walked before the value is collapsed. Cycle-safe. */
35
+ const MAX_DEPTH = 6
36
+
37
+ /**
38
+ * Return a copy of `value` with credential-ish fields replaced.
39
+ *
40
+ * Never throws and never mutates the caller's object — a logging call must not
41
+ * be able to change plugin state or crash the host.
42
+ */
43
+ export function redact(value: unknown, depth = 0): unknown {
44
+ try {
45
+ return redactInner(value, depth, new WeakSet<object>())
46
+ } catch {
47
+ // Anything unexpected (proxy getters, exotic objects) — do not log it.
48
+ return REDACTED
49
+ }
50
+ }
51
+
52
+ function redactInner(value: unknown, depth: number, seen: WeakSet<object>): unknown {
53
+ if (value === null || value === undefined) return value
54
+
55
+ if (typeof value === 'string') return redactString(value)
56
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return value
57
+ if (typeof value === 'function') return '[function]'
58
+ if (typeof value === 'symbol') return value.toString()
59
+
60
+ if (value instanceof Error) {
61
+ return { name: value.name, message: redactString(value.message), ...(value.stack ? { stack: value.stack } : {}) }
62
+ }
63
+ if (value instanceof Date) return value.toISOString()
64
+
65
+ if (depth >= MAX_DEPTH) return '[deep]'
66
+ if (seen.has(value as object)) return '[circular]'
67
+
68
+ if (Array.isArray(value)) {
69
+ seen.add(value)
70
+ const out = value.map((item) => redactInner(item, depth + 1, seen))
71
+ seen.delete(value)
72
+ return out
73
+ }
74
+
75
+ if (typeof value === 'object') {
76
+ seen.add(value as object)
77
+ const out: Record<string, unknown> = {}
78
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
79
+ out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactInner(raw, depth + 1, seen)
80
+ }
81
+ seen.delete(value as object)
82
+ return out
83
+ }
84
+
85
+ return String(value)
86
+ }
87
+
88
+ /** Redact credentials embedded in an otherwise ordinary string. */
89
+ function redactString(value: string): string {
90
+ if (BEARER_IN_STRING.test(value)) return value.replace(BEARER_IN_STRING, '$1 ' + REDACTED)
91
+ // A bare 32+ char opaque blob is almost always a credential. Project paths,
92
+ // URLs and sentences all contain separators, so they survive this filter.
93
+ if (OPAQUE_SECRET.test(value)) return REDACTED
94
+ return value
95
+ }