feat: add redacted diagnostic issue reports (#1647)

* feat: add redacted diagnostic issue reports

* fix: address diagnostic report review feedback

* fix: report Codex runtime diagnostics accurately
This commit is contained in:
Bogdan
2026-06-16 08:41:18 +08:00
committed by GitHub
parent b036e9fa7c
commit 7c034c5a62
14 changed files with 1742 additions and 27 deletions
+2 -5
View File
@@ -26,11 +26,8 @@ What happened instead?
## Environment
- OpenClaude version:
- OS:
- Terminal:
- Provider:
- Model:
Run `openclaude doctor report --markdown` and paste the redacted report here.
For JSON attachment, run `openclaude doctor report --json --out openclaude-report.json`.
## Logs / Screenshots
+7
View File
@@ -370,6 +370,12 @@ bun run doctor:runtime:json
# persist a diagnostics report to reports/doctor-runtime.json
bun run doctor:report
# print a redacted public issue report
openclaude doctor report --markdown
# write a redacted JSON issue report for attachment
openclaude doctor report --json --out openclaude-report.json
# full local hardening check (smoke + runtime doctor)
bun run hardening:check
@@ -383,6 +389,7 @@ Notes:
- `doctor:runtime` also validates the dedicated Gemini and Mistral env paths when `CLAUDE_CODE_USE_GEMINI=1` or `CLAUDE_CODE_USE_MISTRAL=1`.
- Local providers such as `http://localhost:11434/v1`, `http://10.0.0.1:11434/v1`, and `http://127.0.0.1:1337/v1` can run without `OPENAI_API_KEY`.
- Codex profiles validate `CODEX_API_KEY` or the Codex CLI auth file and probe `POST /responses` instead of `GET /models`.
- `openclaude doctor report` is redacted by default and is intended for GitHub issues. It summarizes provider/runtime/build/settings state without prompts, transcripts, raw settings files, API keys, MCP command details, or full home-directory paths.
## Provider Launch Profiles
+6
View File
@@ -154,3 +154,9 @@ openclaude --version
```
If this prints a version number, the install succeeded. If it says "command not found," close your terminal, open a new one, and try again. On Windows, you may also need to add npm's global bin folder to your user `Path` (see the [Windows Quick Start](quick-start-windows.md) guide for details).
When filing a bug, run this and paste the redacted output into the issue:
```bash
openclaude doctor report --markdown
```
+35
View File
@@ -0,0 +1,35 @@
import {
formatIssueReport,
buildIssueReport,
writeIssueReport,
type IssueReportArgs,
} from '../../utils/diagnostics/issueReport.js'
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export async function doctorReportHandler(
options: IssueReportArgs,
): Promise<void> {
if (!options.redacted) {
throw new Error('Unredacted diagnostic reports are not supported')
}
const report = await buildIssueReport({
includeDebug: options.includeDebug,
})
const content = formatIssueReport(report, options.format)
if (options.outFile) {
const outputPath = writeIssueReport(options.outFile, content)
console.log(`Diagnostic report written to ${outputPath}`)
return
}
console.log(content)
}
export function printDoctorReportError(error: unknown): void {
console.error(`Failed to generate diagnostic report: ${formatError(error)}`)
}
+185
View File
@@ -0,0 +1,185 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { isValidElement } from 'react'
import {
createDoctorCommandCall,
runDoctorReportCommand,
splitDoctorArgs,
} from './doctor.js'
afterEach(() => {
mock.restore()
})
describe('/doctor report', () => {
test('emits report output when no outFile is provided', async () => {
const options = {
format: 'json' as const,
outFile: null,
includeDebug: false,
redacted: true as const,
}
const parseIssueReportArgs = mock(() => options)
const renderIssueReport = mock(async () => '# diagnostic report')
const writeIssueReport = mock(() => '/tmp/openclaude-report.md')
const onDone = mock(() => {})
const result = await runDoctorReportCommand(['--json'], onDone, {
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
})
expect(result).toBeNull()
expect(parseIssueReportArgs).toHaveBeenCalledWith(['--json'])
expect(renderIssueReport).toHaveBeenCalledWith(options)
expect(writeIssueReport).not.toHaveBeenCalled()
expect(onDone).toHaveBeenCalledWith('# diagnostic report', {
display: 'system',
})
})
test('writes report output when --out is provided', async () => {
const options = {
format: 'markdown' as const,
outFile: 'report.md',
includeDebug: false,
redacted: true as const,
}
const parseIssueReportArgs = mock(() => options)
const renderIssueReport = mock(async () => '# diagnostic report')
const writeIssueReport = mock(() => '/tmp/openclaude-report.md')
const onDone = mock(() => {})
const result = await runDoctorReportCommand(
['--out', 'report.md'],
onDone,
{
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
},
)
expect(result).toBeNull()
expect(parseIssueReportArgs).toHaveBeenCalledWith(['--out', 'report.md'])
expect(renderIssueReport).toHaveBeenCalledWith(options)
expect(writeIssueReport).toHaveBeenCalledWith('report.md', '# diagnostic report')
expect(onDone).toHaveBeenCalledWith(
'Diagnostic report written to /tmp/openclaude-report.md',
{ display: 'system' },
)
})
test('routes report arguments through the slash command entrypoint', async () => {
const options = {
format: 'markdown' as const,
outFile: null,
includeDebug: true,
redacted: true,
}
const parseIssueReportArgs = mock(() => options)
const renderIssueReport = mock(async () => '# report')
const writeIssueReport = mock(() => '/tmp/report.md')
const call = createDoctorCommandCall({
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
})
const onDone = mock(() => {})
const result = await call(
onDone as never,
{} as never,
'report --markdown --include-debug',
)
expect(result).toBeNull()
expect(parseIssueReportArgs).toHaveBeenCalledWith([
'--markdown',
'--include-debug',
])
expect(renderIssueReport).toHaveBeenCalledWith(options)
expect(onDone).toHaveBeenCalledWith('# report', { display: 'system' })
})
test('falls back to the Doctor screen for non-report arguments', async () => {
const parseIssueReportArgs = mock(() => ({
format: 'markdown' as const,
outFile: null,
includeDebug: false,
redacted: true,
}))
const call = createDoctorCommandCall({
parseIssueReportArgs,
renderIssueReport: mock(async () => '# report'),
writeIssueReport: mock(() => '/tmp/report.md'),
})
const onDone = mock(() => {})
const result = await call(onDone as never, {} as never, '')
expect(isValidElement(result)).toBe(true)
expect(parseIssueReportArgs).not.toHaveBeenCalled()
expect(onDone).not.toHaveBeenCalled()
})
test('propagates report generation errors through the slash command promise', async () => {
const call = createDoctorCommandCall({
parseIssueReportArgs: mock(() => ({
format: 'markdown' as const,
outFile: null,
includeDebug: false,
redacted: true,
})),
renderIssueReport: mock(async () => {
throw new Error('render failed')
}),
writeIssueReport: mock(() => '/tmp/report.md'),
})
await expect(call(mock(() => {}) as never, {} as never, 'report')).rejects.toThrow(
'render failed',
)
})
test('rejects unredacted report options in the slash command flow', async () => {
const parseIssueReportArgs = mock(() => ({
format: 'markdown' as const,
outFile: null,
includeDebug: false,
redacted: false,
}))
const renderIssueReport = mock(async () => '# report')
const writeIssueReport = mock(() => '/tmp/report.md')
const call = createDoctorCommandCall({
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
})
await expect(call(mock(() => {}) as never, {} as never, 'report')).rejects.toThrow(
'Unredacted diagnostic reports are not supported',
)
expect(renderIssueReport).not.toHaveBeenCalled()
expect(writeIssueReport).not.toHaveBeenCalled()
})
test('splits quoted report arguments without eating escaped quotes', () => {
expect(
splitDoctorArgs(`report --out "nested/report file.md" '--json-ish'`),
).toEqual(['report', '--out', 'nested/report file.md', '--json-ish'])
expect(splitDoctorArgs(`report --out "a \\"quoted\\" file.md"`)).toEqual([
'report',
'--out',
'a "quoted" file.md',
])
expect(splitDoctorArgs(String.raw`report --out foo\ bar.md`)).toEqual([
'report',
'--out',
'foo bar.md',
])
expect(splitDoctorArgs(String.raw`report --out C:\Users\Alice\report.md`)).toEqual([
'report',
'--out',
String.raw`C:\Users\Alice\report.md`,
])
})
})
+111 -4
View File
@@ -1,6 +1,113 @@
import React from 'react';
import { Doctor } from '../../screens/Doctor.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
return Promise.resolve(<Doctor onDone={onDone} />);
};
import type {
LocalJSXCommandCall,
LocalJSXCommandOnDone,
} from '../../types/command.js';
import {
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
} from '../../utils/diagnostics/issueReport.js'
export function splitDoctorArgs(args: string): string[] {
const parts: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let escaping = false
for (let index = 0; index < args.length; index++) {
const char = args[index]!
const next = args[index + 1]
if (escaping) {
current += char
escaping = false
continue
}
if (
char === '\\' &&
(quote || next === '\\' || next === '"' || next === "'" || /\s/.test(next ?? ''))
) {
escaping = true
continue
}
if (quote) {
if (char === quote) {
quote = null
} else {
current += char
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (/\s/.test(char)) {
if (current) {
parts.push(current)
current = ''
}
continue
}
current += char
}
if (escaping) current += '\\'
if (current) parts.push(current)
return parts
}
type DoctorReportDependencies = {
parseIssueReportArgs: typeof parseIssueReportArgs
renderIssueReport: typeof renderIssueReport
writeIssueReport: typeof writeIssueReport
}
const defaultDoctorReportDependencies: DoctorReportDependencies = {
parseIssueReportArgs,
renderIssueReport,
writeIssueReport,
}
export async function runDoctorReportCommand(
args: string[],
onDone: LocalJSXCommandOnDone,
dependencies: DoctorReportDependencies = defaultDoctorReportDependencies,
): Promise<null> {
const options = dependencies.parseIssueReportArgs(args)
if (!options.redacted) {
throw new Error('Unredacted diagnostic reports are not supported')
}
const content = await dependencies.renderIssueReport(options)
if (options.outFile) {
const outputPath = dependencies.writeIssueReport(options.outFile, content)
onDone(`Diagnostic report written to ${outputPath}`, { display: 'system' })
return null
}
onDone(content, { display: 'system' })
return null
}
export function createDoctorCommandCall(
dependencies: DoctorReportDependencies = defaultDoctorReportDependencies,
): LocalJSXCommandCall {
return async (onDone, _context, args) => {
const parts = splitDoctorArgs(args)
if (parts[0]?.toLowerCase() === 'report') {
return runDoctorReportCommand(parts.slice(1), onDone, dependencies)
}
return Promise.resolve(<Doctor onDone={onDone} />);
}
}
export const call: LocalJSXCommandCall = createDoctorCommandCall()
+1
View File
@@ -4,6 +4,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js'
const doctor: Command = {
name: 'doctor',
description: 'Diagnose and verify your OpenClaude installation and settings',
argumentHint: 'report [--json|--markdown] [--out file] [--include-debug]',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
type: 'local-jsx',
load: () => import('./doctor.js'),
+36 -1
View File
@@ -4163,7 +4163,42 @@ async function run(): Promise<CommanderCommand> {
}
// Doctor command - check installation health
program.command('doctor').description('Check the health of your OpenClaude auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.').action(async () => {
const doctorCommand = program
.command('doctor')
.description('Check the health of your OpenClaude auto-updater. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.');
doctorCommand
.command('report')
.description('Print a redacted diagnostic report for GitHub issues')
.addOption(new Option('--json', 'Print JSON output').conflicts('markdown'))
.addOption(new Option('--markdown', 'Print Markdown output').conflicts('json'))
.option('--out <file>', 'Write the report to a file')
.addOption(new Option('--redacted', 'Keep redaction enabled (default)').default(true).hideHelp())
.option('--include-debug', 'Include redacted recent error summaries')
.action(async (options: {
json?: boolean;
markdown?: boolean;
out?: string;
redacted?: boolean;
includeDebug?: boolean;
}) => {
const {
doctorReportHandler,
printDoctorReportError,
} = await import('./cli/handlers/doctorReport.js');
try {
await doctorReportHandler({
format: options.json ? 'json' : 'markdown',
outFile: options.out ?? null,
includeDebug: options.includeDebug === true,
redacted: options.redacted !== false,
});
process.exit(0);
} catch (error) {
printDoctorReportError(error);
process.exit(1);
}
});
doctorCommand.action(async () => {
const [{
doctorHandler
}, {
+18 -16
View File
@@ -618,18 +618,20 @@ export function resolveProviderRequest(options?: {
fallbackModel?: string
reasoningEffortOverride?: ReasoningEffort
apiFormat?: OpenAICompatibleApiFormat | string
processEnv?: NodeJS.ProcessEnv
}): ResolvedProviderRequest {
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const isGeminiMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const processEnv = options?.processEnv ?? process.env
const isGithubMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)
const isMistralMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)
const isGeminiMode = isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI)
const requestedModel =
options?.model?.trim() ||
(isMistralMode
? process.env.MISTRAL_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
? processEnv.MISTRAL_MODEL?.trim()
: processEnv.OPENAI_MODEL?.trim()) ||
(isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
? processEnv.GEMINI_MODEL?.trim()
: processEnv.OPENAI_MODEL?.trim()) ||
options?.fallbackModel?.trim() ||
(isGeminiMode ? DEFAULT_GEMINI_MODEL : undefined) ||
(isGithubMode ? 'github:copilot' : 'codexplan')
@@ -637,12 +639,12 @@ export function resolveProviderRequest(options?: {
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
process.env.MISTRAL_BASE_URL,
processEnv.MISTRAL_BASE_URL,
'MISTRAL_BASE_URL',
)
const normalizedGeminiEnvBaseUrl = asNamedEnvUrl(
process.env.GEMINI_BASE_URL,
processEnv.GEMINI_BASE_URL,
'GEMINI_BASE_URL',
)
@@ -650,21 +652,21 @@ export function resolveProviderRequest(options?: {
? normalizedMistralEnvBaseUrl
: isGeminiMode
? normalizedGeminiEnvBaseUrl
: asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL')
: asNamedEnvUrl(processEnv.OPENAI_BASE_URL, 'OPENAI_BASE_URL')
// In Mistral mode, a literal "undefined" MISTRAL_BASE_URL is treated as
// misconfiguration and falls back to OPENAI_API_BASE, then
// DEFAULT_MISTRAL_BASE_URL for a safe default endpoint.
const fallbackEnvBaseUrl = isMistralMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL
? asNamedEnvUrl(processEnv.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL
: undefined)
: isGeminiMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_GEMINI_BASE_URL
? asNamedEnvUrl(processEnv.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_GEMINI_BASE_URL
: undefined)
: (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE')
? asNamedEnvUrl(processEnv.OPENAI_API_BASE, 'OPENAI_API_BASE')
: undefined)
const envBaseUrlRaw =
@@ -680,7 +682,7 @@ export function resolveProviderRequest(options?: {
const rawBaseUrl = explicitBaseUrl ?? envBaseUrl
const shellModel = process.env.OPENAI_MODEL?.trim() ?? ''
const shellModel = processEnv.OPENAI_MODEL?.trim() ?? ''
const envIsCodexShortcut = isOpenAICodexShortcutAlias(shellModel)
const envResolvedCodexModel = envIsCodexShortcut
? parseModelDescriptor(shellModel).baseModel
@@ -713,12 +715,12 @@ export function resolveProviderRequest(options?: {
isGithubMode
? undefined
: parseOpenAICompatibleApiFormat(options?.apiFormat) ??
parseOpenAICompatibleApiFormat(process.env.OPENAI_API_FORMAT)
parseOpenAICompatibleApiFormat(processEnv.OPENAI_API_FORMAT)
const supportsRequestedApiFormat =
(requestedApiFormat !== 'responses' && requestedApiFormat !== 'responses_compat') ||
(() => {
const runtimeShimContext = resolveOpenAIShimRuntimeContext({
processEnv: process.env,
processEnv,
baseUrl: finalBaseUrl,
model: descriptor.baseModel,
treatAsLocal: finalBaseUrl ? isLocalProviderUrl(finalBaseUrl) : false,
+316
View File
@@ -0,0 +1,316 @@
import { describe, expect, test } from 'bun:test'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import {
buildIssueReport,
formatIssueReportAsMarkdown,
parseIssueReportArgs,
writeIssueReport,
} from './issueReport.js'
const baseEnv = {
HOME: '/home/alice',
PATH: '/usr/bin',
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_API_KEY: 'sk-openai-secret',
OPENAI_BASE_URL:
'https://user:pass@api.openai.com/v1?api_key=secret&mode=test',
OPENAI_MODEL: 'gpt-5.5',
}
describe('diagnostic issue report', () => {
test('builds a safe JSON report without secrets or full home paths', async () => {
const report = await buildIssueReport({
env: baseEnv,
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: {
version: '0.18.0',
displayVersion: '0.18.0-test',
},
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: ['userSettings', 'projectSettings'],
validationErrors: [],
},
mcpServers: {
alpha: { type: 'stdio', command: 'node', args: ['server.js'] },
beta: { type: 'http', url: 'https://mcp.example.test' },
},
errors: [
{
error:
'Error: request failed with sk-openai-secret at /home/alice/private/openclaude/src/file.ts',
timestamp: '2026-06-15T10:00:00.000Z',
},
],
})
const serialized = JSON.stringify(report)
expect(report.schemaVersion).toBe(1)
expect(report.generatedAt).toBe('2026-06-15T10:30:00.000Z')
expect(report.openclaude.version).toBe('0.18.0')
expect(report.workspace.cwd).toBe('openclaude')
expect(report.provider.routeId).toBe('openai')
expect(report.provider.credential.present).toBe(true)
expect(report.provider.credential.sources).toEqual(['OPENAI_API_KEY'])
expect(report.provider.baseUrl).toBe(
'https://redacted:redacted@api.openai.com/v1?api_key=redacted&mode=test',
)
expect(report.mcp.transports).toEqual({ stdio: 1, http: 1 })
expect(report.errors.recent).toEqual([{ category: 'Error', count: 1 }])
expect(report.redaction.secretsIncluded).toBe(false)
expect(serialized).not.toContain('sk-openai-secret')
expect(serialized).not.toContain('/home/alice')
expect(serialized).not.toContain('server.js')
})
test('formats markdown suitable for a GitHub issue', async () => {
const report = await buildIssueReport({
env: baseEnv,
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: { version: '0.18.0' },
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: ['userSettings'],
validationErrors: [],
},
mcpServers: {},
errors: [],
})
const markdown = formatIssueReportAsMarkdown(report)
expect(markdown).toContain('# OpenClaude diagnostic report')
expect(markdown).toContain('## Summary')
expect(markdown).toContain('| Check | Status | Detail |')
expect(markdown).toContain(
'This report is redacted. It should not contain API keys, prompts, transcripts, or file contents.',
)
expect(markdown).not.toContain('sk-openai-secret')
expect(markdown).not.toContain('/home/alice')
})
test('falls back safely when build macros are absent in source tests', async () => {
const originalMacro = (globalThis as Record<string, unknown>).MACRO
const hadMacro = Object.hasOwn(globalThis, 'MACRO')
delete (globalThis as Record<string, unknown>).MACRO
try {
const report = await buildIssueReport({
env: baseEnv,
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: [],
validationErrors: [],
},
mcpServers: {},
errors: [],
})
expect(report.openclaude.version).toBe('unknown')
} finally {
if (hadMacro) {
;(globalThis as Record<string, unknown>).MACRO = originalMacro
}
}
})
test('parses report command output options', () => {
expect(parseIssueReportArgs(['--json'])).toEqual({
format: 'json',
outFile: null,
includeDebug: false,
redacted: true,
})
expect(
parseIssueReportArgs(['--markdown', '--out', 'report.md', '--redacted']),
).toEqual({
format: 'markdown',
outFile: 'report.md',
includeDebug: false,
redacted: true,
})
expect(parseIssueReportArgs(['--out=nested/report.md'])).toEqual({
format: 'markdown',
outFile: 'nested/report.md',
includeDebug: false,
redacted: true,
})
expect(parseIssueReportArgs(['--include-debug'])).toEqual({
format: 'markdown',
outFile: null,
includeDebug: true,
redacted: true,
})
})
test('redacts include-debug error details', async () => {
const home = homedir()
const report = await buildIssueReport({
env: baseEnv,
cwd: `${home}/private/openclaude`,
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: { version: '0.18.0' },
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: [],
validationErrors: [],
},
mcpServers: {},
errors: [
{
error: `ProviderError: failed with sk-openai-secret-token at ${home}/private/openclaude/src/file.ts`,
timestamp: '2026-06-15T10:00:00.000Z',
},
],
includeDebug: true,
})
expect(report.errors.recent).toEqual([{ category: 'ProviderError', count: 1 }])
expect(report.errors.debug).toEqual([
'ProviderError: failed with [redacted] at ~/private/openclaude/src/file.ts',
])
expect(JSON.stringify(report)).not.toContain('sk-openai-secret-token')
expect(JSON.stringify(report)).not.toContain(home)
})
test('reports public descriptor credential sources without arbitrary secret env names', async () => {
const report = await buildIssueReport({
env: {
HOME: '/home/alice',
PATH: '/usr/bin',
CLAUDE_CODE_USE_GITHUB: '1',
GITHUB_TOKEN: 'ghp_abcdefghijklmnopqrstuvwxyz',
MY_PRIVATE_TOKEN: 'private-token-value',
},
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: { version: '0.18.0' },
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: [],
validationErrors: [],
},
mcpServers: {},
errors: [],
})
const serialized = JSON.stringify(report)
expect(report.provider.routeId).toBe('github')
expect(report.provider.credential.present).toBe(true)
expect(report.provider.credential.sources).toEqual(['GITHUB_TOKEN'])
expect(serialized).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz')
expect(serialized).not.toContain('MY_PRIVATE_TOKEN')
expect(serialized).not.toContain('private-token-value')
})
test('reports Codex alias runtime auth as Codex instead of OpenAI', async () => {
const report = await buildIssueReport({
env: {
HOME: '/home/alice',
PATH: '/usr/bin',
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'codexplan',
CODEX_API_KEY: 'codex-secret-token',
CHATGPT_ACCOUNT_ID: 'acct_codex',
},
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: { version: '0.18.0' },
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: [],
validationErrors: [],
},
mcpServers: {},
errors: [],
})
const serialized = JSON.stringify(report)
expect(report.provider.routeId).toBe('codex')
expect(report.provider.label).toBe('Codex')
expect(report.provider.providerType).toBe('Codex Responses API')
expect(report.provider.model).toBe('codexplan')
expect(report.provider.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
expect(report.provider.credential).toEqual({
required: true,
present: true,
sources: ['CODEX_API_KEY', 'CHATGPT_ACCOUNT_ID'],
})
expect(serialized).not.toContain('codex-secret-token')
expect(serialized).not.toContain('acct_codex')
})
test('reports official Codex base URL as Codex instead of custom', async () => {
const report = await buildIssueReport({
env: {
HOME: '/home/alice',
PATH: '/usr/bin',
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_MODEL: 'codexspark',
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
CODEX_API_KEY: 'codex-secret-token',
CODEX_ACCOUNT_ID: 'acct_codex',
},
cwd: '/home/alice/private/openclaude',
now: new Date('2026-06-15T10:30:00.000Z'),
packageInfo: { version: '0.18.0' },
checks: {
buildArtifactsPresent: true,
ripgrep: { available: true, detail: 'system rg' },
},
settings: {
sourcesPresent: [],
validationErrors: [],
},
mcpServers: {},
errors: [],
})
expect(report.provider.routeId).toBe('codex')
expect(report.provider.label).toBe('Codex')
expect(report.provider.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
expect(report.provider.credential).toEqual({
required: true,
present: true,
sources: ['CODEX_API_KEY', 'CODEX_ACCOUNT_ID'],
})
})
test('writes report files and creates parent directories', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-report-'))
try {
const outFile = join(tempDir, 'nested', 'report.md')
const outputPath = writeIssueReport(outFile, 'redacted report')
expect(outputPath).toBe(outFile)
expect(existsSync(outFile)).toBe(true)
expect(readFileSync(outFile, 'utf8')).toBe('redacted report')
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})
+769
View File
@@ -0,0 +1,769 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve, basename } from 'node:path'
import { arch, platform } from 'node:os'
import { getCatalogEntriesForRoute } from '../../integrations/index.js'
import {
getRouteDefaultBaseUrl,
getRouteDefaultModel,
getRouteDescriptor,
getRouteProviderTypeLabel,
resolveActiveRouteIdFromEnv,
resolveRouteIdFromBaseUrl,
getRouteCredentialEnvVars,
} from '../../integrations/routeMetadata.js'
import { resolveModelRuntimeLimits } from '../../integrations/runtimeMetadata.js'
import type { CapabilityFlags, ModelCatalogEntry } from '../../integrations/descriptors.js'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import { getClaudeCodeMcpConfigs } from '../../services/mcp/config.js'
import {
resolveProviderRequest,
resolveRuntimeCodexCredentials,
} from '../../services/api/providerConfig.js'
import { getInMemoryErrors } from '../log.js'
import { getRipgrepStatus, testRipgrepOnFirstUse } from '../ripgrep.js'
import {
getSettingsWithErrors,
getSettingsWithSources,
} from '../settings/settings.js'
import type { SettingSource } from '../settings/constants.js'
import type { ValidationError } from '../settings/validation.js'
import {
collectProviderSecretEnvVars,
redactDiagnosticObject,
redactDiagnosticUrl,
redactHomePath,
redactLikelySecrets,
} from './redaction.js'
export type IssueReportFormat = 'json' | 'markdown'
export type IssueReportArgs = {
format: IssueReportFormat
outFile: string | null
includeDebug: boolean
// Unredacted reports are intentionally unsupported; this captures --redacted
// as an explicit safety assertion and lets handlers reject future false values.
redacted: boolean
}
export type DiagnosticCheck = {
label: string
ok: boolean
detail: string
}
export type IssueReport = {
schemaVersion: 1
generatedAt: string
openclaude: {
version: string
displayVersion?: string
buildTime?: string
source: 'source' | 'dist' | 'unknown'
}
workspace: {
cwd: string
}
runtime: {
platform: NodeJS.Platform
arch: string
node: string
bun: string | null
packageManager: string | null
tty: {
stdin: boolean
stdout: boolean
stderr: boolean
}
}
provider: {
routeId: string
label: string
providerType: string
model: string
apiFormat?: string
baseUrl?: string
credential: {
required: boolean
present: boolean
sources: string[]
}
}
model: {
contextWindow?: number
maxOutputTokens?: number
catalogSource: 'static' | 'dynamic' | 'hybrid' | 'custom' | 'unknown'
capabilities: CapabilityFlags
}
settings: {
sourcesPresent: SettingSource[]
validationErrors: Array<{ file: string; path: string; message: string }>
}
checks: DiagnosticCheck[]
mcp: {
serverCount: number
transports: Record<string, number>
}
errors: {
recent: Array<{ category: string; count: number }>
debug?: string[]
}
warnings: string[]
redaction: {
homeRedacted: boolean
cwdRedacted: boolean
secretsIncluded: false
}
}
type BuildIssueReportOptions = {
env?: NodeJS.ProcessEnv
cwd?: string
now?: Date
packageInfo?: {
version?: string
displayVersion?: string
buildTime?: string
}
checks?: {
buildArtifactsPresent?: boolean
ripgrep?: { available: boolean; detail: string }
}
settings?: {
sourcesPresent: SettingSource[]
validationErrors: Array<Pick<ValidationError, 'file' | 'path' | 'message'>>
}
mcpServers?: Record<string, Partial<ScopedMcpServerConfig>>
errors?: Array<{ error: string; timestamp: string }>
includeDebug?: boolean
}
type CredentialSummary = IssueReport['provider']['credential']
type DiagnosticProviderContext = {
routeId: string
label: string
providerType: string
model: string
limitsModel: string
catalogRouteId: string
baseUrl?: string
apiFormat?: string
credential: CredentialSummary
}
function isTruthy(value: string | undefined): boolean {
if (!value) return false
const normalized = value.trim().toLowerCase()
return normalized !== '' && normalized !== '0' && normalized !== 'false' && normalized !== 'no'
}
function readPackageManager(env: NodeJS.ProcessEnv): string | null {
const userAgent = env.npm_config_user_agent?.trim()
if (userAgent) return userAgent.split(' ')[0] ?? userAgent
const execPath = env.npm_execpath?.trim()
if (!execPath) return null
if (execPath.includes('bun')) return 'bun'
if (execPath.includes('pnpm')) return 'pnpm'
if (execPath.includes('yarn')) return 'yarn'
if (execPath.includes('npm')) return 'npm'
return basename(execPath)
}
function readMacroVersion(): string | undefined {
try {
return MACRO.VERSION
} catch {
return undefined
}
}
function readMacroDisplayVersion(): string | undefined {
try {
return MACRO.DISPLAY_VERSION
} catch {
return undefined
}
}
function readMacroBuildTime(): string | undefined {
try {
return MACRO.BUILD_TIME
} catch {
return undefined
}
}
function readPackageInfo(options?: BuildIssueReportOptions['packageInfo']) {
const version =
options?.version ?? readMacroVersion() ?? 'unknown'
const displayVersion = options?.displayVersion ?? readMacroDisplayVersion()
const buildTime = options?.buildTime ?? readMacroBuildTime()
return { version, displayVersion, buildTime }
}
function detectSource(cwd: string): IssueReport['openclaude']['source'] {
if (existsSync(resolve(cwd, 'src')) && existsSync(resolve(cwd, 'package.json'))) {
return 'source'
}
if (existsSync(resolve(cwd, 'dist', 'cli.mjs'))) {
return 'dist'
}
return 'unknown'
}
function resolveProviderModel(routeId: string, env: NodeJS.ProcessEnv): string {
if (routeId === 'gemini') {
return env.GEMINI_MODEL?.trim() || getRouteDefaultModel(routeId) || 'unknown'
}
if (routeId === 'mistral') {
return env.MISTRAL_MODEL?.trim() || getRouteDefaultModel(routeId) || 'unknown'
}
if (routeId === 'anthropic') {
return env.ANTHROPIC_MODEL?.trim() || getRouteDefaultModel(routeId) || 'unknown'
}
return env.OPENAI_MODEL?.trim() || getRouteDefaultModel(routeId) || 'unknown'
}
function resolveProviderBaseUrl(
routeId: string,
env: NodeJS.ProcessEnv,
): string | undefined {
if (routeId === 'gemini') {
return env.GEMINI_BASE_URL?.trim() || getRouteDefaultBaseUrl(routeId)
}
if (routeId === 'mistral') {
return env.MISTRAL_BASE_URL?.trim() || getRouteDefaultBaseUrl(routeId)
}
if (routeId === 'anthropic') {
return env.ANTHROPIC_BASE_URL?.trim() || getRouteDefaultBaseUrl(routeId)
}
return (
env.OPENAI_BASE_URL?.trim() ||
env.OPENAI_API_BASE?.trim() ||
getRouteDefaultBaseUrl(routeId)
)
}
function resolveRouteId(env: NodeJS.ProcessEnv): string {
const activeRoute = resolveActiveRouteIdFromEnv(env)
const baseUrl = env.OPENAI_BASE_URL ?? env.OPENAI_API_BASE
const normalizedBaseUrlRoute = resolveRouteIdFromBaseUrl(
normalizeRouteMatchBaseUrl(baseUrl),
)
if (activeRoute && activeRoute !== 'custom') return activeRoute
return normalizedBaseUrlRoute ?? activeRoute ?? 'custom'
}
function normalizeRouteMatchBaseUrl(
baseUrl: string | undefined,
): string | undefined {
if (!baseUrl?.trim()) return baseUrl
try {
const parsed = new URL(baseUrl)
parsed.username = ''
parsed.password = ''
parsed.search = ''
parsed.hash = ''
return parsed.toString().replace(/\/+$/, '')
} catch {
return baseUrl
}
}
function getCredentialSummary(routeId: string, env: NodeJS.ProcessEnv) {
const descriptor = getRouteDescriptor(routeId)
const envVars = getRouteCredentialEnvVars(routeId)
const sources = envVars.filter(name => Boolean(env[name]?.trim()))
const requiresAuth = descriptor?.setup.requiresAuth ?? routeId !== 'custom'
return {
required: requiresAuth,
present: sources.length > 0,
sources,
}
}
function getCodexCredentialSummary(env: NodeJS.ProcessEnv): CredentialSummary {
const credentials = resolveRuntimeCodexCredentials({ env })
const sources: string[] = []
if (env.CODEX_API_KEY?.trim()) {
sources.push('CODEX_API_KEY')
}
if (env.CODEX_ACCOUNT_ID?.trim()) {
sources.push('CODEX_ACCOUNT_ID')
} else if (env.CHATGPT_ACCOUNT_ID?.trim()) {
sources.push('CHATGPT_ACCOUNT_ID')
}
if (credentials.source === 'auth.json') {
if (env.CODEX_AUTH_JSON_PATH?.trim()) {
sources.push('CODEX_AUTH_JSON_PATH')
} else if (env.CODEX_HOME?.trim()) {
sources.push('CODEX_HOME')
} else {
sources.push('auth.json')
}
} else if (credentials.source === 'secure-storage') {
sources.push('secure-storage')
}
return {
required: true,
present: Boolean(credentials.apiKey && credentials.accountId),
sources: [...new Set(sources)],
}
}
function getKnownCredentialSourceNames(routeId: string): Set<string> {
return new Set([
...collectProviderSecretEnvVars(),
...getRouteCredentialEnvVars(routeId),
...(routeId === 'codex'
? [
'CODEX_API_KEY',
'CODEX_ACCOUNT_ID',
'CHATGPT_ACCOUNT_ID',
'CODEX_AUTH_JSON_PATH',
'CODEX_HOME',
'auth.json',
'secure-storage',
]
: []),
])
}
function resolveDiagnosticProviderContext(
env: NodeJS.ProcessEnv,
): DiagnosticProviderContext {
if (isTruthy(env.CLAUDE_CODE_USE_OPENAI)) {
const request = resolveProviderRequest({ processEnv: env })
if (request.transport === 'codex_responses') {
return {
routeId: 'codex',
label: 'Codex',
providerType: 'Codex Responses API',
model: request.requestedModel,
limitsModel: request.resolvedModel,
catalogRouteId: 'openai',
baseUrl: request.baseUrl,
credential: getCodexCredentialSummary(env),
}
}
}
const routeId = resolveRouteId(env)
const descriptor = getRouteDescriptor(routeId)
const model = resolveProviderModel(routeId, env)
return {
routeId,
label: descriptor?.label ?? routeId,
providerType: getRouteProviderTypeLabel(routeId),
model,
limitsModel: model,
catalogRouteId: routeId,
baseUrl: resolveProviderBaseUrl(routeId, env),
...(isTruthy(env.CLAUDE_CODE_USE_OPENAI) && env.OPENAI_API_FORMAT
? { apiFormat: env.OPENAI_API_FORMAT }
: {}),
credential: getCredentialSummary(routeId, env),
}
}
function findCatalogEntry(
routeId: string,
model: string,
): ModelCatalogEntry | undefined {
const normalizedModel = model.trim().toLowerCase()
if (!normalizedModel) return undefined
return getCatalogEntriesForRoute(routeId).find(entry => {
return (
entry.apiName.trim().toLowerCase() === normalizedModel ||
entry.id.trim().toLowerCase() === normalizedModel
)
})
}
function buildModelSummary(
catalogRouteId: string,
model: string,
limitsModel: string,
env: NodeJS.ProcessEnv,
baseUrl?: string,
): IssueReport['model'] {
const descriptor = getRouteDescriptor(catalogRouteId)
const catalogEntry =
findCatalogEntry(catalogRouteId, limitsModel) ??
findCatalogEntry(catalogRouteId, model)
const limits = resolveModelRuntimeLimits({
model: limitsModel,
processEnv: env,
baseUrl,
})
const fallbackCapabilities =
catalogEntry?.capabilities ??
descriptor?.catalog?.models?.find(entry => entry.default)?.capabilities ??
{}
return {
contextWindow: limits.contextWindow,
maxOutputTokens: limits.maxOutputTokens,
catalogSource: resolveCatalogSource(catalogRouteId, descriptor, catalogEntry),
capabilities: fallbackCapabilities,
}
}
function resolveCatalogSource(
routeId: string,
descriptor: ReturnType<typeof getRouteDescriptor>,
catalogEntry: ModelCatalogEntry | undefined,
): IssueReport['model']['catalogSource'] {
if (catalogEntry) return descriptor?.catalog?.source ?? 'unknown'
if (descriptor?.catalog?.discovery) return 'dynamic'
if (routeId === 'custom') return 'custom'
return 'unknown'
}
function summarizeSettings(
options?: BuildIssueReportOptions['settings'],
): IssueReport['settings'] {
if (options) {
return {
sourcesPresent: options.sourcesPresent,
validationErrors: options.validationErrors.map(error => ({
file: basename(redactHomePath(error.file ?? 'unknown')),
path: error.path,
message: redactLikelySecrets(redactHomePath(error.message)),
})),
}
}
const withSources = getSettingsWithSources()
const withErrors = getSettingsWithErrors()
return {
sourcesPresent: withSources.sources.map(source => source.source),
validationErrors: withErrors.errors.map(error => ({
file: basename(redactHomePath(error.file ?? 'unknown')),
path: error.path,
message: redactLikelySecrets(redactHomePath(error.message)),
})),
}
}
function getMcpTransport(config: Partial<ScopedMcpServerConfig>): string {
return config.type ?? 'stdio'
}
function summarizeMcpServers(
servers: Record<string, Partial<ScopedMcpServerConfig>>,
): IssueReport['mcp'] {
const transports: Record<string, number> = {}
for (const config of Object.values(servers)) {
const transport = getMcpTransport(config)
transports[transport] = (transports[transport] ?? 0) + 1
}
return {
serverCount: Object.keys(servers).length,
transports,
}
}
async function getMcpSummary(
servers?: Record<string, Partial<ScopedMcpServerConfig>>,
): Promise<IssueReport['mcp']> {
if (servers) return summarizeMcpServers(servers)
try {
const result = await getClaudeCodeMcpConfigs()
return summarizeMcpServers(result.servers)
} catch {
return { serverCount: 0, transports: {} }
}
}
function summarizeErrors(
errors: Array<{ error: string; timestamp: string }>,
includeDebug: boolean,
): IssueReport['errors'] {
const counts = new Map<string, number>()
const debug: string[] = []
for (const entry of errors.slice(-20)) {
const firstLine = entry.error.split(/\r?\n/, 1)[0] ?? entry.error
const category =
firstLine.match(/^([A-Za-z][A-Za-z0-9_ .-]{0,60})(?::|$)/)?.[1]?.trim() ||
'Error'
counts.set(category, (counts.get(category) ?? 0) + 1)
if (includeDebug) {
debug.push(redactLikelySecrets(redactHomePath(firstLine)).slice(0, 500))
}
}
const recent = [...counts.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([category, count]) => ({ category, count }))
return includeDebug ? { recent, debug } : { recent }
}
async function buildChecks(
cwd: string,
options?: BuildIssueReportOptions['checks'],
): Promise<DiagnosticCheck[]> {
const buildArtifactsPresent =
options?.buildArtifactsPresent ?? existsSync(resolve(cwd, 'dist', 'cli.mjs'))
if (!options?.ripgrep) {
await testRipgrepOnFirstUse()
}
const ripgrep =
options?.ripgrep ??
(() => {
const status = getRipgrepStatus()
return {
available: status.working ?? false,
detail:
status.working === null
? `${status.mode} ripgrep (not tested)`
: status.mode === 'system' && status.path
? 'system rg'
: `${status.mode} ripgrep`,
}
})()
return [
{
label: 'Build artifacts',
ok: buildArtifactsPresent,
detail: buildArtifactsPresent ? 'dist/cli.mjs present' : 'dist/cli.mjs missing',
},
{
label: 'ripgrep',
ok: ripgrep.available,
detail: ripgrep.detail,
},
]
}
export async function buildIssueReport(
options: BuildIssueReportOptions = {},
): Promise<IssueReport> {
const env = options.env ?? process.env
const cwd = options.cwd ?? process.cwd()
const now = options.now ?? new Date()
const packageInfo = readPackageInfo(options.packageInfo)
const providerContext = resolveDiagnosticProviderContext(env)
const mcp = await getMcpSummary(options.mcpServers)
const settings = summarizeSettings(options.settings)
const knownCredentialSourceNames = getKnownCredentialSourceNames(
providerContext.routeId,
)
const activeCredentialSources = providerContext.credential.sources.filter(source =>
knownCredentialSourceNames.has(source),
)
const report: IssueReport = {
schemaVersion: 1,
generatedAt: now.toISOString(),
openclaude: {
version: packageInfo.version,
...(packageInfo.displayVersion ? { displayVersion: packageInfo.displayVersion } : {}),
...(packageInfo.buildTime ? { buildTime: packageInfo.buildTime } : {}),
source: detectSource(cwd),
},
workspace: {
cwd: basename(cwd) || '(unknown)',
},
runtime: {
platform: platform(),
arch: arch(),
node: process.versions.node,
bun: (globalThis as { Bun?: { version?: string } }).Bun?.version ?? null,
packageManager: readPackageManager(env),
tty: {
stdin: Boolean(process.stdin.isTTY),
stdout: Boolean(process.stdout.isTTY),
stderr: Boolean(process.stderr.isTTY),
},
},
provider: {
routeId: providerContext.routeId,
label: providerContext.label,
providerType: providerContext.providerType,
model: providerContext.model,
...(providerContext.apiFormat ? { apiFormat: providerContext.apiFormat } : {}),
...(providerContext.baseUrl
? { baseUrl: redactDiagnosticUrl(providerContext.baseUrl) }
: {}),
credential: {
...providerContext.credential,
sources: activeCredentialSources,
},
},
model: buildModelSummary(
providerContext.catalogRouteId,
providerContext.model,
providerContext.limitsModel,
env,
providerContext.baseUrl,
),
settings,
checks: await buildChecks(cwd, options.checks),
mcp,
errors: summarizeErrors(options.errors ?? getInMemoryErrors(), options.includeDebug ?? false),
warnings: [],
redaction: {
homeRedacted: true,
cwdRedacted: true,
secretsIncluded: false,
},
}
return redactDiagnosticObject(report) as IssueReport
}
function formatStatus(ok: boolean): string {
return ok ? 'PASS' : 'WARN'
}
function tableEscape(value: string | number | boolean | null | undefined): string {
return String(value ?? '')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, ' ')
}
export function formatIssueReportAsMarkdown(report: IssueReport): string {
const lines = [
'# OpenClaude diagnostic report',
'',
'## Summary',
`- OpenClaude: ${report.openclaude.displayVersion ?? report.openclaude.version}`,
`- Runtime: ${report.runtime.platform} ${report.runtime.arch}, Node ${report.runtime.node}`,
`- Provider: ${report.provider.label} (${report.provider.routeId})`,
`- Model: ${report.provider.model}`,
'',
'## Checks',
'| Check | Status | Detail |',
'| --- | --- | --- |',
...report.checks.map(check =>
`| ${tableEscape(check.label)} | ${formatStatus(check.ok)} | ${tableEscape(check.detail)} |`,
),
'',
'## Provider',
`- Route: ${report.provider.routeId}`,
`- Type: ${report.provider.providerType}`,
`- Base URL: ${report.provider.baseUrl ?? '(not set)'}`,
`- Credential: ${report.provider.credential.present ? 'present' : 'missing'}${
report.provider.credential.sources.length > 0
? ` (${report.provider.credential.sources.join(', ')})`
: ''
}`,
'',
'## Model',
`- Catalog source: ${report.model.catalogSource}`,
`- Context window: ${report.model.contextWindow ?? 'unknown'}`,
`- Max output tokens: ${report.model.maxOutputTokens ?? 'unknown'}`,
`- Capabilities: ${Object.entries(report.model.capabilities)
.filter(([, enabled]) => enabled === true)
.map(([name]) => name)
.sort()
.join(', ') || 'unknown'}`,
'',
'## Settings',
`- Sources present: ${report.settings.sourcesPresent.join(', ') || 'none'}`,
`- Validation errors: ${report.settings.validationErrors.length}`,
'',
'## MCP',
`- Server count: ${report.mcp.serverCount}`,
`- Transports: ${Object.entries(report.mcp.transports)
.map(([transport, count]) => `${transport}: ${count}`)
.join(', ') || 'none'}`,
'',
'## Recent Errors',
report.errors.recent.length > 0
? report.errors.recent
.map(error => `- ${error.category}: ${error.count}`)
.join('\n')
: '- none',
'',
'## Notes',
'This report is redacted. It should not contain API keys, prompts, transcripts, or file contents.',
'',
]
return lines.join('\n')
}
export function formatIssueReportAsJson(report: IssueReport): string {
return JSON.stringify(report, null, 2)
}
export function parseIssueReportArgs(args: string[]): IssueReportArgs {
const parsed: IssueReportArgs = {
format: 'markdown',
outFile: null,
includeDebug: false,
redacted: true,
}
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if (arg === '--json') {
parsed.format = 'json'
continue
}
if (arg === '--markdown') {
parsed.format = 'markdown'
continue
}
if (arg === '--include-debug') {
parsed.includeDebug = true
continue
}
if (arg === '--redacted') {
parsed.redacted = true
continue
}
if (arg === '--out') {
const outFile = args[index + 1]
if (outFile && !outFile.startsWith('--')) {
parsed.outFile = outFile
index++
}
continue
}
if (arg.startsWith('--out=')) {
parsed.outFile = arg.slice('--out='.length)
}
}
return parsed
}
export function formatIssueReport(
report: IssueReport,
format: IssueReportFormat,
): string {
return format === 'json'
? formatIssueReportAsJson(report)
: formatIssueReportAsMarkdown(report)
}
export async function renderIssueReport(
options: Partial<IssueReportArgs> & BuildIssueReportOptions = {},
): Promise<string> {
const report = await buildIssueReport({
...options,
includeDebug: options.includeDebug,
})
return formatIssueReport(report, options.format ?? 'markdown')
}
export function writeIssueReport(outFile: string, content: string): string {
const outputPath = resolve(process.cwd(), outFile)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, content, 'utf8')
return outputPath
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test'
import { homedir } from 'node:os'
import { PROVIDER_PRESET_MANIFEST } from '../../integrations/index.js'
import {
collectProviderSecretEnvVars,
redactDiagnosticObject,
redactDiagnosticUrl,
redactHomePath,
summarizeSecretEnvPresence,
} from './redaction.js'
describe('diagnostic redaction', () => {
test('collects every provider preset API key env var from the generated manifest', () => {
const expected = new Set(
PROVIDER_PRESET_MANIFEST.flatMap(preset =>
'apiKeyEnvVars' in preset ? [...preset.apiKeyEnvVars] : [],
),
)
expect(new Set(collectProviderSecretEnvVars())).toEqual(expected)
expect(expected.size).toBeGreaterThan(10)
})
test('represents provider preset secret env vars as presence booleans only', () => {
const envVars = collectProviderSecretEnvVars()
const env = Object.fromEntries(
envVars.map((name, index) => [name, `sk-${name}-secret-${index}`]),
)
const summary = summarizeSecretEnvPresence(env, envVars)
const serialized = JSON.stringify(summary)
for (const name of envVars) {
expect(summary).toContainEqual({ name, present: true })
expect(serialized).not.toContain(env[name]!)
}
})
test('redacts known and likely secret-looking values in nested objects', () => {
const redacted = redactDiagnosticObject({
OPENAI_API_KEY: 'sk-openai-secret',
headers: {
Authorization: 'Bearer abc123',
'x-api-key': 'plain-token',
},
nested: [{ password: 'hunter2' }, { safe: 'enabled' }],
})
expect(redacted).toEqual({
OPENAI_API_KEY: '[set]',
headers: {
Authorization: '[redacted]',
'x-api-key': '[redacted]',
},
nested: [{ password: '[redacted]' }, { safe: 'enabled' }],
})
})
test('redacts secret-looking values even under harmless field names', () => {
const home = homedir()
const redacted = redactDiagnosticObject({
messages: [
'request used sk-openai-secret-token',
'google key AIzaSyDUMMY-secret-token',
'header was Bearer abcdefghijklmnop',
'token github_pat_abcdefghijklmnopqrstuvwxyz',
'MISTRAL_API_KEY=mistralOpaqueToken123456789',
'mistral api key abcdefghijklmnopqrstuvwxyz',
],
path: `${home}/private/openclaude/src/file.ts`,
}) as { messages: string[]; path: string }
const serialized = JSON.stringify(redacted)
expect(redacted.messages).toEqual([
'request used [redacted]',
'google key [redacted]',
'header was [redacted]',
'token [redacted]',
'MISTRAL_API_KEY=[redacted]',
'mistral api key [redacted]',
])
expect(redacted.path).toBe('~/private/openclaude/src/file.ts')
expect(serialized).not.toContain('sk-openai-secret-token')
expect(serialized).not.toContain('AIzaSyDUMMY-secret-token')
expect(serialized).not.toContain('abcdefghijklmnop')
expect(serialized).not.toContain('github_pat_abcdefghijklmnopqrstuvwxyz')
expect(serialized).not.toContain('mistralOpaqueToken123456789')
expect(serialized).not.toContain('abcdefghijklmnopqrstuvwxyz')
expect(serialized).not.toContain(home)
})
test('does not redact arbitrary opaque ids without Mistral key context', () => {
expect(
redactDiagnosticObject({
traceId: 'abcdefghijklmnopqrstuvwxyz',
message: 'request id abcdefghijklmnopqrstuvwxyz failed',
}),
).toEqual({
traceId: 'abcdefghijklmnopqrstuvwxyz',
message: 'request id abcdefghijklmnopqrstuvwxyz failed',
})
})
test('redacts Windows-style home paths without matching sibling directories', () => {
const home = 'C:\\Users\\Alice'
expect(
redactHomePath(
'debug path C:\\Users\\Alice\\AppData\\Roaming\\openclaude',
home,
),
).toBe('debug path ~\\AppData\\Roaming\\openclaude')
expect(redactHomePath('C:\\Users\\AliceOther\\openclaude', home)).toBe(
'C:\\Users\\AliceOther\\openclaude',
)
})
test('sanitizes credentials and sensitive query params in URLs', () => {
expect(
redactDiagnosticUrl(
'https://user:pass@example.com/v1?api_key=secret&mode=test&token=abc',
),
).toBe(
'https://redacted:redacted@example.com/v1?api_key=redacted&mode=test&token=redacted',
)
})
})
+128
View File
@@ -0,0 +1,128 @@
import { homedir } from 'node:os'
import { PROVIDER_PRESET_MANIFEST } from '../../integrations/index.js'
import { redactUrlForDisplay } from '../urlRedaction.js'
const SECRET_KEY_PATTERN =
/(?:api[_-]?key|auth(?:orization)?|bearer|cookie|credential|password|passwd|pwd|private[_-]?key|refresh[_-]?token|secret|token)/i
type SecretValuePattern = {
pattern: RegExp
replacement: string
}
const LIKELY_SECRET_VALUE_PATTERNS = [
{ pattern: /\bsk-[A-Za-z0-9_-]{8,}\b/g, replacement: '[redacted]' },
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{8,}\b/g, replacement: '[redacted]' },
{ pattern: /\bAIza[0-9A-Za-z_-]{10,}\b/g, replacement: '[redacted]' },
{ pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, replacement: '[redacted]' },
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{10,}\b/g, replacement: '[redacted]' },
{ pattern: /\bgh[pousr]_[A-Za-z0-9_]{10,}\b/g, replacement: '[redacted]' },
{
pattern:
/\b((?:MISTRAL_API_KEY|mistral(?:\s+api)?\s+key)(?:\s*[:=]\s*|\s+)["']?)[A-Za-z0-9._~+/=-]{12,}(?=$|[\s"',;)\]}])/gi,
replacement: '$1[redacted]',
},
] satisfies SecretValuePattern[]
export type SecretEnvPresence = {
name: string
present: boolean
}
function unique(values: Iterable<string>): string[] {
return [...new Set([...values].filter(Boolean))].sort((a, b) =>
a.localeCompare(b),
)
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function collectProviderSecretEnvVars(): string[] {
return unique(
PROVIDER_PRESET_MANIFEST.flatMap(preset =>
'apiKeyEnvVars' in preset ? [...preset.apiKeyEnvVars] : [],
),
)
}
export function summarizeSecretEnvPresence(
env: NodeJS.ProcessEnv,
envVars: readonly string[] = collectProviderSecretEnvVars(),
): SecretEnvPresence[] {
return unique(envVars).map(name => ({
name,
present: Boolean(env[name]?.trim()),
}))
}
export function redactDiagnosticUrl(rawUrl: string | undefined): string | undefined {
if (!rawUrl) return undefined
return redactUrlForDisplay(rawUrl).replace(/\/+$/, '')
}
export function redactHomePath(
value: string,
homeDir = homedir(),
): string {
if (!value || !homeDir) return value
const normalizedHome = homeDir.replace(/[/\\]+$/, '')
if (!normalizedHome) return value
return value.replace(
new RegExp(`${escapeRegExp(normalizedHome)}(?=$|[/\\\\])`, 'g'),
'~',
)
}
export function redactLikelySecrets(value: string): string {
return LIKELY_SECRET_VALUE_PATTERNS.reduce(
(current, { pattern, replacement }) => current.replace(pattern, replacement),
value,
)
}
function isSecretKey(key: string): boolean {
return SECRET_KEY_PATTERN.test(key)
}
function isEnvPresenceKey(key: string): boolean {
return /^[A-Z0-9_]+$/.test(key) && /(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTH)/.test(key)
}
export function redactDiagnosticObject(value: unknown): unknown {
return redactDiagnosticObjectInternal(value)
}
function redactDiagnosticObjectInternal(value: unknown, key?: string): unknown {
if (value === null || value === undefined) return value
if (typeof value === 'string') {
if (key && isSecretKey(key)) {
return isEnvPresenceKey(key) ? '[set]' : '[redacted]'
}
return redactLikelySecrets(redactHomePath(value))
}
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
) {
return value
}
if (Array.isArray(value)) {
return value.map(item => redactDiagnosticObjectInternal(item))
}
if (typeof value === 'object') {
const output: Record<string, unknown> = {}
for (const [entryKey, entryValue] of Object.entries(value)) {
output[entryKey] = redactDiagnosticObjectInternal(entryValue, entryKey)
}
return output
}
return String(value)
}
+1 -1
View File
@@ -648,7 +648,7 @@ export function getRipgrepStatus(): {
/**
* Test ripgrep availability on first use and cache the result
*/
const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
export const testRipgrepOnFirstUse = memoize(async (): Promise<void> => {
// Already tested
if (ripgrepStatus !== null) {
return