Skip to content

feat(projects): per-project canvas board (tldraw + SSE)#270

Merged
jaylfc merged 28 commits intomasterfrom
worktree-canvas-board
Apr 29, 2026
Merged

feat(projects): per-project canvas board (tldraw + SSE)#270
jaylfc merged 28 commits intomasterfrom
worktree-canvas-board

Conversation

@jaylfc
Copy link
Copy Markdown
Owner

@jaylfc jaylfc commented Apr 29, 2026

Summary

Pass B item #2 from the Projects roadmap: per-project tldraw infinite canvas as a persistent ideation surface. Users own elements; agents drop notes/links/images. Live-collaborative via SSE within ~200 ms. Per-agent can_edit_canvas flag (default OFF, fail-closed).

What's in this PR

Backend (Python) — new tinyagentos/projects/canvas/ module:

  • store.py — async aiosqlite store (mirrors ProjectTaskStore) with element CRUD, soft delete, kind validation, permission enforcement
  • snapshotter.py — debounced .tldr writer (mirrors BeadsBridge), per-project asyncio.Lock, atomic os.replace
  • unfurl.py — HTML/OG-tag scraping for link previews; never raises
  • render.py — Pillow PNG renderer for vision agents (low-fidelity)
  • mcp_tools.py — 7 in-process handler functions (canvas_list_elements, canvas_add_note, canvas_add_link, canvas_add_image, canvas_update_element, canvas_delete_element, canvas_get_snapshot_png)

Routes (FastAPI) — new tinyagentos/routes/project_canvas.py:

  • GET/POST /api/projects/{id}/canvas/elements
  • PATCH/DELETE /api/projects/{id}/canvas/elements/{element_id}
  • GET /api/projects/{id}/canvas/snapshot.png|.tldr
  • PATCH /api/projects/{id}/canvas/permissions/{agent_id}
  • GET /api/projects/{id}/canvas/stream (SSE)

Schema — adds project_canvas_elements table + project_members.can_edit_canvas column. Idempotent ALTER for existing DBs.

Frontend (React + tldraw v4) — new desktop/src/apps/ProjectsApp/canvas/:

  • canvas-api.ts — REST wrapper
  • canvas-store.ts — zustand element cache
  • canvas-sse.ts — EventSource subscriber dispatching to store
  • shapes/{Note,Link,Image}Shape.tsx — three custom tldraw shape utilities
  • CanvasBoard.tsx<Tldraw> mount with REST hydration + SSE live updates + push-add/update/delete on user edits
  • CanvasView.tsx + ProjectWorkspace.tsx tab integration
  • ProjectMembers.tsx — per-agent Can edit canvas toggle

Test plan

  • tests/projects/test_canvas_store.py — 11 tests (CRUD, permission, broker publish)
  • tests/projects/test_canvas_unfurl.py — 4 tests
  • tests/projects/test_canvas_snapshotter.py — 2 tests
  • tests/projects/test_canvas_render.py — 2 tests
  • tests/projects/test_routes_canvas.py — 7 tests (raw ASGI for SSE)
  • tests/projects/test_canvas_mcp_tools.py — 4 tests
  • tests/projects/test_canvas_integration.py — 2 tests (REST → SSE → snapshot → permission matrix)
  • desktop/src/apps/ProjectsApp/__tests__/canvas-api.test.ts — 3 tests
  • desktop/src/apps/ProjectsApp/__tests__/canvas-store.test.ts — 3 tests
  • Local: 164 backend + 181 frontend tests pass; npm run build clean
  • CI green
  • Manual smoke test in browser before merge

Notes for reviewers

  • The route uses app.state.project_canvas_store (not canvas_store — that's owned by chat-canvas). All wiring matches.
  • canvas_stream and canvas_set_permission use app.state.project_event_broker (not project_broker); fixed mid-review when a regression was caught.
  • Soft-delete via deleted_at column; list_elements filters them out.
  • Permission check: users always pass; agents must have can_edit_canvas = 1 row in project_members. Fails closed.
  • Playwright spec at desktop/tests/canvas-e2e.spec.ts is documented but not runnable — no Playwright scaffolding in repo yet. Header comment lists the steps to wire it up.
  • tldraw v4 API drift: custom shapes use ShapeUtil<any>, Rectangle2d for geometry, editor.run() (was batch()), shapeUtils as <Tldraw> prop (not createTLStore). All small, expected.

Summary by CodeRabbit

  • New Features

    • Collaborative project canvas with notes, links, and images; new Canvas tab and per-member "Can edit canvas" permission toggle; live realtime sync and on-demand snapshot exports (PNG and TLDR).
  • Chores

    • Added runtime libraries to support the embedded canvas editor and image rendering.
  • Tests

    • New unit, integration, and end-to-end tests covering canvas APIs, realtime updates, rendering, snapshotting, and permissions.

jaylfc added 25 commits April 29, 2026 00:11
Playwright is not yet scaffolded in this repo; the spec is committed as
documented test code. Header comment explains how to wire up Playwright
before running. Vitest exclude added so tests/**/*.spec.ts files are
skipped by the unit-test runner.
…te + app wiring)

The route was reading request.app.state.project_broker, but app.py sets
project_event_broker. Both routes (SSE stream + permission toggle) would
have raised AttributeError in production. Tests passed because the two
fixtures used the same wrong key, masking the bug.

Aligns with tinyagentos/routes/projects.py:531 and app.py:410, 777.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

Adds a tldraw-based project canvas: frontend components and shapes, a Zustand client store, REST + SSE integration, backend SQL persistence with permission checks, a snapshotter (TLDR + PNG), link unfurling, MCP tools, tests, and dependency updates (Pillow, @tldraw/tldraw).

Changes

