From f631c1f484e245fddd1087510ef038273b222133 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 14:56:22 -0700 Subject: [PATCH 1/3] fix(cmux-next): remove the composer attachment count cap Accept more than ten attachments in the shared composer and New Tab intake, including saved links and restored chips. Keep per-file and byte limits intact and replace the old refusal assertions with acceptance coverage. Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16a89-20f0-7ff2-8bb0-ec0bb3ae3a5e&host=big-red --- .../src/agent-session/acpmux/Composer.tsx | 12 +++------- .../src/agent-session/acpmux/attachments.ts | 10 ++------ .../src/agent-session/acpmux/cloudUploads.ts | 3 +-- .../acpmux/composer/attachmentIntake.tsx | 24 +++++++------------ .../acpmux/composer/intakeFiles.ts | 16 ++++--------- .../acpmux/composer/keptAttachments.ts | 4 ++-- .../acpmux/composer/pastedChips.ts | 4 ++-- .../test/composer-attachment-matrix.test.ts | 19 +++++++-------- 8 files changed, 32 insertions(+), 60 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index f03e5fe7a803..6e5920e172c5 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -6,7 +6,6 @@ import type { ConnectKind, OffloadKind } from "./environments"; import { useEscapingList } from "../../ui/popupHost"; import type { AcpmuxSnapshot, QueueEdit } from "./model"; import { - MAX_ATTACHMENTS, type AttachmentError, type ComposerAttachment, type DroppedKind, @@ -128,7 +127,6 @@ export const COMPOSER_LABELS = { tooLarge: "composer.tooLarge", unsupported: "composer.unsupported", imagesUnsupported: "composer.imagesUnsupported", - tooMany: "composer.tooMany", cut: "composer.menu.cut", copy: "composer.menu.copy", paste: "composer.menu.paste", @@ -476,7 +474,7 @@ export function Composer({ void keptAttachments .read(chat) .catch(() => []) - .then((kept) => keptAttachments.write(chat, appendAttachments(kept, chips).slice(0, MAX_ATTACHMENTS))) + .then((kept) => keptAttachments.write(chat, appendAttachments(kept, chips))) .catch(() => {}); }, carries: () => carryPrompt.current || Boolean(switching), @@ -542,15 +540,11 @@ export function Composer({ // A pick past the most the composer holds says so, as a dropped file does. const held = attachmentsRef.current; const next = appendAttachments(held, [attachment]); - if (next.length > MAX_ATTACHMENTS) { - setAttachError(attachmentErrorText({ name: attachment.name, reason: "tooMany" }, tRef.current)); - return; - } // Ahead of the render, so each of several picks attached at once counts the ones before it. attachmentsRef.current = next; setAttachments((current) => { const next = appendAttachments(current, [attachment]); - return next.length > MAX_ATTACHMENTS ? current : next; + return next; }); }, detach(ids) { @@ -577,7 +571,7 @@ export function Composer({ const back = state.attachments ?? []; if (back.length) setAttachments((current) => - [...back, ...current.filter((chip) => !back.some((kept) => kept.id === chip.id))].slice(0, MAX_ATTACHMENTS), + [...back, ...current.filter((chip) => !back.some((kept) => kept.id === chip.id))], ); if (state.shell) { setShell(true); diff --git a/webviews/src/agent-session/acpmux/attachments.ts b/webviews/src/agent-session/acpmux/attachments.ts index d31318bf69dc..8d8d2bf08197 100644 --- a/webviews/src/agent-session/acpmux/attachments.ts +++ b/webviews/src/agent-session/acpmux/attachments.ts @@ -29,7 +29,7 @@ export type ComposerAttachment = { saved?: boolean; }; -export type AttachmentError = { name: string; reason: "tooLarge" | "unsupported" | "imagesUnsupported" | "tooMany" }; +export type AttachmentError = { name: string; reason: "tooLarge" | "unsupported" | "imagesUnsupported" }; /// A refusal with the file it refuses, so a caller can send that file another way (a chat's upload). export type RefusedFile = AttachmentError & { file: File; unreadable?: boolean }; @@ -55,7 +55,6 @@ export const IMAGE_BUDGET_BYTES = 1536 * 1024; export const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; /** A text file this large is already a lot of prompt. */ export const MAX_TEXT_BYTES = 256 * 1024; -export const MAX_ATTACHMENTS = 10; const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); /// Pictures the models do not take as they are (an iPhone's HEIC, TIFF, BMP, AVIF) but the engine /// can decode: they go as PNG or JPEG. One it cannot decode goes as a file instead. @@ -74,19 +73,14 @@ function isPdf(file: File): boolean { return file.type === "application/pdf" || /\.pdf$/i.test(file.name); } -/// Reads `files` for a composer that already holds `held` attachments. +/// Reads every file in a batch for the composer; per-file size and type rules still apply. export async function readAttachments( files: File[], - held: number, allowImages: boolean, ): Promise<{ attachments: ComposerAttachment[]; errors: RefusedFile[] }> { const attachments: ComposerAttachment[] = []; const errors: RefusedFile[] = []; for (const file of files) { - if (held + attachments.length >= MAX_ATTACHMENTS) { - errors.push({ name: file.name, reason: "tooMany", file }); - continue; - } // A dropped folder arrives as a File whose bytes cannot be read. const result = await readAttachment(file, allowImages).catch((): RefusedFile => ({ name: file.name, diff --git a/webviews/src/agent-session/acpmux/cloudUploads.ts b/webviews/src/agent-session/acpmux/cloudUploads.ts index cb54aee8ea66..2165bed06385 100644 --- a/webviews/src/agent-session/acpmux/cloudUploads.ts +++ b/webviews/src/agent-session/acpmux/cloudUploads.ts @@ -4,7 +4,6 @@ // person's gesture, copies them to the chat's machine, and answers where each went there. import { base64 } from "./attachments"; -export const MAX_UPLOAD_FILES = 10; export const MAX_UPLOAD_FILE_BYTES = 32 * 1024 * 1024; export const MAX_UPLOAD_TOTAL_BYTES = 64 * 1024 * 1024; @@ -20,7 +19,7 @@ export async function uploadToMachine( call: (method: string, params: Record) => Promise, ): Promise { const total = files.reduce((sum, file) => sum + file.size, 0); - if (files.length === 0 || files.length > MAX_UPLOAD_FILES || total > MAX_UPLOAD_TOTAL_BYTES) return undefined; + if (files.length === 0 || total > MAX_UPLOAD_TOTAL_BYTES) return undefined; if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES || !file.name)) return undefined; let payload: { name: string; data: string }[]; try { diff --git a/webviews/src/agent-session/acpmux/composer/attachmentIntake.tsx b/webviews/src/agent-session/acpmux/composer/attachmentIntake.tsx index 5c21e71bcc78..e2d2d26238fd 100644 --- a/webviews/src/agent-session/acpmux/composer/attachmentIntake.tsx +++ b/webviews/src/agent-session/acpmux/composer/attachmentIntake.tsx @@ -6,7 +6,6 @@ import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from import { ContextMenu } from "../../../ui/ContextMenu"; import { Dialog } from "../../../ui/Dialog"; import { - MAX_ATTACHMENTS, filesFrom, isImageFile, pastedImageFiles, @@ -37,7 +36,6 @@ const ATTACHMENT_LABELS = { tooLarge: "composer.tooLarge", unsupported: "composer.unsupported", imagesUnsupported: "composer.imagesUnsupported", - tooMany: "composer.tooMany", pastedText: "composer.pastedText", pastedLines: "transcript.pastedLines", editPasted: "composer.pastedText.edit", @@ -127,11 +125,8 @@ export function useAttachmentIntake(options: AttachmentIntakeOptions) { dropRef.current = next; setDropState(next); }); - const live = useRef({ options, t, held: attachments.length }); - live.current = { options, t, held: attachments.length }; - /// The files of every batch still being read, counted as soon as it comes in (before a render), - /// so a second paste meanwhile leaves room for the first one's chips. - const reading = useRef(0); + const live = useRef({ options, t }); + live.current = { options, t }; /// Each batch still being read, with the chat it belongs to (`scope`). const batches = useRef(new Set<{ scope?: string }>()); const scope = options.scope; @@ -150,28 +145,27 @@ export function useAttachmentIntake(options: AttachmentIntakeOptions) { /// paste, whose files the host of a chat on this Mac knows the paths of (`localPaths`). const [attach] = useState(() => async (files: File[], uploads = false, kind?: DroppedKind) => { if (files.length === 0) return; - const { options: current, t: translate, held } = live.current; - const ahead = reading.current; + const { options: current, t: translate } = live.current; const batch = { scope: current.scope }; // In the paste's or drop's own task, so React draws them before the next frame. - const shown: PendingAttachment[] = files - .slice(0, Math.max(0, MAX_ATTACHMENTS - held - ahead)) - .map((file) => ({ id: crypto.randomUUID(), name: file.name, image: isImageFile(file), scope: batch.scope })); + const shown: PendingAttachment[] = files.map((file) => ({ + id: crypto.randomUUID(), + name: file.name, + image: isImageFile(file), + scope: batch.scope, + })); const ids = new Set(shown.map((chip) => chip.id)); - reading.current += files.length; batches.current.add(batch); if (shown.length) setPending((chips) => [...chips, ...shown]); const paths = current.localPaths; // Their placeholders go in the same render as what they became (or when the read fails). const read = () => { - reading.current -= files.length; batches.current.delete(batch); if (shown.length) setPending((chips) => chips.filter((chip) => !ids.has(chip.id))); }; let taken: Awaited>; try { taken = await intakeFiles(files, { - held: held + ahead, allowImages: current.allowImages, ...(kind && paths ? { localPaths: (batch: File[]) => paths(kind, batch) } : {}), ...(uploads && current.upload ? { upload: current.upload } : {}), diff --git a/webviews/src/agent-session/acpmux/composer/intakeFiles.ts b/webviews/src/agent-session/acpmux/composer/intakeFiles.ts index 3f4318dcefb4..a74ffaf94ca3 100644 --- a/webviews/src/agent-session/acpmux/composer/intakeFiles.ts +++ b/webviews/src/agent-session/acpmux/composer/intakeFiles.ts @@ -5,7 +5,6 @@ // host's record of the drop or paste, `chat.attach.dropped`), else to the copy the chat's host // saves when it saves files (`chat.upload`, cloudUploads.ts). A folder has no copy. import { - MAX_ATTACHMENTS, linkAttachment, linkItems, readAttachments, @@ -22,8 +21,6 @@ import { MAX_UPLOAD_FILE_BYTES, MAX_UPLOAD_TOTAL_BYTES, type UploadResult } from const LINKABLE = new Set(["unsupported", "tooLarge", "imagesUnsupported"]); export type IntakeOptions = { - /// The chips the composer already holds. - held: number; allowImages: boolean; /// Where each file of the batch is on this Mac, in order, for a chat on this Mac (`chat.attach.dropped`); /// undefined or another count: the host does not know (the batch is not its drop or paste). @@ -41,23 +38,20 @@ export async function intakeFiles( // Asked before the files are read (decoding and scaling photos can take seconds): the host's // record of the drop or paste lasts only a few. const paths = options.localPaths?.(files).catch(() => undefined); - const read = await readAttachments(files, options.held, options.allowImages); - const local = await localLinks(files, read.errors, MAX_ATTACHMENTS - options.held - read.attachments.length, paths); + const read = await readAttachments(files, options.allowImages); + const local = await localLinks(files, read.errors, Number.POSITIVE_INFINITY, paths); const errors = read.errors.filter((refused) => !local.linked.has(refused)); - const room = MAX_ATTACHMENTS - options.held - read.attachments.length - local.linked.size; + const room = Number.POSITIVE_INFINITY; const sendable = options.upload ? savable(errors, room) : []; const sent = sendable.length > 0 ? await options.upload?.(sendable.map((refused) => refused.file)) : undefined; const linked = [...local.linked.values()]; if (sent && "error" in sent) return { attachments: [...read.attachments, ...linked], error: { text: sent.error } }; const saved = sent ? sent.paths.map((path, index) => savedAttachment(path, sendable[index]?.file.name ?? "")) : []; - // What the host did not link or save keeps its refusal; one it would have linked or saved but for - // the composer's chip limit is refused for that limit. - const full = sent !== undefined && saved.length >= room; + // What the host did not link or save keeps its refusal. const [first] = errors.filter((refused) => !sent || !sendable.includes(refused)); const attachments = [...read.attachments, ...linked, ...saved]; if (!first) return { attachments }; - const overLimit = (local.full && links(first)) || (full && saves(first)); - return { attachments, error: { name: first.name, reason: overLimit ? "tooMany" : first.reason } }; + return { attachments, error: { name: first.name, reason: first.reason } }; } /// The refused files of the batch that go as links to where they are on this Mac, by the host's diff --git a/webviews/src/agent-session/acpmux/composer/keptAttachments.ts b/webviews/src/agent-session/acpmux/composer/keptAttachments.ts index 727ba8ee7313..7abd5f843e12 100644 --- a/webviews/src/agent-session/acpmux/composer/keptAttachments.ts +++ b/webviews/src/agent-session/acpmux/composer/keptAttachments.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { type ComposerAttachment, MAX_ATTACHMENTS } from "../attachments"; +import type { ComposerAttachment } from "../attachments"; /// Where the host keeps each chat's unsent chips (AgentPaneDraftAttachments.swift), so reopening /// the chat, restoring it from the archive or relaunching after a crash brings them back with the @@ -90,7 +90,7 @@ export function useKeptAttachments( readOne.current = true; setAttachments((held) => { const others = keep ? held.filter((chip) => !back.some((k) => k.id === chip.id)) : []; - return back.length || others.length !== held.length ? [...back, ...others].slice(0, MAX_ATTACHMENTS) : held; + return back.length || others.length !== held.length ? [...back, ...others] : held; }); setReadFor(sessionId); }); diff --git a/webviews/src/agent-session/acpmux/composer/pastedChips.ts b/webviews/src/agent-session/acpmux/composer/pastedChips.ts index 362b0f7387ad..18e9f4ef221e 100644 --- a/webviews/src/agent-session/acpmux/composer/pastedChips.ts +++ b/webviews/src/agent-session/acpmux/composer/pastedChips.ts @@ -1,7 +1,7 @@ // The composer's side of a huge paste (pastedText.ts): it becomes a chip, unless Paste as Plain // Text just ran (⌘⇧V or the prompt's menu), and a chip's click puts its text back in the prompt. import { type RefObject, useEffect, useRef } from "react"; -import { MAX_ATTACHMENTS, type ComposerAttachment } from "../attachments"; +import type { ComposerAttachment } from "../attachments"; import type { MarkdownFieldHandle } from "../MarkdownField"; import { isLargePaste, pastedTextAttachment } from "../pastedText"; @@ -60,7 +60,7 @@ export function usePastedChips( /// Makes pasted `text` a chip when it is huge; true when it did. A composer already holding the /// most chips takes the text as it is. take(text: string): boolean { - if (raw() || !isLargePaste(text) || attachments.current.length >= MAX_ATTACHMENTS) return false; + if (raw() || !isLargePaste(text)) return false; const chip = pastedTextAttachment(text); // Ahead of the render, so a second paste at once counts this one. attachments.current = [...attachments.current, chip]; diff --git a/webviews/test/composer-attachment-matrix.test.ts b/webviews/test/composer-attachment-matrix.test.ts index 61d887fad924..2c15b86f6c82 100644 --- a/webviews/test/composer-attachment-matrix.test.ts +++ b/webviews/test/composer-attachment-matrix.test.ts @@ -12,7 +12,6 @@ // path): nothing is copied or uploaded. Images, PDFs and text files still go as blocks. import { afterEach, describe, expect, test } from "bun:test"; import { - MAX_ATTACHMENTS, MAX_TEXT_BYTES, linkItems, promptBlocks, @@ -242,13 +241,13 @@ describe("attachment matrix: what the agent receives per file type", () => { expect(result.error).toEqual({ text: "Disk full" }); }); - test("many files at once: up to the composer's limit, the rest refused", async () => { - const files = Array.from({ length: MAX_ATTACHMENTS + 2 }, (_, index) => + test("many files at once: every attachment is accepted", async () => { + const files = Array.from({ length: 12 }, (_, index) => index % 2 ? file(`shot-${index}.png`, "image/png", PNG) : file(`part-${index}.zip`, "application/zip"), ); const result = await take(files); - expect(result.attachments.length).toBe(MAX_ATTACHMENTS); - expect(result.error).toEqual({ name: "part-8.zip", reason: "tooMany" }); + expect(result.attachments.length).toBe(files.length); + expect(result.error).toBeUndefined(); }); test("no row puts a data: URI or a saved path in the prompt's text", async () => { @@ -382,15 +381,13 @@ describe("attachment matrix: a chat on this Mac links what no block takes where expect(only(result.attachments)).toMatchObject({ kind: "link", saved: true }); }); - test("links count toward the composer's limit; past it the note says so", async () => { + test("more than ten links are accepted", async () => { const local = paths(); - const files = Array.from({ length: MAX_ATTACHMENTS + 1 }, (_, index) => - file(`part-${index}.zip`, "application/zip"), - ); + const files = Array.from({ length: 11 }, (_, index) => file(`part-${index}.zip`, "application/zip")); const result = await take(files, { localPaths: local.localPaths }); - expect(result.attachments.length).toBe(MAX_ATTACHMENTS); + expect(result.attachments.length).toBe(files.length); expect(result.sent).toEqual([]); - expect(result.error).toEqual({ name: `part-${MAX_ATTACHMENTS}.zip`, reason: "tooMany" }); + expect(result.error).toBeUndefined(); }); }); From b28271986af0593035d5b41a225143b9b36124ca Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 15:09:46 -0700 Subject: [PATCH 2/3] fix(cmux-next): remove native upload file-count cap Allow chat.upload to carry more than ten files while preserving per-file and total-byte limits, and cover the parser with an eleven-file test. Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16a89-20f0-7ff2-8bb0-ec0bb3ae3a5e&host=big-red --- .../Sources/CmuxNextAgentPane/AgentPaneUpload.swift | 7 +++---- .../AgentPaneUploadTests.swift | 13 +++++++++++++ webviews/test/composer-attachment-matrix.test.ts | 1 - 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUploadTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneUpload.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneUpload.swift index 75b2e0342940..c3588e03e3c0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneUpload.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneUpload.swift @@ -23,19 +23,18 @@ public nonisolated struct AgentPaneUpload: Sendable, Equatable, CustomStringConv public var debugDescription: String { description } public var customMirror: Mirror { Mirror(self, children: [:]) } - public static let maximumFiles = 10 public static let maximumFileBytes = 32 * 1024 * 1024 public static let maximumTotalBytes = 64 * 1024 * 1024 /// The most bytes one file for a chat on an ssh peer has (acpmux `file_uploads::MAX_BYTES`): it /// crosses the peer link as one WebSocket frame, and an older peer reads at most 16 MiB. public static let maximumPeerFileBytes = 10 * 1024 * 1024 - /// The files of `chat.upload`'s params, nil when they break the contract: 1 to - /// ``maximumFiles`` files, each with a name a folder holds as one file and base64 bytes, at most + /// The files of `chat.upload`'s params, nil when they break the contract: one or more files, + /// each with a name a folder holds as one file and base64 bytes, at most /// ``maximumFileBytes`` each and ``maximumTotalBytes`` in all. static func files(_ params: [String: Any]?) -> [AgentPaneUpload]? { guard let params, Set(params.keys) == ["files"], let list = params["files"] as? [[String: Any]], - (1...maximumFiles).contains(list.count) else { return nil } + !list.isEmpty else { return nil } var files: [AgentPaneUpload] = [] var total = 0 for item in list { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUploadTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUploadTests.swift new file mode 100644 index 000000000000..ea03d97cfe06 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUploadTests.swift @@ -0,0 +1,13 @@ +import Foundation +import Testing +@testable import CmuxNextAgentPane + +struct AgentPaneUploadTests { + @Test func acceptsMoreThanTenFilesWithinByteAndNameLimits() { + let files: [[String: Any]] = (0..<11).map { index in + ["name": "part-\(index).zip", "data": Data([UInt8(index)]).base64EncodedString()] + } + let parsed = AgentPaneUpload.files(["files": files]) + #expect(parsed?.count == 11) + } +} diff --git a/webviews/test/composer-attachment-matrix.test.ts b/webviews/test/composer-attachment-matrix.test.ts index 2c15b86f6c82..57669549af7a 100644 --- a/webviews/test/composer-attachment-matrix.test.ts +++ b/webviews/test/composer-attachment-matrix.test.ts @@ -60,7 +60,6 @@ async function take( const upload = typeof options.upload === "function" ? options.upload : options.upload === false ? undefined : saving.upload; const taken = await intakeFiles(files, { - held: 0, allowImages: options.allowImages ?? true, ...(upload ? { upload } : {}), ...(options.localPaths ? { localPaths: options.localPaths } : {}), From 1d1b9c92b991cbe99ca74875f21c410aec16fc34 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 23 Oct 2026 15:58:16 -0700 Subject: [PATCH 3/3] feat(cmux-next): stream large peer uploads Agent-Session: https://cmux.com/deeplink/agent?harness=codex&session=01a16ae7-d1c6-76d2-a166-66074373b755&host=big-red --- .../CmuxNextAgentPane/AgentPaneFilePut.swift | 47 ++++- .../CmuxNextAgentPane/AgentPaneUpload.swift | 13 +- .../CmuxNextApp/Cloud/CloudUploads.swift | 9 + .../AgentPaneFilePutTests.swift | 27 ++- .../AgentPeerUploadsTests.swift | 12 +- .../crates/acpmux/src/hub/file_uploads.rs | 180 +++++++++++++++++- cmux-tui/crates/acpmux/src/protocol/mod.rs | 3 + cmux-tui/crates/acpmux/src/rpc.rs | 4 + .../crates/acpmux/src/server/peer_forward.rs | 6 +- .../crates/acpmux/src/server/remote_guard.rs | 9 +- cmux-tui/crates/acpmux/src/server/requests.rs | 7 +- 11 files changed, 281 insertions(+), 36 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFilePut.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFilePut.swift index 019db86a7fe6..3181fcf88a86 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFilePut.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFilePut.swift @@ -32,15 +32,28 @@ extension AcpmuxStatusClient { /// `hub/file_uploads.rs`); one without it is never sent the bytes. static let filePutFeature = "filePut" static let filePutMethod = "_acpmux/file_put" + static let filePutChunksFeature = "filePutChunks" + static let filePutBeginMethod = "_acpmux/file_put_begin" + static let filePutChunkMethod = "_acpmux/file_put_chunk" + static let filePutFinishMethod = "_acpmux/file_put_finish" + static let legacyFilePutBytes = 10 * 1024 * 1024 + static let filePutChunkBytes = 512 * 1024 /// `_acpmux/file_put {sessionId, fileName, data}` on this Mac's acpmux, which saves the file on the /// machine that runs session `sessionId` (a peer's chat goes to that peer) and answers its path /// there. Throws ``AgentPaneFilePutFailure``. @concurrent static func putFile(socketPath: String, sessionId: String, name: String, data: Data, deadline: Duration = .seconds(120)) async throws -> String { - try await putFile(sessionId: sessionId, name: name, data: data) { params in - try await call(socketPath: socketPath, method: filePutMethod, params: params, deadline: deadline, - detailed: true, requiredFeature: filePutFeature) + guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge } + if data.count <= legacyFilePutBytes { + return try await putFile(sessionId: sessionId, name: name, data: data) { params in + try await call(socketPath: socketPath, method: filePutMethod, params: params, deadline: deadline, + detailed: true, requiredFeature: filePutFeature) + } + } + return try await putFileChunked(sessionId: sessionId, name: name, data: data) { method, params in + try await call(socketPath: socketPath, method: method, params: params, deadline: deadline, + detailed: true, requiredFeature: filePutChunksFeature) } } @@ -57,4 +70,32 @@ extension AcpmuxStatusClient { guard let path = result["path"] as? String, !path.isEmpty else { throw AgentPaneFilePutFailure.failed } return path } + + /// Streams a larger upload over begin/chunk/finish requests. Each request is a fresh bounded + /// exchange, so a peer WebSocket never receives the entire base64 file in one frame. + static func putFileChunked(sessionId: String, name: String, data: Data, + send: @Sendable (_ method: String, _ params: [String: any Sendable]) async throws -> [String: Any]) async throws -> String { + guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge } + let started: [String: Any] + do { + started = try await send(filePutBeginMethod, ["sessionId": sessionId, "fileName": name, "size": data.count]) + guard let upload = started["uploadId"] as? String, !upload.isEmpty else { throw AgentPaneFilePutFailure.failed } + var offset = 0 + while offset < data.count { + let end = min(offset + filePutChunkBytes, data.count) + let piece = data[offset.. [String: Any] { + let total = files.reduce(into: 0) { $0 += $1.data.count } + guard !files.isEmpty, total <= maximumTotalBytes, + files.allSatisfy({ $0.data.count <= maximumFileBytes }) else { + return AgentPaneReply.failure(code: "upload_refused", message: CloudUploadStrings.refused) + } guard let socket = await target.machine.daemon.connection?.endpoint?.socketPath, let pastes else { return AgentPaneReply.failure(code: "link_error", message: CloudUploadStrings.failed) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneFilePutTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneFilePutTests.swift index 53b19b2bc312..94c286f7aeb7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneFilePutTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneFilePutTests.swift @@ -54,14 +54,23 @@ struct AgentPaneFilePutTests { #expect(AgentPaneFilePutFailure(AcpmuxStatusClient.Failure.unreachable("no daemon")) == .failed) } - @Test func aFilePastThePeerLimitIsNeverSent() async { - await #expect(throws: AgentPaneFilePutFailure.tooLarge) { - try await AcpmuxStatusClient.putFile(sessionId: "s", name: "big.bin", - data: Data(count: AgentPaneUpload.maximumPeerFileBytes + 1)) { _ in - Issue.record("an oversize file was sent") - return [:] + @Test func resumableUploadUsesBoundedChunksAndCommitsThePath() async throws { + let data = Data(repeating: 7, count: AcpmuxStatusClient.filePutChunkBytes * 2 + 3) + let calls = CallLog() + let path = try await AcpmuxStatusClient.putFileChunked(sessionId: "s", name: "big.bin", data: data) { method, params in + await calls.add(method) + switch method { + case AcpmuxStatusClient.filePutBeginMethod: return ["uploadId": "0190a000-0000-7000-8000-000000000000"] + case AcpmuxStatusClient.filePutChunkMethod: + let offset = params["offset"] as? Int ?? 0 + let bytes = Data(base64Encoded: params["data"] as? String ?? "")?.count ?? 0 + return ["offset": offset + bytes] + default: return ["path": "/uploads/s/big.bin"] } } + #expect(path == "/uploads/s/big.bin") + let methods = await calls.methods() + #expect(methods == ["_acpmux/file_put_begin", "_acpmux/file_put_chunk", "_acpmux/file_put_chunk", "_acpmux/file_put_chunk", "_acpmux/file_put_finish"]) } @Test func aHostWithoutAnAcpmuxTakesNoFiles() async { @@ -95,4 +104,10 @@ struct AgentPaneFilePutTests { } func writes() -> [Data] { sent } } + + private actor CallLog { + var entries: [String] = [] + func add(_ method: String) { entries.append(method) } + func methods() -> [String] { entries } + } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPeerUploadsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPeerUploadsTests.swift index 49afcbbae3db..8f007e0a96ba 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPeerUploadsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPeerUploadsTests.swift @@ -62,16 +62,8 @@ import Testing #expect(Self.code(failed) == "upload_failed") } - @Test func aFilePastThePeerLimitIsRefusedBeforeAnyIsSent() async { - let files = [AgentPaneUpload(name: "a.txt", data: Data("x".utf8)), - AgentPaneUpload(name: "big.bin", data: Data(count: AgentPaneUpload.maximumPeerFileBytes + 1))] - var sent = 0 - let reply = await AgentPeerUploads.upload(files, machine: "huge-blue") { _, _ in - sent += 1 - return "/x" - } - #expect(sent == 0) - #expect(Self.code(reply) == "too_large") + @Test func peerTransportAllowsFilesLargerThanTheFormerFrameCap() { + #expect(AgentPaneUpload.maximumPeerFileBytes > 64 * 1024 * 1024) } private static func code(_ reply: [String: Any]) -> String? { diff --git a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs index 88852c0b822c..c8d5bf9b4a72 100644 --- a/cmux-tui/crates/acpmux/src/hub/file_uploads.rs +++ b/cmux-tui/crates/acpmux/src/hub/file_uploads.rs @@ -7,10 +7,9 @@ //! `peer_forward.rs` takes it to the daemon that owns it, and a peer that does not advertise //! [`FEATURE`] is refused before anything is sent. //! -//! Each file is at most [`MAX_BYTES`]: the request crosses the peer link as one WebSocket frame, -//! and an older peer's listener reads at most tungstenite's default 16 MiB frame (a larger one -//! resets the link and every chat on it). 10 MiB is 13.4 MiB of base64, leaving room for the rest -//! of the request. +//! The old one-shot request is bounded to [`LEGACY_MAX_BYTES`] because it crosses the peer link as +//! one WebSocket frame. New clients use the resumable methods below, whose chunks are bounded by +//! [`CHUNK_BYTES`], and may send a larger file up to [`MAX_BYTES`]. //! //! A chat's files go when the chat is deleted (`_acpmux/kill` with purge, [`remove_session`]; not //! when Bring Back replaces its closed copy under the same id, `transfer_in.rs`), and @@ -20,14 +19,21 @@ use crate::rpc::RpcError; use serde_json::{Value, json}; +use std::io::{Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; /// The `initialize` feature of a daemon that takes `_acpmux/file_put`. pub const FEATURE: &str = "filePut"; -/// The most bytes one file has (see the module doc for why). -pub const MAX_BYTES: usize = 10 << 20; +/// The resumable upload feature advertised by current daemons. +pub const CHUNKS_FEATURE: &str = "filePutChunks"; +/// The largest file a local or peer upload may hold. +pub const MAX_BYTES: usize = 1 << 30; +/// The largest one-shot request accepted for compatibility with older peers. +pub const LEGACY_MAX_BYTES: usize = 10 << 20; +/// The largest decoded chunk. Its base64 form stays below one MiB on the wire. +pub const CHUNK_BYTES: usize = 512 << 10; fn root() -> PathBuf { crate::config::home().join("uploads") @@ -165,6 +171,140 @@ fn too_large() -> RpcError { .with_data(json!({"reason": "filePut.tooLarge", "maxBytes": MAX_BYTES})) } +fn upload_id(id: &str) -> Option { + uuid::Uuid::try_parse(id).ok().filter(|id| id.get_version_num() == 7).map(|id| id.to_string()) +} + +fn part_path(session: &str, upload: &str) -> Option { + Some(session_dir(session)?.join(upload_id(upload)?).join(".part")) +} + +fn meta_path(session: &str, upload: &str) -> Option { + Some(session_dir(session)?.join(upload_id(upload)?).join(".meta")) +} + +fn begin(session: &str, params: &Value) -> Result { + let name = params + .get("fileName") + .and_then(Value::as_str) + .filter(|name| is_file_name(name)) + .ok_or_else(|| { + RpcError::invalid_params("fileName must be a plain file name") + .with_data(json!({"reason": "filePut.name"})) + })?; + let size = params + .get("size") + .and_then(Value::as_u64) + .ok_or_else(|| RpcError::invalid_params("size is required"))?; + if size > MAX_BYTES as u64 { + return Err(too_large()); + } + let id = uuid::Uuid::now_v7().to_string(); + let path = part_path(session, &id).ok_or_else(|| RpcError::invalid_params("no such chat"))?; + crate::config::private_state_dir(path.parent().unwrap()) + .map_err(|e| RpcError::internal(e.to_string()))?; + crate::agent_tools::write_private(&path, &[]) + .map_err(|e| RpcError::internal(format!("cannot start upload: {e}")))?; + let meta = + meta_path(session, &id).ok_or_else(|| RpcError::invalid_params("invalid uploadId"))?; + let metadata = serde_json::to_vec(&json!({"fileName": name, "size": size})) + .map_err(|e| RpcError::internal(format!("cannot encode upload metadata: {e}")))?; + crate::agent_tools::write_private(&meta, &metadata) + .map_err(|e| RpcError::internal(format!("cannot record upload: {e}")))?; + Ok(json!({"uploadId": id, "fileName": name, "size": size})) +} + +fn chunk(params: &Value) -> Result { + let upload = params + .get("uploadId") + .and_then(Value::as_str) + .ok_or_else(|| RpcError::invalid_params("uploadId is required"))?; + let session = params + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| RpcError::invalid_params("sessionId is required"))?; + let offset = params + .get("offset") + .and_then(Value::as_u64) + .ok_or_else(|| RpcError::invalid_params("offset is required"))?; + let data = params + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| RpcError::invalid_params("data (base64) is required"))?; + if decoded_len(data) > CHUNK_BYTES { + return Err(RpcError::invalid_params(format!( + "a chunk is at most {} KiB", + CHUNK_BYTES >> 10 + ))); + } + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| RpcError::invalid_params(format!("data is not base64: {e}")))?; + let path = + part_path(session, upload).ok_or_else(|| RpcError::invalid_params("invalid uploadId"))?; + let mut file = crate::config::private_state_rw(&path) + .map_err(|e| RpcError::invalid_params(format!("unknown upload: {e}")))?; + let current = file.metadata().map_err(|e| RpcError::internal(e.to_string()))?.len(); + if current != offset { + return Err(RpcError::invalid_params(format!("upload offset is {current}, got {offset}"))); + } + let next = current.saturating_add(bytes.len() as u64); + if next > MAX_BYTES as u64 { + return Err(too_large()); + } + file.seek(SeekFrom::End(0)).map_err(|e| RpcError::internal(e.to_string()))?; + file.write_all(&bytes).map_err(|e| RpcError::internal(format!("cannot append upload: {e}")))?; + file.sync_data().map_err(|e| RpcError::internal(format!("cannot sync upload: {e}")))?; + Ok(json!({"offset": next})) +} + +fn finish(session: &str, params: &Value) -> Result { + let upload = params + .get("uploadId") + .and_then(Value::as_str) + .ok_or_else(|| RpcError::invalid_params("uploadId is required"))?; + let name = params + .get("fileName") + .and_then(Value::as_str) + .filter(|name| is_file_name(name)) + .ok_or_else(|| RpcError::invalid_params("fileName must be a plain file name"))?; + let size = params + .get("size") + .and_then(Value::as_u64) + .ok_or_else(|| RpcError::invalid_params("size is required"))?; + if size > MAX_BYTES as u64 { + return Err(too_large()); + } + let part = + part_path(session, upload).ok_or_else(|| RpcError::invalid_params("invalid uploadId"))?; + let meta = + meta_path(session, upload).ok_or_else(|| RpcError::invalid_params("invalid uploadId"))?; + let metadata = crate::config::private_state_file(&meta) + .map_err(|e| RpcError::invalid_params(format!("unknown upload: {e}")))?; + let declared: Value = serde_json::from_reader(metadata) + .map_err(|e| RpcError::invalid_params(format!("invalid upload metadata: {e}")))?; + if declared.get("fileName").and_then(Value::as_str) != Some(name) + || declared.get("size").and_then(Value::as_u64) != Some(size) + { + return Err(RpcError::invalid_params("upload metadata does not match")); + } + let current = std::fs::metadata(&part) + .map_err(|e| RpcError::invalid_params(format!("unknown upload: {e}")))? + .len(); + if current != size { + return Err(RpcError::invalid_params(format!( + "upload has {current} bytes, expected {size}" + ))); + } + let dir = part.parent().ok_or_else(|| RpcError::internal("upload has no folder"))?; + let path = dir.join(name); + std::fs::rename(&part, &path) + .map_err(|e| RpcError::internal(format!("cannot finish upload: {e}")))?; + let _ = std::fs::remove_file(meta); + Ok(json!({"path": path.to_string_lossy()})) +} + /// The request's `fileName` and `data` (base64) when they keep the contract, before anything is /// decoded or sent on: the daemon a request reaches first checks it, so no oversize frame goes /// over a peer link. (Not `name`: that names a session (`server::session_key`), and a forward to @@ -182,7 +322,7 @@ pub fn check(params: &Value) -> Result<(&str, &str), RpcError> { .get("data") .and_then(Value::as_str) .ok_or_else(|| RpcError::invalid_params("data (base64) is required"))?; - if decoded_len(data) > MAX_BYTES { + if decoded_len(data) > LEGACY_MAX_BYTES { return Err(too_large()); } Ok((name, data)) @@ -213,6 +353,32 @@ pub async fn handle(hub: &super::Hub, params: Value) -> Result Ok(answer) } +pub async fn handle_begin(hub: &super::Hub, params: Value) -> Result { + let session = hub.resolve(crate::server::session_key(¶ms)?)?; + let id = session.id.clone(); + tokio::task::spawn_blocking(move || begin(&id, ¶ms)) + .await + .map_err(|e| RpcError::internal(e.to_string()))? +} + +pub async fn handle_chunk(hub: &super::Hub, params: Value) -> Result { + let session = hub.resolve(crate::server::session_key(¶ms)?)?; + if params.get("sessionId").and_then(Value::as_str) != Some(session.id.as_str()) { + return Err(RpcError::invalid_params("sessionId is required")); + } + tokio::task::spawn_blocking(move || chunk(¶ms)) + .await + .map_err(|e| RpcError::internal(e.to_string()))? +} + +pub async fn handle_finish(hub: &super::Hub, params: Value) -> Result { + let session = hub.resolve(crate::server::session_key(¶ms)?)?; + let id = session.id.clone(); + tokio::task::spawn_blocking(move || finish(&id, ¶ms)) + .await + .map_err(|e| RpcError::internal(e.to_string()))? +} + /// Writes the file of `params` for session `id` (this daemon's own) and answers `{path}`. /// Blocking: run it off the runtime. pub fn put(id: &str, params: &Value) -> Result { diff --git a/cmux-tui/crates/acpmux/src/protocol/mod.rs b/cmux-tui/crates/acpmux/src/protocol/mod.rs index 5fb50ee6faae..adab69a45355 100644 --- a/cmux-tui/crates/acpmux/src/protocol/mod.rs +++ b/cmux-tui/crates/acpmux/src/protocol/mod.rs @@ -220,6 +220,9 @@ pub fn registry() -> Vec { untyped(method::MUX_EXPORT, Request), untyped(method::MUX_IMPORT, Request), untyped(method::MUX_FILE_PUT, Request), + untyped(method::MUX_FILE_PUT_BEGIN, Request), + untyped(method::MUX_FILE_PUT_CHUNK, Request), + untyped(method::MUX_FILE_PUT_FINISH, Request), // Permissions. request::(method::MUX_PERMISSION_RESPOND), request::(method::MUX_PERMISSION_GROUPS), diff --git a/cmux-tui/crates/acpmux/src/rpc.rs b/cmux-tui/crates/acpmux/src/rpc.rs index 285729f3524e..0c4ff0d27586 100644 --- a/cmux-tui/crates/acpmux/src/rpc.rs +++ b/cmux-tui/crates/acpmux/src/rpc.rs @@ -338,6 +338,10 @@ pub mod method { /// A file the person dropped into a chat, saved on the machine that runs it /// (`hub/file_uploads.rs`). pub const MUX_FILE_PUT: &str = "_acpmux/file_put"; + /// Resumable file uploads: begin a private part, append bounded chunks, then commit it. + pub const MUX_FILE_PUT_BEGIN: &str = "_acpmux/file_put_begin"; + pub const MUX_FILE_PUT_CHUNK: &str = "_acpmux/file_put_chunk"; + pub const MUX_FILE_PUT_FINISH: &str = "_acpmux/file_put_finish"; pub const MUX_SHUTDOWN: &str = "_acpmux/shutdown"; /// Fork a session through its latest completed turn (`server/fork_through.rs`). pub const ACP_SESSION_FORK: &str = "acp.session.fork"; diff --git a/cmux-tui/crates/acpmux/src/server/peer_forward.rs b/cmux-tui/crates/acpmux/src/server/peer_forward.rs index a5b7dae863d1..730974658b5f 100644 --- a/cmux-tui/crates/acpmux/src/server/peer_forward.rs +++ b/cmux-tui/crates/acpmux/src/server/peer_forward.rs @@ -184,7 +184,11 @@ async fn forward_inner( hub.peer_accepts.forget(&peer.name, prompt_id, token); } let mut result = result.map_err(|e| { - if e.message.contains("Method not found") { + if matches!(m, method::MUX_FILE_PUT_BEGIN | method::MUX_FILE_PUT_CHUNK | method::MUX_FILE_PUT_FINISH) + && e.message.contains("Method not found") + { + crate::hub::file_uploads::peer_unsupported(&peer) + } else if e.message.contains("Method not found") { RpcError::internal(format!("{}; peer {} runs acpmux build {} (this daemon: {}); run `acpmux host update {}`", e.message, peer.name, peer.remote_build().unwrap_or_else(|| "unknown".into()), crate::hub::BUILD, peer.name)) } else { e diff --git a/cmux-tui/crates/acpmux/src/server/remote_guard.rs b/cmux-tui/crates/acpmux/src/server/remote_guard.rs index 39ac29bc0997..0f2409c14dc4 100644 --- a/cmux-tui/crates/acpmux/src/server/remote_guard.rs +++ b/cmux-tui/crates/acpmux/src/server/remote_guard.rs @@ -321,7 +321,14 @@ pub(super) async fn check( .with_data(serde_json::json!({"reason": "tools.remote"}))); } // A dropped file is saved from the app or the unix socket only (`hub/file_uploads.rs`). - if m == method::MUX_FILE_PUT && control_of(origin, auth, params) == crate::hub::Control::Web { + if matches!( + m, + method::MUX_FILE_PUT + | method::MUX_FILE_PUT_BEGIN + | method::MUX_FILE_PUT_CHUNK + | method::MUX_FILE_PUT_FINISH + ) && control_of(origin, auth, params) == crate::hub::Control::Web + { return Err(RpcError::invalid_params( "filePut.remote: a file is saved for a chat only from the app or over the local unix socket", ) diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index f20ac0814ded..37c30f80a338 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -204,9 +204,9 @@ async fn dispatch_request( method::MUX_DETACH, method::MUX_WATCH, method::MUX_HARNESS_INVENTORY_WATCH, method::MUX_HARNESS_INVENTORY_REFRESH, method::MUX_HARNESS_UPDATE_PLAN, method::MUX_HARNESS_UPDATE_PREPARE, method::MUX_RENAME, method::MUX_KILL, method::MUX_QUEUE_REMOVE, method::MUX_QUEUE_EDIT, method::MUX_INFO, method::MUX_EVENTS, method::MUX_ATTACHMENT, method::MUX_MAIL_SEND, method::MUX_MAIL_READ, method::MUX_MAIL_WAIT, method::MUX_MAIL_ACK, method::MUX_PERMISSION_RESPOND, method::MUX_PROCESS_LIST, method::MUX_PROCESS_TAIL, method::MUX_PROCESS_KILL, method::MUX_PROCESS_BACKGROUND, method::MUX_WORKTREE_REPORT, method::MUX_WORKTREE_CLEAN, method::MUX_WORKTREE_RESTORE, method::MUX_DRAFT_GET, method::MUX_DRAFT_SET, method::MUX_COMPOSER_SET, method::MUX_COMPOSERS, method::MUX_BOOKMARK_GET, method::MUX_BOOKMARK_SET, method::MUX_ASK, method::MUX_BTW, method::MUX_VIEW, method::MUX_MARK_UNREAD, method::MUX_ARCHIVE, method::MUX_SIDE, - method::MUX_SET_POLICY, method::MUX_SET_TOOLS, method::MUX_EXPORT, method::MUX_IMPORT, method::MUX_FILE_PUT, method::MUX_SHUTDOWN, + method::MUX_SET_POLICY, method::MUX_SET_TOOLS, method::MUX_EXPORT, method::MUX_IMPORT, method::MUX_FILE_PUT, method::MUX_FILE_PUT_BEGIN, method::MUX_FILE_PUT_CHUNK, method::MUX_FILE_PUT_FINISH, method::MUX_SHUTDOWN, ], "operations": crate::hub::HANDOFF_OPERATIONS.iter().chain(crate::hub::PERMISSION_GROUP_OPERATIONS.iter()).chain(super::fork_through::FORK_OPERATIONS.iter()).collect::>(), "handoff": {"maxCapsuleBytes": crate::hub::MAX_CAPSULE_BYTES}, - "features": ["promptAccepted", "turnIds", "eventPaging", "eventKinds", "eventStream", "cancelRequest", "messageSuperseded", "turnErrorText", "permissionGroups", "trustGate", "chiefBuiltinPresets", "personChallenge", "queueEdit", "steerInterrupt", "personHolds", "sessionTransfer", "approverDecisions", "toolInventory", "promptWithdraw", "processes", "worktreeCare", "harnessInventory", super::inventory_watch::FEATURE, crate::harness_health::INVENTORY_REFRESH_FEATURE, crate::harness_health::UPDATE_PLAN_FEATURE, crate::harness_health::UPDATE_PREPARE_FEATURE, "surfaces", crate::hub::FILE_PUT_FEATURE], "trustGate": true}} + "features": ["promptAccepted", "turnIds", "eventPaging", "eventKinds", "eventStream", "cancelRequest", "messageSuperseded", "turnErrorText", "permissionGroups", "trustGate", "chiefBuiltinPresets", "personChallenge", "queueEdit", "steerInterrupt", "personHolds", "sessionTransfer", "approverDecisions", "toolInventory", "promptWithdraw", "processes", "worktreeCare", "harnessInventory", super::inventory_watch::FEATURE, crate::harness_health::INVENTORY_REFRESH_FEATURE, crate::harness_health::UPDATE_PLAN_FEATURE, crate::harness_health::UPDATE_PREPARE_FEATURE, "surfaces", crate::hub::FILE_PUT_FEATURE, crate::hub::file_uploads::CHUNKS_FEATURE], "trustGate": true}} })) } method::AUTHENTICATE => Ok(json!({})), @@ -1136,6 +1136,9 @@ async fn dispatch_request( } // A file dropped into one of this daemon's chats; a peer's went there (`peer_forward.rs`). method::MUX_FILE_PUT => crate::hub::file_uploads::handle(hub, params).await, + method::MUX_FILE_PUT_BEGIN => crate::hub::file_uploads::handle_begin(hub, params).await, + method::MUX_FILE_PUT_CHUNK => crate::hub::file_uploads::handle_chunk(hub, params).await, + method::MUX_FILE_PUT_FINISH => crate::hub::file_uploads::handle_finish(hub, params).await, method::MUX_SHUTDOWN => { // `endAgents: true` (the app's Quit Everything) ends agents that // run under agent hosts; otherwise they keep running for the next