Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/buddy/electron/main/__tests__/mediaProtocol.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Buffer } from 'node:buffer'
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { serveMediaFile } from '../serveMediaFile'

const cleanup: Array<() => Promise<void>> = []
afterEach(async () => {
for (const dispose of cleanup.splice(0)) await dispose()
})
async function directory() {
const root = await mkdtemp(join(tmpdir(), 'buddy-media-'))
cleanup.push(() => rm(root, { recursive: true, force: true }))
return root
}

describe('media protocol', () => {
it('streams the whole file with range support advertised', async () => {
const path = join(await directory(), 'clip.mp4')
await writeFile(path, Buffer.from('0123456789'))
const response = await serveMediaFile(path, null)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('video/mp4')
expect(response.headers.get('accept-ranges')).toBe('bytes')
expect(response.headers.get('content-length')).toBe('10')
expect(await response.text()).toBe('0123456789')
})

it('answers byte ranges with 206 and only the requested bytes', async () => {
const path = join(await directory(), 'song.mp3')
await writeFile(path, Buffer.from('0123456789'))
const middle = await serveMediaFile(path, 'bytes=2-5')
expect(middle.status).toBe(206)
expect(middle.headers.get('content-range')).toBe('bytes 2-5/10')
expect(await middle.text()).toBe('2345')
const tail = await serveMediaFile(path, 'bytes=-3')
expect(tail.headers.get('content-range')).toBe('bytes 7-9/10')
expect(await tail.text()).toBe('789')
})

it('rejects unsatisfiable ranges, unknown types and empty files cleanly', async () => {
const root = await directory()
await writeFile(join(root, 'clip.webm'), Buffer.from('abc'))
await writeFile(join(root, 'empty.ogg'), '')
await writeFile(join(root, 'notes.txt'), 'abc')
const unsatisfiable = await serveMediaFile(join(root, 'clip.webm'), 'bytes=10-')
expect(unsatisfiable.status).toBe(416)
expect(unsatisfiable.headers.get('content-range')).toBe('bytes */3')
expect((await serveMediaFile(join(root, 'notes.txt'), null)).status).toBe(415)
const empty = await serveMediaFile(join(root, 'empty.ogg'), null)
expect(empty.status).toBe(200)
expect(empty.headers.get('content-length')).toBe('0')
})

it('does not follow a link at the leaf', async () => {
const root = await directory()
await writeFile(join(root, 'secret.mp4'), 'secret')
try {
await symlink(join(root, 'secret.mp4'), join(root, 'link.mp4'))
}
catch { return }
expect((await serveMediaFile(join(root, 'link.mp4'), null)).status).toBe(404)
})
})
2 changes: 2 additions & 0 deletions apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { LexoraConfigStore } from '../config/LexoraConfigStore'
import { ContextPanelHost } from '../context-panel/ContextPanelHost'
import { DesktopPerformanceMonitor } from '../diagnostics/DesktopPerformanceMonitor'
import { registerExtensionAuthoringRpc } from '../extensions/registerExtensionAuthoringRpc'
import { installMediaProtocol } from '../mediaProtocol'
import { DesktopNetwork } from '../network/DesktopNetwork'
import { registerWebHostRpc } from '../network/registerWebHostRpc'
import { createDesktopFeatures } from '../platform/desktopFeatures'
Expand Down Expand Up @@ -279,6 +280,7 @@ export class DesktopRuntimeHost {
service.start()
this.performance.start()
this.#subscriptions.push(installAttachmentProtocol(service))
this.#subscriptions.push(installMediaProtocol(service))
this.#subscriptions.push(installRendererProtocol())
}

Expand Down
3 changes: 2 additions & 1 deletion apps/buddy/electron/main/app/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { OPERATING_SYSTEM } from '../../../shared/platform/identifiers'
import { attachmentSchemePrivileges } from '../attachmentProtocol'
import { DesktopDiagnosticLogger } from '../desktopDiagnostics'
import { extensionSchemePrivileges } from '../extensions/ExtensionProtocol'
import { mediaSchemePrivileges } from '../mediaProtocol'
import { resolveBuddyRuntimePaths } from '../paths'
import { desktopHosts } from '../platform/desktopHost'
import { rendererSchemePrivileges } from '../rendererProtocol'
Expand Down Expand Up @@ -130,7 +131,7 @@ export function initializeDesktopEnvironment(environment: DesktopEnvironment): v
}
}
bootstrapStep('register_protocols', () => {
protocol.registerSchemesAsPrivileged([...attachmentSchemePrivileges, rendererSchemePrivileges, extensionSchemePrivileges, fileIconSchemePrivileges, themeSchemePrivileges])
protocol.registerSchemesAsPrivileged([...attachmentSchemePrivileges, rendererSchemePrivileges, mediaSchemePrivileges, extensionSchemePrivileges, fileIconSchemePrivileges, themeSchemePrivileges])
})
bootstrapStep('desktop_identity', () => desktopHosts[currentPlatform.id].setIdentity?.(paths.desktopName))
}
Expand Down
5 changes: 5 additions & 0 deletions apps/buddy/electron/main/attachmentProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { resolveBuddyFileReader } from '../../platform/native/nativeHost'
import { artifactsRequestSchemas, artifactsResponseSchemas } from '../../shared/artifacts/artifactApi'
import { attachmentsRequestSchemas } from '../../shared/conversation/attachmentApi'
import { composerResourcesRpc } from '../../shared/conversation/composerApi'
import { mediaKindOf } from '../../shared/files/mediaFiles'
import { serveMediaFile } from './serveMediaFile'

