fix: add explicit UTF-8 encoding across all Electron main process I/O (#1554)

* fix: add explicit UTF-8 encoding across all Electron main process I/O

Ensure cross-platform compatibility (Windows/macOS/Linux) by making all
text encoding explicit rather than relying on platform defaults.

- Add 'utf8' to ~50 Buffer.toString() calls on child process stdout/stderr
- Add 'utf-8' to ~45 writeFileSync/writeFile calls writing text/JSON data
- Add PYTHONIOENCODING and PYTHONUTF8 env vars to Python subprocess spawns
- Add encoding option to execSync/execFileSync calls in python-detector

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

* style: standardize encoding string to 'utf-8' everywhere

Replace all toString('utf8') with toString('utf-8') across 23 files
to use one consistent encoding format. Both are valid Node.js aliases
but 'utf-8' is the canonical IANA name and matches the writeFileSync
encoding parameter already used throughout the codebase.

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

* fix(frontend): resolve 16 CodeQL alerts (TOCTOU + network data sanitization)

Fix 9 TOCTOU race conditions by replacing existsSync() guards with
try/catch around readFileSync, handling ENOENT in catch blocks.

Fix 7 network-data-to-file alerts by sanitizing GitHub/Linear API data
before writing to disk. Extract shared sanitization module from
gitlab/spec-utils.ts for reuse across integrations.

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

* fix(frontend): resolve 7 PR review findings for code quality

- Add console.error logging to empty catch blocks in crud-handlers.ts
  (lines 349, 379) that silently swallowed non-ENOENT errors
- Add missing 'utf-8' encoding to writeFileSync in roadmap-handlers.ts:680
- Consolidate gitlab/triage-handlers.ts sanitization functions to use
  shared/sanitize.ts module, removing duplicate local implementations
- Remove redundant .toString() call in python-env-manager.ts:255 since
  execSync with encoding: 'utf-8' already returns a string
- Fix TOCTOU race in getFeatureSettings() by removing existsSync check
  and handling ENOENT in catch block
- Add comprehensive test suite for shared/sanitize.ts with 46 tests
  covering control chars, Unicode, URLs with credentials/javascript:/data:

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-27 15:45:12 +01:00
committed by GitHub
parent 6f1002dd79
commit 4243530e9e
47 changed files with 645 additions and 304 deletions
@@ -706,11 +706,11 @@ export class AgentProcessManager {
};
childProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf8'));
stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf-8'));
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf8'));
stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf-8'));
});
childProcess.on('exit', (code: number | null) => {
+4 -4
View File
@@ -421,7 +421,7 @@ export class AgentQueueManager {
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
childProcess.stdout?.on('data', (data: Buffer) => {
const log = data.toString('utf8');
const log = data.toString('utf-8');
// Collect output for rate limit detection (keep last 10KB)
allOutput = (allOutput + log).slice(-10000);
@@ -509,7 +509,7 @@ export class AgentQueueManager {
// Handle stderr - also emit as logs, explicitly decode as UTF-8
childProcess.stderr?.on('data', (data: Buffer) => {
const log = data.toString('utf8');
const log = data.toString('utf-8');
// Collect stderr for rate limit detection too
allOutput = (allOutput + log).slice(-10000);
console.error('[Ideation STDERR]', log);
@@ -757,7 +757,7 @@ export class AgentQueueManager {
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
childProcess.stdout?.on('data', (data: Buffer) => {
const log = data.toString('utf8');
const log = data.toString('utf-8');
// Collect output for rate limit detection (keep last 10KB)
allRoadmapOutput = (allRoadmapOutput + log).slice(-10000);
@@ -792,7 +792,7 @@ export class AgentQueueManager {
// Handle stderr - explicitly decode as UTF-8
childProcess.stderr?.on('data', (data: Buffer) => {
const log = data.toString('utf8');
const log = data.toString('utf-8');
// Collect stderr for rate limit detection too
allRoadmapOutput = (allRoadmapOutput + log).slice(-10000);
console.error('[Roadmap STDERR]', log);
@@ -62,7 +62,7 @@ export async function validateOpenAIApiKey(
let data = '';
res.on('data', (chunk: Buffer) => {
data += chunk;
data += chunk.toString('utf-8');
});
res.on('end', () => {
+1 -1
View File
@@ -368,7 +368,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
}
response.on('data', (chunk) => {
data += chunk.toString();
data += chunk.toString('utf-8');
});
response.on('end', () => {
@@ -156,7 +156,7 @@ export class ChangelogGenerator extends EventEmitter {
let errorOutput = '';
childProcess.stdout?.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
output += chunk;
this.debug('stdout chunk received', { chunkLength: chunk.length, totalOutput: output.length });
@@ -168,7 +168,7 @@ export class ChangelogGenerator extends EventEmitter {
});
childProcess.stderr?.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
errorOutput += chunk;
this.debug('stderr chunk received', { chunk: chunk.substring(0, 200) });
});
@@ -65,11 +65,11 @@ export class VersionSuggester {
let errorOutput = '';
childProcess.stdout?.on('data', (data: Buffer) => {
output += data.toString();
output += data.toString('utf-8');
});
childProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
errorOutput += data.toString('utf-8');
});
childProcess.on('exit', (code: number | null) => {
+1 -1
View File
@@ -61,7 +61,7 @@ export function getWritablePath(originalPath: string, filename: string): string
if (fs.existsSync(dir)) {
// Try to write a test file
const testFile = path.join(dir, `.write-test-${Date.now()}`);
fs.writeFileSync(testFile, '');
fs.writeFileSync(testFile, '', 'utf-8');
// Cleanup test file - ignore errors (e.g., file locked on Windows)
try { fs.unlinkSync(testFile); } catch { /* ignore cleanup failure */ }
return originalPath;
@@ -133,7 +133,7 @@ export class InsightsExecutor extends EventEmitter {
let stderrOutput = '';
proc.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
const text = data.toString('utf-8');
// Collect output for rate limit detection (keep last 10KB)
allInsightsOutput = (allInsightsOutput + text).slice(-10000);
@@ -159,7 +159,7 @@ export class InsightsExecutor extends EventEmitter {
});
proc.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
const text = data.toString('utf-8');
// Collect stderr for rate limit detection and error reporting
allInsightsOutput = (allInsightsOutput + text).slice(-10000);
stderrOutput = (stderrOutput + text).slice(-2000);
@@ -61,7 +61,7 @@ export class SessionStorage {
}
const sessionPath = this.paths.getSessionPath(projectPath, session.id);
writeFileSync(sessionPath, JSON.stringify(session, null, 2));
writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf-8');
}
/**
@@ -152,7 +152,7 @@ export class SessionStorage {
}
const currentPath = this.paths.getCurrentSessionPath(projectPath);
writeFileSync(currentPath, JSON.stringify({ currentSessionId: sessionId }, null, 2));
writeFileSync(currentPath, JSON.stringify({ currentSessionId: sessionId }, null, 2), 'utf-8');
}
/**
@@ -179,15 +179,19 @@ export function registerProjectContextHandlers(
'--output', indexOutputPath
], {
cwd: project.path,
env: getAugmentedEnv()
env: {
...getAugmentedEnv(),
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
}
});
proc.stdout?.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
proc.stderr?.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code: number) => {
@@ -562,17 +562,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
const envPath = path.join(project.path, project.autoBuildPath, '.env');
try {
// Read existing content if file exists
// Read existing content if file exists (atomic read, no TOCTOU)
let existingContent: string | undefined;
if (existsSync(envPath)) {
try {
existingContent = readFileSync(envPath, 'utf-8');
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException).code !== 'ENOENT') throw readErr;
// File doesn't exist yet - existingContent stays undefined
}
// Generate new content
const newContent = generateEnvContent(config, existingContent);
// Write to file
writeFileSync(envPath, newContent);
writeFileSync(envPath, newContent, 'utf-8');
return { success: true };
} catch (error) {
@@ -612,11 +615,11 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
let _stderr = '';
proc.stdout?.on('data', (data: Buffer) => {
_stdout += data.toString();
_stdout += data.toString('utf-8');
});
proc.stderr?.on('data', (data: Buffer) => {
_stderr += data.toString();
_stderr += data.toString('utf-8');
});
proc.on('close', (code: number | null) => {
@@ -181,7 +181,7 @@ function saveAutoFixConfig(project: Project, config: AutoFixConfig): void {
thinking_level: config.thinkingLevel,
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8');
}
/**
@@ -422,7 +422,8 @@ async function startAutoFix(
created_at: state.createdAt,
updated_at: state.updatedAt,
issue_url: sanitizedIssueUrl,
}, null, 2)
}, null, 2),
'utf-8'
);
sendProgress({ phase: 'creating_spec', issueNumber, progress: 70, message: 'Starting spec creation...' });
@@ -850,7 +851,7 @@ export function registerAutoFixHandlers(
theme: b.theme ?? '',
}));
fs.writeFileSync(tempFile, JSON.stringify(pythonBatches, null, 2));
fs.writeFileSync(tempFile, JSON.stringify(pythonBatches, null, 2), 'utf-8');
// Comprehensive validation of GitHub module
const validation = await validateGitHubModule(project);
@@ -350,7 +350,7 @@ export function registerStartGhAuth(): void {
};
ghProcess.stdout?.on('data', (data) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
output += chunk;
debugLog('gh stdout:', chunk);
// Try to extract device code as data comes in
@@ -359,7 +359,7 @@ export function registerStartGhAuth(): void {
});
ghProcess.stderr?.on('data', (data) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
errorOutput += chunk;
debugLog('gh stderr:', chunk);
// gh often outputs to stderr, so check there too
@@ -9,6 +9,7 @@ import type { Project, TaskMetadata } from '../../../shared/types';
import { withSpecNumberLock } from '../../utils/spec-number-lock';
import { debugLog } from './utils/logger';
import { labelMatchesWholeWord } from '../shared/label-utils';
import { sanitizeText, sanitizeStringArray, sanitizeUrl } from '../shared/sanitize';
export interface SpecCreationData {
specId: string;
@@ -107,11 +108,17 @@ export async function createSpecForIssue(
mkdirSync(specsDir, { recursive: true });
}
// Sanitize network-sourced data before writing to disk
const safeTitle = sanitizeText(issueTitle, 500);
const safeDescription = sanitizeText(taskDescription, 50000, true);
const safeGithubUrl = sanitizeUrl(githubUrl);
const safeLabels = sanitizeStringArray(labels, 50, 200);
// Use coordinated spec numbering with lock to prevent collisions
return await withSpecNumberLock(project.path, async (lock) => {
// Get next spec number from global scan (main + all worktrees)
const specNumber = lock.getNextSpecNumber(project.autoBuildPath);
const slugifiedTitle = slugifyTitle(issueTitle);
const slugifiedTitle = slugifyTitle(safeTitle);
const specId = `${String(specNumber).padStart(3, '0')}-${slugifiedTitle}`;
// Create spec directory (inside lock to ensure atomicity)
@@ -123,8 +130,8 @@ export async function createSpecForIssue(
// implementation_plan.json
const implementationPlan = {
feature: issueTitle,
description: taskDescription,
feature: safeTitle,
description: safeDescription,
created_at: now,
updated_at: now,
status: 'pending',
@@ -132,27 +139,29 @@ export async function createSpecForIssue(
};
writeFileSync(
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
JSON.stringify(implementationPlan, null, 2)
JSON.stringify(implementationPlan, null, 2),
'utf-8'
);
// requirements.json
const requirements = {
task_description: taskDescription,
task_description: safeDescription,
workflow_type: 'feature'
};
writeFileSync(
path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS),
JSON.stringify(requirements, null, 2)
JSON.stringify(requirements, null, 2),
'utf-8'
);
// Determine category from GitHub issue labels
const category = determineCategoryFromLabels(labels);
const category = determineCategoryFromLabels(safeLabels);
// task_metadata.json
const metadata: TaskMetadata = {
sourceType: 'github',
githubIssueNumber: issueNumber,
githubUrl,
githubUrl: safeGithubUrl,
category,
// Store baseBranch for worktree creation and QA comparison
// This comes from project.settings.mainBranch or task-level override
@@ -160,13 +169,14 @@ export async function createSpecForIssue(
};
writeFileSync(
path.join(specDir, 'task_metadata.json'),
JSON.stringify(metadata, null, 2)
JSON.stringify(metadata, null, 2),
'utf-8'
);
return {
specId,
specDir,
taskDescription,
taskDescription: safeDescription,
metadata
};
});
@@ -228,7 +238,7 @@ export function updateImplementationPlanStatus(specDir: string, status: string):
const plan = JSON.parse(content);
plan.status = status;
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
} catch (error) {
// File doesn't exist or couldn't be read - this is expected for new specs
// Log legitimate errors (malformed JSON, disk write failures, permission errors)
@@ -140,7 +140,7 @@ function saveTriageConfig(project: Project, config: TriageConfig): void {
enable_triage_comments: config.enableComments,
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8');
}
/**
@@ -194,7 +194,7 @@ export function runPythonSubprocess<T = unknown>(
};
child.stdout.on('data', (data: Buffer) => {
const text = data.toString();
const text = data.toString('utf-8');
stdout += text;
const lines = text.split('\n');
@@ -218,7 +218,7 @@ export function runPythonSubprocess<T = unknown>(
});
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
const text = data.toString('utf-8');
stderr += text;
const lines = text.split('\n');
@@ -129,7 +129,7 @@ function saveAutoFixConfig(project: Project, config: GitLabAutoFixConfig): void
thinking_level: config.thinkingLevel,
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8');
}
/**
@@ -400,7 +400,8 @@ async function startAutoFix(
created_at: state.createdAt,
updated_at: state.updatedAt,
issue_url: sanitizedIssueUrl,
}, null, 2)
}, null, 2),
'utf-8'
);
sendProgress(mainWindow, project.id, {
@@ -621,7 +622,7 @@ export function registerAutoFixHandlers(
reasoning: batch.reasoning,
status: 'pending',
created_at: new Date().toISOString(),
}, null, 2));
}, null, 2), 'utf-8');
}
const batches = getBatches(project);
@@ -253,7 +253,7 @@ export function registerStartGlabAuth(): void {
let browserOpened = false;
glabProcess.stdout?.on('data', (data) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
output += chunk;
debugLog('glab stdout:', chunk);
@@ -268,7 +268,7 @@ export function registerStartGlabAuth(): void {
});
glabProcess.stderr?.on('data', (data) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
errorOutput += chunk;
debugLog('glab stderr:', chunk);
});
@@ -8,6 +8,7 @@ import path from 'path';
import type { Project } from '../../../shared/types';
import type { GitLabAPIIssue, GitLabConfig } from './types';
import { labelMatchesWholeWord } from '../shared/label-utils';
import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
/**
* Simplified task info returned when creating a spec from a GitLab issue.
@@ -102,33 +103,6 @@ function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' |
return 'feature';
}
function stripControlChars(value: string, allowNewlines: boolean): string {
let sanitized = '';
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 0x0A || code === 0x0D || code === 0x09) {
if (allowNewlines) {
sanitized += value[i];
}
continue;
}
if (code <= 0x1F || code === 0x7F) {
continue;
}
sanitized += value[i];
}
return sanitized;
}
function sanitizeText(value: unknown, maxLength: number, allowNewlines = false): string {
if (typeof value !== 'string') return '';
let sanitized = stripControlChars(value, allowNewlines).trim();
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
return sanitized;
}
function sanitizeIssueNumber(value: unknown): number {
const issueId = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(issueId) || issueId <= 0) {
@@ -141,21 +115,6 @@ function sanitizeIssueState(value: unknown): 'opened' | 'closed' {
return value === 'closed' ? 'closed' : 'opened';
}
function sanitizeStringArray(value: unknown, maxItems: number, maxLength: number): string[] {
if (!Array.isArray(value)) return [];
const sanitized: string[] = [];
for (const entry of value) {
const cleanEntry = sanitizeText(entry, maxLength);
if (cleanEntry) {
sanitized.push(cleanEntry);
}
if (sanitized.length >= maxItems) {
break;
}
}
return sanitized;
}
function sanitizeAssignees(value: unknown): Array<{ username: string }> {
if (!Array.isArray(value)) return [];
const sanitized: Array<{ username: string }> = [];
@@ -20,6 +20,7 @@ import type {
GitLabTriageResult,
GitLabTriageCategory,
} from './types';
import { sanitizeStringArray } from '../shared/sanitize';
// Debug logging
function debugLog(message: string, ...args: unknown[]): void {
@@ -36,18 +37,6 @@ const TRIAGE_CATEGORIES: GitLabTriageCategory[] = [
'feature_creep',
];
function stripControlChars(value: string): string {
let sanitized = '';
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code <= 0x1F || code === 0x7F) {
continue;
}
sanitized += value[i];
}
return sanitized;
}
function sanitizeIssueIid(value: unknown): number | null {
const issueIid = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(issueIid) || issueIid <= 0) {
@@ -60,15 +49,8 @@ function sanitizeCategory(value: unknown): GitLabTriageCategory {
return TRIAGE_CATEGORIES.includes(value as GitLabTriageCategory) ? (value as GitLabTriageCategory) : 'feature';
}
function sanitizeLabel(value: unknown): string {
if (typeof value !== 'string') return '';
const sanitized = stripControlChars(value).trim();
return sanitized.length > 50 ? sanitized.substring(0, 50) : sanitized;
}
function sanitizeLabels(values: string[]): string[] {
const sanitized = values.map(label => sanitizeLabel(label)).filter(label => Boolean(label));
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
return sanitizeStringArray(values, 50, 50);
}
function sanitizeConfidence(value: number): number {
@@ -171,7 +153,7 @@ function saveTriageConfig(project: Project, config: GitLabTriageConfig): void {
triage_enable_comments: config.enableComments,
};
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2));
fs.writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2), 'utf-8');
}
/**
@@ -440,7 +422,8 @@ export function registerTriageHandlers(
// Save result
fs.writeFileSync(
path.join(triageDir, `triage_${sanitizedResult.issue_iid}.json`),
JSON.stringify(sanitizedResult, null, 2)
JSON.stringify(sanitizedResult, null, 2),
'utf-8'
);
results.push(result);
@@ -28,7 +28,7 @@ export function readIdeationFile(ideationPath: string): RawIdeationData | null {
*/
export function writeIdeationFile(ideationPath: string, data: RawIdeationData): void {
try {
writeFileSync(ideationPath, JSON.stringify(data, null, 2));
writeFileSync(ideationPath, JSON.stringify(data, null, 2), 'utf-8');
} catch (error) {
throw new Error(
error instanceof Error ? error.message : 'Failed to write ideation file'
@@ -155,7 +155,8 @@ function createSpecFiles(
};
writeFileSync(
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
JSON.stringify(initialPlan, null, 2)
JSON.stringify(initialPlan, null, 2),
'utf-8'
);
// Create initial spec.md
@@ -172,7 +173,7 @@ ${idea.rationale}
---
*This spec was created from ideation and is pending detailed specification.*
`;
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent);
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), specContent, 'utf-8');
}
/**
@@ -249,7 +250,7 @@ export async function convertIdeaToTask(
// Save metadata
const metadataPath = path.join(specDir, 'task_metadata.json');
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
// Update idea status to archived (converted ideas are archived)
idea.status = 'archived';
@@ -215,11 +215,11 @@ export function registerInsightsHandlers(getMainWindow: () => BrowserWindow | nu
};
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2));
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2), 'utf-8');
// Save task metadata
const metadataPath = path.join(specDir, "task_metadata.json");
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2), 'utf-8');
// Create the task object
const task: Task = {
@@ -6,6 +6,7 @@ import path from 'path';
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs';
import { projectStore } from '../project-store';
import { parseEnvFile } from './utils';
import { sanitizeText, sanitizeUrl } from './shared/sanitize';
import { AgentManager } from '../agent';
@@ -446,18 +447,27 @@ export function registerLinearHandlers(
// Create tasks for each imported issue
for (const issue of data.issues.nodes) {
try {
// Build description from Linear issue
const labels = issue.labels.nodes.map(l => l.name).join(', ');
const description = `# ${issue.title}
// Sanitize network-sourced data before writing to disk
const safeTitle = sanitizeText(issue.title, 500);
const safeIdentifier = sanitizeText(issue.identifier, 50);
const safeDescription = sanitizeText(issue.description ?? '', 50000, true);
const safePriorityLabel = sanitizeText(issue.priorityLabel, 100);
const safeStateName = sanitizeText(issue.state.name, 100);
const safeUrl = sanitizeUrl(issue.url);
const safeLabels = issue.labels.nodes.map(l => sanitizeText(l.name, 200)).filter(Boolean);
**Linear Issue:** [${issue.identifier}](${issue.url})
**Priority:** ${issue.priorityLabel}
**Status:** ${issue.state.name}
${labels ? `**Labels:** ${labels}` : ''}
// Build description from Linear issue
const labelsStr = safeLabels.join(', ');
const description = `# ${safeTitle}
**Linear Issue:** [${safeIdentifier}](${safeUrl})
**Priority:** ${safePriorityLabel}
**Status:** ${safeStateName}
${labelsStr ? `**Labels:** ${labelsStr}` : ''}
## Description
${issue.description || 'No description provided.'}
${safeDescription || 'No description provided.'}
`;
// Find next available spec number
@@ -476,7 +486,7 @@ ${issue.description || 'No description provided.'}
}
// Create spec ID with zero-padded number and slugified title
const slugifiedTitle = issue.title
const slugifiedTitle = safeTitle
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
@@ -490,31 +500,31 @@ ${issue.description || 'No description provided.'}
// Create initial implementation_plan.json
const now = new Date().toISOString();
const implementationPlan = {
feature: issue.title,
feature: safeTitle,
description: description,
created_at: now,
updated_at: now,
status: 'pending',
phases: []
};
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2));
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2), 'utf-8');
// Create requirements.json
const requirements = {
task_description: description,
workflow_type: 'feature'
};
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2));
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2), 'utf-8');
// Build metadata
const metadata: TaskMetadata = {
sourceType: 'linear',
linearIssueId: issue.id,
linearIdentifier: issue.identifier,
linearUrl: issue.url,
linearIssueId: sanitizeText(issue.id, 100),
linearIdentifier: safeIdentifier,
linearUrl: safeUrl,
category: 'feature'
};
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2));
writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8');
// Start spec creation with the existing spec directory
agentManager.startSpecCreation(specId, project.path, description, specDir, metadata);
@@ -461,7 +461,7 @@ async function testCommandConnection(server: CustomMcpServer, startTime: number)
proc.stdin.write(initRequest);
proc.stdout.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
// Try to parse JSON response
try {
@@ -490,7 +490,7 @@ async function testCommandConnection(server: CustomMcpServer, startTime: number)
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('error', (error) => {
@@ -272,11 +272,11 @@ async function executeOllamaDetector(
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
// Single timeout mechanism to avoid race condition
@@ -749,11 +749,11 @@ export function registerMemoryHandlers(): void {
let stderrBuffer = ''; // Buffer for NDJSON parsing
proc.stdout.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
proc.stderr.on('data', (data) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stderr += chunk;
stderrBuffer += chunk;
@@ -35,24 +35,24 @@ function getFeatureSettings(): { model?: string; thinkingLevel?: string } {
const settingsPath = path.join(app.getPath("userData"), "settings.json");
try {
if (existsSync(settingsPath)) {
const content = readFileSync(settingsPath, "utf-8");
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
const content = readFileSync(settingsPath, "utf-8");
const settings: AppSettings = { ...DEFAULT_APP_SETTINGS, ...JSON.parse(content) };
// Get roadmap-specific settings
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
// Get roadmap-specific settings
const featureModels = settings.featureModels || DEFAULT_FEATURE_MODELS;
const featureThinking = settings.featureThinking || DEFAULT_FEATURE_THINKING;
return {
model: featureModels.roadmap,
thinkingLevel: featureThinking.roadmap,
};
}
return {
model: featureModels.roadmap,
thinkingLevel: featureThinking.roadmap,
};
} catch (error) {
debugError("[Roadmap Handler] Failed to read feature settings:", error);
// Return defaults if settings file doesn't exist (ENOENT) or fails to parse
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
debugError("[Roadmap Handler] Failed to read feature settings:", error);
}
}
// Return defaults if settings file doesn't exist or fails to parse
return {
model: DEFAULT_FEATURE_MODELS.roadmap,
thinkingLevel: DEFAULT_FEATURE_THINKING.roadmap,
@@ -378,12 +378,16 @@ export function registerRoadmapHandlers(
AUTO_BUILD_PATHS.ROADMAP_FILE
);
if (!existsSync(roadmapPath)) {
return { success: false, error: "Roadmap not found" };
}
try {
const content = readFileSync(roadmapPath, "utf-8");
let content: string;
try {
content = readFileSync(roadmapPath, "utf-8");
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
return { success: false, error: "Roadmap not found" };
}
throw readErr;
}
const existingRoadmap = JSON.parse(content);
// Transform camelCase features back to snake_case for JSON file
@@ -408,7 +412,7 @@ export function registerRoadmapHandlers(
existingRoadmap.metadata = existingRoadmap.metadata || {};
existingRoadmap.metadata.updated_at = new Date().toISOString();
writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2));
writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2), 'utf-8');
return { success: true };
} catch (error) {
@@ -439,12 +443,16 @@ export function registerRoadmapHandlers(
AUTO_BUILD_PATHS.ROADMAP_FILE
);
if (!existsSync(roadmapPath)) {
return { success: false, error: "Roadmap not found" };
}
try {
const content = readFileSync(roadmapPath, "utf-8");
let content: string;
try {
content = readFileSync(roadmapPath, "utf-8");
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
return { success: false, error: "Roadmap not found" };
}
throw readErr;
}
const roadmap = JSON.parse(content);
// Find and update the feature
@@ -457,7 +465,7 @@ export function registerRoadmapHandlers(
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8');
return { success: true };
} catch (error) {
@@ -483,12 +491,16 @@ export function registerRoadmapHandlers(
AUTO_BUILD_PATHS.ROADMAP_FILE
);
if (!existsSync(roadmapPath)) {
return { success: false, error: "Roadmap not found" };
}
try {
const content = readFileSync(roadmapPath, "utf-8");
let content: string;
try {
content = readFileSync(roadmapPath, "utf-8");
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
return { success: false, error: "Roadmap not found" };
}
throw readErr;
}
const roadmap = JSON.parse(content);
// Find the feature
@@ -562,7 +574,8 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
};
writeFileSync(
path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN),
JSON.stringify(implementationPlan, null, 2)
JSON.stringify(implementationPlan, null, 2),
'utf-8'
);
// Create requirements.json
@@ -572,11 +585,12 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
};
writeFileSync(
path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS),
JSON.stringify(requirements, null, 2)
JSON.stringify(requirements, null, 2),
'utf-8'
);
// Create spec.md (required by backend spec creation process)
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription);
writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription, 'utf-8');
// Build metadata
const metadata: TaskMetadata = {
@@ -584,7 +598,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
featureId: feature.id,
category: "feature",
};
writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2));
writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2), 'utf-8');
// NOTE: We do NOT auto-start spec creation here - user should explicitly start the task
// from the kanban board when they're ready
@@ -594,7 +608,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
feature.linked_spec_id = specId;
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2));
writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8');
// Create task object
const task: Task = {
@@ -663,7 +677,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
is_running: isRunning,
};
writeFileSync(progressPath, JSON.stringify(fileData, null, 2));
writeFileSync(progressPath, JSON.stringify(fileData, null, 2), 'utf-8');
debugLog("[Roadmap Handler] Saved progress checkpoint:", { projectId, phase: progressData.phase });
return { success: true };
@@ -161,7 +161,7 @@ export function registerSettingsHandlers(
// Persist migration changes
if (needsSave) {
try {
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
} catch (error) {
console.error('[SETTINGS_GET] Failed to persist migration:', error);
// Continue anyway - settings will be migrated in-memory for this session
@@ -202,7 +202,7 @@ export function registerSettingsHandlers(
}
}
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2));
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), 'utf-8');
// Apply Python path if changed
if (settings.pythonPath || settings.autoBuildPath) {
@@ -724,7 +724,7 @@ export function registerSettingsHandlers(
}
}
writeFileSync(envPath, lines.join('\n'));
writeFileSync(envPath, lines.join('\n'), 'utf-8');
return { success: true };
} catch (error) {
@@ -0,0 +1,260 @@
import { describe, expect, it } from 'vitest';
import { stripControlChars, sanitizeText, sanitizeStringArray, sanitizeUrl } from '../sanitize';
describe('stripControlChars', () => {
describe('basic functionality', () => {
it('should return empty string for empty input', () => {
expect(stripControlChars('', false)).toBe('');
expect(stripControlChars('', true)).toBe('');
});
it('should pass through plain text unchanged', () => {
const input = 'Hello, World!';
expect(stripControlChars(input, false)).toBe(input);
expect(stripControlChars(input, true)).toBe(input);
});
it('should strip null character (0x00)', () => {
expect(stripControlChars('hello\x00world', false)).toBe('helloworld');
});
it('should strip bell character (0x07)', () => {
expect(stripControlChars('hello\x07world', false)).toBe('helloworld');
});
it('should strip backspace (0x08)', () => {
expect(stripControlChars('hello\x08world', false)).toBe('helloworld');
});
it('should strip escape character (0x1B)', () => {
expect(stripControlChars('hello\x1Bworld', false)).toBe('helloworld');
});
it('should strip DEL character (0x7F)', () => {
expect(stripControlChars('hello\x7Fworld', false)).toBe('helloworld');
});
it('should strip all ASCII control characters (0x00-0x1F)', () => {
let input = '';
for (let i = 0; i <= 0x1F; i++) {
input += String.fromCharCode(i);
}
input += 'visible';
// When allowNewlines is false, only 'visible' should remain
expect(stripControlChars(input, false)).toBe('visible');
});
});
describe('newline handling', () => {
it('should strip newlines when allowNewlines is false', () => {
expect(stripControlChars('hello\nworld', false)).toBe('helloworld');
expect(stripControlChars('hello\rworld', false)).toBe('helloworld');
expect(stripControlChars('hello\r\nworld', false)).toBe('helloworld');
});
it('should preserve newlines when allowNewlines is true', () => {
expect(stripControlChars('hello\nworld', true)).toBe('hello\nworld');
expect(stripControlChars('hello\rworld', true)).toBe('hello\rworld');
expect(stripControlChars('hello\r\nworld', true)).toBe('hello\r\nworld');
});
it('should preserve tabs when allowNewlines is true', () => {
expect(stripControlChars('hello\tworld', true)).toBe('hello\tworld');
});
it('should strip tabs when allowNewlines is false', () => {
expect(stripControlChars('hello\tworld', false)).toBe('helloworld');
});
});
describe('Unicode handling', () => {
it('should preserve non-ASCII Unicode characters', () => {
const input = '日本語テスト 🎉 émojis';
expect(stripControlChars(input, false)).toBe(input);
});
it('should preserve right-to-left text', () => {
const input = 'مرحبا بالعالم';
expect(stripControlChars(input, false)).toBe(input);
});
});
});
describe('sanitizeText', () => {
describe('type checking', () => {
it('should return empty string for non-string input', () => {
expect(sanitizeText(null, 100)).toBe('');
expect(sanitizeText(undefined, 100)).toBe('');
expect(sanitizeText(123, 100)).toBe('');
expect(sanitizeText({}, 100)).toBe('');
expect(sanitizeText([], 100)).toBe('');
});
});
describe('length enforcement', () => {
it('should truncate strings exceeding maxLength', () => {
expect(sanitizeText('hello world', 5)).toBe('hello');
});
it('should not truncate strings within maxLength', () => {
expect(sanitizeText('hello', 10)).toBe('hello');
});
it('should handle zero maxLength', () => {
expect(sanitizeText('hello', 0)).toBe('');
});
});
describe('trimming', () => {
it('should trim leading and trailing whitespace', () => {
expect(sanitizeText(' hello ', 100)).toBe('hello');
});
it('should trim before applying maxLength', () => {
expect(sanitizeText(' hello ', 3)).toBe('hel');
});
});
describe('control character stripping', () => {
it('should strip control characters', () => {
expect(sanitizeText('hello\x00\x07world', 100)).toBe('helloworld');
});
it('should strip newlines by default', () => {
expect(sanitizeText('hello\nworld', 100)).toBe('helloworld');
});
it('should preserve newlines when allowNewlines is true', () => {
expect(sanitizeText('hello\nworld', 100, true)).toBe('hello\nworld');
});
});
});
describe('sanitizeStringArray', () => {
describe('type checking', () => {
it('should return empty array for non-array input', () => {
expect(sanitizeStringArray(null, 10, 50)).toEqual([]);
expect(sanitizeStringArray(undefined, 10, 50)).toEqual([]);
expect(sanitizeStringArray('string', 10, 50)).toEqual([]);
expect(sanitizeStringArray(123, 10, 50)).toEqual([]);
expect(sanitizeStringArray({}, 10, 50)).toEqual([]);
});
});
describe('item count limiting', () => {
it('should limit number of items to maxItems', () => {
const input = ['a', 'b', 'c', 'd', 'e'];
expect(sanitizeStringArray(input, 3, 50)).toEqual(['a', 'b', 'c']);
});
it('should return all items if under maxItems', () => {
const input = ['a', 'b'];
expect(sanitizeStringArray(input, 5, 50)).toEqual(['a', 'b']);
});
});
describe('item sanitization', () => {
it('should sanitize each item with maxLength', () => {
const input = ['hello world', 'test'];
expect(sanitizeStringArray(input, 10, 5)).toEqual(['hello', 'test']);
});
it('should filter out non-string items', () => {
const input = ['valid', 123, null, 'also valid', undefined];
expect(sanitizeStringArray(input, 10, 50)).toEqual(['valid', 'also valid']);
});
it('should filter out empty strings after sanitization', () => {
const input = ['valid', '', ' ', 'also valid'];
expect(sanitizeStringArray(input, 10, 50)).toEqual(['valid', 'also valid']);
});
});
describe('control character handling', () => {
it('should strip control characters from items', () => {
const input = ['hello\x00world', 'test\x07data'];
expect(sanitizeStringArray(input, 10, 50)).toEqual(['helloworld', 'testdata']);
});
});
});
describe('sanitizeUrl', () => {
describe('valid URLs', () => {
it('should accept valid HTTPS URLs', () => {
expect(sanitizeUrl('https://example.com')).toBe('https://example.com/');
});
it('should accept valid HTTP URLs', () => {
expect(sanitizeUrl('http://example.com')).toBe('http://example.com/');
});
it('should accept URLs with paths', () => {
expect(sanitizeUrl('https://example.com/path/to/resource')).toBe('https://example.com/path/to/resource');
});
it('should accept URLs with query parameters', () => {
expect(sanitizeUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com/?foo=bar&baz=qux');
});
it('should accept URLs with fragments', () => {
expect(sanitizeUrl('https://example.com#section')).toBe('https://example.com/#section');
});
it('should accept URLs with port numbers', () => {
expect(sanitizeUrl('https://example.com:8080')).toBe('https://example.com:8080/');
});
});
describe('invalid URLs', () => {
it('should reject non-string input', () => {
expect(sanitizeUrl(null)).toBe('');
expect(sanitizeUrl(undefined)).toBe('');
expect(sanitizeUrl(123)).toBe('');
});
it('should reject javascript: URIs', () => {
expect(sanitizeUrl('javascript:alert(1)')).toBe('');
});
it('should reject data: URIs', () => {
expect(sanitizeUrl('data:text/html,<script>alert(1)</script>')).toBe('');
});
it('should reject file: URIs', () => {
expect(sanitizeUrl('file:///etc/passwd')).toBe('');
});
it('should reject URLs with credentials', () => {
expect(sanitizeUrl('https://user:pass@example.com')).toBe('');
expect(sanitizeUrl('https://user@example.com')).toBe('');
});
it('should reject malformed URLs', () => {
expect(sanitizeUrl('not-a-url')).toBe('');
expect(sanitizeUrl('://missing-protocol.com')).toBe('');
});
it('should reject URLs exceeding maxLength', () => {
const longUrl = 'https://example.com/' + 'a'.repeat(3000);
expect(sanitizeUrl(longUrl)).toBe('');
});
});
describe('control character handling', () => {
it('should strip control characters before parsing', () => {
expect(sanitizeUrl('https://example\x00.com')).toBe('https://example.com/');
});
});
describe('length limits', () => {
it('should respect custom maxLength', () => {
const url = 'https://example.com/path';
expect(sanitizeUrl(url, 10)).toBe('');
});
it('should accept URLs within custom maxLength', () => {
const url = 'https://example.com';
expect(sanitizeUrl(url, 100)).toBe('https://example.com/');
});
});
});
@@ -0,0 +1,76 @@
/**
* Shared sanitization utilities for network data before writing to disk.
* Prevents control character injection and enforces length limits on
* data from external APIs (GitHub, GitLab, Linear, etc.).
*/
/**
* Strip control characters from a string.
* Keeps tabs, newlines, and carriage returns only when allowNewlines is true.
*/
export function stripControlChars(value: string, allowNewlines: boolean): string {
let sanitized = '';
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 0x0A || code === 0x0D || code === 0x09) {
if (allowNewlines) {
sanitized += value[i];
}
continue;
}
if (code <= 0x1F || code === 0x7F) {
continue;
}
sanitized += value[i];
}
return sanitized;
}
/**
* Sanitize a text value: type-check, strip control chars, enforce max length.
*/
export function sanitizeText(value: unknown, maxLength: number, allowNewlines = false): string {
if (typeof value !== 'string') return '';
let sanitized = stripControlChars(value, allowNewlines).trim();
if (sanitized.length > maxLength) {
sanitized = sanitized.substring(0, maxLength);
}
return sanitized;
}
/**
* Sanitize an array of strings: type-check each entry, strip control chars,
* enforce per-item length and max item count.
*/
export function sanitizeStringArray(value: unknown, maxItems: number, maxLength: number): string[] {
if (!Array.isArray(value)) return [];
const sanitized: string[] = [];
for (const entry of value) {
const cleanEntry = sanitizeText(entry, maxLength);
if (cleanEntry) {
sanitized.push(cleanEntry);
}
if (sanitized.length >= maxItems) {
break;
}
}
return sanitized;
}
/**
* Sanitize a URL value: validate format, strip control chars, enforce length.
* Returns empty string for invalid URLs.
*/
export function sanitizeUrl(value: unknown, maxLength = 2000): string {
if (typeof value !== 'string') return '';
const cleaned = stripControlChars(value, false).trim();
if (cleaned.length > maxLength) return '';
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return '';
if (parsed.username || parsed.password) return '';
return parsed.toString();
} catch {
return '';
}
}
@@ -166,12 +166,12 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
};
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2));
writeFileSync(planPath, JSON.stringify(implementationPlan, null, 2), 'utf-8');
// Save task metadata if provided
if (taskMetadata) {
const metadataPath = path.join(specDir, 'task_metadata.json');
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(taskMetadata, null, 2), 'utf-8');
}
// Create requirements.json with attached images
@@ -190,7 +190,7 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
}
const requirementsPath = path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS);
writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2));
writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2), 'utf-8');
// Create the task object
const task: Task = {
@@ -331,51 +331,53 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
// Update implementation_plan.json
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
if (existsSync(planPath)) {
try {
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
try {
const planContent = readFileSync(planPath, 'utf-8');
const plan = JSON.parse(planContent);
if (finalTitle !== undefined) {
plan.feature = finalTitle;
}
if (updates.description !== undefined) {
plan.description = updates.description;
}
plan.updated_at = new Date().toISOString();
if (finalTitle !== undefined) {
plan.feature = finalTitle;
}
if (updates.description !== undefined) {
plan.description = updates.description;
}
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
} catch {
// Plan file might not be valid JSON, continue anyway
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
} catch (planErr: unknown) {
// File missing or invalid JSON - continue anyway
if ((planErr as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[TASK_UPDATE] Error updating implementation plan:', planErr);
}
}
// Update spec.md if it exists
const specPath = path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE);
if (existsSync(specPath)) {
try {
let specContent = readFileSync(specPath, 'utf-8');
try {
let specContent = readFileSync(specPath, 'utf-8');
// Update title (first # heading)
if (finalTitle !== undefined) {
specContent = specContent.replace(
/^#\s+.*$/m,
`# ${finalTitle}`
);
}
// Update title (first # heading)
if (finalTitle !== undefined) {
specContent = specContent.replace(
/^#\s+.*$/m,
`# ${finalTitle}`
);
}
// Update description (## Overview section content)
if (updates.description !== undefined) {
// Replace content between ## Overview and the next ## section
specContent = specContent.replace(
/(## Overview\n)([\s\S]*?)((?=\n## )|$)/,
`$1${updates.description}\n\n$3`
);
}
// Update description (## Overview section content)
if (updates.description !== undefined) {
// Replace content between ## Overview and the next ## section
specContent = specContent.replace(
/(## Overview\n)([\s\S]*?)((?=\n## )|$)/,
`$1${updates.description}\n\n$3`
);
}
writeFileSync(specPath, specContent);
} catch {
// Spec file update failed, continue anyway
writeFileSync(specPath, specContent, 'utf-8');
} catch (specErr: unknown) {
// File missing or update failed - continue anyway
if ((specErr as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[TASK_UPDATE] Error updating spec.md:', specErr);
}
}
@@ -421,27 +423,27 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
// Update task_metadata.json
const metadataPath = path.join(specDir, 'task_metadata.json');
try {
writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(updatedMetadata, null, 2), 'utf-8');
} catch (err) {
console.error('Failed to update task_metadata.json:', err);
}
// Update requirements.json if it exists
const requirementsPath = path.join(specDir, 'requirements.json');
if (existsSync(requirementsPath)) {
try {
const requirementsContent = readFileSync(requirementsPath, 'utf-8');
const requirements = JSON.parse(requirementsContent);
try {
const requirementsContent = readFileSync(requirementsPath, 'utf-8');
const requirements = JSON.parse(requirementsContent);
if (updates.description !== undefined) {
requirements.task_description = updates.description;
}
if (updates.metadata.category) {
requirements.workflow_type = updates.metadata.category;
}
if (updates.description !== undefined) {
requirements.task_description = updates.description;
}
if (updates.metadata.category) {
requirements.workflow_type = updates.metadata.category;
}
writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2));
} catch (err) {
writeFileSync(requirementsPath, JSON.stringify(requirements, null, 2), 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Failed to update requirements.json:', err);
}
}
@@ -384,7 +384,8 @@ export function registerTaskExecutionHandlers(
try {
writeFileSync(
qaReportPath,
`# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`
`# QA Review\n\nStatus: APPROVED\n\nReviewed at: ${new Date().toISOString()}\n`,
'utf-8'
);
} catch (error) {
console.error('[TASK_REVIEW] Failed to write QA report:', error);
@@ -521,7 +522,8 @@ export function registerTaskExecutionHandlers(
try {
writeFileSync(
fixRequestPath,
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n`
`# QA Fix Request\n\nStatus: REJECTED\n\n## Feedback\n\n${feedback || 'No feedback provided'}${imageReferences}\n\nCreated at: ${new Date().toISOString()}\n`,
'utf-8'
);
} catch (error) {
console.error('[TASK_REVIEW] Failed to write QA fix request:', error);
@@ -111,7 +111,7 @@ export async function persistPlanStatus(planPath: string, status: TaskStatus, pr
plan.planStatus = mapStatusToPlanStatus(status);
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
console.warn(`[plan-file-utils] Successfully persisted status: ${status} to implementation_plan.json`);
// Invalidate tasks cache since status changed
@@ -167,7 +167,7 @@ export function persistPlanStatusSync(planPath: string, status: TaskStatus, proj
plan.planStatus = mapStatusToPlanStatus(status);
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
// Invalidate tasks cache since status changed
if (projectId) {
@@ -207,7 +207,7 @@ export async function updatePlanFile<T extends Record<string, unknown>>(
// Add updated_at timestamp - use type assertion since T extends Record<string, unknown>
(updatedPlan as Record<string, unknown>).updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2));
writeFileSync(planPath, JSON.stringify(updatedPlan, null, 2), 'utf-8');
console.warn(`[plan-file-utils] Successfully updated implementation_plan.json`);
return updatedPlan;
} catch (err) {
@@ -267,7 +267,7 @@ export async function createPlanIfNotExists(
}
}
writeFileSync(planPath, JSON.stringify(plan, null, 2));
writeFileSync(planPath, JSON.stringify(plan, null, 2), 'utf-8');
});
}
@@ -301,7 +301,7 @@ export function updateTaskMetadataPrUrl(metadataPath: string, prUrl: string): bo
mkdirSync(path.dirname(metadataPath), { recursive: true });
// Write back
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
return true;
} catch (err) {
console.warn(`[plan-file-utils] Could not update metadata at ${metadataPath}:`, err);
@@ -2043,13 +2043,13 @@ export function registerWorktreeHandlers(
}, MERGE_TIMEOUT_MS);
mergeProcess.stdout.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stdout += chunk;
debug('STDOUT:', chunk);
});
mergeProcess.stderr.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stderr += chunk;
debug('STDERR:', chunk);
});
@@ -2243,7 +2243,7 @@ export function registerWorktreeHandlers(
plan.stagedAt = new Date().toISOString();
plan.stagedInMainProject = true;
}
await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2));
await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2), 'utf-8');
// Verify the write succeeded by reading back
const verifyContent = await fsPromises.readFile(planPath, 'utf-8');
@@ -2482,13 +2482,13 @@ export function registerWorktreeHandlers(
let stderr = '';
previewProcess.stdout.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stdout += chunk;
console.warn('[IPC] merge-preview stdout:', chunk);
});
previewProcess.stderr.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stderr += chunk;
console.warn('[IPC] merge-preview stderr:', chunk);
});
@@ -2929,7 +2929,7 @@ export function registerWorktreeHandlers(
delete plan.stagedAt;
plan.updated_at = new Date().toISOString();
await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2));
await fsPromises.writeFile(planPath, JSON.stringify(plan, null, 2), 'utf-8');
// Also update worktree plan if it exists
const worktreePath = findTaskWorktree(project.path, task.specId);
@@ -2941,7 +2941,7 @@ export function registerWorktreeHandlers(
delete worktreePlan.stagedInMainProject;
delete worktreePlan.stagedAt;
worktreePlan.updated_at = new Date().toISOString();
await fsPromises.writeFile(worktreePlanPath, JSON.stringify(worktreePlan, null, 2));
await fsPromises.writeFile(worktreePlanPath, JSON.stringify(worktreePlan, null, 2), 'utf-8');
} catch (e) {
// Non-fatal - worktree plan update is best-effort
// ENOENT is expected when worktree has no plan file
@@ -3097,13 +3097,13 @@ export function registerWorktreeHandlers(
}, PR_CREATION_TIMEOUT_MS);
createPRProcess.stdout.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stdout += chunk;
debug('STDOUT:', chunk);
});
createPRProcess.stderr.on('data', (data: Buffer) => {
const chunk = data.toString();
const chunk = data.toString('utf-8');
stderr += chunk;
debug('STDERR:', chunk);
});
@@ -303,7 +303,7 @@ function saveWorktreeConfig(projectPath: string, name: string, config: TerminalW
const metadataDir = getTerminalWorktreeMetadataDir(projectPath);
mkdirSync(metadataDir, { recursive: true });
const metadataPath = getTerminalWorktreeMetadataPath(projectPath, name);
writeFileSync(metadataPath, JSON.stringify(config, null, 2));
writeFileSync(metadataPath, JSON.stringify(config, null, 2), 'utf-8');
}
function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktreeConfig | null {
+2 -2
View File
@@ -61,7 +61,7 @@ export class LogService {
''
].join('\n');
writeFileSync(logFile, header);
writeFileSync(logFile, header, 'utf-8');
// Track active session
this.activeSessions.set(taskId, {
@@ -167,7 +167,7 @@ export class LogService {
const logsDir = path.dirname(session.logPath);
const latestPath = path.join(logsDir, 'latest.log');
const logContent = readFileSync(session.logPath, 'utf-8');
writeFileSync(latestPath, logContent);
writeFileSync(latestPath, logContent, 'utf-8');
} catch (error) {
console.error(`[LogService] Failed to end session for task ${taskId}:`, error);
}
+4 -4
View File
@@ -220,11 +220,11 @@ async function executeQuery(
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code) => {
@@ -362,11 +362,11 @@ async function executeSemanticQuery(
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code) => {
+23 -18
View File
@@ -164,14 +164,19 @@ const GITIGNORE_ENTRIES = ['.auto-claude/'];
function ensureGitignoreEntries(projectPath: string, entries: string[]): void {
const gitignorePath = path.join(projectPath, '.gitignore');
// Read existing content atomically (no TOCTOU)
let content = '';
let existingLines: string[] = [];
if (existsSync(gitignorePath)) {
let fileExists = false;
try {
content = readFileSync(gitignorePath, 'utf-8');
existingLines = content.split('\n').map(line => line.trim());
fileExists = true;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// File doesn't exist - content stays empty
}
const existingLines = content ? content.split('\n').map(line => line.trim()) : [];
// Find entries that need to be added
const entriesToAdd: string[] = [];
for (const entry of entries) {
@@ -191,23 +196,23 @@ function ensureGitignoreEntries(projectPath: string, entries: string[]): void {
return;
}
// Build the content to append
let appendContent = '';
if (fileExists) {
// Build the content to append
let appendContent = '';
// Ensure file ends with newline before adding our entries
if (content && !content.endsWith('\n')) {
appendContent += '\n';
}
// Ensure file ends with newline before adding our entries
if (content && !content.endsWith('\n')) {
appendContent += '\n';
}
appendContent += '\n# Auto Claude data directory\n';
for (const entry of entriesToAdd) {
appendContent += entry + '\n';
}
appendContent += '\n# Auto Claude data directory\n';
for (const entry of entriesToAdd) {
appendContent += entry + '\n';
}
if (existsSync(gitignorePath)) {
appendFileSync(gitignorePath, appendContent);
} else {
writeFileSync(gitignorePath, '# Auto Claude data directory\n' + entriesToAdd.join('\n') + '\n');
writeFileSync(gitignorePath, '# Auto Claude data directory\n' + entriesToAdd.join('\n') + '\n', 'utf-8');
}
debug('Added entries to .gitignore', { entries: entriesToAdd });
@@ -315,7 +320,7 @@ export function initializeProject(projectPath: string): InitializationResult {
const dirPath = path.join(dotAutoBuildPath, dataDir);
debug('Creating data directory', { dataDir, dirPath });
mkdirSync(dirPath, { recursive: true });
writeFileSync(path.join(dirPath, '.gitkeep'), '');
writeFileSync(path.join(dirPath, '.gitkeep'), '', 'utf-8');
}
// Update .gitignore to exclude .auto-claude/
@@ -353,7 +358,7 @@ export function ensureDataDirectories(projectPath: string): InitializationResult
if (!existsSync(dirPath)) {
debug('Creating missing data directory', { dataDir, dirPath });
mkdirSync(dirPath, { recursive: true });
writeFileSync(path.join(dirPath, '.gitkeep'), '');
writeFileSync(path.join(dirPath, '.gitkeep'), '', 'utf-8');
}
}
return { success: true };
+3 -3
View File
@@ -74,7 +74,7 @@ export class ProjectStore {
* Save store to disk
*/
private save(): void {
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2));
writeFileSync(this.storePath, JSON.stringify(this.data, null, 2), 'utf-8');
}
/**
@@ -705,7 +705,7 @@ export class ProjectStore {
metadata.archivedInVersion = version;
}
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
} catch (error) {
console.error(`[ProjectStore] archiveTasks: Failed to archive task ${taskId} at ${specPath}:`, error);
hasErrors = true;
@@ -763,7 +763,7 @@ export class ProjectStore {
delete metadata.archivedAt;
delete metadata.archivedInVersion;
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
} catch (error) {
console.error(`[ProjectStore] unarchiveTasks: Failed to unarchive task ${taskId} at ${specPath}:`, error);
hasErrors = true;
+2 -2
View File
@@ -117,7 +117,7 @@ function getPythonVersion(pythonCmd: string): string | null {
stdio: 'pipe',
timeout: 5000,
windowsHide: true
}).toString().trim();
}).toString('utf-8').trim();
// Extract version number from "Python 3.10.5" format
const match = version.match(/Python (\d+\.\d+\.\d+)/);
@@ -352,7 +352,7 @@ function verifyIsPython(pythonCmd: string): boolean {
timeout: 5000,
windowsHide: true,
shell: false
}).toString().trim();
}).toString('utf-8').trim();
// Must output "Python X.Y.Z"
return /^Python \d+\.\d+/.test(output);
+17 -11
View File
@@ -218,7 +218,8 @@ if sys.version_info >= (3, 12):
`;
execSync(`"${venvPython}" -c "${checkScript.replace(/\n/g, '; ').replace(/; ; /g, '; ')}"`, {
stdio: 'pipe',
timeout: 15000
timeout: 15000,
encoding: 'utf-8'
});
return true;
} catch {
@@ -249,8 +250,9 @@ if sys.version_info >= (3, 12):
// For commands like "py -3", we need to resolve to the actual executable
const pythonPath = execSync(`${pythonCmd} -c "import sys; print(sys.executable)"`, {
stdio: 'pipe',
timeout: 5000
}).toString().trim();
timeout: 5000,
encoding: 'utf-8'
}).trim();
console.log(`[PythonEnvManager] Found Python at: ${pythonPath}`);
return pythonPath;
@@ -287,7 +289,8 @@ if sys.version_info >= (3, 12):
return new Promise((resolve) => {
const proc = spawn(systemPython, ['-m', 'venv', venvPath], {
cwd: this.autoBuildSourcePath!,
stdio: 'pipe'
stdio: 'pipe',
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
});
// Track the process for cleanup on app exit
@@ -313,7 +316,7 @@ if sys.version_info >= (3, 12):
}, PythonEnvManager.VENV_CREATION_TIMEOUT_MS);
proc.stderr?.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code) => {
@@ -358,12 +361,13 @@ if sys.version_info >= (3, 12):
return new Promise((resolve) => {
const proc = spawn(venvPython, ['-m', 'ensurepip'], {
cwd: this.autoBuildSourcePath!,
stdio: 'pipe'
stdio: 'pipe',
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
});
let stderr = '';
proc.stderr?.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code) => {
@@ -412,16 +416,17 @@ if sys.version_info >= (3, 12):
// Use python -m pip for better compatibility across Python versions
const proc = spawn(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], {
cwd: this.autoBuildSourcePath!,
stdio: 'pipe'
stdio: 'pipe',
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' }
});
let stdout = '';
let stderr = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
stdout += data.toString('utf-8');
// Emit progress updates for long-running installations
const lines = data.toString().split('\n');
const lines = data.toString('utf-8').split('\n');
for (const line of lines) {
if (line.includes('Installing') || line.includes('Successfully')) {
this.emit('status', line.trim());
@@ -430,7 +435,7 @@ if sys.version_info >= (3, 12):
});
proc.stderr?.on('data', (data) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
proc.on('close', (code) => {
@@ -750,6 +755,7 @@ if sys.version_info >= (3, 12):
PYTHONDONTWRITEBYTECODE: '1',
// Use UTF-8 encoding
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
// Disable user site-packages to avoid conflicts
PYTHONNOUSERSITE: '1',
// Override PYTHONPATH if we have bundled packages
+11 -7
View File
@@ -553,17 +553,21 @@ export class ReleaseService extends EventEmitter {
});
const pkgPath = path.join(projectPath, 'package.json');
if (!existsSync(pkgPath)) {
throw new Error('package.json not found in project root');
let pkgContent: string;
try {
pkgContent = readFileSync(pkgPath, 'utf-8');
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error('package.json not found in project root');
}
throw readErr;
}
const pkgContent = readFileSync(pkgPath, 'utf-8');
const pkg = JSON.parse(pkgContent);
pkg.version = version;
// Preserve formatting (detect indent)
const indent = pkgContent.match(/^(\s+)/m)?.[1] || ' ';
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n', 'utf-8');
// Stage and commit only package.json
this.emitProgress(projectId, {
@@ -719,11 +723,11 @@ export class ReleaseService extends EventEmitter {
let stderr = '';
child.stdout?.on('data', (data: Buffer) => {
stdout += data.toString();
stdout += data.toString('utf-8');
});
child.stderr?.on('data', (data: Buffer) => {
stderr += data.toString();
stderr += data.toString('utf-8');
});
child.on('exit', (code) => {
@@ -199,11 +199,11 @@ export class TerminalNameGenerator extends EventEmitter {
}, 30000); // 30 second timeout
childProcess.stdout?.on('data', (data: Buffer) => {
output += data.toString();
output += data.toString('utf-8');
});
childProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
errorOutput += data.toString('utf-8');
});
childProcess.on('exit', (code: number | null) => {
@@ -140,7 +140,7 @@ export class TerminalSessionStore {
console.warn('[TerminalSessionStore] Successfully recovered from backup!');
// Immediately save the recovered data to main file
try {
writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2));
writeFileSync(this.storePath, JSON.stringify(backupResult.data, null, 2), 'utf-8');
console.warn('[TerminalSessionStore] Restored main file from backup');
} catch (writeError) {
console.error('[TerminalSessionStore] Failed to restore main file:', writeError);
@@ -201,7 +201,7 @@ export class TerminalSessionStore {
const content = JSON.stringify(this.data, null, 2);
// Step 1: Write to temp file
writeFileSync(this.tempPath, content);
writeFileSync(this.tempPath, content, 'utf-8');
// Step 2: Rotate current file to backup (if it exists and is valid)
if (existsSync(this.storePath)) {
@@ -267,7 +267,7 @@ export class TerminalSessionStore {
const content = JSON.stringify(this.data, null, 2);
// Step 1: Write to temp file
await fsPromises.writeFile(this.tempPath, content);
await fsPromises.writeFile(this.tempPath, content, 'utf-8');
// Step 2: Rotate current file to backup (if it exists and is valid)
if (await this.fileExists(this.storePath)) {
@@ -154,7 +154,7 @@ class PtyDaemonClient {
if (!this.socket) return;
this.socket.on('data', (chunk) => {
this.buffer += chunk.toString();
this.buffer += chunk.toString('utf-8');
// Handle newline-delimited JSON
const lines = this.buffer.split('\n');
@@ -130,7 +130,7 @@ class PtyDaemon {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString();
buffer += chunk.toString('utf-8');
// Handle newline-delimited JSON messages
const lines = buffer.split('\n');
+2 -2
View File
@@ -201,11 +201,11 @@ export class TitleGenerator extends EventEmitter {
}, 60000); // 60 second timeout for SDK initialization + API call
childProcess.stdout?.on('data', (data: Buffer) => {
output += data.toString();
output += data.toString('utf-8');
});
childProcess.stderr?.on('data', (data: Buffer) => {
errorOutput += data.toString();
errorOutput += data.toString('utf-8');
});
childProcess.on('exit', (code: number | null) => {