feat(security): implement path validation to prevent traversal attacks in session handling

This commit is contained in:
catlog22
2026-02-26 09:56:35 +08:00
parent 519efe9783
commit 21e3647331
8 changed files with 211 additions and 116 deletions

View File

@@ -187,6 +187,8 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
type="button" type="button"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors" className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
aria-expanded={isExpanded}
aria-controls={`toolcall-panel-${toolCall.name}-${index}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? ( {isExpanded ? (
@@ -194,7 +196,9 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
) : ( ) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
<span aria-label={toolCall.output ? 'Tool completed' : 'Tool status unknown'}>
{getToolStatusIcon(toolCall.output ? 'completed' : undefined)} {getToolStatusIcon(toolCall.output ? 'completed' : undefined)}
</span>
<span className="font-mono font-medium">{toolCall.name}</span> <span className="font-mono font-medium">{toolCall.name}</span>
<span className="text-muted-foreground text-xs">#{index + 1}</span> <span className="text-muted-foreground text-xs">#{index + 1}</span>
</div> </div>
@@ -205,7 +209,10 @@ function ToolCallPanel({ toolCall, index }: ToolCallPanelProps) {
{/* Collapsible content */} {/* Collapsible content */}
{isExpanded && ( {isExpanded && (
<div className="border-t border-border/50 divide-y divide-border/50"> <div
id={`toolcall-panel-${toolCall.name}-${index}`}
className="border-t border-border/50 divide-y divide-border/50"
>
{toolCall.arguments && ( {toolCall.arguments && (
<div className="p-3"> <div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5"> <p className="text-xs font-medium text-muted-foreground mb-1.5">
@@ -331,7 +338,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
</summary> </summary>
<ul className="px-4 pb-3 space-y-1 text-sm text-muted-foreground list-disc list-inside"> <ul className="px-4 pb-3 space-y-1 text-sm text-muted-foreground list-disc list-inside">
{turn.thoughts.map((thought, i) => ( {turn.thoughts.map((thought, i) => (
<li key={i} className="leading-relaxed pl-2">{thought}</li> <li key={`thought-${turn.turnNumber}-${i}`} className="leading-relaxed pl-2">{thought}</li>
))} ))}
</ul> </ul>
</details> </details>
@@ -348,7 +355,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
<span className="text-xs">({turn.toolCalls.length})</span> <span className="text-xs">({turn.toolCalls.length})</span>
</div> </div>
{turn.toolCalls.map((tc, i) => ( {turn.toolCalls.map((tc, i) => (
<ToolCallPanel key={i} toolCall={tc} index={i} /> <ToolCallPanel key={`toolcall-${turn.turnNumber}-${tc.name}-${i}`} toolCall={tc} index={i} />
))} ))}
</div> </div>
)} )}

View File

@@ -7,7 +7,6 @@ import { useEffect, useRef, useCallback } from 'react';
import { useNotificationStore } from '@/stores'; import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore'; import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores'; import { useFlowStore } from '@/stores';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useCliSessionStore } from '@/stores/cliSessionStore'; import { useCliSessionStore } from '@/stores/cliSessionStore';
import { import {
handleSessionLockedMessage, handleSessionLockedMessage,
@@ -36,7 +35,6 @@ function getStoreState() {
const notification = useNotificationStore.getState(); const notification = useNotificationStore.getState();
const execution = useExecutionStore.getState(); const execution = useExecutionStore.getState();
const flow = useFlowStore.getState(); const flow = useFlowStore.getState();
const cliStream = useCliStreamStore.getState();
const cliSessions = useCliSessionStore.getState(); const cliSessions = useCliSessionStore.getState();
return { return {
// Notification store // Notification store
@@ -64,8 +62,6 @@ function getStoreState() {
addNodeOutput: execution.addNodeOutput, addNodeOutput: execution.addNodeOutput,
// Flow store // Flow store
updateNode: flow.updateNode, updateNode: flow.updateNode,
// CLI stream store
addOutput: cliStream.addOutput,
// CLI session store (PTY-backed terminal) // CLI session store (PTY-backed terminal)
upsertCliSession: cliSessions.upsertSession, upsertCliSession: cliSessions.upsertSession,
@@ -167,18 +163,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Handle CLI messages // Handle CLI messages
if (data.type?.startsWith('CLI_')) { if (data.type?.startsWith('CLI_')) {
switch (data.type) { switch (data.type) {
case 'CLI_STARTED': {
const { executionId, tool, mode, timestamp } = data.payload;
// Add system message for CLI start
stores.addOutput(executionId, {
type: 'system',
content: `[${new Date(timestamp).toLocaleTimeString()}] CLI execution started: ${tool} (${mode || 'default'} mode)`,
timestamp: Date.now(),
});
break;
}
// ========== PTY CLI Sessions ========== // ========== PTY CLI Sessions ==========
case 'CLI_SESSION_CREATED': { case 'CLI_SESSION_CREATED': {
const session = data.payload?.session; const session = data.payload?.session;
@@ -245,7 +229,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
} }
case 'CLI_OUTPUT': { case 'CLI_OUTPUT': {
const { executionId, chunkType, data: outputData, unit } = data.payload; const { chunkType, data: outputData, unit } = data.payload;
// Handle structured output // Handle structured output
const unitContent = unit?.content || outputData; const unitContent = unit?.content || outputData;
@@ -301,33 +285,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
} }
} }
// ========== Legacy CLI Stream Output ==========
// Split by lines and add each line to cliStreamStore
const lines = content.split('\n');
lines.forEach((line: string) => {
// Add non-empty lines, or single line if that's all we have
if (line.trim() || lines.length === 1) {
stores.addOutput(executionId, {
type: unitType as any,
content: line,
timestamp: Date.now(),
});
}
});
break;
}
case 'CLI_COMPLETED': {
const { executionId, success, duration } = data.payload;
const statusText = success ? 'completed successfully' : 'failed';
const durationText = duration ? ` (${duration}ms)` : '';
stores.addOutput(executionId, {
type: 'system',
content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`,
timestamp: Date.now(),
});
break; break;
} }
} }

