diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 639b14e..7ea2a83 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiMcpHealthRouteImport } from './routes/api/mcp-health' import { Route as ApiHistoryRouteImport } from './routes/api/history' import { Route as ApiGitReviewRouteImport } from './routes/api/git-review' +import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts' const NewRoute = NewRouteImport.update({ id: '/new', @@ -100,11 +101,17 @@ const ApiGitReviewRoute = ApiGitReviewRouteImport.update({ path: '/api/git-review', getParentRoute: () => rootRouteImport, } as any) +const ApiArtifactsRoute = ApiArtifactsRouteImport.update({ + id: '/api/artifacts', + path: '/api/artifacts', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -122,6 +129,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -140,6 +148,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/artifacts': typeof ApiArtifactsRoute '/api/git-review': typeof ApiGitReviewRoute '/api/history': typeof ApiHistoryRoute '/api/mcp-health': typeof ApiMcpHealthRoute @@ -159,6 +168,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/artifacts' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -176,6 +186,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/artifacts' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -193,6 +204,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/artifacts' | '/api/git-review' | '/api/history' | '/api/mcp-health' @@ -211,6 +223,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConnectRoute: typeof ConnectRoute NewRoute: typeof NewRoute + ApiArtifactsRoute: typeof ApiArtifactsRoute ApiGitReviewRoute: typeof ApiGitReviewRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiMcpHealthRoute: typeof ApiMcpHealthRoute @@ -332,6 +345,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGitReviewRouteImport parentRoute: typeof rootRouteImport } + '/api/artifacts': { + id: '/api/artifacts' + path: '/api/artifacts' + fullPath: '/api/artifacts' + preLoaderRoute: typeof ApiArtifactsRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -339,6 +359,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConnectRoute: ConnectRoute, NewRoute: NewRoute, + ApiArtifactsRoute: ApiArtifactsRoute, ApiGitReviewRoute: ApiGitReviewRoute, ApiHistoryRoute: ApiHistoryRoute, ApiMcpHealthRoute: ApiMcpHealthRoute, diff --git a/apps/codex-claw/src/routes/api/artifacts.ts b/apps/codex-claw/src/routes/api/artifacts.ts new file mode 100644 index 0000000..aafe2da --- /dev/null +++ b/apps/codex-claw/src/routes/api/artifacts.ts @@ -0,0 +1,71 @@ +import path from 'node:path' +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { + getCodexArtifactFile, + listCodexArtifacts, +} from '../../server/codex-cli' + +function contentTypeForPath(filePath: string) { + const ext = path.extname(filePath).toLowerCase() + if (ext === '.json') return 'application/json' + if (ext === '.md') return 'text/markdown' + if (ext === '.patch' || ext === '.diff') return 'text/x-diff' + if (ext === '.png') return 'image/png' + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg' + if (ext === '.gif') return 'image/gif' + if (ext === '.webp') return 'image/webp' + return 'text/plain' +} + +function downloadName(filePath: string) { + return path.basename(filePath).replace(/[^a-zA-Z0-9._-]/g, '-') +} + +export const Route = createFileRoute('/api/artifacts')({ + server: { + handlers: { + GET: ({ request }) => { + try { + const url = new URL(request.url) + const sessionKey = url.searchParams.get('sessionKey') ?? undefined + const friendlyId = url.searchParams.get('friendlyId') ?? undefined + const id = url.searchParams.get('id') ?? '' + + if (url.searchParams.get('manifest') === '1') { + const payload = listCodexArtifacts({ sessionKey, friendlyId }) + return new Response(JSON.stringify(payload.manifest, null, 2), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': + 'attachment; filename="codexclaw-artifacts-manifest.json"', + }, + }) + } + + if (id && url.searchParams.get('download') === '1') { + const payload = getCodexArtifactFile({ id, sessionKey, friendlyId }) + return new Response(payload.content, { + headers: { + 'content-type': contentTypeForPath(payload.artifact.path), + 'content-disposition': + 'attachment; filename="' + + downloadName(payload.artifact.path) + + '"', + }, + }) + } + + return json(listCodexArtifacts({ sessionKey, friendlyId })) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/chat-queries.ts b/apps/codex-claw/src/screens/chat/chat-queries.ts index fcbf021..fccdc87 100644 --- a/apps/codex-claw/src/screens/chat/chat-queries.ts +++ b/apps/codex-claw/src/screens/chat/chat-queries.ts @@ -1,6 +1,7 @@ import { getMessageTimestamp, normalizeSessions, readError } from './utils' import type { QueryClient } from '@tanstack/react-query' import type { + ArtifactListResponse, GatewayMessage, GitReviewPayload, HistoryResponse, @@ -33,6 +34,9 @@ export const chatQueryKeys = { }, workspaces: ['chat', 'workspaces'] as const, tasks: ['chat', 'tasks'] as const, + artifacts: function artifacts(sessionKey: string, friendlyId: string) { + return ['chat', 'artifacts', sessionKey, friendlyId] as const + }, history: function history(friendlyId: string, sessionKey: string) { return ['chat', 'history', friendlyId, sessionKey] as const }, @@ -110,6 +114,18 @@ export async function fetchHistory(payload: { return (await res.json()) as HistoryResponse } +export async function fetchArtifacts(payload: { + sessionKey: string + friendlyId: string +}): Promise { + const query = new URLSearchParams() + if (payload.sessionKey) query.set('sessionKey', payload.sessionKey) + if (payload.friendlyId) query.set('friendlyId', payload.friendlyId) + const res = await fetch('/api/artifacts?' + query.toString()) + if (!res.ok) throw new Error(await readError(res)) + return (await res.json()) as ArtifactListResponse +} + export async function fetchGatewayStatus(): Promise { const controller = new AbortController() const timeout = window.setTimeout(() => controller.abort(), 2500) diff --git a/apps/codex-claw/src/screens/chat/chat-screen.tsx b/apps/codex-claw/src/screens/chat/chat-screen.tsx index cc7a49e..5403294 100644 --- a/apps/codex-claw/src/screens/chat/chat-screen.tsx +++ b/apps/codex-claw/src/screens/chat/chat-screen.tsx @@ -26,6 +26,7 @@ import { ChatComposer } from './components/chat-composer' import { GitReviewPanel } from './components/git-review-panel' import { McpHealthPanel } from './components/mcp-health-panel' import { TaskQueuePanel } from './components/task-queue-panel' +import { ArtifactsPanel } from './components/artifacts-panel' import { GatewayStatusMessage } from './components/gateway-status-message' import { hasPendingGeneration, @@ -75,6 +76,7 @@ export function ChatScreen({ const [gitReviewOpen, setGitReviewOpen] = useState(false) const [mcpHealthOpen, setMcpHealthOpen] = useState(false) const [taskQueueOpen, setTaskQueueOpen] = useState(false) + const [artifactsOpen, setArtifactsOpen] = useState(false) const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } = useChatMeasurements() const [waitingForResponse, setWaitingForResponse] = useState( @@ -637,11 +639,19 @@ export function ChatScreen({ onToggleMcpHealth={() => setMcpHealthOpen((current) => !current)} taskQueueOpen={taskQueueOpen} onToggleTaskQueue={() => setTaskQueueOpen((current) => !current)} + artifactsOpen={artifactsOpen} + onToggleArtifacts={() => setArtifactsOpen((current) => !current)} /> setTaskQueueOpen(false)} /> + setArtifactsOpen(false)} + /> setMcpHealthOpen(false)} diff --git a/apps/codex-claw/src/screens/chat/components/artifacts-panel.tsx b/apps/codex-claw/src/screens/chat/components/artifacts-panel.tsx new file mode 100644 index 0000000..7b77ae5 --- /dev/null +++ b/apps/codex-claw/src/screens/chat/components/artifacts-panel.tsx @@ -0,0 +1,249 @@ +import { useEffect, useMemo, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Cancel01Icon, + Copy01Icon, + Download01Icon, + File01Icon, + FolderFileStorageIcon, + RefreshIcon, +} from '@hugeicons/core-free-icons' +import { fetchArtifacts } from '../chat-queries' +import type { ArtifactListResponse, CodexArtifactRecord } from '../types' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type ArtifactsPanelProps = { + open: boolean + sessionKey: string + friendlyId: string + onClose: () => void +} + +function formatArtifactType(type: CodexArtifactRecord['type']) { + if (type === 'terminal-log') return 'Terminal log' + if (type === 'package') return 'Package' + if (type === 'patch') return 'Patch' + if (type === 'export') return 'Export' + if (type === 'image') return 'Image' + return 'File' +} + +function formatSize(size: number | undefined) { + if (typeof size !== 'number') return 'unknown' + if (size < 1024) return String(size) + ' B' + if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB' + return (size / (1024 * 1024)).toFixed(1) + ' MB' +} + +function formatTime(value: number) { + return new Date(value).toLocaleString('en-GB', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +function artifactTypeClass(type: CodexArtifactRecord['type']) { + if (type === 'patch') return 'bg-amber-100 text-amber-700' + if (type === 'terminal-log') return 'bg-primary-200 text-primary-700' + if (type === 'package') return 'bg-emerald-100 text-emerald-700' + if (type === 'image') return 'bg-sky-100 text-sky-700' + return 'bg-primary-100 text-primary-700' +} + +function copyText(value: string) { + try { + void navigator.clipboard.writeText(value) + } catch { + // Clipboard permissions vary by browser context. + } +} + +function downloadUrl( + artifact: CodexArtifactRecord, + sessionKey: string, + friendlyId: string, +) { + const query = new URLSearchParams({ + id: artifact.id, + download: '1', + }) + if (sessionKey) query.set('sessionKey', sessionKey) + if (friendlyId) query.set('friendlyId', friendlyId) + return '/api/artifacts?' + query.toString() +} + +function manifestUrl(sessionKey: string, friendlyId: string) { + const query = new URLSearchParams({ manifest: '1' }) + if (sessionKey) query.set('sessionKey', sessionKey) + if (friendlyId) query.set('friendlyId', friendlyId) + return '/api/artifacts?' + query.toString() +} + +export function ArtifactsPanel({ + open, + sessionKey, + friendlyId, + onClose, +}: ArtifactsPanelProps) { + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + function loadArtifacts() { + if (!sessionKey && !friendlyId) return + setLoading(true) + setError(null) + fetchArtifacts({ sessionKey, friendlyId }) + .then(setPayload) + .catch((err) => + setError(err instanceof Error ? err.message : String(err)), + ) + .finally(() => setLoading(false)) + } + + useEffect(() => { + if (!open) return + loadArtifacts() + }, [friendlyId, open, sessionKey]) + + const counts = useMemo(() => { + const artifacts = payload?.artifacts ?? [] + return { + total: artifacts.length, + safe: artifacts.filter((artifact) => artifact.safeToOpen).length, + packages: artifacts.filter((artifact) => artifact.type === 'package') + .length, + } + }, [payload]) + + if (!open) return null + + return ( +
+
+ +
+
+ Artifacts +
+
+ {counts.total} local, {counts.safe} safe to open, {counts.packages}{' '} + package outputs +
+
+ + + +
+ + {error ? ( +
{error}
+ ) : null} + {loading ? ( +
Refreshing...
+ ) : null} + +
+ {payload && payload.artifacts.length === 0 ? ( +
+ No local artifacts have been recorded for this session yet. +
+ ) : null} + {(payload?.artifacts ?? []).map((artifact) => ( +
+
+
+ +
+
+
+ + {formatArtifactType(artifact.type)} + + + {artifact.redactedPath} + +
+
+ + Run {artifact.runId || 'manual'} + + + {formatTime(artifact.createdAt)} + + + {formatSize(artifact.size)} + +
+
+ {artifact.safeToOpen + ? 'Safe local text or image artifact' + : 'Path is tracked but direct browser download is disabled'} +
+
+
+ + +
+
+
+ ))} +
+
+ ) +} diff --git a/apps/codex-claw/src/screens/chat/components/chat-header.tsx b/apps/codex-claw/src/screens/chat/components/chat-header.tsx index 088b09b..067227b 100644 --- a/apps/codex-claw/src/screens/chat/components/chat-header.tsx +++ b/apps/codex-claw/src/screens/chat/components/chat-header.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { HugeiconsIcon } from '@hugeicons/react' import { CheckmarkCircle01Icon, + FolderFileStorageIcon, GitBranchIcon, Menu01Icon, Settings01Icon, @@ -28,6 +29,8 @@ type ChatHeaderProps = { onToggleMcpHealth?: () => void taskQueueOpen?: boolean onToggleTaskQueue?: () => void + artifactsOpen?: boolean + onToggleArtifacts?: () => void } function ChatHeaderComponent({ @@ -46,6 +49,8 @@ function ChatHeaderComponent({ onToggleMcpHealth, taskQueueOpen = false, onToggleTaskQueue, + artifactsOpen = false, + onToggleArtifacts, }: ChatHeaderProps) { return (
) : null} + {onToggleArtifacts ? ( + + ) : null} {showExport ? ( ) : null} diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index 983f269..1bc58db 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -281,3 +281,47 @@ export type CodexTaskRecord = { export type TaskListResponse = { tasks: Array } + +export type CodexArtifactType = + | 'file' + | 'patch' + | 'terminal-log' + | 'export' + | 'package' + | 'image' + +export type CodexArtifactRecord = { + id: string + sessionKey: string + runId?: string + path: string + redactedPath: string + type: CodexArtifactType + createdAt: number + safeToOpen: boolean + size?: number + source: 'command-log' | 'detected-file' | 'export-manifest' +} + +export type ArtifactManifestEntry = { + id: string + type: CodexArtifactType + path: string + createdAt: number + runId?: string + safeToOpen: boolean + size?: number + source: CodexArtifactRecord['source'] +} + +export type ArtifactManifest = { + exportedAt: string + sessionKey: string + artifactCount: number + artifacts: Array +} + +export type ArtifactListResponse = { + artifacts: Array + manifest: ArtifactManifest +} diff --git a/apps/codex-claw/src/server/codex-cli.test.ts b/apps/codex-claw/src/server/codex-cli.test.ts index 322e7a9..d5f5769 100644 --- a/apps/codex-claw/src/server/codex-cli.test.ts +++ b/apps/codex-claw/src/server/codex-cli.test.ts @@ -6,7 +6,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createCodexWorkspace, deleteCodexWorkspace, + getCodexArtifactFile, getCodexPaths, + listCodexArtifacts, listCodexSessions, listCodexWorkspaces, mergeAssistantText, @@ -304,4 +306,57 @@ describe('codex workspace registry', function () { ) expect(listCodexSessions().sessions).toEqual([]) }) + + it('lists local artifacts with redacted manifests and safe downloads', function () { + const paths = getCodexPaths() + const artifactDir = path.join(paths.stateDir, 'artifacts', 'artifact-test') + const artifactPath = path.join(artifactDir, 'run-1.log') + mkdirSync(artifactDir, { recursive: true }) + writeFileSync(artifactPath, 'command output') + writeFileSync( + path.join(paths.stateDir, 'artifacts.json'), + JSON.stringify({ + version: 1, + artifacts: [ + { + id: 'artifact-1', + sessionKey: 'artifact-test', + runId: 'run-1', + path: artifactPath, + redactedPath: '$CODEX_CLAW_STATE/artifacts/artifact-test/run-1.log', + type: 'terminal-log', + createdAt: 400, + safeToOpen: true, + size: 14, + source: 'command-log', + }, + ], + }), + ) + resetCodexServerStateForTests() + + const payload = listCodexArtifacts({ sessionKey: 'artifact-test' }) + + expect(payload.artifacts).toEqual([ + expect.objectContaining({ + id: 'artifact-1', + path: artifactPath, + redactedPath: '$CODEX_CLAW_STATE/artifacts/artifact-test/run-1.log', + type: 'terminal-log', + safeToOpen: true, + }), + ]) + expect(payload.manifest.artifacts).toEqual([ + expect.objectContaining({ + id: 'artifact-1', + path: '$CODEX_CLAW_STATE/artifacts/artifact-test/run-1.log', + }), + ]) + expect( + getCodexArtifactFile({ + id: 'artifact-1', + sessionKey: 'artifact-test', + }).content.toString('utf8'), + ).toBe('command output') + }) }) diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index bbda4d1..615aa27 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -8,6 +8,7 @@ import { readFileSync, renameSync, rmSync, + statSync, writeFileSync, } from 'node:fs' import os from 'node:os' @@ -68,6 +69,32 @@ type SessionStore = { sessions: Array } +type CodexArtifactType = + | 'file' + | 'patch' + | 'terminal-log' + | 'export' + | 'package' + | 'image' + +type CodexArtifactRecord = { + id: string + sessionKey: string + runId?: string + path: string + redactedPath: string + type: CodexArtifactType + createdAt: number + safeToOpen: boolean + size?: number + source: 'command-log' | 'detected-file' | 'export-manifest' +} + +type ArtifactStore = { + version: 1 + artifacts: Array +} + type SessionFilter = 'all' | 'recent' | 'failed' | 'tagged' | 'archived' type ListCodexSessionsInput = { @@ -313,6 +340,7 @@ const runProfiles: Record< let storeCache: SessionStore | null = null let workspaceStoreCache: WorkspaceStore | null = null let taskStoreCache: TaskStore | null = null +let artifactStoreCache: ArtifactStore | null = null let stateVersion = 0 const runningTasks = new Map>() @@ -338,6 +366,14 @@ function getTaskStorePath() { return path.join(getStateDir(), 'tasks.json') } +function getArtifactStorePath() { + return path.join(getStateDir(), 'artifacts.json') +} + +function getArtifactsDir() { + return path.join(getStateDir(), 'artifacts') +} + function getCodexCommand() { return getActiveWorkspace().codexCommand } @@ -725,6 +761,55 @@ function writeTaskStore(store: TaskStore) { stateVersion += 1 } +function isArtifactStore(value: unknown): value is ArtifactStore { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return candidate.version === 1 && Array.isArray(candidate.artifacts) +} + +function isArtifactRecord(value: unknown): value is CodexArtifactRecord { + if (!value || typeof value !== 'object') return false + const candidate = value as Record + return ( + typeof candidate.id === 'string' && + typeof candidate.sessionKey === 'string' && + typeof candidate.path === 'string' && + typeof candidate.redactedPath === 'string' && + typeof candidate.type === 'string' && + typeof candidate.createdAt === 'number' && + typeof candidate.safeToOpen === 'boolean' && + typeof candidate.source === 'string' + ) +} + +function readArtifactStore() { + if (artifactStoreCache) return artifactStoreCache + const artifactStorePath = getArtifactStorePath() + try { + const parsed = JSON.parse(readFileSync(artifactStorePath, 'utf8')) as unknown + artifactStoreCache = isArtifactStore(parsed) + ? { + version: 1, + artifacts: parsed.artifacts.filter(isArtifactRecord), + } + : { version: 1, artifacts: [] } + } catch { + artifactStoreCache = { version: 1, artifacts: [] } + } + return artifactStoreCache +} + +function writeArtifactStore(store: ArtifactStore) { + const artifactStorePath = getArtifactStorePath() + mkdirSync(path.dirname(artifactStorePath), { recursive: true }) + const tempPath = + artifactStorePath + '.' + process.pid + '.' + Date.now() + '.tmp' + writeFileSync(tempPath, JSON.stringify(store, null, 2)) + renameSync(tempPath, artifactStorePath) + artifactStoreCache = store + stateVersion += 1 +} + function taskDuration(task: CodexTaskRecord, now = Date.now()) { const startedAt = task.startedAt ?? task.createdAt const finishedAt = task.finishedAt ?? now @@ -998,6 +1083,7 @@ function appendMessage(session: SessionRecord, message: CodexMessage) { session.derivedTitle = titleFromMessage(firstUserText) session.title = session.derivedTitle } + recordCommandArtifacts(session, message) } function textFromMessage(message: CodexMessage) { @@ -1009,6 +1095,202 @@ function textFromMessage(message: CodexMessage) { : '' } +function sanitizeArtifactSegment(value: string) { + return ( + value + .replace(/[^a-zA-Z0-9._-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 80) || 'artifact' + ) +} + +function isPathInside(parent: string, child: string) { + const relative = path.relative(parent, child) + return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) +} + +function redactArtifactPath(filePath: string) { + const resolved = path.resolve(filePath) + const stateDir = path.resolve(getStateDir()) + const workdir = path.resolve(getCodexWorkdir()) + if (resolved === stateDir || isPathInside(stateDir, resolved)) { + return resolved.replace(stateDir, '$CODEX_CLAW_STATE') + } + if (resolved === workdir || isPathInside(workdir, resolved)) { + return resolved.replace(workdir, '$WORKSPACE') + } + const home = os.homedir() + if (resolved === home || isPathInside(home, resolved)) { + return resolved.replace(home, '~') + } + return path.basename(resolved) +} + +function artifactTypeForPath(filePath: string): CodexArtifactType { + const normalized = filePath.toLowerCase() + if (normalized.endsWith('.patch') || normalized.endsWith('.diff')) { + return 'patch' + } + if (normalized.endsWith('.log') || normalized.endsWith('.txt')) { + return 'terminal-log' + } + if ( + normalized.endsWith('.tgz') || + normalized.endsWith('.zip') || + normalized.endsWith('.tar.gz') || + normalized.endsWith('sha256sums') + ) { + return 'package' + } + if (/\.(png|jpe?g|gif|webp)$/i.test(normalized)) return 'image' + if (/\.(md|json)$/i.test(normalized)) return 'export' + return 'file' +} + +function artifactSafeToOpen(filePath: string, type: CodexArtifactType) { + const resolved = path.resolve(filePath) + const trustedRoot = + resolved === path.resolve(getStateDir()) || + isPathInside(path.resolve(getStateDir()), resolved) || + resolved === path.resolve(getCodexWorkdir()) || + isPathInside(path.resolve(getCodexWorkdir()), resolved) + if (!trustedRoot) return false + return type !== 'package' && type !== 'file' +} + +function artifactId(sessionKey: string, artifactPath: string, runId?: string) { + return [ + sanitizeArtifactSegment(deriveFriendlyIdFromKey(sessionKey)), + sanitizeArtifactSegment(runId || 'manual'), + Buffer.from(path.resolve(artifactPath)).toString('base64url').slice(0, 24), + ].join('-') +} + +function upsertArtifact(record: CodexArtifactRecord) { + const store = readArtifactStore() + const index = store.artifacts.findIndex((artifact) => artifact.id === record.id) + if (index >= 0) { + store.artifacts[index] = record + } else { + store.artifacts.unshift(record) + } + writeArtifactStore(store) +} + +function artifactRecordFromPath(input: { + sessionKey: string + filePath: string + runId?: string + source: CodexArtifactRecord['source'] + createdAt?: number +}) { + const resolved = path.resolve(input.filePath) + const stats = statSync(resolved) + if (!stats.isFile()) return null + const type = artifactTypeForPath(resolved) + return { + id: artifactId(input.sessionKey, resolved, input.runId), + sessionKey: input.sessionKey, + runId: input.runId, + path: resolved, + redactedPath: redactArtifactPath(resolved), + type, + createdAt: input.createdAt ?? stats.mtimeMs, + safeToOpen: artifactSafeToOpen(resolved, type), + size: stats.size, + source: input.source, + } satisfies CodexArtifactRecord +} + +function writeCommandLogArtifact(input: { + session: SessionRecord + runId?: string + content: string + createdAt: number +}) { + if (!input.content.trim()) return + const sessionDir = path.join( + getArtifactsDir(), + sanitizeArtifactSegment(input.session.friendlyId), + ) + mkdirSync(sessionDir, { recursive: true }) + const fileName = + sanitizeArtifactSegment(input.runId || String(input.createdAt)) + '.log' + const artifactPath = path.join(sessionDir, fileName) + writeFileSync(artifactPath, input.content) + const record = artifactRecordFromPath({ + sessionKey: input.session.key, + filePath: artifactPath, + runId: input.runId, + source: 'command-log', + createdAt: input.createdAt, + }) + if (record) upsertArtifact(record) +} + +function extractArtifactPathCandidates(value: string) { + const candidates = new Set() + const pattern = + /(?:"([^"]+\.(?:patch|diff|log|txt|md|json|tgz|zip|tar\.gz|sha256sums|png|jpe?g|gif|webp))"|([A-Za-z]:\\[^\s"'<>|]+\.(?:patch|diff|log|txt|md|json|tgz|zip|tar\.gz|sha256sums|png|jpe?g|gif|webp))|((?:\.{1,2}[\\/]|[\w.-]+[\\/])?[^\s"'<>|]+\.(?:patch|diff|log|txt|md|json|tgz|zip|tar\.gz|sha256sums|png|jpe?g|gif|webp)))/gi + for (const match of value.matchAll(pattern)) { + const candidate = match[1] || match[2] || match[3] + if (candidate) candidates.add(candidate.replace(/[),.;:]+$/g, '')) + } + return [...candidates] +} + +function resolveArtifactCandidate(candidate: string) { + if (path.isAbsolute(candidate)) return path.resolve(candidate) + return path.resolve(getCodexWorkdir(), candidate) +} + +function recordDetectedFileArtifacts(input: { + session: SessionRecord + runId?: string + command?: string + output: string + createdAt: number +}) { + const text = [input.command, input.output].filter(Boolean).join('\n') + for (const candidate of extractArtifactPathCandidates(text)) { + const resolved = resolveArtifactCandidate(candidate) + if (!existsSync(resolved)) continue + try { + const record = artifactRecordFromPath({ + sessionKey: input.session.key, + filePath: resolved, + runId: input.runId, + source: 'detected-file', + createdAt: input.createdAt, + }) + if (record) upsertArtifact(record) + } catch { + // Ignore candidates that are not readable local files. + } + } +} + +function recordCommandArtifacts(session: SessionRecord, message: CodexMessage) { + if (message.role !== 'toolResult') return + if (message.toolName !== 'command_execution') return + const output = textFromMessage(message) + const runId = message.toolCallId || message.id + const createdAt = + typeof message.timestamp === 'number' && Number.isFinite(message.timestamp) + ? message.timestamp + : Date.now() + writeCommandLogArtifact({ session, runId, content: output, createdAt }) + const command = + typeof message.details?.command === 'string' ? message.details.command : '' + recordDetectedFileArtifacts({ + session, + runId, + command, + output, + createdAt, + }) +} + function emit(sessionKey: string, event: CodexStreamEvent) { const keys = new Set([sessionKey, deriveFriendlyIdFromKey(sessionKey)]) for (const key of keys) { @@ -2170,6 +2452,66 @@ export function listCodexTasks() { } } +export function listCodexArtifacts(input: { + sessionKey?: string + friendlyId?: string +}) { + const key = input.sessionKey || input.friendlyId || '' + const session = key ? findSession(readStore(), key) : null + const sessionKeys = new Set() + if (session) { + sessionKeys.add(session.key) + sessionKeys.add(session.friendlyId) + } else if (key) { + sessionKeys.add(key) + sessionKeys.add(deriveFriendlyIdFromKey(key)) + } + const artifacts = readArtifactStore().artifacts + .filter((artifact) => { + if (sessionKeys.size === 0) return true + return sessionKeys.has(artifact.sessionKey) + }) + .filter((artifact) => existsSync(artifact.path)) + .sort((first, second) => second.createdAt - first.createdAt) + + return { + artifacts, + manifest: { + exportedAt: new Date().toISOString(), + sessionKey: session?.key ?? key, + artifactCount: artifacts.length, + artifacts: artifacts.map((artifact) => ({ + id: artifact.id, + type: artifact.type, + path: artifact.redactedPath, + createdAt: artifact.createdAt, + runId: artifact.runId, + safeToOpen: artifact.safeToOpen, + size: artifact.size, + source: artifact.source, + })), + }, + } +} + +export function getCodexArtifactFile(input: { + id: string + sessionKey?: string + friendlyId?: string +}) { + const payload = listCodexArtifacts({ + sessionKey: input.sessionKey, + friendlyId: input.friendlyId, + }) + const artifact = payload.artifacts.find((item) => item.id === input.id) + if (!artifact) throw new Error('Artifact not found.') + if (!artifact.safeToOpen) throw new Error('Artifact is not safe to open.') + return { + artifact, + content: readFileSync(artifact.path), + } +} + export function cancelCodexTask(taskId: string) { const id = taskId.trim() if (!id) throw new Error('task id required') @@ -2295,6 +2637,7 @@ export function resetCodexServerStateForTests() { storeCache = null workspaceStoreCache = null taskStoreCache = null + artifactStoreCache = null runningTasks.clear() stateVersion = 0 }