feat(web,hub): scratchlist v2.2 hub attachment storage (#921) - #1205
Conversation
There was a problem hiding this comment.
Findings
- [Major] Attachment metadata is trusted without proving session ownership —
POST/PUT /scratchlistaccepts client-suppliedid/path/sizeand only checks size/MIME, then the download route authorizes the current session but readsmatch.path; a forged metadata row can point at another session/namespace's hub file if the path is known. Evidencehub/src/web/routes/sessions.ts:856. - [Major] Scratchlist upload quota ignores uncommitted files — upload writes bytes immediately, but the per-session limit is computed only from persisted scratchlist entries, so repeated upload calls that are never attached to an entry can fill disk outside
maxBytesPerSession. Evidencehub/src/sync/syncEngine.ts:600.
Summary
Review mode: initial
Found two Major issues in the new hub-backed attachment storage path. Both need server-side fixes; tests should cover forged attachment metadata and abandoned uploads.
Testing
Not run (automation; avoided executing PR code). Suggested tests: forged cross-session/cross-namespace attachment metadata is rejected; repeated unattached uploads cannot exceed session quota and are cleaned up.
HAPI Bot
…iann#921) Hub stores scratchlist attachment bytes on filesystem; SQLite holds AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12). Upstream ladder: v10→v11 text-only scratchlist table (tiann#896), v11→v12 attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars. Upload, serve, and limits REST routes; delete entry cleans hub files. Web promote/rehydrate still TODO. Soup renumber branch follows. Co-authored-by: Cursor <cursoragent@cursor.com>
Route scratchlist-mode composer submits with attachments to hub storage, show image thumbnails in the drawer, and rehydrate attachments on promote to composer or queue (hub fetch → CLI upload for send). Co-authored-by: Cursor <cursoragent@cursor.com>
…n#921) Hub upload adapter now sets path on ready attachments so the composer send button unlocks in scratchlist mode; routing label matches attachments too. Entry thumbnails float left with text wrap; copy tooltip clarifies text-only. Co-authored-by: Cursor <cursoragent@cursor.com>
update() now takes { text?, attachments? }; v12 CRUD tests still passed a string.
Co-authored-by: Cursor <cursoragent@cursor.com>
b4ffded to
28c5777
Compare
Resolve claimed hub paths against the current session before persist, count on-disk session bytes for upload caps, delete blobs dropped on entry update, and DELETE pending uploads when composer remove runs. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Promote-to-composer rehydrates attachments while scratchlist mode is still active, so
assistantApi.composer().addAttachment(file)runs againstcreateScratchlistAttachmentAdapter(...)instead of the normal upload adapter. That leaveshapi-hub:scratchlist/...paths in the composer, and the later chat send will format them as@pathreferences the agent cannot read.web/src/components/SessionChat.tsx:326
Suggested fix:const handlePromoteToComposer = useCallback(async (entry: ScratchlistEntry) => { assistantApi.composer().setText(entry.text) props.onExitScratchlistMode() if (entry.attachments?.length) { window.requestAnimationFrame(() => { void rehydrateScratchlistAttachmentsToComposer( props.api, props.sessionId, entry.attachments, assistantApi.composer() ) }) } }, [assistantApi, props.api, props.onExitScratchlistMode, props.sessionId])
- [Major] Canceling a scratchlist attachment while its hub upload is in flight leaves the newly written blob orphaned, because the post-upload cancel branch returns without deleting the server-side file. Those bytes stay in
HAPI_HOME/scratchlist-attachments, count against the session quota, and nothing points back to them for cleanup.web/src/lib/scratchlistAttachmentAdapter.ts:60
Suggested fix:const result = await api.uploadScratchlistAttachment(sessionId, file.name, content, contentType) if (cancelledAttachmentIds.has(id)) { if (result.success && result.attachment) { await api.deleteScratchlistAttachment(sessionId, result.attachment.id).catch(() => {}) } return }
Summary
Review mode: follow-up after new commits
Two Major regressions remain in the new scratchlist attachment flow. The server-side trust/quota issues from the prior pass appear addressed; the remaining gaps are in the web-side composer handoff and cancel race.
Testing
Not run (automation). Suggested tests: promote-to-composer uses the normal upload adapter after leaving scratchlist mode; removing an attachment during upload deletes any successfully written hub blob.
Promote-to-composer flushes mode exit so attachments use the chat adapter. Cancel-during-upload deletes the hub blob once upload returns. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Partial attachment IDs can delete referenced files — scratchlist files are named
${uuid}-${filename}, but the delete helper removes the first filename that starts with${attachmentId}-; because UUIDs contain hyphens, a request using only the first UUID segment bypasses the route's exactstillReferencedcheck and deletes a file still referenced by an entry. Evidencehub/src/scratchlistAttachments/storage.ts:255andhub/src/web/routes/sessions.ts:984.
Suggested fix:const SCRATCHLIST_ATTACHMENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i function isScratchlistAttachmentId(id: string): boolean { return SCRATCHLIST_ATTACHMENT_ID_RE.test(id) } if (!isScratchlistAttachmentId(claimed.id)) { return { ok: false, error: 'Invalid scratchlist attachment id' } } if (!isScratchlistAttachmentId(attachmentId)) return false
- [Major] Hub-only scratchlist uploads can be sent to chat after toggling the drawer off — when
scratchlistModeis on, the composer uploads attachments tohapi-hub:scratchlist/...; if the user turns scratchlist mode off before pressing Send, the normal chat path receives that hub-private path unchanged, and the CLI only formats attachment paths as local@pathreferences. Evidenceweb/src/components/SessionChat.tsx:1191andweb/src/lib/scratchlistAttachmentAdapter.ts:130.
Suggested fix:const prepareChatAttachments = async (items?: AttachmentMetadata[]) => { const hubItems = (items ?? []).filter((att) => isHubScratchlistAttachmentPath(att.path)) if (hubItems.length === 0) return items const normalItems = (items ?? []).filter((att) => !isHubScratchlistAttachmentPath(att.path)) const staged = await stageScratchlistAttachmentsForComposeSend( props.api, props.session.id, hubItems ) return [...normalItems, ...staged] } return props.onSend(text, await prepareChatAttachments(attachments), scheduledAt)
Questions
- None.
Summary
Review mode: follow-up after new commits
Found two Major issues in the current scratchlist attachment flow. The earlier bot concerns around cross-session metadata and abandoned-upload quota are mostly addressed, but the current ID matching still has an exactness bug, and hub-staged attachments need conversion before they enter the normal chat send path.
Testing
Not run (automation; avoided executing PR code). Suggested coverage: DELETE with a UUID prefix must not remove an in-use attachment; toggling scratchlist mode off after a hub upload must stage the file through normal upload before chat send.
HAPI Bot
Reject partial attachment ids on disk delete, and restage scratchlist hub attachments through uploadFile when sending after leaving scratchlist mode. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Text-only scratchlist edits can fail after a session-id transfer because existing attachment paths still point at the old session id.
- [Major] Deleting a session removes the DB rows but leaves hub-side scratchlist attachment bytes on disk.
- [Major] Attachment validation still accepts partial UUID ids, letting direct REST callers persist aliases that bypass the in-use check.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous bot findings were addressed, but the current attachment storage still has lifecycle and identity issues around session transfer, session deletion, and server-side metadata validation.
Testing
- Not run (automation)
HAPI Bot
Text-only edits keep existing attachment metadata after session-id transfer. Require full UUID on resolve. Delete scratchlist attachment files when a session is deleted. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Major] Attach-before-toggle files are routed to scratchlist with non-hub paths, so the hub rejects the send. Evidence
web/src/components/SessionChat.tsx:531.
Suggested fix:export function shouldRouteToScratchlist( scratchlistMode: boolean, attachments: AttachmentMetadata[] | undefined, scheduledAt: number | null | undefined, ): boolean { if (!scratchlistMode) return false if (scheduledAt != null) return false return (attachments ?? []).every((att) => isHubScratchlistAttachmentPath(att.path)) }
- [Major] Replacing attachments can falsely exceed the per-session byte cap because removed bytes are still counted during PUT validation. Evidence
hub/src/web/routes/sessions.ts:937.
Suggested fix:const removedAttachments = existing.attachments.filter( (old) => !nextAttachments.some((next) => next.id === old.id) ) const removedBytes = removedAttachments.reduce((sum, att) => sum + att.size, 0) const diskBytesAfterRemoval = Math.max(0, diskBytes - removedBytes) const sessionBytesBefore = Math.max(0, diskBytesAfterRemoval - entryBytes)
- [Major] Deleting one scratchlist entry can delete attachment bytes still referenced by another entry. Evidence
hub/src/sync/syncEngine.ts:572.
Suggested fix:const remainingIds = new Set( this.store.scratchlist .list(sessionId) .flatMap((entry) => entry.attachments.map((att) => att.id)) ) const orphaned = existing.attachments.filter((att) => !remainingIds.has(att.id)) void import('../scratchlistAttachments/storage').then(({ deleteScratchlistAttachmentFiles, getHapiHomeDir }) => deleteScratchlistAttachmentFiles(getHapiHomeDir(), orphaned) )
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The prior ID/path validation fixes are present, but the current scratchlist attachment lifecycle still has routing, byte-accounting, and shared-reference deletion issues.
Testing
- Not run (automation)
HAPI Bot
Park only hub-resident attachments; subtract removed blobs from the PUT session cap; delete attachment files only when no other entry still references them. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Adds scratchlist v2.2 image/file attachment support on top of hub-synced scratchlist (#896).
HAPI_HOME/scratchlist-attachments/; SQLite holds AttachmentMetadata JSON onsession_scratchlist.attachments(schema V14 to V15; scratchlist table remains V11 to V12 from feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback #896).Closes #921.
Test plan
bun typecheckandbun run testgreen on this branchsession_scratchlist.attachmentsattachmentswithout dropping scratchlist rowsNotes
HAPI_SCRATCHLIST_MAX_ATTACHMENT_*/HAPI_SCRATCHLIST_ALLOWED_ATTACHMENT_MIMES.