feat(composer): non-image file attachments, restored on current main - #929
Damian-Szczepanski wants to merge 4 commits into
Conversation
…th-noted in the prompt (#file-attachments) The composer accepted only image/* — a CSV or log dropped on it was silently ignored, with no way to hand the agent a data file from the cockpit. Any file type is now accepted (same 4x5MB caps, shared with images). Images keep their inline base64 path; non-image files extend the #357 mechanism instead: the server materializes them under .ai/cezar/runs/<runId>-images/ (original filename, sanitized; collisions suffixed) and appends the absolute-path note to the message text. Riding in the TEXT means the delivery ladder (live session, queued fold, starting-state buffer), restart recovery and the folded task bound all inherit the attachment without learning a new field. Task-start files travel as StartRunInput.files and are recorded on the run as taskFiles - deliberately separate from taskImages, which hydration re-encodes into image blocks at dequeue (a CSV must never become an image block). The opening-prompt note no longer requires image blocks to be present. Known v1 gap: files added while EDITING a stacked message are dropped (the PATCH schema is unchanged); files attached at post time survive edits via the text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
….ai/cezar/attachments/ (#attachments-library) User uploads were scattered across per-run dirs (.ai/cezar/runs/<id>-images/) and gone with run cleanup. Every user-attached file (non-image files and pasted images; never agent screenshots) now also lands as a copy in ONE flat per-project folder: .ai/cezar/attachments/. Pre-collision names + content dedupe, so re-attaching the same file to another task archives nothing new. Best-effort by design: a library failure never breaks the upload; deleting the folder loses only duplicates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both features keep: the continue endpoint validates the picked agent account first (nothing written to disk on a rejected continue), then materializes non-image attachments into the continuation text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔍 Code Review: feat(composer): non-image file attachments, restored on current main
🎯 Summary
This PR restores the non-image attachment feature on current main and adds a per-project attachments library on top. The composer's intake (screenFiles) stops filtering to image/*, the <input type="file"> loses its accept filter, and a shared pending array is split at submit time (splitAttachments) into the inline images wire field and a new files field. Server-side, RunManager.persistFile materializes each non-image upload under .ai/cezar/runs/<runId>-images/ with a sanitized version of its original filename, and the absolute path is appended to the message text via the existing pastedAttachmentsText note. Reviewed scope: the 17 changed files across packages/cezar (run manager, server routes, run store), packages/contract, and packages/web (composer, API client, new-task and task-thread routes).
The core design decision is genuinely good and worth calling out: riding the attachment inside the message text rather than adding a new field to every rung means the three-rung delivery ladder, the queued-message fold, the starting-state buffer and restart recovery all inherit attachments with no new plumbing, and the taskFiles / taskImages split correctly prevents a CSV from ever being re-encoded as an inline image block at hydration. The filename sanitization in persistFile is careful — basename() first, then a strict charset, then a leading-[.-] strip — and I verified it defeats both POSIX traversal (../../etc/passwd → passwd) and the Windows-separator variant that basename() alone would miss. Test coverage for the new units is real and specific rather than decorative.
What holds it back is not the feature but its housekeeping. The second commit introduces a brand-new state directory, .ai/cezar/attachments/, that is never registered with the .gitignore the CLI maintains in users' repositories — so every file a user uploads lands in a git-visible folder inside their project. On top of that there are four correctness and hardening gaps, the sharpest being two project-scoped API client helpers that accept the widened input type and silently discard the new files field with no compile error.
Verdict
❌ request changes — one blocker (the new .ai/cezar/attachments/ directory is not added to ensureDataGitignore, which both AGENTS.md and BACKWARD_COMPATIBILITY.md §3 require in the same PR, and which causes user-uploaded files to appear in users' git status) and four majors (silent files drop in the project-scoped client helpers; the image-serving route now handing out arbitrary user bytes without the nosniff/sandbox hardening its sibling raw path applies; an unbounded, write-only attachments library; and silent partial-failure handling when only some attachments materialize). The validation gate is otherwise green and the feature's central design is sound — none of these findings requires rethinking the approach.
🧪 Validation Gate
| Command | Status | Notes |
|---|---|---|
npm run typecheck |
✅ PASS | All four projects (contract, client, server, web) clean. |
npm test |
12 suites fail on the PR head. I ran the identical suite on a clean origin/main worktree in the same environment as a baseline: 13 suites fail there — a strict superset of the PR's 12. Every failure is in the git-environment/health/route-parity family (git.test.ts, git-changes.test.ts, health-forge.test.ts, projects-api.test.ts, route-parity.test.ts, the task-git web tests), none touch the changed code, and the count varies run to run. No regression is attributable to this PR, and the author's claim on that point is verified. The PR head also runs 6187 tests vs main's 6177 — the 10 new tests all pass. |
|
npm run test:unit |
✅ PASS | The node:test core-module suite is clean. |
npm run build |
✅ PASS | Includes the check:pack tarball gate. |
npm run test:package |
✅ PASS | Packaged CLI E2E is clean. |
Findings
⛔ Blocker
packages/cezar/src/index.ts:666 — the new .ai/cezar/attachments/ state directory is missing from ensureDataGitignore, so every user upload becomes a tracked file in the user's repository.
copyToAttachmentsLibrary (packages/cezar/src/workflows/run.ts:3450) creates .ai/cezar/attachments/ and copies every user upload into it. The wanted list in ensureDataGitignore — the .ai/cezar/.gitignore that cezar writes and maintains inside each user's project — enumerates runs.json, runs/, worktrees/, tmp/, todos.json, launch-key and the automation files, but not attachments/. That list is a per-entry allowlist rather than a blanket *, and deliberately so: .ai/cezar/workflows/ and .ai/cezar/skills/ are meant to be committable. I verified the maintained file's current contents; attachments/ is absent, so the folder is not covered by anything.
The concrete failure: a user drags q3-revenue.csv, a production log, or a .env-shaped export onto the composer. It is copied into .ai/cezar/attachments/, shows up in their next git status as untracked, and a git add -A commits it — potentially to a public repository. Every pasted screenshot is archived there too (run.ts:3411 calls the copy for the pasted prefix), so this is not a rare path but the default one for anyone who attaches anything. This is the exact hazard CODE_REVIEW.md names under Security ("No secrets in state files: nothing under .ai/cezar/ may contain tokens or credentials") and it violates two explicit, documented rules: AGENTS.md line 128 ("Keep .ai/cezar/.gitignore maintenance (ensureDataGitignore) in sync with any new state file") and BACKWARD_COMPATIBILITY.md §3 ("any new run-data file must be added there in the same PR").
Note that this is invisible when testing inside the cezar repo itself, because cezar's own root .gitignore carries a blanket .ai/cezar/ entry — which is very likely why it was not caught. It only reproduces in a consumer repo.
Fix: add 'attachments/' to the wanted array in ensureDataGitignore. Because ensureDataGitignore appends only missing entries, existing installs pick it up on the next openStore, so no migration is needed. Please add a regression test asserting attachments/ is present in the generated .gitignore, alongside the existing state-file entries.
⚠️ Major
1. packages/web/src/api/client.ts:1482 and packages/web/src/api/client.ts:1268 — the project-scoped twins of sendMessage and continueRun accept the widened input types but silently discard files.
sendMessage (line 1633) was updated to forward json: { text, images, files }, and continueRun (line 1243) gained its ...(opts.files !== undefined ? { files: opts.files } : {}) spread. Their project-scoped siblings — sendProjectRunMessage, which still sends json: { text: message.text ?? '', images: message.images ?? [] }, and continueProjectRun, whose spread list stops at agentProfile — were not. Both take the same widened types (MessageInput now carries files; ContinueOptions now carries files), so a caller can pass attachments, get no type error, receive a 200, and have the files vanish: the server's messageSchema defaults the absent files to [], so nothing anywhere reports a problem.
This is latent rather than live today — the only caller that passes a projectId is useAskAnswer (packages/web/src/routes/task-thread/ask-answer.ts:116-117), which sends { text } only. But the projectId variants exist precisely for the global Tasks page, which spans the registry, and the moment any surface there routes a composer through them uploads disappear silently. A one-line divergence that the type system cannot catch and that fails without an error is worth closing while the context is fresh.
Fix: mirror the two updated helpers — add files: message.files ?? [] to sendProjectRunMessage's json, and the ...(opts.files !== undefined ? { files: opts.files } : {}) spread to continueProjectRun. packages/web/src/api/client.test.ts already asserts the exact request shape for the unscoped sendMessage; adding the scoped twin to that table would pin the parity permanently.
2. packages/cezar/src/server/server.ts:4072-4085 — GET /api/v1/runs/:id/images/:file now serves arbitrary user-supplied bytes without the nosniff and sandbox-CSP hardening its sibling raw-file path applies.
Before this PR, everything in <runId>-images/ was written by persistImage, whose extension is derived from the image media type and can only be one of png, jpg, webp, gif, img (run.ts:3394-3399). The route's response headers reflect that assumption: it sets content-type from the IMAGE_TYPES map with an application/octet-stream fallback, plus a long-lived cache-control, and nothing else. persistFile now writes files with any user-chosen extension into that same directory, so the route's guarantee has silently changed from "only ever image bytes" to "arbitrary bytes the user uploaded."
The repo already documents the correct shape for exactly this situation. The raw branch of GET /api/v1/runs/:id/files sends x-content-type-options: nosniff and content-security-policy: default-src 'none'; style-src 'unsafe-inline'; sandbox (server.ts:4194-4195), and its own doc comment (server.ts:977) describes the protections as "image extensions only, size cap, nosniff, sandbox CSP". The images route now carries none of them while serving a strictly wider set of content.
I want to be precise about severity rather than overstate it: IMAGE_TYPES has no svg entry, so an uploaded evil.svg is served as application/octet-stream, and current Chrome and Firefox will not sniff that into an executable document. So I did not find a working same-origin XSS today. But this is a same-origin route on the process that holds the launch key and executes agents with file access, the hardening its sibling applies costs two headers, and the safety currently rests on a browser behavior rather than on anything the route asserts. CODE_REVIEW.md puts server-security regressions in the blocker/major band on principle, not on demonstrated exploitability.
Fix: add 'x-content-type-options': 'nosniff' and the same sandbox CSP to this route's headers, and consider a content-disposition: attachment for any extension outside IMAGE_TYPES so non-image attachments download rather than render.
3. packages/cezar/src/workflows/run.ts:3429-3454 — the attachments library is write-only and unbounded: nothing reads it, nothing prunes it, and pasted screenshots are archived under provenance-free sequence names.
copyToAttachmentsLibrary's doc comment sells the folder as a place where uploads "survive run cleanup and are browsable in one place." I grepped the whole repository: the only references to .ai/cezar/attachments are the writer itself and its tests. There is no route, no CLI command, no cockpit surface, and no entry in packages/cezar/src/runs/retention.ts, so nothing browses it and nothing ever reclaims it. Every user upload is duplicated there permanently.
The naming compounds it. The copy is also invoked from persistImage for the pasted prefix (run.ts:3411), where the name is a bare sequence number: pasted-1.png, pasted-2.png. Those numbers restart per run, so run A's pasted-1.png and run B's different pasted-1.png collide, and the content-inequality branch archives the second as pasted-1-2.png. After a few months of normal use the folder is a flat pile of pasted-N-M.png files carrying no run id, no date, and no original name — the opposite of browsable, and (per the blocker above) sitting in the user's git working tree.
The dedupe logic itself is well built — matching on the pre-collision name plus byte-identical content is the right call, and the "never let the library break the upload" degradation matches the repo's "written, never required" convention. The problem is scope, not implementation.
Fix, in roughly increasing cost: at minimum, give the archived copies provenance (prefix with the run id, or keep the user's original filename for pasted images rather than the sequence name) and add a size or age bound so the folder cannot grow without limit. Better still, given that nothing consumes the folder yet, consider landing the file-attachments commit on its own and holding the library commit until the surface that browses it exists — that also shrinks this PR back to one reviewable feature.
4. packages/cezar/src/server/server.ts:3733-3739, server.ts:3885-3891 and packages/cezar/src/workflows/run.ts:768-772 — a partial materialization failure is silent, and on the run-start path a total failure is silent too.
All three call sites use the same shape: map every attachment through persistFile, then .filter() out the nulls that a failed write returns. The two route handlers guard only the all-or-nothing case (parsed.data.files.length > 0 && persistedFiles.length === 0 → 500). If a user attaches three files and one write fails — a full disk, a permission problem, a name that exhausts the 100-attempt collision loop — the request answers 200, the path note lists two files, and neither the user nor the agent is told the third is missing. The agent then works from an incomplete set while believing it has everything, which is the kind of quiet wrongness that is expensive to debug later.
startRun is weaker still: if (persistedFiles.length) { … } records taskFiles when anything survived and does nothing at all when nothing did. A user whose attachments all failed to materialize gets a normally-started run with no attachments and no error anywhere.
Fix: compare persistedFiles.length against parsed.data.files.length and fail the request when they differ (the message and continue routes can reuse the existing 500 with a count, e.g. "2 of 3 attachments could not be saved to disk"). For startRun, where failing the run outright is likely too aggressive, emit a note event naming the attachments that did not materialize so the transcript records the loss.
🔹 Minor
1. packages/web/src/components/composer/composer.tsx:295 — onPaste still filters to image/*, so ⌘V of a non-image file is silently ignored.
The PR description states the feature accepts "any file type via paperclip/paste/drag-drop". The paperclip path works (the accept="image/*" attribute was removed) and onDrop passes dataTransfer.files through unfiltered, but onPaste still runs .filter((item) => item.type.startsWith('image/')) before reaching addFiles. Copying a CSV in the OS file manager and pressing ⌘V in the composer therefore does nothing at all — no attachment, no rejection toast, no explanation. The three intake paths the feature advertises as equivalent are not.
Fix: drop the image/* predicate and instead filter event.clipboardData.items to item.kind === 'file', letting screenFiles apply the caps as it does for the other two paths. If keeping paste image-only is deliberate, please say so in the PR body and in the tooltip, since the current text ("Attach a file (or paste a screenshot)") reads as if paste were the narrower path by accident.
2. packages/web/src/components/composer/composer-images.ts:70-80 — a 0-byte file fails the whole send with a raw zod message.
screenFiles checks only the upper bound (file.size > MAX_IMAGE_BYTES). An empty file encodes to data: '', which the server's fileInputSchema rejects with data: z.string().min(1), so the entire message is bounced with a schema error rather than a human sentence. Before this PR the case could not arise, because non-images were dropped client-side and a 0-byte image is not something a browser produces from a paste. CODE_REVIEW.md asks that user-facing errors be "one human-readable line".
The draft is restored on error so nothing the user typed is lost, which is why this is minor rather than major. Fix: reject file.size === 0 in screenFiles with a sentence in the same style as the existing ones, e.g. `${file.name || 'attachment'} is empty`.
3. packages/cezar/src/server/server.ts:3726-3776 — attachments are written to disk before the queue-cap and fold-length checks, and files are not counted against MAX_QUEUED_IMAGES.
The persistUserFile loop runs immediately after the provider gate, ahead of the MAX_QUEUED_IMAGES check and the foldedLength guard that can still answer 400. A message rejected by either check therefore leaves its files on disk with nothing referencing them, and dropOrphanImages cannot reclaim them because it only considers URLs recorded in queuedMessages[].images — attachments live in the message text instead. Relatedly, MAX_QUEUED_IMAGES counts stacked images only, so the stack-wide attachment ceiling is now enforced over one of the two attachment kinds.
Fix: move the materialization below both guards (nothing above it depends on the persisted paths except the text used by foldedLength, which can be computed from a projected note), and include files in the stackedImages tally so the cap covers everything a stack can hold.
4. packages/contract/src/runs.ts:148 — taskFiles is absent from the contract's runRecordSchema, and non-image attachments are consequently invisible everywhere in the cockpit.
packages/cezar/src/runs/store.ts gained taskFiles, and the routes hand the record out, but the contract mirror — the file whose parity test states it "must describe EXACTLY what the runs routes send — no wider, no narrower" — was not updated. The compile-time Mutual<> check does not catch it because an added optional property is assignable in both directions, so this drift passes typecheck silently.
The practical consequence is a visible product gap. session-transcript.tsx:78 builds the first bubble from run.taskImages only, the note event announcing attachments is still emitted solely when images.length is non-zero (run.ts:2847), and the typed client cannot even see taskFiles. So a user attaches a CSV, sees a chip in the composer, presses send — and the thread's opening bubble shows no trace of it. The agent gets the file; the human loses the record of having sent it. This is worth stating in the PR body's "known v1 gaps" section even if the rendering work lands separately.
Fix: add taskFiles: z.array(z.string()).optional() to the contract's runRecordSchema with a doc comment matching the store's, and (separately, if you prefer) extend the transcript's first bubble and the attachment note to cover non-image files.
5. packages/web/src/components/composer/composer-images.ts — naming no longer matches behavior.
The module is composer-images.ts, the type is PendingImage, the encoder is fileToPendingImage, and the composer's state is images/setImages/imagesRef — all of which now routinely hold CSVs and logs. The JSDoc has been updated to say "attachment", which makes the mismatch more conspicuous rather than less. CODE_REVIEW.md lists naming drift under Minor. A rename to PendingAttachment / fileToPendingAttachment / attachments (the module filename can stay if churn is a concern) would make the next reader's job much easier.
💅 Nit
1. packages/web/src/components/composer/composer.tsx:322 — the conditional two-arg call is more machinery than the compatibility it buys.
await (files.length ? onSubmit(body, inlineImages, files) : onSubmit(body, inlineImages)) exists so hosts that predate the third parameter "keep observing the exact legacy signature". In JavaScript, a handler declared with two parameters is unaffected by a third argument, and every in-repo host has already been updated, so the branch protects against nothing observable outside a test that counts arguments. await onSubmit(body, inlineImages, files) with files defaulting to [] would read more plainly. Author's call — if a test does assert the arity, that assertion is the thing worth revisiting.
2. packages/cezar/src/server/server.ts:606-613, 899 — the schema's maximum now exceeds the global body limit.
images and files are each capped at 4 entries of up to 7,000,000 base64 characters, so a request can legitimately satisfy both schemas at roughly 56 MB while GLOBAL_BODY_LIMIT is 32 MiB. The cockpit never hits this because MAX_IMAGES caps the two kinds at 4 combined, but a scripted client that reads the schema bounds gets a bare 413 instead of the schema's friendly message. Either document that the two arrays share an effective ceiling or cap their combined size in the refine.
💥 Breaking Changes
- No exported/public symbol removed or renamed without a deprecation path — everything added (
fileInputSchema,FileInput,persistUserFile,splitAttachments,taskFiles) is additive. - No function signature changed in a breaking way —
Composer.onSubmitgains an optional third parameter,pendingPlanOfan optional fourth; both are backward compatible. - No required type field removed or narrowed —
taskFilesand every newfilesfield are optional or defaulted. - No HTTP route URL removed or renamed; no method changed for an existing operation.
- No field removed or retyped in an existing response shape —
taskFilesis an additive record field. (See Minor 4: it is additive on the wire but undocumented in the contract mirror, which is a drift finding rather than a break.) - No event or message name renamed or removed; no payload field removed.
- No CLI command or flag renamed or removed; no machine-parsed output format changed.
- No database table or column renamed or removed — not applicable; state is file-based.
- No config key renamed and no default changed silently.
- Where a contract had to change: old surface kept working through a deprecation window, with migration notes — the
.ai/cezar/state contract inBACKWARD_COMPATIBILITY.md§3 requires a new run-data file to be registered withensureDataGitignorein the same PR, and.ai/cezar/attachments/was not (see the Blocker). No user-facing state becomes unreadable, so this is a rule violation rather than a data break, and the fix is a one-line addition to thewantedlist.
Two error-message strings change as a side effect: 'message needs text or at least one image' becomes 'message needs text, an image or a file', and the composer's cap toast changes "images" to "attachments". Both are human-readable prose rather than machine-parsed output, both are updated in their tests, and both are more accurate after the change — I do not consider them breaking, but flagging them since they are user-visible.
🧪 Test Coverage
The new tests are specific and cover the parts most likely to break. pasted-attachments.test.ts exercises the full task-start path end to end — the sanitized on-disk name, taskFiles being set while taskImages stays undefined (the distinction the whole design rests on), the path note landing in the opening prompt with imageCount === 0, and an explicit assertion that the base64 payload never enters the NDJSON event log. The persistUserFile unit test covers traversal (../../etc/passwd → passwd), collision suffixing, the dotfile case (.env → env), and an all-hostile name degrading to attachment. queued-messages.test.ts covers both the path note riding the stacked text and a file-only message passing the emptiness refine. composer-images.test.ts covers splitAttachments in both directions and the shared cap. That is genuinely good coverage of the happy paths and the security-relevant sanitizer.
The gaps map onto the findings above, and each would have caught one of them:
- No test asserts
attachments/is in the generated.gitignore. The existingensureDataGitignorecoverage should gainattachments/alongsideruns/andtmp/— this is the test that turns the blocker into a permanent regression guard. - No test covers partial materialization failure. Add a case where
persistFileis stubbed to returnnullfor one of three attachments and assert the route reports the loss rather than answering200(Major 4). - No test covers the project-scoped client helpers' request shape.
client.test.tspinssendMessage's body exactly; addingsendProjectRunMessageandcontinueProjectRunto that table would have caught the silentfilesdrop at review time (Major 1). - No test covers the 0-byte file through
screenFiles(Minor 2), nor the paste path with a non-image file (Minor 1) — the composer tests exercise paste only withpngFile(...), which is exactly why theimage/*filter survived. copyToAttachmentsLibrary's dedupe is tested, but its unbounded growth is not — there is no test pinning what the library does after many runs, which is the behavior Major 3 is about.
One note on the suite as a whole: I could not use the vitest run as a pass/fail signal on its own because this environment fails 13 suites on clean main. I established that baseline explicitly rather than taking the PR description's word for it, and the PR head fails a strict subset (12 of those same 13). The 10 tests this PR adds all pass.
|
🤖
|
|
Thanks @Damian-Szczepanski — review found actionable items, so I'm handing this PR back to you for the next pass. When the updates are pushed, re-request review and the automation can pick it up from the latest head. |
|
🤖 Reviewed the head at The verdict is driven by one blocker ( autofix: skipped (not my PR — re-run with --autofix to fix it here). |
Blocker — `.ai/cezar/attachments/` is now in `ensureDataGitignore`. The library copies every user upload there, so without the entry a CSV export, a production log or a screenshot landed in the user's `git status`, one `git add -A` from a public repo. That list is a per-entry allowlist (workflows/ and skills/ are meant to be committable), so an unnamed directory is covered by nothing. `data-gitignore.test.ts` guards the class, not just this instance: it reads the `wanted` list and every `join(dataDir, '<literal>')` in the service and fails on any name written there but not ignored — with `workflows`, `skills` and `config.json` declared as the deliberately committable exceptions. Verified to fail with the entry removed and pass with it back. Major 1 — the project-scoped twins of `sendMessage` and `continueRun` take the same widened input types but stopped forwarding at `images`, so a caller could attach files, get a 200, and have them vanish (the route defaults an absent `files` to `[]`, so nothing reported anything). Both now carry `files`, pinned in `client.test.ts` beside their unscoped siblings. Major 2 — `GET /runs/:id/images/:file` served only `persistImage` output when it was written, so "image bytes" was a property of the writer. `persistFile` broke that, so the route now asserts what it serves: `nosniff` and the sandbox CSP its sibling raw-file path already applies, plus `content-disposition: attachment` for anything outside the image map, with the filename re-sanitized rather than trusted. Major 3 — the library was unbounded and its pasted-image copies were named by a per-run sequence, so two runs' different first screenshots collided into `pasted-1.png` + `pasted-1-2.png` with no run, no date, no original name. Copies now carry the run id, and the folder is bounded at 512 MB, oldest first. The doc comment no longer calls it browsable — nothing browses it yet. Major 4 — a partial materialization failure was silent: three files attached, one write failed, 200 with a note listing two, and the agent worked from an incomplete set believing it had everything. Both routes now fail on ANY loss with the count; `startRun` still starts (the prompt is usually worth running) but records a note naming what did not survive. Also: paste accepts any file rather than filtering to `image/*` (⌘V of a CSV did nothing at all, silently, while paperclip and drag-drop worked); a 0-byte file is refused with one human line instead of bouncing the whole message with a zod error; `taskFiles` is mirrored into the contract, whose optional-property drift the `Mutual<>` check cannot catch; rejected messages no longer strand their already-written attachments on disk; and files count against the stack's attachment cap, with the residual gap named in a comment. Validation: typecheck, test:unit and build all clean; 9 suites fail here and fail the same way on a clean baseline in this environment (git-in-tmpdir, WSL paths, route-parity timing), none touching the changed code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — the blocker and all four majors are addressed in 9160590, plus the three minors that were one-line fixes. Blocker — Major 1 — scoped client twins. Major 2 — the images route. It now sets Major 3 — the library. Pasted copies now carry the run id ( Major 4 — partial failures. Both routes now fail on ANY shortfall, not just total loss, with the count in the message ( Minors. Paste now filters on Not addressed: rendering Validation on the new head: |
|
🤖 Re-review pass: the previous review's changes-requested handoff left @Damian-Szczepanski as assignee and that was the only claim signal on the PR — no |
pat-lewczuk
left a comment
There was a problem hiding this comment.
🔍 Re-review: feat(composer): non-image file attachments, restored on current main
🎯 Summary
This is a re-review of head 9160590f, following the changes-requested review of e97735d0 on 2026-08-28. The intervening commit, fix(attachments): close the review's blocker and four majors, is 396 added lines across 11 files, of which 221 are tests.
Every previous finding that blocked the PR is closed, and closed properly rather than minimally:
- The blocker is fixed and, more importantly, made un-repeatable.
'attachments/'is in thewantedlist (packages/cezar/src/index.ts:670), and the newpackages/cezar/src/data-gitignore.test.tsguards the class of bug rather than this one instance: it statically reads thewantedarray out ofindex.ts, collects everyjoin(dataDir, '<literal>')first segment acrosspackages/cezar/src, and fails on any name that is written under.ai/cezar/and absent from the ignore list — withworkflows,skillsandconfig.jsondeclared as the deliberately committable exceptions, each with its reason. That is a better answer than the one I asked for. I verified the assertion is load-bearing rather than vacuous:wantedEntries()parses line-by-line specifically because an apostrophe in the surrounding prose would otherwise shift every match, and the array's last entry does carry the trailing comma the regex requires. - Major 1 (silent
filesdrop in the project-scoped client twins) —sendProjectRunMessagenow sendsfiles,continueProjectRungained the spread, and both are pinned inclient.test.ts's request-shape table directly beside their unscoped siblings, which is exactly the thing that makes the parity survive the next refactor. - Major 2 (images route serving arbitrary bytes unhardened) — the route now sends
nosniffand the same sandbox CSP as the sibling raw-file branch, pluscontent-disposition: attachmentfor anything outsideIMAGE_TYPES, with the filename re-sanitized rather than trusted. Declining to lean onpersistFile's guarantee for a header assembled from a path param is the right instinct. - Major 3 (unbounded, write-only, provenance-free library) — pasted copies carry the run id, the folder is bounded at 512 MB oldest-first with the same "never break the upload" degradation as the copy itself, and the doc comment dropped the "browsable in one place" claim that nothing in the repo delivered.
- Major 4 (silent partial materialization failure) — both routes now reject on any shortfall with the count in the message, and
startRunappends anotenaming the files that did not materialize. I checked the note's shape against the other twenty-oddtype: 'note'emitters inrun.tsand againstthread-state.ts:517; it renders, andNoteLineinterpolatesnote.textas a React child, so the unsanitized user filenames it carries are escaped rather than injected. - Minors 1–3 are fixed with tests, and the
⌘V-of-a-CSV fix came with the observation that the old test stub was passing only because it lackedkind— which is the honest reading of why that filter survived. - Minor 4 landed as the contract mirror plus a comment explaining why
Mutual<>cannot catch that class of drift; the rendering half is explicitly declined and recorded as a known v1 gap, which is a legitimate way to close a finding.
What remains is five minors and four nits. None of them is a correctness or security hazard, none matches this repository's Major criteria in CODE_REVIEW.md ("incorrect run/step state transitions; SSE replay duplication or event loss; unbounded input reaching files or processes; a schema field added as required when old files carry it as absent"), and all of them are the kind of thing that is cheaper to fix in a follow-up than to hold a finished feature for. Two are new consequences of the fixes themselves (Minor 1 and Minor 2), two are gaps in the fixes' test coverage or resource use (Minor 3 and Minor 5), and one is carried forward unaddressed from the last round (Minor 4).
Verdict
✅ approve — no blockers, no majors. The one blocker and all four majors from the previous review are closed, each with the reasoning stated rather than just the code changed, and the validation gate is green with the vitest failures proven identical to a clean origin/main baseline. The five minors and four nits below are listed so they can be picked up; per CODE_REVIEW.md's severity guidance ("Minor — approve with comments") none of them blocks. Minor 3 in particular was requested last round and did not land, so it is worth not losing a third time.
Note that needs-qa stays on: this changes the composer's paperclip, paste and drag-drop intake, adds a chip affordance, and changes what reaches the agent, so a human should exercise the real UI before it merges. Manual QA instructions are posted separately.
🧪 Validation Gate
| Command | Status | Notes |
|---|---|---|
npm run typecheck |
✅ PASS | Clean across all projects. |
npm test |
6 suites fail on the PR head; the identical 6 fail on a clean origin/main (ae9b38b2) in the same environment — src/git-worktree.test.ts, src/server/automations-api.test.ts, src/server/git-changes.test.ts, src/server/git.test.ts, src/server/health-forge.test.ts, src/server/projects-api.test.ts. Not a subset or a superset: the same set, byte for byte. I ran both from the repository root so the scopes are comparable (327 files each; 6196 tests on the head, 6195 on main), and re-ran the head to confirm the result reproduces. The failures are all environmental — health-forge.test.ts:105 and projects-api.test.ts:753 expect repo/forge to be null in a temp dir and get this checkout's real git remote, because TMPDIR resolves inside the repository here. None touches the changed code. No regression is attributable to this PR. |
|
npm run test:unit |
✅ PASS | 36/36 in the node:test core-module suite. |
npm run build |
✅ PASS | Includes check:pack — "ok — 475 files, 85 under web/dist". |
npm run test:package |
✅ PASS | 15/15 packaged-CLI E2E. |
I also ran the PR's own tests in isolation to confirm they are real rather than merely collected: composer.test.tsx, client.test.ts, composer-images.test.ts → 139 passed; data-gitignore.test.ts, pasted-attachments.test.ts, queued-messages.test.ts → 41 passed.
CI status at review time: the required check Unit, build, E2E, and package was still pending (run 33242626225) when this review was submitted; license/cla is green. This review is not gated on it — the local gate above is this run's evidence, and CI remains the authority for the merge. A follow-up comment will record the CI outcome.
Findings
🔹 Minor
1. packages/cezar/src/server/server.ts:4131 — nosniff on a response typed application/octet-stream may stop legitimate pasted images from rendering in the cockpit.
The hardening is right, but it lands on a route that serves two different things. persistImage (run.ts:3411-3416) derives its extension from the media type and falls back to 'img' for anything that is not PNG, JPEG, WebP or GIF, and splitAttachments (composer-images.ts:49) routes everything whose mediaType starts with image/ down that path. So an AVIF, BMP, HEIC or SVG file dragged onto the composer is stored as pasted-N.img, and IMAGE_TYPES (server.ts:4494-4499) has no img key — it is served as application/octet-stream, now with nosniff and content-disposition: attachment, and rendered through <img src> by ImageItem (thread-items.tsx:641).
Per the Fetch specification nosniff blocks only script and style destinations, so Chrome is unaffected, and content-disposition is ignored for subresource loads in every engine. Firefox, however, additionally enforces nosniff on image loads and refuses an image whose declared type is not an image type. I have not run a browser against this build, so I am flagging it as a risk to confirm rather than a demonstrated break — but it is worth confirming, because before this commit those files rendered by sniffing and now they are declared un-sniffable.
Independently of the browser question, serving a known-image payload as application/octet-stream is simply inaccurate, and the fix closes both concerns at once. Fix: have persistImage record the real media type (or a truthful extension) instead of collapsing everything unknown to .img, and widen IMAGE_TYPES to the formats the composer can actually produce — deliberately excluding svg, which is the one case where refusing to render is the correct outcome and where content-disposition: attachment is doing exactly its job. Worth a line in the QA pass either way (it is in the manual-QA comment).
2. packages/cezar/src/server/server.ts:3818 and packages/cezar/src/server/server.ts:3939 — the new orphan-attachment cleanup misses the two 409 rejection paths.
dropPersistedFiles is a good fix and its comment states the rule correctly — "every rejection below has to take them back out" — but it is wired into only the three 400s inside the currentRun?.status === 'queued' block (server.ts:3789, :3800, :3805). Two rejection paths persist files and then return without it:
server.ts:3818, the terminalreturn c.json({ error: 'session closed' }, 409)inPOST /runs/:id/messages, reached when neitherenqueueMessagenordeferMessageaccepts the message.server.ts:3939,if (!result.ok) return c.json({ error: result.error }, 409)inPOST /runs/:id/continue, where the whole route has no cleanup at all.
Both are the ordinary race — a user finishes typing and attaches a file just as the session closes — so they are arguably more likely in practice than the queue-cap 400s that are covered, and they leave exactly the orphans the fix set out to prevent, unreclaimable by dropOrphanImages for the same reason (attachments live in the message text, not in queuedMessages[].images). Fix: hoist dropPersistedFiles above the ladder and call it on both, and add the same closure to the continue route. Related but smaller: the attachments-library copy made by persistFile is never dropped, so a rejected upload still archives. That is defensible — the library is "everything the user uploaded" and the folder is now gitignored and bounded — but worth a sentence in the doc comment so the asymmetry is deliberate rather than incidental.
3. packages/cezar/src/server/server.ts:3746 and packages/cezar/src/workflows/run.ts:788 — the two highest-severity fixes in this commit landed without regression tests, and one of those tests was explicitly requested last round.
The all-or-nothing rule is now a single !== comparison and the startRun loss note is a single if (lost.length); nothing in the suite pins either. grep for could not be saved to disk across packages/cezar/src and packages/web/src returns no test hit. The previous review listed this as test-coverage gap #2 with the shape spelled out — stub persistFile to return null for one of three attachments and assert the route reports the loss rather than answering 200 — and it did not land and was not mentioned in the response comment, so it reads as dropped rather than declined.
The same applies to the Major 2 hardening: no test asserts the images route's x-content-type-options, CSP or content-disposition, even though the sibling raw-file path's header is pinned at git-changes.test.ts:701. Security headers are precisely the thing a later refactor removes quietly. Fix: one route test per fix — a partial-failure 500 with the count in the body, and a header assertion on GET /api/v1/runs/:id/images/:file for both a known extension and an unknown one.
4. packages/web/src/components/composer/composer-images.ts:17,23,42 — the naming drift carried over from the previous review is still open, and unacknowledged.
The module is composer-images.ts, the interface is PendingImage, the encoder is fileToPendingImage, and the composer's state is images/setImages/imagesRef — all of which now routinely hold CSVs and logs, and the JSDoc saying "attachment" makes the mismatch more conspicuous rather than less. CODE_REVIEW.md lists naming drift under Minor, so this does not block; I am re-raising it only because the response comment accounted for every other finding explicitly and this one silently, and a rename gets more expensive with every surface that adopts the old name. PendingAttachment / fileToPendingAttachment / attachments would do it; the filename can stay if the churn is a concern.
5. packages/cezar/src/workflows/pasted-attachments.test.ts:380 — the library-cap test allocates and writes a real 520 MB file to exercise a 512 MB constant.
writeFileSync(old, Buffer.alloc(520 * 1024 * 1024)) costs a ~520 MB resident allocation in the vitest worker plus 520 MB of real disk I/O, for one assertion. I measured it at 597 ms locally, which is tolerable here, but npm test is a required check and vitest runs several workers in parallel on a 7 GB GitHub-hosted runner — this is the kind of fixture that turns into an intermittent OOM or a disk-space failure on someone else's machine rather than a clean red.
The test itself is the right test and should stay. Fix, in order of preference: make ATTACHMENTS_LIBRARY_MAX_BYTES injectable (a constructor option or an env override on RunManager) so the case can assert the same behavior at a few kilobytes; or, if the constant should stay private, create the fixture sparsely — writeFileSync(old, ''); truncateSync(old, 520 * 1024 * 1024) — which statSync().size reports at the full logical size, so trimAttachmentsLibrary behaves identically with near-zero allocation and I/O.
💅 Nit
1. packages/cezar/src/data-gitignore.test.ts:60 — the doc comment's "and none exists today" is not accurate, and it describes the guard's one blind spot.
The comment says computed path segments cannot be checked statically "and none exists today", but packages/cezar/src/automations/store.ts has several — join(this.dataDir, POLL_LOCK) at line 210 and join(this.dataDir, filename) at 276, 292, 310, 318 and 328. Those particular files are already in wanted, so nothing is currently unguarded; the problem is that the sentence tells the next reader the blind spot is empty when it is not, which is the exact reader who needs to know it is not. Suggest replacing it with a short note that computed segments exist in automations/store.ts and are covered by hand.
2. packages/cezar/src/server/server.ts:3801 — the queue-cap error still says "image limit" on a rejection a file caused.
Now that incomingAttachments folds files.length into the tally, a user who stacks four CSVs onto a queued run is told "too many queued images — 8 image limit across the stack". "attachment limit" would match what is actually being counted; the accompanying comment already uses the right word.
3–4. The previous review's two nits are still open, both of which were explicitly the author's call and remain so. composer.tsx:327's conditional two-arg onSubmit still guards a JavaScript behavior that needs no guard, and the images/files schema maxima (4 × 7,000,000 chars each) still sum above GLOBAL_BODY_LIMIT (32 MiB) for a scripted client that reads the bounds. Neither blocks; noting them only so the thread stays complete.
💥 Breaking Changes
- No exported/public symbol removed or renamed without a deprecation path —
attachmentSaveErrorandtrimAttachmentsLibraryare new and private/module-local; everything else added this round is additive. - No function signature changed in a breaking way —
copyToAttachmentsLibrary's callers changed the value ofname, not the signature. - No required type field removed or narrowed —
taskFilesis optional in both the store and, now, the contract mirror (packages/contract/src/runs.ts:157), so oldruns.jsonfiles still parse (CODE_REVIEW.md, State-file compatibility). - No HTTP route URL removed or renamed; no method changed for an existing operation.
- No field removed or retyped in an existing response shape — the contract drift flagged as Minor 4 last round is now closed, so the mirror and the routes agree.
- No event or message name renamed or removed; the new loss note reuses the existing
notetype. - No CLI command or flag renamed or removed; no machine-parsed output format changed.
- No config key renamed and no default changed silently.
-
BACKWARD_COMPATIBILITY.md§3 — a new run-data file must be registered withensureDataGitignorein the same PR. This was the previous review's blocker and it is now satisfied, with a test that will keep it satisfied. The.ai/cezar/state contract is intact.
Two behavior changes are worth naming even though I do not consider them breaking, because both change what an existing client observes:
POST /runs/:id/messagesandPOST /runs/:id/continuenow answer500on a partial attachment write where they previously answered200with a truncated set. That is the requested fix and the correct trade — a silent partial loss is strictly worse — but it is a status-code change on an existing route for a case that used to succeed, so it belongs in the release notes.GET /api/v1/runs/:id/images/:filegained three response headers. See Minor 1 for the one case where that is more than hardening.
🧪 Test Coverage
The commit adds 221 lines of test against 175 lines of production code, and the tests are pointed at the failure rather than at the code. Three deserve specific credit:
data-gitignore.test.tsguards the rule instead of the instance, and its second case will fail for a state file nobody has written yet. That is the difference between fixing a bug and closing a class of bug, and it is the right answer to a finding whose whole problem was that it is invisible from inside this repository.- The provenance test scopes its assertion to the two run ids it created, because earlier cases in the same file archive into the same shared library folder — a detail that would otherwise have made it flaky the moment someone added a case above it.
- The composer paste tests fixed the stub as well as the code: adding
kindto the fakeDataTransferItemis what makes the new filter actually exercised, and thepasteTextcase pins that plain-text paste still reaches the textarea, which is the regression the widened filter could plausibly have caused.
Remaining gaps, all mapped to findings above:
- No test for the all-or-nothing partial-failure rule on either route, and none for the
startRunloss note (Minor 3) — explicitly requested in the previous review. - No test for the images route's new security headers (Minor 3), while the sibling raw path's header is pinned at
git-changes.test.ts:701. - The library-cap test's fixture is 520 MB of real bytes (Minor 5) — the assertion is right, the fixture is not.
- Nothing exercises the two uncovered orphan-cleanup paths (Minor 2), which is consistent with the code not covering them.
None of these changes the verdict. The behavior this PR ships is covered where it matters most — the sanitizer, the intake paths, the wire shapes, the taskFiles/taskImages split, and now the gitignore rule.
🧪 Manual QA instructions (
|
|
🤖 Re-reviewed head Validation gate: What is left is five minors and four nits, none of which matches this repository's
Labels: autofix: skipped (not my PR — re-run with
|
|
🤖 CI settled inside the follow-up budget, and it is red — but not because of this PR. The failure is a pre-existing test-isolation bug in the release tooling that fires on any workflow re-run, which for a fork PR means every run. What failed. Run 33242626225, check Unit, build, E2E, and package — one job step, Root cause — Why this necessarily hits fork PRs. A fork PR's first workflow attempt is Evidence it is not this PR. Three independent lines:
What this means for the verdict. The code approval stands: it was a judgement about the diff and nothing here changes it. But a red required check is a red required check, so I have moved the pipeline label off I set The unblock, in order of preference. Pin the ambient variable in the nightly case, matching what its sibling at line 110 already does — one line, and it fixes every future fork PR at the same time: GITHUB_RUN_NUMBER: '12',
GITHUB_RUN_ATTEMPT: '1',
CEZ_RELEASE_CHANNEL: 'nightly',Better still, since this is the second case to be bitten by inheritance, consider having As a stopgap a maintainer can re-run the failed job, but note that a re-run raises the attempt number rather than resetting it, so it will fail again for the same reason. There is no attempt number that makes this pass except 1. Removing |
|
Closing this from our side — the change already lives in our own build and we are no longer maintaining the branch it was proposed from. Nothing here is blocked on you; feel free to pick the idea up independently if it is useful. Thanks for taking a look. |
|
Thanks for saying so plainly — and no hard feelings at all about the withdrawal. Keeping a fork branch rebased against a moving For the record, and so this does not read as a rejection to anyone finding it later: the review verdict on this PR was approve. The blocker and all four majors were closed, and the final red CI was a pre-existing test-isolation bug in our own release tooling ( Two things came out of your work:
We narrowed it in one place: files only, not images. A pasted screenshot has no filename to file it under, and putting a name on the image path would mean widening Thanks for the idea and for the care you put into the review rounds — both landed. |
An attachment's on-disk name has always been derived from its media type alone (`pasted-<n>.<ext>`), so the user's own filename never reached the server. That is correct for the RUN folder — several readers depend on that numbering space — but it leaves nothing to file a per-project attachment library under, which is what #929 proposed and what this branch builds. Adds an additive optional `name` to `attachmentInputSchema` plus `sanitizeAttachmentName`, the one place a client-supplied string is allowed to influence a path: basename on both separator conventions, control characters and Windows-refused characters removed, no leading dots, stem bounded, and the extension pinned to the media type the schema already validated — so a `text/plain` upload named `install.sh` lands as `install.sh.txt` rather than as something that passed a check which believed it was screening for exactly that. Idea and the original design: #929 by @Damian-Szczepanski.
Every named file a user attaches is now also copied to `.ai/cezar/attachments/`, under the name they picked. The run folder is unchanged — it still names files `pasted-<n>.<ext>`, which `isImageAttachmentName`, the orphan sweep, the restart re-read and the per-stack cap all depend on — so this is purely additive. The writer resolves a name clash against CONTENT first: a library spanning every task in a repository collects a great many `notes.md`, and the same brief attached to six tasks should leave one file, not six. Only same-name-different-bytes takes a `-2` suffix. Best-effort throughout: the run folder already holds the file the agent was promised, so a full disk costs the library entry and nothing else. The agent is pointed at the library as a DIRECTORY rather than per file. The per-run paths already cover the current message; what the library answers is "the brief I attached last week", which no per-attachment handle survives to reach — attachments are reconstructed from their URL alone at dequeue. Images are deliberately not filed: a pasted screenshot has no filename to file it under, and carrying one would mean widening `ContentBlock`, which is the runner protocol and reaches vendor APIs verbatim. Idea: #929 by @Damian-Szczepanski.
`ensureDataGitignore` names every entry cezar writes under `.ai/cezar/` one by one, because `workflows/` and `skills/` beside them are meant to be committable. The cost of that design is that a new state directory nobody adds to the list is covered by nothing — and here the entries are files the user uploaded, so a missing line is one `git add -A` from an internal PDF in a public repository. The bug is invisible from inside this repository (cezar's own root `.gitignore` ignores `.ai/cezar/` wholesale) and invisible to typecheck, so it gets a test rather than care: `data-gitignore.test.ts` statically cross-checks every literal `join(dataDir, '…')` in the service against the list, with the committable exceptions named. Both of its assertions go red with the `attachments/` line removed. Verified against a real consumer repo: after `cezar init`, a file in the library is matched by `.ai/cezar/.gitignore:4:attachments/` and `git status` stays clean. An existing install self-heals — the writer appends what is missing and leaves the user's own entries alone, which the same test pins statically. Diagnosis and the fix shape: #929 by @Damian-Szczepanski.
* docs(runs): add execution plan for attachment-library * feat(contract): carry the user's filename on an attachment, sanitized An attachment's on-disk name has always been derived from its media type alone (`pasted-<n>.<ext>`), so the user's own filename never reached the server. That is correct for the RUN folder — several readers depend on that numbering space — but it leaves nothing to file a per-project attachment library under, which is what #929 proposed and what this branch builds. Adds an additive optional `name` to `attachmentInputSchema` plus `sanitizeAttachmentName`, the one place a client-supplied string is allowed to influence a path: basename on both separator conventions, control characters and Windows-refused characters removed, no leading dots, stem bounded, and the extension pinned to the media type the schema already validated — so a `text/plain` upload named `install.sh` lands as `install.sh.txt` rather than as something that passed a check which believed it was screening for exactly that. Idea and the original design: #929 by @Damian-Szczepanski. * feat(composer): send a file attachment's own filename Routes the composer's submit through one `toAttachmentInput` helper instead of an inline destructure, so there is a single place that decides what leaves the browser — the render-only `preview` (a second full copy of the bytes) never does, and the filename does, for files only. A pasted image has no filename to send; its chip falls back to a literal placeholder, and images are not filed in the library. * docs(runs): mark attachment-library Phase 1 complete * feat(attachments): per-project attachment library Every named file a user attaches is now also copied to `.ai/cezar/attachments/`, under the name they picked. The run folder is unchanged — it still names files `pasted-<n>.<ext>`, which `isImageAttachmentName`, the orphan sweep, the restart re-read and the per-stack cap all depend on — so this is purely additive. The writer resolves a name clash against CONTENT first: a library spanning every task in a repository collects a great many `notes.md`, and the same brief attached to six tasks should leave one file, not six. Only same-name-different-bytes takes a `-2` suffix. Best-effort throughout: the run folder already holds the file the agent was promised, so a full disk costs the library entry and nothing else. The agent is pointed at the library as a DIRECTORY rather than per file. The per-run paths already cover the current message; what the library answers is "the brief I attached last week", which no per-attachment handle survives to reach — attachments are reconstructed from their URL alone at dequeue. Images are deliberately not filed: a pasted screenshot has no filename to file it under, and carrying one would mean widening `ContentBlock`, which is the runner protocol and reaches vendor APIs verbatim. Idea: #929 by @Damian-Szczepanski. * docs(runs): mark attachment-library Phase 2 complete * fix(init): ignore the attachment library, and guard the rule with a test `ensureDataGitignore` names every entry cezar writes under `.ai/cezar/` one by one, because `workflows/` and `skills/` beside them are meant to be committable. The cost of that design is that a new state directory nobody adds to the list is covered by nothing — and here the entries are files the user uploaded, so a missing line is one `git add -A` from an internal PDF in a public repository. The bug is invisible from inside this repository (cezar's own root `.gitignore` ignores `.ai/cezar/` wholesale) and invisible to typecheck, so it gets a test rather than care: `data-gitignore.test.ts` statically cross-checks every literal `join(dataDir, '…')` in the service against the list, with the committable exceptions named. Both of its assertions go red with the `attachments/` line removed. Verified against a real consumer repo: after `cezar init`, a file in the library is matched by `.ai/cezar/.gitignore:4:attachments/` and `git status` stays clean. An existing install self-heals — the writer appends what is missing and leaves the user's own entries alone, which the same test pins statically. Diagnosis and the fix shape: #929 by @Damian-Szczepanski. * docs(runs): mark attachment-library Phase 3 complete * docs: record the attachment library and the ignore-list rule BACKWARD_COMPATIBILITY.md gains the additive optional `name` on the attachment wire shape (2) and `attachments/` as a new, entirely optional `.ai/cezar/` directory (3), including why no retention sweep touches it: it holds user content, not run data. AGENTS.md's `ensureDataGitignore` note now says what makes the per-entry allowlist sharp and why the mistake is invisible from inside this repository. * docs(runs): mark attachment-library Phase 4 complete * fix(contract): bound an attachment name in bytes, not just characters Self-review of the sanitizer found two ways a legitimate name could still produce an entry the filesystem refuses — and because the failing write is caught, the cost of either is a silently missing library entry rather than a visible error. - Filesystems bound an entry in BYTES (255 on ext4/APFS/NTFS). A 100-character bound is not one: 100 emoji are 400 bytes. Truncation is now byte-aware and cuts on a code point, so a surrogate pair is never left half-written into a filename. - A name ending in a dot or a space (`notes.`) produced `notes..txt`, and Windows refuses an entry ending in either. Stripped after truncation, which can expose one. The width is computed arithmetically rather than measured: `packages/contract` is Node-free AND DOM-free by construction, so neither `Buffer` nor `TextEncoder` is in scope — its typecheck caught the first attempt, which is the guard working. * fix(attachments): grant the agent the library its note points at Code review of this PR found one thing that stopped the feature from reaching the agent at all, plus four smaller ones. The library was announced but never granted. `pastedAttachmentsText` names `.ai/cezar/attachments` in the note appended to a message, but `agentDirectories` — the only source of `--add-dir` — passed just `runs/` and the run's TMPDIR. A run's cwd is its worktree under `.ai/cezar/worktrees/<id>`, so the library is neither below cwd nor on the granted list, and headless runs use `--permission-mode dontAsk`: the agent's `Read`/`Glob` on the exact path it was told to look in were refused, without even a prompt. `runs/` is on that list for precisely this reason, and `agentDirectories`' own docstring makes the argument — handing an agent a directory its file tools may not use "would trade one silent failure for another". The grant now travels with the mention, and the end-to-end test asserts it against the mock's captured argv (it fails without the fix). The composer's display fallback was reaching the wire. A chip needs a label, so a nameless upload falls back to `pasted.<ext>` — and `toAttachmentInput` sent that, so the library filed `pasted.md`, then `pasted-2.md`, then `pasted-3.md`: the numbering the library exists to replace, and the opposite of what `fileInAttachmentLibrary` documents. Only a name the upload itself carried is sent now (`originalName`), which leaves the chip's fallback where it belongs. The stem truncation could still emit a lone surrogate. The character bound ran first as a UTF-16 `slice`, so it cut code UNITS and could halve a pair before the byte pass — which then faithfully preserved the half. `'a'.repeat(97) + '😀😀'` reproduces it; Node writes the result out as `U+FFFD`. Both bounds now apply in one code-point pass. Also: `copyToAttachmentLibrary` refuses a name that is not already a bare segment, so path safety lives next to the write rather than in a caller two modules away; Windows reserved device names (`CON.txt` is the console, whatever follows the dot) get a suffix; the note says "documents" rather than "files", because images and nameless uploads are deliberately never filed; and the ignore-list guard strips comments before matching quotes, so an apostrophe in future prose cannot inject a phantom entry and make the check more permissive. Gate: typecheck, test:unit (36/36), build incl. check:pack, test:package (16/16) all green. `npm test` is 6282/6288 with the same six environmental failures already documented on this PR — all assert "not a git repo" and pass on CI, where `TMPDIR` does not resolve inside a checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(init): ignore dispatch task state --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the file-attachments feature that never landed: the composer on main still only accepted
image/*— a CSV/log/JSON dropped on it was silently ignored, and the picker filtered to images.This is #899 (6363000) rebased onto current main as a clean cherry-pick, plus its follow-up commit from the same branch family:
.ai/cezar/runs/<runId>-images/and the absolute path is appended to the message text, so the delivery ladder, restart recovery and folded-task bound all inherit attachments for free. Non-image attachments render as a named chip in the composer; the file input no longer has anacceptfilter..ai/cezar/attachments/(pre-collision names + content dedupe, best-effort).Supersedes #899 (same feature commit; that branch is 4 commits behind main and lacks the library follow-up).
Verified on this branch:
npm run typecheck✓,npm run build(incl. check:pack) ✓, all 171 attachment-related tests ✓. The 9 remaining suite failures reproduce identically on clean main in the same environment (WSL-specific) and are unrelated.Known v1 gap (unchanged from #899): files added while editing a stacked message are dropped; files attached at post time survive edits via the text. The in-flight #915 touches the same prompt-note wording and can merge independently.
🤖 Generated with Claude Code