From 48442885bd7130af2797f6e19fff8e8e0f4c84ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 15:28:42 +0200 Subject: [PATCH 1/6] fix(mobile): gate chat attachment size before materializing the file --- .../message-attachment-picker.test.ts | 147 +++++++++- .../kilo-chat/message-attachment-picker.ts | 70 ++--- .../message-attachment-state.test.ts | 251 +++++++++++------- .../kilo-chat/message-attachment-state.ts | 85 +++--- .../message-input-attachment-queue.tsx | 71 ++--- 5 files changed, 413 insertions(+), 211 deletions(-) diff --git a/apps/mobile/src/components/kilo-chat/message-attachment-picker.test.ts b/apps/mobile/src/components/kilo-chat/message-attachment-picker.test.ts index e3f48844ce..9b6f4496b2 100644 --- a/apps/mobile/src/components/kilo-chat/message-attachment-picker.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-attachment-picker.test.ts @@ -1,16 +1,27 @@ import { type AlertButton } from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { pickCameraImage, pickLibraryImages } from './message-attachment-picker'; +import { + materializeAttachment, + pickCameraImage, + pickFiles, + pickLibraryImages, +} from './message-attachment-picker'; +import { normalizeAttachmentSelection } from './message-attachment-state'; -const fileSystemMock = vi.hoisted(() => ({ - File: vi.fn(function File(this: { name: string; size: number; type: string }, uri: string) { - this.name = uri.split('/').at(-1) ?? 'attachment'; - this.size = 12; - this.type = 'image/jpeg'; - }), -})); +const fileSystemMock = vi.hoisted(() => { + const state = { fileSize: 12 }; + return { + state, + File: vi.fn(function File(this: { name: string; size: number; type: string }, uri: string) { + this.name = uri.split('/').at(-1) ?? 'attachment'; + this.size = state.fileSize; + this.type = 'image/jpeg'; + }), + }; +}); const reactNativeMock = vi.hoisted(() => ({ alert: vi.fn(), @@ -44,6 +55,7 @@ const requestMediaLibraryPermissionsMock = vi.mocked( ); const launchCameraMock = vi.mocked(ImagePicker.launchCameraAsync); const launchImageLibraryMock = vi.mocked(ImagePicker.launchImageLibraryAsync); +const getDocumentAsyncMock = vi.mocked(DocumentPicker.getDocumentAsync); function deniedPermissionResponse(): ImagePicker.PermissionResponse { return { @@ -59,6 +71,7 @@ describe('message attachment picker permissions', () => { beforeEach(() => { vi.clearAllMocks(); + fileSystemMock.state.fileSize = 12; globalThis.fetch = vi.fn(async () => { await Promise.resolve(); return new Response(new Blob(['x'], { type: 'image/jpeg' })); @@ -117,13 +130,123 @@ describe('message attachment picker permissions', () => { expect(reactNativeMock.alert).not.toHaveBeenCalled(); expect(result).toEqual([ { - input: { - blob: expect.objectContaining({ type: 'image/jpeg' }), - filename: 'photo.jpg', + uri: 'file:///photo.jpg', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 42, + isImage: true, + }, + ]); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('returns normalized document selections without materializing', async () => { + getDocumentAsyncMock.mockResolvedValue({ + canceled: false, + assets: [ + { + uri: 'file:///notes.pdf', + name: 'notes.pdf', + mimeType: 'application/pdf', + size: 2048, + lastModified: 0, + }, + ], + }); + + const result = await pickFiles(); + + expect(result).toEqual([ + { + uri: 'file:///notes.pdf', + filename: 'notes.pdf', + mimeType: 'application/pdf', + size: 2048, + isImage: false, + }, + ]); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('materializeAttachment fetches the file uri and returns a picked attachment', async () => { + const attachment = normalizeAttachmentSelection({ + uri: 'file:///photo.jpg', + name: 'photo.jpg', + mimeType: 'image/jpeg', + size: 42, + }); + + const result = await materializeAttachment(attachment); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith('file:///photo.jpg'); + expect(result).toEqual({ + input: { + blob: expect.objectContaining({ type: 'image/jpeg' }), + filename: 'photo.jpg', + mimeType: 'image/jpeg', + }, + localUri: 'file:///photo.jpg', + }); + }); + + it('uses the larger of measured file size and reported picker size', async () => { + fileSystemMock.state.fileSize = 4096; + launchImageLibraryMock.mockResolvedValue({ + assets: [ + { + uri: 'file:///photo.jpg', + fileName: 'photo.jpg', + mimeType: 'image/jpeg', + fileSize: 12, + height: 100, + width: 100, + }, + ], + canceled: false, + }); + + const result = await pickLibraryImages(); + + expect(result).toEqual([ + { + uri: 'file:///photo.jpg', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 4096, + isImage: true, + }, + ]); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('uses reported picker size when the file stat is unreadable', async () => { + fileSystemMock.state.fileSize = 0; + launchImageLibraryMock.mockResolvedValue({ + assets: [ + { + uri: 'file:///photo.jpg', + fileName: 'photo.jpg', mimeType: 'image/jpeg', + fileSize: 2048, + height: 100, + width: 100, }, - localUri: 'file:///photo.jpg', + ], + canceled: false, + }); + + const result = await pickLibraryImages(); + + expect(result).toEqual([ + { + uri: 'file:///photo.jpg', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 2048, + isImage: true, }, ]); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-attachment-picker.ts b/apps/mobile/src/components/kilo-chat/message-attachment-picker.ts index 423ace6fce..491ea25081 100644 --- a/apps/mobile/src/components/kilo-chat/message-attachment-picker.ts +++ b/apps/mobile/src/components/kilo-chat/message-attachment-picker.ts @@ -5,6 +5,7 @@ import { Alert, Linking } from 'react-native'; import { type AddFileInput } from '@kilocode/kilo-chat-hooks'; import { + type MessageAttachment, type NativeAttachmentSelection, normalizeAttachmentSelection, } from './message-attachment-state'; @@ -21,32 +22,43 @@ const IMAGE_PICKER_OPTIONS = { quality: 1, } satisfies ImagePicker.ImagePickerOptions; -async function assetToPickedAttachment(asset: LocalAttachmentAsset): Promise { +function assetToSelection(asset: LocalAttachmentAsset): MessageAttachment { const file = new File(asset.uri); - const attachment = normalizeAttachmentSelection({ + return normalizeAttachmentSelection({ uri: asset.uri, name: asset.name, fileName: asset.fileName ?? file.name, mimeType: asset.mimeType ?? file.type, - size: asset.size ?? asset.fileSize ?? file.size, + // Never trust the smaller number. `File.size` is `0` when the file does not + // exist or cannot be read, and picker-reported sizes are unreliable (the + // cloud-agent path re-measures for the same reason, see + // `lib/agent-attachments/validate.ts`). Taking the max keeps the gate + // fail-closed both ways: an unreadable file with no reported size lands on + // 0 and is rejected before any bytes are read, and a file the picker + // reports as larger than the stat is judged on the larger figure. + size: Math.max(file.size, asset.size ?? 0, asset.fileSize ?? 0), fileSize: asset.fileSize, }); +} - // Materialize a real `Blob` from the file:// URI so the upload PUT carries - // the correct body and matches the signed Content-Length. expo/fetch (global - // since SDK 56) supports file:// on iOS (NativeResponse.swift) and Android - // (OkHttpFileUrlInterceptor.kt); File implements Blob but XHR still needs a - // store-backed Blob for the signed PUT. - const response = await fetch(asset.uri); +/** + * Materialize a real `Blob` from the file:// URI so the upload PUT carries the + * correct body and matches the signed Content-Length. expo/fetch (global since + * SDK 56) supports file:// on iOS (NativeResponse.swift) and Android + * (OkHttpFileUrlInterceptor.kt); File implements Blob but XHR still needs a + * store-backed Blob for the signed PUT. + * + * Only ever called for attachments that already passed + * `selectAllowedAttachments` — this reads the whole file into memory. + */ +export async function materializeAttachment( + attachment: MessageAttachment +): Promise { + const response = await fetch(attachment.uri); const blob = await response.blob(); - return { - input: { - blob, - filename: attachment.filename, - mimeType: attachment.mimeType, - }, - localUri: asset.uri, + input: { blob, filename: attachment.filename, mimeType: attachment.mimeType }, + localUri: attachment.uri, }; } @@ -57,7 +69,7 @@ function showPermissionSettingsAlert({ message, title }: { message: string; titl ]); } -export async function pickCameraImage(): Promise { +export async function pickCameraImage(): Promise { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) { showPermissionSettingsAlert({ @@ -73,10 +85,10 @@ export async function pickCameraImage(): Promise { return []; } - return Promise.all(result.assets.map(imageAssetToPicked)); + return result.assets.map(imageAssetToSelection); } -export async function pickLibraryImages(): Promise { +export async function pickLibraryImages(): Promise { const result = await ImagePicker.launchImageLibraryAsync({ ...IMAGE_PICKER_OPTIONS, allowsMultipleSelection: true, @@ -86,10 +98,10 @@ export async function pickLibraryImages(): Promise { return []; } - return Promise.all(result.assets.map(imageAssetToPicked)); + return result.assets.map(imageAssetToSelection); } -export async function pickFiles(): Promise { +export async function pickFiles(): Promise { const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true, multiple: true, @@ -100,17 +112,16 @@ export async function pickFiles(): Promise { return []; } - return Promise.all(result.assets.map(documentAssetToPicked)); + return result.assets.map(documentAssetToSelection); } -// eslint-disable-next-line typescript-eslint/promise-function-async -- thin pass-through; making it async only to satisfy this rule conflicts with `require-await`. -function imageAssetToPicked(asset: { +function imageAssetToSelection(asset: { uri: string; fileName?: string | null; mimeType?: string | null; fileSize?: number | null; -}): Promise { - return assetToPickedAttachment({ +}): MessageAttachment { + return assetToSelection({ uri: asset.uri, fileName: asset.fileName, mimeType: asset.mimeType, @@ -118,14 +129,13 @@ function imageAssetToPicked(asset: { }); } -// eslint-disable-next-line typescript-eslint/promise-function-async -- thin pass-through; making it async only to satisfy this rule conflicts with `require-await`. -function documentAssetToPicked(asset: { +function documentAssetToSelection(asset: { uri: string; name: string; mimeType?: string; size?: number; -}): Promise { - return assetToPickedAttachment({ +}): MessageAttachment { + return assetToSelection({ uri: asset.uri, name: asset.name, mimeType: asset.mimeType, diff --git a/apps/mobile/src/components/kilo-chat/message-attachment-state.test.ts b/apps/mobile/src/components/kilo-chat/message-attachment-state.test.ts index edf1f268a7..4d79834e18 100644 --- a/apps/mobile/src/components/kilo-chat/message-attachment-state.test.ts +++ b/apps/mobile/src/components/kilo-chat/message-attachment-state.test.ts @@ -1,12 +1,11 @@ -import { ATTACHMENT_MAX_BYTES } from '@kilocode/kilo-chat'; import { describe, expect, it } from 'vitest'; import { - addFilesWithinAttachmentCapacity, buildAttachmentLimitToast, buildAttachmentSizeRejectionToast, getAttachmentActionSheetConfig, isImageMimeType, + MOBILE_ATTACHMENT_MAX_BYTES, normalizeAttachmentSelection, selectAllowedAttachments, } from './message-attachment-state'; @@ -58,114 +57,184 @@ describe('message attachment state helpers', () => { expect(isImageMimeType(undefined)).toBe(false); }); - it('truncates selections to ten attachments and returns toast copy', () => { - const existing = Array.from({ length: 8 }, (_value, index) => - normalizeAttachmentSelection({ - uri: `file:///existing-${index}.txt`, - name: `existing-${index}.txt`, - mimeType: 'text/plain', - size: 1, - }) - ); - const selected = Array.from({ length: 4 }, (_value, index) => ({ - uri: `file:///selected-${index}.txt`, - name: `selected-${index}.txt`, - mimeType: 'text/plain', - size: 1, - })); - - expect(selectAllowedAttachments({ existing, selected })).toEqual({ - accepted: [ - normalizeAttachmentSelection({ - uri: 'file:///selected-0.txt', - name: 'selected-0.txt', - mimeType: 'text/plain', - size: 1, - }), - normalizeAttachmentSelection({ - uri: 'file:///selected-1.txt', - name: 'selected-1.txt', - mimeType: 'text/plain', - size: 1, - }), - ], - rejected: [], - truncatedCount: 2, - toast: 'You can attach up to 10 files.', + it('accepts a file at the mobile byte boundary', () => { + const attachment = normalizeAttachmentSelection({ + uri: 'file:///boundary.bin', + name: 'boundary.bin', + mimeType: 'application/octet-stream', + size: MOBILE_ATTACHMENT_MAX_BYTES, + }); + + const result = selectAllowedAttachments({ + existingCount: 0, + selected: [attachment], }); + + expect(result.accepted).toEqual([attachment]); + expect(result.rejected).toEqual([]); + expect(result.truncatedCount).toBe(0); + expect(result.toast).toBeUndefined(); }); - it('rejects oversized files using the shared byte limit in the toast copy', () => { + it('rejects a file above the mobile byte boundary with exact toast copy', () => { + const attachment = normalizeAttachmentSelection({ + uri: 'file:///huge.bin', + name: 'huge.bin', + mimeType: 'application/octet-stream', + size: MOBILE_ATTACHMENT_MAX_BYTES + 1, + }); + const result = selectAllowedAttachments({ - existing: [], - selected: [ - { - uri: 'file:///large.mov', - name: 'large.mov', - mimeType: 'video/quicktime', - size: ATTACHMENT_MAX_BYTES + 1, - }, - { - uri: 'file:///small.txt', - name: 'small.txt', - mimeType: 'text/plain', - size: 12, - }, - ], + existingCount: 0, + selected: [attachment], }); - expect(result.accepted).toEqual([ - { - uri: 'file:///small.txt', - filename: 'small.txt', - mimeType: 'text/plain', - size: 12, - isImage: false, - }, - ]); + expect(result.accepted).toEqual([]); expect(result.rejected).toEqual([ { - attachment: { - uri: 'file:///large.mov', - filename: 'large.mov', - mimeType: 'video/quicktime', - size: ATTACHMENT_MAX_BYTES + 1, - isImage: false, - }, + attachment, reason: 'too-large', - toast: 'large.mov exceeds the 100 MB attachment limit.', + toast: 'huge.bin exceeds the 10 MB attachment limit.', }, ]); expect(result.truncatedCount).toBe(0); - expect(result.toast).toBe('large.mov exceeds the 100 MB attachment limit.'); - expect(buildAttachmentSizeRejectionToast('large.mov')).toBe( - 'large.mov exceeds the 100 MB attachment limit.' + expect(result.toast).toBe('huge.bin exceeds the 10 MB attachment limit.'); + expect(buildAttachmentSizeRejectionToast('huge.bin')).toBe( + 'huge.bin exceeds the 10 MB attachment limit.' ); expect(buildAttachmentLimitToast()).toBe('You can attach up to 10 files.'); }); - it('only consumes attachment capacity when the queue accepts a selected file', () => { - const addFileCalls: string[] = []; - const acceptedFiles: { filename: string; tempId: string }[] = []; - let limitToastCount = 0; - - addFilesWithinAttachmentCapacity({ - inputs: [{ filename: 'large.mov' }, { filename: 'small.txt' }], - capacity: 1, - addFile: input => { - addFileCalls.push(input.filename); - return input.filename === 'large.mov' ? null : `temp-${input.filename}`; - }, - onAcceptedFile: (input, tempId) => { - acceptedFiles.push({ filename: input.filename, tempId }); + it('accepts small files and rejects oversized files in the same selection', () => { + const small = normalizeAttachmentSelection({ + uri: 'file:///small.txt', + name: 'small.txt', + mimeType: 'text/plain', + size: 12, + }); + const huge = normalizeAttachmentSelection({ + uri: 'file:///huge.bin', + name: 'huge.bin', + mimeType: 'application/octet-stream', + size: MOBILE_ATTACHMENT_MAX_BYTES + 1, + }); + + const result = selectAllowedAttachments({ + existingCount: 0, + selected: [small, huge], + }); + + expect(result.accepted).toEqual([small]); + expect(result.rejected).toEqual([ + { + attachment: huge, + reason: 'too-large', + toast: 'huge.bin exceeds the 10 MB attachment limit.', }, - onLimitExceeded: () => { - limitToastCount += 1; + ]); + expect(result.truncatedCount).toBe(0); + expect(result.toast).toBe('huge.bin exceeds the 10 MB attachment limit.'); + }); + + it('truncates selections to ten attachments and returns toast copy', () => { + const selected = [ + normalizeAttachmentSelection({ + uri: 'file:///a.txt', + name: 'a.txt', + mimeType: 'text/plain', + size: 12, + }), + normalizeAttachmentSelection({ + uri: 'file:///b.txt', + name: 'b.txt', + mimeType: 'text/plain', + size: 12, + }), + ]; + + const result = selectAllowedAttachments({ + existingCount: 9, + selected, + }); + + expect(result.accepted).toEqual([selected[0]]); + expect(result.rejected).toEqual([]); + expect(result.truncatedCount).toBe(1); + expect(result.toast).toBe('You can attach up to 10 files.'); + }); + + it('prefers size rejection toast over capacity toast when both apply', () => { + const huge = normalizeAttachmentSelection({ + uri: 'file:///huge.bin', + name: 'huge.bin', + mimeType: 'application/octet-stream', + size: MOBILE_ATTACHMENT_MAX_BYTES + 1, + }); + const a = normalizeAttachmentSelection({ + uri: 'file:///a.txt', + name: 'a.txt', + mimeType: 'text/plain', + size: 12, + }); + const b = normalizeAttachmentSelection({ + uri: 'file:///b.txt', + name: 'b.txt', + mimeType: 'text/plain', + size: 12, + }); + + const result = selectAllowedAttachments({ + existingCount: 9, + selected: [huge, a, b], + }); + + expect(result.accepted).toEqual([a]); + expect(result.rejected).toEqual([ + { + attachment: huge, + reason: 'too-large', + toast: 'huge.bin exceeds the 10 MB attachment limit.', }, + ]); + expect(result.truncatedCount).toBe(1); + expect(result.toast).toBe('huge.bin exceeds the 10 MB attachment limit.'); + }); + + it('rejects unknown or zero size as unreadable', () => { + const unknownSize = normalizeAttachmentSelection({ + uri: 'file:///mystery.bin', + name: 'mystery.bin', + mimeType: 'application/octet-stream', + }); + const nullSize = normalizeAttachmentSelection({ + uri: 'file:///mystery.bin', + name: 'mystery.bin', + mimeType: 'application/octet-stream', + size: null, }); + const zeroSize = normalizeAttachmentSelection({ + uri: 'file:///mystery.bin', + name: 'mystery.bin', + mimeType: 'application/octet-stream', + size: 0, + }); + + for (const attachment of [unknownSize, nullSize, zeroSize]) { + const result = selectAllowedAttachments({ + existingCount: 0, + selected: [attachment], + }); - expect(addFileCalls).toEqual(['large.mov', 'small.txt']); - expect(acceptedFiles).toEqual([{ filename: 'small.txt', tempId: 'temp-small.txt' }]); - expect(limitToastCount).toBe(0); + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([ + { + attachment, + reason: 'unreadable', + toast: "Couldn't read mystery.bin.", + }, + ]); + expect(result.truncatedCount).toBe(0); + expect(result.toast).toBe("Couldn't read mystery.bin."); + } }); }); diff --git a/apps/mobile/src/components/kilo-chat/message-attachment-state.ts b/apps/mobile/src/components/kilo-chat/message-attachment-state.ts index 01a7058658..e93513cbec 100644 --- a/apps/mobile/src/components/kilo-chat/message-attachment-state.ts +++ b/apps/mobile/src/components/kilo-chat/message-attachment-state.ts @@ -1,6 +1,18 @@ -import { ATTACHMENT_MAX_BYTES, formatFileSize } from '@kilocode/kilo-chat'; - -export const MESSAGE_ATTACHMENT_MAX_COUNT = 10; +import { formatFileSize } from '@kilocode/kilo-chat'; + +const MESSAGE_ATTACHMENT_MAX_COUNT = 10; + +/** + * Mobile-only per-attachment byte cap, deliberately far below the shared + * `ATTACHMENT_MAX_BYTES` (100 MiB). Picking an attachment materializes the + * whole file twice — once as a JS `ArrayBuffer`, once as a `ByteArray` in + * React Native's blob store on the Java heap — and up to + * `MESSAGE_ATTACHMENT_MAX_COUNT` of them stay resident until the message is + * sent. 10 MiB keeps the worst case (10 files) at ~100 MiB, inside an Android + * heap growth limit of ~268 MB. The shared constant governs the server and the + * web client and is intentionally left alone. + */ +export const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; const DEFAULT_ATTACHMENT_FILENAME = 'Attachment'; const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream'; @@ -21,7 +33,7 @@ export type NativeAttachmentSelection = { fileSize?: number | null; }; -type MessageAttachment = { +export type MessageAttachment = { uri: string; filename: string; mimeType: string; @@ -31,7 +43,7 @@ type MessageAttachment = { type RejectedMessageAttachment = { attachment: MessageAttachment; - reason: 'too-large'; + reason: 'too-large' | 'unreadable'; toast: string; }; @@ -42,14 +54,6 @@ type AttachmentSelectionResult = { toast?: string; }; -type AddFilesWithinCapacityInput = { - inputs: readonly TInput[]; - capacity: number; - addFile: (input: TInput) => string | null; - onAcceptedFile?: (input: TInput, tempId: string) => void; - onLimitExceeded: () => void; -}; - export function getAttachmentActionSheetConfig(): AttachmentActionSheetConfig { return { options: ATTACHMENT_ACTION_SHEET_OPTIONS, @@ -80,53 +84,38 @@ export function buildAttachmentLimitToast(): string { } export function buildAttachmentSizeRejectionToast(filename: string): string { - return `${filename} exceeds the ${formatFileSize(ATTACHMENT_MAX_BYTES)} attachment limit.`; + return `${filename} exceeds the ${formatFileSize(MOBILE_ATTACHMENT_MAX_BYTES)} attachment limit.`; } -export function addFilesWithinAttachmentCapacity({ - inputs, - capacity, - addFile, - onAcceptedFile, - onLimitExceeded, -}: AddFilesWithinCapacityInput) { - const maxAccepted = Math.max(capacity, 0); - let acceptedCount = 0; - let limitExceeded = false; - - for (const input of inputs) { - if (acceptedCount >= maxAccepted) { - limitExceeded = true; - } else { - const tempId = addFile(input); - if (tempId !== null) { - acceptedCount += 1; - onAcceptedFile?.(input, tempId); - } - } - } - - if (limitExceeded) { - onLimitExceeded(); - } +export function buildAttachmentUnreadableToast(filename: string): string { + return `Couldn't read ${filename}.`; } export function selectAllowedAttachments({ - existing, + existingCount, selected, }: { - existing: readonly MessageAttachment[]; - selected: readonly NativeAttachmentSelection[]; + existingCount: number; + selected: readonly MessageAttachment[]; }): AttachmentSelectionResult { - const capacity = Math.max(MESSAGE_ATTACHMENT_MAX_COUNT - existing.length, 0); + const capacity = Math.max(MESSAGE_ATTACHMENT_MAX_COUNT - existingCount, 0); const accepted: MessageAttachment[] = []; const rejected: RejectedMessageAttachment[] = []; let truncatedCount = 0; - for (const selection of selected) { - const attachment = normalizeAttachmentSelection(selection); - - if (attachment.size > ATTACHMENT_MAX_BYTES) { + for (const attachment of selected) { + if (attachment.size <= 0) { + // Fail closed: `sizeFromSelection` collapses a missing, null, non-finite + // or negative size to 0, and materializing a file we could not measure is + // exactly the unbounded read this gate exists to prevent. A genuinely + // empty file is also not worth uploading — the cloud-agent path rejects + // `size <= 0` for the same reason (`lib/agent-attachments/validate.ts`). + rejected.push({ + attachment, + reason: 'unreadable', + toast: buildAttachmentUnreadableToast(attachment.filename), + }); + } else if (attachment.size > MOBILE_ATTACHMENT_MAX_BYTES) { rejected.push({ attachment, reason: 'too-large', diff --git a/apps/mobile/src/components/kilo-chat/message-input-attachment-queue.tsx b/apps/mobile/src/components/kilo-chat/message-input-attachment-queue.tsx index 9100ce28a8..7c86d34935 100644 --- a/apps/mobile/src/components/kilo-chat/message-input-attachment-queue.tsx +++ b/apps/mobile/src/components/kilo-chat/message-input-attachment-queue.tsx @@ -2,19 +2,19 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { useCallback, useRef } from 'react'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; -import { ATTACHMENT_MAX_BYTES } from '@kilocode/kilo-chat'; import { type AddFileInput, useAttachmentQueue } from '@kilocode/kilo-chat-hooks'; import { - addFilesWithinAttachmentCapacity, - buildAttachmentLimitToast, buildAttachmentSizeRejectionToast, + buildAttachmentUnreadableToast, getAttachmentActionSheetConfig, - MESSAGE_ATTACHMENT_MAX_COUNT, + type MessageAttachment, + MOBILE_ATTACHMENT_MAX_BYTES, + selectAllowedAttachments, } from './message-attachment-state'; import { + materializeAttachment, pickCameraImage, - type PickedAttachment, pickFiles, pickLibraryImages, } from './message-attachment-picker'; @@ -44,42 +44,53 @@ export function MessageInputWithAttachmentQueue({ const queue = useAttachmentQueue(client, conversationId, { performUpload: mobilePerformUpload, - maxBytes: ATTACHMENT_MAX_BYTES, + maxBytes: MOBILE_ATTACHMENT_MAX_BYTES, onSizeRejected, }); const { showActionSheetWithOptions } = useActionSheet(); const { bottom } = useSafeAreaInsets(); - const addFiles = useCallback( - (picked: PickedAttachment[]) => { - const capacity = Math.max(MESSAGE_ATTACHMENT_MAX_COUNT - queue.rows.length, 0); - addFilesWithinAttachmentCapacity({ - inputs: picked, - capacity, - addFile: (item: PickedAttachment) => queue.addFile(item.input), - onAcceptedFile: (item, tempId) => { - if (tempId) { - localUrisRef.current.set(tempId, item.localUri); - } - }, - onLimitExceeded: () => { - toast.error(buildAttachmentLimitToast()); - }, - }); - }, - [queue] - ); - const pickFromSource = useCallback( async (source: 'camera' | 'library' | 'files') => { try { - const inputs = await pickAttachmentsFromSource(source); - addFiles(inputs); + const selected = await pickAttachmentsFromSource(source); + if (selected.length === 0) { + return; + } + + // Gate on size and capacity before any bytes are read: materializing an + // oversized file is what runs the device out of memory. + const { accepted, toast: rejectionToast } = selectAllowedAttachments({ + existingCount: queue.rows.length, + selected, + }); + if (rejectionToast) { + toast.error(rejectionToast); + } + + // Sequential on purpose: concurrent materialize multiplies peak memory by + // the selection size. eslint no-await-in-loop wants Promise.all; refuse. + for (const attachment of accepted) { + // Per-file, so one unreadable file in a multi-select does not discard the + // rest. Same message as the gate's unreadable rejection: from the user's + // side "we could not read this file" is the same fact whether the size + // stat or the read itself failed. + try { + // eslint-disable-next-line no-await-in-loop -- sequential materialize bounds peak memory + const picked = await materializeAttachment(attachment); + const tempId = queue.addFile(picked.input); + if (tempId) { + localUrisRef.current.set(tempId, picked.localUri); + } + } catch { + toast.error(buildAttachmentUnreadableToast(attachment.filename)); + } + } } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } }, - [addFiles] + [queue] ); const openPicker = useCallback(() => { @@ -130,7 +141,7 @@ export function MessageInputWithAttachmentQueue({ // eslint-disable-next-line typescript-eslint/promise-function-async -- thin pass-through; making it async only to satisfy this rule conflicts with `require-await`. function pickAttachmentsFromSource( source: 'camera' | 'library' | 'files' -): Promise { +): Promise { if (source === 'camera') { return pickCameraImage(); } From 7a664d20be9733fab15875adae885cba4c7981b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:54:45 +0200 Subject: [PATCH 2/6] docs(workflow): learnings for shared-offset collision and shared docker bridge --- ...kiloclaw-docker-tcp-absent-from-worktree-status.md | 7 +++++++ .../worktree-port-offset-airplay-collision.md | 11 +++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .kilo_workflow/learnings/kiloclaw-docker-tcp-absent-from-worktree-status.md diff --git a/.kilo_workflow/learnings/kiloclaw-docker-tcp-absent-from-worktree-status.md b/.kilo_workflow/learnings/kiloclaw-docker-tcp-absent-from-worktree-status.md new file mode 100644 index 0000000000..d61f41a7c7 --- /dev/null +++ b/.kilo_workflow/learnings/kiloclaw-docker-tcp-absent-from-worktree-status.md @@ -0,0 +1,7 @@ +# `kiloclaw-docker-tcp` absent from a worktree's `dev:status` is expected when the shared 23750 bridge answers + +Symptom: `pnpm dev:status --json` for a worktree whose stack started cleanly lists no `kiloclaw-docker-tcp` service row, and the `dev:start` argv named `kiloclaw` (which expands the group that includes it). Provisioning or starting a docker-local KiloClaw instance looks under-provisioned. + +Cause: `kiloclaw-docker-tcp` is a stateless loopback `socat` proxy to the shared Docker socket on `127.0.0.1:23750` — the one host-wide shared port the runbook allows. When another worktree (or an earlier stack) already owns that listener, this worktree's runner reuses it and records no service row of its own. + +Fix: gate on the bridge, not the row: `curl http://127.0.0.1:23750/v1.44/_ping` must print `OK`. Only if that fails, start the bridge yourself per `services/kiloclaw/README.md` (`socat TCP-LISTEN:23750,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock`). Never kill a `socat` owned by another worktree (runbook, Host Networking Safety) — `lsof -iTCP:23750` shows the owning PID and it is shared by every docker-local instance on the host, not just yours. diff --git a/.kilo_workflow/learnings/worktree-port-offset-airplay-collision.md b/.kilo_workflow/learnings/worktree-port-offset-airplay-collision.md index 291c9abcb7..28cfb9b38e 100644 --- a/.kilo_workflow/learnings/worktree-port-offset-airplay-collision.md +++ b/.kilo_workflow/learnings/worktree-port-offset-airplay-collision.md @@ -1,7 +1,10 @@ -# Worktree slug hashing to port offset 2000 collides with macOS AirPlay Receiver (5000/7000) +# Worktree slug-hash port offset collides — with macOS AirPlay Receiver (5000/7000) or another worktree's live stack -Symptom: `pnpm dev:start` fails with `Refusing to share occupied worktree service ports: nextjs:5000` even with no other dev stack running; `lsof -iTCP:5000` shows `ControlCe` (macOS AirPlay Receiver), which also holds 7000. +Symptom: `pnpm dev:start` fails with `Refusing to share occupied worktree service ports: …`. Two observed occupants of the hash-chosen offset: -Cause: `computePortOffset` derives the offset from the worktree slug hash (`KILO_PORT_OFFSET=auto` uses the same hash — it does not probe for free ports); a slug that buckets to 20 lands nextjs on 3000+2000=5000 permanently. Only `dev:status` reads the offset back from the manifest, so `dev:start`/`dev:restart`/`dev:env` all recompute from the environment. +1. Offset 2000 (nextjs 5000) — `lsof -iTCP:5000` shows `ControlCe` (macOS AirPlay Receiver), which also holds 7000, even with no other dev stack running. +2. Offset 2400 — another worktree's live `kilo-dev-*` stack legitimately holds the ports; nothing is wrong with the machine, the hash just bucketed two worktrees onto the same offset. -Fix: prefix every port-computing dev command with an explicit collision-free offset, e.g. `KILO_PORT_OFFSET=2100 pnpm dev:start ...` (2100 clears AirPlay: nextjs 5100, metro 10181, wrangler 10889+). Per-command prefix only — never `export` it; `apps/mobile/e2e/AGENTS.md` forbids the export because stale shell values select the wrong bundle endpoints, and that rule stands. `dev:status`/`dev:seed`/`login.sh` are manifest- or DB-driven and need no prefix. Restate the prefix rule in every device-phase handoff; a `dev:restart` without it silently recomputes ports at the hash offset and breaks the stack's URL wiring. +Cause: `computePortOffset` derives the offset from the worktree slug hash (`KILO_PORT_OFFSET=auto` uses the same hash — it does not probe for free ports); a slug that buckets to 20 lands nextjs on 3000+2000=5000 permanently, and two slugs can bucket to the same offset. Only `dev:status` reads the offset back from the manifest, so `dev:start`/`dev:restart`/`dev:env` all recompute from the environment. + +Fix: prefix every port-computing dev command with an explicit collision-free offset, e.g. `KILO_PORT_OFFSET=2100 pnpm dev:start ...` (2100 clears AirPlay: nextjs 5100, metro 10181, wrangler 10889+); 2900 is probe-verified against the same full service-port set (nextjs 5900, metro 10981, kiloclaw 11695, kilo-chat 11708, event-service 11709, session-ingest 11700). Probe before committing to an offset: compute every service port from it and check `lsof` — do not probe only the one port that errored. Per-command prefix only — never `export` it; `apps/mobile/e2e/AGENTS.md` forbids the export because stale shell values select the wrong bundle endpoints, and that rule stands. `dev:status`/`dev:seed`/`login.sh` are manifest- or DB-driven and need no prefix. Restate the prefix rule in every device-phase handoff; a `dev:restart` without it silently recomputes ports at the hash offset and breaks the stack's URL wiring. From bc380d5e58dd09a6fc6d101551bd39ff71f795c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 17:34:35 +0200 Subject: [PATCH 3/6] docs(workflow): record hard schema-error wedge signature in orchestrator relaunch learning --- .../kilo-interactive-orchestrator-wedges-relaunch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.kilo_workflow/learnings/kilo-interactive-orchestrator-wedges-relaunch.md b/.kilo_workflow/learnings/kilo-interactive-orchestrator-wedges-relaunch.md index fcc15b5b02..03854f5df7 100644 --- a/.kilo_workflow/learnings/kilo-interactive-orchestrator-wedges-relaunch.md +++ b/.kilo_workflow/learnings/kilo-interactive-orchestrator-wedges-relaunch.md @@ -1,7 +1,7 @@ # Long interactive kilo sessions die or wedge — relaunch fresh with a continuation handoff -Symptom: an orchestrator running as `kilo run --interactive` stops making progress: the process died (stream stall, typically 10–15 minutes into a long run), or it sits wedged after a provider error (`--interactive --auto` hard-wedges on these). Log bytes stagnate while the tmux window looks alive. +Symptom: an orchestrator running as `kilo run --interactive` stops making progress: the process died (stream stall, typically 10–15 minutes into a long run), it sits wedged after a provider error (`--interactive --auto` hard-wedges on these), or it dies loudly with a hard, repeated SDK error — observed: `Invalid prompt: The messages do not match the ModelMessage[] schema.` printed on every retry. Log bytes stagnate while the tmux window looks alive; a frozen context counter and queued steers that never flush confirm the silent variant, while the hard-error variant is detectable immediately from the pane with no stagnation timer. -Cause: provider stream stalls kill long kilo runs; interactive sessions surface provider errors as a wedge the session cannot recover from. Nothing about the plan or repository is wrong. +Cause: provider stream stalls kill long kilo runs; interactive sessions surface provider errors as a wedge the session cannot recover from, and a poisoned message history surfaces as the repeated schema error. Nothing about the plan or repository is wrong. -Fix: detect via log-byte stagnation plus pane inspection, not just process exit. Kill the window and relaunch a **fresh** session (never `--continue` or `--session`) with a continuation handoff: the original handoff plus everything observably done — commits (`git log`), PR number and state, review/E2E rounds passed, resources still held — so the new session verifies and continues instead of redoing. Distrust the dead session's last claims: re-verify anything it reported green that is not independently evidenced (a passing check in CI, a commit, a resolved thread). Related: `kilo-run-exits-0-without-verdict.md` covers the same stall killing non-interactive role-agent rounds. +Fix: detect via log-byte stagnation plus pane inspection, not just process exit — and on the hard schema error, kill immediately rather than waiting out a timer. Kill the window and relaunch a **fresh** session (never `--continue` or `--session`: the poisoned history is exactly what must not be carried over) with a continuation handoff: the original handoff plus everything observably done — commits (`git log`), PR number and state, review/E2E rounds passed, resources still held — so the new session verifies and continues instead of redoing. Distrust the dead session's last claims: re-verify anything it reported green that is not independently evidenced (a passing check in CI, a commit, a resolved thread). Related: `kilo-run-exits-0-without-verdict.md` covers the same stall killing non-interactive role-agent rounds. From 8619b32021268ddf7d078e8ba9e2e77d26c25919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 18:00:07 +0200 Subject: [PATCH 4/6] docs(workflow): learning for transient kiloclaw-docker-tcp dev:start refusal --- .../learnings/dev-start-docker-tcp-refusal-is-transient.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .kilo_workflow/learnings/dev-start-docker-tcp-refusal-is-transient.md diff --git a/.kilo_workflow/learnings/dev-start-docker-tcp-refusal-is-transient.md b/.kilo_workflow/learnings/dev-start-docker-tcp-refusal-is-transient.md new file mode 100644 index 0000000000..4d66b008bc --- /dev/null +++ b/.kilo_workflow/learnings/dev-start-docker-tcp-refusal-is-transient.md @@ -0,0 +1,7 @@ +# `dev:start` refuses on `kiloclaw-docker-tcp:23750` even though the shared bridge is healthy — retry once + +Symptom: `pnpm dev:start` fails with `Refusing to share occupied worktree service ports: kiloclaw-docker-tcp:23750`, while `curl http://127.0.0.1:23750/_ping` (and even `/v1.44/_ping`) returns `OK` with an `Api-Version` header — the exact thing the runner's own probe requires. + +Cause: the reuse branch in `dev/local/cli.ts` only runs when `probeDockerApi(23750)` (`dev/local/docker-api-probe.ts`) succeeds inside a 500 ms window. A momentarily slow Docker daemon or a freshly (re)started `socat` makes that one probe time out, and the runner then reads the occupied port as a foreign conflict instead of a reusable bridge. The port is host-wide and offset-independent, so no `KILO_PORT_OFFSET` change can clear this refusal. + +Fix: retry the same `dev:start` command once — the probe is re-run and the second attempt typically prints `Reusing host kiloclaw-docker-tcp on 23750` and proceeds. Only if the refusal repeats, investigate the bridge itself per `kiloclaw-docker-tcp-absent-from-worktree-status.md` (`curl` the unversioned `/_ping`, check `lsof -iTCP:23750`); never kill a foreign-owned `socat`, and never "fix" this by picking another offset. From e35546ea21bc61fbae2a5065f6d44194bdaf81b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 19:26:02 +0200 Subject: [PATCH 5/6] docs(workflow): learnings for kilochat R2 secret absence and android picker tap races --- ...roid-picker-tap-races-and-toast-capture.md | 14 +++++++++++++ ...claw-chat-attachments-r2-secret-missing.md | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 .kilo_workflow/learnings/android-picker-tap-races-and-toast-capture.md create mode 100644 .kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md diff --git a/.kilo_workflow/learnings/android-picker-tap-races-and-toast-capture.md b/.kilo_workflow/learnings/android-picker-tap-races-and-toast-capture.md new file mode 100644 index 0000000000..0a29149046 --- /dev/null +++ b/.kilo_workflow/learnings/android-picker-tap-races-and-toast-capture.md @@ -0,0 +1,14 @@ +# Android DocumentsUI picker: stale-dump tap race + reliable sonner toast capture + +Two Android-emulator techniques confirmed on attach-oom-11fd (2026-07-28): + +1. `uiautomator dump` bounds can describe a view the picker has already left (a tap aimed at them hit the wrong + Recent row). Before an `adb input tap`, cross-check the same node's bounds in a fresh + `maestro --device hierarchy` — only tap when both agree. Prefer Maestro flows for navigation and + reserve adb taps for the final pick when sub-second screenshot timing matters. +2. `sonner-native` toasts are catchable deterministically with an adb tap immediately followed by burst + screencaps at +0.5s/+1.2s/+2.2s/+3.5s (`adb exec-out screencap -p > f.png`). A `maestro test` flow's + teardown latency (seconds) is too slow — the toast dismisses before the shell regains control. The first + burst frame usually still shows the picker; the second (+1.2s) reliably showed the toast. +3. Files `adb push`ed to /sdcard/Download appear in the picker's Downloads root immediately, but only enter its + Recent view after being picked once — do not rely on Recent containing a freshly pushed fixture. diff --git a/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md b/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md new file mode 100644 index 0000000000..537b5c616a --- /dev/null +++ b/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md @@ -0,0 +1,20 @@ +# KiloClaw chat attachment uploads fail locally: R2 media secrets absent machine-wide + +Symptom: mobile E2E on the KiloClaw chat composer (`chat/[sandbox-id]/[conversation-id]`): picking an under-limit +file adds a chip that immediately goes to `failed` (a11y `Retry upload for `); kilo-chat logs +`POST /v1/attachments/init 500` with `Error: Secret "R2_ACCESS_KEY_ID_KILOCHAT_MEDIA" not found`. + +Cause: `services/kilo-chat/wrangler.jsonc` binds `R2_ACCESS_KEY_ID`/`R2_SECRET_ACCESS_KEY` to Secrets-Store +secrets `R2_*_KILOCHAT_MEDIA` (store 342a86d9e3a94da698e82d0c6e2a36f0) with `MEDIA_BUCKET` declared +`remote: true`. Those secrets have no canonical local source: `.dev.vars.example` documents manual creation +from a personal Cloudflare R2 API token, and `dev:env`/`dev:start` only warn ("Missing and no source value"). +Checked 2026-07-28: 0 of 42 worktrees' `services/kilo-chat/.wrangler/state/v3/secrets-store` contain an R2 key; +`pnpm dev:worktree:prepare` + `KILO_PORT_OFFSET= pnpm dev:env -y kilo-chat` does not provision them. Every +chip upload 500s deterministically (initial attempt and in-app retry alike). Attachment E2E assertions that +require a chip to reach `ready`/send/render are environment-blocked until someone creates the two secrets +with a real R2 token. Oversized-rejection assertions (pre-read size gate) are unaffected and fully testable. + +Related: a `prep`-style phase that ends with `e2e-slot.sh release` stops the worktree stack (slot/stack are one +resource), so a handoff claiming "stack is up" can be stale by verifier time — check `pnpm dev:status --json` +and restart per the runbook quickstart (with the handoff's KILO_PORT_OFFSET prefix on dev:env/dev:start); +the docker-local KiloClaw sandbox container survives stack restarts. From 47015c153184e44fea3abd143f7cfff5ead22b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 21:49:33 +0200 Subject: [PATCH 6/6] docs(workflow): learnings for android systemui anr recovery and provisioned R2 media secrets --- ...mserver-restart-systemui-anr-under-load.md | 25 +++++++++++++++++++ ...claw-chat-attachments-r2-secret-missing.md | 8 ++++++ 2 files changed, 33 insertions(+) create mode 100644 .kilo_workflow/learnings/android-emulator-systemserver-restart-systemui-anr-under-load.md diff --git a/.kilo_workflow/learnings/android-emulator-systemserver-restart-systemui-anr-under-load.md b/.kilo_workflow/learnings/android-emulator-systemserver-restart-systemui-anr-under-load.md new file mode 100644 index 0000000000..b121fc0256 --- /dev/null +++ b/.kilo_workflow/learnings/android-emulator-systemserver-restart-systemui-anr-under-load.md @@ -0,0 +1,25 @@ +# Android emulator SystemServer restart + SystemUI ANR deadlock under host load spikes (2026-07-28) + +Observed on attach-oom-11fd r2 while 3 slots were active (host load avg spiked to ~168): + +**Symptom chain:** mid-run `adb shell input tap` fails with `Broken pipe (32)` then `Can't find service: input`; +`sys.boot_completed` still reads 1; `uiautomator dump` returns `Killed` (RC=137); qemu process stays alive. +Cause: the guest's system_server restarted under memory/CPU pressure — treat those adb signatures as +"guest rebooting", poll `getprop sys.boot_completed` (bounded loop) instead of assuming an adb wedge. + +**Aftermath:** SystemUI can deadlock afterwards: persistent `System UI isn't responding` ANR dialog, +`Wait` taps are no-ops, guest CPU idle (so not starvation), `dumpsys window` shows +`mCurrentFocus=... Application Not Responding: com.android.systemui`, SystemUI in S state, and a shell-user +`kill ` silently does nothing (PID unchanged). + +**Recovery (confirmed):** `adb root` works on the kilo AVDs (`restarting adbd as root`), then +`adb shell kill -9 `; system_server restarts SystemUI in ~20 s, the ANR dialog clears, and the +app process, adb reverse mappings, login session, and conversation state all survive. No emulator relaunch +(never consume a launch attempt for this). Afterwards Maestro, which also wedges under the load +(`maestro hierarchy` empty/timing out, `maestro test` never writing its first log line), works again. + +**Related:** plain `pnpm dev:stop` (runbook-mandated when the slot release leaves the stack up) tears down +the SHARED `kiloclaw-docker-tcp` socat on 127.0.0.1:23750 even though `e2e-slot.sh release` first prints +"Leaving Docker infrastructure running". After cleanup, 23750 having no listener is expected; the next +`dev:start` recreates it on demand. Do not "repair" it by hand (never create a socat you did not start). +The KiloClaw sandbox container itself survives both the release and dev:stop. diff --git a/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md b/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md index 537b5c616a..38f04798b6 100644 --- a/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md +++ b/.kilo_workflow/learnings/kiloclaw-chat-attachments-r2-secret-missing.md @@ -1,5 +1,13 @@ # KiloClaw chat attachment uploads fail locally: R2 media secrets absent machine-wide +> **RESOLVED 2026-07-28 (r2):** the `R2_*_KILOCHAT_MEDIA` pair now exists in the Secrets Store and +> `dev:start` provisions it from that canonical source like any source-backed secret +> (`⊕ secret: R2_ACCESS_KEY_ID_KILOCHAT_MEDIA @from …`); verified in the local secrets-store KV and +> end-to-end (`/v1/attachments/init 200`, chip `ready`, send 201). Everything below is the historical +> r1 record — the "no canonical source" claim is stale. If the symptom ever recurs, first check whether +> the store lost the pair again, then whether the worktree's `.wrangler/state` secrets KV is stale +> (re-run `dev:start`). + Symptom: mobile E2E on the KiloClaw chat composer (`chat/[sandbox-id]/[conversation-id]`): picking an under-limit file adds a chip that immediately goes to `failed` (a11y `Retry upload for `); kilo-chat logs `POST /v1/attachments/init 500` with `Error: Secret "R2_ACCESS_KEY_ID_KILOCHAT_MEDIA" not found`.