Files
claude-mem/src/services/worker/BranchManager.ts
T
Alex Newman 2659ec3231 fix: Claude Code 2.1.1 compatibility + log-level audit + path validation fixes (#614)
* Refactor CLAUDE.md and related files for December 2025 updates

- Updated CLAUDE.md in src/services/worker with new entries for December 2025, including changes to Search.ts, GeminiAgent.ts, SDKAgent.ts, and SessionManager.ts.
- Revised CLAUDE.md in src/shared to reflect updates and new entries for December 2025, including paths.ts and worker-utils.ts.
- Modified hook-constants.ts to clarify exit codes and their behaviors.
- Added comprehensive hooks reference documentation for Claude Code, detailing usage, events, and examples.
- Created initial CLAUDE.md files in various directories to track recent activity.

* fix: Merge user-message-hook output into context-hook hookSpecificOutput

- Add footer message to additionalContext in context-hook.ts
- Remove user-message-hook from SessionStart hooks array
- Fixes issue where stderr+exit(1) approach was silently discarded

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update logs and documentation for recent plugin and worker service changes

- Added detailed logs for worker service activities from Dec 10, 2025 to Jan 7, 2026, including initialization patterns, cleanup confirmations, and diagnostic logging.
- Updated plugin documentation with recent activities, including plugin synchronization and configuration changes from Dec 3, 2025 to Jan 7, 2026.
- Enhanced the context hook and worker service logs to reflect improvements and fixes in the plugin architecture.
- Documented the migration and verification processes for the Claude memory system and its integration with the marketplace.

* Refactor hooks architecture and remove deprecated user-message-hook

- Updated hook configurations in CLAUDE.md and hooks.json to reflect changes in session start behavior.
- Removed user-message-hook functionality as it is no longer utilized in Claude Code 2.1.0; context is now injected silently.
- Enhanced context-hook to handle session context injection without user-visible messages.
- Cleaned up documentation across multiple files to align with the new hook structure and removed references to obsolete hooks.
- Adjusted timing and command execution for hooks to improve performance and reliability.

* fix: Address PR #610 review issues

- Replace USER_MESSAGE_ONLY test with BLOCKING_ERROR test in hook-constants.test.ts
- Standardize Claude Code 2.1.0 note wording across all three documentation files
- Exclude deprecated user-message-hook.ts from logger-usage-standards test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Remove hardcoded fake token counts from context injection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address PR #610 review issues by fixing test files, standardizing documentation notes, and verifying code quality improvements.

* fix: Add path validation to CLAUDE.md distribution to prevent invalid directory creation

- Add isValidPathForClaudeMd() function to reject invalid paths:
  - Tilde paths (~) that Node.js doesn't expand
  - URLs (http://, https://)
  - Paths with spaces (likely command text or PR references)
  - Paths with # (GitHub issue/PR references)
  - Relative paths that escape project boundary

- Integrate validation in updateFolderClaudeMdFiles loop
- Add 6 unit tests for path validation
- Update .gitignore to prevent accidental commit of malformed directories
- Clean up existing invalid directories (~/, PR #610..., git diff..., https:)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: Implement path validation in CLAUDE.md generation to prevent invalid directory creation

- Added `isValidPathForClaudeMd()` function to validate file paths in `src/utils/claude-md-utils.ts`.
- Integrated path validation in `updateFolderClaudeMdFiles` to skip invalid paths.
- Added 6 new unit tests in `tests/utils/claude-md-utils.test.ts` to cover various rejection cases.
- Updated `.gitignore` to prevent tracking of invalid directories.
- Cleaned up existing invalid directories in the repository.

* feat: Promote critical WARN logs to ERROR level across codebase

Comprehensive log-level audit promoting 38+ WARN messages to ERROR for
improved debugging and incident response:

- Parser: observation type errors, data contamination
- SDK/Agents: empty init responses (Gemini, OpenRouter)
- Worker/Queue: session recovery, auto-recovery failures
- Chroma: sync failures, search failures (now treated as critical)
- SQLite: search failures (primary data store)
- Session/Generator: failures, missing context
- Infrastructure: shutdown, process management failures
- File Operations: CLAUDE.md updates, config reads
- Branch Management: recovery checkout failures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: Address PR #614 review issues

- Remove incorrectly tracked tilde-prefixed files from git
- Fix absolute path validation to check projectRoot boundaries
- Add test coverage for absolute path validation edge cases

Closes review issues:
- Issue 1: ~/ prefixed files removed from tracking
- Issue 3: Absolute paths now validated against projectRoot
- Issue 4: Added 3 new test cases for absolute path scenarios

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* build assets and context

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 23:34:20 -05:00

315 lines
8.7 KiB
TypeScript

/**
* BranchManager: Git branch detection and switching for beta feature toggle
*
* Enables users to switch between stable (main) and beta branches via the UI.
* The installed plugin at ~/.claude/plugins/marketplaces/thedotmack/ is a git repo.
*/
import { execSync, spawnSync } from 'child_process';
import { existsSync, unlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { logger } from '../../utils/logger.js';
const INSTALLED_PLUGIN_PATH = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
/**
* Validate branch name to prevent command injection
* Only allows alphanumeric, hyphens, underscores, forward slashes, and dots
*/
function isValidBranchName(branchName: string): boolean {
if (!branchName || typeof branchName !== 'string') {
return false;
}
// Git branch name validation: alphanumeric, hyphen, underscore, slash, dot
// Must not start with dot, hyphen, or slash
// Must not contain double dots (..)
const validBranchRegex = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/;
return validBranchRegex.test(branchName) && !branchName.includes('..');
}
// Timeout constants (increased for slow systems)
const GIT_COMMAND_TIMEOUT_MS = 300_000;
const NPM_INSTALL_TIMEOUT_MS = 600_000;
const DEFAULT_SHELL_TIMEOUT_MS = 60_000;
export interface BranchInfo {
branch: string | null;
isBeta: boolean;
isGitRepo: boolean;
isDirty: boolean;
canSwitch: boolean;
error?: string;
}
export interface SwitchResult {
success: boolean;
branch?: string;
message?: string;
error?: string;
}
/**
* Execute git command in installed plugin directory using safe array-based arguments
* SECURITY: Uses spawnSync with argument array to prevent command injection
*/
function execGit(args: string[]): string {
const result = spawnSync('git', args, {
cwd: INSTALLED_PLUGIN_PATH,
encoding: 'utf-8',
timeout: GIT_COMMAND_TIMEOUT_MS,
windowsHide: true,
shell: false // CRITICAL: Never use shell with user input
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || 'Git command failed');
}
return result.stdout.trim();
}
/**
* Execute npm command in installed plugin directory using safe array-based arguments
* SECURITY: Uses spawnSync with argument array to prevent command injection
*/
function execNpm(args: string[], timeoutMs: number = NPM_INSTALL_TIMEOUT_MS): string {
const isWindows = process.platform === 'win32';
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
const result = spawnSync(npmCmd, args, {
cwd: INSTALLED_PLUGIN_PATH,
encoding: 'utf-8',
timeout: timeoutMs,
windowsHide: true,
shell: false // CRITICAL: Never use shell with user input
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || 'npm command failed');
}
return result.stdout.trim();
}
/**
* Get current branch information
*/
export function getBranchInfo(): BranchInfo {
// Check if git repo exists
const gitDir = join(INSTALLED_PLUGIN_PATH, '.git');
if (!existsSync(gitDir)) {
return {
branch: null,
isBeta: false,
isGitRepo: false,
isDirty: false,
canSwitch: false,
error: 'Installed plugin is not a git repository'
};
}
try {
// Get current branch
const branch = execGit(['rev-parse', '--abbrev-ref', 'HEAD']);
// Check if dirty (has uncommitted changes)
const status = execGit(['status', '--porcelain']);
const isDirty = status.length > 0;
// Determine if on beta branch
const isBeta = branch.startsWith('beta');
return {
branch,
isBeta,
isGitRepo: true,
isDirty,
canSwitch: true // We can always switch (will discard local changes)
};
} catch (error) {
logger.error('BRANCH', 'Failed to get branch info', {}, error as Error);
return {
branch: null,
isBeta: false,
isGitRepo: true,
isDirty: false,
canSwitch: false,
error: (error as Error).message
};
}
}
/**
* Switch to a different branch
*
* Steps:
* 1. Discard local changes (from rsync syncs)
* 2. Fetch latest from origin
* 3. Checkout target branch
* 4. Pull latest
* 5. Clear install marker and run npm install
* 6. Restart worker (handled by caller after response)
*/
export async function switchBranch(targetBranch: string): Promise<SwitchResult> {
// SECURITY: Validate branch name to prevent command injection
if (!isValidBranchName(targetBranch)) {
return {
success: false,
error: `Invalid branch name: ${targetBranch}. Branch names must be alphanumeric with hyphens, underscores, slashes, or dots.`
};
}
const info = getBranchInfo();
if (!info.isGitRepo) {
return {
success: false,
error: 'Installed plugin is not a git repository. Please reinstall.'
};
}
if (info.branch === targetBranch) {
return {
success: true,
branch: targetBranch,
message: `Already on branch ${targetBranch}`
};
}
try {
logger.info('BRANCH', 'Starting branch switch', {
from: info.branch,
to: targetBranch
});
// 1. Discard local changes (safe - user data is at ~/.claude-mem/)
logger.debug('BRANCH', 'Discarding local changes');
execGit(['checkout', '--', '.']);
execGit(['clean', '-fd']); // Remove untracked files too
// 2. Fetch latest
logger.debug('BRANCH', 'Fetching from origin');
execGit(['fetch', 'origin']);
// 3. Checkout target branch
logger.debug('BRANCH', 'Checking out branch', { branch: targetBranch });
try {
execGit(['checkout', targetBranch]);
} catch (error) {
// Branch might not exist locally, try tracking remote
logger.debug('BRANCH', 'Branch not local, tracking remote', { branch: targetBranch, error: error instanceof Error ? error.message : String(error) });
execGit(['checkout', '-b', targetBranch, `origin/${targetBranch}`]);
}
// 4. Pull latest
logger.debug('BRANCH', 'Pulling latest');
execGit(['pull', 'origin', targetBranch]);
// 5. Clear install marker and run npm install
const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version');
if (existsSync(installMarker)) {
unlinkSync(installMarker);
}
logger.debug('BRANCH', 'Running npm install');
execNpm(['install'], NPM_INSTALL_TIMEOUT_MS);
logger.success('BRANCH', 'Branch switch complete', {
branch: targetBranch
});
return {
success: true,
branch: targetBranch,
message: `Switched to ${targetBranch}. Worker will restart automatically.`
};
} catch (error) {
logger.error('BRANCH', 'Branch switch failed', { targetBranch }, error as Error);
// Try to recover by checking out original branch
try {
if (info.branch && isValidBranchName(info.branch)) {
execGit(['checkout', info.branch]);
}
} catch (recoveryError) {
// [POSSIBLY RELEVANT]: Recovery checkout failed, user needs manual intervention - already logging main error above
logger.error('BRANCH', 'Recovery checkout also failed', { originalBranch: info.branch }, recoveryError as Error);
}
return {
success: false,
error: `Branch switch failed: ${(error as Error).message}`
};
}
}
/**
* Pull latest updates for current branch
*/
export async function pullUpdates(): Promise<SwitchResult> {
const info = getBranchInfo();
if (!info.isGitRepo || !info.branch) {
return {
success: false,
error: 'Cannot pull updates: not a git repository'
};
}
try {
// SECURITY: Validate branch name before use
if (!isValidBranchName(info.branch)) {
return {
success: false,
error: `Invalid current branch name: ${info.branch}`
};
}
logger.info('BRANCH', 'Pulling updates', { branch: info.branch });
// Discard local changes first
execGit(['checkout', '--', '.']);
// Fetch and pull
execGit(['fetch', 'origin']);
execGit(['pull', 'origin', info.branch]);
// Clear install marker and reinstall
const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version');
if (existsSync(installMarker)) {
unlinkSync(installMarker);
}
execNpm(['install'], NPM_INSTALL_TIMEOUT_MS);
logger.success('BRANCH', 'Updates pulled', { branch: info.branch });
return {
success: true,
branch: info.branch,
message: `Updated ${info.branch}. Worker will restart automatically.`
};
} catch (error) {
logger.error('BRANCH', 'Pull failed', {}, error as Error);
return {
success: false,
error: `Pull failed: ${(error as Error).message}`
};
}
}
/**
* Get installed plugin path (for external use)
*/
export function getInstalledPluginPath(): string {
return INSTALLED_PLUGIN_PATH;
}