const ATTACHMENT_PROTOCOL = 'lexora-attachment'
const ARTIFACT_PROTOCOL = 'lexora-artifact'
Expand Down Expand Up @@ -54,6 +56,9 @@ export function installAttachmentProtocol(runtime: BuddyServiceSupervisor): () =
const preview = artifactsResponseSchemas.artifactPreview.parse(
await runtime.request(method, input),
)
// Audio and video are streamed in ranges instead of being read whole, so a long recording can be seeked.
if (scheme === ARTIFACT_PROTOCOL && mediaKindOf(preview.path))
return await serveMediaFile(preview.path, request.headers.get('range'), request.signal)
const body = await readFile(preview.path)
return new Response(body, {
headers: {
Expand Down
31 changes: 31 additions & 0 deletions apps/buddy/electron/main/mediaProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { BuddyServiceSupervisor } from './runtime/BuddyServiceSupervisor'
import { protocol } from 'electron'
import { MEDIA_PROTOCOL, parseMediaUrl } from '../../shared/files/mediaFiles'
import { spaceFilesRpc } from '../../shared/spaces/spaceFileApi'
import { serveMediaFile } from './serveMediaFile'

export const mediaSchemePrivileges: Electron.CustomScheme = {
scheme: MEDIA_PROTOCOL,
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true },
}

export function installMediaProtocol(runtime: Pick<BuddyServiceSupervisor, 'request'>): () => void {
protocol.handle(MEDIA_PROTOCOL, async (request) => {
if (request.method !== 'GET')
return new Response(null, { status: 405 })
const target = parseMediaUrl(request.url)
if (!target)
return new Response(null, { status: 400 })
try {
// The service re-checks the space, directory grant and revision on every request, as it does for any file read.
const located = spaceFilesRpc.locate.response.parse(await runtime.request(spaceFilesRpc.locate.method, spaceFilesRpc.locate.input.parse(target)))
if (located.kind !== 'file')
return new Response(null, { status: 404 })
return await serveMediaFile(located.path, request.headers.get('range'), request.signal)
}
catch {
return new Response(null, { status: 404 })
}
})
return () => protocol.unhandle(MEDIA_PROTOCOL)
}
53 changes: 53 additions & 0 deletions apps/buddy/electron/main/serveMediaFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { lstat, open } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { mediaMimeType, parseMediaRange } from '../../shared/files/mediaFiles'

// Streams a local audio or video file with HTTP Range support, so media elements can seek without loading it whole.
export async function serveMediaFile(path: string, rangeHeader: string | null, signal?: AbortSignal): Promise<Response> {
const mimeType = mediaMimeType(path)
if (!mimeType)
return new Response(null, { status: 415 })
// The path is canonical when located; refusing a leaf that has since become a link narrows the swap window.
const linked = await lstat(path)
if (!linked.isFile())
return new Response(null, { status: 404 })
const handle = await open(path, 'r')
try {
const metadata = await handle.stat()
if (!metadata.isFile() || metadata.size !== linked.size) {
await handle.close()
return new Response(null, { status: 404 })
}
const headers: Record<string, string> = {
'accept-ranges': 'bytes',
'cache-control': 'private, no-store',
'content-type': mimeType,
'x-content-type-options': 'nosniff',
}
const range = rangeHeader ? parseMediaRange(rangeHeader, metadata.size) : null
if (range === 'unsatisfiable') {
await handle.close()
return new Response(null, { status: 416, headers: { ...headers, 'content-range': `bytes */${metadata.size}` } })
}
if (metadata.size === 0) {
await handle.close()
return new Response(null, { status: 200, headers: { ...headers, 'content-length': '0' } })
}
const start = range?.start ?? 0
const end = range?.end ?? metadata.size - 1
const stream = handle.createReadStream({ start, end, autoClose: true })
signal?.addEventListener('abort', () => stream.destroy(), { once: true })
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
status: range ? 206 : 200,
headers: {
...headers,
'content-length': String(end - start + 1),
...(range ? { 'content-range': `bytes ${start}-${end}/${metadata.size}` } : {}),
},
})
}
catch (error) {
await handle.close().catch(() => {})
throw error
}
}
2 changes: 1 addition & 1 deletion apps/buddy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' lexora-extension:; img-src 'self' data: lexora-attachment: lexora-artifact: lexora-icon: lexora-theme: lexora-extension:; connect-src 'self'; font-src 'self' data: lexora-extension:; object-src 'none'; base-uri 'none'; frame-src lexora-extension:; form-action 'none'" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' lexora-extension:; media-src 'self' lexora-media: lexora-artifact:; img-src 'self' data: lexora-attachment: lexora-artifact: lexora-icon: lexora-theme: lexora-extension:; connect-src 'self'; font-src 'self' data: lexora-extension:; object-src 'none'; base-uri 'none'; frame-src lexora-extension:; form-action 'none'" />
<title>Lexora</title>
<link rel="preload" href="/src/assets/fonts/lxgw-wenkai-regular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/src/assets/fonts/lxgw-wenkai-medium.woff2" as="font" type="font/woff2" crossorigin />
Expand Down
14 changes: 12 additions & 2 deletions apps/buddy/service/src/artifacts/ArtifactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import { relativeCanonicalPath } from '../../../platform/filesystem/filePaths'
import { Emitter } from '../../../shared/events/Emitter'
import { copyEventSnapshot } from '../../../shared/events/eventSnapshot'
import { mediaMimeType } from '../../../shared/files/mediaFiles'
import { resolveGrantedPath } from '../directories/resolveGrantedPath'

export const BUDDY_ARTIFACT_COUNT_LIMIT = 512
Expand Down Expand Up @@ -355,7 +356,13 @@ export class ArtifactService {

resolvePreview(id: string): { mimeType: string, path: string } {
const artifact = this.#requireVisibleArtifact(id)
if (artifact.kind !== 'file' || !artifact.mimeType.startsWith('image/'))
if (artifact.kind !== 'file')
throw new ArtifactError('VALIDATION_FAILED')
// Audio and video are served in ranges by the protocol; their content type comes from the extension, which the artifact record does not map.
const mimeType = mediaMimeType(artifact.currentPath)
if (mimeType)
return { mimeType, path: artifact.currentPath }
if (!artifact.mimeType.startsWith('image/'))
throw new ArtifactError('VALIDATION_FAILED')
return { mimeType: artifact.mimeType, path: artifact.currentPath }
}
Expand All @@ -373,7 +380,10 @@ export class ArtifactService {
) {
throw new ArtifactError('VALIDATION_FAILED')
}
const bytes = await readFile(artifact.currentPath)
// The record keeps the path the file had when it was presented; it may have been moved or deleted since.
const bytes = await readFile(artifact.currentPath).catch((error: NodeJS.ErrnoException) => {
throw error.code === 'ENOENT' ? new ArtifactError('FILE_NOT_FOUND') : error
})
if (bytes.byteLength > BUDDY_ARTIFACT_TEXT_BYTES_LIMIT)
throw new ArtifactError('ARTIFACT_SIZE_LIMIT')
let text: string
Expand Down
19 changes: 19 additions & 0 deletions apps/buddy/service/src/artifacts/__tests__/ArtifactService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,25 @@ describe('artifactService', () => {
expect(fixture.service.listConversationArtifacts('conversation-1')).toHaveLength(2)
})

it('resolves audio and video outputs for streaming but still refuses other binary files', async () => {
const fixture = await createFixture()
const paths = ['voice.MP3', 'clip.mp4', 'archive.zip'].map(name => join(fixture.workspace, name))
await Promise.all(paths.map(path => writeFile(path, Buffer.from([1, 2, 3]))))
const [voice, clip, archive] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths })
expect(fixture.service.resolvePreview(voice!.id)).toEqual({ mimeType: 'audio/mpeg', path: voice!.currentPath })
expect(fixture.service.resolvePreview(clip!.id)).toEqual({ mimeType: 'video/mp4', path: clip!.currentPath })
expect(() => fixture.service.resolvePreview(archive!.id)).toThrow(/artifact operation failed/i)
})

