Skip to content

Commit 3a202e1

Browse files
feat: Enhance GitHub authentication and error handling in repository operations
- Improved token authentication for GitHub repositories with multiple methods - Added robust error handling and token validation for cloning and pushing - Implemented token type detection and sanitization of error messages - Enhanced logging with secure token representation and detailed diagnostics - Added methods to safely handle different GitHub token types and authentication scenarios
1 parent 5edf1ad commit 3a202e1

File tree

4 files changed

+273
-15
lines changed

4 files changed

+273
-15
lines changed

.claudeignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
node_modules
2+
npm-debug.log
3+
*.pem
4+
!mock-cert.pem
5+
.env
6+
coverage
7+
lib
8+
.env.archive

.claudesync/config.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"active_provider": "claude.ai",
3+
"local_path": "/Users/stevengonsalvez/d/git/probot-aider-bot",
4+
"active_organization_id": "8aee5349-4c55-4608-b5a4-f81691a3180d",
5+
"active_project_id": "76ad8c6a-72b1-4664-9d8c-7ad4f209896e",
6+
"active_project_name": "probot-aider-bot"
7+
}

src/github.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,19 @@ export class GitHubClient {
233233
async getInstallationToken(): Promise<string> {
234234
await this.waitForRateLimit();
235235
try {
236+
// Explicitly request required permissions for repository operations
236237
const { data } = await this.context.octokit.apps.createInstallationAccessToken({
237238
installation_id: this.context.payload.installation.id,
239+
repositories: [this.context.payload.repository.name], // Limit to this repo only
240+
permissions: {
241+
contents: 'write', // Essential for cloning and pushing changes
242+
pull_requests: 'write', // For creating PRs
243+
issues: 'write', // For commenting on issues
244+
metadata: 'read' // For repo metadata
245+
}
238246
});
247+
248+
console.log(`Generated installation token with permissions: ${JSON.stringify(data.permissions)}`);
239249
return data.token;
240250
} catch (error) {
241251
console.error(`Error getting installation token: ${error instanceof Error ? error.message : String(error)}`);

src/patch.ts

Lines changed: 248 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,85 @@ export class PatchClient {
105105
}
106106

107107
try {
108+
// Diagnostic logging for tokens (without exposing the token)
109+
if (authToken) {
110+
console.log(`Auth token provided: ${authToken.substring(0, 4)}...${authToken.substring(authToken.length - 4)} (${authToken.length} chars)`);
111+
console.log(`Token type: ${this.identifyTokenType(authToken)}`);
112+
} else {
113+
console.log('No auth token provided for repository access');
114+
}
115+
108116
// Prepare auth for private repositories if token is provided
109117
let authenticatedRepoUrl = repoUrl;
110118
if (authToken) {
111-
// Insert auth token into the URL
119+
// Validate token format
120+
if (!this.isValidGitHubToken(authToken)) {
121+
console.warn('Warning: Token format does not match standard GitHub token patterns');
122+
}
123+
124+
// Use token for authentication but handle differently based on URL format
112125
const url = new URL(repoUrl);
113-
authenticatedRepoUrl = repoUrl.replace(`${url.protocol}//`, `${url.protocol}//${authToken}@`);
126+
127+
// GitHub-specific handling with multiple auth methods
128+
if (url.hostname === 'github.com') {
129+
// Determine the correct authentication format based on token type
130+
const tokenType = this.identifyTokenType(authToken);
131+
132+
// Create authentication URLs in order of preference
133+
const authUrls: string[] = [];
134+
135+
if (tokenType === 'GitHub App Installation Token') {
136+
// For GitHub App installation tokens (ghs_*), x-access-token is preferred
137+
authUrls.push(`https://x-access-token:${authToken}@github.com${url.pathname}`);
138+
authUrls.push(`https://${authToken}@github.com${url.pathname}`);
139+
} else if (tokenType === 'OAuth App Token') {
140+
// For OAuth tokens, oauth2: prefix is preferred
141+
authUrls.push(`https://oauth2:${authToken}@github.com${url.pathname}`);
142+
authUrls.push(`https://${authToken}@github.com${url.pathname}`);
143+
} else {
144+
// For PATs and unknown tokens, try multiple formats
145+
authUrls.push(`https://${authToken}@github.com${url.pathname}`);
146+
authUrls.push(`https://x-access-token:${authToken}@github.com${url.pathname}`);
147+
authUrls.push(`https://oauth2:${authToken}@github.com${url.pathname}`);
148+
}
149+
150+
console.log('Prepared multiple authentication methods for GitHub');
151+
152+
// Try multiple authentication methods in sequence
153+
let cloned = false;
154+
let lastError: Error | null = null;
155+
156+
for (const authUrl of authUrls) {
157+
try {
158+
console.log('Attempting GitHub repository clone...');
159+
await this.attemptRepositoryClone(authUrl, this.workingDir);
160+
cloned = true;
161+
console.log('Authentication method succeeded');
162+
break;
163+
} catch (cloneError) {
164+
const errorMessage = cloneError instanceof Error ? cloneError.message : String(cloneError);
165+
lastError = cloneError instanceof Error ? cloneError : new Error(String(cloneError));
166+
console.log(`Authentication method failed: ${this.sanitizeErrorMessage(errorMessage)}`);
167+
}
168+
}
169+
170+
// If all methods failed, throw the last error
171+
if (!cloned) {
172+
if (lastError) {
173+
throw lastError;
174+
}
175+
throw new Error('All authentication methods failed. Please verify the token has the "contents: read" permission.');
176+
}
177+
} else {
178+
// For other Git providers, use their URL structure
179+
authenticatedRepoUrl = repoUrl.replace(`${url.protocol}//`, `${url.protocol}//${authToken}@`);
180+
await this.attemptRepositoryClone(authenticatedRepoUrl, this.workingDir);
181+
}
182+
} else {
183+
// No auth token provided - try public access
184+
console.log('Attempting to clone repository without authentication');
185+
await this.attemptRepositoryClone(repoUrl, this.workingDir);
114186
}
115-
116-
// Clone the repository
117-
console.log(`Cloning repository: ${repoUrl} to ${this.workingDir}`);
118-
await execa('git', ['clone', authenticatedRepoUrl, this.workingDir]);
119187

120188
// Configure Git for commits
121189
await execa('git', ['config', 'user.name', 'patchmycode'], { cwd: this.workingDir });
@@ -252,13 +320,64 @@ export class PatchClient {
252320
// Configure push URL with auth if needed
253321
if (authToken) {
254322
const url = new URL(repoUrl);
255-
const authenticatedPushUrl = repoUrl.replace(`${url.protocol}//`, `${url.protocol}//${authToken}@`);
323+
// Use proper GitHub authentication format based on token type
324+
let authenticatedPushUrl;
325+
326+
if (url.hostname === 'github.com') {
327+
const tokenType = this.identifyTokenType(authToken);
328+
329+
if (tokenType === 'GitHub App Installation Token') {
330+
// For GitHub App installation tokens (ghs_*)
331+
authenticatedPushUrl = `https://x-access-token:${authToken}@github.com${url.pathname}`;
332+
} else if (tokenType === 'OAuth App Token') {
333+
// For OAuth tokens
334+
authenticatedPushUrl = `https://oauth2:${authToken}@github.com${url.pathname}`;
335+
} else if (tokenType === 'Personal Access Token' || tokenType === 'Fine-grained Personal Access Token') {
336+
// For PATs
337+
authenticatedPushUrl = `https://oauth2:${authToken}@github.com${url.pathname}`;
338+
} else {
339+
// Fallback for unknown token types - try x-access-token format
340+
authenticatedPushUrl = `https://x-access-token:${authToken}@github.com${url.pathname}`;
341+
console.log('Using x-access-token format for GitHub authentication');
342+
}
343+
} else {
344+
// For non-GitHub repositories
345+
authenticatedPushUrl = repoUrl.replace(`${url.protocol}//`, `${url.protocol}//${authToken}@`);
346+
}
347+
348+
// Apply the authenticated URL for push
349+
console.log(`Configuring authenticated remote for pushing to ${url.hostname}${url.pathname}`);
256350
await execa('git', ['remote', 'set-url', 'origin', authenticatedPushUrl], { cwd: this.workingDir });
257351
}
258352

259353
// Push the changes
260354
console.log(`Pushing changes to branch: ${branchName}`);
261-
await execa('git', ['push', 'origin', branchName], { cwd: this.workingDir });
355+
// Set environment variables for Git to prevent prompting
356+
const pushEnv = {
357+
...process.env,
358+
GIT_TERMINAL_PROMPT: '0',
359+
GIT_ASKPASS: 'echo',
360+
GCM_INTERACTIVE: 'never'
361+
};
362+
363+
try {
364+
await execa('git', ['push', 'origin', branchName], {
365+
cwd: this.workingDir,
366+
env: pushEnv,
367+
timeout: 60000 // 1 minute timeout for push
368+
});
369+
} catch (pushError) {
370+
const errorMessage = pushError instanceof Error ? pushError.message : String(pushError);
371+
console.error(`Push error (sanitized): ${this.sanitizeErrorMessage(errorMessage)}`);
372+
373+
if (errorMessage.includes('could not read Username') ||
374+
errorMessage.includes('Authentication failed') ||
375+
errorMessage.includes('403') ||
376+
errorMessage.includes('401')) {
377+
throw new Error('Failed to push changes: Authentication error. The token may not have write access to this repository.');
378+
}
379+
throw new Error(`Failed to push changes: ${this.sanitizeErrorMessage(errorMessage)}`);
380+
}
262381

263382
return {
264383
success: true,
@@ -278,9 +397,13 @@ export class PatchClient {
278397

279398
// Try to get additional information from the error
280399
let errorMessage = error instanceof Error ? error.message : String(error);
281-
const fullError = errorMessage; // Keep the full error for logging
282400

283-
// Log the full error for debugging
401+
// Sanitize error message to remove any tokens
402+
errorMessage = this.sanitizeErrorMessage(errorMessage);
403+
404+
const fullError = errorMessage; // Keep the sanitized error for logging
405+
406+
// Log the sanitized error for debugging
284407
console.error('Full Aider error:', fullError);
285408

286409
// Check for specific error types
@@ -305,12 +428,12 @@ export class PatchClient {
305428
errorMessage = `Aider command line error: ${errorMessage}\n\nThis may be due to version differences. Try updating Aider: pip install -U aider-chat`;
306429
}
307430

308-
// If using Claude model, add specific advice
431+
// Log Claude-specific advice to console, but don't include it in the user-facing error message
309432
if (this.isClaudeModel()) {
310-
errorMessage += '\n\nAdditional Claude troubleshooting:\n' +
311-
'• Make sure ANTHROPIC_API_KEY is set and valid\n' +
312-
'• Try different flags for Claude in AIDER_EXTRA_ARGS (--anthropic or --claude)\n' +
313-
'• Check Aider version compatibility with Claude models';
433+
console.log('Additional Claude troubleshooting (for developers):');
434+
console.log('• Make sure ANTHROPIC_API_KEY is set and valid');
435+
console.log('• Try different flags for Claude in AIDER_EXTRA_ARGS (--anthropic or --claude)');
436+
console.log('• Check Aider version compatibility with Claude models');
314437
}
315438

316439
return {
@@ -324,6 +447,29 @@ export class PatchClient {
324447
}
325448
}
326449

450+
/**
451+
* Sanitize error messages to remove sensitive information like tokens
452+
*/
453+
private sanitizeErrorMessage(message: string): string {
454+
// Remove any GitHub tokens that might be in error messages
455+
message = message.replace(/https:\/\/[^@:]+:[^@:]+@/g, 'https://');
456+
message = message.replace(/https:\/\/[^@:]+@/g, 'https://');
457+
458+
// Specifically handle GitHub tokens (ghs_*)
459+
message = message.replace(/ghs_[a-zA-Z0-9]{16,}/g, 'ghs_REDACTED');
460+
461+
// Remove any filepath with potential tokens
462+
message = message.replace(/clone\s+['"]https:\/\/.*?@.*?['"]/, 'clone [REPOSITORY_URL]');
463+
message = message.replace(/git clone\s+'[^']*'/g, 'git clone [REPOSITORY_URL]');
464+
message = message.replace(/git clone\s+"[^"]*"/g, 'git clone [REPOSITORY_URL]');
465+
466+
// Remove any API keys that might be in the message
467+
message = message.replace(/key[-_][a-zA-Z0-9]{20,}/g, 'key-REDACTED');
468+
message = message.replace(/sk[-_][a-zA-Z0-9]{20,}/g, 'sk-REDACTED');
469+
470+
return message;
471+
}
472+
327473
/**
328474
* Clean up temporary directory
329475
*/
@@ -338,4 +484,91 @@ export class PatchClient {
338484
}
339485
}
340486
}
487+
488+
/**
489+
* Validate GitHub token format
490+
*/
491+
private isValidGitHubToken(token: string): boolean {
492+
// Check for common GitHub token formats
493+
const githubPAT = /^ghp_[a-zA-Z0-9]{20,}$/; // Personal Access Token
494+
const githubOAuth = /^gho_[a-zA-Z0-9]{20,}$/; // OAuth Access Token
495+
const githubInstall = /^ghs_[a-zA-Z0-9]{20,}$/; // GitHub App Installation Token
496+
const githubUser = /^github_pat_[a-zA-Z0-9_]{20,}$/; // Fine-grained PAT
497+
498+
// If it matches a known pattern, great
499+
if (githubPAT.test(token) ||
500+
githubOAuth.test(token) ||
501+
githubInstall.test(token) ||
502+
githubUser.test(token)) {
503+
return true;
504+
}
505+
506+
// Otherwise, check for basic requirements (some minimum length and no whitespace)
507+
// This is to accommodate different token formats while still catching obvious errors
508+
return token.length >= 10 && !/\s/.test(token);
509+
}
510+
511+
/**
512+
* Attempts to clone a repository with proper error handling
513+
*/
514+
private async attemptRepositoryClone(repoUrl: string, targetDir: string): Promise<void> {
515+
// Safely log the clone attempt without exposing tokens
516+
const safeUrl = repoUrl.replace(/\/\/[^@]+@/, '//').replace(/\/\/[^@:]+:[^@:]+@/, '//');
517+
console.log(`Cloning repository from ${safeUrl} to ${targetDir}`);
518+
519+
try {
520+
// Setup environment for git
521+
const cloneEnv = {
522+
...process.env,
523+
GIT_TERMINAL_PROMPT: '0',
524+
GIT_ASKPASS: 'echo',
525+
GCM_INTERACTIVE: 'never' // Disable GitHub credential manager interactive prompts
526+
};
527+
528+
// Clone with credentials in environment and URL
529+
await execa('git', ['clone', repoUrl, targetDir, '--depth', '1'], {
530+
env: cloneEnv,
531+
stdio: ['ignore', 'pipe', 'pipe'],
532+
timeout: 60000 // 1 minute timeout for clone
533+
});
534+
535+
console.log('Repository clone successful');
536+
} catch (cloneError) {
537+
const errorMessage = cloneError instanceof Error ? cloneError.message : String(cloneError);
538+
539+
// Check for common auth errors
540+
if (errorMessage.includes('Authentication failed') ||
541+
errorMessage.includes('Invalid username or password') ||
542+
errorMessage.includes('could not read Username') ||
543+
errorMessage.includes('403') ||
544+
errorMessage.includes('401')) {
545+
546+
// Provide specific error for permission issues
547+
if (errorMessage.includes('Permission to') && errorMessage.includes('denied')) {
548+
throw new Error('Permission denied. The token does not have access to this repository. Check that it has "contents: read" permission.');
549+
}
550+
551+
throw new Error('Authentication failed. Token may be invalid or missing required permissions (needs "contents: read" at minimum).');
552+
}
553+
554+
// For timeout errors
555+
if (errorMessage.includes('timed out') || errorMessage.includes('ETIMEDOUT')) {
556+
throw new Error('Repository clone timed out. Check network connectivity or repository size.');
557+
}
558+
559+
// For other errors, provide the sanitized message
560+
throw new Error(`Repository clone failed: ${this.sanitizeErrorMessage(errorMessage)}`);
561+
}
562+
}
563+
564+
/**
565+
* Identify the type of token provided
566+
*/
567+
private identifyTokenType(token: string): string {
568+
if (token.startsWith('ghp_')) return 'Personal Access Token';
569+
if (token.startsWith('gho_')) return 'OAuth App Token';
570+
if (token.startsWith('ghs_')) return 'GitHub App Installation Token';
571+
if (token.startsWith('github_pat_')) return 'Fine-grained Personal Access Token';
572+
return 'Unknown Token Type';
573+
}
341574
}

0 commit comments

Comments
 (0)