Cohort / File(s) Summary
Package & build
desktop/package.json, pyproject.toml, desktop/vite.config.ts
Added @tldraw/tldraw and Pillow>=10.0; updated Vitest exclude rules.
Project UI integration
desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx, desktop/src/apps/ProjectsApp/ProjectMembers.tsx
Added "canvas" workspace tab and a “Can edit canvas” member checkbox wired to canvasApi.setPermission.
Canvas UI components
desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx, .../CanvasBoard.tsx
New CanvasView/CanvasBoard mounting tldraw, bootstrapping from canvasApi, subscribing to SSE, and syncing editor edits to backend.
Tldraw custom shapes
desktop/src/apps/ProjectsApp/canvas/shapes/NoteShape.tsx, .../LinkShape.tsx, .../ImageShape.tsx
Added TaosNote/TaosLink/TaosImage shapes and ShapeUtil classes for rendering notes, links and images with project-scoped payloads and author metadata.
Frontend state & comms
desktop/src/apps/ProjectsApp/canvas/canvas-store.ts, .../canvas-api.ts, .../canvas-sse.ts
Zustand canvas store, REST client (CRUD + permission), SSE subscriber applying remote element/permission events.
Frontend tests
desktop/src/apps/ProjectsApp/__tests__/canvas-store.test.ts, .../canvas-api.test.ts, desktop/tests/canvas-e2e.spec.ts
Unit tests for API/store and a Playwright e2e spec exercising seeding and live SSE updates.
Backend router & endpoints
tinyagentos/routes/project_canvas.py
New FastAPI router with canvas CRUD, permission PATCH, snapshot PNG/TLDR endpoints, and SSE stream emitting canvas.* events.
Backend canvas store & errors
tinyagentos/projects/canvas/store.py, tinyagentos/projects/canvas/__init__.py
New SQL-backed ProjectCanvasStore with soft deletes, permission checks (CanvasPermissionError), event publishing, and public exports.
Snapshotter & renderer
tinyagentos/projects/canvas/snapshotter.py, .../render.py
CanvasSnapshotter (debounced board.tldr writer) and Pillow-based PNG renderer.
MCP tools & unfurl
tinyagentos/projects/canvas/mcp_tools.py, .../unfurl.py
Agent-facing canvas helpers (list/add/update/delete/export) and URL unfurl utility with SSRF protections and metadata extraction.
Backend wiring & schema
tinyagentos/app.py, tinyagentos/projects/project_store.py, tinyagentos/projects/ids.py
App startup wiring for canvas store and snapshotter; added project_members.can_edit_canvas column and runtime ALTER; allowed cve id prefix.
Shared types
desktop/src/lib/projects.ts
Extended ProjectMember type with optional can_edit_canvas?: boolean.
Backend tests
tests/projects/* (test_canvas_*, test_routes_canvas.py, ...)
Extensive backend tests covering store, permissions, snapshotter, renderer, unfurl, MCP tools, REST endpoints, and SSE streaming.

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Browser)
    participant UI as Canvas UI (tldraw)
    participant API as Project API
    participant Store as ProjectCanvasStore (DB)
    participant Broker as Event Broker
    participant SSE as SSE clients

    User->>UI: create/update/delete shape
    UI->>API: POST/PATCH/DELETE /api/projects/:id/canvas/elements
    API->>Store: add/update/delete element
    Store-->>API: element result
    API->>Broker: publish canvas.event (added/updated/deleted/permission_changed)
    Broker->>SSE: deliver event to subscribers
    SSE-->>UI: stream data: canvas.* event
    UI->>UI: reflect remote changes in editor
Loading
sequenceDiagram
    participant Broker as Event Broker
    participant Snap as CanvasSnapshotter
    participant Store as ProjectCanvasStore
    participant Renderer as PNG Renderer
    participant FS as File System

    Broker->>Snap: canvas.* event
    Snap->>Snap: mark_dirty(project_id)
    Note over Snap: debounce timer elapses
    Snap->>Store: list_elements(project_id)
    Store-->>Snap: elements list
    Snap->>Renderer: render_snapshot_png(elements)
    Renderer->>FS: write snapshot.png
    FS-->>Renderer: path
    Renderer-->>Snap: output path
    Snap->>FS: write board.tldr (atomic replace)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hop and sketch upon a tiny board,
Shapes take root where carrots once were stored,
SSE hums, snapshots glow in a file,
Checkboxes grant artists a hops-and-smile,
Hooray — a canvas for my rabbit-style.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(projects): per-project canvas board (tldraw + SSE)' accurately summarizes the main change: adding a collaborative infinite canvas feature to projects using tldraw and Server-Sent Events for live updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-canvas-board

Review rate limit: 1/3 review remaining, refill in 35 minutes and 28 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

The canvas PNG renderer imports PIL but Pillow was only listed as a
worker extra. CI installs '.[dev]' so the import failed at test
collection time on all four Python versions. Move Pillow to main deps.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/projects/test_canvas_integration.py (1)

184-188: ⚠️ Potential issue | 🟠 Major

SSE event field assertion uses wrong key.

Line 188 asserts evt["type"], but the stream payload uses kind. This will fail even when the endpoint is correct.

✅ Suggested fix
-    assert evt["type"] == "canvas.element_added"
+    assert evt["kind"] == "canvas.element_added"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/projects/test_canvas_integration.py` around lines 184 - 188, The test
currently asserts the SSE event field using evt["type"] which is incorrect
because the stream payload uses the "kind" key; update the assertion in the test
(where evt is checked in tests/projects/test_canvas_integration.py) to assert
evt["kind"] (and adjust any related expectations/strings to match the "kind"
value) so the assertion validates the actual payload shape emitted by the
endpoint.
🧹 Nitpick comments (5)
tinyagentos/projects/ids.py (1)

4-4: LGTM! Clean addition of canvas element prefix.

The addition of "cve" to the allowed prefixes is straightforward and correctly enables ID generation for canvas elements. The change follows the existing pattern and introduces no breaking changes.

Would you like me to verify that test coverage exists for this ID generation module?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/ids.py` at line 4, ID_PREFIXES was updated to include
"cve" but there are no tests referenced; add/extend unit tests to assert that
the new prefix is accepted and produces valid IDs by exercising the ID
generation and validation code (e.g., functions named generate_id, parse_id, or
is_valid_id) and by asserting IDs start with "cve" and conform to the existing
format; update or add tests to cover both generation and parsing/validation
paths to prevent regressions.
desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx (1)

7-7: Prefer container-relative sizing over fixed viewport subtraction.

Line 7 hard-codes calc(100vh - 100px), which is brittle if parent layout/header sizes change. Using parent-driven height (100% + minHeight: 0) is usually safer.

Optional tweak
-    <div style={{ height: "calc(100vh - 100px)", padding: 0 }}>
+    <div style={{ height: "100%", minHeight: 0, padding: 0 }}>
       <CanvasBoard projectId={projectId} projectSlug={projectSlug} />
     </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx` at line 7, The inline
style on the root container div in CanvasView.tsx uses a brittle fixed viewport
subtraction ("calc(100vh - 100px)"); change it to use container-relative sizing
by setting height to "100%" and adding minHeight: 0 on that div (and ensure the
parent container/layout allows stretching) so the canvas grows to the parent
rather than relying on a hard-coded viewport offset.
tinyagentos/projects/canvas/unfurl.py (1)

67-70: Avoid silent parser failures.

Line 67-70 swallows parser exceptions with no signal. Consider debug logging so bad upstream HTML is diagnosable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/unfurl.py` around lines 67 - 70, The parser call
p.feed(html) currently swallows all exceptions; change the except block to
capture the exception (e.g., except Exception as e) and emit a debug/error log
that includes the exception message and a safe/truncated snippet of html to aid
diagnosis (reference p.feed and the html variable in unfurl.py), then allow the
function to continue or re-raise based on existing error-handling conventions so
parser failures are visible in logs.
tinyagentos/projects/canvas/__init__.py (1)

10-10: Sort __all__ to satisfy Ruff RUF022.

Keep exported symbol ordering deterministic to avoid lint noise.

Diff
-__all__ = ["ProjectCanvasStore", "CanvasPermissionError"]
+__all__ = ["CanvasPermissionError", "ProjectCanvasStore"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/__init__.py` at line 10, The exported __all__
list is not alphabetically ordered, triggering Ruff RUF022; update the __all__
assignment in tinyagentos/projects/canvas/__init__.py so the symbols are sorted
deterministically (place "CanvasPermissionError" before "ProjectCanvasStore") to
keep exports stable and satisfy the linter.
tests/projects/test_canvas_store.py (1)

126-126: Rename the unused unpacked variable to satisfy Ruff RUF059.

Use a dummy name for the unused fixture value.

Diff
-    cs, ps = store_with_member
+    cs, _ps = store_with_member
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/projects/test_canvas_store.py` at line 126, The unpacked fixture value
"ps" is unused and triggers Ruff RUF059; change the assignment from "cs, ps =
store_with_member" to use a dummy name (e.g., "cs, _ = store_with_member" or
"cs, _ps = store_with_member") in the test so the unused value is prefixed with
an underscore; ensure you update any further references (if any) to match the
new dummy name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@desktop/src/apps/ProjectsApp/canvas/canvas-api.ts`:
- Around line 75-80: The deleteElement function should reject when the DELETE
fails instead of returning false: in deleteElement (canvas-api.ts) check the
fetch response r.ok and if not ok throw an Error (include r.status and/or await
r.text() for details) so callers receive a rejected promise on 403/404;
otherwise return true. Update callers if they expect boolean to handle the
thrown error.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx`:
- Around line 30-38: The initial HTTP fetch (canvasApi.listElements) can
overwrite live SSE updates because subscribeCanvasStream is started before the
fetch settles; to fix, after the fetch resolves (and after the cancelled check)
verify the current cache state and only call
cacheRef.current.getState().seed(elements) and hydrateEditor(editorRef.current,
elements, projectSlug) if the cache is still empty/unseeded (e.g., check
cacheRef.current.getState().items.length === 0 or an isSeeded flag on the cache
state); keep the subscribeCanvasStream(projectId, cacheRef.current) call as-is
and retain cancelled handling so late fetch responses don’t clobber live updates
from subscribeCanvasStream.
- Around line 116-131: elementToShape currently omits CanvasElement.z_index so
stacking is lost; map z_index into the shape and round-trip it in the inverse
mapping/update handlers too. Add a top-level stacking field (e.g. z or z_index)
on the returned shape object in elementToShape: set it to el.z_index (for images
and non-images alike), and update the corresponding reverse conversion/update
logic (the function that converts editor shapes back to CanvasElement at 141-162
and any payload builders for server updates) to read that shape field and
populate CanvasElement.z_index when sending saves/SSE updates. Ensure the same
symbol name (z_index) is used consistently across mappings and update payloads.
- Around line 116-131: elementToShape currently maps every element with kind
"user_shape" to a generic "geo" type and drops the original tldraw shape, so
shapes stored via pushAdd (which writes kind: "user_shape" +
payload.tldraw_shape) don't round-trip; modify elementToShape to detect el.kind
=== "user_shape" and, when el.payload?.tldraw_shape exists, return the stored
tldraw_shape (using its type and props) merged with the baseProps and the
id/position/rotation fields (and include project_slug for images) instead of
forcing shapeType(el.kind); also update the corresponding inverse/persistence
conversion used by pushAdd to ensure it writes payload.tldraw_shape unchanged so
arrows/drawings retain their original type and props on reload/SSE.

In `@desktop/src/apps/ProjectsApp/ProjectMembers.tsx`:
- Around line 58-73: The handlers calling canvasApi.setPermission and
projectsApi.members.remove should await refresh() and handle async failures to
avoid stale UI: wrap the async work in try/catch inside the onChange and delete
button callbacks, await the API call, then await refresh(), call onChanged()
only after successful refresh, and in the catch log or surface the error (e.g.,
show toast or processLogger) so failures don't silently leave the UI out of
sync; update the anonymous handlers that call canvasApi.setPermission,
projectsApi.members.remove, refresh, and onChanged accordingly.

In `@desktop/tests/canvas-e2e.spec.ts`:
- Around line 21-22: The test currently uses hard-coded project slugs
("e2e-canvas" and "e2e-sse") which can collide across runs; change the project
creation in desktop/tests/canvas-e2e.spec.ts (the create/project setup where
data: { name: "E2E Canvas", slug: "e2e-canvas", ... } and the similar "e2e-sse"
usage) to generate a unique slug at runtime (e.g., append a timestamp or
random/UUID suffix) and use that generated slug for any subsequent lookups or
assertions so tests do not collide on reruns; ensure any places reading the slug
reference the variable rather than the literal string.

In `@tests/projects/test_routes_canvas.py`:
- Around line 185-188: The test currently asserts evt["type"] ==
"canvas.element_added" but the SSE payload uses the key "kind"; update the
assertion to check evt["kind"] == "canvas.element_added" (locate the test
variables data_lines and evt in tests/projects/test_routes_canvas.py) so the SSE
JSON envelope is validated against the correct field.

In `@tinyagentos/projects/canvas/snapshotter.py`:
- Around line 188-197: The export sets a constant index "a1" for every shape,
losing layer/stacking order; update the loop that writes to store (the code that
assigns store[f"shape:{el['id']}"] and its "index" field) to compute a proper
index based on the element's position in elements (or its stored z-order)
instead of the hardcoded "a1" so relative stacking is preserved when importing
snapshots; use the element's list index or its existing order property (e.g., el
index or el["order"]) to generate unique, monotonically increasing indices per
shape.
- Around line 82-107: backfill_active only subscribes existing active projects
at startup so projects that become active later never get a broker loop; fix by
wiring a project-status change handler that calls _ensure_subscribed for
newly-active projects. Specifically, register a listener on the project store
(or the code-path that updates project status) that, when it sees a project
transition to status == "active", calls mark_dirty(project_id) and schedules
_ensure_subscribed(project_id) (use asyncio.create_task if called from a sync
callback) so the _broker_queues/_broker_tasks are created the same way as in
backfill_active/_ensure_subscribed.

In `@tinyagentos/projects/canvas/store.py`:
- Around line 160-177: The reloads use self.get_element(element_id) which can
return an element from a different project; change both the empty-patch fast
path and the post-UPDATE reload to fetch/check by project too (e.g., call
get_element with project_id or verify the returned object's project_id matches)
so you only accept an element that belongs to the given project_id and is not
soft-deleted; if the lookup fails or the project_id mismatches, raise the same
ValueError and avoid calling _publish("canvas.element_updated", ...) or
returning the wrong element.
- Around line 75-89: add_element currently enforces kind and author_kind checks
but never calls _check_edit_permission, so agents can still create canvas
elements; update add_element to call
self._check_edit_permission(project_id=project_id, author_kind=author_kind,
author_id=author_id) (or the existing signature of _check_edit_permission)
before allowing creation, ensuring the permission check runs for both
author_kind "agent" and "user" and raising the same permission error path when
it fails; place the call after validating author_kind and kind but before any DB
insert/return so creation is blocked when edit permission is denied.

In `@tinyagentos/projects/canvas/unfurl.py`:
- Around line 23-31: The _http_get function currently eagerly reads r.text, so
update it to stream the response and enforce content-type and size limits: open
the request with httpx.AsyncClient.stream (or client.get(..., stream=True)),
inspect response.headers for Content-Type and optional Content-Length, reject or
return an error if the type is disallowed or Content-Length exceeds a configured
max, then read at most MAX_BYTES from the stream (using r.aiter_bytes or
r.aiter_raw/read with a byte counter) and decode only those bytes to text (or
return a binary-safe error) so you never materialize arbitrarily large or binary
payloads; keep the function signature _http_get and return an appropriate status
code and truncated/typed body string when limits are hit.
- Around line 113-121: fetch_link_metadata currently fetches arbitrary user URLs
(via _http_get) allowing SSRF; before calling _http_get validate the URL: parse
with urllib.parse to ensure only allowed schemes (https, maybe http if
required), reject non-http(s) schemes, and normalize hostnames; then resolve the
hostname to IP(s) using socket.getaddrinfo and check each IP with the ipaddress
module to block localhost (127.0.0.0/8, ::1), RFC1918 private ranges, link-local
(169.254.0.0/16, fe80::/10) and other non-routable ranges; also enforce a
host/IP allowlist if provided (e.g., ALLOWED_HOSTS/ALLOWED_IPS) and return
_fallback(url) immediately when validation fails; finally only call _http_get
after successful validation. Reference symbols: function fetch_link_metadata,
helper _http_get, and _fallback for where to bail out on invalid targets.

---

Outside diff comments:
In `@tests/projects/test_canvas_integration.py`:
- Around line 184-188: The test currently asserts the SSE event field using
evt["type"] which is incorrect because the stream payload uses the "kind" key;
update the assertion in the test (where evt is checked in
tests/projects/test_canvas_integration.py) to assert evt["kind"] (and adjust any
related expectations/strings to match the "kind" value) so the assertion
validates the actual payload shape emitted by the endpoint.

---

Nitpick comments:
In `@desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx`:
- Line 7: The inline style on the root container div in CanvasView.tsx uses a
brittle fixed viewport subtraction ("calc(100vh - 100px)"); change it to use
container-relative sizing by setting height to "100%" and adding minHeight: 0 on
that div (and ensure the parent container/layout allows stretching) so the
canvas grows to the parent rather than relying on a hard-coded viewport offset.

In `@tests/projects/test_canvas_store.py`:
- Line 126: The unpacked fixture value "ps" is unused and triggers Ruff RUF059;
change the assignment from "cs, ps = store_with_member" to use a dummy name
(e.g., "cs, _ = store_with_member" or "cs, _ps = store_with_member") in the test
so the unused value is prefixed with an underscore; ensure you update any
further references (if any) to match the new dummy name.

In `@tinyagentos/projects/canvas/__init__.py`:
- Line 10: The exported __all__ list is not alphabetically ordered, triggering
Ruff RUF022; update the __all__ assignment in
tinyagentos/projects/canvas/__init__.py so the symbols are sorted
deterministically (place "CanvasPermissionError" before "ProjectCanvasStore") to
keep exports stable and satisfy the linter.

In `@tinyagentos/projects/canvas/unfurl.py`:
- Around line 67-70: The parser call p.feed(html) currently swallows all
exceptions; change the except block to capture the exception (e.g., except
Exception as e) and emit a debug/error log that includes the exception message
and a safe/truncated snippet of html to aid diagnosis (reference p.feed and the
html variable in unfurl.py), then allow the function to continue or re-raise
based on existing error-handling conventions so parser failures are visible in
logs.

In `@tinyagentos/projects/ids.py`:
- Line 4: ID_PREFIXES was updated to include "cve" but there are no tests
referenced; add/extend unit tests to assert that the new prefix is accepted and
produces valid IDs by exercising the ID generation and validation code (e.g.,
functions named generate_id, parse_id, or is_valid_id) and by asserting IDs
start with "cve" and conform to the existing format; update or add tests to
cover both generation and parsing/validation paths to prevent regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d8f34f22-d501-4bb8-9127-722471f9894e

📥 Commits

Reviewing files that changed from the base of the PR and between 29516ac and 35ce519.

⛔ Files ignored due to path filters (1)
  • desktop/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (33)
  • desktop/package.json
  • desktop/src/apps/ProjectsApp/ProjectMembers.tsx
  • desktop/src/apps/ProjectsApp/ProjectWorkspace.tsx
  • desktop/src/apps/ProjectsApp/__tests__/canvas-api.test.ts
  • desktop/src/apps/ProjectsApp/__tests__/canvas-store.test.ts
  • desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx
  • desktop/src/apps/ProjectsApp/canvas/CanvasView.tsx
  • desktop/src/apps/ProjectsApp/canvas/canvas-api.ts
  • desktop/src/apps/ProjectsApp/canvas/canvas-sse.ts
  • desktop/src/apps/ProjectsApp/canvas/canvas-store.ts
  • desktop/src/apps/ProjectsApp/canvas/shapes/ImageShape.tsx
  • desktop/src/apps/ProjectsApp/canvas/shapes/LinkShape.tsx
  • desktop/src/apps/ProjectsApp/canvas/shapes/NoteShape.tsx
  • desktop/src/lib/projects.ts
  • desktop/tests/canvas-e2e.spec.ts
  • desktop/vite.config.ts
  • tests/projects/test_canvas_integration.py
  • tests/projects/test_canvas_mcp_tools.py
  • tests/projects/test_canvas_render.py
  • tests/projects/test_canvas_snapshotter.py
  • tests/projects/test_canvas_store.py
  • tests/projects/test_canvas_unfurl.py
  • tests/projects/test_routes_canvas.py
  • tinyagentos/app.py
  • tinyagentos/projects/canvas/__init__.py
  • tinyagentos/projects/canvas/mcp_tools.py
  • tinyagentos/projects/canvas/render.py
  • tinyagentos/projects/canvas/snapshotter.py
  • tinyagentos/projects/canvas/store.py
  • tinyagentos/projects/canvas/unfurl.py
  • tinyagentos/projects/ids.py
  • tinyagentos/projects/project_store.py
  • tinyagentos/routes/project_canvas.py

Comment on lines +75 to +80
async deleteElement(projectId: string, elementId: string): Promise<boolean> {
const r = await fetch(
`/api/projects/${projectId}/canvas/elements/${elementId}`,
{ method: "DELETE" },
);
return r.ok;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface DELETE failures by rejecting the promise.

This currently returns false on 403/404, but the caller only handles rejections. In the current CanvasBoard flow that makes failed deletes look successful and leaves the local board diverged until reload.

Proposed fix
   async deleteElement(projectId: string, elementId: string): Promise<boolean> {
     const r = await fetch(
       `/api/projects/${projectId}/canvas/elements/${elementId}`,
       { method: "DELETE" },
     );
-    return r.ok;
+    if (!r.ok) {
+      const body = await r.text();
+      throw new Error(`canvas-api ${r.status}: ${body}`);
+    }
+    return true;
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/canvas-api.ts` around lines 75 - 80, The
deleteElement function should reject when the DELETE fails instead of returning
false: in deleteElement (canvas-api.ts) check the fetch response r.ok and if not
ok throw an Error (include r.status and/or await r.text() for details) so
callers receive a rejected promise on 403/404; otherwise return true. Update
callers if they expect boolean to handle the thrown error.

Comment on lines +30 to +38
useEffect(() => {
let cancelled = false;
(async () => {
const elements = await canvasApi.listElements(projectId);
if (cancelled) return;
cacheRef.current.getState().seed(elements);
hydrateEditor(editorRef.current, elements, projectSlug);
})();
const unsub = subscribeCanvasStream(projectId, cacheRef.current);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid clobbering live SSE updates with the initial fetch result.

The subscription is opened before listElements() settles, but seed(elements) still replaces the cache when that older HTTP response arrives. If another client edits the board in that window, the later seed rolls the local state back.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 30 - 38,
The initial HTTP fetch (canvasApi.listElements) can overwrite live SSE updates
because subscribeCanvasStream is started before the fetch settles; to fix, after
the fetch resolves (and after the cancelled check) verify the current cache
state and only call cacheRef.current.getState().seed(elements) and
hydrateEditor(editorRef.current, elements, projectSlug) if the cache is still
empty/unseeded (e.g., check cacheRef.current.getState().items.length === 0 or an
isSeeded flag on the cache state); keep the subscribeCanvasStream(projectId,
cacheRef.current) call as-is and retain cancelled handling so late fetch
responses don’t clobber live updates from subscribeCanvasStream.

Comment on lines +116 to +131
function elementToShape(el: CanvasElement, projectSlug: string): any {
const baseProps = {
w: el.w, h: el.h,
taos_kind: el.kind,
taos_payload: el.payload,
taos_author_id: el.author_id,
taos_author_kind: el.author_kind,
};
return {
id: `shape:${el.id}`,
type: shapeType(el.kind),
x: el.x, y: el.y, rotation: el.rotation,
props: el.kind === "image"
? { ...baseProps, project_slug: projectSlug }
: baseProps,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist stacking order too.

CanvasElement.z_index never gets mapped into the editor or sent back on updates. Overlapping items will come back in a default order after reload/SSE hydration, so users can lose intentional layering.

Also applies to: 141-162

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 116 - 131,
elementToShape currently omits CanvasElement.z_index so stacking is lost; map
z_index into the shape and round-trip it in the inverse mapping/update handlers
too. Add a top-level stacking field (e.g. z or z_index) on the returned shape
object in elementToShape: set it to el.z_index (for images and non-images
alike), and update the corresponding reverse conversion/update logic (the
function that converts editor shapes back to CanvasElement at 141-162 and any
payload builders for server updates) to read that shape field and populate
CanvasElement.z_index when sending saves/SSE updates. Ensure the same symbol
name (z_index) is used consistently across mappings and update payloads.

⚠️ Potential issue | 🔴 Critical

user_shape does not round-trip through persistence.

pushAdd() stores native shapes as kind: "user_shape" plus payload.tldraw_shape, but elementToShape() ignores that record and always recreates a generic "geo" shape. After reload/SSE, arrows, drawings, and other non-custom shapes lose their original type and props.

Also applies to: 141-151

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/canvas/CanvasBoard.tsx` around lines 116 - 131,
elementToShape currently maps every element with kind "user_shape" to a generic
"geo" type and drops the original tldraw shape, so shapes stored via pushAdd
(which writes kind: "user_shape" + payload.tldraw_shape) don't round-trip;
modify elementToShape to detect el.kind === "user_shape" and, when
el.payload?.tldraw_shape exists, return the stored tldraw_shape (using its type
and props) merged with the baseProps and the id/position/rotation fields (and
include project_slug for images) instead of forcing shapeType(el.kind); also
update the corresponding inverse/persistence conversion used by pushAdd to
ensure it writes payload.tldraw_shape unchanged so arrows/drawings retain their
original type and props on reload/SSE.

Comment on lines +58 to +73
onChange={async (e) => {
await canvasApi.setPermission(project.id, m.member_id, e.target.checked);
refresh();
onChanged();
}}
/>
<span className="text-xs">Can edit canvas</span>
</label>
)}
<button
type="button"
onClick={async () => {
await projectsApi.members.remove(project.id, m.member_id);
refresh();
onChanged();
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle async failures and await refresh to prevent stale member state.

Both action handlers can reject before refresh runs, and refresh() is currently fire-and-forget. This can leave permission/member UI out of sync.

Diff
                   <input
                     type="checkbox"
                     checked={!!m.can_edit_canvas}
                     onChange={async (e) => {
-                      await canvasApi.setPermission(project.id, m.member_id, e.target.checked);
-                      refresh();
-                      onChanged();
+                      const checked = e.currentTarget.checked;
+                      try {
+                        await canvasApi.setPermission(project.id, m.member_id, checked);
+                      } finally {
+                        await refresh();
+                        onChanged();
+                      }
                     }}
                   />
@@
               <button
                 type="button"
                 onClick={async () => {
-                  await projectsApi.members.remove(project.id, m.member_id);
-                  refresh();
-                  onChanged();
+                  try {
+                    await projectsApi.members.remove(project.id, m.member_id);
+                  } finally {
+                    await refresh();
+                    onChanged();
+                  }
                 }}
                 className="text-xs text-red-400 hover:underline"
                 aria-label={`Remove ${m.member_id}`}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/ProjectsApp/ProjectMembers.tsx` around lines 58 - 73, The
handlers calling canvasApi.setPermission and projectsApi.members.remove should
await refresh() and handle async failures to avoid stale UI: wrap the async work
in try/catch inside the onChange and delete button callbacks, await the API
call, then await refresh(), call onChanged() only after successful refresh, and
in the catch log or surface the error (e.g., show toast or processLogger) so
failures don't silently leave the UI out of sync; update the anonymous handlers
that call canvasApi.setPermission, projectsApi.members.remove, refresh, and
onChanged accordingly.

Comment on lines +21 to +22
data: { name: "E2E Canvas", slug: "e2e-canvas", description: "" },
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid hard-coded project slugs in E2E setup.

Line 21 and Line 44 use static slugs (e2e-canvas, e2e-sse), which can collide on reruns and make this suite flaky.

💡 Suggested fix
-    const created = await request.post("/api/projects", {
-      data: { name: "E2E Canvas", slug: "e2e-canvas", description: "" },
-    });
+    const slug = `e2e-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+    const created = await request.post("/api/projects", {
+      data: { name: "E2E Canvas", slug, description: "" },
+    });
-    const created = await request.post("/api/projects", {
-      data: { name: "E2E SSE", slug: "e2e-sse", description: "" },
-    });
+    const slug = `e2e-sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+    const created = await request.post("/api/projects", {
+      data: { name: "E2E SSE", slug, description: "" },
+    });

Also applies to: 44-45

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/tests/canvas-e2e.spec.ts` around lines 21 - 22, The test currently
uses hard-coded project slugs ("e2e-canvas" and "e2e-sse") which can collide
across runs; change the project creation in desktop/tests/canvas-e2e.spec.ts
(the create/project setup where data: { name: "E2E Canvas", slug: "e2e-canvas",
... } and the similar "e2e-sse" usage) to generate a unique slug at runtime
(e.g., append a timestamp or random/UUID suffix) and use that generated slug for
any subsequent lookups or assertions so tests do not collide on reruns; ensure
any places reading the slug reference the variable rather than the literal
string.

Comment on lines +188 to +197
for el in elements:
store[f"shape:{el['id']}"] = {
"id": f"shape:{el['id']}",
"typeName": "shape",
"type": _tldraw_shape_type(el["kind"]),
"x": el["x"],
"y": el["y"],
"rotation": el["rotation"],
"index": "a1",
"parentId": "page:page",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The .tldr export drops layer ordering.

Every shape is serialized with index: "a1" regardless of its stored order. That means overlapping elements cannot preserve their stacking in exported snapshots.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/snapshotter.py` around lines 188 - 197, The
export sets a constant index "a1" for every shape, losing layer/stacking order;
update the loop that writes to store (the code that assigns
store[f"shape:{el['id']}"] and its "index" field) to compute a proper index
based on the element's position in elements (or its stored z-order) instead of
the hardcoded "a1" so relative stacking is preserved when importing snapshots;
use the element's list index or its existing order property (e.g., el index or
el["order"]) to generate unique, monotonically increasing indices per shape.

Comment on lines +75 to +89
async def add_element(
self,
*,
project_id: str,
element: dict,
author_kind: str,
author_id: str,
) -> dict:
kind = element.get("kind")
if kind not in _VALID_KINDS:
raise ValueError(f"invalid kind: {kind}")
if author_kind == "agent" and kind not in _AGENT_ALLOWED_KINDS:
raise ValueError(f"agents may not emit kind={kind}")
if author_kind not in ("user", "agent"):
raise ValueError(f"invalid author_kind: {author_kind}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Enforce can_edit_canvas on agent creates too.

add_element() validates agent kinds, but it never calls _check_edit_permission(). Right now any agent can add notes/links/images even though update/delete are fail-closed, which breaks the permission model for the new MCP canvas_add_* handlers.

Proposed fix
     ) -> dict:
         kind = element.get("kind")
         if kind not in _VALID_KINDS:
             raise ValueError(f"invalid kind: {kind}")
         if author_kind == "agent" and kind not in _AGENT_ALLOWED_KINDS:
             raise ValueError(f"agents may not emit kind={kind}")
         if author_kind not in ("user", "agent"):
             raise ValueError(f"invalid author_kind: {author_kind}")
+        await self._check_edit_permission(project_id, author_kind, author_id)
         eid = element.get("id") or new_id("cve")
         now = time.time()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/store.py` around lines 75 - 89, add_element
currently enforces kind and author_kind checks but never calls
_check_edit_permission, so agents can still create canvas elements; update
add_element to call self._check_edit_permission(project_id=project_id,
author_kind=author_kind, author_id=author_id) (or the existing signature of
_check_edit_permission) before allowing creation, ensuring the permission check
runs for both author_kind "agent" and "user" and raising the same permission
error path when it fails; place the call after validating author_kind and kind
but before any DB insert/return so creation is blocked when edit permission is
denied.

Comment thread tinyagentos/projects/canvas/store.py
Comment thread tinyagentos/projects/canvas/unfurl.py Outdated
Comment on lines +23 to +31
async def _http_get(url: str) -> tuple[int, str]:
"""Indirection seam for tests."""
async with httpx.AsyncClient(
follow_redirects=True,
timeout=_TIMEOUT,
headers={"User-Agent": "taOS-canvas-unfurl/0.1"},
) as client:
r = await client.get(url)
return r.status_code, r.text
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add response size/content-type limits for unfurl fetches.

Line 23-31 eagerly materializes r.text for any response. Large or binary payloads can increase memory usage and request latency.

Suggested guardrail
 async def _http_get(url: str) -> tuple[int, str]:
     async with httpx.AsyncClient(
         follow_redirects=True,
         timeout=_TIMEOUT,
         headers={"User-Agent": "taOS-canvas-unfurl/0.1"},
     ) as client:
-        r = await client.get(url)
-        return r.status_code, r.text
+        r = await client.get(url)
+        ctype = (r.headers.get("content-type") or "").lower()
+        if "text/html" not in ctype:
+            return r.status_code, ""
+        body = r.text
+        if len(body) > 1_000_000:
+            body = body[:1_000_000]
+        return r.status_code, body
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/unfurl.py` around lines 23 - 31, The _http_get
function currently eagerly reads r.text, so update it to stream the response and
enforce content-type and size limits: open the request with
httpx.AsyncClient.stream (or client.get(..., stream=True)), inspect
response.headers for Content-Type and optional Content-Length, reject or return
an error if the type is disallowed or Content-Length exceeds a configured max,
then read at most MAX_BYTES from the stream (using r.aiter_bytes or
r.aiter_raw/read with a byte counter) and decode only those bytes to text (or
return a binary-safe error) so you never materialize arbitrarily large or binary
payloads; keep the function signature _http_get and return an appropriate status
code and truncated/typed body string when limits are hit.

Comment thread tinyagentos/projects/canvas/unfurl.py
Two correctness fixes flagged in PR review:

- ProjectCanvasStore.update_element previously filtered the UPDATE by
  project_id but reloaded the row via get_element(element_id) without
  scoping, so a caller passing the wrong project_id could read another
  project's element (and the empty-patch fast path returned it). Add an
  optional project_id arg to get_element and use it in both reload spots.
  Regression test added.

- unfurl._http_get now resolves the target hostname and rejects private,
  loopback, link-local, multicast, reserved, and unspecified addresses
  before any request is made. Redirects are disabled so a 30x to a
  private host can't bypass the resolution check. Response bodies are
  capped at 1 MB; the parser only needs <head> metadata.
@jaylfc
Copy link
Copy Markdown
Owner Author

jaylfc commented Apr 29, 2026

Triaged the review.

Just pushed d4674b4 covering the two correctness items:

  • update_element cross-project leakget_element now takes an optional project_id and the post-UPDATE reload (and the empty-patch fast path) both pass it. Added a regression test in test_canvas_store.py.
  • unfurl SSRF + body cap_http_get resolves the host and rejects private / loopback / link-local / multicast / reserved / unspecified addresses, follows no redirects (so 30x → localhost can't bypass), and caps the response body at 1 MB.

On the agent-add permission flag default: that's by spec. can_edit_canvas defaults OFF and is fail-closed at the store layer (ProjectCanvasStore._check_edit_permission); a project owner toggles it per-agent via PATCH /api/projects/{id}/canvas/permissions/{agent_id}. Agents can still drop notes/links/images they author themselves regardless — the flag only gates editing/deleting.

The remaining frontend findings (fetch/SSE race, z_index loss on drag end, deleteElement silent failure logging, .tldr stacking on snapshot reload) are real but not correctness-blocking for the initial ship — tracking them as follow-ups.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
tinyagentos/projects/canvas/store.py (1)

84-98: ⚠️ Potential issue | 🔴 Critical

Enforce can_edit_canvas on agent creates.

add_element() validates kind and author_kind but never calls _check_edit_permission(). Agents can add elements without can_edit_canvas = 1, while updates and deletes are permission-gated. This inconsistency allows unpermitted agents to create canvas elements via MCP tools (canvas_add_note, etc.).

🐛 Proposed fix
         if author_kind not in ("user", "agent"):
             raise ValueError(f"invalid author_kind: {author_kind}")
+        await self._check_edit_permission(project_id, author_kind, author_id)
         eid = element.get("id") or new_id("cve")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/store.py` around lines 84 - 98, The add_element
function currently skips permission checks for agents; before allowing an agent
to create an element, call the existing _check_edit_permission helper to enforce
can_edit_canvas (e.g., from within add_element when author_kind == "agent"),
supplying the project_id and author_id (and author_kind if
_check_edit_permission requires it) and abort/raise if the check fails so agent
creates are gated the same way as updates/deletes.
🧹 Nitpick comments (5)
tinyagentos/projects/canvas/unfurl.py (1)

55-63: Short-circuit body streaming for non-2xx statuses.

Since non-2xx should fall back, you can return immediately after reading the status line and skip streaming up to 1 MB of irrelevant body bytes.

Proposed optimization
     async with client.stream("GET", url) as r:
+        if r.status_code < 200 or r.status_code >= 300:
+            return r.status_code, ""
         chunks: list[bytes] = []
         total = 0
         async for chunk in r.aiter_bytes():
             total += len(chunk)
             chunks.append(chunk)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/unfurl.py` around lines 55 - 63, After opening
the stream (async with client.stream("GET", url) as r) check the response status
immediately (e.g., r.status_code or r.status) and if it is not a 2xx,
short-circuit and return/handle the fallback without iterating r.aiter_bytes()
so you don't read up to _MAX_BODY_BYTES for error responses; only proceed to
accumulate chunks into the chunks list and build body =
b"".join(chunks)[:_MAX_BODY_BYTES] when the status is 2xx.
tinyagentos/projects/canvas/store.py (2)

45-51: Consider adding strict=True to zip() for defensive safety.

While cursor description and row lengths should always match in SQLite results, using strict=True guards against silent data truncation if assumptions ever break.

♻️ Proposed fix
 def _row_to_element(row, description) -> dict:
     keys = [d[0] for d in description]
-    e = dict(zip(keys, row))
+    e = dict(zip(keys, row, strict=True))
     for f in _CANVAS_JSON_FIELDS:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/store.py` around lines 45 - 51, Update
_row_to_element to use Python's strict zip to ensure the cursor description and
row lengths match; specifically, when creating e = dict(zip(keys, row)) replace
with a strict zip call (zip(keys, row, strict=True)) so mismatched lengths raise
an error instead of silently truncating, and leave the subsequent JSON decoding
loop over _CANVAS_JSON_FIELDS unchanged.

174-174: Split multiple statements on one line.

Static analysis flags E702: Multiple statements on one line (semicolon). Split for readability.

♻️ Proposed fix
-        sets.append("updated_at = ?"); params.append(time.time())
+        sets.append("updated_at = ?")
+        params.append(time.time())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/store.py` at line 174, The line containing two
statements joined by a semicolon should be split into separate statements for
readability and to satisfy E702: locate the place where sets.append("updated_at
= ?"); params.append(time.time()) is used (the update-building logic in the
store module) and replace it with two distinct statements: one that calls
sets.append("updated_at = ?") and a following line that calls
params.append(time.time()), preserving order and functionality.
tests/projects/test_canvas_store.py (2)

124-141: Prefix unused variable with underscore.

Static analysis flags that ps is unpacked but never used. Use _ or _ps to indicate intentional discard.

♻️ Proposed fix
 `@pytest.mark.asyncio`
 async def test_agent_with_permission_can_update(store_with_member):
-    cs, ps = store_with_member
+    cs, _ps = store_with_member
     await cs._db.execute(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/projects/test_canvas_store.py` around lines 124 - 141, The test
test_agent_with_permission_can_update unpacks store_with_member into cs, ps but
never uses ps; rename the unused variable to _ps or _ to silence the static
analysis warning (e.g., change "cs, ps = store_with_member" to "cs, _ps =
store_with_member" or "cs, _ = store_with_member") so the fixture is still
consumed but the linter knows the variable is intentionally discarded.

52-59: Missing test: agent add_element permission enforcement.

Once add_element() is fixed to call _check_edit_permission(), add a test verifying that agents without can_edit_canvas cannot create elements. This parallels test_agent_without_permission_cannot_update and test_delete_requires_permission.

🧪 Suggested test
`@pytest.mark.asyncio`
async def test_agent_without_permission_cannot_add(store_with_member):
    from tinyagentos.projects.canvas.store import CanvasPermissionError
    cs, _ = store_with_member
    with pytest.raises(CanvasPermissionError):
        await cs.add_element(
            project_id="p1",
            element={"kind": "note", "x": 0, "y": 0, "w": 1, "h": 1, "payload": {"text": "a"}},
            author_kind="agent",
            author_id="agent-1",
        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/projects/test_canvas_store.py` around lines 52 - 59, Add a new async
test that mirrors the existing update/delete permission tests to ensure
add_element enforces agent edit permissions: use the fixture store_with_member
to get cs, import CanvasPermissionError, and call cs.add_element(...) with
author_kind="agent" and author_id="agent-1" expecting a CanvasPermissionError;
this verifies that add_element calls _check_edit_permission and blocks agents
without can_edit_canvas (name the test e.g.
test_agent_without_permission_cannot_add).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tinyagentos/projects/canvas/unfurl.py`:
- Around line 156-157: The current check in fetch_link_metadata only falls back
on status >= 400; change the logic so any non-2xx response triggers the fallback
(i.e., treat responses where status < 200 or status >= 300 as failures). Update
the condition around the call that currently does "if status >= 400: return
_fallback(url)" in fetch_link_metadata to call _fallback(url) for non-2xx codes
instead, ensuring 1xx and 3xx are also handled.

---

Duplicate comments:
In `@tinyagentos/projects/canvas/store.py`:
- Around line 84-98: The add_element function currently skips permission checks
for agents; before allowing an agent to create an element, call the existing
_check_edit_permission helper to enforce can_edit_canvas (e.g., from within
add_element when author_kind == "agent"), supplying the project_id and author_id
(and author_kind if _check_edit_permission requires it) and abort/raise if the
check fails so agent creates are gated the same way as updates/deletes.

---

Nitpick comments:
In `@tests/projects/test_canvas_store.py`:
- Around line 124-141: The test test_agent_with_permission_can_update unpacks
store_with_member into cs, ps but never uses ps; rename the unused variable to
_ps or _ to silence the static analysis warning (e.g., change "cs, ps =
store_with_member" to "cs, _ps = store_with_member" or "cs, _ =
store_with_member") so the fixture is still consumed but the linter knows the
variable is intentionally discarded.
- Around line 52-59: Add a new async test that mirrors the existing
update/delete permission tests to ensure add_element enforces agent edit
permissions: use the fixture store_with_member to get cs, import
CanvasPermissionError, and call cs.add_element(...) with author_kind="agent" and
author_id="agent-1" expecting a CanvasPermissionError; this verifies that
add_element calls _check_edit_permission and blocks agents without
can_edit_canvas (name the test e.g. test_agent_without_permission_cannot_add).

In `@tinyagentos/projects/canvas/store.py`:
- Around line 45-51: Update _row_to_element to use Python's strict zip to ensure
the cursor description and row lengths match; specifically, when creating e =
dict(zip(keys, row)) replace with a strict zip call (zip(keys, row,
strict=True)) so mismatched lengths raise an error instead of silently
truncating, and leave the subsequent JSON decoding loop over _CANVAS_JSON_FIELDS
unchanged.
- Line 174: The line containing two statements joined by a semicolon should be
split into separate statements for readability and to satisfy E702: locate the
place where sets.append("updated_at = ?"); params.append(time.time()) is used
(the update-building logic in the store module) and replace it with two distinct
statements: one that calls sets.append("updated_at = ?") and a following line
that calls params.append(time.time()), preserving order and functionality.

In `@tinyagentos/projects/canvas/unfurl.py`:
- Around line 55-63: After opening the stream (async with client.stream("GET",
url) as r) check the response status immediately (e.g., r.status_code or
r.status) and if it is not a 2xx, short-circuit and return/handle the fallback
without iterating r.aiter_bytes() so you don't read up to _MAX_BODY_BYTES for
error responses; only proceed to accumulate chunks into the chunks list and
build body = b"".join(chunks)[:_MAX_BODY_BYTES] when the status is 2xx.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c69987e0-bf0b-4961-bb8c-ca8dd35f5788

📥 Commits

Reviewing files that changed from the base of the PR and between b84d071 and d4674b4.

📒 Files selected for processing (3)
  • tests/projects/test_canvas_store.py
  • tinyagentos/projects/canvas/store.py
  • tinyagentos/projects/canvas/unfurl.py

Comment thread tinyagentos/projects/canvas/unfurl.py Outdated
Module contract says "on any failure (timeout, non-2xx, parse error)
returns a fallback dict". Previous check only triggered on >=400, so
1xx and 3xx slipped through to the parser. Now that follow_redirects
is disabled, 3xx replies are no longer followed and would otherwise
be parsed as if they were a real page.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tinyagentos/projects/canvas/unfurl.py (1)

101-104: Avoid silent parser failure in metadata extraction.

Line 103-104 swallows all parse exceptions with pass, which hides malformed-input patterns and makes production debugging harder. Prefer a debug-level log (still returning fallback-safe metadata behavior).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/canvas/unfurl.py` around lines 101 - 104, The try/except
around p.feed(html) currently swallows all exceptions; replace the bare pass
with a debug-level log that includes the exception information (e.g.,
logger.debug("HTML parse failed in p.feed(html)", exc_info=True) or use
logging.getLogger(__name__) if no logger exists) and keep the existing
fallback-safe metadata return path so behavior is unchanged for callers; update
the block surrounding p.feed(html) in unfurl.py accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tinyagentos/projects/canvas/unfurl.py`:
- Around line 101-104: The try/except around p.feed(html) currently swallows all
exceptions; replace the bare pass with a debug-level log that includes the
exception information (e.g., logger.debug("HTML parse failed in p.feed(html)",
exc_info=True) or use logging.getLogger(__name__) if no logger exists) and keep
the existing fallback-safe metadata return path so behavior is unchanged for
callers; update the block surrounding p.feed(html) in unfurl.py accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ff354729-ef83-4b3a-ab43-2d48665ebee3

📥 Commits

Reviewing files that changed from the base of the PR and between d4674b4 and 4cefae1.

📒 Files selected for processing (1)
  • tinyagentos/projects/canvas/unfurl.py

Comment on lines +27 to +31
async def _check_ssrf_safe(host: str) -> None:
"""Resolve host and reject private / loopback / link-local addresses."""
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(host, None)
for _family, _type, _proto, _canon, sockaddr in infos:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n tinyagentos/projects/canvas/unfurl.py | head -80

Repository: jaylfc/tinyagentos

Length of output: 3364


🏁 Script executed:

cat -n tinyagentos/projects/canvas/unfurl.py | sed -n '27,80p'

Repository: jaylfc/tinyagentos

Length of output: 2556


🏁 Script executed:

rg -n "httpx\.|AsyncClient|socket|bind|getaddrinfo" tinyagentos/projects/canvas/unfurl.py

Repository: jaylfc/tinyagentos

Length of output: 149


🌐 Web query:

httpx AsyncClient DNS resolution behavior IP pinning

💡 Result:

httpx.AsyncClient performs DNS resolution using Python's standard socket.getaddrinfo on each connection attempt, as it relies on httpcore's AsyncConnectionPool. There is no built-in DNS caching (confirmed in official GitHub discussion as of 2022, no evidence of addition by 2026). This can lead to issues like DNS lookup storms under high concurrency to the same host, causing temporary resolution failures. For IP pinning (forcing connection to a specific IP while using a hostname for Host header and SNI): - Use the IP address directly in the URL, set Host header to the hostname, and for HTTPS, use the "sni_hostname" extension to specify the hostname for SSL verification. - Example (works for sync Client, analogous for AsyncClient): headers = {"Host": "www.encode.io"} extensions = {"sni_hostname": "www.encode.io"} response = client.get("https://185.199.108.153/path", headers=headers, extensions=extensions) - Without the extension, HTTPS fails with certificate mismatch since SSL uses the IP for hostname verification. - No built-in support for custom DNS resolvers or automatic IP pinning from hostname. Custom transports via mounts can be implemented for advanced control, but standard use relies on system DNS. - To get the resolved IP post-request: Access response.extensions["network_stream"].get_extra_info("server_addr") (async stream context) or private stream attributes (not recommended, unstable).

Citations:


DNS-rebinding attack: resolved IP not pinned to connection.

_check_ssrf_safe validates the hostname's DNS answers (lines 27–31) and rejects private addresses, but _http_get then passes the original hostname string to httpx.AsyncClient.stream() (line 55). Since httpx re-resolves the hostname at connection time with no IP caching, an attacker can change DNS between the validation check (line 47) and the actual connection (line 55) to point the hostname to a private IP, bypassing the SSRF guard.

Harden by resolving the hostname in _check_ssrf_safe, returning the validated IP(s), and using the IP address directly in the URL while preserving the hostname via Host header and SNI for HTTPS.

@jaylfc jaylfc merged commit 629c081 into master Apr 29, 2026
8 checks passed
@jaylfc jaylfc deleted the worktree-canvas-board branch April 29, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant