From e88d3a1e985658520e7cfd22d7b295676cbc687c Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 25 May 2026 21:29:00 +0300 Subject: [PATCH] feat(team): open persisted attachments in editor --- src/main/services/team/TeamAttachmentStore.ts | 1 + src/main/utils/pathValidation.ts | 12 ++- .../team/attachments/AttachmentDisplay.tsx | 29 ++++++- src/shared/types/team.ts | 2 + .../utils/electronUserDataMigration.test.ts | 14 ++-- test/main/utils/pathValidation.test.ts | 22 ++++- .../attachments/AttachmentDisplay.test.tsx | 81 +++++++++++++++++++ 7 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 test/renderer/components/team/attachments/AttachmentDisplay.test.tsx diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 8b78943f..1b0b05ae 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -217,6 +217,7 @@ export class TeamAttachmentStore { id: entry.id, data: buffer.toString('base64'), mimeType, + filePath, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index 9e58601d..5590b9cd 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -11,7 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { getClaudeBasePath, getHomeDir } from './pathDecoder'; +import { getAppDataPath, getClaudeBasePath, getHomeDir } from './pathDecoder'; /** * Sensitive file patterns that should never be accessible. @@ -101,6 +101,7 @@ export function matchesSensitivePattern(normalizedPath: string): boolean { * Allowed directories: * - The project path itself * - The ~/.claude directory (for session data) + * - The app-owned data directory (attachments, task attachments) * * @param normalizedPath - The normalized absolute path to check * @param projectPath - The project root path (can be null for global access) @@ -114,12 +115,19 @@ export function isPathWithinAllowedDirectories( const normalizedTarget = normalizeForCompare(normalizedPath, isWindows); const claudeDir = getClaudeBasePath(); const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows); + const appDataDir = getAppDataPath(); + const normalizedAppDataDir = normalizeForCompare(appDataDir, isWindows); // Always allow access to ~/.claude for session data if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) { return true; } + // Allow app-owned persisted data such as message attachment files. + if (isPathWithinRoot(normalizedTarget, normalizedAppDataDir)) { + return true; + } + // If project path provided, allow access within project if (projectPath) { const normalizedProjectPath = normalizeForCompare(projectPath, isWindows); @@ -137,7 +145,7 @@ export function isPathWithinAllowedDirectories( * Security checks performed: * 1. Path must be absolute * 2. Path traversal prevention (no ..) - * 3. Must be within allowed directories (project or ~/.claude) + * 3. Must be within allowed directories (project, ~/.claude, or app data) * 4. Must not match sensitive file patterns * * @param filePath - The file path to validate diff --git a/src/renderer/components/team/attachments/AttachmentDisplay.tsx b/src/renderer/components/team/attachments/AttachmentDisplay.tsx index dd03cf65..0cff8048 100644 --- a/src/renderer/components/team/attachments/AttachmentDisplay.tsx +++ b/src/renderer/components/team/attachments/AttachmentDisplay.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; +import { useStore } from '@renderer/store'; import { isImageMime } from '@renderer/utils/attachmentUtils'; import { Loader2 } from 'lucide-react'; @@ -22,6 +23,7 @@ export const AttachmentDisplay = ({ attachments, }: AttachmentDisplayProps): React.JSX.Element | null => { const { t } = useAppTranslation('team'); + const revealFileInEditor = useStore((s) => s.revealFileInEditor); const [state, setState] = useState<{ loaded: AttachmentFileData[]; loading: boolean; @@ -74,10 +76,16 @@ export const AttachmentDisplay = ({ return { meta, dataUrl: isImage ? `data:${data.mimeType};base64,${data.data}` : undefined, + filePath: data.filePath ?? meta.filePath, isImage, }; }) - .filter(Boolean) as { meta: AttachmentMeta; dataUrl: string | undefined; isImage: boolean }[]; + .filter(Boolean) as { + meta: AttachmentMeta; + dataUrl: string | undefined; + filePath: string | undefined; + isImage: boolean; + }[]; if (items.length === 0) return null; @@ -107,10 +115,29 @@ export const AttachmentDisplay = ({ : undefined } /> + ) : item.filePath ? ( + ) : (
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index cff49c08..7d690285 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -591,6 +591,8 @@ export interface AttachmentFileData { id: string; data: string; mimeType: AttachmentMediaType; + /** Absolute path to the persisted attachment file when available. */ + filePath?: string; } /** Lightweight metadata for a single tool call (for UI display in tooltips). */ diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index 3b00d4ff..a7ee7e17 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -1,11 +1,16 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, describe, expect, it } from 'vitest'; import { TeamAttachmentStore } from '../../../src/main/services/team/TeamAttachmentStore'; import { TeamTaskAttachmentStore } from '../../../src/main/services/team/TeamTaskAttachmentStore'; +import { + type ElectronUserDataMigrationApp, + getLegacyElectronUserDataCandidates, + migrateElectronUserDataDirectory, + shouldCopyElectronUserDataEntry, +} from '../../../src/main/utils/electronUserDataMigration'; import { getAppDataPath, getBackupsBasePath, @@ -13,12 +18,6 @@ import { getMcpServerBasePath, setAppDataBasePath, } from '../../../src/main/utils/pathDecoder'; -import { - getLegacyElectronUserDataCandidates, - migrateElectronUserDataDirectory, - shouldCopyElectronUserDataEntry, - type ElectronUserDataMigrationApp, -} from '../../../src/main/utils/electronUserDataMigration'; class FakeElectronApp implements ElectronUserDataMigrationApp { setPathCalls: { name: string; value: string }[] = []; @@ -407,6 +406,7 @@ describe('electron userData migration', () => { id: 'att-1', data: Buffer.from('message attachment').toString('base64'), mimeType: 'text/plain', + filePath: path.join(currentPath, 'data', 'attachments', 'team-a', 'msg-1', 'att-1--note.txt'), }, ]); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts index 9965f72f..de8bbb77 100644 --- a/test/main/utils/pathValidation.test.ts +++ b/test/main/utils/pathValidation.test.ts @@ -7,11 +7,14 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getHomeDir, setClaudeBasePathOverride } from '../../../src/main/utils/pathDecoder'; - import { - isPathWithinRoot, + getHomeDir, + setAppDataBasePath, + setClaudeBasePathOverride, +} from '../../../src/main/utils/pathDecoder'; +import { isPathWithinAllowedDirectories, + isPathWithinRoot, isWindowsReservedFileName, validateFileName, validateFilePath, @@ -22,14 +25,18 @@ import { describe('pathValidation', () => { const homeDir = getHomeDir(); const claudeDir = path.join(homeDir, '.claude'); + const appDataBasePath = path.join(homeDir, '.agent-teams-ai-test'); + const appDataPath = path.join(appDataBasePath, 'data'); const testProjectPath = path.resolve('/home/user/my-project'); beforeEach(() => { setClaudeBasePathOverride(claudeDir); + setAppDataBasePath(appDataBasePath); }); afterEach(() => { setClaudeBasePathOverride(null); + setAppDataBasePath(null); }); describe('isPathWithinAllowedDirectories', () => { @@ -48,6 +55,15 @@ describe('pathValidation', () => { ).toBe(true); }); + it('should allow paths within app-owned data directory', () => { + expect( + isPathWithinAllowedDirectories( + path.join(appDataPath, 'attachments', 'team-a', 'msg-1', 'att-1--note.txt'), + testProjectPath + ) + ).toBe(true); + }); + it('should reject paths outside allowed directories', () => { expect(isPathWithinAllowedDirectories('/etc/passwd', testProjectPath)).toBe(false); }); diff --git a/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx b/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx new file mode 100644 index 00000000..a942c317 --- /dev/null +++ b/test/renderer/components/team/attachments/AttachmentDisplay.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const storeMocks = vi.hoisted(() => ({ + revealFileInEditor: vi.fn(), +})); + +vi.mock('@features/localization/renderer', () => ({ + useAppTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@renderer/components/team/editor/FileIcon', () => ({ + FileIcon: ({ fileName }: { fileName: string }) => React.createElement('span', null, fileName), +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: { revealFileInEditor: (filePath: string) => void }) => unknown) => + selector({ revealFileInEditor: storeMocks.revealFileInEditor }), +})); + +describe('AttachmentDisplay', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + storeMocks.revealFileInEditor.mockReset(); + }); + + it('opens persisted non-image attachments in the built-in editor', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const getAttachments = vi.fn().mockResolvedValue([ + { + id: 'att-1', + data: Buffer.from('verification').toString('base64'), + mimeType: 'text/markdown', + filePath: '/app/data/attachments/team-a/msg-1/att-1--verification.md', + }, + ]); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { teams: { getAttachments } }, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + ); + }); + await act(async () => { + await Promise.resolve(); + }); + + const button = host.querySelector('button[aria-label="Open verification.md"]'); + expect(button).not.toBeNull(); + + await act(async () => { + button?.click(); + }); + + expect(storeMocks.revealFileInEditor).toHaveBeenCalledWith( + '/app/data/attachments/team-a/msg-1/att-1--verification.md' + ); + }); +});