View File

@@ -49,6 +49,68 @@ import {
getCodeIndexMcp getCodeIndexMcp
} from '../../tools/claude-cli-tools.js'; } from '../../tools/claude-cli-tools.js';
import type { RouteContext } from './types.js'; import type { RouteContext } from './types.js';
import { resolve, normalize } from 'path';
import { homedir } from 'os';
// ========== Path Security Utilities ==========
// Allowed directories for session file access (path traversal protection)
const ALLOWED_SESSION_DIRS: string[] = [
resolve(homedir(), '.claude', 'projects'),
resolve(homedir(), '.local', 'share', 'opencode', 'storage'),
resolve(homedir(), '.gemini', 'sessions'),
resolve(homedir(), '.qwen', 'sessions'),
resolve(homedir(), '.codex')
];
/**
* Validates that an absolute path is within one of the allowed directories.
* Prevents path traversal attacks by checking the resolved path.
*
* @param absolutePath - The absolute path to validate
* @param allowedDirs - Array of allowed directory paths
* @returns true if path is within an allowed directory, false otherwise
*/
function isPathWithinAllowedDirs(absolutePath: string, allowedDirs: string[]): boolean {
// Normalize the path to resolve any remaining . or .. sequences
const normalizedPath = normalize(absolutePath);
// Check if the path starts with any of the allowed directories
for (const allowedDir of allowedDirs) {
const normalizedAllowedDir = normalize(allowedDir);
// Ensure path is within allowed dir (starts with allowedDir + separator)
if (normalizedPath.startsWith(normalizedAllowedDir + '/') ||
normalizedPath.startsWith(normalizedAllowedDir + '\\') ||
normalizedPath === normalizedAllowedDir) {
return true;
}
}
return false;
}
/**
* Validates a file path parameter to prevent path traversal attacks.
* Returns validated absolute path or throws an error.
*
* @param inputPath - The user-provided path (may be relative or absolute)
* @param allowedDirs - Array of allowed directory paths
* @returns Object with resolved path or error
*/
function validatePath(inputPath: string, allowedDirs: string[]): { valid: true; path: string } | { valid: false; error: string } {
if (!inputPath || typeof inputPath !== 'string') {
return { valid: false, error: 'Path parameter is required' };
}
// Resolve to absolute path (handles relative paths and .. sequences)
const resolvedPath = resolve(inputPath);
// Validate the resolved path is within allowed directories
if (!isPathWithinAllowedDirs(resolvedPath, allowedDirs)) {
console.warn(`[Security] Path traversal attempt blocked: ${inputPath} resolved to ${resolvedPath}`);
return { valid: false, error: 'Invalid path: access denied' };
}
return { valid: true, path: resolvedPath };
}
// ========== Active Executions State ========== // ========== Active Executions State ==========
// Stores running CLI executions for state recovery when view is opened/refreshed // Stores running CLI executions for state recovery when view is opened/refreshed
@@ -574,6 +636,16 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
return true; return true;
} }
// Security: Validate filePath is within allowed session directories
if (filePath) {
const pathValidation = validatePath(filePath, ALLOWED_SESSION_DIRS);
if (!pathValidation.valid) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid path: access denied' }));
return true;
}
}
try { try {
let result; let result;
@@ -601,7 +673,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
} }
} }
const session = parseSessionFile(filePath, tool); const session = await parseSessionFile(filePath, tool);
if (!session) { if (!session) {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Native session not found at path: ' + filePath })); res.end(JSON.stringify({ error: 'Native session not found at path: ' + filePath }));
@@ -788,6 +860,15 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
return { error: 'tool and prompt are required', status: 400 }; return { error: 'tool and prompt are required', status: 400 };
} }
// Security: Validate toFile path is within project directory
if (toFile) {
const projectDir = resolve(dir || initialPath);
const pathValidation = validatePath(toFile, [projectDir]);
if (!pathValidation.valid) {
return { error: 'Invalid path: access denied', status: 400 };
}
}
// Generate smart context if enabled // Generate smart context if enabled
let finalPrompt = prompt; let finalPrompt = prompt;
if (smartContext?.enabled) { if (smartContext?.enabled) {

View File

@@ -162,8 +162,8 @@ export function parseClaudeSession(filePath: string): ParsedSession | null {
if (entry.timestamp) { if (entry.timestamp) {
lastUpdated = entry.timestamp; lastUpdated = entry.timestamp;
} }
} catch { } catch (e) {
// Skip invalid lines console.warn('[claude-session-parser] Failed to parse JSON line:', e instanceof Error ? e.message : String(e));
} }
} }
@@ -426,8 +426,8 @@ export function parseClaudeSessionContent(content: string, filePath?: string): P
if (entry.timestamp) { if (entry.timestamp) {
lastUpdated = entry.timestamp; lastUpdated = entry.timestamp;
} }
} catch { } catch (e) {
// Skip invalid lines console.warn('[claude-session-parser] Failed to parse JSON line in parseClaudeSessionContent:', e instanceof Error ? e.message : String(e));
} }
} }

