fix(sdk): give table-cell annotations semantic row and column names - #242
Open
adelin-b wants to merge 9 commits into
Open
fix(sdk): give table-cell annotations semantic row and column names#242adelin-b wants to merge 9 commits into
adelin-b wants to merge 9 commits into
Conversation
Owner
|
Speaking as Kun's firstmate: this is a good corrective — semantic row and column names, fail-closed, locator unchanged. It's conflicted with current main ( |
Owner
Owner
kunchenguid
added a commit
that referenced
this pull request
Aug 19, 2026
* fix(sdk): give table-cell annotations semantic row and column names Port the accepted work from #242 onto current main so filtered or sorted tables keep semantic row and column names while the clicked element's own selector, tag, and text stay the locator. Co-authored-by: Adelin <adelin-b@users.noreply.github.com> * no-mistakes(review): Remove source-token SDK serialization test * no-mistakes(document): Clarify semantic table target documentation * no-mistakes: apply CI fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
Owner
|
Speaking as Kun's firstmate: the conflict follow-up #256 is now merged (semantic table-cell names on main). Leaving this PR open so you can close it when you want. |
Julian-Dasilva
pushed a commit
to Julian-Dasilva/lavish-axi
that referenced
this pull request
Aug 20, 2026
* fix(sdk): give table-cell annotations semantic row and column names Port the accepted work from kunchenguid#242 onto current main so filtered or sorted tables keep semantic row and column names while the clicked element's own selector, tag, and text stay the locator. Co-authored-by: Adelin <adelin-b@users.noreply.github.com> * no-mistakes(review): Remove source-token SDK serialization test * no-mistakes(document): Clarify semantic table target documentation * no-mistakes: apply CI fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
Julian-Dasilva
pushed a commit
to Julian-Dasilva/lavish-axi
that referenced
this pull request
Aug 20, 2026
* fix(sdk): give table-cell annotations semantic row and column names Port the accepted work from kunchenguid#242 onto current main so filtered or sorted tables keep semantic row and column names while the clicked element's own selector, tag, and text stay the locator. Co-authored-by: Adelin <adelin-b@users.noreply.github.com> * no-mistakes(review): Remove source-token SDK serialization test * no-mistakes(document): Clarify semantic table target documentation * no-mistakes: apply CI fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
Julian-Dasilva
pushed a commit
to Julian-Dasilva/lavish-axi
that referenced
this pull request
Aug 20, 2026
* fix(sdk): give table-cell annotations semantic row and column names Port the accepted work from kunchenguid#242 onto current main so filtered or sorted tables keep semantic row and column names while the clicked element's own selector, tag, and text stay the locator. Co-authored-by: Adelin <adelin-b@users.noreply.github.com> * no-mistakes(review): Remove source-token SDK serialization test * no-mistakes(document): Clarify semantic table target documentation * no-mistakes: apply CI fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
stefanwalther
added a commit
to stefanwalther/lavish-axi
that referenced
this pull request
Aug 21, 2026
* fix(server): confine artifact asset route by realpath (symlink-escape hardening) (#194)
* fix(server): confine the artifact asset route by realpath to block symlink-escape (Phase-B sweep)
resolveArtifactAsset() confined asset paths lexically only (path.resolve/relative — a
string check that never touches disk), so a symlink whose NAME sits inside the artifact
dir but whose TARGET points outside was served by res.sendFile, which follows it. The
export path (guardedRead) already defends this exact threat with realpath resolution.
Live-verified: GET /artifact/<key>/<symlink> returned 200 + leaked an outside file
pre-fix, 403 post-fix. The route is reachable by script inside the artifact (its HTML
embeds the session key). Fix: make resolveArtifactAsset async, realpath the resolved
path + root, reject anything escaping (mirrors guardedRead); both call sites await it.
node --test 592 pass / 0 fail (+2 new symlink-escape tests), eslint + tsc clean.
* no-mistakes(review): fail closed on non-ENOENT realpath errors in resolveArtifactAsset
* no-mistakes(document): document realpath symlink confinement on artifact asset routes
* fix(server): return the resolved asset path and cover both routes with regression tests
Harden the realpath confinement added in this branch and prove it:
- resolveArtifactAsset now hands back the symlink-resolved path instead of the
requested one. A real path contains no symlinks, so sendFile re-opening it
cannot be redirected by a link swapped in between the check and the read.
- Regression tests for both routes it guards (/artifact and /whiteboard-assets):
a leaf symlink escape, an escape through an intermediate directory symlink,
and an in-directory symlink that must still resolve (no over-blocking).
- Guard the pre-existing lexical ".." rejection at the HTTP level via raw
requests, since fetch collapses ".." in a URL before it reaches the server.
All seven escape tests fail against the pre-fix resolver (the /artifact case
serves the outside file with 200); the two lexical-traversal guards pass in
both states, which is what makes them preservation checks.
---------
Co-authored-by: Nic Nogueira <tibernero@proton.me>
Co-authored-by: kunchenguid <kun@kunchenguid.com>
* chore(main): release lavish-axi 0.1.49 (#234)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(server): harden feedback submission boundaries (#235)
* fix(server,chrome): bind whiteboard channels to their session and guard prompt submission
The session key is derived from the artifact path, not a secret, and the
whiteboard frame page is framable by any origin, so neither key nor channel
token possession can stand in for authorization:
- Whiteboard channel tokens are now signed over the session key, making a
token a capability for exactly one session; /whiteboard-frame requires
?key= and both call sites pass their own.
- The chrome accepts inline whiteboard messages only from windows that
descend from the current artifact frame, mirroring the guard the ordinary
artifact-annotation handler already applies.
- POST /api/:key/prompts is same-origin guarded like /share and the
whiteboard write routes.
- The session chrome page answers X-Frame-Options: DENY and
frame-ancestors 'none'. Scoped to that route: /artifact/* is framed by the
chrome, and /whiteboard-frame is framed by the artifact document, whose
sandbox gives it an opaque origin no frame-ancestors expression can name.
Addresses GHSA-w887-pf37-frrv.
* no-mistakes(review): Preserve secure proxied same-origin feedback
* style: apply prettier to the proxied same-origin fix
* no-mistakes(review): Harden host authority validation against origin bypasses
* no-mistakes(review): Reject authorities without valid canonical origins
* no-mistakes(review): Preserve wildcard proxy routing with strict authority validation
* no-mistakes(document): Document proxy headers and fix lint formatting
* chore(main): release lavish-axi 0.1.50 (#236)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore: remove committed no-mistakes evidence (now on orphan branch) (#239)
* chore: gitignore no-mistakes evidence dir (contributor safety) (#240)
* feat(attachments): attach reference images to annotations and deliver them to the agent (#188)
* feat(attachments): content-addressed image storage in the state dir
Add src/attachment-store.js: magic-byte image validation (PNG/JPEG/WebP),
content-hash ids, atomic dedup writes under <state-dir>/attachments/<key>/,
and a resolveAttachment trust boundary that re-derives every field (absolute
path, mime, bytes, dimensions) from disk rather than from the caller. Limits
are LAVISH_AXI_* env-configurable.
* feat(attachments): upload, fetch, and remove endpoints
Add POST/GET/DELETE /api/:key/attachments routes. Upload takes raw image
bytes via express.raw (per-image byte cap -> 413) and is same-origin guarded
like the whiteboard writes; fetch serves the stored bytes with the resolved
mime; delete removes the file idempotently. Non-images 415, unknown sessions
404.
* feat(attachments): resolve prompt attachments at the trust boundary
Prompts carry only a client id + display name; queuePrompts re-resolves each
id against the on-disk store, injecting the authoritative path/mime/bytes/dims
and enforcing per-prompt count and total-byte caps. Unknown ids are dropped and
a missing resolver drops attachments rather than trusting client metadata, so a
crafted /prompts POST cannot aim an attachment at an arbitrary file.
* feat(attachments): reference-aware TTL sweeper and disk-cap backstop
Add listAttachments + sweepAttachments to the store and referencedAttachmentIds
to SessionStore. A file is reaped only when past its TTL AND not referenced by a
pending prompt (so send-and-end batches survive); the optional disk cap then
evicts oldest UNREFERENCED files, never referenced ones. The server sweeps at
startup and hourly, skipping entirely when neither TTL nor disk cap is set.
* feat(attachments): capture images in the annotation card (SDK)
The annotation card gains paste, drag-drop, and an Attach-image picker for
PNG/JPEG/WebP. Captured files render as chips with a thumbnail and upload/
ready/error state; the SDK reads bytes and hands them to the chrome (which owns
the server round trip), applies upload results, and supports remove/retry. On
queue it rides the ready attachments' server ids along with the prompt.
* feat(attachments): chrome upload orchestration and pill thumbnails
The chrome performs the same-origin attachment upload/delete on behalf of the
sandboxed iframe and reports the server-vetted id back to the card; queued-prompt
pills render image thumbnails from the attachment endpoint and label image-only
annotations.
* feat(attachments): poll delivery guidance, docs, and #123 non-regression
Poll next_step tells the agent when prompts carry image attachments and to open
their local paths. Document the feature and its LAVISH_AXI_* limits in README
(user-facing contract) and the storage/trust/sweeper invariants in AGENTS.md.
Add a non-regression test that export coexists with the raw-upload route and
never leaks attachment ids/paths/SDK into an exported bundle.
* fix(attachments): deliver a clean 413 on over-cap upload instead of hanging
VERIFIED: over-cap uploads DID get an HTTP 413 (curl/undici recovered it), but
express.raw aborts on Content-Length WITHOUT draining the body, so a browser
mid-upload is reset and the chip hangs on "uploading" with no recovery.
Fix both paths: the route now reads the stream via readAttachmentUploadBody,
which buffers up to the cap but always drains to end-of-body before sending a
clean 413 the browser reliably receives. Belt-and-suspenders, the chrome pre-
checks byte length against attachmentMaxBytes (surfaced in the session JSON) and
fails the chip locally before uploading. Both reach the error+retry state; the
errored chip never blocks queueing.
* fix(attachments): intercept non-image drops and show a visible remove X
Drops over the annotation card now preventDefault unconditionally, so a dropped
PDF can't navigate the frame; non-images surface a dismissible UNSUPPORTED_TYPE
error chip (no thumbnail, no retry). The chip's remove control is a larger,
higher-contrast, titled X. Trim the card helper text.
* polish(attachments): use Lavish's native close icon for the chip remove control
Match the app's own close buttons (.pill-close / .share-close): same path,
viewBox, 1.6 stroke weight, and round caps, in a restrained circular button
(subtle white, hover fill) instead of the heavier foreign-looking glyph. Remove
function, title, and aria-label are unchanged.
* fix(attachments): render the chip remove-X (padding override) + clean inline glyph
Root cause of the empty/squished remove-X: the generic '.lavish-annotation-card
button' rule (padding:8px 10px, specificity 0,1,1) outweighs '.lavish-attachment-
remove' (padding:0, specificity 0,1,0), so with box-sizing:border-box the 22px
button's content box collapsed to ~2x6px and squished the glyph into a speck. Add
padding:0!important (the sibling .lavish-attach button already uses !important for
this same conflict) so the icon gets its full box. Also inline a clean, self-
contained X path (no sprite/symbol/use/mask reference). Verified end-to-end by
driving the real annotation card in a sandboxed iframe (real drop -> real chip):
the X now paints crisp and centered. Remove function, title, aria-label unchanged.
* harden(attachments): seal v2.5 concurrency, lifecycle, and security gaps
Round-3 conformance to the sealed design v2.5 for the #103 attachment
feature (no-mistakes flagged Risk HIGH). Four groups:
Group 1 — lifecycle & concurrency
- D5: one shared AsyncMutex serializes upload-finalize, /prompts
resolve+persist, delete, and the reference-aware sweep, closing the
window where a reference is acquired between the sweeper's snapshot and
its delete (src/async-mutex.js).
- refcount: DELETE is reference-counted under the lock — a content-
addressed file still referenced by a queued prompt survives a chip
removal (status "referenced") instead of breaking the queued thumbnail.
- B3: dedup re-upload refreshes the file mtime so a re-referenced aged
file isn't reaped by the next sweep.
Group 2 — batch & queue integrity
- C4: attachment resolution is all-or-nothing — an unknown id or a
count/byte-cap breach rejects the whole batch (400, persist nothing)
with { rejected, caps }; the chrome keeps its queue and surfaces the
reason instead of silently dropping images.
- R2.4: the SDK card gates queuing on hasPending() so an in-flight upload
can't be dropped by collectReady/closeCard, and only deletes a removed
chip's file when no sibling shares its deduped id.
Group 3 — security
- The chrome mediates the same-origin confused-deputy: rate limit + per-
session cumulative-byte quota before any upload hits the loopback
server, plus a bounded default disk quota (512 MiB, off/0 disables).
Group 4 — perf
- D6: persist vetted dims in an <id>.meta sidecar at upload;
resolveAttachment reads it instead of re-parsing the image, and the
thumbnail GET serves via a lightweight stat (statAttachmentForServe),
removing the ~2x full-image read per render.
* no-mistakes(review): Harden attachment payload validation and JPEG TEM parsing
* no-mistakes(document): Document attachment guards and fix lint
* fix(attachments): close state race and harden the annotation card (round 4)
Round-4 conformance fixes for the #103 image-attachment feature, on top of
c2db2df. Addresses the four defects no-mistakes flagged (1 error + 3 warnings).
E1 (state race, DRIVER): queuePrompts held its pre-resolve state snapshot across
the attachment-resolution await while takeFeedback/recordLayoutWarnings mutated
state.json without any shared lock, so a poll landing mid-resolution clobbered the
write (resurrecting delivered prompts or dropping queued ones). SessionStore now
owns ONE AsyncMutex and serializes EVERY state.json read-modify-write under it
(queuePrompts, takeFeedback, recordLayoutWarnings, upsertSession, endSession,
addAgentReply). The server routes its attachment disk-lifecycle sections through
store.runExclusive so the D5 file lock and state consistency share one lock;
referencedAttachmentIds stays lock-free (called from inside runExclusive).
Covered by a regression test that fails without the unified lock.
W1 (hardcoded cap): the card's per-prompt count cap was a literal 4. createSdkJs
now threads the server's maxPerPrompt (LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT) into
the SDK, and an over-cap pick is surfaced instantly instead of silently swallowed.
W2 (error attachment lost): queuing gated only on in-flight uploads, so a text send
closed the card and discarded an errored/rejected chip with its retry/remove UI.
tryQueue now also gates on hasErrors(), keeping the card open with a notice.
W3 (card leaves viewport): the card top was clamped once with the initial height, so
attachment rows grew it past the frame and hid Queue/Cancel. The card re-clamps after
every attachment-row render, plus a max-height/scroll backstop on the chip list.
* docs: reconcile SessionStore locking invariant with the E1 mutex
The E1 change made every SessionStore mutation acquire a shared AsyncMutex, but
the 'Things to know' bullet still claimed SessionStore has 'no locking'. no-mistakes
flagged the contradiction; update the bullet to point at the store lock and the
Image attachments (E1) section that owns the invariant.
* fix(attachments): count hidden preview images and defer racy deletes (round 5)
W-A: the queued-prompt pill always sliced its thumbnails to four, but
LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT is configurable, so a prompt could
legitimately carry more and the preview silently hid the surplus - the queue
looked like it had lost accepted attachments. The overflow now collapses into a
+N badge instead of disappearing.
W-B: removing a ready chip deleted its stored file whenever no OTHER chip
already held that content-addressed id. A twin uploading identical bytes dedups
onto that very id but carries none until its upload returns, so the check missed
it and the delete pulled the file out from under the twin: its upload finalized
onto a deleted id and the send then failed as not-found. classifyAttachmentDelete
now parks an id whose fate an in-flight upload could still change and re-decides
it as uploads settle; the server's reference count and the TTL sweeper stay the
backstop, so parking can only leak a file, never break a live chip.
The card's count-cap notice shares its line with the neutral keyboard hint and
inherited its passive gray, which read as help text rather than a rejection. It
now renders in the error color and is restored once the condition clears.
* no-mistakes(review): Fix attachment reference, notice, and dedup handling
* no-mistakes(review): Preserve cap notice across transient attachment states
* no-mistakes(review): Derive attachment notices from current controller state
* no-mistakes(document): Document attachment preview invariant and fix formatting
* fix(attachments): make image files private and close store accounting gaps
Closes four findings from the review-only gate on 0c02f37:
- E4 (security): images, dims sidecars, and their dirs were created with the
process umask, landing 0644 in 0755 dirs and exposing screenshots to other
local users. Modes are now set explicitly at creation (0600/0700), and the
dir modes are re-asserted so installs that uploaded before this hardening
do not keep their existing images exposed.
- W2: a dedup upload swallowed a failed mtime refresh and still reported
success, leaving the file TTL-expired and sweepable before the prompt was
queued. It now falls back to an atomic rewrite of the identical bytes, and
propagates when that fails too, instead of reporting a false success.
- W3: an expired orphan whose delete failed was dropped from `survivors`, so
its still-present bytes vanished from disk-cap accounting and the quota
could stay exceeded while nothing was evicted. It is kept as an
unreferenced survivor.
- W5: a fractional limit such as `0.5` passed the positivity check and then
floored to 0, disabling uploads server-side while the SDK kept advertising
its own default. Both env resolvers now floor first and require >= 1.
`touchFile` is injected into writeAttachment so the W2 refresh failure is
testable without depending on a filesystem that rejects utimes. The mode and
permission-failure tests are POSIX-only; Windows has no equivalent.
* fix(attachments): close the untrusted-input, race, and DoS findings
Closes the remaining seven findings from the review-only gate on 0c02f37,
each with its own failing-then-passing test.
- E1 result-correlation: the SDK's message listener accepted any sender and
correlated upload results by chip id alone. Chip ids restart at att-1 on
every document load, so a result in flight across an iframe reload marked a
new chip ready with the previous document's image, and a same-window forged
message could hand a chip any id. Uploads now carry a per-document nonce
that results must echo, and the listener requires event.source === parent.
- E2 delete confused-deputy: the chrome honored deletes driven by the
untrusted iframe, while its reference checks could not see a ready-but-
unqueued chip in another tab, so one tab could destroy bytes another live
card still needed. The eager delete is gone; the reference-aware sweeper
owns reclamation. This removes the classifyAttachmentDelete/defer machinery
it existed to support.
- E3 lock-DoS: attachment resolution ran an uncapped sequential stat per ref
while holding the store's single global mutex, because the cap counted
RESOLVED refs and unknown ids never advance it. Raw per-prompt and
request-wide ref counts are now rejected before the resolver is called.
- E5 preview-poison: a queued prompt's attachments were dereferenced
unvalidated, and the queue persists before it renders, so attachments:[null]
threw out of render and re-threw on every reload. Refs are filtered to
well-formed {id} objects at the enqueue and restore boundaries.
- W1 malformed-drop: malformed attachment fields were silently normalized
away and the POST then succeeded, so the chrome cleared a queue whose images
were never delivered. Malformed input now fails the whole batch with 400.
- W4-a mixed-drop: a drop of images plus unsupported files accepted the images
and ignored the rest, because the error branch only ran when no image was
found. Both halves are now reported: the images attach and each unsupported
file raises a visible error chip.
- attachment-post-poll-retention: the sweeper's reference set covered only
pending prompts, which takeFeedback clears on delivery, so an attachment
could be reaped while the agent was still reading the path it had just been
handed. Delivered ids are retained for a bounded read grace.
Two existing tests asserted the old contracts that two of these findings
identify as defects (the eager delete, and "after the agent takes the feedback
the reference clears"); both are updated to the corrected behavior rather than
deleted.
* docs: reconcile the attachment invariants with the round-6 fixes
AGENTS.md documented the behaviors these findings removed - the eager
`lavish:removeAttachment` delete and its classifyAttachmentDelete parking, and
"takeFeedback clears delivered prompts, which is what makes their attachments
sweep-eligible" (the post-poll-retention defect, written down as intent).
Records what neither the code nor the tests show at a glance: why the raw ref
bound must stay ahead of the first filesystem await, why no eager delete may be
reintroduced, why upload results are nonce-scoped, and that the delivery grace
is a bounded read window rather than a second lifetime.
* docs(server): note the delete route's grace-aware guard and its unused-by-chrome status
* fix(attachments): close three defects the after-gate found in the round-6 fixes
The after-review on e841887 confirmed all eleven findings closed and then found
three defects in the fixes themselves. Adjacent bugs bred by a fix are the exact
failure this round exists to correct (E5 was born in round-5's own W-A fix), so
these are part of closing E5 and post-poll-retention, not new scope.
- The E5 sanitizer filtered entries but returned the artifact's own objects.
postMessage delivers a structured clone, which preserves BigInt values and
cycles that JSON.stringify then refuses, so the junk rode along into
sessionStorage and the POST body and made the queue unsendable - the same
wedge one step later. Each ref is now projected onto a fresh {id, name}.
- `upsertSession` rebuilds a session from an explicit field list and did not
carry `delivered_attachments`, so re-opening the artifact inside the grace
hour erased the retention and handed the next sweep a path the agent was
still reading. The field is carried, with a note that this constructor
silently drops anything it omits.
- Delivery-grace entries were appended per delivery, so one reused
content-addressed image consumed many slots and could evict distinct
attachments still inside their own grace. Retention is now keyed by id: a
re-delivery refreshes the window instead of taking another slot.
Two further after-gate findings (an object-count/sidecar disk-quota bypass, and
the SDK materializing a file buffer before checking its size) are real but
pre-existing and outside the ruled scope of the eleven; they are surfaced to
firstmate rather than fixed here.
* fix(attachments): derive the delivery retention bound from the request bound
The final after-review caught a contradiction between two constants introduced in
this same round: /prompts accepts up to MAX_REQUEST_ATTACHMENT_REFS (256) images
per batch (the E3 pre-resolver bound), but delivery retained only the newest
MAX_DELIVERED_ATTACHMENTS (200). A max-size batch therefore left 56 delivered
paths immediately sweepable while the agent was reading the very response that
handed them over - the post-poll-retention hole reopening for the largest batch
the E3 bound permits.
Retention is now derived from the request bound rather than hand-picked
separately, so whatever a single batch may queue, a single delivery can always
protect, and the two cannot drift apart again.
* fix(attachments): retain the whole delivery, not a constant's worth of it
Third defect found in this round's own retention work, and the same root cause
each time: the bound was a NUMBER when the invariant is structural.
Prompts accumulate across an unbounded number of accepted POSTs until a poll
drains them, so a single takeFeedback can legitimately deliver far more than one
request may queue. Retention sized to the per-request bound therefore sliced the
delivery itself: two legal 256-ref batches queued before one poll produced 512
delivered paths and left the first 256 immediately sweepable - while the agent
was reading exactly those paths.
`takeFeedback` now retains every id in the current delivery in full, whatever its
size, and MAX_DELIVERED_ATTACHMENTS bounds only the history of EARLIER deliveries
that rides along. The current delivery is never trimmed, so no constant has to be
correct for the invariant to hold.
* feat(attachments): round 7 - client size gate + real disk accounting
Round 7 = the two deferred items César scoped IN (a+b); the (c) cluster
(DEFERRED-3/4/5/6) stays tracked and deferred.
(a) The SDK now rejects an over-limit file in add() BEFORE createObjectURL or
arrayBuffer, so a multi-gigabyte drop is never read into a buffer and
structured-cloned into the chrome only to be rejected afterwards. The server
byte limit is threaded into the SDK via createSdkJs (mirroring the existing
maxAttachmentCount wiring); attachmentSizeError is a pure exported helper
serialized into the bundle, and the oversized file surfaces as a dismissible
error chip. The server still re-checks authoritatively.
(b) The disk cap now charges REAL allocation instead of logical image bytes:
listAttachments reports chargedBytes = the image rounded up to whole 4096-byte
blocks plus the sidecar's own block, and sweepAttachments enforces the cap on
that. This closes the measured ~683x undercount, where a flood of 12-byte
magic-prefix uploads sat far under the reported total while consuming real disk
and inodes. A derived object-count bound (disk budget / min per-object charge,
never a separate knob) backstops the inode dimension.
Each closed with its own forced RED->GREEN test. Two existing disk-cap tests that
expressed the cap in logical .bytes are rewritten in terms of chargedBytes, since
that is the accounting basis the fix intentionally changed.
* docs: record the round-7 disk-accounting and client size-gate invariants
* perf(attachments): make the disk-cap sweep O(n log n), not O(n^2) under the mutex
The round-7 gate caught a regression in round 7's own (b) code: the eviction loop
recomputed the charged-byte and object totals by filtering and reducing over ALL
survivors on every candidate, making a large over-cap sweep O(n^2) - while holding
the store's single global mutex. That is an E3-class lock hazard (the very thing
the lock-DoS fix closed), reintroduced by the disk-accounting change.
The totals are now computed once and decremented per successful removal. Behavior
is identical - a new test pins that a multi-file over-cap sweep still evicts the
exact minimum, oldest-first - so this is a complexity fix, not a behavior change.
* docs: note the sweep keeps running totals to avoid an under-mutex O(n^2)
* feat(attachments): round 8 - upload concurrency bound, temp-file reap, batched render
The tight bundle César scoped for round 8 (D8 + D6 + D7), each with its own
forced RED->GREEN test. D3/D4/D5/D9 stay tracked as architectural/ask-user.
- D8 (chrome-client.js): the confused-deputy guards bounded upload rate and
cumulative bytes but not how many ran AT ONCE, so a hostile artifact could post
~30 large bodies in one tick and hold hundreds of MiB of clones + server buffers
concurrently before the cumulative quota tripped. An in-flight ceiling
(UPLOAD_MAX_IN_FLIGHT) now refuses over-cap uploads with a retry hint, and a
settled upload frees a slot. RED: eight held-open uploads all hit the network at
once (got 8).
- D6 (attachment-store.js): writeFileAtomically writes `<name>.<pid>.<n>.tmp` then
renames; a crash in between left temp files that ID_RE excludes, so no TTL, disk,
or object cap ever counted or removed them - a permanent leak. sweepAttachments
now reaps temp files matching the store's own pattern older than a 5-minute grace
(a live write renames in ms). RED: a stale orphan temp survived the sweep.
- D7 (artifact-sdk.js): add()/rejectUnsupported each rebuilt the whole chip DOM, so
a multi-file drop was O(N^2). A new pure classifyAttachmentBatch decides the whole
batch in one pass; addFiles and a batched rejectUnsupportedBatch now render once.
RED: seam-first (the classifier did not exist). The round-7 (a) size gate now
lives in the classifier and still precedes the single createObjectURL, so an
oversized file is decided "error" before any read - re-pinned accordingly.
Two existing tests updated for the new structure: the confused-deputy rate-cap test
now settles each upload so the new in-flight bound does not mask the rate cap, and
the round-7 (a) bundle pin follows the size gate into the classifier. Neither is
weakened - the invariants they assert are unchanged.
* docs: record round-8 upload-concurrency, temp-reap, and batched-render invariants
* fix(attachments): reap orphan .meta sidecars and delete them first (ATTACH-002)
The round-8 gate found a leak in the same orphan-file class D6 addressed:
removeAttachment/removeFile deleted the image first, then the sidecar with a
suppressed failure, so a crash between the two left an orphan `.meta` that ID_RE
hides from every cap - a permanent leak. Completes D6's orphan-file hardening.
- The sweep's orphan reap now also removes `<id>.meta` sidecars whose image is
gone. No grace: a live upload writes the image before its sidecar, so a
sidecar-without-image is unambiguous debris (unlike a .tmp, which may be a live
write). RED: an orphan sidecar survived the sweep.
- removeAttachment/removeFile now delete the sidecar FIRST, so a crash after the
first removal leaves only the counted image (TTL/disk/object reclaimable), never
the uncounted sidecar.
The other round-8 gate finding (ATTACH-001: the fixed 256 request-wide ref bound
vs a configurable per-prompt count) is a config-coherence ask-user decision, left
tracked as DEFERRED-10 for César alongside D3/D4/D5/D9 - not patched.
* docs: record the orphan-sidecar reap and sidecar-first delete invariant (ATTACH-002)
* fix(attachments): enforce the disk cap at the upload admission chokepoint and shield ready cards from eviction
Item 1 (root cause B): uploads never checked current usage, so several chrome
pages could each write past `maxDiskBytes` before the next periodic sweep,
defeating the documented disk-cap backstop. Route every byte-adding write path
through one admission chokepoint (`admitAttachmentCharge`) inside `writeAttachment`:
before a NEW image+sidecar (or a dedup sidecar repair) touches disk, reclaim
unreferenced/expired bytes toward (cap - newCharge), then measure the true
committed allocation on disk and refuse with 507 when it plus the new charge still
exceeds the cap. The whole reference-snapshot + reclaim + write runs under the
server's lifecycle lock. Committed bytes are measured by an independent tree walk
so `sweepAttachments`' public return shape is unchanged.
Item 2 (attachment-store.js eviction filter): cap eviction removed the oldest
UNREFERENCED file with no minimum-age floor, so it could delete a freshly uploaded
"ready card" (an image dropped into the composer but not yet queued on a prompt,
hence unreferenced) out from under the imminent Send -> unrecoverable not-found,
user-visible data loss. Add a bounded upload grace (`ATTACHMENT_EVICTION_GRACE_MS`,
1h, symmetric with the delivery grace): files younger than the grace are never
cap-evicted. The grace is opt-in via a `sweepAttachments` option (default 0 = off)
so the pure mechanism and its existing tests are unchanged; the server passes it on
both the periodic sweep and the upload admission. The grace never lets the total
exceed the cap (admission still 507s when only referenced/fresh bytes remain); it
only makes the cap prefer refusing a new write over destroying a ready card.
Tests: test/attachment-disk-admission.test.js reproduces both vectors (RED on the
base tip, GREEN here) - a new over-cap upload is refused, and a fresh ready card
survives disk pressure so Send still resolves it.
* test(chrome-client): stop reserving 300 MiB in the quota test (item 3)
The "oversized upload is refused" case allocated a real
`new ArrayBuffer(300 * 1024 * 1024)` (300 MiB) purely so the upload size check
would read an over-quota `byteLength` - 300 MiB of real memory per run and a
needless OOM risk in CI. The size check only reads `byteLength`, so spoof a real
view (`ArrayBuffer.isView` still true) whose `byteLength` own property REPORTS the
over-quota length without reserving the bytes. The test still exercises the exact
same session-quota refusal path.
* test(attachments): cover attachment routes under the Host-allowlist (DNS-rebinding) guard
The attachment upload/fetch/delete routes must sit behind Kun's Host-header
allowlist, not only the same-origin guard. A DNS-rebound page carries its hostile
domain in both Origin and Host, so isSameOriginRequest still matches; only the
Host allowlist rejects it. Assert a rebound request (Origin == forged Host) is a
clean 403 forbidden host on all three attachment routes while a legitimate
loopback same-origin request still succeeds.
* no-mistakes(document): document attachment disk-cap admission and eviction grace
* style(server): format rebased SDK response
* fix(attachments): route SDK uploads through postArtifactMessage so the chrome mediates them
The annotation card sent lavish:uploadAttachment via a raw parent.postMessage
with no artifact_load_token, and the chrome drops every artifact message whose
token is not the current load's before dispatch - so real uploads were silently
discarded while every mocked harness stayed green (the chrome harness patched
the missing token into test messages). Send the upload through
postArtifactMessage like every other SDK message, make the chrome harness send
messages verbatim, pin the token gate for uploads, and add a real-browser e2e
that round-trips an actual image through the gate into the attachment store
and back to the agent poll.
---------
Co-authored-by: “420tombombadil” <“dijongui@gmail.com”>
Co-authored-by: kunchenguid <kun@kunchenguid.com>
* chore(main): release lavish-axi 0.1.51 (#244)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(whiteboard): preserve Mermaid node label line breaks in Excalidraw (#246)
* fix(whiteboard): preserve mermaid label line breaks in Excalidraw
The converter left <br> and \n as literal characters in skeleton labels, so adjacent words fused and the box sized as one line. Convert those markers to real newlines and size the bound text from the resulting line set.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(whiteboard): recenter bound text when linebreak pass grows the box
Growing the container from its center left the independently stored bound-text x/y at the old coords, so a multi-line label sat off-center on first paint. Reposition the bound text into the resized box.
Co-authored-by: Cursor <cursoragent@cursor.com>
* no-mistakes(review): Skip labelled-arrow resize in linebreak restore
* no-mistakes(document): Document labelled-arrow skip for linebreak restore
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(main): release lavish-axi 0.1.52 (#247)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(agents): use @AGENTS.md import instead of CLAUDE.md symlink (#249)
* chore(agents): use @AGENTS.md import instead of CLAUDE.md symlink
* chore(agents): exempt the CLAUDE.md pointer from prettier
* test(agents): assert CLAUDE.md is a real @AGENTS.md pointer, not a symlink
---------
Co-authored-by: Kun Chen <kun-1@kunchenguid.com>
* fix: avoid phantom whiteboard conflicts (#252)
* fix(whiteboard): prompt on source change only for real scene edits
Conversion autosaves on view, so a live Mermaid rewrite was treated as
stale user edits whenever a sidecar existed. Compare against the
conversion baseline and silently re-convert unmodified scenes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* no-mistakes(review): Preserve empty edited scenes without conversion baselines
* no-mistakes(review): Detect all meaningful whiteboard edits before reconversion
* no-mistakes(review): Compare geometry jitter using symmetric raw deltas
* no-mistakes(review): Correct whiteboard preservable-edit invariant
* no-mistakes(review): Normalize whiteboard baselines through Excalidraw restore
* no-mistakes(review): Cover mounted autosave conflicts with real Excalidraw regression
* no-mistakes(document): Format whiteboard conflict regression files
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(main): release lavish-axi 0.1.53 (#253)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* docs: establish author-approved project vision (#254)
* docs: add author-approved VISION.md
Adds the project's first VISION.md at the repo root, co-authored with the
repository owner over six rounds on a Lavish review board.
Every principle traces to real merged history (80 merged PRs mined, 15 bodies
read) or to a recorded author verdict. Twenty-four hypotheticals were
stress-tested; three reversed positions the initial evidence-only draft had
asserted, and four principles came from the author rather than the history.
Also registers VISION.md in the AGENTS.md documentation-ownership list as the
owner of the acceptance policy.
* no-mistakes: apply CI fixes
* fix: preserve Mermaid label line breaks (#237)
* fix(whiteboard): preserve Mermaid label line breaks
* fix(design): retain Mermaid label markup
* no-mistakes(document): Verify Mermaid label preservation documentation and lint
* fix: add semantic names to table annotations (#256)
* fix(sdk): give table-cell annotations semantic row and column names
Port the accepted work from #242 onto current main so filtered or sorted
tables keep semantic row and column names while the clicked element's own
selector, tag, and text stay the locator.
Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
* no-mistakes(review): Remove source-token SDK serialization test
* no-mistakes(document): Clarify semantic table target documentation
* no-mistakes: apply CI fixes
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
* fix(server): reject foreign origins on mutating routes (#257)
* fix(server): reject cross-origin mutating requests on the loopback server
Add a global Origin/Referer CSRF guard after the Host allowlist so a
foreign page that can reach 127.0.0.1 cannot CSRF previously unguarded
mutating routes. Header-less CLI requests still pass; per-route
isSameOriginRequest checks stay in place.
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
* no-mistakes(document): Clarify CSRF guard documentation
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
* feat(attachments): support image paste, drop, and picker uploads in the conversation composer (#248)
* feat(attachments): support images in every composer
* no-mistakes(review): fix stale composer upload notice and replace source-regex tests
* no-mistakes(review): fix composer clipboard items paste and dragleave flicker
* no-mistakes(review): harden composer attachment drag, retry, and MIME source
* no-mistakes(review): untrack .omc state, fix notice order and MIME source
* no-mistakes(document): fix stale attachment docs for composer uploads
* fix(chrome): harden composer attachment gating, cap counting, and drop guards
A deep review pass over the composer attachment feature surfaced a set of
confirmed defects; this commit fixes the correctness tier:
- The composer chip gate no longer blocks the entire send pipeline. A pending
or failed chip now holds back only the composer's own message and an explicit
end, while queued annotation prompts still deliver - previously Send and
Send & End silently delivered nothing with the only signal in an 11px notice.
- The per-batch cap count no longer charges size-refused chips (which retain no
file and cannot upload), so an oversized companion in a mixed paste can no
longer silently swallow a valid image while syncNotice clears the cap notice
on the same render tick.
- Paste no longer raises unsupported-type chips: Office and macOS pastes expose
stray non-image file flavors beside their text, and a permanent red chip for
a perceived text paste blocked sending until manually removed. The picker and
drop still refuse visibly, and a Files drag with nothing enumerable now gets
an explicit refused chip instead of being swallowed after preventDefault.
- A document-level Files-drag guard stops a drop that misses the composer from
navigating the chrome away (losing chips, uploads, and the SSE connection).
Text drags are left to the browser so dropping text into the textarea works.
- planClipboardPaste (and its chrome mirror keepsClipboardText) now treats a
pasted file's own name or path in text/plain as placeholder rather than a
caption, restoring the pre-branch behavior for Finder/Explorer file copies.
- The chip list gets the card's W3 max-height/scroll backstop so unbounded
chips cannot push Send or their own Remove buttons off-frame.
- CHAT_ATTACHMENT_MIME falls back to PNG/JPEG/WebP when the session JSON list
is unwired, matching acceptedImageTypes instead of refusing every image.
AGENTS.md records the new easy-to-reintroduce composer rules; README notes the
filename-placeholder paste refinement.
Claude-Session: https://claude.ai/code/session_01CTomK8PgtEyDEpSbg6CmvW
* no-mistakes(document): docs verified current; lint, format, typecheck clean
* fix: let artifact popups escape the iframe sandbox (#258)
* fix: allow artifact popups to escape sandbox
Adds allow-popups-to-escape-sandbox to the artifact iframe so
author-intended external links and popups open as normal top-level
tabs. The iframe itself stays sandboxed; allow-same-origin is not
granted.
Last-resort port of kunchenguid/lavish-axi#199 onto current main.
Original work by Srikanth Vuppuluri (@vuppuluri-srikanth).
Co-authored-by: Srikanth Vuppuluri <svuppuluri@salesforce.com>
* no-mistakes(document): Document artifact popup sandbox behavior
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Srikanth Vuppuluri <svuppuluri@salesforce.com>
* docs: document the self-hosted share backend contract (#151)
* docs: document the self-hosted share backend contract
The README says to set LAVISH_AXI_HTML_APP_API_URL when "overriding the
ht-ml.app API base" but does not say what that base has to implement, so you
cannot self-host the share backend without reading the client source.
This documents the contract share expects: POST /v1/sites with
{ html_content, password? }, a JSON response with url + update_key, and
optional bearer auth. Adds a small Cloudflare Worker reference and the security
notes (an open publish endpoint is an open HTML host on your domain; isolate
the viewer origin). Came out of self-hosting the share backend for an internal
project.
Added as docs/self-hosting-share.md since there is no docs/ dir yet. Happy to
fold it into the README instead.
* no-mistakes(document): link README to self-hosting share doc, fix its formatting
* fix(cli): put poll feedback ahead of the DOM snapshot in output (#266)
* fix(poll): prioritize feedback in delivery output
Fixes #228; deliberately does not touch #168 ack-before-consume.
* no-mistakes(document): document poll output ordering invariant
* chore: ignore agent runtime state
* no-mistakes: apply CI fixes
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* fix(server): keep sends available while an agent works and stop losing feedback on closed polls (#265)
* fix: preserve sends across poll presence transitions
Keep feedback actions durable while the agent is working, close poll cleanup races, and clear delivered state for final feedback on ended sessions.
Fixes #229
* fix(server): restore feedback from closed immediate polls
* no-mistakes(document): document poll feedback restore and share contract owner
* no-mistakes(review): untrack .planning agent runtime state
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* fix(chrome): recover a review that never finishes loading (#268)
* fix(chrome): recover a review that never finishes loading
A review page could come up with a spinner that never resolved, or with the
chrome shell around a permanently empty artifact frame. There are two triggers.
The first is the shared background server being replaced while a review page is
opening, which any `lavish-axi <html-file>` invocation can do after an upgrade:
the server broadcasts `chrome-reload` to every connected chrome and steps down,
so an unrelated session's page reloads on its own and then races the
replacement. The second is a second browser tab claiming the same review, which
supersedes the reviewer handoff the first tab is holding.
Four failures on those paths left no way back:
- The chrome page ships with the layout-gate overlay already covering the
artifact, and only `chrome-client.js` ever takes it down - including wiring
the "Show anyway" button. When that script never runs, the page sits on the
spinner with a button that does nothing. An inline failsafe now arms before
the script tag, fires on its `onerror` and on a timeout, and replaces the
spinner with a named failure and a working Reload button. The client cancels
it once it has run to completion.
- `replaceArtifactFrame` gave up permanently after two retries spanning 400ms,
which does not cover the multi-second window where the server is being
replaced. On a first load there is no previous frame to fall back to, so the
iframe was never navigated and `artifact_revision` never advanced. Recoverable
outcomes now retry on a backoff, and only surface a failure once that is
exhausted with nothing on screen. `superseded` and `out-of-order` still do not
retry: another reviewer, or a newer request in this same chrome, owns the
artifact.
- After `chrome-reload`, the chrome reloaded on a fixed 5s deadline whether or
not anything was listening, replacing a recoverable page with the browser's
connection-error page. It now waits for `/health` to answer, and says the
server is not running instead of reloading into a dead port.
- A `superseded` begin-load told the user through the takeover banner in the
conversation panel, which the gate overlay covers. A tab that had never loaded
the artifact therefore held the "Checking layout" spinner for the full gate
hold and then revealed an empty frame, with its only recovery control hidden
the whole time. That case now names itself on the overlay with a "Take over
here" button. A tab that already shows an artifact is unchanged: it keeps the
artifact and uses the banner alone. Neither case retries in the background.
Adds regression tests for each, plus one pinning the no-retry rule for a
superseded reviewer.
* no-mistakes(review): reset artifact load retry budget per attempt; execute boot failsafe in tests
* no-mistakes(review): confirm server health before and after not-running card
* no-mistakes(review): scope upgrade reloads and persist unsent annotation drafts
* no-mistakes(review): name shutdown reason, gate banner reload, retire dead drafts
* no-mistakes(review): keep drafts through transient anchor misses; probe before every reload
* no-mistakes(review): hand back retired drafts; never restore over an open card
* no-mistakes(review): never drop a retired draft; correct unreachable-banner docs
* no-mistakes(review): bound health probes; always hand recovery controls back
* no-mistakes(review): name local-build restarts; guard stale probe copy
* no-mistakes(review): carry shutdown reason to the reloaded page
* no-mistakes(review): agree card title with probe result; keep handbacks last
* no-mistakes(document): move unsent-annotation handback docs under live-reload owner
* no-mistakes: apply CI fixes
* chore(main): release lavish-axi 0.1.54 (#255)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci: require no-mistakes pipeline attestation in the gate (#271)
* ci: require no-mistakes pipeline attestation in the gate
* ci: bind the no-mistakes attestation to the PR head
Fail the gate unless the attestation's head_sha equals the PR's current head:
a commit pushed after the pipeline run leaves an attestation that describes
different code. A synchronize whose body was not rewritten by no-mistakes
going red is the intended contract.
Also skip the bash+jq gate test on Windows, where the gate workflow never
runs and Windows-native jq's CRLF output made every parsed verdict a false
failure.
* fix(server): restore taken feedback when a long-poll client disconnects (#270)
* fix(server): restore feedback after long-poll disconnect
A long-poll response could destructively take feedback and then lose it when the client disconnected before the response was written. Restore the exact batch before marking delivery in that race, and cover the event-driven path with a deterministic regression test.
* no-mistakes(review): wake and log failures on closed-poll feedback restore
* fix(server): preserve concurrent feedback during restore
Prepend a restored batch ahead of prompts queued after the destructive take, preserve newer DOM snapshots and artifact failures, and wake a poll that attached during the restore. Add deterministic coverage for the concurrent state merge and the poll-B wake race.\n\nThe restore still cannot recover a disconnect after response bytes begin or a process loss between the destructive take and the restore; closing those windows requires an application-level acknowledgement or a non-destructive take protocol.
* no-mistakes(review): exempt restore from request ref cap, dedupe artifact failures
* no-mistakes(document): note restore exemption in attachment ref bound docs
* no-mistakes(lint): format pre tag close in layout audit fixture
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* test(browser): decode structured eval results
Parse the automation CLI's quoted result scalar instead of extracting a
JSON-looking substring that still contains escape characters.
* no-mistakes(document): align docs with upstream sync, scope README CSP claim
* chore: preserve fork release metadata
Keep the sync on this fork's Release Please line and document the
release-owned files that future upstream merges must restore.
---------
Co-authored-by: tibernero <76794638+tibernero@users.noreply.github.com>
Co-authored-by: Nic Nogueira <tibernero@proton.me>
Co-authored-by: kunchenguid <kun@kunchenguid.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Rodrigo Díaz Jonguitud <dijongui@gmail.com>
Co-authored-by: “420tombombadil” <“dijongui@gmail.com”>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Kun Chen <kun-1@kunchenguid.com>
Co-authored-by: darcy <113143816+goblinmode2700@users.noreply.github.com>
Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
Co-authored-by: Adelin Berard <adelinb.pro@gmail.com>
Co-authored-by: Srikanth Vuppuluri <svuppuluri@salesforce.com>
Co-authored-by: Eric <1057844+Ectsang@users.noreply.github.com>
Co-authored-by: Julian-Dasilva <julianalecssandredasilva@gmail.com>
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
Co-authored-by: CodeSmile <42713966+CodeSmile-0000011110110111@users.noreply.github.com>
Julian-Dasilva
added a commit
to Julian-Dasilva/lavish-axi
that referenced
this pull request
Aug 31, 2026
* feat(cli): guard against invisible unpainted artifacts (#230)
* test: tolerate coalesced heartbeat chunks in the long-poll heartbeat test
Under full-suite load two 10ms heartbeat writes can arrive in one TCP chunk,
failing the one-byte-per-read assertion. Collect bytes until two heartbeats
have streamed and assert they are all whitespace, which is the actual contract.
* feat: steer agents away from unpainted, invisible artifacts
An agent-built artifact that styles light text while never painting its own
page background renders invisible over Lavish's light surface, because Lavish
deliberately injects no design system. Make the authoring guidance and the CLI
itself steer agents away from that failure:
- SELF_PAINT_RULE (single-sourced in src/design-reference.js): every artifact
must set an explicit page background plus matching high-contrast text color,
theme-aware via prefers-color-scheme. Leads home visual_guidance, so it
reaches the no-args home, top-level --help, the SessionStart hook context,
and the generated skill; also surfaced in `lavish-axi design` output and help.
- RENDER_VERIFY_RULE (src/cli.js): render-verify the composed page by loading
the saved HTML file itself in a browser before presenting it - verifying the
inputs is not verifying the page, and the served session URL must never be
the verification target because a chrome load supersedes the user's reviewer
handoff. In home help and as an explicit skill workflow step.
- src/self-paint.js: render-free, fail-open check wired into open/export/share
that returns a one-line self_paint_warning plus fix-first next_step guidance
when an artifact has no background signal on html/body/:root and no
stylesheet that could provide one. Any stylesheet link, @import, Tailwind
runtime script, or color-scheme suppresses it, so false positives stay near
zero; it never blocks the open.
* no-mistakes(review): Correct unsupported share warning guidance
* no-mistakes(document): Verify self-paint documentation and lint
* fix: streamline invisible artifact guidance (#232)
* fix: slim invisible artifact guidance
* no-mistakes(review): Fix guidance coverage and remove dead references
* no-mistakes(document): Refresh invisible-artifact guidance and pass lint
* chore(main): release lavish-axi 0.1.48 (#231)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(server): confine artifact asset route by realpath (symlink-escape hardening) (#194)
* fix(server): confine the artifact asset route by realpath to block symlink-escape (Phase-B sweep)
resolveArtifactAsset() confined asset paths lexically only (path.resolve/relative — a
string check that never touches disk), so a symlink whose NAME sits inside the artifact
dir but whose TARGET points outside was served by res.sendFile, which follows it. The
export path (guardedRead) already defends this exact threat with realpath resolution.
Live-verified: GET /artifact/<key>/<symlink> returned 200 + leaked an outside file
pre-fix, 403 post-fix. The route is reachable by script inside the artifact (its HTML
embeds the session key). Fix: make resolveArtifactAsset async, realpath the resolved
path + root, reject anything escaping (mirrors guardedRead); both call sites await it.
node --test 592 pass / 0 fail (+2 new symlink-escape tests), eslint + tsc clean.
* no-mistakes(review): fail closed on non-ENOENT realpath errors in resolveArtifactAsset
* no-mistakes(document): document realpath symlink confinement on artifact asset routes
* fix(server): return the resolved asset path and cover both routes with regression tests
Harden the realpath confinement added in this branch and prove it:
- resolveArtifactAsset now hands back the symlink-resolved path instead of the
requested one. A real path contains no symlinks, so sendFile re-opening it
cannot be redirected by a link swapped in between the check and the read.
- Regression tests for both routes it guards (/artifact and /whiteboard-assets):
a leaf symlink escape, an escape through an intermediate directory symlink,
and an in-directory symlink that must still resolve (no over-blocking).
- Guard the pre-existing lexical ".." rejection at the HTTP level via raw
requests, since fetch collapses ".." in a URL before it reaches the server.
All seven escape tests fail against the pre-fix resolver (the /artifact case
serves the outside file with 200); the two lexical-traversal guards pass in
both states, which is what makes them preservation checks.
---------
Co-authored-by: Nic Nogueira <tibernero@proton.me>
Co-authored-by: kunchenguid <kun@kunchenguid.com>
* chore(main): release lavish-axi 0.1.49 (#234)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(server): harden feedback submission boundaries (#235)
* fix(server,chrome): bind whiteboard channels to their session and guard prompt submission
The session key is derived from the artifact path, not a secret, and the
whiteboard frame page is framable by any origin, so neither key nor channel
token possession can stand in for authorization:
- Whiteboard channel tokens are now signed over the session key, making a
token a capability for exactly one session; /whiteboard-frame requires
?key= and both call sites pass their own.
- The chrome accepts inline whiteboard messages only from windows that
descend from the current artifact frame, mirroring the guard the ordinary
artifact-annotation handler already applies.
- POST /api/:key/prompts is same-origin guarded like /share and the
whiteboard write routes.
- The session chrome page answers X-Frame-Options: DENY and
frame-ancestors 'none'. Scoped to that route: /artifact/* is framed by the
chrome, and /whiteboard-frame is framed by the artifact document, whose
sandbox gives it an opaque origin no frame-ancestors expression can name.
Addresses GHSA-w887-pf37-frrv.
* no-mistakes(review): Preserve secure proxied same-origin feedback
* style: apply prettier to the proxied same-origin fix
* no-mistakes(review): Harden host authority validation against origin bypasses
* no-mistakes(review): Reject authorities without valid canonical origins
* no-mistakes(review): Preserve wildcard proxy routing with strict authority validation
* no-mistakes(document): Document proxy headers and fix lint formatting
* chore(main): release lavish-axi 0.1.50 (#236)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore: remove committed no-mistakes evidence (now on orphan branch) (#239)
* chore: gitignore no-mistakes evidence dir (contributor safety) (#240)
* feat(attachments): attach reference images to annotations and deliver them to the agent (#188)
* feat(attachments): content-addressed image storage in the state dir
Add src/attachment-store.js: magic-byte image validation (PNG/JPEG/WebP),
content-hash ids, atomic dedup writes under <state-dir>/attachments/<key>/,
and a resolveAttachment trust boundary that re-derives every field (absolute
path, mime, bytes, dimensions) from disk rather than from the caller. Limits
are LAVISH_AXI_* env-configurable.
* feat(attachments): upload, fetch, and remove endpoints
Add POST/GET/DELETE /api/:key/attachments routes. Upload takes raw image
bytes via express.raw (per-image byte cap -> 413) and is same-origin guarded
like the whiteboard writes; fetch serves the stored bytes with the resolved
mime; delete removes the file idempotently. Non-images 415, unknown sessions
404.
* feat(attachments): resolve prompt attachments at the trust boundary
Prompts carry only a client id + display name; queuePrompts re-resolves each
id against the on-disk store, injecting the authoritative path/mime/bytes/dims
and enforcing per-prompt count and total-byte caps. Unknown ids are dropped and
a missing resolver drops attachments rather than trusting client metadata, so a
crafted /prompts POST cannot aim an attachment at an arbitrary file.
* feat(attachments): reference-aware TTL sweeper and disk-cap backstop
Add listAttachments + sweepAttachments to the store and referencedAttachmentIds
to SessionStore. A file is reaped only when past its TTL AND not referenced by a
pending prompt (so send-and-end batches survive); the optional disk cap then
evicts oldest UNREFERENCED files, never referenced ones. The server sweeps at
startup and hourly, skipping entirely when neither TTL nor disk cap is set.
* feat(attachments): capture images in the annotation card (SDK)
The annotation card gains paste, drag-drop, and an Attach-image picker for
PNG/JPEG/WebP. Captured files render as chips with a thumbnail and upload/
ready/error state; the SDK reads bytes and hands them to the chrome (which owns
the server round trip), applies upload results, and supports remove/retry. On
queue it rides the ready attachments' server ids along with the prompt.
* feat(attachments): chrome upload orchestration and pill thumbnails
The chrome performs the same-origin attachment upload/delete on behalf of the
sandboxed iframe and reports the server-vetted id back to the card; queued-prompt
pills render image thumbnails from the attachment endpoint and label image-only
annotations.
* feat(attachments): poll delivery guidance, docs, and #123 non-regression
Poll next_step tells the agent when prompts carry image attachments and to open
their local paths. Document the feature and its LAVISH_AXI_* limits in README
(user-facing contract) and the storage/trust/sweeper invariants in AGENTS.md.
Add a non-regression test that export coexists with the raw-upload route and
never leaks attachment ids/paths/SDK into an exported bundle.
* fix(attachments): deliver a clean 413 on over-cap upload instead of hanging
VERIFIED: over-cap uploads DID get an HTTP 413 (curl/undici recovered it), but
express.raw aborts on Content-Length WITHOUT draining the body, so a browser
mid-upload is reset and the chip hangs on "uploading" with no recovery.
Fix both paths: the route now reads the stream via readAttachmentUploadBody,
which buffers up to the cap but always drains to end-of-body before sending a
clean 413 the browser reliably receives. Belt-and-suspenders, the chrome pre-
checks byte length against attachmentMaxBytes (surfaced in the session JSON) and
fails the chip locally before uploading. Both reach the error+retry state; the
errored chip never blocks queueing.
* fix(attachments): intercept non-image drops and show a visible remove X
Drops over the annotation card now preventDefault unconditionally, so a dropped
PDF can't navigate the frame; non-images surface a dismissible UNSUPPORTED_TYPE
error chip (no thumbnail, no retry). The chip's remove control is a larger,
higher-contrast, titled X. Trim the card helper text.
* polish(attachments): use Lavish's native close icon for the chip remove control
Match the app's own close buttons (.pill-close / .share-close): same path,
viewBox, 1.6 stroke weight, and round caps, in a restrained circular button
(subtle white, hover fill) instead of the heavier foreign-looking glyph. Remove
function, title, and aria-label are unchanged.
* fix(attachments): render the chip remove-X (padding override) + clean inline glyph
Root cause of the empty/squished remove-X: the generic '.lavish-annotation-card
button' rule (padding:8px 10px, specificity 0,1,1) outweighs '.lavish-attachment-
remove' (padding:0, specificity 0,1,0), so with box-sizing:border-box the 22px
button's content box collapsed to ~2x6px and squished the glyph into a speck. Add
padding:0!important (the sibling .lavish-attach button already uses !important for
this same conflict) so the icon gets its full box. Also inline a clean, self-
contained X path (no sprite/symbol/use/mask reference). Verified end-to-end by
driving the real annotation card in a sandboxed iframe (real drop -> real chip):
the X now paints crisp and centered. Remove function, title, aria-label unchanged.
* harden(attachments): seal v2.5 concurrency, lifecycle, and security gaps
Round-3 conformance to the sealed design v2.5 for the #103 attachment
feature (no-mistakes flagged Risk HIGH). Four groups:
Group 1 — lifecycle & concurrency
- D5: one shared AsyncMutex serializes upload-finalize, /prompts
resolve+persist, delete, and the reference-aware sweep, closing the
window where a reference is acquired between the sweeper's snapshot and
its delete (src/async-mutex.js).
- refcount: DELETE is reference-counted under the lock — a content-
addressed file still referenced by a queued prompt survives a chip
removal (status "referenced") instead of breaking the queued thumbnail.
- B3: dedup re-upload refreshes the file mtime so a re-referenced aged
file isn't reaped by the next sweep.
Group 2 — batch & queue integrity
- C4: attachment resolution is all-or-nothing — an unknown id or a
count/byte-cap breach rejects the whole batch (400, persist nothing)
with { rejected, caps }; the chrome keeps its queue and surfaces the
reason instead of silently dropping images.
- R2.4: the SDK card gates queuing on hasPending() so an in-flight upload
can't be dropped by collectReady/closeCard, and only deletes a removed
chip's file when no sibling shares its deduped id.
Group 3 — security
- The chrome mediates the same-origin confused-deputy: rate limit + per-
session cumulative-byte quota before any upload hits the loopback
server, plus a bounded default disk quota (512 MiB, off/0 disables).
Group 4 — perf
- D6: persist vetted dims in an <id>.meta sidecar at upload;
resolveAttachment reads it instead of re-parsing the image, and the
thumbnail GET serves via a lightweight stat (statAttachmentForServe),
removing the ~2x full-image read per render.
* no-mistakes(review): Harden attachment payload validation and JPEG TEM parsing
* no-mistakes(document): Document attachment guards and fix lint
* fix(attachments): close state race and harden the annotation card (round 4)
Round-4 conformance fixes for the #103 image-attachment feature, on top of
c2db2df. Addresses the four defects no-mistakes flagged (1 error + 3 warnings).
E1 (state race, DRIVER): queuePrompts held its pre-resolve state snapshot across
the attachment-resolution await while takeFeedback/recordLayoutWarnings mutated
state.json without any shared lock, so a poll landing mid-resolution clobbered the
write (resurrecting delivered prompts or dropping queued ones). SessionStore now
owns ONE AsyncMutex and serializes EVERY state.json read-modify-write under it
(queuePrompts, takeFeedback, recordLayoutWarnings, upsertSession, endSession,
addAgentReply). The server routes its attachment disk-lifecycle sections through
store.runExclusive so the D5 file lock and state consistency share one lock;
referencedAttachmentIds stays lock-free (called from inside runExclusive).
Covered by a regression test that fails without the unified lock.
W1 (hardcoded cap): the card's per-prompt count cap was a literal 4. createSdkJs
now threads the server's maxPerPrompt (LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT) into
the SDK, and an over-cap pick is surfaced instantly instead of silently swallowed.
W2 (error attachment lost): queuing gated only on in-flight uploads, so a text send
closed the card and discarded an errored/rejected chip with its retry/remove UI.
tryQueue now also gates on hasErrors(), keeping the card open with a notice.
W3 (card leaves viewport): the card top was clamped once with the initial height, so
attachment rows grew it past the frame and hid Queue/Cancel. The card re-clamps after
every attachment-row render, plus a max-height/scroll backstop on the chip list.
* docs: reconcile SessionStore locking invariant with the E1 mutex
The E1 change made every SessionStore mutation acquire a shared AsyncMutex, but
the 'Things to know' bullet still claimed SessionStore has 'no locking'. no-mistakes
flagged the contradiction; update the bullet to point at the store lock and the
Image attachments (E1) section that owns the invariant.
* fix(attachments): count hidden preview images and defer racy deletes (round 5)
W-A: the queued-prompt pill always sliced its thumbnails to four, but
LAVISH_AXI_MAX_ATTACHMENTS_PER_PROMPT is configurable, so a prompt could
legitimately carry more and the preview silently hid the surplus - the queue
looked like it had lost accepted attachments. The overflow now collapses into a
+N badge instead of disappearing.
W-B: removing a ready chip deleted its stored file whenever no OTHER chip
already held that content-addressed id. A twin uploading identical bytes dedups
onto that very id but carries none until its upload returns, so the check missed
it and the delete pulled the file out from under the twin: its upload finalized
onto a deleted id and the send then failed as not-found. classifyAttachmentDelete
now parks an id whose fate an in-flight upload could still change and re-decides
it as uploads settle; the server's reference count and the TTL sweeper stay the
backstop, so parking can only leak a file, never break a live chip.
The card's count-cap notice shares its line with the neutral keyboard hint and
inherited its passive gray, which read as help text rather than a rejection. It
now renders in the error color and is restored once the condition clears.
* no-mistakes(review): Fix attachment reference, notice, and dedup handling
* no-mistakes(review): Preserve cap notice across transient attachment states
* no-mistakes(review): Derive attachment notices from current controller state
* no-mistakes(document): Document attachment preview invariant and fix formatting
* fix(attachments): make image files private and close store accounting gaps
Closes four findings from the review-only gate on 0c02f37:
- E4 (security): images, dims sidecars, and their dirs were created with the
process umask, landing 0644 in 0755 dirs and exposing screenshots to other
local users. Modes are now set explicitly at creation (0600/0700), and the
dir modes are re-asserted so installs that uploaded before this hardening
do not keep their existing images exposed.
- W2: a dedup upload swallowed a failed mtime refresh and still reported
success, leaving the file TTL-expired and sweepable before the prompt was
queued. It now falls back to an atomic rewrite of the identical bytes, and
propagates when that fails too, instead of reporting a false success.
- W3: an expired orphan whose delete failed was dropped from `survivors`, so
its still-present bytes vanished from disk-cap accounting and the quota
could stay exceeded while nothing was evicted. It is kept as an
unreferenced survivor.
- W5: a fractional limit such as `0.5` passed the positivity check and then
floored to 0, disabling uploads server-side while the SDK kept advertising
its own default. Both env resolvers now floor first and require >= 1.
`touchFile` is injected into writeAttachment so the W2 refresh failure is
testable without depending on a filesystem that rejects utimes. The mode and
permission-failure tests are POSIX-only; Windows has no equivalent.
* fix(attachments): close the untrusted-input, race, and DoS findings
Closes the remaining seven findings from the review-only gate on 0c02f37,
each with its own failing-then-passing test.
- E1 result-correlation: the SDK's message listener accepted any sender and
correlated upload results by chip id alone. Chip ids restart at att-1 on
every document load, so a result in flight across an iframe reload marked a
new chip ready with the previous document's image, and a same-window forged
message could hand a chip any id. Uploads now carry a per-document nonce
that results must echo, and the listener requires event.source === parent.
- E2 delete confused-deputy: the chrome honored deletes driven by the
untrusted iframe, while its reference checks could not see a ready-but-
unqueued chip in another tab, so one tab could destroy bytes another live
card still needed. The eager delete is gone; the reference-aware sweeper
owns reclamation. This removes the classifyAttachmentDelete/defer machinery
it existed to support.
- E3 lock-DoS: attachment resolution ran an uncapped sequential stat per ref
while holding the store's single global mutex, because the cap counted
RESOLVED refs and unknown ids never advance it. Raw per-prompt and
request-wide ref counts are now rejected before the resolver is called.
- E5 preview-poison: a queued prompt's attachments were dereferenced
unvalidated, and the queue persists before it renders, so attachments:[null]
threw out of render and re-threw on every reload. Refs are filtered to
well-formed {id} objects at the enqueue and restore boundaries.
- W1 malformed-drop: malformed attachment fields were silently normalized
away and the POST then succeeded, so the chrome cleared a queue whose images
were never delivered. Malformed input now fails the whole batch with 400.
- W4-a mixed-drop: a drop of images plus unsupported files accepted the images
and ignored the rest, because the error branch only ran when no image was
found. Both halves are now reported: the images attach and each unsupported
file raises a visible error chip.
- attachment-post-poll-retention: the sweeper's reference set covered only
pending prompts, which takeFeedback clears on delivery, so an attachment
could be reaped while the agent was still reading the path it had just been
handed. Delivered ids are retained for a bounded read grace.
Two existing tests asserted the old contracts that two of these findings
identify as defects (the eager delete, and "after the agent takes the feedback
the reference clears"); both are updated to the corrected behavior rather than
deleted.
* docs: reconcile the attachment invariants with the round-6 fixes
AGENTS.md documented the behaviors these findings removed - the eager
`lavish:removeAttachment` delete and its classifyAttachmentDelete parking, and
"takeFeedback clears delivered prompts, which is what makes their attachments
sweep-eligible" (the post-poll-retention defect, written down as intent).
Records what neither the code nor the tests show at a glance: why the raw ref
bound must stay ahead of the first filesystem await, why no eager delete may be
reintroduced, why upload results are nonce-scoped, and that the delivery grace
is a bounded read window rather than a second lifetime.
* docs(server): note the delete route's grace-aware guard and its unused-by-chrome status
* fix(attachments): close three defects the after-gate found in the round-6 fixes
The after-review on e841887 confirmed all eleven findings closed and then found
three defects in the fixes themselves. Adjacent bugs bred by a fix are the exact
failure this round exists to correct (E5 was born in round-5's own W-A fix), so
these are part of closing E5 and post-poll-retention, not new scope.
- The E5 sanitizer filtered entries but returned the artifact's own objects.
postMessage delivers a structured clone, which preserves BigInt values and
cycles that JSON.stringify then refuses, so the junk rode along into
sessionStorage and the POST body and made the queue unsendable - the same
wedge one step later. Each ref is now projected onto a fresh {id, name}.
- `upsertSession` rebuilds a session from an explicit field list and did not
carry `delivered_attachments`, so re-opening the artifact inside the grace
hour erased the retention and handed the next sweep a path the agent was
still reading. The field is carried, with a note that this constructor
silently drops anything it omits.
- Delivery-grace entries were appended per delivery, so one reused
content-addressed image consumed many slots and could evict distinct
attachments still inside their own grace. Retention is now keyed by id: a
re-delivery refreshes the window instead of taking another slot.
Two further after-gate findings (an object-count/sidecar disk-quota bypass, and
the SDK materializing a file buffer before checking its size) are real but
pre-existing and outside the ruled scope of the eleven; they are surfaced to
firstmate rather than fixed here.
* fix(attachments): derive the delivery retention bound from the request bound
The final after-review caught a contradiction between two constants introduced in
this same round: /prompts accepts up to MAX_REQUEST_ATTACHMENT_REFS (256) images
per batch (the E3 pre-resolver bound), but delivery retained only the newest
MAX_DELIVERED_ATTACHMENTS (200). A max-size batch therefore left 56 delivered
paths immediately sweepable while the agent was reading the very response that
handed them over - the post-poll-retention hole reopening for the largest batch
the E3 bound permits.
Retention is now derived from the request bound rather than hand-picked
separately, so whatever a single batch may queue, a single delivery can always
protect, and the two cannot drift apart again.
* fix(attachments): retain the whole delivery, not a constant's worth of it
Third defect found in this round's own retention work, and the same root cause
each time: the bound was a NUMBER when the invariant is structural.
Prompts accumulate across an unbounded number of accepted POSTs until a poll
drains them, so a single takeFeedback can legitimately deliver far more than one
request may queue. Retention sized to the per-request bound therefore sliced the
delivery itself: two legal 256-ref batches queued before one poll produced 512
delivered paths and left the first 256 immediately sweepable - while the agent
was reading exactly those paths.
`takeFeedback` now retains every id in the current delivery in full, whatever its
size, and MAX_DELIVERED_ATTACHMENTS bounds only the history of EARLIER deliveries
that rides along. The current delivery is never trimmed, so no constant has to be
correct for the invariant to hold.
* feat(attachments): round 7 - client size gate + real disk accounting
Round 7 = the two deferred items César scoped IN (a+b); the (c) cluster
(DEFERRED-3/4/5/6) stays tracked and deferred.
(a) The SDK now rejects an over-limit file in add() BEFORE createObjectURL or
arrayBuffer, so a multi-gigabyte drop is never read into a buffer and
structured-cloned into the chrome only to be rejected afterwards. The server
byte limit is threaded into the SDK via createSdkJs (mirroring the existing
maxAttachmentCount wiring); attachmentSizeError is a pure exported helper
serialized into the bundle, and the oversized file surfaces as a dismissible
error chip. The server still re-checks authoritatively.
(b) The disk cap now charges REAL allocation instead of logical image bytes:
listAttachments reports chargedBytes = the image rounded up to whole 4096-byte
blocks plus the sidecar's own block, and sweepAttachments enforces the cap on
that. This closes the measured ~683x undercount, where a flood of 12-byte
magic-prefix uploads sat far under the reported total while consuming real disk
and inodes. A derived object-count bound (disk budget / min per-object charge,
never a separate knob) backstops the inode dimension.
Each closed with its own forced RED->GREEN test. Two existing disk-cap tests that
expressed the cap in logical .bytes are rewritten in terms of chargedBytes, since
that is the accounting basis the fix intentionally changed.
* docs: record the round-7 disk-accounting and client size-gate invariants
* perf(attachments): make the disk-cap sweep O(n log n), not O(n^2) under the mutex
The round-7 gate caught a regression in round 7's own (b) code: the eviction loop
recomputed the charged-byte and object totals by filtering and reducing over ALL
survivors on every candidate, making a large over-cap sweep O(n^2) - while holding
the store's single global mutex. That is an E3-class lock hazard (the very thing
the lock-DoS fix closed), reintroduced by the disk-accounting change.
The totals are now computed once and decremented per successful removal. Behavior
is identical - a new test pins that a multi-file over-cap sweep still evicts the
exact minimum, oldest-first - so this is a complexity fix, not a behavior change.
* docs: note the sweep keeps running totals to avoid an under-mutex O(n^2)
* feat(attachments): round 8 - upload concurrency bound, temp-file reap, batched render
The tight bundle César scoped for round 8 (D8 + D6 + D7), each with its own
forced RED->GREEN test. D3/D4/D5/D9 stay tracked as architectural/ask-user.
- D8 (chrome-client.js): the confused-deputy guards bounded upload rate and
cumulative bytes but not how many ran AT ONCE, so a hostile artifact could post
~30 large bodies in one tick and hold hundreds of MiB of clones + server buffers
concurrently before the cumulative quota tripped. An in-flight ceiling
(UPLOAD_MAX_IN_FLIGHT) now refuses over-cap uploads with a retry hint, and a
settled upload frees a slot. RED: eight held-open uploads all hit the network at
once (got 8).
- D6 (attachment-store.js): writeFileAtomically writes `<name>.<pid>.<n>.tmp` then
renames; a crash in between left temp files that ID_RE excludes, so no TTL, disk,
or object cap ever counted or removed them - a permanent leak. sweepAttachments
now reaps temp files matching the store's own pattern older than a 5-minute grace
(a live write renames in ms). RED: a stale orphan temp survived the sweep.
- D7 (artifact-sdk.js): add()/rejectUnsupported each rebuilt the whole chip DOM, so
a multi-file drop was O(N^2). A new pure classifyAttachmentBatch decides the whole
batch in one pass; addFiles and a batched rejectUnsupportedBatch now render once.
RED: seam-first (the classifier did not exist). The round-7 (a) size gate now
lives in the classifier and still precedes the single createObjectURL, so an
oversized file is decided "error" before any read - re-pinned accordingly.
Two existing tests updated for the new structure: the confused-deputy rate-cap test
now settles each upload so the new in-flight bound does not mask the rate cap, and
the round-7 (a) bundle pin follows the size gate into the classifier. Neither is
weakened - the invariants they assert are unchanged.
* docs: record round-8 upload-concurrency, temp-reap, and batched-render invariants
* fix(attachments): reap orphan .meta sidecars and delete them first (ATTACH-002)
The round-8 gate found a leak in the same orphan-file class D6 addressed:
removeAttachment/removeFile deleted the image first, then the sidecar with a
suppressed failure, so a crash between the two left an orphan `.meta` that ID_RE
hides from every cap - a permanent leak. Completes D6's orphan-file hardening.
- The sweep's orphan reap now also removes `<id>.meta` sidecars whose image is
gone. No grace: a live upload writes the image before its sidecar, so a
sidecar-without-image is unambiguous debris (unlike a .tmp, which may be a live
write). RED: an orphan sidecar survived the sweep.
- removeAttachment/removeFile now delete the sidecar FIRST, so a crash after the
first removal leaves only the counted image (TTL/disk/object reclaimable), never
the uncounted sidecar.
The other round-8 gate finding (ATTACH-001: the fixed 256 request-wide ref bound
vs a configurable per-prompt count) is a config-coherence ask-user decision, left
tracked as DEFERRED-10 for César alongside D3/D4/D5/D9 - not patched.
* docs: record the orphan-sidecar reap and sidecar-first delete invariant (ATTACH-002)
* fix(attachments): enforce the disk cap at the upload admission chokepoint and shield ready cards from eviction
Item 1 (root cause B): uploads never checked current usage, so several chrome
pages could each write past `maxDiskBytes` before the next periodic sweep,
defeating the documented disk-cap backstop. Route every byte-adding write path
through one admission chokepoint (`admitAttachmentCharge`) inside `writeAttachment`:
before a NEW image+sidecar (or a dedup sidecar repair) touches disk, reclaim
unreferenced/expired bytes toward (cap - newCharge), then measure the true
committed allocation on disk and refuse with 507 when it plus the new charge still
exceeds the cap. The whole reference-snapshot + reclaim + write runs under the
server's lifecycle lock. Committed bytes are measured by an independent tree walk
so `sweepAttachments`' public return shape is unchanged.
Item 2 (attachment-store.js eviction filter): cap eviction removed the oldest
UNREFERENCED file with no minimum-age floor, so it could delete a freshly uploaded
"ready card" (an image dropped into the composer but not yet queued on a prompt,
hence unreferenced) out from under the imminent Send -> unrecoverable not-found,
user-visible data loss. Add a bounded upload grace (`ATTACHMENT_EVICTION_GRACE_MS`,
1h, symmetric with the delivery grace): files younger than the grace are never
cap-evicted. The grace is opt-in via a `sweepAttachments` option (default 0 = off)
so the pure mechanism and its existing tests are unchanged; the server passes it on
both the periodic sweep and the upload admission. The grace never lets the total
exceed the cap (admission still 507s when only referenced/fresh bytes remain); it
only makes the cap prefer refusing a new write over destroying a ready card.
Tests: test/attachment-disk-admission.test.js reproduces both vectors (RED on the
base tip, GREEN here) - a new over-cap upload is refused, and a fresh ready card
survives disk pressure so Send still resolves it.
* test(chrome-client): stop reserving 300 MiB in the quota test (item 3)
The "oversized upload is refused" case allocated a real
`new ArrayBuffer(300 * 1024 * 1024)` (300 MiB) purely so the upload size check
would read an over-quota `byteLength` - 300 MiB of real memory per run and a
needless OOM risk in CI. The size check only reads `byteLength`, so spoof a real
view (`ArrayBuffer.isView` still true) whose `byteLength` own property REPORTS the
over-quota length without reserving the bytes. The test still exercises the exact
same session-quota refusal path.
* test(attachments): cover attachment routes under the Host-allowlist (DNS-rebinding) guard
The attachment upload/fetch/delete routes must sit behind Kun's Host-header
allowlist, not only the same-origin guard. A DNS-rebound page carries its hostile
domain in both Origin and Host, so isSameOriginRequest still matches; only the
Host allowlist rejects it. Assert a rebound request (Origin == forged Host) is a
clean 403 forbidden host on all three attachment routes while a legitimate
loopback same-origin request still succeeds.
* no-mistakes(document): document attachment disk-cap admission and eviction grace
* style(server): format rebased SDK response
* fix(attachments): route SDK uploads through postArtifactMessage so the chrome mediates them
The annotation card sent lavish:uploadAttachment via a raw parent.postMessage
with no artifact_load_token, and the chrome drops every artifact message whose
token is not the current load's before dispatch - so real uploads were silently
discarded while every mocked harness stayed green (the chrome harness patched
the missing token into test messages). Send the upload through
postArtifactMessage like every other SDK message, make the chrome harness send
messages verbatim, pin the token gate for uploads, and add a real-browser e2e
that round-trips an actual image through the gate into the attachment store
and back to the agent poll.
---------
Co-authored-by: “420tombombadil” <“dijongui@gmail.com”>
Co-authored-by: kunchenguid <kun@kunchenguid.com>
* chore(main): release lavish-axi 0.1.51 (#244)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(whiteboard): preserve Mermaid node label line breaks in Excalidraw (#246)
* fix(whiteboard): preserve mermaid label line breaks in Excalidraw
The converter left <br> and \n as literal characters in skeleton labels, so adjacent words fused and the box sized as one line. Convert those markers to real newlines and size the bound text from the resulting line set.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(whiteboard): recenter bound text when linebreak pass grows the box
Growing the container from its center left the independently stored bound-text x/y at the old coords, so a multi-line label sat off-center on first paint. Reposition the bound text into the resized box.
Co-authored-by: Cursor <cursoragent@cursor.com>
* no-mistakes(review): Skip labelled-arrow resize in linebreak restore
* no-mistakes(document): Document labelled-arrow skip for linebreak restore
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(main): release lavish-axi 0.1.52 (#247)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(agents): use @AGENTS.md import instead of CLAUDE.md symlink (#249)
* chore(agents): use @AGENTS.md import instead of CLAUDE.md symlink
* chore(agents): exempt the CLAUDE.md pointer from prettier
* test(agents): assert CLAUDE.md is a real @AGENTS.md pointer, not a symlink
---------
Co-authored-by: Kun Chen <kun-1@kunchenguid.com>
* fix: avoid phantom whiteboard conflicts (#252)
* fix(whiteboard): prompt on source change only for real scene edits
Conversion autosaves on view, so a live Mermaid rewrite was treated as
stale user edits whenever a sidecar existed. Compare against the
conversion baseline and silently re-convert unmodified scenes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* no-mistakes(review): Preserve empty edited scenes without conversion baselines
* no-mistakes(review): Detect all meaningful whiteboard edits before reconversion
* no-mistakes(review): Compare geometry jitter using symmetric raw deltas
* no-mistakes(review): Correct whiteboard preservable-edit invariant
* no-mistakes(review): Normalize whiteboard baselines through Excalidraw restore
* no-mistakes(review): Cover mounted autosave conflicts with real Excalidraw regression
* no-mistakes(document): Format whiteboard conflict regression files
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(main): release lavish-axi 0.1.53 (#253)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* docs: establish author-approved project vision (#254)
* docs: add author-approved VISION.md
Adds the project's first VISION.md at the repo root, co-authored with the
repository owner over six rounds on a Lavish review board.
Every principle traces to real merged history (80 merged PRs mined, 15 bodies
read) or to a recorded author verdict. Twenty-four hypotheticals were
stress-tested; three reversed positions the initial evidence-only draft had
asserted, and four principles came from the author rather than the history.
Also registers VISION.md in the AGENTS.md documentation-ownership list as the
owner of the acceptance policy.
* no-mistakes: apply CI fixes
* fix: preserve Mermaid label line breaks (#237)
* fix(whiteboard): preserve Mermaid label line breaks
* fix(design): retain Mermaid label markup
* no-mistakes(document): Verify Mermaid label preservation documentation and lint
* fix: add semantic names to table annotations (#256)
* fix(sdk): give table-cell annotations semantic row and column names
Port the accepted work from #242 onto current main so filtered or sorted
tables keep semantic row and column names while the clicked element's own
selector, tag, and text stay the locator.
Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
* no-mistakes(review): Remove source-token SDK serialization test
* no-mistakes(document): Clarify semantic table target documentation
* no-mistakes: apply CI fixes
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adelin <adelin-b@users.noreply.github.com>
* fix(server): reject foreign origins on mutating routes (#257)
* fix(server): reject cross-origin mutating requests on the loopback server
Add a global Origin/Referer CSRF guard after the Host allowlist so a
foreign page that can reach 127.0.0.1 cannot CSRF previously unguarded
mutating routes. Header-less CLI requests still pass; per-route
isSameOriginRequest checks stay in place.
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
* no-mistakes(document): Clarify CSRF guard documentation
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kun Chen <kunchenguid@users.noreply.github.com>
* feat(attachments): support image paste, drop, and picker uploads in the conversation composer (#248)
* feat(attachments): support images in every composer
* no-mistakes(review): fix stale composer upload notice and replace source-regex tests
* no-mistakes(review): fix composer clipboard items paste and dragleave flicker
* no-mistakes(review): harden composer attachment drag, retry, and MIME source
* no-mistakes(review): untrack .omc state, fix notice order and MIME source
* no-mistakes(document): fix stale attachment docs for composer uploads
* fix(chrome): harden composer attachment gating, cap counting, and drop guards
A deep review pass over the composer attachment feature surfaced a set of
confirmed defects; this commit fixes the correctness tier:
- The composer chip gate no longer blocks the entire send pipeline. A pending
or failed chip now holds back only the composer's own message and an explicit
end, while queued annotation prompts still deliver - previously Send and
Send & End silently delivered nothing with the only signal in an 11px notice.
- The per-batch cap count no longer charges size-refused chips (which retain no
file and cannot upload), so an oversized companion in a mixed paste can no
longer silently swallow a valid image while syncNotice clears the cap notice
on the same render tick.
- Paste no longer raises unsupported-type chips: Office and macOS pastes expose
stray non-image file flavors beside their text, and a permanent red chip for
a perceived text paste blocked sending until manually removed. The picker and
drop still refuse visibly, and a Files drag with nothing enumerable now gets
an explicit refused chip instead of being swallowed after preventDefault.
- A document-level Files-drag guard stops a drop that misses the composer from
navigating the chrome away (losing chips, uploads, and the SSE connection).
Text drags are left to the browser so dropping text into the textarea works.
- planClipboardPaste (and its chrome mirror keepsClipboardText) now treats a
pasted file's own name or path in text/plain as placeholder rather than a
caption, restoring the pre-branch behavior for Finder/Explorer file copies.
- The chip list gets the card's W3 max-height/scroll backstop so unbounded
chips cannot push Send or their own Remove buttons off-frame.
- CHAT_ATTACHMENT_MIME falls back to PNG/JPEG/WebP when the session JSON list
is unwired, matching acceptedImageTypes instead of refusing every image.
AGENTS.md records the new easy-to-reintroduce composer rules; README notes the
filename-placeholder paste refinement.
Claude-Session: https://claude.ai/code/session_01CTomK8PgtEyDEpSbg6CmvW
* no-mistakes(document): docs verified current; lint, format, typecheck clean
* fix: let artifact popups escape the iframe sandbox (#258)
* fix: allow artifact popups to escape sandbox
Adds allow-popups-to-escape-sandbox to the artifact iframe so
author-intended external links and popups open as normal top-level
tabs. The iframe itself stays sandboxed; allow-same-origin is not
granted.
Last-resort port of kunchenguid/lavish-axi#199 onto current main.
Original work by Srikanth Vuppuluri (@vuppuluri-srikanth).
Co-authored-by: Srikanth Vuppuluri <svuppuluri@salesforce.com>
* no-mistakes(document): Document artifact popup sandbox behavior
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Srikanth Vuppuluri <svuppuluri@salesforce.com>
* docs: document the self-hosted share backend contract (#151)
* docs: document the self-hosted share backend contract
The README says to set LAVISH_AXI_HTML_APP_API_URL when "overriding the
ht-ml.app API base" but does not say what that base has to implement, so you
cannot self-host the share backend without reading the client source.
This documents the contract share expects: POST /v1/sites with
{ html_content, password? }, a JSON response with url + update_key, and
optional bearer auth. Adds a small Cloudflare Worker reference and the security
notes (an open publish endpoint is an open HTML host on your domain; isolate
the viewer origin). Came out of self-hosting the share backend for an internal
project.
Added as docs/self-hosting-share.md since there is no docs/ dir yet. Happy to
fold it into the README instead.
* no-mistakes(document): link README to self-hosting share doc, fix its formatting
* fix(cli): put poll feedback ahead of the DOM snapshot in output (#266)
* fix(poll): prioritize feedback in delivery output
Fixes #228; deliberately does not touch #168 ack-before-consume.
* no-mistakes(document): document poll output ordering invariant
* chore: ignore agent runtime state
* no-mistakes: apply CI fixes
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* fix(server): keep sends available while an agent works and stop losing feedback on closed polls (#265)
* fix: preserve sends across poll presence transitions
Keep feedback actions durable while the agent is working, close poll cleanup races, and clear delivered state for final feedback on ended sessions.
Fixes #229
* fix(server): restore feedback from closed immediate polls
* no-mistakes(document): document poll feedback restore and share contract owner
* no-mistakes(review): untrack .planning agent runtime state
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* fix(chrome): recover a review that never finishes loading (#268)
* fix(chrome): recover a review that never finishes loading
A review page could come up with a spinner that never resolved, or with the
chrome shell around a permanently empty artifact frame. There are two triggers.
The first is the shared background server being replaced while a review page is
opening, which any `lavish-axi <html-file>` invocation can do after an upgrade:
the server broadcasts `chrome-reload` to every connected chrome and steps down,
so an unrelated session's page reloads on its own and then races the
replacement. The second is a second browser tab claiming the same review, which
supersedes the reviewer handoff the first tab is holding.
Four failures on those paths left no way back:
- The chrome page ships with the layout-gate overlay already covering the
artifact, and only `chrome-client.js` ever takes it down - including wiring
the "Show anyway" button. When that script never runs, the page sits on the
spinner with a button that does nothing. An inline failsafe now arms before
the script tag, fires on its `onerror` and on a timeout, and replaces the
spinner with a named failure and a working Reload button. The client cancels
it once it has run to completion.
- `replaceArtifactFrame` gave up permanently after two retries spanning 400ms,
which does not cover the multi-second window where the server is being
replaced. On a first load there is no previous frame to fall back to, so the
iframe was never navigated and `artifact_revision` never advanced. Recoverable
outcomes now retry on a backoff, and only surface a failure once that is
exhausted with nothing on screen. `superseded` and `out-of-order` still do not
retry: another reviewer, or a newer request in this same chrome, owns the
artifact.
- After `chrome-reload`, the chrome reloaded on a fixed 5s deadline whether or
not anything was listening, replacing a recoverable page with the browser's
connection-error page. It now waits for `/health` to answer, and says the
server is not running instead of reloading into a dead port.
- A `superseded` begin-load told the user through the takeover banner in the
conversation panel, which the gate overlay covers. A tab that had never loaded
the artifact therefore held the "Checking layout" spinner for the full gate
hold and then revealed an empty frame, with its only recovery control hidden
the whole time. That case now names itself on the overlay with a "Take over
here" button. A tab that already shows an artifact is unchanged: it keeps the
artifact and uses the banner alone. Neither case retries in the background.
Adds regression tests for each, plus one pinning the no-retry rule for a
superseded reviewer.
* no-mistakes(review): reset artifact load retry budget per attempt; execute boot failsafe in tests
* no-mistakes(review): confirm server health before and after not-running card
* no-mistakes(review): scope upgrade reloads and persist unsent annotation drafts
* no-mistakes(review): name shutdown reason, gate banner reload, retire dead drafts
* no-mistakes(review): keep drafts through transient anchor misses; probe before every reload
* no-mistakes(review): hand back retired drafts; never restore over an open card
* no-mistakes(review): never drop a retired draft; correct unreachable-banner docs
* no-mistakes(review): bound health probes; always hand recovery controls back
* no-mistakes(review): name local-build restarts; guard stale probe copy
* no-mistakes(review): carry shutdown reason to the reloaded page
* no-mistakes(review): agree card title with probe result; keep handbacks last
* no-mistakes(document): move unsent-annotation handback docs under live-reload owner
* no-mistakes: apply CI fixes
* chore(main): release lavish-axi 0.1.54 (#255)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* ci: require no-mistakes pipeline attestation in the gate (#271)
* ci: require no-mistakes pipeline attestation in the gate
* ci: bind the no-mistakes attestation to the PR head
Fail the gate unless the attestation's head_sha equals the PR's current head:
a commit pushed after the pipeline run leaves an attestation that describes
different code. A synchronize whose body was not rewritten by no-mistakes
going red is the intended contract.
Also skip the bash+jq gate test on Windows, where the gate workflow never
runs and Windows-native jq's CRLF output made every parsed verdict a false
failure.
* fix(server): restore taken feedback when a long-poll client disconnects (#270)
* fix(server): restore feedback after long-poll disconnect
A long-poll response could destructively take feedback and then lose it when the client disconnected before the response was written. Restore the exact batch before marking delivery in that race, and cover the event-driven path with a deterministic regression test.
* no-mistakes(review): wake and log failures on closed-poll feedback restore
* fix(server): preserve concurrent feedback during restore
Prepend a restored batch ahead of prompts queued after the destructive take, preserve newer DOM snapshots and artifact failures, and wake a poll that attached during the restore. Add deterministic coverage for the concurrent state merge and the poll-B wake race.\n\nThe restore still cannot recover a disconnect after response bytes begin or a process loss between the destructive take and the restore; closing those windows requires an application-level acknowledgement or a non-destructive take protocol.
* no-mistakes(review): exempt restore from request ref cap, dedupe artifact failures
* no-mistakes(document): note restore exemption in attachment ref bound docs
* no-mistakes(lint): format pre tag close in layout audit fixture
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
---------
Co-authored-by: Julian Alecssandre Dasilva <jdas@Julians-MacBook-Pro.local>
* chore(main): release lavish-axi 0.1.55 (#272)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: replace clipped mobile conversation with bottom sheet (#275)
* feat(chrome): conversation dock and bottom sheet for phone-width review
Below 860px the conversation panel was a fixed-fraction strip under the
artifact: the sticky composer alone consumed it, leaving the chat log a
72px sliver at 390x844 and no height at all on a short phone, where the
Send row also ran past the 100vh viewport.
The artifact now takes the whole viewport above a persistent Conversation
dock, and the conversation opens as a bottom sheet over a scrim: tap,
swipe, chevron, or Escape raise and lower it. The dock reports queued
prompt count, an unread agent reply, or agent presence while the sheet is
down, and the open state survives a chrome reload. The sheet is sized
from the visual viewport so an on-screen keyboard shrinks it instead of
covering the composer, pads by safe-area insets, and covers the top bar
in landscape. Desktop is pixel-identical: the head wrappers are
display: contents outside the breakpoint.
Covered by harness tests for the sheet state machine and dock summary,
a served-page contract test, and an opt-in real-browser geometry test at
390x844, 375x548, and 1440x1000.
* no-mistakes(review): Prevent cancelled swipes from toggling conversation sheet
* no-mistakes(review): Prevent mobile composer overflow and reset sheet state
* no-mistakes(review): Keep mobile send actions visible and preserve focus
* no-mistakes(review): Preserve mobile send actions with keyboard viewport
* no-mistakes(document): Refresh mobile sheet docs and lint
* chore(main): release lavish-axi 0.1.56 (#276)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* chore(deps): harden pnpm trust policy (#278)
Set `trustPolicy: no-downgrade` and `blockExoticSubdeps: true` in
pnpm-workspace.yaml so a malicious dependency update cannot silently
downgrade pnpm's security settings or pull in exotic sub-dependencies.
Narrow supply-chain hardening; no runtime behavior change.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix: make ended review sessions read-only (#273)
* fix(server): notify the browser when an ended session goes read-only
A browser tab left open across `lavish-axi end` (or an End in another
tab) kept accepting Sends: the chrome never learned the session had
ended, and prompts queued afterward were silently stored with a 200
even though no agent would ever poll for them again.
Forward the server's "ended" event over the existing session SSE
stream so an attached chrome calls markSessionEnded() immediately, and
have a page loaded or reloaded after the session already ended start
read-only from its initial state instead of waiting for an event that
will never arrive. As defense in depth for the race where a batch was
already in flight, reject (409) a prompt batch that arrives after the
session was already ended by something else, rather than accepting it
with a 200 the server can't keep the promise of.
Closes #171
* fix(server): close two remaining gaps in the ended-session read-only fix
Address review findings on the previous commit:
- queuePrompts still let a batch through with a 200 if it also asked
to end the session (shouldEndSession), even when the session had
already been ended by something else. That batch is exactly as
undeliverable as a plain late Send, so drop the exemption - only the
store's own restore-after-disconnect replay stays exempt, since it
never originates a new POST and never sets endSession.
- the /events/:key SSE handler read session state before registering
its "ended" listener, so an end landing in that await window could
fire with nothing here listening yet. Register every listener first,
then send an immediate ended snapshot if the freshly read session is
already ended - covering a reconnect (or first attach) that lands
after the live event already fired, which a page's initialEnded
boot state can't reach without a full reload.
* no-mistakes(review): Fix SSE cleanup during initial session reads
* no-mistakes(document): Document ended-session read-only behavior
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* test: restore CONTRIBUTING.md attestation-version regression test
An automated CI auto-fix removed this test, contradicting the approved
PR head; restoring it exactly.
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes(review): Close share dialog when review session ends
* no-mistakes(document): Document ended-session read-only behavior
* no-mistakes: apply CI fixes
* no-mistakes(review): Keep ended composer inert across responsive updates
* no-mistakes(review): Keep ended panel content inert across responsive updates
* no-mistakes(document): Clarify ended panel inert-state documentation
* ci: migrate no-mistakes gate to shared action (#280)
* ci: use shared require-no-mistakes composite action
Replace the inline gate script in .github/workflows/no-mistakes-required.yml
with a thin caller of kunchenguid/no-mistakes/.github/actions/require-no-mistakes,
pinned to an immutable commit. Enforcement now lives upstream instead of being
hand-copied between sibling repos.
Drop the synchronize trigger: the verdict is a pure function of the PR body, and
a push pinned a failing check run to a head whose body the same pipeline run was
about to rewrite.
Remove test/no-mistakes-gate.test.js, which extracted and executed the now-absent
inline run block, and point AGENTS.md and CONTRIBUTING.md at the shared action.
* no-mistakes(document): Correct no-mistakes trigger rationale
* chore(main): release lavish-axi 0.1.57 (#279)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat: default diagram guidance to hand-authored SVG, Mermaid becomes the whiteboard opt-in (#282)
Rewrites the agent-facing diagram guidance per the captain-approved SVG-first
proposal: the diagram playbook now defaults to hand-authored inline SVG under a
six-rule authoring contract, Mermaid is routed solely by an explicit
editable-whiteboard request, the home output's visual_guidance carries the
illustration-over-words posture, and design output renames diagram_tooling to
whiteboard_tooling with opt-in wording. Guidance strings, README, and the
regenerated skill only - no runtime behavior changes.
* chore(main): release lavish-axi 0.1.58 (#283)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(chrome): prevent layout gate sticky failure traps (#284)
* fix: prevent layout gate sticky trap
* no-mistakes(review): Fix layout gate bypass and diagnostic republishing
* no-mistakes(review): Fix no-gate sticky failure escape paths
* no-mistakes(review): Unify boot failsafe gate escape behavior
* no-mistakes(document): Document layout gate escapes and fix static checks
* chore(main): release lavish-axi 0.1.59 (#285)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix: keep the shipped skill as a minimal CLI-deferring stub (#286)
* fix: keep the shipped skill as a short CLI-deferring stub
Installed SKILL.md copies went stale against newer CLI guidance. The
generator now emits a minimal discovery stub that points at
lavish-axi --help, design, and playbook instead of copying those
instructions, and check still fails if the committed file drifts.
Also unlink Cursor plugin replacement sidecars with recursive rmSync so
Node on Darwin does not throw EISDIR on directory symlinks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* no-mistakes(review): Remove installed-copy fallbacks from minimal skill stub
* no-mistakes(review): Revert unrelated Cursor plugin behavior change
* no-mistakes(document): Consolidate minimal skill documentation contract
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(share): publish a private link with a generated password (#287)
* feat(share): generate share passwords and republish published pages
`share` could publish a private page only when the agent invented a password
itself, and it had no way to change a page it had already published.
- `--private` mints a high-entropy password (`src/share-password.js`,
node:crypto, unambiguous alphabet) and returns it once, with guidance to
hand it to the user as a shared secret. `--password <pw>` still takes one
the user chose, and is never echoed back.
- `--site <site_id> --update-key <key>` republishes an artifact in place via
`PUT /v1/sites/{site_id}`, leaving the password alone unless `--private`,
`--password`, or `--clear-password` says otherwise.
- `--unpublish` replaces a page with a locked placeholder. ht-ml.app has no
delete endpoint, so every surface says the page still exists rather than
claiming it was removed.
- The browser publish dialog gets the same generated password. It is minted
server-side because chrome-client.js is served raw and cannot import the
generator, and the route echoes only a password Lavish itself created.
Neither the password nor the update_key is persisted; state.json does not
become a store of hosting credentials.
* no-mistakes(review): stop guessing republish URLs; reject --token on republish
* no-mistakes(review): drop clear-password path the host silently ignores
* no-mistakes(review): stop claiming a republished page's password state
* no-mistakes(review): record none-to-set probe; unpublish is not instant
* no-mistakes(review): warn that a republished lock is not instant
* no-mistakes(review): refuse share value flags that swallow the next flag
* no-mistakes(review): remove unused optionalFlagString helper failing lint
* no-mistakes(review): show the site id in the publish dialog
* no-mistakes(review): stop saying a plain republish locks the page
* no-mistakes(review): test share dispatch; classify bad --site as usage error
* no-mistakes(review): surface a generated password when republish fails
* no-mistakes(review): only surface a password when the outcome is unknown
* no-mistakes(document): clarify share token scope and site_id contract in docs
* no-mistakes(ci): point home share guidance at share --help for republish
The home help entry is emitted on every no-argument invocation and every
SessionStart hook, so it has to earn that cost on each run (VISION.md, "Every
token is spent on purpose"). It had grown the site-ID, update-key, republish,
and unpublish mechanics that `lavish-axi share --help` already owns.
Name the update_key and point at that command instead of restating its flags.
The pointer avoids the word "delete" so the shorter line cannot imply an
unpublish removes the page, which is the honesty contract `share --help`,
README, and the unpublish output carry.
* no-mistakes(review): fix share recovery hints; classify unpublish failures
* no-mistakes(review): report every indeterminate republish, not just generated-password ones
* no-mistakes(review): drop password placeholders; report indeterminate publishes honestly
* no-mistakes(review): share honest failures across browser route and create path
* no-mistakes(review): centralize share result panel; classify incomplete publishes
* no-mistakes(review): derive share dialog copy from what it rendered
* feat: add automatic Tailscale phone access (#289)
* feat: add Tailscale-aware review server binding
* no-mistakes(review): Align wildcard control host with concrete loopback listener
* no-mistakes(review): Harden Tailscale detection, binding, links, and reconciliation
* no-mistakes(review): Coalesce and cache Tailscale reconciliation checks
* no-mistakes(review): Isolate loopback URL test from local Tailscale state
* no-mistakes(review): Secure and reconcile Tailscale listener lifecycle
* no-mistakes(review): Retry failed Tailscale binds and bound detection latency
* no-mistakes(review): Honor explicit binds and surface incomplete Tailscale access
* no-mistakes(review): Stabilize Tailscale fallback state and refresh session URLs
* no-mistakes(review): Make forbidden-host guidance reflect actual phone availability
* no-mistakes(document): Refresh Tailscale docs and formatting
* no-mistakes: apply CI fixes
* chore(main): release lavish-axi 0.1.60 (#288)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat: teach diagrams from zero, one concept at a time (#290)
Agents explaining a system with SVG should assume no prior knowledge and
prefer a sequence of simple figures over one dense multi-concept diagram.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(main): release lavish-axi 0.1.61 (#291)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* feat(artifact-sdk): close an empty annotation card on Escape (#293)
An accidental click in annotate mode opens an annotation card, and the only
way out was a mouse round-trip to Cancel. Escape now closes the card, but
only while there is nothing to lose: no trimmed text and no attachment in
any state. A card holding unsent content stays open rather than letting a
keystr…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix Lavish table cell annotations so filtered or sorted rows retain semantic row and column identity while preserving the exact clicked locator.
What Changed
src/table-cell.js, which resolves the clicked cell's visible row and column names and attaches them as atype: "table-cell"target; the clicked element's ownselector,tag, andtextare unchanged, so the CSS locator still points at exactly what was highlighted. Naming is suppressed rather than guessed where a merge makes it unprovable: spans are read from the browser-parsedrowSpan/colSpan(falling back to HTML's integer-parsing rules on the raw attribute), arowspanstarting earlier in a row's own<thead>/<tbody>/<tfoot>group drops the row name unless a<th scope="row">is declared, and a column name requires an unshifted header row whose colspans sum to the clicked row's with no straddling cell.context()insrc/artifact-sdk.jstakes an opt-in{ table: true }so only the annotation card pays the table walk (snapshot()does not), and the card heading/placeholder now read "Annotate cell: Row → Column" for a cell click or "Annotate <tag> in Row → Column" for a nested element.src/chrome-client.jsshows the semantic name as the queued pill's Target and the CSS selector as a separate Locator row when they differ.src/server.js:serializeModuleHelpersnow serializes any shared module's exports as same-scope consts (throwing on a non-function export instead of shipping an empty{}), and it injectssrc/table-cell.jsalongsidesrc/mermaid-node.js. README and AGENTS.md document the new target contract and suppression rules, and.gitignorestops tracking local.omc/runtime state apart from.omc/skills/.Risk Assessment
✅ Low: The table-cell logic is spec-correct on row-group clipping and span parsing, fails closed by omitting unprovable labels rather than guessing, is covered by behavioral tests that boot the real served bundle, and has its contract documented on the owning surfaces; the open items are a scope question about the artifact-driven queuePrompt path and branch-history hygiene, neither of which can produce a wrong label.
Testing
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
src/table-cell.js:114-tableHasRowSpan(table)scans the whole table, soshiftedis true for a rowspan confined to<thead>— the standard grouped-header idiom<th rowspan="2">. That suppressesrowLabel(line 128) for every<tbody>row, even though a rowspan cannot cross a row-group boundary and therefore cannot shift any body row's cells, so the positionalcells[0]heading is still provable there. Concrete path:<table><thead><tr><th rowspan="2">Feature</th><th colspan="2">Result</th></tr><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>Login</td><td>ok</td><td>ok</td></tr>…</tbody></table>; filter to a subset and click anokcell — the target comes back{rowLabel: "", columnLabel: ""}and the agent is left with onlytr:nth-of-type(n), which is exactly the misdirected-feedback failure this change targets. (columnLabelis correctly empty here on its own merits: the leaf header row is genuinely shifted, so its width no longer matches the body row's.) Narrowing the row-label guard to rowspans in the clicked cell's own row group at or above its row would keep the never-guess invariant and restore coverage for this shape. This tradeoff is explicitly documented in README.md:207 and AGENTS.md, so it reads as a deliberate conservative choice rather than an oversight — flagging it as the author's call, not a blocker.src/table-cell.js:50-tableCellSpansRowsparses the rawrowspanattribute string in preference to the browser-parsedcell.rowSpan. HTML's rules for parsing non-negative integers ignore trailing garbage, sorowspan="2x"renders as a real two-row span, butNumber("2x")isNaN,Number.isFinite(raw)is false, and the helper reports no rowspan.shiftedis then false at line 114 andtableCellTargetemits positionalrowLabel/columnLabelfor a grid that is actually shifted — the fail-open outcome the module's own comments say must never happen (every other malformed value, e.g.rowspan="1.5", fails closed instead).tableColumnSpanat line 41 has the same attribute-first shape, though there a mismatch usually trips the header/row width check and fails closed. Readingcell.rowSpan/cell.colSpanfirst when finite and falling back to the attribute parse is strictly more spec-correct (browsers already handlerowspan=""→ 1 androwspan="0"→ 0) and leaves the existing DOM-stub unit tests, which define neither property, on the current fallback path.🔧 Fix: scope table rowspan guard to row group; parse spans per HTML
3 issues (1 warning, 2 infos) still open:
src/artifact-sdk.js:704- A fix round gated the table lookup to annotation clicks:context()only resolvestargetunder{ table: true }, whichshowAnnotationCardpasses (line 1684) butqueuePromptdoes not (line 704). The stated rationale — "snapshot()calls this for every element in the document" (line 307) — applies only tosnapshot();queuePromptruns once per queued prompt, so the cost there is one bounded table walk. The author's original commit (f8b94c7) attached the target incontext()for every caller, so this is a narrowing the pipeline introduced, and the new bundle test at test/artifact-sdk-bundle.test.js:246 locks it in. Reachable path: the input playbook (src/playbooks.js:196) tells artifact authors to build choices from native controls, and the table playbook (src/playbooks.js:62) tells them to make rows easy targets; a per-row radio plus a submit that callslavish.queuePromptin a client-filtered table now deliversselector: "...tr:nth-of-type(7) > td..."and notarget— exactly the misdirected-row-number problem the intent names, just on the artifact-driven path instead of the click path. README.md:206 scopes the user-facing contract to "Clicking an element inside a table", so this is a scope decision rather than a broken contract: confirm whether artifact-queued prompts from inside a table should also carry the row/column names (one-line change:context(originElement, { table: true })).src/artifact-sdk.js:320- The fix round replaced the author's cell-level identity with element-level identity plus a side-channel target. In f8b94c7 a click inside a cell setbase.tag = "table-cell", overwrotebase.selectorwith the cell's selector, and coarsenedbase.textto the cell text; nowselector/tag/textkeep describing the clicked element and onlytargetcarries the cell (lines 317-321), with the card heading branching onc.target?.typeand a new "Annotate <code> in Row → Column" wording for nested clicks (lines 1699-1715). I judge the new contract the better one — it matches the intent's "preserving the exact clicked locator", keepstaga real tag name in line with the target contract in AGENTS.md, and keeps the heading consistent with the on-screen highlight, which outlines the clicked element and not the cell. Flagging it only because it is a user-visible change to the author's deliberate design made by the pipeline rather than by the author: confirm the element-identity-plus-target shape is the wanted contract..gitignore:10- Commit 3531311 committed ~1.1k lines of local agent runtime state under.omc/(project-memory.json with the absolute worktree path under /Users/adelinb, session tool-error state, session-end job tickets), and 8fa4344 deleted it again and added this ignore block. The working tree is therefore clean — the base..HEAD diff carries only these three .gitignore lines — but the blobs are still reachable in the branch's own history, so they land in main if this branch is merged with history preserved rather than squashed. Nothing credential-bearing is in them, but they are one contributor's tool state in a public, npm-published repo. Two calls for the author: squash (or drop those two commits) before merge, and decide whether an ignore rule for a contributor-local agent tool belongs in the shared .gitignore at all, since it is unrelated to the table-cell intent.⏭️ **Test** - skipped
Step was skipped.
⏭️ **Document** - skipped
Step was skipped.
⏭️ **Lint** - skipped
Step was skipped.
✅ **Push** - passed
✅ No issues found.