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:
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user