mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
@@ -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`,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
}, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user