diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 17f7d558..31db9884 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -120,19 +120,18 @@ function applyFastFlags(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return env } -function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void { +function printSummary(profile: ProviderProfile): void { console.log(`Launching profile: ${profile}`) if (profile === 'gemini') { - console.log(`GEMINI_MODEL=${env.GEMINI_MODEL}`) + console.log('Using configured Gemini provider settings.') } else if (profile === 'codex') { - console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) - console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log('Using configured Codex/OpenAI-compatible provider settings.') } else if (profile === 'atomic-chat') { - console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) - console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log('Using configured Atomic Chat provider settings.') + } else if (profile === 'ollama') { + console.log('Using configured Ollama provider settings.') } else { - console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) - console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log('Using configured OpenAI-compatible provider settings.') } } @@ -227,7 +226,7 @@ async function main(): Promise { } } - printSummary(profile, env) + printSummary(profile) const doctorCode = await runProcess('bun', ['run', 'scripts/system-check.ts'], env) if (doctorCode !== 0) { diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index f7483ca8..ac6edaef 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -325,7 +325,7 @@ export async function mcpGetHandler(name: string): Promise { if (server.oauth.clientId) { parts.push('oauth client configured'); } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) parts.push('callback port configured'); // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` OAuth: ${parts.join(', ')}`); } @@ -347,7 +347,7 @@ export async function mcpGetHandler(name: string): Promise { if (server.oauth.clientId) { parts.push('oauth client configured'); } - if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + if (server.oauth.callbackPort) parts.push('callback port configured'); // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` OAuth: ${parts.join(', ')}`); } diff --git a/src/commands/install-github-app/install-github-app.tsx b/src/commands/install-github-app/install-github-app.tsx index 639f2237..000ea225 100644 --- a/src/commands/install-github-app/install-github-app.tsx +++ b/src/commands/install-github-app/install-github-app.tsx @@ -282,17 +282,16 @@ function InstallGitHubApp(props: { return; } const repoWarnings: Warning[] = []; - if (repoName_1.includes('github.com')) { - const slug = extractGitHubRepoSlug(repoName_1); - if (!slug) { - repoWarnings.push({ - title: 'Invalid GitHub URL format', - message: 'The repository URL format appears to be invalid.', - instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'] - }); - } else { - repoName_1 = slug; - } + const slug = extractGitHubRepoSlug(repoName_1); + const isUrlLike = /^[a-z][a-z0-9+.-]*:\/\//i.test(repoName_1) || repoName_1.startsWith('www.'); + if (slug) { + repoName_1 = slug; + } else if (isUrlLike) { + repoWarnings.push({ + title: 'Invalid GitHub URL format', + message: 'The repository URL format appears to be invalid.', + instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'] + }); } if (!repoName_1.includes('/')) { repoWarnings.push({ diff --git a/src/commands/install-github-app/repoSlug.test.ts b/src/commands/install-github-app/repoSlug.test.ts index a3058391..79253abd 100644 --- a/src/commands/install-github-app/repoSlug.test.ts +++ b/src/commands/install-github-app/repoSlug.test.ts @@ -33,4 +33,16 @@ test('rejects malformed or non-GitHub URLs', () => { assert.equal(extractGitHubRepoSlug('https://gitlab.com/Gitlawb/openclaude'), null) assert.equal(extractGitHubRepoSlug('https://github.com/Gitlawb'), null) assert.equal(extractGitHubRepoSlug('not actually github.com/Gitlawb/openclaude'), null) + assert.equal( + extractGitHubRepoSlug('https://evil.example/?next=github.com/Gitlawb/openclaude'), + null, + ) + assert.equal( + extractGitHubRepoSlug('https://github.com.evil.example/Gitlawb/openclaude'), + null, + ) + assert.equal( + extractGitHubRepoSlug('https://example.com/github.com/Gitlawb/openclaude'), + null, + ) }) diff --git a/src/commands/install-github-app/repoSlug.ts b/src/commands/install-github-app/repoSlug.ts index 4d2915e1..17d4b2cb 100644 --- a/src/commands/install-github-app/repoSlug.ts +++ b/src/commands/install-github-app/repoSlug.ts @@ -1,12 +1,24 @@ export function extractGitHubRepoSlug(value: string): string | null { const trimmed = value.trim() - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.includes('github.com')) { - return null + const slugMatch = trimmed.match( + /^(?[^/:\s]+)\/(?[^/\s]+?)(?:\.git)?\/?$/i, + ) + if (slugMatch?.groups?.owner && slugMatch.groups.repo) { + return `${slugMatch.groups.owner}/${slugMatch.groups.repo}`.replace( + /\.git$/i, + '', + ) } - if (!trimmed.includes('github.com')) { - return trimmed + const shorthandUrlMatch = trimmed.match( + /^(?:https?:\/\/)?(?:www\.)?github\.com\/(?[^/:\s]+)\/(?[^/\s]+?)(?:\.git)?\/?$/i, + ) + if (shorthandUrlMatch?.groups?.owner && shorthandUrlMatch.groups.repo) { + return `${shorthandUrlMatch.groups.owner}/${shorthandUrlMatch.groups.repo}`.replace( + /\.git$/i, + '', + ) } const sshMatch = trimmed.match( @@ -16,6 +28,10 @@ export function extractGitHubRepoSlug(value: string): string | null { return `${sshMatch.groups.owner}/${sshMatch.groups.repo}` } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + return null + } + try { const parsed = new URL(trimmed) const hostname = parsed.hostname.toLowerCase() diff --git a/src/services/oauth/crypto.test.ts b/src/services/oauth/crypto.test.ts new file mode 100644 index 00000000..849f3dbe --- /dev/null +++ b/src/services/oauth/crypto.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './crypto.ts' + +test('generateCodeChallenge returns the RFC 7636 S256 challenge', async () => { + const challenge = await generateCodeChallenge( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + ) + assert.equal(challenge, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') +}) + +test('generateCodeVerifier returns a URL-safe random string', () => { + const verifier = generateCodeVerifier() + assert.match(verifier, /^[A-Za-z0-9_-]+$/) + assert.ok(verifier.length >= 43) +}) + +test('generateState returns a URL-safe random string', () => { + const state = generateState() + assert.match(state, /^[A-Za-z0-9_-]+$/) + assert.ok(state.length >= 43) +}) diff --git a/src/services/oauth/crypto.ts b/src/services/oauth/crypto.ts index 93f87265..b2881efa 100644 --- a/src/services/oauth/crypto.ts +++ b/src/services/oauth/crypto.ts @@ -1,4 +1,4 @@ -import { createHash, randomBytes } from 'crypto' +import { randomBytes, webcrypto } from 'crypto' function base64URLEncode(buffer: Buffer): string { return buffer @@ -12,10 +12,10 @@ export function generateCodeVerifier(): string { return base64URLEncode(randomBytes(32)) } -export function generateCodeChallenge(verifier: string): string { - const hash = createHash('sha256') - hash.update(verifier) - return base64URLEncode(hash.digest()) +export async function generateCodeChallenge(verifier: string): Promise { + const encoded = new TextEncoder().encode(verifier) + const digest = await webcrypto.subtle.digest('SHA-256', encoded) + return base64URLEncode(Buffer.from(digest)) } export function generateState(): string { diff --git a/src/services/oauth/index.ts b/src/services/oauth/index.ts index fdd8e5f7..1c0a88ad 100644 --- a/src/services/oauth/index.ts +++ b/src/services/oauth/index.ts @@ -52,7 +52,7 @@ export class OAuthService { this.port = await this.authCodeListener.start() // Generate PKCE values and state - const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier) + const codeChallenge = await crypto.generateCodeChallenge(this.codeVerifier) const state = crypto.generateState() // Build auth URLs for both automatic and manual flows diff --git a/src/utils/execFileNoThrow.ts b/src/utils/execFileNoThrow.ts index d6bf018e..cc71fb8d 100644 --- a/src/utils/execFileNoThrow.ts +++ b/src/utils/execFileNoThrow.ts @@ -50,7 +50,6 @@ type ExecFileWithCwdOptions = { maxBuffer?: number cwd?: string env?: NodeJS.ProcessEnv - shell?: boolean | string | undefined stdin?: 'ignore' | 'inherit' | 'pipe' input?: string } @@ -96,7 +95,6 @@ export function execFileNoThrowWithCwd( cwd: finalCwd, env: finalEnv, maxBuffer, - shell, stdin: finalStdin, input: finalInput, }: ExecFileWithCwdOptions = { @@ -113,7 +111,7 @@ export function execFileNoThrowWithCwd( timeout: finalTimeout, cwd: finalCwd, env: finalEnv, - shell, + shell: false, stdin: finalStdin, input: finalInput, reject: false, // Don't throw on non-zero exit codes diff --git a/src/utils/log.ts b/src/utils/log.ts index d10d8abf..87579165 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -159,7 +159,7 @@ export function logError(error: unknown): void { const err = toError(error) if (feature('HARD_FAIL') && isHardFailMode()) { // biome-ignore lint/suspicious/noConsole:: intentional crash output - console.error('[HARD FAIL] logError called:', err.name || 'Error') + console.error('[HARD FAIL] logError called') // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) }