fix: address CodeRabbit PR #69 feedback
Security fixes: - Fix Windows command injection vulnerability in terminal-handlers.ts by adding escapeShellArgWindows() for proper cmd.exe escaping - Fix OAuth race condition in device code extraction using mutex pattern Bug fixes: - Fix settings migration to preserve existing user profile selections instead of unconditionally overwriting them - Fix asyncio.run() to handle existing event loops by using ThreadPoolExecutor - Add error logging for migration persistence failures UX improvements: - Add cleanup for copy feedback timeouts in GitHubOAuthFlow to prevent setState on unmounted component warnings - Add error handling for failed agent profile saves Type safety: - Add _migratedAgentProfileToAuto to AppSettings interface - Fix GraphitiStep type to use Partial<Pick<AppSettings, ...>> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,10 +214,13 @@ export function registerStartGhAuth(): void {
|
||||
let extractedDeviceCode: string | null = null;
|
||||
let extractedAuthUrl: string = GITHUB_DEVICE_URL;
|
||||
let browserOpenedSuccessfully = false;
|
||||
let extractionInProgress = false;
|
||||
|
||||
// Function to attempt device code extraction and browser opening
|
||||
// Uses mutex pattern to prevent race conditions from concurrent data handlers
|
||||
const tryExtractAndOpenBrowser = async () => {
|
||||
if (deviceCodeExtracted) return; // Already extracted
|
||||
if (deviceCodeExtracted || extractionInProgress) return;
|
||||
extractionInProgress = true;
|
||||
|
||||
const deviceFlowInfo = parseDeviceFlowOutput(output, errorOutput);
|
||||
|
||||
@@ -240,6 +243,9 @@ export function registerStartGhAuth(): void {
|
||||
browserOpenedSuccessfully = false;
|
||||
// Don't fail here - we'll return the device code so user can manually navigate
|
||||
}
|
||||
} else {
|
||||
// No device code found yet, allow next data chunk to try again
|
||||
extractionInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -248,7 +254,8 @@ export function registerStartGhAuth(): void {
|
||||
output += chunk;
|
||||
debugLog('gh stdout:', chunk);
|
||||
// Try to extract device code as data comes in
|
||||
tryExtractAndOpenBrowser();
|
||||
// Use void to explicitly ignore promise
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.stderr?.on('data', (data) => {
|
||||
@@ -256,7 +263,7 @@ export function registerStartGhAuth(): void {
|
||||
errorOutput += chunk;
|
||||
debugLog('gh stderr:', chunk);
|
||||
// gh often outputs to stderr, so check there too
|
||||
tryExtractAndOpenBrowser();
|
||||
void tryExtractAndOpenBrowser();
|
||||
});
|
||||
|
||||
ghProcess.on('close', (code) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg } from '../../shared/utils/shell-escape';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
|
||||
|
||||
/**
|
||||
@@ -327,16 +327,17 @@ export function registerTerminalHandlers(
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Build the login command with the profile's config dir
|
||||
// Use platform-specific syntax for environment variables
|
||||
// Use platform-specific syntax and escaping for environment variables
|
||||
let loginCommand: string;
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
// SECURITY: Use escapeShellArg to prevent command injection via configDir
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// SECURITY: Use Windows-specific escaping for cmd.exe
|
||||
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
|
||||
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${profile.configDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
} else {
|
||||
// SECURITY: Use POSIX escaping for bash/zsh
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SelectValue
|
||||
} from '../ui/select';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import type { GraphitiLLMProvider, GraphitiEmbeddingProvider } from '../../../shared/types';
|
||||
import type { GraphitiLLMProvider, GraphitiEmbeddingProvider, AppSettings } from '../../../shared/types';
|
||||
|
||||
interface GraphitiStepProps {
|
||||
onNext: () => void;
|
||||
@@ -314,8 +314,8 @@ export function GraphitiStep({ onNext, onBack, onSkip }: GraphitiStepProps) {
|
||||
const result = await window.electronAPI.saveSettings(settingsToSave);
|
||||
|
||||
if (result?.success) {
|
||||
// Update local settings store
|
||||
const storeUpdate: Record<string, string> = {};
|
||||
// Update local settings store with API key settings
|
||||
const storeUpdate: Partial<Pick<AppSettings, 'globalOpenAIApiKey' | 'globalAnthropicApiKey' | 'globalGoogleApiKey' | 'globalGroqApiKey' | 'ollamaBaseUrl'>> = {};
|
||||
if (config.openaiApiKey.trim()) storeUpdate.globalOpenAIApiKey = config.openaiApiKey.trim();
|
||||
if (config.anthropicApiKey.trim()) storeUpdate.globalAnthropicApiKey = config.anthropicApiKey.trim();
|
||||
if (config.googleApiKey.trim()) storeUpdate.globalGoogleApiKey = config.googleApiKey.trim();
|
||||
|
||||
@@ -57,6 +57,9 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
|
||||
// Ref to track authentication timeout
|
||||
const authTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Refs to track copy feedback timeouts
|
||||
const codeCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const urlCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Check gh CLI installation and authentication status on mount
|
||||
// Use a ref to prevent double-execution in React Strict Mode
|
||||
@@ -71,6 +74,18 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup copy feedback timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (codeCopyTimeoutRef.current) {
|
||||
clearTimeout(codeCopyTimeoutRef.current);
|
||||
}
|
||||
if (urlCopyTimeoutRef.current) {
|
||||
clearTimeout(urlCopyTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle authentication timeout
|
||||
const handleAuthTimeout = useCallback(() => {
|
||||
debugLog('Authentication timeout triggered after 5 minutes');
|
||||
@@ -254,8 +269,12 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(deviceCode);
|
||||
setCodeCopied(true);
|
||||
// Clear any existing timeout before setting a new one
|
||||
if (codeCopyTimeoutRef.current) {
|
||||
clearTimeout(codeCopyTimeoutRef.current);
|
||||
}
|
||||
// Reset the copied state after 2 seconds
|
||||
setTimeout(() => setCodeCopied(false), 2000);
|
||||
codeCopyTimeoutRef.current = setTimeout(() => setCodeCopied(false), 2000);
|
||||
} catch (err) {
|
||||
debugLog('Failed to copy device code:', err);
|
||||
}
|
||||
@@ -508,7 +527,11 @@ export function GitHubOAuthFlow({ onSuccess, onCancel }: GitHubOAuthFlowProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
setUrlCopied(true);
|
||||
setTimeout(() => setUrlCopied(false), 2000);
|
||||
// Clear any existing timeout before setting a new one
|
||||
if (urlCopyTimeoutRef.current) {
|
||||
clearTimeout(urlCopyTimeoutRef.current);
|
||||
}
|
||||
urlCopyTimeoutRef.current = setTimeout(() => setUrlCopied(false), 2000);
|
||||
} catch (err) {
|
||||
debugLog('Failed to copy URL:', err);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,33 @@ export function buildCdCommand(path: string | undefined): string {
|
||||
return `cd ${escapeShellPath(path)} && `;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe use as a Windows cmd.exe argument.
|
||||
*
|
||||
* Windows cmd.exe uses different escaping rules than POSIX shells.
|
||||
* This function escapes special characters that could break out of strings
|
||||
* or execute additional commands.
|
||||
*
|
||||
* @param arg - The argument to escape
|
||||
* @returns The escaped argument safe for use in cmd.exe
|
||||
*/
|
||||
export function escapeShellArgWindows(arg: string): string {
|
||||
// Escape characters that have special meaning in cmd.exe:
|
||||
// ^ is the escape character in cmd.exe
|
||||
// " & | < > ^ need to be escaped
|
||||
// % is used for variable expansion
|
||||
const escaped = arg
|
||||
.replace(/\^/g, '^^') // Escape carets first (escape char itself)
|
||||
.replace(/"/g, '^"') // Escape double quotes
|
||||
.replace(/&/g, '^&') // Escape ampersand (command separator)
|
||||
.replace(/\|/g, '^|') // Escape pipe
|
||||
.replace(/</g, '^<') // Escape less than
|
||||
.replace(/>/g, '^>') // Escape greater than
|
||||
.replace(/%/g, '%%'); // Escape percent (variable expansion)
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path doesn't contain obviously malicious patterns.
|
||||
* This is a defense-in-depth measure - escaping should handle all cases,
|
||||
|
||||
@@ -127,7 +127,9 @@ def _get_spec_context(spec_dir: Path) -> dict:
|
||||
context["github_issue"] = metadata["githubIssueNumber"]
|
||||
# Fallback title
|
||||
if not context["title"]:
|
||||
context["title"] = plan_data.get("feature") or plan_data.get("title", "")
|
||||
context["title"] = plan_data.get("feature") or plan_data.get(
|
||||
"title", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read implementation_plan.json: {e}")
|
||||
|
||||
@@ -150,24 +152,29 @@ def _build_prompt(
|
||||
|
||||
# Truncate file list if too long
|
||||
if len(files_changed) > 20:
|
||||
files_display = "\n".join(files_changed[:20]) + f"\n... and {len(files_changed) - 20} more files"
|
||||
files_display = (
|
||||
"\n".join(files_changed[:20])
|
||||
+ f"\n... and {len(files_changed) - 20} more files"
|
||||
)
|
||||
else:
|
||||
files_display = "\n".join(files_changed) if files_changed else "(no files listed)"
|
||||
files_display = (
|
||||
"\n".join(files_changed) if files_changed else "(no files listed)"
|
||||
)
|
||||
|
||||
prompt = f"""Generate a commit message for this change.
|
||||
|
||||
Task: {spec_context.get('title', 'Unknown task')}
|
||||
Task: {spec_context.get("title", "Unknown task")}
|
||||
Type: {commit_type}
|
||||
Files changed: {len(files_changed)}
|
||||
{github_ref}
|
||||
|
||||
Description: {spec_context.get('description', 'No description available')}
|
||||
Description: {spec_context.get("description", "No description available")}
|
||||
|
||||
Changed files:
|
||||
{files_display}
|
||||
|
||||
Diff summary:
|
||||
{diff_summary[:2000] if diff_summary else '(no diff available)'}
|
||||
{diff_summary[:2000] if diff_summary else "(no diff available)"}
|
||||
|
||||
Generate ONLY the commit message, nothing else. Follow the format exactly:
|
||||
type(scope): short description
|
||||
@@ -268,7 +275,21 @@ def generate_commit_message_sync(
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Already in an async context - create a new thread to run
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(asyncio.run, _call_claude_haiku(prompt)).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user