View File

@@ -447,7 +447,7 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec
*/ */
export async function getNativeSessionContent(baseDir: string, ccwId: string) { export async function getNativeSessionContent(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir); const store = await getSqliteStore(baseDir);
return store.getNativeSessionContent(ccwId); return await store.getNativeSessionContent(ccwId);
} }
/** /**
@@ -460,7 +460,7 @@ export async function getFormattedNativeConversation(baseDir: string, ccwId: str
maxContentLength?: number; maxContentLength?: number;
}) { }) {
const store = await getSqliteStore(baseDir); const store = await getSqliteStore(baseDir);
return store.getFormattedNativeConversation(ccwId, options); return await store.getFormattedNativeConversation(ccwId, options);
} }
/** /**
@@ -468,7 +468,7 @@ export async function getFormattedNativeConversation(baseDir: string, ccwId: str
*/ */
export async function getNativeConversationPairs(baseDir: string, ccwId: string) { export async function getNativeConversationPairs(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir); const store = await getSqliteStore(baseDir);
return store.getNativeConversationPairs(ccwId); return await store.getNativeConversationPairs(ccwId);
} }
/** /**
@@ -476,7 +476,7 @@ export async function getNativeConversationPairs(baseDir: string, ccwId: string)
*/ */
export async function getEnrichedConversation(baseDir: string, ccwId: string) { export async function getEnrichedConversation(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir); const store = await getSqliteStore(baseDir);
return store.getEnrichedConversation(ccwId); return await store.getEnrichedConversation(ccwId);
} }
/** /**

View File

@@ -1063,7 +1063,7 @@ export class CliHistoryStore {
* Get parsed native session content by CCW ID * Get parsed native session content by CCW ID
* Returns full conversation with all turns from native session file * Returns full conversation with all turns from native session file
*/ */
getNativeSessionContent(ccwId: string): ParsedSession | null { async getNativeSessionContent(ccwId: string): Promise<ParsedSession | null> {
const mapping = this.getNativeSessionMapping(ccwId); const mapping = this.getNativeSessionMapping(ccwId);
if (!mapping || !mapping.native_session_path) { if (!mapping || !mapping.native_session_path) {
return null; return null;
@@ -1075,13 +1075,13 @@ export class CliHistoryStore {
/** /**
* Get formatted conversation text from native session * Get formatted conversation text from native session
*/ */
getFormattedNativeConversation(ccwId: string, options?: { async getFormattedNativeConversation(ccwId: string, options?: {
includeThoughts?: boolean; includeThoughts?: boolean;
includeToolCalls?: boolean; includeToolCalls?: boolean;
includeTokens?: boolean; includeTokens?: boolean;
maxContentLength?: number; maxContentLength?: number;
}): string | null { }): Promise<string | null> {
const session = this.getNativeSessionContent(ccwId); const session = await this.getNativeSessionContent(ccwId);
if (!session) { if (!session) {
return null; return null;
} }
@@ -1091,13 +1091,13 @@ export class CliHistoryStore {
/** /**
* Get conversation pairs (user prompt + assistant response) from native session * Get conversation pairs (user prompt + assistant response) from native session
*/ */
getNativeConversationPairs(ccwId: string): Array<{ async getNativeConversationPairs(ccwId: string): Promise<Array<{
turn: number; turn: number;
userPrompt: string; userPrompt: string;
assistantResponse: string; assistantResponse: string;
timestamp: string; timestamp: string;
}> | null { }> | null> {
const session = this.getNativeSessionContent(ccwId); const session = await this.getNativeSessionContent(ccwId);
if (!session) { if (!session) {
return null; return null;
} }
@@ -1108,7 +1108,7 @@ export class CliHistoryStore {
* Get conversation with enriched native session data * Get conversation with enriched native session data
* Merges CCW history with native session content * Merges CCW history with native session content
*/ */
getEnrichedConversation(ccwId: string): { async getEnrichedConversation(ccwId: string): Promise<{
ccw: ConversationRecord | null; ccw: ConversationRecord | null;
native: ParsedSession | null; native: ParsedSession | null;
merged: Array<{ merged: Array<{
@@ -1121,9 +1121,9 @@ export class CliHistoryStore {
nativeThoughts?: string[]; nativeThoughts?: string[];
nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>; nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>;
}>; }>;
} | null { } | null> {
const ccwConv = this.getConversation(ccwId); const ccwConv = this.getConversation(ccwId);
const nativeSession = this.getNativeSessionContent(ccwId); const nativeSession = await this.getNativeSessionContent(ccwId);
if (!ccwConv && !nativeSession) { if (!ccwConv && !nativeSession) {
return null; return null;

View File

@@ -7,8 +7,8 @@
* part/<messageId>/<partId>.json - Message parts (text, tool, reasoning, step-start) * part/<messageId>/<partId>.json - Message parts (text, tool, reasoning, step-start)
*/ */
import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; import { readFile, access, readdir, stat } from 'fs/promises';
import { join, dirname } from 'path'; import { join } from 'path';
import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js'; import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js';
// ============================================================ // ============================================================
@@ -111,32 +111,45 @@ export function getOpenCodeStoragePath(): string {
} }
/** /**
* Read JSON file safely * Check if a path exists (async)
*/ */
function readJsonFile<T>(filePath: string): T | null { async function pathExists(filePath: string): Promise<boolean> {
try { try {
if (!existsSync(filePath)) { await access(filePath);
return true;
} catch {
return false;
}
}
/**
* Read JSON file safely (async)
*/
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
if (!(await pathExists(filePath))) {
return null; return null;
} }
const content = readFileSync(filePath, 'utf8'); const content = await readFile(filePath, 'utf8');
return JSON.parse(content) as T; return JSON.parse(content) as T;
} catch { } catch (error) {
console.warn(`[OpenCodeParser] Failed to read JSON file ${filePath}:`, error);
return null; return null;
} }
} }
/** /**
* Get all JSON files in a directory sorted by name (which includes timestamp) * Get all JSON files in a directory sorted by name (which includes timestamp) (async)
*/ */
function getJsonFilesInDir(dirPath: string): string[] { async function getJsonFilesInDir(dirPath: string): Promise<string[]> {
if (!existsSync(dirPath)) { if (!(await pathExists(dirPath))) {
return []; return [];
} }
try { try {
return readdirSync(dirPath) const files = await readdir(dirPath);
.filter(f => f.endsWith('.json')) return files.filter(f => f.endsWith('.json')).sort();
.sort(); } catch (error) {
} catch { console.warn(`[OpenCodeParser] Failed to read directory ${dirPath}:`, error);
return []; return [];
} }
} }
@@ -159,12 +172,12 @@ function formatTimestamp(ms: number): string {
* @param storageBasePath - Optional base path to storage (auto-detected if not provided) * @param storageBasePath - Optional base path to storage (auto-detected if not provided)
* @returns ParsedSession with aggregated turns from messages and parts * @returns ParsedSession with aggregated turns from messages and parts
*/ */
export function parseOpenCodeSession( export async function parseOpenCodeSession(
sessionPath: string, sessionPath: string,
storageBasePath?: string storageBasePath?: string
): ParsedSession | null { ): Promise<ParsedSession | null> {
// Read session file // Read session file
const session = readJsonFile<OpenCodeSession>(sessionPath); const session = await readJsonFile<OpenCodeSession>(sessionPath);
if (!session) { if (!session) {
return null; return null;
} }
@@ -175,7 +188,7 @@ export function parseOpenCodeSession(
// Read all messages for this session // Read all messages for this session
const messageDir = join(basePath, 'message', sessionId); const messageDir = join(basePath, 'message', sessionId);
const messageFiles = getJsonFilesInDir(messageDir); const messageFiles = await getJsonFilesInDir(messageDir);
if (messageFiles.length === 0) { if (messageFiles.length === 0) {
// Return session with no turns // Return session with no turns
@@ -199,16 +212,16 @@ export function parseOpenCodeSession(
}> = []; }> = [];
for (const msgFile of messageFiles) { for (const msgFile of messageFiles) {
const message = readJsonFile<OpenCodeMessage>(join(messageDir, msgFile)); const message = await readJsonFile<OpenCodeMessage>(join(messageDir, msgFile));
if (!message) continue; if (!message) continue;
// Read all parts for this message // Read all parts for this message
const partDir = join(basePath, 'part', message.id); const partDir = join(basePath, 'part', message.id);
const partFiles = getJsonFilesInDir(partDir); const partFiles = await getJsonFilesInDir(partDir);
const parts: OpenCodePart[] = []; const parts: OpenCodePart[] = [];
for (const partFile of partFiles) { for (const partFile of partFiles) {
const part = readJsonFile<OpenCodePart>(join(partDir, partFile)); const part = await readJsonFile<OpenCodePart>(join(partDir, partFile));
if (part) { if (part) {
parts.push(part); parts.push(part);
} }
@@ -333,14 +346,14 @@ function buildTurns(messages: Array<{ message: OpenCodeMessage; parts: OpenCodeP
* @param projectHash - Optional project hash (will search all projects if not provided) * @param projectHash - Optional project hash (will search all projects if not provided)
* @returns ParsedSession or null if not found * @returns ParsedSession or null if not found
*/ */
export function parseOpenCodeSessionById( export async function parseOpenCodeSessionById(
sessionId: string, sessionId: string,
projectHash?: string projectHash?: string
): ParsedSession | null { ): Promise<ParsedSession | null> {
const basePath = getOpenCodeStoragePath(); const basePath = getOpenCodeStoragePath();
const sessionDir = join(basePath, 'session'); const sessionDir = join(basePath, 'session');
if (!existsSync(sessionDir)) { if (!(await pathExists(sessionDir))) {
return null; return null;
} }
@@ -352,19 +365,29 @@ export function parseOpenCodeSessionById(
// Search all project directories // Search all project directories
try { try {
const projectDirs = readdirSync(sessionDir).filter(d => { const entries = await readdir(sessionDir);
const fullPath = join(sessionDir, d); const projectDirs: string[] = [];
return statSync(fullPath).isDirectory();
}); for (const entry of entries) {
const fullPath = join(sessionDir, entry);
try {
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
projectDirs.push(entry);
}
} catch {
// Skip entries that can't be stat'd
}
}
for (const projHash of projectDirs) { for (const projHash of projectDirs) {
const sessionPath = join(sessionDir, projHash, `${sessionId}.json`); const sessionPath = join(sessionDir, projHash, `${sessionId}.json`);
if (existsSync(sessionPath)) { if (await pathExists(sessionPath)) {
return parseOpenCodeSession(sessionPath, basePath); return parseOpenCodeSession(sessionPath, basePath);
} }
} }
} catch { } catch (error) {
// Ignore errors console.warn('[OpenCodeParser] Failed to search project directories:', error);
} }
return null; return null;
@@ -376,14 +399,14 @@ export function parseOpenCodeSessionById(
* @param projectHash - Project hash to filter by * @param projectHash - Project hash to filter by
* @returns Array of session info (not full parsed sessions) * @returns Array of session info (not full parsed sessions)
*/ */
export function getOpenCodeSessions(projectHash?: string): Array<{ export async function getOpenCodeSessions(projectHash?: string): Promise<Array<{
sessionId: string; sessionId: string;
projectHash: string; projectHash: string;
filePath: string; filePath: string;
title?: string; title?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}> { }>> {
const basePath = getOpenCodeStoragePath(); const basePath = getOpenCodeStoragePath();
const sessionDir = join(basePath, 'session'); const sessionDir = join(basePath, 'session');
const sessions: Array<{ const sessions: Array<{
@@ -395,27 +418,41 @@ export function getOpenCodeSessions(projectHash?: string): Array<{
updatedAt: Date; updatedAt: Date;
}> = []; }> = [];
if (!existsSync(sessionDir)) { if (!(await pathExists(sessionDir))) {
return sessions; return sessions;
} }
try { try {
const projectDirs = projectHash let projectDirs: string[];
? [projectHash]
: readdirSync(sessionDir).filter(d => { if (projectHash) {
const fullPath = join(sessionDir, d); projectDirs = [projectHash];
return statSync(fullPath).isDirectory(); } else {
}); const entries = await readdir(sessionDir);
projectDirs = [];
for (const entry of entries) {
const fullPath = join(sessionDir, entry);
try {
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
projectDirs.push(entry);
}
} catch {
// Skip entries that can't be stat'd
}
}
}
for (const projHash of projectDirs) { for (const projHash of projectDirs) {
const projDir = join(sessionDir, projHash); const projDir = join(sessionDir, projHash);
if (!existsSync(projDir)) continue; if (!(await pathExists(projDir))) continue;
const sessionFiles = getJsonFilesInDir(projDir); const sessionFiles = await getJsonFilesInDir(projDir);
for (const sessionFile of sessionFiles) { for (const sessionFile of sessionFiles) {
const filePath = join(projDir, sessionFile); const filePath = join(projDir, sessionFile);
const session = readJsonFile<OpenCodeSession>(filePath); const session = await readJsonFile<OpenCodeSession>(filePath);
if (session) { if (session) {
sessions.push({ sessions.push({
@@ -429,8 +466,8 @@ export function getOpenCodeSessions(projectHash?: string): Array<{
} }
} }
} }
} catch { } catch (error) {
// Ignore errors console.warn('[OpenCodeParser] Failed to get sessions:', error);
} }
// Sort by updated time descending // Sort by updated time descending

View File

@@ -4,6 +4,7 @@
*/ */
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { readFile, access } from 'fs/promises';
import { parseClaudeSession } from './claude-session-parser.js'; import { parseClaudeSession } from './claude-session-parser.js';
import { parseOpenCodeSession } from './opencode-session-parser.js'; import { parseOpenCodeSession } from './opencode-session-parser.js';
@@ -178,15 +179,27 @@ function isJSONL(content: string): boolean {
} }
/** /**
* Parse a native session file and return standardized conversation data * Check if a path exists (async)
*/ */
export function parseSessionFile(filePath: string, tool: string): ParsedSession | null { async function pathExists(filePath: string): Promise<boolean> {
if (!existsSync(filePath)) { try {
await access(filePath);
return true;
} catch {
return false;
}
}
/**
* Parse a native session file and return standardized conversation data (async)
*/
export async function parseSessionFile(filePath: string, tool: string): Promise<ParsedSession | null> {
if (!(await pathExists(filePath))) {
return null; return null;
} }
try { try {
const content = readFileSync(filePath, 'utf8'); const content = await readFile(filePath, 'utf8');
switch (tool) { switch (tool) {
case 'gemini': case 'gemini':