mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge 4de9156331 into 4737bfb1fa
This commit is contained in:
commit
faae00fb69
5 changed files with 688 additions and 1 deletions
|
|
@ -93,6 +93,7 @@
|
|||
## 0.9.7 - 2026-05-07
|
||||
|
||||
### Added (CLI)
|
||||
- **Redacted share bundles.** New `codeburn share` command writes a local JSON support bundle with project/session/turn structure while pseudonymizing project labels and redacting common emails, local paths, credentials, bearer tokens, and API keys. Supports period, date range, provider, project, exclude, and output-path filters.
|
||||
- **MCP tool coverage detector.** New `optimize` finding flags MCP servers
|
||||
whose tool inventory is largely unused. Inventory is observed from the
|
||||
Claude `deferred_tools_delta` JSONL attachments (exact tool names per
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -78,6 +78,7 @@ codeburn status # compact one-liner (today + month)
|
|||
codeburn status --format json
|
||||
codeburn export # CSV with today, 7 days, 30 days
|
||||
codeburn export -f json # JSON export
|
||||
codeburn share # redacted JSON bundle for support/debugging
|
||||
codeburn optimize # find waste, get copy-paste fixes
|
||||
codeburn optimize -p week # scope the scan to last 7 days
|
||||
codeburn compare # side-by-side model comparison
|
||||
|
|
@ -123,7 +124,7 @@ Provider logos are trademarks of their respective owners. The icon set was sourc
|
|||
|
||||
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
|
||||
|
||||
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `optimize`, `compare`, `yield`.
|
||||
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `share`, `optimize`, `compare`, `yield`.
|
||||
|
||||
### Provider Notes
|
||||
|
||||
|
|
@ -327,6 +328,19 @@ codeburn today --format json | jq '.overview.cost'
|
|||
|
||||
For lighter output, use `status --format json` (today and month totals only) or file exports (`export -f json`).
|
||||
|
||||
### Redacted Share
|
||||
|
||||
Create a local support bundle without posting raw prompts, project labels, absolute paths, emails, or common API tokens:
|
||||
|
||||
```bash
|
||||
codeburn share # 7-day redacted JSON bundle
|
||||
codeburn share -p 30days # last 30 days
|
||||
codeburn share --provider claude # provider-specific bundle
|
||||
codeburn share --project api -o api-share.json
|
||||
```
|
||||
|
||||
The bundle keeps enough structure to debug provider parsing and cost attribution: pseudonymous projects, sessions, turns, models, token usage, tools, activity categories, and costs. Redaction is best-effort; review the generated file before posting it publicly.
|
||||
|
||||
## Menu Bar
|
||||
|
||||
```bash
|
||||
|
|
|
|||
56
src/cli.ts
56
src/cli.ts
|
|
@ -16,6 +16,7 @@ import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type
|
|||
import { runOptimize, scanAndDetect } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { buildRedactedShare, writeRedactedShare } from './share.js'
|
||||
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
|
||||
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
|
||||
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
|
||||
|
|
@ -643,6 +644,61 @@ program
|
|||
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
|
||||
})
|
||||
|
||||
program
|
||||
.command('share')
|
||||
.description('Export a redacted local JSON bundle for debugging or support')
|
||||
.option('-p, --period <period>', 'Share period: today, week, 30days, month, all', 'week')
|
||||
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
|
||||
.option('-o, --output <path>', 'Output file path')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Include only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
.action(async (opts) => {
|
||||
let customRange: DateRange | null = null
|
||||
try {
|
||||
customRange = parseDateRangeFlags(opts.from, opts.to)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`\n Error: ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await loadPricing()
|
||||
await hydrateCache()
|
||||
|
||||
const period = toPeriod(opts.period)
|
||||
const rangeInfo = customRange
|
||||
? { range: customRange, label: `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` }
|
||||
: getDateRange(period)
|
||||
|
||||
const projects = filterProjectsByName(
|
||||
await parseAllSessions(rangeInfo.range, opts.provider),
|
||||
opts.project,
|
||||
opts.exclude,
|
||||
)
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log('\n No usage data found.\n')
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const timeSuffix = `${now.toTimeString().slice(0, 8).replace(/:/g, '')}${String(now.getMilliseconds()).padStart(3, '0')}`
|
||||
const defaultName = `codeburn-share-${toDateString(now)}-${timeSuffix}.json`
|
||||
const outputPath = opts.output ?? defaultName
|
||||
const share = buildRedactedShare(projects, {
|
||||
label: rangeInfo.label,
|
||||
range: rangeInfo.range,
|
||||
provider: opts.provider,
|
||||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
})
|
||||
const savedPath = await writeRedactedShare(share, outputPath)
|
||||
console.log(`\n Redacted share exported to: ${savedPath}`)
|
||||
console.log(' Review before posting publicly; redaction is best-effort.\n')
|
||||
})
|
||||
|
||||
program
|
||||
.command('menubar')
|
||||
.description('Install and launch the macOS menubar app (one command, no clone)')
|
||||
|
|
|
|||
332
src/share.ts
Normal file
332
src/share.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { dirname, resolve } from 'path'
|
||||
|
||||
import type { ClassifiedTurn, DateRange, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from './types.js'
|
||||
|
||||
type RedactionKind = 'email' | 'path' | 'project' | 'secret'
|
||||
|
||||
type RedactionStats = Record<RedactionKind, number>
|
||||
|
||||
export type RedactedShareOptions = {
|
||||
label: string
|
||||
range: DateRange
|
||||
provider: string
|
||||
project: string[]
|
||||
exclude: string[]
|
||||
}
|
||||
|
||||
type RedactedCall = {
|
||||
provider: string
|
||||
model: string
|
||||
usage: TokenUsage
|
||||
costUSD: number
|
||||
tools: string[]
|
||||
mcpTools: string[]
|
||||
skills: string[]
|
||||
hasAgentSpawn: boolean
|
||||
hasPlanMode: boolean
|
||||
speed: 'standard' | 'fast'
|
||||
timestamp: string
|
||||
bashCommands: string[]
|
||||
}
|
||||
|
||||
type RedactedTurn = {
|
||||
timestamp: string
|
||||
sessionId: string
|
||||
category: string
|
||||
subCategory?: string
|
||||
retries: number
|
||||
hasEdits: boolean
|
||||
userMessage: string
|
||||
assistantCalls: RedactedCall[]
|
||||
}
|
||||
|
||||
type RedactedSession = {
|
||||
sessionId: string
|
||||
firstTimestamp: string
|
||||
lastTimestamp: string
|
||||
totalCostUSD: number
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalCacheReadTokens: number
|
||||
totalCacheWriteTokens: number
|
||||
apiCalls: number
|
||||
turns: RedactedTurn[]
|
||||
}
|
||||
|
||||
type RedactedProject = {
|
||||
project: string
|
||||
projectPath: string
|
||||
totalCostUSD: number
|
||||
totalApiCalls: number
|
||||
sessions: RedactedSession[]
|
||||
}
|
||||
|
||||
export type RedactedShare = {
|
||||
schema: 'codeburn.share.v1'
|
||||
generated: string
|
||||
period: {
|
||||
label: string
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
filters: {
|
||||
provider: string
|
||||
project: string[]
|
||||
exclude: string[]
|
||||
}
|
||||
redaction: {
|
||||
applied: true
|
||||
placeholders: Record<RedactionKind, string>
|
||||
uniqueReplacements: RedactionStats
|
||||
}
|
||||
summary: {
|
||||
projects: number
|
||||
sessions: number
|
||||
turns: number
|
||||
apiCalls: number
|
||||
totalCostUSD: number
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
}
|
||||
projects: RedactedProject[]
|
||||
}
|
||||
|
||||
function roundCost(n: number): number {
|
||||
return Math.round(n * 10000) / 10000
|
||||
}
|
||||
|
||||
const POSIX_PATH_PREFIXES = [
|
||||
'Applications',
|
||||
'Library',
|
||||
'Users',
|
||||
'app',
|
||||
'builds',
|
||||
'code',
|
||||
'data',
|
||||
'etc',
|
||||
'home',
|
||||
'mnt',
|
||||
'opt',
|
||||
'private',
|
||||
'repo',
|
||||
'repos',
|
||||
'git',
|
||||
'projects',
|
||||
'scratch',
|
||||
'srv',
|
||||
'tmp',
|
||||
'usr',
|
||||
'var',
|
||||
'Volumes',
|
||||
'work',
|
||||
'workspace',
|
||||
'workspaces',
|
||||
]
|
||||
|
||||
const posixPathPattern = new RegExp(
|
||||
`/(?:${POSIX_PATH_PREFIXES.map(escapeRegex).join('|')})/[^\\s"'\\\`<>|]+(?:/[^\\s"'\\\`<>|]+)*`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
class StableRedactor {
|
||||
private readonly values: Record<RedactionKind, Map<string, string>> = {
|
||||
email: new Map(),
|
||||
path: new Map(),
|
||||
project: new Map(),
|
||||
secret: new Map(),
|
||||
}
|
||||
|
||||
stats(): RedactionStats {
|
||||
return {
|
||||
email: this.values.email.size,
|
||||
path: this.values.path.size,
|
||||
project: this.values.project.size,
|
||||
secret: this.values.secret.size,
|
||||
}
|
||||
}
|
||||
|
||||
addProjectLabel(value: string): void {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
this.placeholder('project', trimmed)
|
||||
}
|
||||
|
||||
redactProjectLabel(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return value
|
||||
this.addProjectLabel(trimmed)
|
||||
return this.placeholder('project', trimmed)
|
||||
}
|
||||
|
||||
redactText(value: string): string {
|
||||
let out = value
|
||||
|
||||
out = out.replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/\s:@]+):([^@\s]+)@/g, (match: string, user: string, password: string) =>
|
||||
match.replace(`${user}:${password}@`, `${this.placeholder('secret', `${user}:${password}`)}@`),
|
||||
)
|
||||
out = out.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, email => this.placeholder('email', email))
|
||||
|
||||
out = out.replace(
|
||||
/\b(authorization\s*[:=]\s*)(bearer|basic)\s+([A-Za-z0-9._~+/=-]{12,})/gi,
|
||||
(_match, prefix: string, scheme: string, token: string) => `${prefix}${scheme} ${this.placeholder('secret', token)}`,
|
||||
)
|
||||
|
||||
out = out.replace(
|
||||
/\b["']?(api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|pwd)["']?\s*[:=]\s*["']?([A-Za-z0-9._~+/=-]{8,})["']?/gi,
|
||||
(_match, key: string, secret: string) => `${key}=${this.placeholder('secret', secret)}`,
|
||||
)
|
||||
|
||||
out = out.replace(/\b(sk-proj-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,})\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\b(gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,})\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\b[psr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\bnpm_[A-Za-z0-9_]{10,}\b/g, token => this.placeholder('secret', token))
|
||||
out = out.replace(/\b(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/g, token => this.placeholder('secret', token))
|
||||
|
||||
out = out.replace(/\\\\[^\\\s"'`<>|]+\\[^\\\s"'`<>|]+(?:\\[^\\\s"'`<>|]+)*/g, path => this.placeholder('path', path))
|
||||
out = out.replace(/\b[A-Za-z]:\\[^\\\s"'`<>|]+(?:\\[^\\\s"'`<>|]+)*/g, path => this.placeholder('path', path))
|
||||
out = out.replace(/~[/\\][^\s"'`<>|]+/g, path => this.placeholder('path', path))
|
||||
out = out.replace(posixPathPattern, path => this.placeholder('path', path))
|
||||
out = out.replace(/(^|[\s"'`(=:{\[,;<>|])(\.{1,2}\/[^\s"'`<>|]+(?:\/[^\s"'`<>|]+)*)/g, (_match, prefix: string, path: string) =>
|
||||
`${prefix}${this.placeholder('path', path)}`,
|
||||
)
|
||||
|
||||
for (const [term, placeholder] of Array.from(this.values.project.entries()).sort((a, b) => b[0].length - a[0].length)) {
|
||||
const re = new RegExp(`(^|[^A-Za-z0-9_-])(${escapeRegex(term)})(?=$|[^A-Za-z0-9_-])`, 'gi')
|
||||
out = out.replace(re, (match: string, prefix: string, label: string, offset: number, source: string) => {
|
||||
const labelStart = offset + prefix.length
|
||||
const labelEnd = labelStart + label.length
|
||||
if (prefix === '[' && source[labelEnd] === ':') return match
|
||||
return `${prefix}${placeholder}`
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private placeholder(kind: RedactionKind, value: string): string {
|
||||
const bucket = this.values[kind]
|
||||
const existing = bucket.get(value)
|
||||
if (existing) return existing
|
||||
const next = `[${kind}:${bucket.size + 1}]`
|
||||
bucket.set(value, next)
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function redactForShare(value: string): string {
|
||||
return new StableRedactor().redactText(value)
|
||||
}
|
||||
|
||||
function redactCall(call: ParsedApiCall, redactor: StableRedactor): RedactedCall {
|
||||
return {
|
||||
provider: call.provider,
|
||||
model: redactor.redactText(call.model),
|
||||
usage: call.usage,
|
||||
costUSD: roundCost(call.costUSD),
|
||||
tools: call.tools.map(t => redactor.redactText(t)),
|
||||
mcpTools: call.mcpTools.map(t => redactor.redactText(t)),
|
||||
skills: call.skills.map(skill => redactor.redactText(skill)),
|
||||
hasAgentSpawn: call.hasAgentSpawn,
|
||||
hasPlanMode: call.hasPlanMode,
|
||||
speed: call.speed,
|
||||
timestamp: call.timestamp,
|
||||
bashCommands: call.bashCommands.map(cmd => redactor.redactText(cmd)),
|
||||
}
|
||||
}
|
||||
|
||||
function redactTurn(turn: ClassifiedTurn, redactor: StableRedactor): RedactedTurn {
|
||||
return {
|
||||
timestamp: turn.timestamp,
|
||||
sessionId: redactor.redactText(turn.sessionId),
|
||||
category: turn.category,
|
||||
...(turn.subCategory ? { subCategory: redactor.redactText(turn.subCategory) } : {}),
|
||||
retries: turn.retries,
|
||||
hasEdits: turn.hasEdits,
|
||||
userMessage: redactor.redactText(turn.userMessage),
|
||||
assistantCalls: turn.assistantCalls.map(call => redactCall(call, redactor)),
|
||||
}
|
||||
}
|
||||
|
||||
function redactSession(session: SessionSummary, redactor: StableRedactor): RedactedSession {
|
||||
return {
|
||||
sessionId: redactor.redactText(session.sessionId),
|
||||
firstTimestamp: session.firstTimestamp,
|
||||
lastTimestamp: session.lastTimestamp,
|
||||
totalCostUSD: roundCost(session.totalCostUSD),
|
||||
totalInputTokens: session.totalInputTokens,
|
||||
totalOutputTokens: session.totalOutputTokens,
|
||||
totalCacheReadTokens: session.totalCacheReadTokens,
|
||||
totalCacheWriteTokens: session.totalCacheWriteTokens,
|
||||
apiCalls: session.apiCalls,
|
||||
turns: session.turns.map(turn => redactTurn(turn, redactor)),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRedactedShare(projects: ProjectSummary[], options: RedactedShareOptions): RedactedShare {
|
||||
const redactor = new StableRedactor()
|
||||
const sessions = projects.flatMap(project => project.sessions)
|
||||
const turns = sessions.flatMap(session => session.turns)
|
||||
|
||||
for (const project of projects) redactor.addProjectLabel(project.project)
|
||||
for (const project of options.project) redactor.addProjectLabel(project)
|
||||
for (const project of options.exclude) redactor.addProjectLabel(project)
|
||||
|
||||
const redactedProjects: RedactedProject[] = projects.map(project => ({
|
||||
project: redactor.redactProjectLabel(project.project),
|
||||
projectPath: redactor.redactText(project.projectPath),
|
||||
totalCostUSD: roundCost(project.totalCostUSD),
|
||||
totalApiCalls: project.totalApiCalls,
|
||||
sessions: project.sessions.map(session => redactSession(session, redactor)),
|
||||
}))
|
||||
|
||||
const uniqueReplacements = redactor.stats()
|
||||
|
||||
return {
|
||||
schema: 'codeburn.share.v1',
|
||||
generated: new Date().toISOString(),
|
||||
period: {
|
||||
label: options.label,
|
||||
start: options.range.start.toISOString(),
|
||||
end: options.range.end.toISOString(),
|
||||
},
|
||||
filters: {
|
||||
provider: options.provider,
|
||||
project: options.project.map(project => redactor.redactProjectLabel(project)),
|
||||
exclude: options.exclude.map(project => redactor.redactProjectLabel(project)),
|
||||
},
|
||||
redaction: {
|
||||
applied: true,
|
||||
placeholders: {
|
||||
email: '[email:<index>]',
|
||||
path: '[path:<index>]',
|
||||
project: '[project:<index>]',
|
||||
secret: '[secret:<index>]',
|
||||
},
|
||||
uniqueReplacements,
|
||||
},
|
||||
summary: {
|
||||
projects: projects.length,
|
||||
sessions: sessions.length,
|
||||
turns: turns.length,
|
||||
apiCalls: projects.reduce((sum, project) => sum + project.totalApiCalls, 0),
|
||||
totalCostUSD: roundCost(projects.reduce((sum, project) => sum + project.totalCostUSD, 0)),
|
||||
totalInputTokens: sessions.reduce((sum, session) => sum + session.totalInputTokens, 0),
|
||||
totalOutputTokens: sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0),
|
||||
},
|
||||
projects: redactedProjects,
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeRedactedShare(share: RedactedShare, outputPath: string): Promise<string> {
|
||||
const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, JSON.stringify(share, null, 2), 'utf-8')
|
||||
return target
|
||||
}
|
||||
284
tests/share.test.ts
Normal file
284
tests/share.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { mkdtemp, readFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { buildRedactedShare, redactForShare, writeRedactedShare } from '../src/share.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
describe('redacted share', () => {
|
||||
it('redacts common secrets, emails, and local paths', () => {
|
||||
const openAiProjectKey = `sk-proj-${'abcdefghijklmnopqrstuvwxyz123456'}`
|
||||
const githubPat = `ghp_${'abcdefghijklmnopqrstuvwxyz123456'}`
|
||||
const githubServerToken = `ghs_${'abcdefghijklmnopqrstuvwxyz123456'}`
|
||||
const jwt = `eyJ${'abcdefghijklmnopqrstuvwxyz'}.eyJ${'abcdefghijklmnopqrstuvwxyz'}.signatureabcdefghijkl`
|
||||
const slackToken = `xox${'b'}-123456789012-abcdefghijklmnop`
|
||||
const stripeKey = `sk_${'live'}_abcdefghijklmnopqrstuvwxyz`
|
||||
const npmToken = `npm_${'abcdefghijklmnopqrstuvwxyz123456'}`
|
||||
const googleApiKey = `AI${'za'}abcdefghijklmnopqrstuvwxyz1234567890`
|
||||
const raw = [
|
||||
'email husam@example.com',
|
||||
'path /Users/husam/client-a/src/app.ts',
|
||||
'linux /srv/client-a/src/app.ts',
|
||||
'workspace /workspace/client-a/src/app.ts',
|
||||
'usr /usr/local/client-a/src/app.ts',
|
||||
'relative ../client-a/src/app.ts and ./src/private.ts <./angle/path.ts|./pipe/path.ts',
|
||||
'windows C:\\Users\\husam\\client-a\\src\\app.ts',
|
||||
'unc \\\\server\\share\\client-a\\notes.txt',
|
||||
`token ${openAiProjectKey}`,
|
||||
`Authorization: Bearer ${githubPat}`,
|
||||
`github server token ${githubServerToken}`,
|
||||
`jwt ${jwt}`,
|
||||
`slack ${slackToken}`,
|
||||
`stripe ${stripeKey}`,
|
||||
`npm ${npmToken}`,
|
||||
`api_key=${googleApiKey}`,
|
||||
'json {"password": "hunter2hunter2"}',
|
||||
'url https://alice:supersecretpass@example.com/repo',
|
||||
].join('\n')
|
||||
|
||||
const redacted = redactForShare(raw)
|
||||
|
||||
expect(redacted).not.toContain('husam@example.com')
|
||||
expect(redacted).not.toContain('/Users/husam')
|
||||
expect(redacted).not.toContain('/srv/client-a')
|
||||
expect(redacted).not.toContain('/workspace/client-a')
|
||||
expect(redacted).not.toContain('/usr/local/client-a')
|
||||
expect(redacted).not.toContain('../client-a')
|
||||
expect(redacted).not.toContain('./src/private.ts')
|
||||
expect(redacted).not.toContain('./angle/path.ts')
|
||||
expect(redacted).not.toContain('./pipe/path.ts')
|
||||
expect(redacted).not.toContain('C:\\Users\\husam')
|
||||
expect(redacted).not.toContain('\\\\server\\share')
|
||||
expect(redacted).not.toContain(openAiProjectKey)
|
||||
expect(redacted).not.toContain(githubPat)
|
||||
expect(redacted).not.toContain(githubServerToken)
|
||||
expect(redacted).not.toContain(jwt)
|
||||
expect(redacted).not.toContain(slackToken)
|
||||
expect(redacted).not.toContain(stripeKey)
|
||||
expect(redacted).not.toContain(npmToken)
|
||||
expect(redacted).not.toContain(googleApiKey)
|
||||
expect(redacted).not.toContain('hunter2hunter2')
|
||||
expect(redacted).not.toContain('alice:supersecretpass')
|
||||
expect(redacted).toContain('[email:1]')
|
||||
expect(redacted).toContain('[path:1]')
|
||||
expect(redacted).toContain('[path:2]')
|
||||
expect(redacted).toContain('[path:3]')
|
||||
expect(redacted).toContain('[path:4]')
|
||||
expect(redacted).toContain('[path:5]')
|
||||
expect(redacted).toContain('[path:6]')
|
||||
expect(redacted).toContain('[path:7]')
|
||||
expect(redacted).toContain('[path:8]')
|
||||
expect(redacted).toContain('[path:9]')
|
||||
expect(redacted).toContain('[path:10]')
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect(redacted).toContain(`[secret:${i}]`)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds a useful redacted support bundle', () => {
|
||||
const projects: ProjectSummary[] = [{
|
||||
project: 'client-a',
|
||||
projectPath: '/Users/husam/work/client-a',
|
||||
totalCostUSD: 1.23456,
|
||||
totalApiCalls: 1,
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
project: 'client-a',
|
||||
firstTimestamp: '2026-05-05T10:00:00.000Z',
|
||||
lastTimestamp: '2026-05-05T10:01:00.000Z',
|
||||
totalCostUSD: 1.23456,
|
||||
totalInputTokens: 1000,
|
||||
totalOutputTokens: 200,
|
||||
totalCacheReadTokens: 50,
|
||||
totalCacheWriteTokens: 25,
|
||||
apiCalls: 1,
|
||||
turns: [{
|
||||
userMessage: 'fix client-a at /Users/husam/work/client-a for husam@example.com with token=secret-token-12345',
|
||||
assistantCalls: [{
|
||||
provider: 'claude',
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheCreationInputTokens: 25,
|
||||
cacheReadInputTokens: 50,
|
||||
cachedInputTokens: 50,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
costUSD: 1.23456,
|
||||
tools: ['Read', 'Bash'],
|
||||
mcpTools: [],
|
||||
skills: ['browser-use'],
|
||||
hasAgentSpawn: true,
|
||||
hasPlanMode: true,
|
||||
speed: 'standard',
|
||||
timestamp: '2026-05-05T10:01:00.000Z',
|
||||
bashCommands: ['npm'],
|
||||
deduplicationKey: 'dedupe',
|
||||
}],
|
||||
timestamp: '2026-05-05T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
category: 'debugging',
|
||||
retries: 1,
|
||||
hasEdits: true,
|
||||
}],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {},
|
||||
skillBreakdown: {},
|
||||
}],
|
||||
}]
|
||||
|
||||
const share = buildRedactedShare(projects, {
|
||||
label: '7 Days',
|
||||
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
|
||||
provider: 'all',
|
||||
project: [],
|
||||
exclude: [],
|
||||
})
|
||||
|
||||
expect(share.schema).toBe('codeburn.share.v1')
|
||||
expect(share.summary).toMatchObject({ projects: 1, sessions: 1, turns: 1, apiCalls: 1 })
|
||||
expect(share.projects[0]!.project).toBe('[project:1]')
|
||||
expect(share.projects[0]!.projectPath).toBe('[path:1]')
|
||||
expect(share.projects[0]!.sessions[0]!.totalCostUSD).toBe(1.2346)
|
||||
const message = share.projects[0]!.sessions[0]!.turns[0]!.userMessage
|
||||
const call = share.projects[0]!.sessions[0]!.turns[0]!.assistantCalls[0]!
|
||||
expect(message).not.toContain('/Users/husam')
|
||||
expect(message).not.toContain('husam@example.com')
|
||||
expect(message).not.toContain('secret-token-12345')
|
||||
expect(message).not.toContain('client-a')
|
||||
expect(message).toContain('[path:1]')
|
||||
expect(message).toContain('[email:1]')
|
||||
expect(message).toContain('[project:1]')
|
||||
expect(message).toContain('[secret:1]')
|
||||
expect(call.skills).toEqual(['browser-use'])
|
||||
expect(call.hasAgentSpawn).toBe(true)
|
||||
expect(call.hasPlanMode).toBe(true)
|
||||
})
|
||||
|
||||
it('redacts project labels without mangling unrelated substrings', () => {
|
||||
const projects: ProjectSummary[] = [{
|
||||
project: 'api',
|
||||
projectPath: '/Users/husam/work/api',
|
||||
totalCostUSD: 0.01,
|
||||
totalApiCalls: 1,
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
project: 'api',
|
||||
firstTimestamp: '2026-05-05T10:00:00.000Z',
|
||||
lastTimestamp: '2026-05-05T10:01:00.000Z',
|
||||
totalCostUSD: 0.01,
|
||||
totalInputTokens: 10,
|
||||
totalOutputTokens: 5,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [{
|
||||
userMessage: 'API failed in api, but rapidapi and apiKey are library names',
|
||||
assistantCalls: [],
|
||||
timestamp: '2026-05-05T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
category: 'debugging',
|
||||
retries: 0,
|
||||
hasEdits: false,
|
||||
}],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {},
|
||||
skillBreakdown: {},
|
||||
}],
|
||||
}]
|
||||
|
||||
const share = buildRedactedShare(projects, {
|
||||
label: '7 Days',
|
||||
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
|
||||
provider: 'all',
|
||||
project: ['api'],
|
||||
exclude: ['client-a'],
|
||||
})
|
||||
|
||||
const message = share.projects[0]!.sessions[0]!.turns[0]!.userMessage
|
||||
expect(message).toBe('[project:1] failed in [project:1], but rapidapi and apiKey are library names')
|
||||
expect(share.filters.project).toEqual(['[project:1]'])
|
||||
expect(share.filters.exclude).toEqual(['[project:2]'])
|
||||
})
|
||||
|
||||
it('keeps placeholders stable and does not rewrite them as project labels', () => {
|
||||
const repeated = redactForShare('token=repeatsecret123 token=repeatsecret123')
|
||||
expect((repeated.match(/\[secret:1\]/g) ?? [])).toHaveLength(2)
|
||||
expect(repeated).not.toContain('[secret:2]')
|
||||
|
||||
const projects: ProjectSummary[] = [{
|
||||
project: 'secret',
|
||||
projectPath: '/Users/husam/work/secret',
|
||||
totalCostUSD: 0.01,
|
||||
totalApiCalls: 1,
|
||||
sessions: [{
|
||||
sessionId: 'session-1',
|
||||
project: 'secret',
|
||||
firstTimestamp: '2026-05-05T10:00:00.000Z',
|
||||
lastTimestamp: '2026-05-05T10:01:00.000Z',
|
||||
totalCostUSD: 0.01,
|
||||
totalInputTokens: 10,
|
||||
totalOutputTokens: 5,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [{
|
||||
userMessage: 'secret uses token=verysecret12345',
|
||||
assistantCalls: [],
|
||||
timestamp: '2026-05-05T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
category: 'debugging',
|
||||
retries: 0,
|
||||
hasEdits: false,
|
||||
}],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {},
|
||||
skillBreakdown: {},
|
||||
}],
|
||||
}]
|
||||
|
||||
const share = buildRedactedShare(projects, {
|
||||
label: '7 Days',
|
||||
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
|
||||
provider: 'all',
|
||||
project: [],
|
||||
exclude: [],
|
||||
})
|
||||
|
||||
expect(share.projects[0]!.sessions[0]!.turns[0]!.userMessage).toBe('[project:1] uses token=[secret:1]')
|
||||
})
|
||||
|
||||
it('writes json output and appends json extension when needed', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-share-'))
|
||||
try {
|
||||
const share = buildRedactedShare([], {
|
||||
label: 'Today',
|
||||
range: { start: new Date('2026-05-05T00:00:00.000Z'), end: new Date('2026-05-05T23:59:59.999Z') },
|
||||
provider: 'all',
|
||||
project: [],
|
||||
exclude: [],
|
||||
})
|
||||
|
||||
const savedPath = await writeRedactedShare(share, join(dir, 'support-bundle'))
|
||||
const content = JSON.parse(await readFile(savedPath, 'utf-8'))
|
||||
|
||||
expect(savedPath.endsWith('support-bundle.json')).toBe(true)
|
||||
expect(content.schema).toBe('codeburn.share.v1')
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue