diff --git a/packages/app/src/attachments/local-file-attachment-store.test.ts b/packages/app/src/attachments/local-file-attachment-store.test.ts index d6aa4fdb1..a9cc02bc0 100644 --- a/packages/app/src/attachments/local-file-attachment-store.test.ts +++ b/packages/app/src/attachments/local-file-attachment-store.test.ts @@ -14,6 +14,21 @@ const fileSystemMock = vi.hoisted(() => ({ readAsStringAsync: vi.fn(async () => "AAECAw=="), deleteAsync: vi.fn(async () => {}), readDirectoryAsync: vi.fn(async () => []), + fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}), +})); + +vi.mock("expo-file-system", () => ({ + File: class { + uri: string; + + constructor(uri: string) { + this.uri = uri; + } + + write(content: Uint8Array) { + fileSystemMock.fileWrite(this.uri, content); + } + }, })); vi.mock("expo-file-system/legacy", () => ({ @@ -37,9 +52,10 @@ describe("local file attachment store", () => { fileSystemMock.readAsStringAsync.mockClear(); fileSystemMock.deleteAsync.mockClear(); fileSystemMock.readDirectoryAsync.mockClear(); + fileSystemMock.fileWrite.mockClear(); }); - it("writes raw base64 sources directly to the managed file path", async () => { + it("writes raw byte sources directly to the managed file path", async () => { const store = createLocalFileAttachmentStore({ storageType: "native-file", baseDirectoryName: "preview-assets", @@ -50,14 +66,14 @@ describe("local file attachment store", () => { id: "preview_8_test", mimeType: "image/png", fileName: "result.png", - source: { kind: "base64", base64: "AAECAw==" }, + source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) }, }); - expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith( + expect(fileSystemMock.fileWrite).toHaveBeenCalledWith( "file:///cache/preview-assets/preview_8_test.png", - "AAECAw==", - { encoding: "base64" }, + new Uint8Array([0, 1, 2, 3]), ); + expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled(); expect(attachment).toMatchObject({ id: "preview_8_test", mimeType: "image/png", diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index 98111d812..1ed2bd69e 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -1,3 +1,4 @@ +import { File } from "expo-file-system"; import * as FileSystem from "expo-file-system/legacy"; import { type AttachmentStore, @@ -6,7 +7,6 @@ import { type SaveAttachmentInput, } from "@/attachments/types"; import { - blobToBase64, fileUriToPath, generateAttachmentId, getFileExtensionFromName, @@ -45,6 +45,15 @@ async function ensureDirectory(uri: string): Promise { await FileSystem.makeDirectoryAsync(uri, { intermediates: true }); } +async function dataUrlToBytes(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return new Uint8Array(await response.arrayBuffer()); +} + +async function blobToBytes(blob: Blob): Promise { + return new Uint8Array(await blob.arrayBuffer()); +} + async function writeFromSource(input: { source: SaveAttachmentInput["source"]; targetUri: string; @@ -59,30 +68,16 @@ async function writeFromSource(input: { return; } + let bytes: Uint8Array; if (input.source.kind === "data_url") { - const parsed = parseDataUrl(input.source.dataUrl); - const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType); - const base64 = parsed.base64; - await FileSystem.writeAsStringAsync(input.targetUri, base64, { - encoding: FileSystem.EncodingType.Base64, - }); - if (mimeType !== input.mimeType) { - return; - } - return; + bytes = await dataUrlToBytes(input.source.dataUrl); + } else if (input.source.kind === "blob") { + bytes = await blobToBytes(input.source.blob); + } else { + bytes = input.source.bytes; } - if (input.source.kind === "base64") { - await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, { - encoding: FileSystem.EncodingType.Base64, - }); - return; - } - - const base64 = await blobToBase64(input.source.blob); - await FileSystem.writeAsStringAsync(input.targetUri, base64, { - encoding: FileSystem.EncodingType.Base64, - }); + new File(input.targetUri).write(bytes); } function attachmentUri(metadata: AttachmentMetadata): string { diff --git a/packages/app/src/attachments/service.test.ts b/packages/app/src/attachments/service.test.ts index 81e3002a5..080204ed0 100644 --- a/packages/app/src/attachments/service.test.ts +++ b/packages/app/src/attachments/service.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types"; import { __setAttachmentStoreForTests } from "./store"; -import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service"; +import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service"; function createAttachment(input: Partial = {}): AttachmentMetadata { return { @@ -54,19 +54,20 @@ describe("attachment service", () => { __setAttachmentStoreForTests(null); }); - it("persists raw base64 without requiring a data URL wrapper", async () => { + it("persists raw bytes without requiring a base64 wrapper", async () => { const store = createRecordingStore(); __setAttachmentStoreForTests(store); + const bytes = new Uint8Array([0, 1, 2, 3]); - const attachment = await persistAttachmentFromBase64({ - id: "att_base64", - base64: "AAECAw==", + const attachment = await persistAttachmentFromBytes({ + id: "att_bytes", + bytes, mimeType: "image/png", fileName: "image.png", }); expect(attachment).toEqual({ - id: "att_base64", + id: "att_bytes", mimeType: "image/png", storageType: "web-indexeddb", storageKey: "att_1", @@ -76,10 +77,10 @@ describe("attachment service", () => { }); expect(store.savedSources).toEqual([ { - id: "att_base64", + id: "att_bytes", mimeType: "image/png", fileName: "image.png", - source: { kind: "base64", base64: "AAECAw==" }, + source: { kind: "bytes", bytes }, }, ]); }); diff --git a/packages/app/src/attachments/service.ts b/packages/app/src/attachments/service.ts index c44b64339..15cff1ee2 100644 --- a/packages/app/src/attachments/service.ts +++ b/packages/app/src/attachments/service.ts @@ -31,8 +31,8 @@ export async function persistAttachmentFromDataUrl(input: { }); } -export async function persistAttachmentFromBase64(input: { - base64: string; +export async function persistAttachmentFromBytes(input: { + bytes: Uint8Array; mimeType?: string; fileName?: string | null; id?: string; @@ -42,7 +42,7 @@ export async function persistAttachmentFromBase64(input: { id: input.id, mimeType: input.mimeType, fileName: input.fileName, - source: { kind: "base64", base64: input.base64 }, + source: { kind: "bytes", bytes: input.bytes }, }); } diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index f82423225..b6f01658b 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -33,7 +33,7 @@ export type UserComposerAttachment = Exclude; export type AttachmentDataSource = - | { kind: "base64"; base64: string } + | { kind: "bytes"; bytes: Uint8Array } | { kind: "blob"; blob: Blob } | { kind: "data_url"; dataUrl: string } | { kind: "file_uri"; uri: string }; diff --git a/packages/app/src/attachments/web/indexeddb-attachment-store.test.ts b/packages/app/src/attachments/web/indexeddb-attachment-store.test.ts new file mode 100644 index 000000000..618824277 --- /dev/null +++ b/packages/app/src/attachments/web/indexeddb-attachment-store.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store"; + +type Listener = () => void; + +class FakeRequest { + result!: T; + error: Error | null = null; + private listeners = new Map(); + + addEventListener(event: string, listener: Listener): void { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); + } + + emit(event: string): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(); + } + } +} + +class FakeObjectStore { + constructor(private readonly onPut: (record: unknown) => void) {} + + put(record: unknown): FakeRequest { + this.onPut(record); + const request = new FakeRequest(); + queueMicrotask(() => request.emit("success")); + return request; + } +} + +class FakeTransaction { + error: Error | null = null; + + constructor(private readonly store: FakeObjectStore) {} + + objectStore(): FakeObjectStore { + return this.store; + } + + addEventListener(): void {} +} + +class FakeDatabase { + readonly objectStoreNames = { + contains: () => true, + }; + + constructor(private readonly store: FakeObjectStore) {} + + createObjectStore(): void {} + + transaction(): FakeTransaction { + return new FakeTransaction(this.store); + } + + close(): void {} +} + +describe("indexeddb attachment store", () => { + let storedRecord: unknown; + + beforeEach(() => { + storedRecord = null; + Object.defineProperty(globalThis, "indexedDB", { + configurable: true, + value: { + open: () => { + const store = new FakeObjectStore((record) => { + storedRecord = record; + }); + const request = new FakeRequest(); + request.result = new FakeDatabase(store); + queueMicrotask(() => request.emit("success")); + return request; + }, + } as unknown as IDBFactory, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(globalThis, "indexedDB"); + }); + + it("stores raw byte sources as a Blob", async () => { + const store = createIndexedDbAttachmentStore(); + const bytes = new Uint8Array([0, 1, 2, 3]); + + const attachment = await store.save({ + id: "att_bytes", + mimeType: "image/png", + fileName: "image.png", + source: { kind: "bytes", bytes }, + }); + + expect(storedRecord).toEqual({ + id: "att_bytes", + blob: new Blob([bytes], { type: "image/png" }), + createdAt: expect.any(Number), + fileName: "image.png", + }); + expect(attachment).toMatchObject({ + id: "att_bytes", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "att_bytes", + fileName: "image.png", + byteSize: 4, + }); + }); +}); diff --git a/packages/app/src/attachments/web/indexeddb-attachment-store.ts b/packages/app/src/attachments/web/indexeddb-attachment-store.ts index 7d5ee4483..2811c050e 100644 --- a/packages/app/src/attachments/web/indexeddb-attachment-store.ts +++ b/packages/app/src/attachments/web/indexeddb-attachment-store.ts @@ -74,17 +74,18 @@ function runTx( }); } -function base64ToBlob(input: { base64: string; mimeType: string }): Blob { - const binary = atob(input.base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return new Blob([bytes], { type: input.mimeType }); -} - async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> { const source = input.source; + if (source.kind === "bytes") { + const mimeType = normalizeMimeType(input.mimeType); + const buffer = new ArrayBuffer(source.bytes.byteLength); + new Uint8Array(buffer).set(source.bytes); + return { + blob: new Blob([buffer], { type: mimeType }), + mimeType, + }; + } + if (source.kind === "blob") { const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type); const blob = @@ -105,14 +106,6 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m }; } - if (source.kind === "base64") { - const mimeType = normalizeMimeType(input.mimeType); - return { - blob: base64ToBlob({ base64: source.base64, mimeType }), - mimeType, - }; - } - const response = await fetch(source.uri); const blob = await response.blob(); const mimeType = normalizeMimeType(input.mimeType ?? blob.type); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e93f0a64c..9b7fdbe10 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { FileReadResult } from "@server/client/daemon-client"; import Markdown, { MarkdownIt } from "react-native-markdown-display"; import { ActivityIndicator, @@ -27,8 +28,9 @@ import { isWeb } from "@/constants/platform"; import { createMarkdownStyles } from "@/styles/markdown-styles"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { persistAttachmentFromBase64 } from "@/attachments/service"; +import { persistAttachmentFromBytes } from "@/attachments/service"; import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils"; +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; interface CodeLineProps { tokens: HighlightToken[]; @@ -65,30 +67,34 @@ function formatFileSize({ size }: { size: number }): string { return `${(size / (1024 * 1024)).toFixed(1)} MB`; } -async function createFilePanePreview(file: ExplorerFile | null): Promise<{ +async function createFilePanePreview(file: FileReadResult | null): Promise<{ file: ExplorerFile | null; imageAttachment: AttachmentMetadata | null; }> { - if (!file || file.kind !== "image" || !file.content) { - return { file, imageAttachment: null }; + if (!file) { + return { file: null, imageAttachment: null }; } - const { content: _content, ...imageFile } = file; - const imageAttachment = await persistAttachmentFromBase64({ + const explorerFile = explorerFileFromReadResult(file); + if (file.kind !== "image") { + return { file: explorerFile, imageAttachment: null }; + } + + const imageAttachment = await persistAttachmentFromBytes({ id: createPreviewAttachmentId({ - mimeType: file.mimeType ?? "image/png", + mimeType: file.mime, path: file.path, size: file.size, modifiedAt: file.modifiedAt, - contentLength: file.content.length, + contentLength: file.bytes.byteLength, }), - base64: file.content, - mimeType: file.mimeType, + bytes: file.bytes, + mimeType: file.mime, fileName: getFileNameFromPath(file.path), }); return { - file: imageFile, + file: explorerFile, imageAttachment, }; } @@ -358,17 +364,21 @@ export function FilePane({ if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) { return { file: null as ExplorerFile | null, error: "Host is not connected" }; } - const payload = await client.exploreFileSystem( - normalizedWorkspaceRoot, - normalizedFilePath, - "file", - ); - const preview = await createFilePanePreview(payload.file ?? null); - return { - file: preview.file, - imageAttachment: preview.imageAttachment, - error: payload.error ?? null, - }; + try { + const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath); + const preview = await createFilePanePreview(file); + return { + file: preview.file, + imageAttachment: preview.imageAttachment, + error: null, + }; + } catch (error) { + return { + file: null, + imageAttachment: null, + error: error instanceof Error ? error.message : "Failed to load file", + }; + } }, staleTime: 5_000, refetchOnMount: true, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 87cc9d4a8..c2d5fd4b4 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -98,7 +98,7 @@ import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service"; +import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; import type { DaemonClient } from "@server/client/daemon-client"; import { isWeb, isNative } from "@/constants/platform"; export type { InlinePathTarget } from "@/utils/inline-path"; @@ -760,25 +760,21 @@ function AssistantMarkdownImage({ return null; } - const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file"); - if (payload.error) { - throw new Error(payload.error); - } - const file = payload.file; - if (!file || file.kind !== "image" || !file.content) { + const file = await client.readFile(resolution.cwd, resolution.path); + if (file.kind !== "image") { throw new Error("Image preview unavailable."); } - return await persistAttachmentFromBase64({ + return await persistAttachmentFromBytes({ id: createPreviewAttachmentId({ - mimeType: file.mimeType ?? "image/png", + mimeType: file.mime, path: file.path || resolution.path, size: file.size, modifiedAt: file.modifiedAt, - contentLength: file.content.length, + contentLength: file.bytes.byteLength, }), - base64: file.content, - mimeType: file.mimeType, + bytes: file.bytes, + mimeType: file.mime, fileName: getFileNameFromPath(file.path || resolution.path), }); }, diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts index 1bc2ceb39..175d9d597 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.test.ts @@ -4,6 +4,7 @@ import { createDesktopAttachmentStore } from "./desktop-attachment-store"; const { copyDesktopAttachmentFileMock, writeDesktopAttachmentBase64Mock, + writeDesktopAttachmentBytesMock, deleteDesktopAttachmentFileMock, garbageCollectDesktopAttachmentFilesMock, readDesktopFileBase64Mock, @@ -18,6 +19,10 @@ const { path: "/managed/att_2.png", byteSize: 4, })), + writeDesktopAttachmentBytesMock: vi.fn(async () => ({ + path: "/managed/att_bytes.png", + byteSize: 4, + })), deleteDesktopAttachmentFileMock: vi.fn(async () => true), garbageCollectDesktopAttachmentFilesMock: vi.fn(async () => 0), readDesktopFileBase64Mock: vi.fn(async () => "AAECAw=="), @@ -28,6 +33,7 @@ const { vi.mock("./desktop-file-commands", () => ({ copyDesktopAttachmentFile: copyDesktopAttachmentFileMock, writeDesktopAttachmentBase64: writeDesktopAttachmentBase64Mock, + writeDesktopAttachmentBytes: writeDesktopAttachmentBytesMock, deleteDesktopAttachmentFile: deleteDesktopAttachmentFileMock, garbageCollectDesktopAttachmentFiles: garbageCollectDesktopAttachmentFilesMock, })); @@ -42,6 +48,7 @@ describe("desktop attachment store", () => { beforeEach(() => { copyDesktopAttachmentFileMock.mockClear(); writeDesktopAttachmentBase64Mock.mockClear(); + writeDesktopAttachmentBytesMock.mockClear(); deleteDesktopAttachmentFileMock.mockClear(); garbageCollectDesktopAttachmentFilesMock.mockClear(); readDesktopFileBase64Mock.mockClear(); @@ -86,28 +93,30 @@ describe("desktop attachment store", () => { }); }); - it("saves raw base64 sources via desktop filesystem writes", async () => { + it("saves raw byte sources via desktop filesystem writes", async () => { const store = createDesktopAttachmentStore(); + const bytes = new Uint8Array([0, 1, 2, 3]); const attachment = await store.save({ - id: "att_base64", + id: "att_bytes", mimeType: "image/png", fileName: "inline.png", source: { - kind: "base64", - base64: "AAECAw==", + kind: "bytes", + bytes, }, }); - expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({ - attachmentId: "att_base64", - base64: "AAECAw==", + expect(writeDesktopAttachmentBytesMock).toHaveBeenCalledWith({ + attachmentId: "att_bytes", + bytes, extension: ".png", }); + expect(writeDesktopAttachmentBase64Mock).not.toHaveBeenCalled(); expect(attachment).toMatchObject({ - id: "att_base64", + id: "att_bytes", mimeType: "image/png", storageType: "desktop-file", - storageKey: "/managed/att_2.png", + storageKey: "/managed/att_bytes.png", fileName: "inline.png", byteSize: 4, }); diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts index 779c09f64..579590e75 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts @@ -12,6 +12,7 @@ import { deleteDesktopAttachmentFile, garbageCollectDesktopAttachmentFiles, writeDesktopAttachmentBase64, + writeDesktopAttachmentBytes, } from "@/desktop/attachments/desktop-file-commands"; import { readDesktopFileBase64, @@ -132,6 +133,32 @@ async function saveDesktopAttachmentFromBase64(input: { }); } +async function saveDesktopAttachmentFromBytes(input: { + id: string; + bytes: Uint8Array; + mimeType: string; + fileName?: string | null; +}): Promise { + const extension = extensionForAttachment({ + fileName: input.fileName, + mimeType: input.mimeType, + }); + + const result = await writeDesktopAttachmentBytes({ + attachmentId: input.id, + bytes: input.bytes, + extension, + }); + + return toDesktopMetadata({ + id: input.id, + mimeType: input.mimeType, + storagePath: result.path, + byteSize: result.byteSize, + fileName: input.fileName, + }); +} + function assertDesktopAttachment(attachment: AttachmentMetadata): void { if (attachment.storageType !== "desktop-file") { throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`); @@ -166,11 +193,11 @@ export function createDesktopAttachmentStore(): AttachmentStore { }); } - if (input.source.kind === "base64") { + if (input.source.kind === "bytes") { const mimeType = normalizeMimeType(input.mimeType); - return await saveDesktopAttachmentFromBase64({ + return await saveDesktopAttachmentFromBytes({ id, - base64: input.source.base64, + bytes: input.source.bytes, mimeType, fileName, }); diff --git a/packages/app/src/desktop/attachments/desktop-file-commands.ts b/packages/app/src/desktop/attachments/desktop-file-commands.ts index b3ef197ec..643c2c50d 100644 --- a/packages/app/src/desktop/attachments/desktop-file-commands.ts +++ b/packages/app/src/desktop/attachments/desktop-file-commands.ts @@ -17,6 +17,18 @@ export async function writeDesktopAttachmentBase64(input: { }); } +export async function writeDesktopAttachmentBytes(input: { + attachmentId: string; + bytes: Uint8Array; + extension?: string | null; +}): Promise { + return await invokeDesktopCommand("write_attachment_bytes", { + attachmentId: input.attachmentId, + bytes: input.bytes, + extension: input.extension ?? null, + }); +} + export async function copyDesktopAttachmentFile(input: { attachmentId: string; sourcePath: string; diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts index 42f43e672..d3efb3122 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts @@ -1,14 +1,10 @@ import type { AttachmentMetadata } from "@/attachments/types"; import { fileUriToPath } from "@/attachments/utils"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { Buffer } from "buffer"; function base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + return new Uint8Array(Buffer.from(base64, "base64")); } const activeObjectUrls = new Set(); diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts new file mode 100644 index 000000000..bce6c7b30 --- /dev/null +++ b/packages/app/src/file-explorer/read-result.ts @@ -0,0 +1,15 @@ +import type { FileReadResult } from "@server/client/daemon-client"; +import type { ExplorerFile } from "@/stores/session-store"; + +export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { + const isText = file.kind === "text"; + return { + path: file.path, + kind: file.kind, + encoding: isText ? "utf-8" : "none", + content: isText ? new TextDecoder().decode(file.bytes) : undefined, + mimeType: file.mime, + size: file.size, + modifiedAt: file.modifiedAt, + }; +} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 932d07f6b..6cf587376 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; function createExplorerState(): AgentFileExplorerState { return { @@ -130,26 +131,20 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } try { - const payload = await client.exploreFileSystem( - normalizedWorkspaceRoot, - normalizedPath, - "list", - ); + const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, isLoading: false, - lastError: payload.error ?? null, + lastError: null, pendingRequest: null, directories: state.directories, files: state.files, }; - if (!payload.error && payload.directory) { - const directories = new Map(state.directories); - directories.set(payload.directory.path, payload.directory); - nextState.directories = directories; - } + const directories = new Map(state.directories); + directories.set(directory.path, directory); + nextState.directories = directories; return nextState; }); @@ -201,34 +196,29 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor } try { - const payload = await client.exploreFileSystem( - normalizedWorkspaceRoot, - normalizedPath, - "file", - ); + const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, isLoading: false, + lastError: null, pendingRequest: null, directories: state.directories, files: state.files, }; - if (!payload.error && payload.file) { - const files = new Map(state.files); - files.set(payload.file.path, payload.file); - nextState.files = files; - } else if (payload.error) { - nextState.lastError = payload.error; - } + const files = new Map(state.files); + const explorerFile = explorerFileFromReadResult(file); + files.set(explorerFile.path, explorerFile); + nextState.files = files; return nextState; }); - } catch { + } catch (error) { updateExplorerState((state) => ({ ...state, isLoading: false, + lastError: error instanceof Error ? error.message : "Failed to load file preview", pendingRequest: null, })); } diff --git a/packages/app/src/utils/image-attachments-from-files.test.ts b/packages/app/src/utils/image-attachments-from-files.test.ts index 5f64ee93d..4316567c9 100644 --- a/packages/app/src/utils/image-attachments-from-files.test.ts +++ b/packages/app/src/utils/image-attachments-from-files.test.ts @@ -31,7 +31,7 @@ function createTestStore(): AttachmentStore { } else if (input.source.kind === "file_uri") { byteSize = input.source.uri.length; } else { - byteSize = input.source.base64.length; + byteSize = input.source.bytes.byteLength; } return { id, diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index 0219eed80..581f70591 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, @@ -55,7 +56,7 @@ export async function runArchiveCommand( throw error; } - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host as string | undefined }); } catch (err) { diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index 323eb46f6..91c78f25d 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; @@ -49,7 +50,7 @@ export async function runDeleteCommand( throw error; } - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host as string | undefined }); } catch (err) { diff --git a/packages/cli/src/commands/agent/reload.ts b/packages/cli/src/commands/agent/reload.ts index 7693f3b5c..653376a17 100644 --- a/packages/cli/src/commands/agent/reload.ts +++ b/packages/cli/src/commands/agent/reload.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, @@ -50,7 +51,7 @@ export async function runReloadCommand( throw error; } - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host as string | undefined }); } catch (err) { diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 370000a82..769017c1f 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; import type { @@ -53,7 +54,7 @@ export async function runStopCommand( throw error; } - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host as string | undefined }); } catch (err) { diff --git a/packages/cli/src/commands/provider/models.ts b/packages/cli/src/commands/provider/models.ts index 6384ee129..e5dde2d13 100644 --- a/packages/cli/src/commands/provider/models.ts +++ b/packages/cli/src/commands/provider/models.ts @@ -12,6 +12,18 @@ export interface ModelListItem { thinkingOptions: string; } +interface ProviderModelOption { + id: string; +} + +interface ProviderModel { + id: string; + label: string; + description?: string; + thinkingOptions?: ProviderModelOption[]; + defaultThinkingOptionId?: string | null; +} + /** Schema for provider models output */ export const providerModelsSchema: OutputSchema = { idField: "id", @@ -61,7 +73,7 @@ export async function runModelsCommand( }; } - const models: ModelListItem[] = (result.models ?? []).map((m) => ({ + const models: ModelListItem[] = (result.models ?? []).map((m: ProviderModel) => ({ model: m.label, id: m.id, description: m.description ?? "", diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index b3a6e518d..bcb6d90d4 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -7,6 +7,8 @@ import { } from "./shared.js"; import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; +type TerminalListEntry = Parameters[0]; + export interface TerminalLsOptions extends TerminalCommandOptions { all?: boolean; cwd?: string; @@ -24,7 +26,9 @@ export async function runLsCommand( cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); return { type: "list", - data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)), + data: payload.terminals.map((terminal: TerminalListEntry) => + toTerminalRow(terminal, payload.cwd ?? cwd), + ), schema: terminalSchema, }; } catch (err) { diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index f75350fd4..5793e6456 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -1,5 +1,6 @@ import path from "path"; import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandOptions, @@ -51,7 +52,7 @@ export async function runArchiveCommand( throw error; } - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host }); } catch (err) { diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index 9ed2d3929..de58de469 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { homedir } from "node:os"; import { basename, join, sep } from "node:path"; +import type { DaemonClient } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; @@ -62,7 +63,7 @@ export async function runLsCommand( ): Promise { const host = getDaemonHost({ host: options.host }); - let client; + let client: DaemonClient; try { client = await connectToDaemon({ host: options.host }); } catch (err) { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 93008ed27..d802b8097 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -10,6 +10,7 @@ import { garbageCollectManagedAttachmentFiles, readManagedFileBase64, writeAttachmentBase64, + writeAttachmentBytes, } from "../features/attachments.js"; import { checkForAppUpdate, @@ -539,6 +540,7 @@ export function createDaemonCommandHandlers(): Record powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), + write_attachment_bytes: (args) => writeAttachmentBytes(args ?? {}), copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}), read_file_base64: (args) => readManagedFileBase64(args ?? {}), delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}), diff --git a/packages/desktop/src/features/attachments.ts b/packages/desktop/src/features/attachments.ts index 5370fe435..f93c974dd 100644 --- a/packages/desktop/src/features/attachments.ts +++ b/packages/desktop/src/features/attachments.ts @@ -90,6 +90,37 @@ export async function writeAttachmentBase64(input: { }; } +function normalizeBytes(value: unknown): Uint8Array { + if (value instanceof Uint8Array) { + return value; + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + if (Array.isArray(value)) { + return Uint8Array.from(value); + } + throw new Error("Attachment byte payload is required."); +} + +export async function writeAttachmentBytes(input: { + attachmentId?: unknown; + bytes?: unknown; + extension?: unknown; +}): Promise { + const bytes = normalizeBytes(input.bytes); + const targetPath = await buildManagedAttachmentPath({ + attachmentId: input.attachmentId, + extension: input.extension, + }); + await writeFile(targetPath, bytes); + const fileInfo = await stat(targetPath); + return { + path: targetPath, + byteSize: fileInfo.size, + }; +} + export async function copyAttachmentFileToManagedStorage(input: { attachmentId?: unknown; sourcePath?: unknown; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 1b52b6fc7..b1181fd91 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { DaemonClient, type DaemonTransport } from "./daemon-client"; +import { encodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js"; import { asUint8Array, decodeTerminalResizePayload, @@ -13,6 +14,9 @@ expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTy expectTypeOf< "getHighlightedDiff" extends keyof DaemonClient ? true : false >().toEqualTypeOf(); +expectTypeOf< + "exploreFileSystem" extends keyof DaemonClient ? true : false +>().toEqualTypeOf(); function createMockLogger() { return { @@ -235,6 +239,209 @@ test("does not reconnect after close when ensureConnected is called", async () = expect(client.getConnectionState().status).toBe("disposed"); }); +test("listDirectory sends a list file explorer request and returns directory entries", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.listDirectory("/tmp/project", "src", "req-list"); + + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "file_explorer_request", + cwd: "/tmp/project", + path: "src", + mode: "list", + requestId: "req-list", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "file_explorer_response", + payload: { + cwd: "/tmp/project", + path: "src", + mode: "list", + directory: { + path: "src", + entries: [ + { + name: "index.ts", + path: "src/index.ts", + kind: "file", + size: 12, + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + ], + }, + file: null, + error: null, + requestId: "req-list", + }, + }), + ); + + await expect(responsePromise).resolves.toEqual({ + path: "src", + entries: [ + { + name: "index.ts", + path: "src/index.ts", + kind: "file", + size: 12, + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + ], + }); +}); + +test("readFile hides legacy base64 behind bytes", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file"); + + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "file_explorer_request", + cwd: "/tmp/project", + path: "logo.png", + mode: "file", + acceptBinary: true, + requestId: "req-file", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "file_explorer_response", + payload: { + cwd: "/tmp/project", + path: "logo.png", + mode: "file", + directory: null, + file: { + path: "logo.png", + kind: "image", + encoding: "base64", + content: "aGVsbG8=", + mimeType: "image/png", + size: 5, + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + error: null, + requestId: "req-file", + }, + }), + ); + + const result = await responsePromise; + expect(result).toMatchObject({ + mime: "image/png", + size: 5, + path: "logo.png", + kind: "image", + modifiedAt: "2026-05-02T00:00:00.000Z", + }); + expect(new TextDecoder().decode(result.bytes)).toBe("hello"); +}); + +test("readFile resolves from binary file frames when the daemon supports them", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary"); + + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "file_explorer_request", + cwd: "/tmp/project", + path: "logo.png", + mode: "file", + acceptBinary: true, + requestId: "req-binary", + }, + }); + + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-binary", + metadata: { + mime: "image/png", + size: 5, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ); + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-binary", + payload: new TextEncoder().encode("hello"), + }), + ); + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-binary", + }), + ); + + const result = await responsePromise; + expect(result).toMatchObject({ + mime: "image/png", + size: 5, + path: "logo.png", + kind: "image", + modifiedAt: "2026-05-02T00:00:00.000Z", + }); + expect(new TextDecoder().decode(result.bytes)).toBe("hello"); +}); + test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 15230bbd6..4a1d56cb7 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -82,13 +82,16 @@ import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "../shared/me import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; import { asUint8Array, + decodeFileTransferFrame, decodeTerminalSnapshotPayload, decodeTerminalStreamFrame, + FileTransferOpcode, encodeTerminalResizePayload, encodeTerminalStreamFrame, TerminalStreamOpcode, + type FileTransferFrame, type TerminalStreamFrame, -} from "../shared/terminal-stream-protocol.js"; +} from "../shared/binary-frames/index.js"; import { createRelayE2eeTransportFactory, createWebSocketTransportFactory, @@ -283,6 +286,16 @@ type CreatePaseoWorktreePayload = Extract< { type: "create_paseo_worktree_response" } >["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; +export type FileExplorerDirectoryPayload = NonNullable; +type LegacyFileExplorerFilePayload = NonNullable; +export interface FileReadResult { + bytes: Uint8Array; + mime: string; + size: number; + path: string; + kind: LegacyFileExplorerFilePayload["kind"]; + modifiedAt: string; +} type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; @@ -569,6 +582,22 @@ interface WaitHandle { cancel: (error: Error) => void; } +interface PendingBinaryFileRead { + cwd: string; + path: string; +} + +interface BinaryFileTransferState extends PendingBinaryFileRead { + mime: string; + size: number; + encoding: Extract< + FileTransferFrame, + { opcode: typeof FileTransferOpcode.FileBegin } + >["metadata"]["encoding"]; + modifiedAt: string; + chunks: Uint8Array[]; +} + type RpcWaitResult = { kind: "ok"; value: T } | { kind: "error"; error: DaemonRpcError }; type GetDaemonConfigResponse = Extract< SessionOutboundMessage, @@ -627,6 +656,55 @@ function normalizeClientId(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function decodeBase64ToBytes(base64: string): Uint8Array { + const binary = globalThis.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function legacyExplorerFileToBytes(file: LegacyFileExplorerFilePayload): FileReadResult { + let bytes: Uint8Array; + if (file.encoding === "base64" && file.content) { + bytes = decodeBase64ToBytes(file.content); + } else if (file.encoding === "utf-8" && file.content) { + bytes = new TextEncoder().encode(file.content); + } else { + bytes = new Uint8Array(); + } + + return { + bytes, + mime: file.mimeType ?? "application/octet-stream", + size: file.size, + path: file.path, + kind: file.kind, + modifiedAt: file.modifiedAt, + }; +} + +function binaryFileKind(mime: string, encoding: string): FileReadResult["kind"] { + if (mime.startsWith("image/")) { + return "image"; + } + if (encoding === "utf-8" || mime.startsWith("text/") || mime === "application/json") { + return "text"; + } + return "binary"; +} + +function concatByteChunks(chunks: Uint8Array[], size: number): Uint8Array { + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + function hashForLog(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { @@ -697,6 +775,9 @@ export class DaemonClient { private terminalDirectorySubscriptions = new Set(); private terminalSlots = new Map(); private slotTerminals = new Map(); + private pendingBinaryFileReads = new Map(); + private activeBinaryFileTransfers = new Map(); + private completedBinaryFileReads = new Map(); private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; @@ -2865,11 +2946,12 @@ export class DaemonClient { // File Explorer // ============================================================================ - async exploreFileSystem( + private async requestFileExplorer( cwd: string, path: string, - mode: "list" | "file" = "list", + mode: "list" | "file", requestId?: string, + acceptBinary = false, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -2878,12 +2960,51 @@ export class DaemonClient { cwd, path, mode, + ...(acceptBinary ? { acceptBinary: true } : {}), }, responseType: "file_explorer_response", timeout: 10000, }); } + async listDirectory( + cwd: string, + path: string, + requestId?: string, + ): Promise { + const payload = await this.requestFileExplorer(cwd, path, "list", requestId); + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.directory) { + throw new Error("Directory listing unavailable."); + } + return payload.directory; + } + + async readFile(cwd: string, path: string, requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId); + this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path }); + try { + const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true); + if (payload.error) { + throw new Error(payload.error); + } + const binaryResult = this.completedBinaryFileReads.get(resolvedRequestId); + if (binaryResult) { + this.completedBinaryFileReads.delete(resolvedRequestId); + return binaryResult; + } + if (!payload.file) { + throw new Error("File unavailable."); + } + return legacyExplorerFileToBytes(payload.file); + } finally { + this.pendingBinaryFileReads.delete(resolvedRequestId); + this.activeBinaryFileTransfers.delete(resolvedRequestId); + } + } + async requestDownloadToken( cwd: string, path: string, @@ -3933,6 +4054,13 @@ export class DaemonClient { } private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean { + const fileFrame = decodeFileTransferFrame(rawBytes); + if (fileFrame) { + this.handleFileTransferFrame(fileFrame); + this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0); + return true; + } + const frame = decodeTerminalStreamFrame(rawBytes); if (!frame) { return false; @@ -3953,6 +4081,57 @@ export class DaemonClient { return true; } + private handleFileTransferFrame(frame: FileTransferFrame): void { + if (frame.opcode === FileTransferOpcode.FileBegin) { + const pending = this.pendingBinaryFileReads.get(frame.requestId); + if (!pending) { + return; + } + this.activeBinaryFileTransfers.set(frame.requestId, { + ...pending, + mime: frame.metadata.mime, + size: frame.metadata.size, + encoding: frame.metadata.encoding, + modifiedAt: frame.metadata.modifiedAt, + chunks: [], + }); + return; + } + + const transfer = this.activeBinaryFileTransfers.get(frame.requestId); + if (!transfer) { + return; + } + + if (frame.opcode === FileTransferOpcode.FileChunk) { + transfer.chunks.push(frame.payload); + return; + } + + const bytes = concatByteChunks(transfer.chunks, transfer.size); + this.activeBinaryFileTransfers.delete(frame.requestId); + this.completedBinaryFileReads.set(frame.requestId, { + bytes, + mime: transfer.mime, + size: transfer.size, + path: transfer.path, + kind: binaryFileKind(transfer.mime, transfer.encoding), + modifiedAt: transfer.modifiedAt, + }); + this.handleSessionMessage({ + type: "file_explorer_response", + payload: { + cwd: transfer.cwd, + path: transfer.path, + mode: "file", + directory: null, + file: null, + error: null, + requestId: frame.requestId, + }, + }); + } + private handleBinaryFrame(frame: TerminalStreamFrame): void { const terminalId = this.slotTerminals.get(frame.slot); if (!terminalId) { diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 3a2915f60..6243acdb7 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1291,11 +1291,9 @@ test("supports git and file operations", async () => { return unsubscribeList; }); - const listResult = await ctx.client.exploreFileSystem(cwd, ".", "list", listRequestId); + const listResult = await ctx.client.listDirectory(cwd, ".", listRequestId); const listMessage = await listMessagePromise; - expect(listResult.error).toBeNull(); - expect(listResult.directory).toBeTruthy(); - expect(listResult.requestId).toBe(listRequestId); + expect(listResult.entries.some((entry) => entry.name === "download.txt")).toBe(true); expect(listMessage.payload.mode).toBe("list"); expect(listMessage.payload.requestId).toBe(listRequestId); @@ -1319,11 +1317,9 @@ test("supports git and file operations", async () => { return unsubscribeFile; }); - const fileResult = await ctx.client.exploreFileSystem(cwd, "download.txt", "file", fileRequestId); + const fileResult = await ctx.client.readFile(cwd, "download.txt", fileRequestId); const fileMessage = await fileMessagePromise; - expect(fileResult.error).toBeNull(); - expect(fileResult.file?.content).toBe(downloadContents); - expect(fileResult.requestId).toBe(fileRequestId); + expect(new TextDecoder().decode(fileResult.bytes)).toBe(downloadContents); expect(fileMessage.payload.mode).toBe("file"); expect(fileMessage.payload.requestId).toBe(fileRequestId); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 9c819cdaa..4bb473613 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -22,7 +22,7 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); -describe("exploreFileSystem", () => { +describe("file explorer", () => { test("lists directory contents", async () => { const cwd = tmpCwd(); @@ -45,16 +45,13 @@ describe("exploreFileSystem", () => { expect(agent.status).toBe("idle"); // List directory contents - const result = await ctx.client.exploreFileSystem(cwd, ".", "list"); + const directory = await ctx.client.listDirectory(cwd, "."); // Verify listing returned without error - expect(result.error).toBeNull(); - expect(result.mode).toBe("list"); - expect(result.directory).toBeTruthy(); - expect(result.directory!.entries).toBeTruthy(); + expect(directory.entries).toBeTruthy(); // Find expected entries - const entries = result.directory!.entries; + const entries = directory.entries; const testTxt = entries.find((e) => e.name === "test.txt"); const dataJson = entries.find((e) => e.name === "data.json"); const subdir = entries.find((e) => e.name === "subdir"); @@ -92,17 +89,14 @@ describe("exploreFileSystem", () => { expect(agent.id).toBeTruthy(); // Read file contents - const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file"); + const result = await ctx.client.readFile(cwd, "readme.txt"); // Verify file read - expect(result.error).toBeNull(); - expect(result.mode).toBe("file"); - expect(result.file).toBeTruthy(); // Server may return basename or full path - expect(result.file!.path).toContain("readme.txt"); - expect(result.file!.kind).toBe("text"); - expect(result.file!.content).toBe(testContent); - expect(result.file!.size).toBe(testContent.length); + expect(result.path).toContain("readme.txt"); + expect(result.kind).toBe("text"); + expect(new TextDecoder().decode(result.bytes)).toBe(testContent); + expect(result.size).toBe(testContent.length); // Cleanup await ctx.client.deleteAgent(agent.id); @@ -124,10 +118,7 @@ describe("exploreFileSystem", () => { expect(agent.id).toBeTruthy(); // Try to list non-existent path - const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list"); - - // Should return error - expect(result.error).toBeTruthy(); + await expect(ctx.client.listDirectory(cwd, "does-not-exist")).rejects.toThrow(); // Cleanup await ctx.client.deleteAgent(agent.id); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 27d975d03..f63badead 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -39,6 +39,16 @@ export interface FileExplorerFile { modifiedAt: string; } +export interface FileExplorerFileBytes { + path: string; + kind: ExplorerFileKind; + encoding: "utf-8" | "binary"; + bytes: Uint8Array; + mimeType: string; + size: number; + modifiedAt: string; +} + const TEXT_MIME_TYPES: Record = { ".json": "application/json", }; @@ -120,6 +130,46 @@ export async function readExplorerFile({ root, relativePath, }: ReadFileParams): Promise { + const file = await readExplorerFileBytes({ root, relativePath }); + + if (file.kind === "image") { + return { + path: file.path, + kind: file.kind, + encoding: "base64", + content: Buffer.from(file.bytes).toString("base64"), + mimeType: file.mimeType, + size: file.size, + modifiedAt: file.modifiedAt, + }; + } + + if (file.kind === "binary") { + return { + path: file.path, + kind: file.kind, + encoding: "none", + mimeType: file.mimeType, + size: file.size, + modifiedAt: file.modifiedAt, + }; + } + + return { + path: file.path, + kind: file.kind, + encoding: "utf-8", + content: Buffer.from(file.bytes).toString("utf-8"), + mimeType: file.mimeType, + size: file.size, + modifiedAt: file.modifiedAt, + }; +} + +export async function readExplorerFileBytes({ + root, + relativePath, +}: ReadFileParams): Promise { const filePath = await resolveScopedPath({ root, relativePath }); const stats = await fs.stat(filePath); @@ -139,8 +189,8 @@ export async function readExplorerFile({ return { ...basePayload, kind: "image", - encoding: "base64", - content: buffer.toString("base64"), + encoding: "binary", + bytes: buffer, mimeType: IMAGE_MIME_TYPES[ext], }; } @@ -150,7 +200,8 @@ export async function readExplorerFile({ return { ...basePayload, kind: "binary", - encoding: "none", + encoding: "binary", + bytes: buffer, mimeType: "application/octet-stream", }; } @@ -159,7 +210,7 @@ export async function readExplorerFile({ ...basePayload, kind: "text", encoding: "utf-8", - content: buffer.toString("utf-8"), + bytes: buffer, mimeType: textMimeTypeForExtension(ext), }; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 62cd98148..ab52e7ca5 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { CheckoutPrStatusSchema } from "../shared/messages.js"; import type { WorkspaceDescriptorPayload } from "../shared/messages.js"; +import { decodeFileTransferFrame, FileTransferOpcode } from "../shared/binary-frames/index.js"; import { normalizeCheckoutPrStatusPayload, Session } from "./session.js"; import type { AgentClient, @@ -47,6 +48,17 @@ function asSessionInternals(session: Session): SessionHandlerInternals { return session as unknown as SessionHandlerInternals; } +function createBinaryMessageHandler( + binaryMessages: Uint8Array[] | undefined, +): ((frame: Uint8Array) => void) | undefined { + if (!binaryMessages) { + return undefined; + } + return (frame) => { + binaryMessages.push(frame); + }; +} + const checkoutGitMocks = vi.hoisted(() => ({ checkoutResolvedBranch: vi.fn(), commitChanges: vi.fn(), @@ -195,7 +207,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }; }); -function createSessionForTest(options?: { +interface SessionForTestOptions { github?: { invalidate: ReturnType; isAuthenticated?: ReturnType; @@ -223,17 +235,20 @@ function createSessionForTest(options?: { getDaemonTcpHost?: () => string | null; providerSnapshotManager?: ProviderSnapshotManager; messages?: unknown[]; -}): Session { + binaryMessages?: Uint8Array[]; +} + +function createSessionForTest(options: SessionForTestOptions = {}): Session { const logger = pino({ level: "silent" }); - const github = options?.github ?? { + const github = options.github ?? { invalidate: vi.fn(), searchIssuesAndPrs: vi.fn(), createPullRequest: vi.fn(), }; - const checkoutDiffManager = options?.checkoutDiffManager ?? { + const checkoutDiffManager = options.checkoutDiffManager ?? { scheduleRefreshForCwd: vi.fn(), }; - const workspaceGitService = options?.workspaceGitService ?? { + const workspaceGitService = options.workspaceGitService ?? { getCheckoutDiff: vi.fn(), getSnapshot: vi.fn(), suggestBranchesForCwd: vi.fn(), @@ -244,11 +259,12 @@ function createSessionForTest(options?: { resolveRepoRemoteUrl: vi.fn(), getWorkspaceGitMetadata: vi.fn(), }; - const messages = options?.messages ?? []; + const messages = options.messages ?? []; return new Session({ clientId: "test-client", onMessage: (message) => messages.push(message), + onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"], pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"], @@ -260,7 +276,7 @@ function createSessionForTest(options?: { agentStorage: { list: vi.fn().mockResolvedValue([]), } as unknown as SessionOptions["agentStorage"], - projectRegistry: (options?.projectRegistry ?? { + projectRegistry: (options.projectRegistry ?? { list: vi.fn().mockResolvedValue([]), get: vi.fn(), upsert: vi.fn(), @@ -269,7 +285,7 @@ function createSessionForTest(options?: { initialize: vi.fn(), existsOnDisk: vi.fn(), }) as unknown as SessionOptions["projectRegistry"], - workspaceRegistry: (options?.workspaceRegistry ?? { + workspaceRegistry: (options.workspaceRegistry ?? { get: vi.fn(), list: vi.fn().mockResolvedValue([]), }) as unknown as SessionOptions["workspaceRegistry"], @@ -288,15 +304,112 @@ function createSessionForTest(options?: { } as unknown as SessionOptions["daemonConfigStore"], stt: null, tts: null, - terminalManager: (options?.terminalManager ?? null) as SessionOptions["terminalManager"], - providerSnapshotManager: options?.providerSnapshotManager, - scriptRouteStore: options?.scriptRouteStore as SessionOptions["scriptRouteStore"], - scriptRuntimeStore: options?.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"], - getDaemonTcpPort: options?.getDaemonTcpPort, - getDaemonTcpHost: options?.getDaemonTcpHost, + terminalManager: (options.terminalManager ?? null) as SessionOptions["terminalManager"], + providerSnapshotManager: options.providerSnapshotManager, + scriptRouteStore: options.scriptRouteStore as SessionOptions["scriptRouteStore"], + scriptRuntimeStore: options.scriptRuntimeStore as SessionOptions["scriptRuntimeStore"], + getDaemonTcpPort: options.getDaemonTcpPort, + getDaemonTcpHost: options.getDaemonTcpHost, }); } +describe("file explorer binary responses", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeRoot(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "file-explorer-session-test-"))); + tempDirs.push(root); + return root; + } + + test("old clients get legacy JSON file content from a new daemon", async () => { + const cwd = makeRoot(); + writeFileSync(join(cwd, "logo.png"), "hello"); + const messages: unknown[] = []; + const binaryMessages: Uint8Array[] = []; + const session = createSessionForTest({ messages, binaryMessages }); + + await session.handleMessage({ + type: "file_explorer_request", + cwd, + path: "logo.png", + mode: "file", + requestId: "req-old-client", + }); + + expect(binaryMessages).toEqual([]); + expect(messages).toEqual([ + { + type: "file_explorer_response", + payload: expect.objectContaining({ + cwd, + path: "logo.png", + mode: "file", + directory: null, + error: null, + requestId: "req-old-client", + file: expect.objectContaining({ + kind: "image", + encoding: "base64", + content: "aGVsbG8=", + mimeType: "image/png", + size: 5, + }), + }), + }, + ]); + }); + + test("new clients get binary file frames without legacy JSON content", async () => { + const cwd = makeRoot(); + writeFileSync(join(cwd, "logo.png"), "hello"); + const messages: unknown[] = []; + const binaryMessages: Uint8Array[] = []; + const session = createSessionForTest({ messages, binaryMessages }); + + await session.handleMessage({ + type: "file_explorer_request", + cwd, + path: "logo.png", + mode: "file", + requestId: "req-new-client", + acceptBinary: true, + }); + + expect(messages).toEqual([]); + expect(binaryMessages).toHaveLength(3); + + const frames = binaryMessages.map((frame) => decodeFileTransferFrame(frame)); + expect(frames[0]).toEqual({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-new-client", + metadata: { + mime: "image/png", + size: 5, + encoding: "binary", + modifiedAt: expect.any(String), + }, + payload: new Uint8Array(), + }); + expect(frames[1]).toEqual({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-new-client", + payload: new TextEncoder().encode("hello"), + }); + expect(frames[2]).toEqual({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-new-client", + payload: new Uint8Array(), + }); + }); +}); + function createProjectRecord(rootPath: string, archivedAt: string | null = null) { return { projectId: `project:${rootPath}`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b87d2125a..99ed5c7c6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -35,7 +35,11 @@ import { } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { TerminalSessionController } from "../terminal/terminal-session-controller.js"; -import type { TerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; +import { + encodeFileTransferFrame, + FileTransferOpcode, + type TerminalStreamFrame, +} from "../shared/binary-frames/index.js"; import { CursorError } from "./pagination/cursor.js"; import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js"; import { TTSManager } from "./agent/tts-manager.js"; @@ -149,6 +153,7 @@ import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { listDirectoryEntries, readExplorerFile, + readExplorerFileBytes, getDownloadableFileInfo, } from "./file-explorer/service.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; @@ -5475,23 +5480,56 @@ export class Session { }, }); } else { - const file = await readExplorerFile({ - root: cwd, - relativePath: requestedPath, - }); + if (request.acceptBinary && this.onBinaryMessage) { + const file = await readExplorerFileBytes({ + root: cwd, + relativePath: requestedPath, + }); - this.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: file.path, - mode, - directory: null, - file, - error: null, - requestId, - }, - }); + this.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: file.mimeType, + size: file.size, + encoding: file.encoding, + modifiedAt: file.modifiedAt, + }, + }), + ); + this.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: file.bytes, + }), + ); + this.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId, + }), + ); + } else { + const file = await readExplorerFile({ + root: cwd, + relativePath: requestedPath, + }); + + this.emit({ + type: "file_explorer_response", + payload: { + cwd, + path: file.path, + mode, + directory: null, + file, + error: null, + requestId, + }, + }); + } } } catch (error) { this.sessionLogger.error( diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index fac6adef9..1da1d9bfb 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -26,7 +26,7 @@ import { type WSOutboundMessage, wrapSessionMessage, } from "./messages.js"; -import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js"; +import { asUint8Array, decodeTerminalStreamFrame } from "../shared/binary-frames/index.js"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; diff --git a/packages/server/src/shared/binary-frames/file-transfer.test.ts b/packages/server/src/shared/binary-frames/file-transfer.test.ts new file mode 100644 index 000000000..36139ed7f --- /dev/null +++ b/packages/server/src/shared/binary-frames/file-transfer.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import { + FileTransferOpcode, + TerminalStreamOpcode, + decodeFileTransferFrame, + decodeTerminalStreamFrame, + encodeFileTransferFrame, +} from "./index.js"; + +describe("file transfer binary frames", () => { + const encoder = new TextEncoder(); + + it("uses non-terminal opcodes", () => { + expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileBegin); + expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileChunk); + expect(Object.values(TerminalStreamOpcode)).not.toContain(FileTransferOpcode.FileEnd); + }); + + it("encodes FileBegin as opcode plus request id prefix plus JSON metadata", () => { + const encoded = encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-1", + metadata: { + mime: "image/png", + size: 6, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }); + const requestId = encoder.encode("req-1"); + const metadata = encoder.encode( + JSON.stringify({ + mime: "image/png", + size: 6, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }), + ); + + expect(decodeTerminalStreamFrame(encoded)).toBeNull(); + expect(encoded[0]).toBe(FileTransferOpcode.FileBegin); + expect(encoded[1]).toBe(requestId.byteLength); + expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId); + expect( + new DataView(encoded.buffer, encoded.byteOffset).getUint16(2 + requestId.byteLength), + ).toBe(metadata.byteLength); + expect(encoded.subarray(4 + requestId.byteLength)).toEqual(metadata); + + expect(decodeFileTransferFrame(encoded)).toEqual({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-1", + metadata: { + mime: "image/png", + size: 6, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + payload: new Uint8Array(), + }); + }); + + it("encodes FileChunk as opcode plus request id prefix plus binary payload", () => { + const payload = new Uint8Array([0, 1, 2, 253, 254, 255]); + const encoded = encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-1", + payload, + }); + const requestId = encoder.encode("req-1"); + + expect(encoded[0]).toBe(FileTransferOpcode.FileChunk); + expect(encoded[1]).toBe(requestId.byteLength); + expect(encoded.subarray(2, 2 + requestId.byteLength)).toEqual(requestId); + expect(encoded.subarray(2 + requestId.byteLength)).toEqual(payload); + const decoded = decodeFileTransferFrame(encoded); + + expect(decoded).toEqual({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-1", + payload, + }); + expect(decoded?.payload).toBeInstanceOf(Uint8Array); + expect(decoded?.payload).toEqual(payload); + }); + + it("encodes FileEnd as opcode plus request id prefix only", () => { + const encoded = encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-1", + }); + const requestId = encoder.encode("req-1"); + + expect(encoded).toEqual( + new Uint8Array([FileTransferOpcode.FileEnd, requestId.byteLength, ...requestId]), + ); + + expect(decodeFileTransferFrame(encoded)).toEqual({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-1", + payload: new Uint8Array(), + }); + }); + + it("rejects malformed metadata and unknown metadata fields", () => { + expect( + decodeFileTransferFrame( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-1", + metadata: { + mime: "image/png", + size: -1, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ), + ).toBeNull(); + + const json = encoder.encode( + JSON.stringify({ + mime: "image/png", + size: 1, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + extra: true, + }), + ); + const requestId = encoder.encode("req-1"); + const encoded = new Uint8Array(4 + requestId.byteLength + json.byteLength); + encoded[0] = FileTransferOpcode.FileBegin; + encoded[1] = requestId.byteLength; + encoded.set(requestId, 2); + new DataView(encoded.buffer).setUint16(2 + requestId.byteLength, json.byteLength); + encoded.set(json, 4 + requestId.byteLength); + + expect(decodeFileTransferFrame(encoded)).toBeNull(); + }); + + it("rejects malformed request id prefixes and frame tails", () => { + expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 0]))).toBeNull(); + expect(decodeFileTransferFrame(new Uint8Array([FileTransferOpcode.FileEnd, 6, 1]))).toBeNull(); + + const requestId = encoder.encode("req-1"); + const encoded = new Uint8Array([ + FileTransferOpcode.FileEnd, + requestId.byteLength, + ...requestId, + 1, + ]); + expect(decodeFileTransferFrame(encoded)).toBeNull(); + }); +}); diff --git a/packages/server/src/shared/binary-frames/file-transfer.ts b/packages/server/src/shared/binary-frames/file-transfer.ts new file mode 100644 index 000000000..663aa0a80 --- /dev/null +++ b/packages/server/src/shared/binary-frames/file-transfer.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { asUint8Array } from "./terminal.js"; + +export const FileTransferOpcode = { + FileBegin: 0x10, + FileChunk: 0x11, + FileEnd: 0x12, +} as const; + +export type FileTransferOpcode = (typeof FileTransferOpcode)[keyof typeof FileTransferOpcode]; + +export const FileBeginMetadataSchema = z + .object({ + mime: z.string().min(1), + size: z.number().int().nonnegative(), + encoding: z.enum(["utf-8", "binary"]), + modifiedAt: z.string(), + }) + .strict(); + +export interface FileBegin { + opcode: typeof FileTransferOpcode.FileBegin; + requestId: string; + metadata: z.infer; + payload: Uint8Array; +} + +export interface FileChunk { + opcode: typeof FileTransferOpcode.FileChunk; + requestId: string; + payload: Uint8Array; +} + +export interface FileEnd { + opcode: typeof FileTransferOpcode.FileEnd; + requestId: string; + payload: Uint8Array; +} + +export type FileTransferFrame = FileBegin | FileChunk | FileEnd; + +type FileTransferFrameInput = + | { + opcode: typeof FileTransferOpcode.FileBegin; + requestId: string; + metadata: z.infer; + } + | { + opcode: typeof FileTransferOpcode.FileChunk; + requestId: string; + payload?: Uint8Array | ArrayBuffer | string; + } + | { + opcode: typeof FileTransferOpcode.FileEnd; + requestId: string; + }; + +export function encodeFileTransferFrame(input: FileTransferFrameInput): Uint8Array { + const requestId = encodeRequestId(input.requestId); + + if (input.opcode === FileTransferOpcode.FileBegin) { + const metadata = encodeJsonPayload(input.metadata); + if (metadata.byteLength > 0xffff) { + throw new RangeError("FileBegin metadata is too long"); + } + const bytes = new Uint8Array(4 + requestId.byteLength + metadata.byteLength); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + bytes[0] = input.opcode; + bytes[1] = requestId.byteLength; + bytes.set(requestId, 2); + view.setUint16(2 + requestId.byteLength, metadata.byteLength); + bytes.set(metadata, 4 + requestId.byteLength); + return bytes; + } + + const payload = + input.opcode === FileTransferOpcode.FileChunk + ? (asUint8Array(input.payload ?? new Uint8Array()) ?? new Uint8Array()) + : new Uint8Array(); + const bytes = new Uint8Array(2 + requestId.byteLength + payload.byteLength); + bytes[0] = input.opcode; + bytes[1] = requestId.byteLength; + bytes.set(requestId, 2); + bytes.set(payload, 2 + requestId.byteLength); + return bytes; +} + +export function decodeFileTransferFrame(bytes: Uint8Array): FileTransferFrame | null { + if (bytes.byteLength < 2) { + return null; + } + const opcode = bytes[0]; + if (!isFileTransferOpcode(opcode)) { + return null; + } + const requestIdLength = bytes[1]; + if (requestIdLength === 0 || requestIdLength > bytes.byteLength - 2) { + return null; + } + + const requestId = decodeRequestId(bytes.subarray(2, 2 + requestIdLength)); + const body = bytes.subarray(2 + requestIdLength); + + if (opcode === FileTransferOpcode.FileBegin) { + if (body.byteLength < 2) { + return null; + } + const view = new DataView(body.buffer, body.byteOffset, body.byteLength); + const metadataLength = view.getUint16(0); + if (metadataLength !== body.byteLength - 2) { + return null; + } + const metadataBytes = body.subarray(2); + const result = FileBeginMetadataSchema.safeParse(decodeJsonPayload(metadataBytes)); + return result.success + ? { opcode, requestId, metadata: result.data, payload: new Uint8Array() } + : null; + } + + if (opcode === FileTransferOpcode.FileChunk) { + return { opcode, requestId, payload: body }; + } + + if (body.byteLength !== 0) { + return null; + } + return { opcode, requestId, payload: new Uint8Array() }; +} + +function isFileTransferOpcode(value: number): value is FileTransferOpcode { + return ( + value === FileTransferOpcode.FileBegin || + value === FileTransferOpcode.FileChunk || + value === FileTransferOpcode.FileEnd + ); +} + +function encodeJsonPayload(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +function encodeRequestId(requestId: string): Uint8Array { + const bytes = new TextEncoder().encode(requestId); + if (bytes.byteLength === 0) { + throw new RangeError("File transfer requestId is required"); + } + if (bytes.byteLength > 0xff) { + throw new RangeError("File transfer requestId is too long"); + } + return bytes; +} + +function decodeRequestId(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function decodeJsonPayload(bytes: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } +} diff --git a/packages/server/src/shared/binary-frames/index.ts b/packages/server/src/shared/binary-frames/index.ts new file mode 100644 index 000000000..33586c995 --- /dev/null +++ b/packages/server/src/shared/binary-frames/index.ts @@ -0,0 +1,2 @@ +export * from "./file-transfer.js"; +export * from "./terminal.js"; diff --git a/packages/server/src/shared/binary-frames/terminal.test.ts b/packages/server/src/shared/binary-frames/terminal.test.ts new file mode 100644 index 000000000..f1a8be4af --- /dev/null +++ b/packages/server/src/shared/binary-frames/terminal.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + TerminalStreamOpcode, + decodeTerminalResizePayload, + decodeTerminalSnapshotPayload, + decodeTerminalStreamFrame, + encodeTerminalResizePayload, + encodeTerminalSnapshotPayload, + encodeTerminalStreamFrame, +} from "./index.js"; + +describe("terminal binary frames", () => { + it("encodes output frames as opcode plus slot plus payload", () => { + const payload = new TextEncoder().encode("hello"); + const encoded = encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 7, + payload, + }); + + expect(encoded[0]).toBe(TerminalStreamOpcode.Output); + expect(encoded[1]).toBe(7); + expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload)); + + const decoded = decodeTerminalStreamFrame(encoded); + expect(decoded).toEqual({ + opcode: TerminalStreamOpcode.Output, + slot: 7, + payload, + }); + }); + + it("round-trips resize payloads", () => { + const payload = encodeTerminalResizePayload({ + rows: 24, + cols: 80, + }); + + expect(decodeTerminalResizePayload(payload)).toEqual({ + rows: 24, + cols: 80, + }); + }); + + it("round-trips snapshot payloads", () => { + const state = { + rows: 1, + cols: 2, + grid: [[{ char: "A" }, { char: "B" }]], + scrollback: [], + cursor: { row: 0, col: 2 }, + }; + + const payload = encodeTerminalSnapshotPayload(state); + expect(decodeTerminalSnapshotPayload(payload)).toEqual(state); + }); + + it("rejects unknown opcodes", () => { + expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull(); + }); + + it("rejects frames without a slot byte", () => { + expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull(); + }); + + it("rejects malformed JSON payloads", () => { + const malformed = new TextEncoder().encode("{"); + + expect(decodeTerminalResizePayload(malformed)).toBeNull(); + expect(decodeTerminalSnapshotPayload(malformed)).toBeNull(); + }); + + it("rejects invalid resize and snapshot shapes", () => { + expect( + decodeTerminalResizePayload( + new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })), + ), + ).toBeNull(); + expect( + decodeTerminalSnapshotPayload( + new TextEncoder().encode( + JSON.stringify({ + rows: 1, + cols: 1, + grid: [[{ char: "A" }]], + scrollback: [], + }), + ), + ), + ).toBeNull(); + }); + + it("rejects unknown fields in resize and snapshot payloads", () => { + expect( + decodeTerminalResizePayload( + new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })), + ), + ).toBeNull(); + expect( + decodeTerminalSnapshotPayload( + new TextEncoder().encode( + JSON.stringify({ + rows: 1, + cols: 1, + grid: [[{ char: "A", extra: true }]], + scrollback: [], + cursor: { row: 0, col: 1 }, + }), + ), + ), + ).toBeNull(); + }); +}); diff --git a/packages/server/src/shared/binary-frames/terminal.ts b/packages/server/src/shared/binary-frames/terminal.ts new file mode 100644 index 000000000..b5c3e467c --- /dev/null +++ b/packages/server/src/shared/binary-frames/terminal.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import { TerminalStateSchema } from "../messages.js"; + +export const TerminalStreamResizeSchema = z + .object({ + rows: z.number().int().positive(), + cols: z.number().int().positive(), + }) + .strict(); + +export const TerminalStreamOpcode = { + Output: 0x01, + Input: 0x02, + Resize: 0x03, + Snapshot: 0x04, +} as const; + +export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; + +export interface TerminalStreamFrame { + opcode: TerminalStreamOpcode; + slot: number; + payload: Uint8Array; +} + +export function asUint8Array(data: unknown): Uint8Array | null { + if (typeof data === "string") { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(data); + } + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(data, "utf8")); + } + const out = new Uint8Array(data.length); + for (let index = 0; index < data.length; index += 1) { + out[index] = data.charCodeAt(index) & 0xff; + } + return out; + } + if (data instanceof Uint8Array) { + return data; + } + if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} + +function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { + return ( + value === TerminalStreamOpcode.Output || + value === TerminalStreamOpcode.Input || + value === TerminalStreamOpcode.Resize || + value === TerminalStreamOpcode.Snapshot + ); +} + +export function encodeTerminalStreamFrame(input: { + opcode: TerminalStreamOpcode; + slot: number; + payload?: Uint8Array | ArrayBuffer | string; +}): Uint8Array { + const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0); + const bytes = new Uint8Array(2 + payload.byteLength); + bytes[0] = input.opcode; + bytes[1] = input.slot & 0xff; + bytes.set(payload, 2); + return bytes; +} + +export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null { + if (bytes.byteLength < 2) { + return null; + } + const opcode = bytes[0]; + if (!isTerminalStreamOpcode(opcode)) { + return null; + } + return { + opcode, + slot: bytes[1], + payload: bytes.subarray(2), + }; +} + +export function encodeTerminalSnapshotPayload( + state: z.infer, +): Uint8Array { + return encodeJsonPayload(state); +} + +export function decodeTerminalSnapshotPayload( + bytes: Uint8Array, +): z.infer | null { + const parsed = decodeJsonPayload(bytes); + const result = TerminalStateSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +export function encodeTerminalResizePayload( + input: z.infer, +): Uint8Array { + return encodeJsonPayload(input); +} + +export function decodeTerminalResizePayload( + bytes: Uint8Array, +): z.infer | null { + const parsed = decodeJsonPayload(bytes); + const result = TerminalStreamResizeSchema.safeParse(parsed); + return result.success ? result.data : null; +} + +function encodeJsonPayload(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value)); +} + +function decodeJsonPayload(bytes: Uint8Array): unknown { + try { + const text = new TextDecoder().decode(bytes); + return JSON.parse(text); + } catch { + return null; + } +} diff --git a/packages/server/src/shared/messages.test.ts b/packages/server/src/shared/messages.test.ts index d9371d403..815a85de1 100644 --- a/packages/server/src/shared/messages.test.ts +++ b/packages/server/src/shared/messages.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { SessionOutboundMessageSchema } from "./messages.js"; +import { FileExplorerRequestSchema, SessionOutboundMessageSchema } from "./messages.js"; function workspaceDescriptor(overrides: Record = {}) { return { @@ -126,3 +126,38 @@ describe("workspace descriptor message compatibility", () => { }); }); }); + +describe("file explorer request compatibility", () => { + test("acceptBinary is optional for old clients and accepted for new clients", () => { + expect( + FileExplorerRequestSchema.parse({ + type: "file_explorer_request", + cwd: "/repo/app", + path: "image.png", + mode: "file", + requestId: "req-old", + }), + ).toEqual({ + type: "file_explorer_request", + cwd: "/repo/app", + path: "image.png", + mode: "file", + requestId: "req-old", + }); + + expect( + FileExplorerRequestSchema.parse({ + type: "file_explorer_request", + cwd: "/repo/app", + path: "image.png", + mode: "file", + requestId: "req-new", + acceptBinary: true, + }), + ).toMatchObject({ + type: "file_explorer_request", + requestId: "req-new", + acceptBinary: true, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 274c8cb92..7f21cb832 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1505,6 +1505,7 @@ export const FileExplorerRequestSchema = z.object({ path: z.string().optional(), mode: z.enum(["list", "file"]), requestId: z.string(), + acceptBinary: z.boolean().optional(), }); export const ProjectIconRequestSchema = z.object({ diff --git a/packages/server/src/shared/terminal-stream-protocol.test.ts b/packages/server/src/shared/terminal-stream-protocol.test.ts index ac395fd28..e399e7829 100644 --- a/packages/server/src/shared/terminal-stream-protocol.test.ts +++ b/packages/server/src/shared/terminal-stream-protocol.test.ts @@ -1,114 +1,36 @@ import { describe, expect, it } from "vitest"; -import { - TerminalStreamOpcode, - decodeTerminalResizePayload, - decodeTerminalSnapshotPayload, - decodeTerminalStreamFrame, - encodeTerminalResizePayload, - encodeTerminalSnapshotPayload, - encodeTerminalStreamFrame, -} from "./terminal-stream-protocol.js"; +import * as terminalBinaryFrames from "./binary-frames/index.js"; +import * as legacyTerminalStreamProtocol from "./terminal-stream-protocol.js"; describe("terminal stream protocol", () => { - it("encodes output frames as opcode plus slot plus payload", () => { + it("keeps the old import path compatible with terminal binary frames", () => { + expect(legacyTerminalStreamProtocol.TerminalStreamOpcode).toBe( + terminalBinaryFrames.TerminalStreamOpcode, + ); + expect(legacyTerminalStreamProtocol.encodeTerminalStreamFrame).toBe( + terminalBinaryFrames.encodeTerminalStreamFrame, + ); + expect(legacyTerminalStreamProtocol.decodeTerminalStreamFrame).toBe( + terminalBinaryFrames.decodeTerminalStreamFrame, + ); + const payload = new TextEncoder().encode("hello"); - const encoded = encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, + const encoded = legacyTerminalStreamProtocol.encodeTerminalStreamFrame({ + opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output, slot: 7, payload, }); - expect(encoded[0]).toBe(TerminalStreamOpcode.Output); + expect(encoded[0]).toBe(terminalBinaryFrames.TerminalStreamOpcode.Output); expect(encoded[1]).toBe(7); expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload)); - const decoded = decodeTerminalStreamFrame(encoded); + const decoded = terminalBinaryFrames.decodeTerminalStreamFrame(encoded); expect(decoded).toEqual({ - opcode: TerminalStreamOpcode.Output, + opcode: legacyTerminalStreamProtocol.TerminalStreamOpcode.Output, slot: 7, payload, }); }); - - it("round-trips resize payloads", () => { - const payload = encodeTerminalResizePayload({ - rows: 24, - cols: 80, - }); - - expect(decodeTerminalResizePayload(payload)).toEqual({ - rows: 24, - cols: 80, - }); - }); - - it("round-trips snapshot payloads", () => { - const state = { - rows: 1, - cols: 2, - grid: [[{ char: "A" }, { char: "B" }]], - scrollback: [], - cursor: { row: 0, col: 2 }, - }; - - const payload = encodeTerminalSnapshotPayload(state); - expect(decodeTerminalSnapshotPayload(payload)).toEqual(state); - }); - - it("rejects unknown opcodes", () => { - expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull(); - }); - - it("rejects frames without a slot byte", () => { - expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull(); - }); - - it("rejects malformed JSON payloads", () => { - const malformed = new TextEncoder().encode("{"); - - expect(decodeTerminalResizePayload(malformed)).toBeNull(); - expect(decodeTerminalSnapshotPayload(malformed)).toBeNull(); - }); - - it("rejects invalid resize and snapshot shapes", () => { - expect( - decodeTerminalResizePayload( - new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })), - ), - ).toBeNull(); - expect( - decodeTerminalSnapshotPayload( - new TextEncoder().encode( - JSON.stringify({ - rows: 1, - cols: 1, - grid: [[{ char: "A" }]], - scrollback: [], - }), - ), - ), - ).toBeNull(); - }); - - it("rejects unknown fields in resize and snapshot payloads", () => { - expect( - decodeTerminalResizePayload( - new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })), - ), - ).toBeNull(); - expect( - decodeTerminalSnapshotPayload( - new TextEncoder().encode( - JSON.stringify({ - rows: 1, - cols: 1, - grid: [[{ char: "A", extra: true }]], - scrollback: [], - cursor: { row: 0, col: 1 }, - }), - ), - ), - ).toBeNull(); - }); }); diff --git a/packages/server/src/shared/terminal-stream-protocol.ts b/packages/server/src/shared/terminal-stream-protocol.ts index 10314242e..4945856c1 100644 --- a/packages/server/src/shared/terminal-stream-protocol.ts +++ b/packages/server/src/shared/terminal-stream-protocol.ts @@ -1,131 +1 @@ -import { z } from "zod"; -import { TerminalStateSchema } from "./messages.js"; - -export const TerminalStreamResizeSchema = z - .object({ - rows: z.number().int().positive(), - cols: z.number().int().positive(), - }) - .strict(); - -export const TerminalStreamOpcode = { - Output: 0x01, - Input: 0x02, - Resize: 0x03, - Snapshot: 0x04, -} as const; - -export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode]; - -export interface TerminalStreamFrame { - opcode: TerminalStreamOpcode; - slot: number; - payload: Uint8Array; -} - -export function asUint8Array(data: unknown): Uint8Array | null { - if (typeof data === "string") { - if (typeof TextEncoder !== "undefined") { - return new TextEncoder().encode(data); - } - if (typeof Buffer !== "undefined") { - return new Uint8Array(Buffer.from(data, "utf8")); - } - const out = new Uint8Array(data.length); - for (let index = 0; index < data.length; index += 1) { - out[index] = data.charCodeAt(index) & 0xff; - } - return out; - } - if (data instanceof Uint8Array) { - return data; - } - if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) { - return new Uint8Array(data); - } - if (ArrayBuffer.isView(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - return null; -} - -function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { - return ( - value === TerminalStreamOpcode.Output || - value === TerminalStreamOpcode.Input || - value === TerminalStreamOpcode.Resize || - value === TerminalStreamOpcode.Snapshot - ); -} - -export function encodeTerminalStreamFrame(input: { - opcode: TerminalStreamOpcode; - slot: number; - payload?: Uint8Array | ArrayBuffer | string; -}): Uint8Array { - const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0); - const bytes = new Uint8Array(2 + payload.byteLength); - bytes[0] = input.opcode; - bytes[1] = input.slot & 0xff; - bytes.set(payload, 2); - return bytes; -} - -export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null { - if (bytes.byteLength < 2) { - return null; - } - const opcode = bytes[0]; - if (!isTerminalStreamOpcode(opcode)) { - return null; - } - return { - opcode, - slot: bytes[1], - payload: bytes.subarray(2), - }; -} - -export function encodeTerminalSnapshotPayload( - state: z.infer, -): Uint8Array { - return encodeJsonPayload(state); -} - -export function decodeTerminalSnapshotPayload( - bytes: Uint8Array, -): z.infer | null { - const parsed = decodeJsonPayload(bytes); - const result = TerminalStateSchema.safeParse(parsed); - return result.success ? result.data : null; -} - -export function encodeTerminalResizePayload( - input: z.infer, -): Uint8Array { - return encodeJsonPayload(input); -} - -export function decodeTerminalResizePayload( - bytes: Uint8Array, -): z.infer | null { - const parsed = decodeJsonPayload(bytes); - const result = TerminalStreamResizeSchema.safeParse(parsed); - return result.success ? result.data : null; -} - -function encodeJsonPayload(value: unknown): Uint8Array { - return new TextEncoder().encode(JSON.stringify(value)); -} - -function decodeJsonPayload(bytes: Uint8Array): unknown { - try { - const text = new TextDecoder().decode(bytes); - return JSON.parse(text); - } catch { - return null; - } -} +export * from "./binary-frames/terminal.js"; diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 138d8c970..eff63973d 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -19,7 +19,7 @@ import { encodeTerminalSnapshotPayload, encodeTerminalStreamFrame, type TerminalStreamFrame, -} from "../shared/terminal-stream-protocol.js"; +} from "../shared/binary-frames/index.js"; import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js"; import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";