mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(security): implement path validation to prevent traversal attacks in session handling
This commit is contained in:
@@ -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" />
|
||||||
)}
|
)}
|
||||||
{getToolStatusIcon(toolCall.output ? 'completed' : undefined)}
|
<span aria-label={toolCall.output ? 'Tool completed' : 'Tool status unknown'}>
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user