diff --git a/packages/agent-connector/src/adapters/codex.js b/packages/agent-connector/src/adapters/codex.js index 816035bd3..ae25d37e8 100644 --- a/packages/agent-connector/src/adapters/codex.js +++ b/packages/agent-connector/src/adapters/codex.js @@ -167,6 +167,45 @@ class CodexAdapter extends BaseAdapter { return null; } + _findNodeBin() { + const home = os.homedir(); + if (process.execPath && fs.existsSync(process.execPath)) return process.execPath; + + const candidates = IS_WINDOWS + ? [path.join(home, '.openagents', 'nodejs', 'node.exe')] + : [ + path.join(home, '.openagents', 'nodejs', 'node'), + path.join(home, '.openagents', 'nodejs', 'bin', 'node'), + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return 'node'; + } + + _resolveToNodeCmd(binPath) { + const nodeBin = this._findNodeBin(); + if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) { + const cmdDir = path.dirname(path.resolve(binPath)); + const cmdContent = fs.readFileSync(binPath, 'utf-8'); + const jsMatch = cmdContent.match(/%dp0%\\([^\s"*?]+\.m?js)/i); + if (jsMatch) { + return [nodeBin, path.resolve(cmdDir, jsMatch[1])]; + } + } else { + try { + let target = binPath; + if (fs.lstatSync(binPath).isSymbolicLink()) { + target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath)); + } + if (target.endsWith('.js') || target.endsWith('.mjs')) { + return [nodeBin, target]; + } + } catch {} + } + return null; + } + _buildSystemContext(channelName) { return buildOpenclawSystemPrompt({ agentName: this.agentName, @@ -179,6 +218,14 @@ class CodexAdapter extends BaseAdapter { }); } + _getSpawnCwd() { + if (!this.workingDir) return undefined; + const normalized = path.normalize(this.workingDir); + if (fs.existsSync(normalized)) return normalized; + this._log(`Working directory not found, using current directory: ${this.workingDir}`); + return undefined; + } + // ------------------------------------------------------------------ // Process management // ------------------------------------------------------------------ @@ -304,14 +351,38 @@ class CodexAdapter extends BaseAdapter { async _spawnCodex(cmd, env, msgChannel, prompt) { return new Promise((resolve, reject) => { - const proc = spawn(cmd[0], cmd.slice(1), { - stdio: ['pipe', 'pipe', 'pipe'], - env, - cwd: this.workingDir, - detached: !IS_WINDOWS, - windowsHide: true, - shell: IS_WINDOWS, - }); + const resolved = this._resolveToNodeCmd(cmd[0]); + if (resolved) { + cmd = [resolved[0], resolved[1], ...cmd.slice(1)]; + } else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) { + cmd = ['cmd.exe', '/c', ...cmd]; + } + + let settled = false; + const fail = (err) => { + if (settled) return; + settled = true; + delete this._channelProcesses[msgChannel]; + reject(err); + }; + + // Passing the prompt as an argument avoids Windows/Node 22 stdin pipe + // ENOTCONN crashes seen when launching npm .cmd shims. + cmd.push(prompt || ''); + + let proc; + try { + proc = spawn(cmd[0], cmd.slice(1), { + stdio: ['ignore', 'pipe', 'pipe'], + env, + cwd: this._getSpawnCwd(), + detached: !IS_WINDOWS, + windowsHide: true, + }); + } catch (err) { + fail(err); + return; + } this._channelProcesses[msgChannel] = proc; const responseTexts = []; @@ -321,14 +392,10 @@ class CodexAdapter extends BaseAdapter { let _pendingLines = Promise.resolve(); if (proc.stderr) { + proc.stderr.on('error', fail); proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); }); } - if (proc.stdin) { - proc.stdin.write(prompt || '', 'utf-8'); - proc.stdin.end(); - } - const processLine = async (line) => { line = line.trim(); if (!line) return; @@ -378,16 +445,20 @@ class CodexAdapter extends BaseAdapter { } }; - proc.stdout.on('data', (chunk) => { - lineBuffer += chunk.toString('utf-8'); - const lines = lineBuffer.split('\n'); - lineBuffer = lines.pop(); - for (const line of lines) { - _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {}); - } - }); + if (proc.stdout) { + proc.stdout.on('error', fail); + proc.stdout.on('data', (chunk) => { + lineBuffer += chunk.toString('utf-8'); + const lines = lineBuffer.split('\n'); + lineBuffer = lines.pop(); + for (const line of lines) { + _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {}); + } + }); + } proc.on('exit', async (code) => { + if (settled) return; // Wait for all in-flight processLine calls try { await _pendingLines; } catch {} @@ -405,6 +476,7 @@ class CodexAdapter extends BaseAdapter { } } + settled = true; resolve({ responseText: responseTexts.join('\n').trim(), exitCode: code, @@ -412,10 +484,7 @@ class CodexAdapter extends BaseAdapter { }); }); - proc.on('error', (err) => { - delete this._channelProcesses[msgChannel]; - reject(err); - }); + proc.on('error', fail); }); } diff --git a/workspace/frontend/components/chat/chat-message.tsx b/workspace/frontend/components/chat/chat-message.tsx index 2d840d4bf..dd2717cde 100644 --- a/workspace/frontend/components/chat/chat-message.tsx +++ b/workspace/frontend/components/chat/chat-message.tsx @@ -20,10 +20,18 @@ interface Attachment { } function isPreviewable(contentType: string, filename: string): boolean { - if (contentType?.startsWith('image/')) return true; - if (contentType === 'text/html' || /\.html?$/i.test(filename)) return true; - if (contentType === 'text/markdown' || /\.mdx?$/i.test(filename)) return true; - if (contentType?.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true; + const type = contentType?.split(';')[0].trim().toLowerCase() || ''; + if (type.startsWith('image/')) return true; + if (type === 'application/pdf' || /\.pdf$/i.test(filename)) return true; + if (type === 'text/html' || /\.html?$/i.test(filename)) return true; + if (type === 'text/markdown' || /\.mdx?$/i.test(filename)) return true; + if ( + type === 'text/csv' || + type === 'application/csv' || + type === 'application/vnd.ms-excel' || + /\.csv$/i.test(filename) + ) return true; + if (type.startsWith('text/') || /\.(json|js|ts|tsx|jsx|py|rs|go|java|rb|sh|yaml|yml)$/i.test(filename)) return true; return false; } diff --git a/workspace/frontend/components/files/file-preview.tsx b/workspace/frontend/components/files/file-preview.tsx index 0bccfcdb2..ee3dc7748 100644 --- a/workspace/frontend/components/files/file-preview.tsx +++ b/workspace/frontend/components/files/file-preview.tsx @@ -27,8 +27,24 @@ function isMarkdownFile(contentType: string, filename: string): boolean { return contentType === 'text/markdown' || /\.mdx?$/i.test(filename); } +function isPdfFile(contentType: string, filename: string): boolean { + const type = contentType.split(';')[0].trim().toLowerCase(); + return type === 'application/pdf' || /\.pdf$/i.test(filename); +} + +function isCsvFile(contentType: string, filename: string): boolean { + const type = contentType.split(';')[0].trim().toLowerCase(); + return ( + type === 'text/csv' || + type === 'application/csv' || + type === 'application/vnd.ms-excel' || + /\.csv$/i.test(filename) + ); +} + function isTextFile(contentType: string, filename: string): boolean { if (isHtmlFile(contentType, filename)) return false; // HTML is handled separately + if (isCsvFile(contentType, filename)) return false; // CSV is handled separately return ( contentType.startsWith('text/') || contentType === 'application/json' || @@ -39,6 +55,53 @@ function isTextFile(contentType: string, filename: string): boolean { ); } +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + + if (inQuotes) { + if (char === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else { + inQuotes = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + } else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + row.push(field); + rows.push(row); + row = []; + field = ''; + if (char === '\r' && text[i + 1] === '\n') i++; + } else { + field += char; + } + } + + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows; +} + export function FilePreview() { const { files, selectedFileId, deleteFile, setSelectedFileId } = useWorkspace(); const { isMobile, openMobileList } = useLayout(); @@ -67,9 +130,11 @@ export function FilePreview() { const fn = file.filename || ''; const isHtml = isHtmlFile(ct, fn); const isImage = isImageFile(ct); + const isPdf = isPdfFile(ct, fn); + const isCsv = isCsvFile(ct, fn); const isText = isTextFile(ct, fn); - // HTML and images use the direct URL — no fetch needed + // HTML uses the direct URL - no fetch needed if (isHtml) { setContent(null); const url = workspaceApi.getFileUrl(file.id); @@ -79,7 +144,7 @@ export function FilePreview() { return; } - if (!isText && !isImage) { + if (!isText && !isImage && !isCsv && !isPdf) { setContent(null); setBlobUrl(null); return; @@ -98,11 +163,12 @@ export function FilePreview() { if (cancelled) return; if (!res.ok) throw new Error(`HTTP ${res.status}`); - if (isImage) { + if (isImage || isPdf) { const blob = await res.blob(); if (!cancelled) { if (blobUrl) URL.revokeObjectURL(blobUrl); - setBlobUrl(URL.createObjectURL(blob)); + const previewBlob = isPdf ? new Blob([blob], { type: 'application/pdf' }) : blob; + setBlobUrl(URL.createObjectURL(previewBlob)); setContent(null); } } else { @@ -201,6 +267,12 @@ export function FilePreview() { className="w-full h-full border-0" sandbox="allow-scripts allow-same-origin" /> + ) : isPdfFile(file.contentType || '', file.filename) && blobUrl ? ( +