it('reports an output whose file was moved or deleted as not found', async () => {
const fixture = await createFixture()
const path = join(fixture.workspace, 'report.md')
await writeFile(path, '# report\n')
const [artifact] = await fixture.service.presentOutputs({ conversationId: 'conversation-1', cwd: fixture.workspace, grants: fixture.grants, paths: [path] })
await rm(path)
await expect(fixture.service.readText(artifact!.id)).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
})

it.each(['report.md', 'REPORT.MD', 'report.markdown'])('reads %s as Markdown without changing the file', async (name) => {
const fixture = await createFixture()
const path = join(fixture.workspace, name)
Expand Down
1 change: 1 addition & 0 deletions apps/buddy/service/src/rpc/runtimeRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type BuddyServiceErrorCode
| 'DIRECTORY_NOT_AUTHORIZED'
| 'MODEL_INPUT_UNSUPPORTED'
| 'MODEL_INPUT_TOO_LARGE'
| 'FILE_NOT_FOUND'
| 'SPACE_UNAVAILABLE'
| 'VALIDATION_FAILED'

Expand Down
24 changes: 22 additions & 2 deletions apps/buddy/service/src/spaces/SpaceFileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Buffer } from 'node:buffer'
import { stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { Emitter } from '../../../shared/events/Emitter'
import { mediaKindOf } from '../../../shared/files/mediaFiles'
import { resolveGrantedPath } from '../directories/resolveGrantedPath'
import { readFilePreview } from '../files/readFilePreview'
import { BuddyServiceError } from '../rpc/runtimeRequest'
Expand Down Expand Up @@ -51,9 +52,18 @@ export class SpaceFileService {
}

async read(input: SpaceFileTarget): Promise<LocalSpaceFilePreview> {
const target = await this.resolve(input)
const target = await this.resolve(input).catch(rethrowMissingFile)
this.requireDirectory(input)
const preview = await readFilePreview(target.root, target.path)
const media = mediaKindOf(target.path)
if (media) {
// Audio and video are streamed by the media protocol, so the size limits that apply to inline previews do not.
const metadata = await stat(target.path).catch(rethrowMissingFile)
if (!metadata.isFile())
throw new BuddyServiceError('VALIDATION_FAILED')
this.requireDirectory(input)
return { kind: media, sizeBytes: metadata.size, text: null, imageUrl: null }
}
const preview = await readFilePreview(target.root, target.path).catch(rethrowMissingFile)
this.requireDirectory(input)
return preview
}
Expand Down Expand Up @@ -235,3 +245,13 @@ export class SpaceFileService {
return { path: target.canonicalPath, root: directory.canonicalRoot }
}
}

// A file moved or deleted after it was listed is reported on its own, so the preview can say so.
function rethrowMissingFile(error: unknown): never {
for (let current = error, depth = 0; current && typeof current === 'object' && depth < 5; current = (current as { cause?: unknown }).cause, depth++) {
const code = (current as { code?: unknown }).code
if (code === 'ENOENT' || code === 'PATH_NOT_FOUND')
throw new BuddyServiceError('FILE_NOT_FOUND')
}
throw error
}
18 changes: 18 additions & 0 deletions apps/buddy/service/src/spaces/__tests__/SpaceFileService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ describe('space file browsing', () => {
expect(await f.files.read({ ...f.target, path: 'large.txt' })).toMatchObject({ kind: 'oversized', text: null })
})

it('previews audio and video by type and size without reading their content', async () => {
const f = await fixture()
await writeFile(join(f.workspace, 'clip.MP4'), Buffer.alloc(2 * 1024 * 1024, 1))
await writeFile(join(f.workspace, 'song.mp3'), Buffer.from([1, 2, 3]))
expect(await f.files.read({ ...f.target, path: 'clip.MP4' })).toEqual({ kind: 'video', sizeBytes: 2 * 1024 * 1024, text: null, imageUrl: null })
expect(await f.files.read({ ...f.target, path: 'song.mp3' })).toEqual({ kind: 'audio', sizeBytes: 3, text: null, imageUrl: null })
await rm(join(f.workspace, 'song.mp3'))
await expect(f.files.read({ ...f.target, path: 'song.mp3' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
})

it('reports a file that was moved or deleted as not found', async () => {
const f = await fixture()
await writeFile(join(f.workspace, 'note.md'), '# note\n')
await expect(f.files.read({ ...f.target, path: 'note.md' })).resolves.toMatchObject({ kind: 'text' })
await rm(join(f.workspace, 'note.md'))
await expect(f.files.read({ ...f.target, path: 'note.md' })).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })
})

it('saves UTF-8 content without changing its BOM, rejects stale etags and rechecks grants', async () => {
const f = await fixture()
const path = join(f.workspace, 'note.md')
Expand Down
Loading